diff --git a/src/app/organisms/channel/ChannelView.jsx b/src/app/organisms/channel/ChannelView.jsx index 4d77f48a..07b9bf1c 100644 --- a/src/app/organisms/channel/ChannelView.jsx +++ b/src/app/organisms/channel/ChannelView.jsx @@ -1,1019 +1,23 @@ -/* 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'; + +import { scrollToBottom, isAtBottom, autoScrollToBottom } from './common'; -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); - - 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, -}; - let lastScrollTop = 0; let lastScrollHeight = 0; let isReachedBottom = true; @@ -1107,29 +111,33 @@ function ChannelView({ roomId }) { 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> diff --git a/src/app/organisms/channel/ChannelView.scss b/src/app/organisms/channel/ChannelView.scss index 9163e619..a50a9ae3 100644 --- a/src/app/organisms/channel/ChannelView.scss +++ b/src/app/organisms/channel/ChannelView.scss @@ -21,103 +21,6 @@ @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; @@ -125,124 +28,4 @@ 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 diff --git a/src/app/organisms/channel/ChannelViewCmdBar.jsx b/src/app/organisms/channel/ChannelViewCmdBar.jsx new file mode 100644 index 00000000..b7006c8c --- /dev/null +++ b/src/app/organisms/channel/ChannelViewCmdBar.jsx @@ -0,0 +1,62 @@ +/* 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; diff --git a/src/app/organisms/channel/ChannelViewCmdBar.scss b/src/app/organisms/channel/ChannelViewCmdBar.scss new file mode 100644 index 00000000..7c14f74e --- /dev/null +++ b/src/app/organisms/channel/ChannelViewCmdBar.scss @@ -0,0 +1,22 @@ +.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 diff --git a/src/app/organisms/channel/ChannelViewContent.jsx b/src/app/organisms/channel/ChannelViewContent.jsx new file mode 100644 index 00000000..2fdf1e24 --- /dev/null +++ b/src/app/organisms/channel/ChannelViewContent.jsx @@ -0,0 +1,377 @@ +/* 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; diff --git a/src/app/organisms/channel/ChannelViewContent.scss b/src/app/organisms/channel/ChannelViewContent.scss new file mode 100644 index 00000000..f2702332 --- /dev/null +++ b/src/app/organisms/channel/ChannelViewContent.scss @@ -0,0 +1,13 @@ +.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 diff --git a/src/app/organisms/channel/ChannelViewFloating.jsx b/src/app/organisms/channel/ChannelViewFloating.jsx new file mode 100644 index 00000000..a73327b5 --- /dev/null +++ b/src/app/organisms/channel/ChannelViewFloating.jsx @@ -0,0 +1,83 @@ +/* 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; diff --git a/src/app/organisms/channel/ChannelViewFloating.scss b/src/app/organisms/channel/ChannelViewFloating.scss new file mode 100644 index 00000000..3c1593c2 --- /dev/null +++ b/src/app/organisms/channel/ChannelViewFloating.scss @@ -0,0 +1,84 @@ +.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 diff --git a/src/app/organisms/channel/ChannelViewHeader.jsx b/src/app/organisms/channel/ChannelViewHeader.jsx new file mode 100644 index 00000000..a9d45516 --- /dev/null +++ b/src/app/organisms/channel/ChannelViewHeader.jsx @@ -0,0 +1,61 @@ +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; diff --git a/src/app/organisms/channel/ChannelViewInput.jsx b/src/app/organisms/channel/ChannelViewInput.jsx new file mode 100644 index 00000000..67b002f4 --- /dev/null +++ b/src/app/organisms/channel/ChannelViewInput.jsx @@ -0,0 +1,234 @@ +/* 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; diff --git a/src/app/organisms/channel/ChannelViewInput.scss b/src/app/organisms/channel/ChannelViewInput.scss new file mode 100644 index 00000000..c5565e5e --- /dev/null +++ b/src/app/organisms/channel/ChannelViewInput.scss @@ -0,0 +1,96 @@ +.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 diff --git a/src/app/organisms/channel/common.jsx b/src/app/organisms/channel/common.jsx new file mode 100644 index 00000000..57498727 --- /dev/null +++ b/src/app/organisms/channel/common.jsx @@ -0,0 +1,261 @@ +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, +};