-/* eslint-disable react/prop-types */
-import React, {
- useState, useEffect, useLayoutEffect, useRef,
-} from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './ChannelView.scss';
import EventEmitter from 'events';
-import TextareaAutosize from 'react-autosize-textarea';
-import dateFormat from 'dateformat';
-import initMatrix from '../../../client/initMatrix';
-import { getUsername, doesRoomHaveUnread } from '../../../util/matrixUtil';
-import colorMXID from '../../../util/colorMXID';
import RoomTimeline from '../../../client/state/RoomTimeline';
-import cons from '../../../client/state/cons';
-import { togglePeopleDrawer, openInviteUser } from '../../../client/action/navigation';
-import * as roomActions from '../../../client/action/room';
-import {
- bytesToSize,
- diffMinutes,
- isNotInSameDay,
-} from '../../../util/common';
-import Text from '../../atoms/text/Text';
-import RawIcon from '../../atoms/system-icons/RawIcon';
-import Header, { TitleWrapper } from '../../atoms/header/Header';
-import Avatar from '../../atoms/avatar/Avatar';
-import IconButton from '../../atoms/button/IconButton';
-import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
import ScrollView from '../../atoms/scroll/ScrollView';
-import Divider from '../../atoms/divider/Divider';
-import Message, { PlaceholderMessage } from '../../molecules/message/Message';
-import * as Media from '../../molecules/media/Media';
-import TimelineChange from '../../molecules/message/TimelineChange';
-import ChannelIntro from '../../molecules/channel-intro/ChannelIntro';
-import EmojiBoard from '../emoji-board/EmojiBoard';
-import UserIC from '../../../../public/res/ic/outlined/user.svg';
-import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
-import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
-import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
-import SendIC from '../../../../public/res/ic/outlined/send.svg';
-import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
-import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
-import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
-import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
-import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
-import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
-import FileIC from '../../../../public/res/ic/outlined/file.svg';
+import ChannelViewHeader from './ChannelViewHeader';
+import ChannelViewContent from './ChannelViewContent';
+import ChannelViewFloating from './ChannelViewFloating';
+import ChannelViewInput from './ChannelViewInput';
+import ChannelViewCmdBar from './ChannelViewCmdBar';
-const MAX_MSG_DIFF_MINUTES = 5;
-const viewEvent = new EventEmitter();
-
-function getTimelineJSXMessages() {
- return {
- join(user) {
- return (
- <>
- <b>{user}</b>
- {' joined the channel'}
- </>
- );
- },
- leave(user) {
- return (
- <>
- <b>{user}</b>
- {' left the channel'}
- </>
- );
- },
- invite(inviter, user) {
- return (
- <>
- <b>{inviter}</b>
- {' invited '}
- <b>{user}</b>
- </>
- );
- },
- cancelInvite(inviter, user) {
- return (
- <>
- <b>{inviter}</b>
- {' canceled '}
- <b>{user}</b>
- {'\'s invite'}
- </>
- );
- },
- rejectInvite(user) {
- return (
- <>
- <b>{user}</b>
- {' rejected the invitation'}
- </>
- );
- },
- kick(actor, user, reason) {
- const reasonMsg = (typeof reason === 'string') ? ` for ${reason}` : '';
- return (
- <>
- <b>{actor}</b>
- {' kicked '}
- <b>{user}</b>
- {reasonMsg}
- </>
- );
- },
- ban(actor, user, reason) {
- const reasonMsg = (typeof reason === 'string') ? ` for ${reason}` : '';
- return (
- <>
- <b>{actor}</b>
- {' banned '}
- <b>{user}</b>
- {reasonMsg}
- </>
- );
- },
- unban(actor, user) {
- return (
- <>
- <b>{actor}</b>
- {' unbanned '}
- <b>{user}</b>
- </>
- );
- },
- avatarSets(user) {
- return (
- <>
- <b>{user}</b>
- {' set the avatar'}
- </>
- );
- },
- avatarChanged(user) {
- return (
- <>
- <b>{user}</b>
- {' changed the avatar'}
- </>
- );
- },
- avatarRemoved(user) {
- return (
- <>
- <b>{user}</b>
- {' removed the avatar'}
- </>
- );
- },
- nameSets(user, newName) {
- return (
- <>
- <b>{user}</b>
- {' set the display name to '}
- <b>{newName}</b>
- </>
- );
- },
- nameChanged(user, newName) {
- return (
- <>
- <b>{user}</b>
- {' changed the display name to '}
- <b>{newName}</b>
- </>
- );
- },
- nameRemoved(user, lastName) {
- return (
- <>
- <b>{user}</b>
- {' removed the display name '}
- <b>{lastName}</b>
- </>
- );
- },
- };
-}
-
-function getUsersActionJsx(userIds, actionStr) {
- const getUserJSX = (username) => <b>{getUsername(username)}</b>;
- if (!Array.isArray(userIds)) return 'Idle';
- if (userIds.length === 0) return 'Idle';
- const MAX_VISIBLE_COUNT = 3;
-
- const u1Jsx = getUserJSX(userIds[0]);
- // eslint-disable-next-line react/jsx-one-expression-per-line
- if (userIds.length === 1) return <>{u1Jsx} is {actionStr}</>;
-
- const u2Jsx = getUserJSX(userIds[1]);
- // eslint-disable-next-line react/jsx-one-expression-per-line
- if (userIds.length === 2) return <>{u1Jsx} and {u2Jsx} are {actionStr}</>;
-
- const u3Jsx = getUserJSX(userIds[2]);
- if (userIds.length === 3) {
- // eslint-disable-next-line react/jsx-one-expression-per-line
- return <>{u1Jsx}, {u2Jsx} and {u3Jsx} are {actionStr}</>;
- }
-
- const othersCount = userIds.length - MAX_VISIBLE_COUNT;
- // eslint-disable-next-line react/jsx-one-expression-per-line
- return <>{u1Jsx}, {u2Jsx}, {u3Jsx} and {othersCount} other are {actionStr}</>;
-}
-
-function parseReply(rawContent) {
- if (rawContent.indexOf('>') !== 0) return null;
- let content = rawContent.slice(rawContent.indexOf('@'));
- const userId = content.slice(0, content.indexOf('>'));
-
- content = content.slice(content.indexOf('>') + 2);
- const replyContent = content.slice(0, content.indexOf('\n\n'));
- content = content.slice(content.indexOf('\n\n') + 2);
+import { scrollToBottom, isAtBottom, autoScrollToBottom } from './common';
- if (userId === '') return null;
-
- return {
- userId,
- replyContent,
- content,
- };
-}
-function parseTimelineChange(mEvent) {
- const tJSXMsgs = getTimelineJSXMessages();
- const makeReturnObj = (variant, content) => ({
- variant,
- content,
- });
- const content = mEvent.getContent();
- const prevContent = mEvent.getPrevContent();
- const sender = mEvent.getSender();
- const senderName = getUsername(sender);
- const userName = getUsername(mEvent.getStateKey());
-
- switch (content.membership) {
- case 'invite': return makeReturnObj('invite', tJSXMsgs.invite(senderName, userName));
- case 'ban': return makeReturnObj('leave', tJSXMsgs.ban(senderName, userName, content.reason));
- case 'join':
- if (prevContent.membership === 'join') {
- if (content.displayname !== prevContent.displayname) {
- if (typeof content.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameRemoved(sender, prevContent.displayname));
- if (typeof prevContent.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameSets(sender, content.displayname));
- return makeReturnObj('avatar', tJSXMsgs.nameChanged(prevContent.displayname, content.displayname));
- }
- if (content.avatar_url !== prevContent.avatar_url) {
- if (typeof content.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarRemoved(content.displayname));
- if (typeof prevContent.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarSets(content.displayname));
- return makeReturnObj('avatar', tJSXMsgs.avatarChanged(content.displayname));
- }
- return null;
- }
- return makeReturnObj('join', tJSXMsgs.join(senderName));
- case 'leave':
- if (sender === mEvent.getStateKey()) {
- switch (prevContent.membership) {
- case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.rejectInvite(senderName));
- default: return makeReturnObj('leave', tJSXMsgs.leave(senderName));
- }
- }
- switch (prevContent.membership) {
- case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.cancelInvite(senderName, userName));
- case 'ban': return makeReturnObj('other', tJSXMsgs.unban(senderName, userName));
- // sender is not target and made the target leave,
- // if not from invite/ban then this is a kick
- default: return makeReturnObj('leave', tJSXMsgs.kick(senderName, userName, content.reason));
- }
- default: return null;
- }
-}
-
-function scrollToBottom(ref) {
- const maxScrollTop = ref.current.scrollHeight - ref.current.offsetHeight;
- // eslint-disable-next-line no-param-reassign
- ref.current.scrollTop = maxScrollTop;
-}
-
-function isAtBottom(ref) {
- const { scrollHeight, scrollTop, offsetHeight } = ref.current;
- const scrollUptoBottom = scrollTop + offsetHeight;
-
- // scroll view have to div inside div which contains messages
- const lastMessage = ref.current.lastElementChild.lastElementChild.lastElementChild;
- const lastChildHeight = lastMessage.offsetHeight;
-
- // auto scroll to bottom even if user has EXTRA_SPACE left to scroll
- const EXTRA_SPACE = 48;
-
- if (scrollHeight - scrollUptoBottom <= lastChildHeight + EXTRA_SPACE) {
- return true;
- }
- return false;
-}
-
-function autoScrollToBottom(ref) {
- if (isAtBottom(ref)) scrollToBottom(ref);
-}
-
-function ChannelViewHeader({ roomId }) {
- const mx = initMatrix.matrixClient;
- const avatarSrc = mx.getRoom(roomId).getAvatarUrl(mx.baseUrl, 36, 36, 'crop');
- const roomName = mx.getRoom(roomId).name;
- const isDM = initMatrix.roomList.directs.has(roomId);
- const roomTopic = mx.getRoom(roomId).currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
-
- return (
- <Header>
- <Avatar imageSrc={avatarSrc} text={roomName.slice(0, 1)} bgColor={colorMXID(roomName)} size="small" />
- <TitleWrapper>
- <Text variant="h2">{roomName}</Text>
- { typeof roomTopic !== 'undefined' && <p title={roomTopic} className="text text-b3">{roomTopic}</p>}
- </TitleWrapper>
- <IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
- <ContextMenu
- placement="bottom"
- content={(toogleMenu) => (
- <>
- <MenuHeader>Options</MenuHeader>
- {/* <MenuBorder /> */}
- <MenuItem
- iconSrc={AddUserIC}
- onClick={() => {
- openInviteUser(roomId); toogleMenu();
- }}
- >
- Invite
- </MenuItem>
- <MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={() => roomActions.leave(roomId, isDM)}>Leave</MenuItem>
- </>
- )}
- render={(toggleMenu) => <IconButton onClick={toggleMenu} tooltip="Options" src={VerticalMenuIC} />}
- />
- </Header>
- );
-}
-ChannelViewHeader.propTypes = {
- roomId: PropTypes.string.isRequired,
-};
-
-let wasAtBottom = true;
-function ChannelViewContent({ roomId, roomTimeline, timelineScroll }) {
- const [isReachedTimelineEnd, setIsReachedTimelineEnd] = useState(false);
- const [onStateUpdate, updateState] = useState(null);
- const [onPagination, setOnPagination] = useState(null);
- const mx = initMatrix.matrixClient;
-
- function autoLoadTimeline() {
- if (timelineScroll.isScrollable() === true) return;
- roomTimeline.paginateBack();
- }
- function trySendingReadReceipt() {
- const { room, timeline } = roomTimeline;
- if (doesRoomHaveUnread(room) && timeline.length !== 0) {
- mx.sendReadReceipt(timeline[timeline.length - 1]);
- }
- }
-
- function onReachedTop() {
- if (roomTimeline.isOngoingPagination || isReachedTimelineEnd) return;
- roomTimeline.paginateBack();
- }
- function toggleOnReachedBottom(isBottom) {
- wasAtBottom = isBottom;
- if (!isBottom) return;
- trySendingReadReceipt();
- }
-
- const updatePAG = (canPagMore) => {
- if (!canPagMore) {
- setIsReachedTimelineEnd(true);
- } else {
- setOnPagination({});
- autoLoadTimeline();
- }
- };
- // force update RoomTimeline on cons.events.roomTimeline.EVENT
- const updateRT = () => {
- if (wasAtBottom) {
- trySendingReadReceipt();
- }
- updateState({});
- };
-
- useEffect(() => {
- setIsReachedTimelineEnd(false);
- wasAtBottom = true;
- }, [roomId]);
- useEffect(() => trySendingReadReceipt(), [roomTimeline]);
-
- // init room setup completed.
- // listen for future. setup stateUpdate listener.
- useEffect(() => {
- roomTimeline.on(cons.events.roomTimeline.EVENT, updateRT);
- roomTimeline.on(cons.events.roomTimeline.PAGINATED, updatePAG);
- viewEvent.on('reached-top', onReachedTop);
- viewEvent.on('toggle-reached-bottom', toggleOnReachedBottom);
-
- return () => {
- roomTimeline.removeListener(cons.events.roomTimeline.EVENT, updateRT);
- roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, updatePAG);
- viewEvent.removeListener('reached-top', onReachedTop);
- viewEvent.removeListener('toggle-reached-bottom', toggleOnReachedBottom);
- };
- }, [roomTimeline, isReachedTimelineEnd, onPagination]);
-
- useLayoutEffect(() => {
- timelineScroll.reachBottom();
- autoLoadTimeline();
- }, [roomTimeline]);
-
- useLayoutEffect(() => {
- if (onPagination === null) return;
- timelineScroll.tryRestoringScroll();
- }, [onPagination]);
-
- useEffect(() => {
- if (onStateUpdate === null) return;
- if (wasAtBottom) timelineScroll.reachBottom();
- }, [onStateUpdate]);
-
- let prevMEvent = null;
- function renderMessage(mEvent) {
- function isMedia(mE) {
- return (
- mE.getContent()?.msgtype === 'm.file'
- || mE.getContent()?.msgtype === 'm.image'
- || mE.getContent()?.msgtype === 'm.audio'
- || mE.getContent()?.msgtype === 'm.video'
- );
- }
- function genMediaContent(mE) {
- const mContent = mE.getContent();
- let mediaMXC = mContent.url;
- let thumbnailMXC = mContent?.info?.thumbnail_url;
- const isEncryptedFile = typeof mediaMXC === 'undefined';
- if (isEncryptedFile) mediaMXC = mContent.file.url;
-
- switch (mE.getContent()?.msgtype) {
- case 'm.file':
- return (
- <Media.File
- name={mContent.body}
- link={mx.mxcUrlToHttp(mediaMXC)}
- file={mContent.file}
- type={mContent.info.mimetype}
- />
- );
- case 'm.image':
- return (
- <Media.Image
- name={mContent.body}
- width={mContent.info.w || null}
- height={mContent.info.h || null}
- link={mx.mxcUrlToHttp(mediaMXC)}
- file={isEncryptedFile ? mContent.file : null}
- type={mContent.info.mimetype}
- />
- );
- case 'm.audio':
- return (
- <Media.Audio
- name={mContent.body}
- link={mx.mxcUrlToHttp(mediaMXC)}
- type={mContent.info.mimetype}
- file={mContent.file}
- />
- );
- case 'm.video':
- if (typeof thumbnailMXC === 'undefined') {
- thumbnailMXC = mContent.info?.thumbnail_file?.url || null;
- }
- return (
- <Media.Video
- name={mContent.body}
- link={mx.mxcUrlToHttp(mediaMXC)}
- thumbnail={thumbnailMXC === null ? null : mx.mxcUrlToHttp(thumbnailMXC)}
- thumbnailFile={isEncryptedFile ? mContent.info.thumbnail_file : null}
- thumbnailType={mContent.info.thumbnail_info?.mimetype || null}
- width={mContent.info.w || null}
- height={mContent.info.h || null}
- file={isEncryptedFile ? mContent.file : null}
- type={mContent.info.mimetype}
- />
- );
- default:
- return 'Unable to attach media file!';
- }
- }
-
- if (mEvent.getType() === 'm.room.create') {
- const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
- return (
- <ChannelIntro
- key={mEvent.getId()}
- avatarSrc={roomTimeline.room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 80, 80, 'crop')}
- name={roomTimeline.room.name}
- heading={`Welcome to ${roomTimeline.room.name}`}
- desc={`This is the beginning of ${roomTimeline.room.name} channel.${typeof roomTopic !== 'undefined' ? (` Topic: ${roomTopic}`) : ''}`}
- time={`Created at ${dateFormat(mEvent.getDate(), 'dd mmmm yyyy, hh:MM TT')}`}
- />
- );
- }
- if (
- mEvent.getType() !== 'm.room.message'
- && mEvent.getType() !== 'm.room.encrypted'
- && mEvent.getType() !== 'm.room.member'
- ) return false;
- if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
-
- // ignore if message is deleted
- if (mEvent.isRedacted()) return false;
-
- let divider = null;
- if (prevMEvent !== null && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate())) {
- divider = <Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />;
- }
-
- if (mEvent.getType() !== 'm.room.member') {
- const isContentOnly = (
- prevMEvent !== null
- && prevMEvent.getType() !== 'm.room.member'
- && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
- && prevMEvent.getSender() === mEvent.getSender()
- );
-
- let content = mEvent.getContent().body;
- if (typeof content === 'undefined') return null;
- let reply = null;
- let reactions = null;
- let isMarkdown = mEvent.getContent().format === 'org.matrix.custom.html';
- const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined';
- const isEdited = roomTimeline.editedTimeline.has(mEvent.getId());
- const haveReactions = roomTimeline.reactionTimeline.has(mEvent.getId());
-
- if (isReply) {
- const parsedContent = parseReply(content);
-
- if (parsedContent !== null) {
- const username = getUsername(parsedContent.userId);
- reply = {
- color: colorMXID(parsedContent.userId),
- to: username,
- content: parsedContent.replyContent,
- };
- content = parsedContent.content;
- }
- }
-
- if (isEdited) {
- const editedList = roomTimeline.editedTimeline.get(mEvent.getId());
- const latestEdited = editedList[editedList.length - 1];
- if (typeof latestEdited.getContent()['m.new_content'] === 'undefined') return null;
- const latestEditBody = latestEdited.getContent()['m.new_content'].body;
- const parsedEditedContent = parseReply(latestEditBody);
- isMarkdown = latestEdited.getContent()['m.new_content'].format === 'org.matrix.custom.html';
- if (parsedEditedContent === null) {
- content = latestEditBody;
- } else {
- content = parsedEditedContent.content;
- }
- }
-
- if (haveReactions) {
- reactions = [];
- roomTimeline.reactionTimeline.get(mEvent.getId()).forEach((rEvent) => {
- if (rEvent.getRelation() === null) return;
- function alreadyHaveThisReaction(rE) {
- for (let i = 0; i < reactions.length; i += 1) {
- if (reactions[i].key === rE.getRelation().key) return true;
- }
- return false;
- }
- if (alreadyHaveThisReaction(rEvent)) {
- for (let i = 0; i < reactions.length; i += 1) {
- if (reactions[i].key === rEvent.getRelation().key) {
- reactions[i].count += 1;
- if (reactions[i].active !== true) {
- reactions[i].active = rEvent.getSender() === initMatrix.matrixClient.getUserId();
- }
- break;
- }
- }
- } else {
- reactions.push({
- id: rEvent.getId(),
- key: rEvent.getRelation().key,
- count: 1,
- active: (rEvent.getSender() === initMatrix.matrixClient.getUserId()),
- });
- }
- });
- }
-
- const myMessageEl = (
- <React.Fragment key={`box-${mEvent.getId()}`}>
- {divider}
- { isMedia(mEvent) ? (
- <Message
- key={mEvent.getId()}
- contentOnly={isContentOnly}
- markdown={isMarkdown}
- avatarSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
- color={colorMXID(mEvent.sender.userId)}
- name={getUsername(mEvent.sender.userId)}
- content={genMediaContent(mEvent)}
- reply={reply}
- time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
- edited={isEdited}
- reactions={reactions}
- />
- ) : (
- <Message
- key={mEvent.getId()}
- contentOnly={isContentOnly}
- markdown={isMarkdown}
- avatarSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
- color={colorMXID(mEvent.sender.userId)}
- name={getUsername(mEvent.sender.userId)}
- content={content}
- reply={reply}
- time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
- edited={isEdited}
- reactions={reactions}
- />
- )}
- </React.Fragment>
- );
-
- prevMEvent = mEvent;
- return myMessageEl;
- }
- prevMEvent = mEvent;
- const timelineChange = parseTimelineChange(mEvent);
- if (timelineChange === null) return null;
- return (
- <React.Fragment key={`box-${mEvent.getId()}`}>
- {divider}
- <TimelineChange
- key={mEvent.getId()}
- variant={timelineChange.variant}
- content={timelineChange.content}
- time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
- />
- </React.Fragment>
- );
- }
-
- const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
- return (
- <div className="channel-view__content">
- <div className="timeline__wrapper">
- {
- roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && (
- <>
- <PlaceholderMessage key={Math.random().toString(20).substr(2, 6)} />
- <PlaceholderMessage key={Math.random().toString(20).substr(2, 6)} />
- <PlaceholderMessage key={Math.random().toString(20).substr(2, 6)} />
- </>
- )
- }
- {
- roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && (
- <ChannelIntro
- key={Math.random().toString(20).substr(2, 6)}
- avatarSrc={roomTimeline.room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 80, 80, 'crop')}
- name={roomTimeline.room.name}
- heading={`Welcome to ${roomTimeline.room.name}`}
- desc={`This is the beginning of ${roomTimeline.room.name} channel.${typeof roomTopic !== 'undefined' ? (` Topic: ${roomTopic}`) : ''}`}
- />
- )
- }
- { roomTimeline.timeline.map(renderMessage) }
- </div>
- </div>
- );
-}
-ChannelViewContent.propTypes = {
- roomId: PropTypes.string.isRequired,
- roomTimeline: PropTypes.shape({}).isRequired,
- timelineScroll: PropTypes.shape({
- reachBottom: PropTypes.func,
- autoReachBottom: PropTypes.func,
- tryRestoringScroll: PropTypes.func,
- enableSmoothScroll: PropTypes.func,
- disableSmoothScroll: PropTypes.func,
- isScrollable: PropTypes.func,
- }).isRequired,
-};
-
-function FloatingOptions({
- roomId, roomTimeline, timelineScroll,
-}) {
- const [reachedBottom, setReachedBottom] = useState(true);
- const [typingMembers, setTypingMembers] = useState(new Set());
- const mx = initMatrix.matrixClient;
-
- function isSomeoneTyping(members) {
- const m = members;
- m.delete(mx.getUserId());
- if (m.size === 0) return false;
- return true;
- }
-
- function getTypingMessage(members) {
- const userIds = members;
- userIds.delete(mx.getUserId());
- return getUsersActionJsx([...userIds], 'typing...');
- }
-
- function updateTyping(members) {
- setTypingMembers(members);
- }
-
- useEffect(() => {
- setReachedBottom(true);
- setTypingMembers(new Set());
- viewEvent.on('toggle-reached-bottom', setReachedBottom);
- return () => viewEvent.removeListener('toggle-reached-bottom', setReachedBottom);
- }, [roomId]);
-
- useEffect(() => {
- roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
- return () => {
- roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
- };
- }, [roomTimeline]);
-
- return (
- <>
- <div className={`channel-view__typing${isSomeoneTyping(typingMembers) ? ' channel-view__typing--open' : ''}`}>
- <div className="bouncingLoader"><div /></div>
- <Text variant="b2">{getTypingMessage(typingMembers)}</Text>
- </div>
- <div className={`channel-view__STB${reachedBottom ? '' : ' channel-view__STB--open'}`}>
- <IconButton
- onClick={() => {
- timelineScroll.enableSmoothScroll();
- timelineScroll.reachBottom();
- timelineScroll.disableSmoothScroll();
- }}
- src={ChevronBottomIC}
- tooltip="Scroll to Bottom"
- />
- </div>
- </>
- );
-}
-FloatingOptions.propTypes = {
- roomId: PropTypes.string.isRequired,
- roomTimeline: PropTypes.shape({}).isRequired,
- timelineScroll: PropTypes.shape({
- reachBottom: PropTypes.func,
- }).isRequired,
-};
-
-function ChannelViewSticky({ children }) {
- return <div className="channel-view__sticky">{children}</div>;
-}
-ChannelViewSticky.propTypes = { children: PropTypes.node.isRequired };
-
-let isTyping = false;
-function ChannelInput({
- roomId, roomTimeline, timelineScroll,
-}) {
- const [attachment, setAttachment] = useState(null);
-
- const textAreaRef = useRef(null);
- const inputBaseRef = useRef(null);
- const uploadInputRef = useRef(null);
- const uploadProgressRef = useRef(null);
-
- const TYPING_TIMEOUT = 5000;
- const mx = initMatrix.matrixClient;
- const { roomsInput } = initMatrix;
-
- const sendIsTyping = (isT) => {
- mx.sendTyping(roomId, isT, isT ? TYPING_TIMEOUT : undefined);
- isTyping = isT;
-
- if (isT === true) {
- setTimeout(() => {
- if (isTyping) sendIsTyping(false);
- }, TYPING_TIMEOUT);
- }
- };
-
- function uploadingProgress(myRoomId, { loaded, total }) {
- if (myRoomId !== roomId) return;
- const progressPer = Math.round((loaded * 100) / total);
- uploadProgressRef.current.textContent = `Uploading: ${bytesToSize(loaded)}/${bytesToSize(total)} (${progressPer}%)`;
- inputBaseRef.current.style.backgroundImage = `linear-gradient(90deg, var(--bg-surface-hover) ${progressPer}%, var(--bg-surface-low) ${progressPer}%)`;
- }
- function clearAttachment(myRoomId) {
- if (roomId !== myRoomId) return;
- setAttachment(null);
- inputBaseRef.current.style.backgroundImage = 'unset';
- uploadInputRef.current.value = null;
- }
-
- useEffect(() => {
- roomsInput.on(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
- roomsInput.on(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
- roomsInput.on(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
- if (textAreaRef?.current !== null) {
- isTyping = false;
- textAreaRef.current.focus();
- textAreaRef.current.value = roomsInput.getMessage(roomId);
- setAttachment(roomsInput.getAttachment(roomId));
- }
- return () => {
- roomsInput.removeListener(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
- roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
- roomsInput.removeListener(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
- if (textAreaRef?.current === null) return;
-
- const msg = textAreaRef.current.value;
- inputBaseRef.current.style.backgroundImage = 'unset';
- if (msg.trim() === '') {
- roomsInput.setMessage(roomId, '');
- return;
- }
- roomsInput.setMessage(roomId, msg);
- };
- }, [roomId]);
-
- async function sendMessage() {
- const msgBody = textAreaRef.current.value;
- if (roomsInput.isSending(roomId)) return;
- if (msgBody.trim() === '' && attachment === null) return;
- sendIsTyping(false);
-
- roomsInput.setMessage(roomId, msgBody);
- if (attachment !== null) {
- roomsInput.setAttachment(roomId, attachment);
- }
- textAreaRef.current.disabled = true;
- textAreaRef.current.style.cursor = 'not-allowed';
- await roomsInput.sendInput(roomId);
- textAreaRef.current.disabled = false;
- textAreaRef.current.style.cursor = 'unset';
- textAreaRef.current.focus();
-
- textAreaRef.current.value = roomsInput.getMessage(roomId);
- timelineScroll.reachBottom();
- viewEvent.emit('message_sent');
- textAreaRef.current.style.height = 'unset';
- }
-
- function processTyping(msg) {
- const isEmptyMsg = msg === '';
-
- if (isEmptyMsg && isTyping) {
- sendIsTyping(false);
- return;
- }
- if (!isEmptyMsg && !isTyping) {
- sendIsTyping(true);
- }
- }
-
- function handleMsgTyping(e) {
- const msg = e.target.value;
- processTyping(msg);
- }
-
- function handleKeyDown(e) {
- if (e.keyCode === 13 && e.shiftKey === false) {
- e.preventDefault();
- sendMessage();
- }
- }
-
- function addEmoji(emoji) {
- textAreaRef.current.value += emoji.unicode;
- }
-
- function handleUploadClick() {
- if (attachment === null) uploadInputRef.current.click();
- else {
- roomsInput.cancelAttachment(roomId);
- }
- }
- function uploadFileChange(e) {
- const file = e.target.files.item(0);
- setAttachment(file);
- if (file !== null) roomsInput.setAttachment(roomId, file);
- }
-
- function renderInputs() {
- return (
- <>
- <div className={`channel-input__option-container${attachment === null ? '' : ' channel-attachment__option'}`}>
- <input onChange={uploadFileChange} style={{ display: 'none' }} ref={uploadInputRef} type="file" />
- <IconButton onClick={handleUploadClick} tooltip={attachment === null ? 'Upload' : 'Cancel'} src={CirclePlusIC} />
- </div>
- <div ref={inputBaseRef} className="channel-input__input-container">
- {roomTimeline.isEncryptedRoom() && <RawIcon size="extra-small" src={ShieldIC} />}
- <ScrollView autoHide>
- <Text className="channel-input__textarea-wrapper">
- <TextareaAutosize
- ref={textAreaRef}
- onChange={handleMsgTyping}
- onResize={() => timelineScroll.autoReachBottom()}
- onKeyDown={handleKeyDown}
- placeholder="Send a message..."
- />
- </Text>
- </ScrollView>
- </div>
- <div className="channel-input__option-container">
- <ContextMenu
- placement="top"
- content={(
- <EmojiBoard onSelect={addEmoji} />
- )}
- render={(toggleMenu) => <IconButton onClick={toggleMenu} tooltip="Emoji" src={EmojiIC} />}
- />
- <IconButton onClick={sendMessage} tooltip="Send" src={SendIC} />
- </div>
- </>
- );
- }
-
- function attachFile() {
- const fileType = attachment.type.slice(0, attachment.type.indexOf('/'));
- return (
- <div className="channel-attachment">
- <div className={`channel-attachment__preview${fileType !== 'image' ? ' channel-attachment__icon' : ''}`}>
- {fileType === 'image' && <img alt={attachment.name} src={URL.createObjectURL(attachment)} />}
- {fileType === 'video' && <RawIcon src={VLCIC} />}
- {fileType === 'audio' && <RawIcon src={VolumeFullIC} />}
- {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && <RawIcon src={FileIC} />}
- </div>
- <div className="channel-attachment__info">
- <Text variant="b1">{attachment.name}</Text>
- <Text variant="b3"><span ref={uploadProgressRef}>{`size: ${bytesToSize(attachment.size)}`}</span></Text>
- </div>
- </div>
- );
- }
-
- return (
- <>
- { attachment !== null && attachFile() }
- <form className="channel-input" onSubmit={(e) => { e.preventDefault(); }}>
- {
- roomTimeline.room.isSpaceRoom()
- ? <Text className="channel-input__space" variant="b1">Spaces are yet to be implemented</Text>
- : renderInputs()
- }
- </form>
- </>
- );
-}
-ChannelInput.propTypes = {
- roomId: PropTypes.string.isRequired,
- roomTimeline: PropTypes.shape({}).isRequired,
- timelineScroll: PropTypes.shape({
- reachBottom: PropTypes.func,
- autoReachBottom: PropTypes.func,
- tryRestoringScroll: PropTypes.func,
- enableSmoothScroll: PropTypes.func,
- disableSmoothScroll: PropTypes.func,
- }).isRequired,
-};
-function ChannelCmdBar({ roomId, roomTimeline }) {
- const [followingMembers, setFollowingMembers] = useState([]);
- const mx = initMatrix.matrixClient;
-
- function handleOnMessageSent() {
- setFollowingMembers([]);
- }
-
- function updateFollowingMembers() {
- const room = mx.getRoom(roomId);
- const { timeline } = room;
- const userIds = room.getUsersReadUpTo(timeline[timeline.length - 1]);
- const myUserId = mx.getUserId();
- setFollowingMembers(userIds.filter((userId) => userId !== myUserId));
- }
-
- useEffect(() => {
- updateFollowingMembers();
- }, [roomId]);
-
- useEffect(() => {
- roomTimeline.on(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
- viewEvent.on('message_sent', handleOnMessageSent);
- return () => {
- roomTimeline.removeListener(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
- viewEvent.removeListener('message_sent', handleOnMessageSent);
- };
- }, [roomTimeline]);
-
- return (
- <div className="channel-cmd-bar">
- {
- followingMembers.length !== 0 && (
- <TimelineChange
- variant="follow"
- content={getUsersActionJsx(followingMembers, 'following the conversation.')}
- time=""
- />
- )
- }
- </div>
- );
-}
-ChannelCmdBar.propTypes = {
- roomId: PropTypes.string.isRequired,
- roomTimeline: PropTypes.shape({}).isRequired,
-};
+const viewEvent = new EventEmitter();
let lastScrollTop = 0;
let lastScrollHeight = 0;
roomId={roomId}
roomTimeline={roomTimeline}
timelineScroll={timelineScroll}
+ viewEvent={viewEvent}
/>
)}
</ScrollView>
{roomTimeline !== null && (
- <FloatingOptions
+ <ChannelViewFloating
roomId={roomId}
roomTimeline={roomTimeline}
timelineScroll={timelineScroll}
+ viewEvent={viewEvent}
/>
)}
</div>
{roomTimeline !== null && (
- <ChannelViewSticky>
- <ChannelInput
+ <div className="channel-view__sticky">
+ <ChannelViewInput
roomId={roomId}
roomTimeline={roomTimeline}
timelineScroll={timelineScroll}
+ viewEvent={viewEvent}
/>
- <ChannelCmdBar
+ <ChannelViewCmdBar
roomId={roomId}
roomTimeline={roomTimeline}
+ viewEvent={viewEvent}
/>
- </ChannelViewSticky>
+ </div>
)}
</div>
</div>
@extend .channel-view-flexItem;
position: relative;
}
-
- &__content {
- min-height: 100%;
- display: flex;
- flex-direction: column;
- justify-content: flex-end;
-
- & .timeline__wrapper {
- --typing-noti-height: 28px;
- min-height: 0;
- min-width: 0;
- padding-bottom: var(--typing-noti-height);
- }
- }
-
- &__typing {
- display: flex;
- padding: var(--sp-ultra-tight) var(--sp-normal);
- background: var(--bg-surface);
- transition: transform 200ms ease-in-out;
-
- & b {
- color: var(--tc-surface-high);
- }
-
- &--open {
- transform: translateY(-99%);
- }
-
- & .text {
- flex: 1;
- min-width: 0;
-
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- margin: 0 var(--sp-tight);
- }
- }
-
- .bouncingLoader {
- transform: translateY(2px);
- margin: 0 calc(var(--sp-ultra-tight) / 2);
- }
- .bouncingLoader > div,
- .bouncingLoader:before,
- .bouncingLoader:after {
- display: inline-block;
- width: 8px;
- height: 8px;
- background: var(--tc-surface-high);
- border-radius: 50%;
- animation: bouncing-loader 0.6s infinite alternate;
- }
-
- .bouncingLoader:before,
- .bouncingLoader:after {
- content: "";
- }
-
- .bouncingLoader > div {
- margin: 0 4px;
- }
-
- .bouncingLoader > div {
- animation-delay: 0.2s;
- }
-
- .bouncingLoader:after {
- animation-delay: 0.4s;
- }
-
- @keyframes bouncing-loader {
- to {
- opacity: 0.1;
- transform: translate3d(0, -4px, 0);
- }
- }
-
- &__STB {
- position: absolute;
- right: var(--sp-normal);
- bottom: 0;
- border-radius: var(--bo-radius);
- box-shadow: var(--bs-surface-border);
- background-color: var(--bg-surface-low);
- transition: transform 200ms ease-in-out;
- transform: translateY(100%) scale(0);
- [dir=rtl] & {
- right: unset;
- left: var(--sp-normal);
- }
-
- &--open {
- transform: translateY(-28px) scale(1);
- }
- }
&__sticky {
min-height: 85px;
background: var(--bg-surface);
border-top: 1px solid var(--bg-surface-border);
}
-}
-
-.channel-input {
- padding: var(--sp-extra-tight) calc(var(--sp-normal) - 2px);
- display: flex;
- min-height: 48px;
-
- &__space {
- min-width: 0;
- align-self: center;
- margin: auto;
- padding: 0 var(--sp-tight);
- }
-
- &__input-container {
- flex: 1;
- min-width: 0;
- display: flex;
- align-items: center;
-
- margin: 0 calc(var(--sp-tight) - 2px);
- background-color: var(--bg-surface-low);
- box-shadow: var(--bs-surface-border);
- border-radius: var(--bo-radius);
-
- & > .ic-raw {
- transform: scale(0.8);
- margin-left: var(--sp-extra-tight);
- [dir=rtl] & {
- margin-left: 0;
- margin-right: var(--sp-extra-tight);
- }
- }
- & .scrollbar {
- max-height: 50vh;
- }
- }
-
- &__textarea-wrapper {
- min-height: 40px;
- display: flex;
- align-items: center;
-
- & textarea {
- resize: none;
- width: 100%;
- min-width: 0;
- min-height: 100%;
- padding: var(--sp-ultra-tight) calc(var(--sp-tight) - 2px);
-
- &::placeholder {
- color: var(--tc-surface-low);
- }
- &:focus {
- outline: none;
- }
- }
- }
-}
-
-.channel-cmd-bar {
- --cmd-bar-height: 28px;
- min-height: var(--cmd-bar-height);
-
- & .timeline-change {
- justify-content: flex-end;
- padding: var(--sp-ultra-tight) var(--sp-normal);
-
- &__content {
- margin: 0;
- flex: unset;
- & > .text {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- & b {
- color: var(--tc-surface-normal);
- }
- }
- }
- }
-}
-
-.channel-attachment {
- --side-spacing: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
- display: flex;
- align-items: center;
- margin-left: var(--side-spacing);
- margin-top: var(--sp-extra-tight);
- line-height: 0;
- [dir=rtl] & {
- margin-left: 0;
- margin-right: var(--side-spacing);
- }
-
- &__preview > img {
- max-height: 40px;
- border-radius: var(--bo-radius);
- }
- &__icon {
- padding: var(--sp-extra-tight);
- background-color: var(--bg-surface-low);
- box-shadow: var(--bs-surface-border);
- border-radius: var(--bo-radius);
- }
- &__info {
- flex: 1;
- min-width: 0;
- margin: 0 var(--sp-tight);
- }
-
- &__option button {
- transition: transform 200ms ease-in-out;
- transform: translateY(-48px);
- & .ic-raw {
- transition: transform 200ms ease-in-out;
- transform: rotate(45deg);
- background-color: var(--bg-caution);
- }
- }
}
\ No newline at end of file
--- /dev/null
+/* eslint-disable react/prop-types */
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import './ChannelViewCmdBar.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+
+import TimelineChange from '../../molecules/message/TimelineChange';
+
+import { getUsersActionJsx } from './common';
+
+function ChannelViewCmdBar({ roomId, roomTimeline, viewEvent }) {
+ const [followingMembers, setFollowingMembers] = useState([]);
+ const mx = initMatrix.matrixClient;
+
+ function handleOnMessageSent() {
+ setFollowingMembers([]);
+ }
+
+ function updateFollowingMembers() {
+ const room = mx.getRoom(roomId);
+ const { timeline } = room;
+ const userIds = room.getUsersReadUpTo(timeline[timeline.length - 1]);
+ const myUserId = mx.getUserId();
+ setFollowingMembers(userIds.filter((userId) => userId !== myUserId));
+ }
+
+ useEffect(() => {
+ updateFollowingMembers();
+ }, [roomId]);
+
+ useEffect(() => {
+ roomTimeline.on(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
+ viewEvent.on('message_sent', handleOnMessageSent);
+ return () => {
+ roomTimeline.removeListener(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
+ viewEvent.removeListener('message_sent', handleOnMessageSent);
+ };
+ }, [roomTimeline]);
+
+ return (
+ <div className="channel-cmd-bar">
+ {
+ followingMembers.length !== 0 && (
+ <TimelineChange
+ variant="follow"
+ content={getUsersActionJsx(followingMembers, 'following the conversation.')}
+ time=""
+ />
+ )
+ }
+ </div>
+ );
+}
+ChannelViewCmdBar.propTypes = {
+ roomId: PropTypes.string.isRequired,
+ roomTimeline: PropTypes.shape({}).isRequired,
+ viewEvent: PropTypes.shape({}).isRequired,
+};
+
+export default ChannelViewCmdBar;
--- /dev/null
+.channel-cmd-bar {
+ --cmd-bar-height: 28px;
+ min-height: var(--cmd-bar-height);
+
+ & .timeline-change {
+ justify-content: flex-end;
+ padding: var(--sp-ultra-tight) var(--sp-normal);
+
+ &__content {
+ margin: 0;
+ flex: unset;
+ & > .text {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ & b {
+ color: var(--tc-surface-normal);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
--- /dev/null
+/* eslint-disable react/prop-types */
+import React, { useState, useEffect, useLayoutEffect } from 'react';
+import PropTypes from 'prop-types';
+import './ChannelViewContent.scss';
+
+import dateFormat from 'dateformat';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import { getUsername, doesRoomHaveUnread } from '../../../util/matrixUtil';
+import colorMXID from '../../../util/colorMXID';
+import { diffMinutes, isNotInSameDay } from '../../../util/common';
+
+import Divider from '../../atoms/divider/Divider';
+import Message, { PlaceholderMessage } from '../../molecules/message/Message';
+import * as Media from '../../molecules/media/Media';
+import ChannelIntro from '../../molecules/channel-intro/ChannelIntro';
+import TimelineChange from '../../molecules/message/TimelineChange';
+
+import { parseReply, parseTimelineChange } from './common';
+
+const MAX_MSG_DIFF_MINUTES = 5;
+
+let wasAtBottom = true;
+function ChannelViewContent({
+ roomId, roomTimeline, timelineScroll, viewEvent,
+}) {
+ const [isReachedTimelineEnd, setIsReachedTimelineEnd] = useState(false);
+ const [onStateUpdate, updateState] = useState(null);
+ const [onPagination, setOnPagination] = useState(null);
+ const mx = initMatrix.matrixClient;
+
+ function autoLoadTimeline() {
+ if (timelineScroll.isScrollable() === true) return;
+ roomTimeline.paginateBack();
+ }
+ function trySendingReadReceipt() {
+ const { room, timeline } = roomTimeline;
+ if (doesRoomHaveUnread(room) && timeline.length !== 0) {
+ mx.sendReadReceipt(timeline[timeline.length - 1]);
+ }
+ }
+
+ function onReachedTop() {
+ if (roomTimeline.isOngoingPagination || isReachedTimelineEnd) return;
+ roomTimeline.paginateBack();
+ }
+ function toggleOnReachedBottom(isBottom) {
+ wasAtBottom = isBottom;
+ if (!isBottom) return;
+ trySendingReadReceipt();
+ }
+
+ const updatePAG = (canPagMore) => {
+ if (!canPagMore) {
+ setIsReachedTimelineEnd(true);
+ } else {
+ setOnPagination({});
+ autoLoadTimeline();
+ }
+ };
+ // force update RoomTimeline on cons.events.roomTimeline.EVENT
+ const updateRT = () => {
+ if (wasAtBottom) {
+ trySendingReadReceipt();
+ }
+ updateState({});
+ };
+
+ useEffect(() => {
+ setIsReachedTimelineEnd(false);
+ wasAtBottom = true;
+ }, [roomId]);
+ useEffect(() => trySendingReadReceipt(), [roomTimeline]);
+
+ // init room setup completed.
+ // listen for future. setup stateUpdate listener.
+ useEffect(() => {
+ roomTimeline.on(cons.events.roomTimeline.EVENT, updateRT);
+ roomTimeline.on(cons.events.roomTimeline.PAGINATED, updatePAG);
+ viewEvent.on('reached-top', onReachedTop);
+ viewEvent.on('toggle-reached-bottom', toggleOnReachedBottom);
+
+ return () => {
+ roomTimeline.removeListener(cons.events.roomTimeline.EVENT, updateRT);
+ roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, updatePAG);
+ viewEvent.removeListener('reached-top', onReachedTop);
+ viewEvent.removeListener('toggle-reached-bottom', toggleOnReachedBottom);
+ };
+ }, [roomTimeline, isReachedTimelineEnd, onPagination]);
+
+ useLayoutEffect(() => {
+ timelineScroll.reachBottom();
+ autoLoadTimeline();
+ }, [roomTimeline]);
+
+ useLayoutEffect(() => {
+ if (onPagination === null) return;
+ timelineScroll.tryRestoringScroll();
+ }, [onPagination]);
+
+ useEffect(() => {
+ if (onStateUpdate === null) return;
+ if (wasAtBottom) timelineScroll.reachBottom();
+ }, [onStateUpdate]);
+
+ let prevMEvent = null;
+ function renderMessage(mEvent) {
+ function isMedia(mE) {
+ return (
+ mE.getContent()?.msgtype === 'm.file'
+ || mE.getContent()?.msgtype === 'm.image'
+ || mE.getContent()?.msgtype === 'm.audio'
+ || mE.getContent()?.msgtype === 'm.video'
+ );
+ }
+ function genMediaContent(mE) {
+ const mContent = mE.getContent();
+ let mediaMXC = mContent.url;
+ let thumbnailMXC = mContent?.info?.thumbnail_url;
+ const isEncryptedFile = typeof mediaMXC === 'undefined';
+ if (isEncryptedFile) mediaMXC = mContent.file.url;
+
+ switch (mE.getContent()?.msgtype) {
+ case 'm.file':
+ return (
+ <Media.File
+ name={mContent.body}
+ link={mx.mxcUrlToHttp(mediaMXC)}
+ file={mContent.file}
+ type={mContent.info.mimetype}
+ />
+ );
+ case 'm.image':
+ return (
+ <Media.Image
+ name={mContent.body}
+ width={mContent.info.w || null}
+ height={mContent.info.h || null}
+ link={mx.mxcUrlToHttp(mediaMXC)}
+ file={isEncryptedFile ? mContent.file : null}
+ type={mContent.info.mimetype}
+ />
+ );
+ case 'm.audio':
+ return (
+ <Media.Audio
+ name={mContent.body}
+ link={mx.mxcUrlToHttp(mediaMXC)}
+ type={mContent.info.mimetype}
+ file={mContent.file}
+ />
+ );
+ case 'm.video':
+ if (typeof thumbnailMXC === 'undefined') {
+ thumbnailMXC = mContent.info?.thumbnail_file?.url || null;
+ }
+ return (
+ <Media.Video
+ name={mContent.body}
+ link={mx.mxcUrlToHttp(mediaMXC)}
+ thumbnail={thumbnailMXC === null ? null : mx.mxcUrlToHttp(thumbnailMXC)}
+ thumbnailFile={isEncryptedFile ? mContent.info.thumbnail_file : null}
+ thumbnailType={mContent.info.thumbnail_info?.mimetype || null}
+ width={mContent.info.w || null}
+ height={mContent.info.h || null}
+ file={isEncryptedFile ? mContent.file : null}
+ type={mContent.info.mimetype}
+ />
+ );
+ default:
+ return 'Unable to attach media file!';
+ }
+ }
+
+ if (mEvent.getType() === 'm.room.create') {
+ const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
+ return (
+ <ChannelIntro
+ key={mEvent.getId()}
+ avatarSrc={roomTimeline.room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 80, 80, 'crop')}
+ name={roomTimeline.room.name}
+ heading={`Welcome to ${roomTimeline.room.name}`}
+ desc={`This is the beginning of ${roomTimeline.room.name} channel.${typeof roomTopic !== 'undefined' ? (` Topic: ${roomTopic}`) : ''}`}
+ time={`Created at ${dateFormat(mEvent.getDate(), 'dd mmmm yyyy, hh:MM TT')}`}
+ />
+ );
+ }
+ if (
+ mEvent.getType() !== 'm.room.message'
+ && mEvent.getType() !== 'm.room.encrypted'
+ && mEvent.getType() !== 'm.room.member'
+ ) return false;
+ if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
+
+ // ignore if message is deleted
+ if (mEvent.isRedacted()) return false;
+
+ let divider = null;
+ if (prevMEvent !== null && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate())) {
+ divider = <Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />;
+ }
+
+ if (mEvent.getType() !== 'm.room.member') {
+ const isContentOnly = (
+ prevMEvent !== null
+ && prevMEvent.getType() !== 'm.room.member'
+ && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
+ && prevMEvent.getSender() === mEvent.getSender()
+ );
+
+ let content = mEvent.getContent().body;
+ if (typeof content === 'undefined') return null;
+ let reply = null;
+ let reactions = null;
+ let isMarkdown = mEvent.getContent().format === 'org.matrix.custom.html';
+ const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined';
+ const isEdited = roomTimeline.editedTimeline.has(mEvent.getId());
+ const haveReactions = roomTimeline.reactionTimeline.has(mEvent.getId());
+
+ if (isReply) {
+ const parsedContent = parseReply(content);
+
+ if (parsedContent !== null) {
+ const username = getUsername(parsedContent.userId);
+ reply = {
+ color: colorMXID(parsedContent.userId),
+ to: username,
+ content: parsedContent.replyContent,
+ };
+ content = parsedContent.content;
+ }
+ }
+
+ if (isEdited) {
+ const editedList = roomTimeline.editedTimeline.get(mEvent.getId());
+ const latestEdited = editedList[editedList.length - 1];
+ if (typeof latestEdited.getContent()['m.new_content'] === 'undefined') return null;
+ const latestEditBody = latestEdited.getContent()['m.new_content'].body;
+ const parsedEditedContent = parseReply(latestEditBody);
+ isMarkdown = latestEdited.getContent()['m.new_content'].format === 'org.matrix.custom.html';
+ if (parsedEditedContent === null) {
+ content = latestEditBody;
+ } else {
+ content = parsedEditedContent.content;
+ }
+ }
+
+ if (haveReactions) {
+ reactions = [];
+ roomTimeline.reactionTimeline.get(mEvent.getId()).forEach((rEvent) => {
+ if (rEvent.getRelation() === null) return;
+ function alreadyHaveThisReaction(rE) {
+ for (let i = 0; i < reactions.length; i += 1) {
+ if (reactions[i].key === rE.getRelation().key) return true;
+ }
+ return false;
+ }
+ if (alreadyHaveThisReaction(rEvent)) {
+ for (let i = 0; i < reactions.length; i += 1) {
+ if (reactions[i].key === rEvent.getRelation().key) {
+ reactions[i].count += 1;
+ if (reactions[i].active !== true) {
+ reactions[i].active = rEvent.getSender() === initMatrix.matrixClient.getUserId();
+ }
+ break;
+ }
+ }
+ } else {
+ reactions.push({
+ id: rEvent.getId(),
+ key: rEvent.getRelation().key,
+ count: 1,
+ active: (rEvent.getSender() === initMatrix.matrixClient.getUserId()),
+ });
+ }
+ });
+ }
+
+ const myMessageEl = (
+ <React.Fragment key={`box-${mEvent.getId()}`}>
+ {divider}
+ { isMedia(mEvent) ? (
+ <Message
+ key={mEvent.getId()}
+ contentOnly={isContentOnly}
+ markdown={isMarkdown}
+ avatarSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
+ color={colorMXID(mEvent.sender.userId)}
+ name={getUsername(mEvent.sender.userId)}
+ content={genMediaContent(mEvent)}
+ reply={reply}
+ time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
+ edited={isEdited}
+ reactions={reactions}
+ />
+ ) : (
+ <Message
+ key={mEvent.getId()}
+ contentOnly={isContentOnly}
+ markdown={isMarkdown}
+ avatarSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
+ color={colorMXID(mEvent.sender.userId)}
+ name={getUsername(mEvent.sender.userId)}
+ content={content}
+ reply={reply}
+ time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
+ edited={isEdited}
+ reactions={reactions}
+ />
+ )}
+ </React.Fragment>
+ );
+
+ prevMEvent = mEvent;
+ return myMessageEl;
+ }
+ prevMEvent = mEvent;
+ const timelineChange = parseTimelineChange(mEvent);
+ if (timelineChange === null) return null;
+ return (
+ <React.Fragment key={`box-${mEvent.getId()}`}>
+ {divider}
+ <TimelineChange
+ key={mEvent.getId()}
+ variant={timelineChange.variant}
+ content={timelineChange.content}
+ time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
+ />
+ </React.Fragment>
+ );
+ }
+
+ const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
+ return (
+ <div className="channel-view__content">
+ <div className="timeline__wrapper">
+ {
+ roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && (
+ <>
+ <PlaceholderMessage key={Math.random().toString(20).substr(2, 6)} />
+ <PlaceholderMessage key={Math.random().toString(20).substr(2, 6)} />
+ <PlaceholderMessage key={Math.random().toString(20).substr(2, 6)} />
+ </>
+ )
+ }
+ {
+ roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && (
+ <ChannelIntro
+ key={Math.random().toString(20).substr(2, 6)}
+ avatarSrc={roomTimeline.room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 80, 80, 'crop')}
+ name={roomTimeline.room.name}
+ heading={`Welcome to ${roomTimeline.room.name}`}
+ desc={`This is the beginning of ${roomTimeline.room.name} channel.${typeof roomTopic !== 'undefined' ? (` Topic: ${roomTopic}`) : ''}`}
+ />
+ )
+ }
+ { roomTimeline.timeline.map(renderMessage) }
+ </div>
+ </div>
+ );
+}
+ChannelViewContent.propTypes = {
+ roomId: PropTypes.string.isRequired,
+ roomTimeline: PropTypes.shape({}).isRequired,
+ timelineScroll: PropTypes.shape({
+ reachBottom: PropTypes.func,
+ autoReachBottom: PropTypes.func,
+ tryRestoringScroll: PropTypes.func,
+ enableSmoothScroll: PropTypes.func,
+ disableSmoothScroll: PropTypes.func,
+ isScrollable: PropTypes.func,
+ }).isRequired,
+ viewEvent: PropTypes.shape({}).isRequired,
+};
+
+export default ChannelViewContent;
--- /dev/null
+.channel-view__content {
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+
+ & .timeline__wrapper {
+ --typing-noti-height: 28px;
+ min-height: 0;
+ min-width: 0;
+ padding-bottom: var(--typing-noti-height);
+ }
+}
\ No newline at end of file
--- /dev/null
+/* eslint-disable react/prop-types */
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import './ChannelViewFloating.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+
+import Text from '../../atoms/text/Text';
+import IconButton from '../../atoms/button/IconButton';
+
+import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
+
+import { getUsersActionJsx } from './common';
+
+function ChannelViewFloating({
+ roomId, roomTimeline, timelineScroll, viewEvent,
+}) {
+ const [reachedBottom, setReachedBottom] = useState(true);
+ const [typingMembers, setTypingMembers] = useState(new Set());
+ const mx = initMatrix.matrixClient;
+
+ function isSomeoneTyping(members) {
+ const m = members;
+ m.delete(mx.getUserId());
+ if (m.size === 0) return false;
+ return true;
+ }
+
+ function getTypingMessage(members) {
+ const userIds = members;
+ userIds.delete(mx.getUserId());
+ return getUsersActionJsx([...userIds], 'typing...');
+ }
+
+ function updateTyping(members) {
+ setTypingMembers(members);
+ }
+
+ useEffect(() => {
+ setReachedBottom(true);
+ setTypingMembers(new Set());
+ viewEvent.on('toggle-reached-bottom', setReachedBottom);
+ return () => viewEvent.removeListener('toggle-reached-bottom', setReachedBottom);
+ }, [roomId]);
+
+ useEffect(() => {
+ roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
+ return () => {
+ roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
+ };
+ }, [roomTimeline]);
+
+ return (
+ <>
+ <div className={`channel-view__typing${isSomeoneTyping(typingMembers) ? ' channel-view__typing--open' : ''}`}>
+ <div className="bouncingLoader"><div /></div>
+ <Text variant="b2">{getTypingMessage(typingMembers)}</Text>
+ </div>
+ <div className={`channel-view__STB${reachedBottom ? '' : ' channel-view__STB--open'}`}>
+ <IconButton
+ onClick={() => {
+ timelineScroll.enableSmoothScroll();
+ timelineScroll.reachBottom();
+ timelineScroll.disableSmoothScroll();
+ }}
+ src={ChevronBottomIC}
+ tooltip="Scroll to Bottom"
+ />
+ </div>
+ </>
+ );
+}
+ChannelViewFloating.propTypes = {
+ roomId: PropTypes.string.isRequired,
+ roomTimeline: PropTypes.shape({}).isRequired,
+ timelineScroll: PropTypes.shape({
+ reachBottom: PropTypes.func,
+ }).isRequired,
+ viewEvent: PropTypes.shape({}).isRequired,
+};
+
+export default ChannelViewFloating;
--- /dev/null
+.channel-view {
+ &__typing {
+ display: flex;
+ padding: var(--sp-ultra-tight) var(--sp-normal);
+ background: var(--bg-surface);
+ transition: transform 200ms ease-in-out;
+
+ & b {
+ color: var(--tc-surface-high);
+ }
+
+ &--open {
+ transform: translateY(-99%);
+ }
+
+ & .text {
+ flex: 1;
+ min-width: 0;
+
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ margin: 0 var(--sp-tight);
+ }
+ }
+
+ .bouncingLoader {
+ transform: translateY(2px);
+ margin: 0 calc(var(--sp-ultra-tight) / 2);
+ }
+ .bouncingLoader > div,
+ .bouncingLoader:before,
+ .bouncingLoader:after {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ background: var(--tc-surface-high);
+ border-radius: 50%;
+ animation: bouncing-loader 0.6s infinite alternate;
+ }
+
+ .bouncingLoader:before,
+ .bouncingLoader:after {
+ content: "";
+ }
+
+ .bouncingLoader > div {
+ margin: 0 4px;
+ }
+
+ .bouncingLoader > div {
+ animation-delay: 0.2s;
+ }
+
+ .bouncingLoader:after {
+ animation-delay: 0.4s;
+ }
+
+ @keyframes bouncing-loader {
+ to {
+ opacity: 0.1;
+ transform: translate3d(0, -4px, 0);
+ }
+ }
+
+ &__STB {
+ position: absolute;
+ right: var(--sp-normal);
+ bottom: 0;
+ border-radius: var(--bo-radius);
+ box-shadow: var(--bs-surface-border);
+ background-color: var(--bg-surface-low);
+ transition: transform 200ms ease-in-out;
+ transform: translateY(100%) scale(0);
+ [dir=rtl] & {
+ right: unset;
+ left: var(--sp-normal);
+ }
+
+ &--open {
+ transform: translateY(-28px) scale(1);
+ }
+ }
+}
\ No newline at end of file
--- /dev/null
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import initMatrix from '../../../client/initMatrix';
+import { togglePeopleDrawer, openInviteUser } from '../../../client/action/navigation';
+import * as roomActions from '../../../client/action/room';
+import colorMXID from '../../../util/colorMXID';
+
+import Text from '../../atoms/text/Text';
+import IconButton from '../../atoms/button/IconButton';
+import Header, { TitleWrapper } from '../../atoms/header/Header';
+import Avatar from '../../atoms/avatar/Avatar';
+import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
+
+import UserIC from '../../../../public/res/ic/outlined/user.svg';
+import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
+import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
+import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
+
+function ChannelViewHeader({ roomId }) {
+ const mx = initMatrix.matrixClient;
+ const avatarSrc = mx.getRoom(roomId).getAvatarUrl(mx.baseUrl, 36, 36, 'crop');
+ const roomName = mx.getRoom(roomId).name;
+ const isDM = initMatrix.roomList.directs.has(roomId);
+ const roomTopic = mx.getRoom(roomId).currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
+
+ return (
+ <Header>
+ <Avatar imageSrc={avatarSrc} text={roomName.slice(0, 1)} bgColor={colorMXID(roomName)} size="small" />
+ <TitleWrapper>
+ <Text variant="h2">{roomName}</Text>
+ { typeof roomTopic !== 'undefined' && <p title={roomTopic} className="text text-b3">{roomTopic}</p>}
+ </TitleWrapper>
+ <IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
+ <ContextMenu
+ placement="bottom"
+ content={(toogleMenu) => (
+ <>
+ <MenuHeader>Options</MenuHeader>
+ {/* <MenuBorder /> */}
+ <MenuItem
+ iconSrc={AddUserIC}
+ onClick={() => {
+ openInviteUser(roomId); toogleMenu();
+ }}
+ >
+ Invite
+ </MenuItem>
+ <MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={() => roomActions.leave(roomId, isDM)}>Leave</MenuItem>
+ </>
+ )}
+ render={(toggleMenu) => <IconButton onClick={toggleMenu} tooltip="Options" src={VerticalMenuIC} />}
+ />
+ </Header>
+ );
+}
+ChannelViewHeader.propTypes = {
+ roomId: PropTypes.string.isRequired,
+};
+
+export default ChannelViewHeader;
--- /dev/null
+/* eslint-disable react/prop-types */
+import React, { useState, useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+import './ChannelViewInput.scss';
+
+import TextareaAutosize from 'react-autosize-textarea';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import { bytesToSize } from '../../../util/common';
+
+import Text from '../../atoms/text/Text';
+import RawIcon from '../../atoms/system-icons/RawIcon';
+import IconButton from '../../atoms/button/IconButton';
+import ContextMenu from '../../atoms/context-menu/ContextMenu';
+import ScrollView from '../../atoms/scroll/ScrollView';
+import EmojiBoard from '../emoji-board/EmojiBoard';
+
+import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
+import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
+import SendIC from '../../../../public/res/ic/outlined/send.svg';
+import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
+import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
+import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
+import FileIC from '../../../../public/res/ic/outlined/file.svg';
+
+let isTyping = false;
+function ChannelViewInput({
+ roomId, roomTimeline, timelineScroll, viewEvent,
+}) {
+ const [attachment, setAttachment] = useState(null);
+
+ const textAreaRef = useRef(null);
+ const inputBaseRef = useRef(null);
+ const uploadInputRef = useRef(null);
+ const uploadProgressRef = useRef(null);
+
+ const TYPING_TIMEOUT = 5000;
+ const mx = initMatrix.matrixClient;
+ const { roomsInput } = initMatrix;
+
+ const sendIsTyping = (isT) => {
+ mx.sendTyping(roomId, isT, isT ? TYPING_TIMEOUT : undefined);
+ isTyping = isT;
+
+ if (isT === true) {
+ setTimeout(() => {
+ if (isTyping) sendIsTyping(false);
+ }, TYPING_TIMEOUT);
+ }
+ };
+
+ function uploadingProgress(myRoomId, { loaded, total }) {
+ if (myRoomId !== roomId) return;
+ const progressPer = Math.round((loaded * 100) / total);
+ uploadProgressRef.current.textContent = `Uploading: ${bytesToSize(loaded)}/${bytesToSize(total)} (${progressPer}%)`;
+ inputBaseRef.current.style.backgroundImage = `linear-gradient(90deg, var(--bg-surface-hover) ${progressPer}%, var(--bg-surface-low) ${progressPer}%)`;
+ }
+ function clearAttachment(myRoomId) {
+ if (roomId !== myRoomId) return;
+ setAttachment(null);
+ inputBaseRef.current.style.backgroundImage = 'unset';
+ uploadInputRef.current.value = null;
+ }
+
+ useEffect(() => {
+ roomsInput.on(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
+ roomsInput.on(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
+ roomsInput.on(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
+ if (textAreaRef?.current !== null) {
+ isTyping = false;
+ textAreaRef.current.focus();
+ textAreaRef.current.value = roomsInput.getMessage(roomId);
+ setAttachment(roomsInput.getAttachment(roomId));
+ }
+ return () => {
+ roomsInput.removeListener(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
+ roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
+ roomsInput.removeListener(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
+ if (textAreaRef?.current === null) return;
+
+ const msg = textAreaRef.current.value;
+ inputBaseRef.current.style.backgroundImage = 'unset';
+ if (msg.trim() === '') {
+ roomsInput.setMessage(roomId, '');
+ return;
+ }
+ roomsInput.setMessage(roomId, msg);
+ };
+ }, [roomId]);
+
+ async function sendMessage() {
+ const msgBody = textAreaRef.current.value;
+ if (roomsInput.isSending(roomId)) return;
+ if (msgBody.trim() === '' && attachment === null) return;
+ sendIsTyping(false);
+
+ roomsInput.setMessage(roomId, msgBody);
+ if (attachment !== null) {
+ roomsInput.setAttachment(roomId, attachment);
+ }
+ textAreaRef.current.disabled = true;
+ textAreaRef.current.style.cursor = 'not-allowed';
+ await roomsInput.sendInput(roomId);
+ textAreaRef.current.disabled = false;
+ textAreaRef.current.style.cursor = 'unset';
+ textAreaRef.current.focus();
+
+ textAreaRef.current.value = roomsInput.getMessage(roomId);
+ timelineScroll.reachBottom();
+ viewEvent.emit('message_sent');
+ textAreaRef.current.style.height = 'unset';
+ }
+
+ function processTyping(msg) {
+ const isEmptyMsg = msg === '';
+
+ if (isEmptyMsg && isTyping) {
+ sendIsTyping(false);
+ return;
+ }
+ if (!isEmptyMsg && !isTyping) {
+ sendIsTyping(true);
+ }
+ }
+
+ function handleMsgTyping(e) {
+ const msg = e.target.value;
+ processTyping(msg);
+ }
+
+ function handleKeyDown(e) {
+ if (e.keyCode === 13 && e.shiftKey === false) {
+ e.preventDefault();
+ sendMessage();
+ }
+ }
+
+ function addEmoji(emoji) {
+ textAreaRef.current.value += emoji.unicode;
+ }
+
+ function handleUploadClick() {
+ if (attachment === null) uploadInputRef.current.click();
+ else {
+ roomsInput.cancelAttachment(roomId);
+ }
+ }
+ function uploadFileChange(e) {
+ const file = e.target.files.item(0);
+ setAttachment(file);
+ if (file !== null) roomsInput.setAttachment(roomId, file);
+ }
+
+ function renderInputs() {
+ return (
+ <>
+ <div className={`channel-input__option-container${attachment === null ? '' : ' channel-attachment__option'}`}>
+ <input onChange={uploadFileChange} style={{ display: 'none' }} ref={uploadInputRef} type="file" />
+ <IconButton onClick={handleUploadClick} tooltip={attachment === null ? 'Upload' : 'Cancel'} src={CirclePlusIC} />
+ </div>
+ <div ref={inputBaseRef} className="channel-input__input-container">
+ {roomTimeline.isEncryptedRoom() && <RawIcon size="extra-small" src={ShieldIC} />}
+ <ScrollView autoHide>
+ <Text className="channel-input__textarea-wrapper">
+ <TextareaAutosize
+ ref={textAreaRef}
+ onChange={handleMsgTyping}
+ onResize={() => timelineScroll.autoReachBottom()}
+ onKeyDown={handleKeyDown}
+ placeholder="Send a message..."
+ />
+ </Text>
+ </ScrollView>
+ </div>
+ <div className="channel-input__option-container">
+ <ContextMenu
+ placement="top"
+ content={(
+ <EmojiBoard onSelect={addEmoji} />
+ )}
+ render={(toggleMenu) => <IconButton onClick={toggleMenu} tooltip="Emoji" src={EmojiIC} />}
+ />
+ <IconButton onClick={sendMessage} tooltip="Send" src={SendIC} />
+ </div>
+ </>
+ );
+ }
+
+ function attachFile() {
+ const fileType = attachment.type.slice(0, attachment.type.indexOf('/'));
+ return (
+ <div className="channel-attachment">
+ <div className={`channel-attachment__preview${fileType !== 'image' ? ' channel-attachment__icon' : ''}`}>
+ {fileType === 'image' && <img alt={attachment.name} src={URL.createObjectURL(attachment)} />}
+ {fileType === 'video' && <RawIcon src={VLCIC} />}
+ {fileType === 'audio' && <RawIcon src={VolumeFullIC} />}
+ {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && <RawIcon src={FileIC} />}
+ </div>
+ <div className="channel-attachment__info">
+ <Text variant="b1">{attachment.name}</Text>
+ <Text variant="b3"><span ref={uploadProgressRef}>{`size: ${bytesToSize(attachment.size)}`}</span></Text>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <>
+ { attachment !== null && attachFile() }
+ <form className="channel-input" onSubmit={(e) => { e.preventDefault(); }}>
+ {
+ roomTimeline.room.isSpaceRoom()
+ ? <Text className="channel-input__space" variant="b1">Spaces are yet to be implemented</Text>
+ : renderInputs()
+ }
+ </form>
+ </>
+ );
+}
+ChannelViewInput.propTypes = {
+ roomId: PropTypes.string.isRequired,
+ roomTimeline: PropTypes.shape({}).isRequired,
+ timelineScroll: PropTypes.shape({
+ reachBottom: PropTypes.func,
+ autoReachBottom: PropTypes.func,
+ tryRestoringScroll: PropTypes.func,
+ enableSmoothScroll: PropTypes.func,
+ disableSmoothScroll: PropTypes.func,
+ }).isRequired,
+ viewEvent: PropTypes.shape({}).isRequired,
+};
+
+export default ChannelViewInput;
--- /dev/null
+.channel-input {
+ padding: var(--sp-extra-tight) calc(var(--sp-normal) - 2px);
+ display: flex;
+ min-height: 48px;
+
+ &__space {
+ min-width: 0;
+ align-self: center;
+ margin: auto;
+ padding: 0 var(--sp-tight);
+ }
+
+ &__input-container {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+
+ margin: 0 calc(var(--sp-tight) - 2px);
+ background-color: var(--bg-surface-low);
+ box-shadow: var(--bs-surface-border);
+ border-radius: var(--bo-radius);
+
+ & > .ic-raw {
+ transform: scale(0.8);
+ margin-left: var(--sp-extra-tight);
+ [dir=rtl] & {
+ margin-left: 0;
+ margin-right: var(--sp-extra-tight);
+ }
+ }
+ & .scrollbar {
+ max-height: 50vh;
+ }
+ }
+
+ &__textarea-wrapper {
+ min-height: 40px;
+ display: flex;
+ align-items: center;
+
+ & textarea {
+ resize: none;
+ width: 100%;
+ min-width: 0;
+ min-height: 100%;
+ padding: var(--sp-ultra-tight) calc(var(--sp-tight) - 2px);
+
+ &::placeholder {
+ color: var(--tc-surface-low);
+ }
+ &:focus {
+ outline: none;
+ }
+ }
+ }
+}
+
+.channel-attachment {
+ --side-spacing: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
+ display: flex;
+ align-items: center;
+ margin-left: var(--side-spacing);
+ margin-top: var(--sp-extra-tight);
+ line-height: 0;
+ [dir=rtl] & {
+ margin-left: 0;
+ margin-right: var(--side-spacing);
+ }
+
+ &__preview > img {
+ max-height: 40px;
+ border-radius: var(--bo-radius);
+ }
+ &__icon {
+ padding: var(--sp-extra-tight);
+ background-color: var(--bg-surface-low);
+ box-shadow: var(--bs-surface-border);
+ border-radius: var(--bo-radius);
+ }
+ &__info {
+ flex: 1;
+ min-width: 0;
+ margin: 0 var(--sp-tight);
+ }
+
+ &__option button {
+ transition: transform 200ms ease-in-out;
+ transform: translateY(-48px);
+ & .ic-raw {
+ transition: transform 200ms ease-in-out;
+ transform: rotate(45deg);
+ background-color: var(--bg-caution);
+ }
+ }
+}
\ No newline at end of file
--- /dev/null
+import React from 'react';
+
+import { getUsername } from '../../../util/matrixUtil';
+
+function getTimelineJSXMessages() {
+ return {
+ join(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' joined the channel'}
+ </>
+ );
+ },
+ leave(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' left the channel'}
+ </>
+ );
+ },
+ invite(inviter, user) {
+ return (
+ <>
+ <b>{inviter}</b>
+ {' invited '}
+ <b>{user}</b>
+ </>
+ );
+ },
+ cancelInvite(inviter, user) {
+ return (
+ <>
+ <b>{inviter}</b>
+ {' canceled '}
+ <b>{user}</b>
+ {'\'s invite'}
+ </>
+ );
+ },
+ rejectInvite(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' rejected the invitation'}
+ </>
+ );
+ },
+ kick(actor, user, reason) {
+ const reasonMsg = (typeof reason === 'string') ? ` for ${reason}` : '';
+ return (
+ <>
+ <b>{actor}</b>
+ {' kicked '}
+ <b>{user}</b>
+ {reasonMsg}
+ </>
+ );
+ },
+ ban(actor, user, reason) {
+ const reasonMsg = (typeof reason === 'string') ? ` for ${reason}` : '';
+ return (
+ <>
+ <b>{actor}</b>
+ {' banned '}
+ <b>{user}</b>
+ {reasonMsg}
+ </>
+ );
+ },
+ unban(actor, user) {
+ return (
+ <>
+ <b>{actor}</b>
+ {' unbanned '}
+ <b>{user}</b>
+ </>
+ );
+ },
+ avatarSets(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' set the avatar'}
+ </>
+ );
+ },
+ avatarChanged(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' changed the avatar'}
+ </>
+ );
+ },
+ avatarRemoved(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' removed the avatar'}
+ </>
+ );
+ },
+ nameSets(user, newName) {
+ return (
+ <>
+ <b>{user}</b>
+ {' set the display name to '}
+ <b>{newName}</b>
+ </>
+ );
+ },
+ nameChanged(user, newName) {
+ return (
+ <>
+ <b>{user}</b>
+ {' changed the display name to '}
+ <b>{newName}</b>
+ </>
+ );
+ },
+ nameRemoved(user, lastName) {
+ return (
+ <>
+ <b>{user}</b>
+ {' removed the display name '}
+ <b>{lastName}</b>
+ </>
+ );
+ },
+ };
+}
+
+function getUsersActionJsx(userIds, actionStr) {
+ const getUserJSX = (username) => <b>{getUsername(username)}</b>;
+ if (!Array.isArray(userIds)) return 'Idle';
+ if (userIds.length === 0) return 'Idle';
+ const MAX_VISIBLE_COUNT = 3;
+
+ const u1Jsx = getUserJSX(userIds[0]);
+ // eslint-disable-next-line react/jsx-one-expression-per-line
+ if (userIds.length === 1) return <>{u1Jsx} is {actionStr}</>;
+
+ const u2Jsx = getUserJSX(userIds[1]);
+ // eslint-disable-next-line react/jsx-one-expression-per-line
+ if (userIds.length === 2) return <>{u1Jsx} and {u2Jsx} are {actionStr}</>;
+
+ const u3Jsx = getUserJSX(userIds[2]);
+ if (userIds.length === 3) {
+ // eslint-disable-next-line react/jsx-one-expression-per-line
+ return <>{u1Jsx}, {u2Jsx} and {u3Jsx} are {actionStr}</>;
+ }
+
+ const othersCount = userIds.length - MAX_VISIBLE_COUNT;
+ // eslint-disable-next-line react/jsx-one-expression-per-line
+ return <>{u1Jsx}, {u2Jsx}, {u3Jsx} and {othersCount} other are {actionStr}</>;
+}
+
+function parseReply(rawContent) {
+ if (rawContent.indexOf('>') !== 0) return null;
+ let content = rawContent.slice(rawContent.indexOf('@'));
+ const userId = content.slice(0, content.indexOf('>'));
+
+ content = content.slice(content.indexOf('>') + 2);
+ const replyContent = content.slice(0, content.indexOf('\n\n'));
+ content = content.slice(content.indexOf('\n\n') + 2);
+
+ if (userId === '') return null;
+
+ return {
+ userId,
+ replyContent,
+ content,
+ };
+}
+
+function parseTimelineChange(mEvent) {
+ const tJSXMsgs = getTimelineJSXMessages();
+ const makeReturnObj = (variant, content) => ({
+ variant,
+ content,
+ });
+ const content = mEvent.getContent();
+ const prevContent = mEvent.getPrevContent();
+ const sender = mEvent.getSender();
+ const senderName = getUsername(sender);
+ const userName = getUsername(mEvent.getStateKey());
+
+ switch (content.membership) {
+ case 'invite': return makeReturnObj('invite', tJSXMsgs.invite(senderName, userName));
+ case 'ban': return makeReturnObj('leave', tJSXMsgs.ban(senderName, userName, content.reason));
+ case 'join':
+ if (prevContent.membership === 'join') {
+ if (content.displayname !== prevContent.displayname) {
+ if (typeof content.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameRemoved(sender, prevContent.displayname));
+ if (typeof prevContent.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameSets(sender, content.displayname));
+ return makeReturnObj('avatar', tJSXMsgs.nameChanged(prevContent.displayname, content.displayname));
+ }
+ if (content.avatar_url !== prevContent.avatar_url) {
+ if (typeof content.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarRemoved(content.displayname));
+ if (typeof prevContent.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarSets(content.displayname));
+ return makeReturnObj('avatar', tJSXMsgs.avatarChanged(content.displayname));
+ }
+ return null;
+ }
+ return makeReturnObj('join', tJSXMsgs.join(senderName));
+ case 'leave':
+ if (sender === mEvent.getStateKey()) {
+ switch (prevContent.membership) {
+ case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.rejectInvite(senderName));
+ default: return makeReturnObj('leave', tJSXMsgs.leave(senderName));
+ }
+ }
+ switch (prevContent.membership) {
+ case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.cancelInvite(senderName, userName));
+ case 'ban': return makeReturnObj('other', tJSXMsgs.unban(senderName, userName));
+ // sender is not target and made the target leave,
+ // if not from invite/ban then this is a kick
+ default: return makeReturnObj('leave', tJSXMsgs.kick(senderName, userName, content.reason));
+ }
+ default: return null;
+ }
+}
+
+function scrollToBottom(ref) {
+ const maxScrollTop = ref.current.scrollHeight - ref.current.offsetHeight;
+ // eslint-disable-next-line no-param-reassign
+ ref.current.scrollTop = maxScrollTop;
+}
+
+function isAtBottom(ref) {
+ const { scrollHeight, scrollTop, offsetHeight } = ref.current;
+ const scrollUptoBottom = scrollTop + offsetHeight;
+
+ // scroll view have to div inside div which contains messages
+ const lastMessage = ref.current.lastElementChild.lastElementChild.lastElementChild;
+ const lastChildHeight = lastMessage.offsetHeight;
+
+ // auto scroll to bottom even if user has EXTRA_SPACE left to scroll
+ const EXTRA_SPACE = 48;
+
+ if (scrollHeight - scrollUptoBottom <= lastChildHeight + EXTRA_SPACE) {
+ return true;
+ }
+ return false;
+}
+
+function autoScrollToBottom(ref) {
+ if (isAtBottom(ref)) scrollToBottom(ref);
+}
+
+export {
+ getTimelineJSXMessages,
+ getUsersActionJsx,
+ parseReply,
+ parseTimelineChange,
+ scrollToBottom,
+ isAtBottom,
+ autoScrollToBottom,
+};