diff --git a/src/app/organisms/channel/ChannelView.jsx b/src/app/organisms/channel/ChannelView.jsx index 4d77f48..07b9bf1 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 ( - <> - {user} - {' joined the channel'} - - ); - }, - leave(user) { - return ( - <> - {user} - {' left the channel'} - - ); - }, - invite(inviter, user) { - return ( - <> - {inviter} - {' invited '} - {user} - - ); - }, - cancelInvite(inviter, user) { - return ( - <> - {inviter} - {' canceled '} - {user} - {'\'s invite'} - - ); - }, - rejectInvite(user) { - return ( - <> - {user} - {' rejected the invitation'} - - ); - }, - kick(actor, user, reason) { - const reasonMsg = (typeof reason === 'string') ? ` for ${reason}` : ''; - return ( - <> - {actor} - {' kicked '} - {user} - {reasonMsg} - - ); - }, - ban(actor, user, reason) { - const reasonMsg = (typeof reason === 'string') ? ` for ${reason}` : ''; - return ( - <> - {actor} - {' banned '} - {user} - {reasonMsg} - - ); - }, - unban(actor, user) { - return ( - <> - {actor} - {' unbanned '} - {user} - - ); - }, - avatarSets(user) { - return ( - <> - {user} - {' set the avatar'} - - ); - }, - avatarChanged(user) { - return ( - <> - {user} - {' changed the avatar'} - - ); - }, - avatarRemoved(user) { - return ( - <> - {user} - {' removed the avatar'} - - ); - }, - nameSets(user, newName) { - return ( - <> - {user} - {' set the display name to '} - {newName} - - ); - }, - nameChanged(user, newName) { - return ( - <> - {user} - {' changed the display name to '} - {newName} - - ); - }, - nameRemoved(user, lastName) { - return ( - <> - {user} - {' removed the display name '} - {lastName} - - ); - }, - }; -} - -function getUsersActionJsx(userIds, actionStr) { - const getUserJSX = (username) => {getUsername(username)}; - 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 ( -
- - - {roomName} - { typeof roomTopic !== 'undefined' &&

{roomTopic}

} -
- - ( - <> - Options - {/* */} - { - openInviteUser(roomId); toogleMenu(); - }} - > - Invite - - roomActions.leave(roomId, isDM)}>Leave - - )} - render={(toggleMenu) => } - /> -
- ); -} -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 ( - - ); - case 'm.image': - return ( - - ); - case 'm.audio': - return ( - - ); - case 'm.video': - if (typeof thumbnailMXC === 'undefined') { - thumbnailMXC = mContent.info?.thumbnail_file?.url || null; - } - return ( - - ); - 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 ( - - ); - } - 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 = ; - } - - 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 = ( - - {divider} - { isMedia(mEvent) ? ( - - ) : ( - - )} - - ); - - prevMEvent = mEvent; - return myMessageEl; - } - prevMEvent = mEvent; - const timelineChange = parseTimelineChange(mEvent); - if (timelineChange === null) return null; - return ( - - {divider} - - - ); - } - - const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic; - return ( -
-
- { - roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && ( - <> - - - - - ) - } - { - roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && ( - - ) - } - { roomTimeline.timeline.map(renderMessage) } -
-
- ); -} -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 ( - <> -
-
- {getTypingMessage(typingMembers)} -
-
- { - timelineScroll.enableSmoothScroll(); - timelineScroll.reachBottom(); - timelineScroll.disableSmoothScroll(); - }} - src={ChevronBottomIC} - tooltip="Scroll to Bottom" - /> -
- - ); -} -FloatingOptions.propTypes = { - roomId: PropTypes.string.isRequired, - roomTimeline: PropTypes.shape({}).isRequired, - timelineScroll: PropTypes.shape({ - reachBottom: PropTypes.func, - }).isRequired, -}; - -function ChannelViewSticky({ children }) { - return
{children}
; -} -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 ( - <> -
- - -
-
- {roomTimeline.isEncryptedRoom() && } - - - timelineScroll.autoReachBottom()} - onKeyDown={handleKeyDown} - placeholder="Send a message..." - /> - - -
-
- - )} - render={(toggleMenu) => } - /> - -
- - ); - } - - function attachFile() { - const fileType = attachment.type.slice(0, attachment.type.indexOf('/')); - return ( -
-
- {fileType === 'image' && {attachment.name}} - {fileType === 'video' && } - {fileType === 'audio' && } - {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && } -
-
- {attachment.name} - {`size: ${bytesToSize(attachment.size)}`} -
-
- ); - } - - return ( - <> - { attachment !== null && attachFile() } -
{ e.preventDefault(); }}> - { - roomTimeline.room.isSpaceRoom() - ? Spaces are yet to be implemented - : renderInputs() - } -
- - ); -} -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 ( -
- { - followingMembers.length !== 0 && ( - - ) - } -
- ); -} -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} /> )} {roomTimeline !== null && ( - )}
{roomTimeline !== null && ( - - + - - + )} diff --git a/src/app/organisms/channel/ChannelView.scss b/src/app/organisms/channel/ChannelView.scss index 9163e61..a50a9ae 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 0000000..b7006c8 --- /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 ( +
+ { + followingMembers.length !== 0 && ( + + ) + } +
+ ); +} +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 0000000..7c14f74 --- /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 0000000..2fdf1e2 --- /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 ( + + ); + case 'm.image': + return ( + + ); + case 'm.audio': + return ( + + ); + case 'm.video': + if (typeof thumbnailMXC === 'undefined') { + thumbnailMXC = mContent.info?.thumbnail_file?.url || null; + } + return ( + + ); + 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 ( + + ); + } + 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 = ; + } + + 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 = ( + + {divider} + { isMedia(mEvent) ? ( + + ) : ( + + )} + + ); + + prevMEvent = mEvent; + return myMessageEl; + } + prevMEvent = mEvent; + const timelineChange = parseTimelineChange(mEvent); + if (timelineChange === null) return null; + return ( + + {divider} + + + ); + } + + const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic; + return ( +
+
+ { + roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && ( + <> + + + + + ) + } + { + roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && ( + + ) + } + { roomTimeline.timeline.map(renderMessage) } +
+
+ ); +} +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 0000000..f270233 --- /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 0000000..a73327b --- /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 ( + <> +
+
+ {getTypingMessage(typingMembers)} +
+
+ { + timelineScroll.enableSmoothScroll(); + timelineScroll.reachBottom(); + timelineScroll.disableSmoothScroll(); + }} + src={ChevronBottomIC} + tooltip="Scroll to Bottom" + /> +
+ + ); +} +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 0000000..3c1593c --- /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 0000000..a9d4551 --- /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 ( +
+ + + {roomName} + { typeof roomTopic !== 'undefined' &&

{roomTopic}

} +
+ + ( + <> + Options + {/* */} + { + openInviteUser(roomId); toogleMenu(); + }} + > + Invite + + roomActions.leave(roomId, isDM)}>Leave + + )} + render={(toggleMenu) => } + /> +
+ ); +} +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 0000000..67b002f --- /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 ( + <> +
+ + +
+
+ {roomTimeline.isEncryptedRoom() && } + + + timelineScroll.autoReachBottom()} + onKeyDown={handleKeyDown} + placeholder="Send a message..." + /> + + +
+
+ + )} + render={(toggleMenu) => } + /> + +
+ + ); + } + + function attachFile() { + const fileType = attachment.type.slice(0, attachment.type.indexOf('/')); + return ( +
+
+ {fileType === 'image' && {attachment.name}} + {fileType === 'video' && } + {fileType === 'audio' && } + {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && } +
+
+ {attachment.name} + {`size: ${bytesToSize(attachment.size)}`} +
+
+ ); + } + + return ( + <> + { attachment !== null && attachFile() } +
{ e.preventDefault(); }}> + { + roomTimeline.room.isSpaceRoom() + ? Spaces are yet to be implemented + : renderInputs() + } +
+ + ); +} +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 0000000..c5565e5 --- /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 0000000..5749872 --- /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 ( + <> + {user} + {' joined the channel'} + + ); + }, + leave(user) { + return ( + <> + {user} + {' left the channel'} + + ); + }, + invite(inviter, user) { + return ( + <> + {inviter} + {' invited '} + {user} + + ); + }, + cancelInvite(inviter, user) { + return ( + <> + {inviter} + {' canceled '} + {user} + {'\'s invite'} + + ); + }, + rejectInvite(user) { + return ( + <> + {user} + {' rejected the invitation'} + + ); + }, + kick(actor, user, reason) { + const reasonMsg = (typeof reason === 'string') ? ` for ${reason}` : ''; + return ( + <> + {actor} + {' kicked '} + {user} + {reasonMsg} + + ); + }, + ban(actor, user, reason) { + const reasonMsg = (typeof reason === 'string') ? ` for ${reason}` : ''; + return ( + <> + {actor} + {' banned '} + {user} + {reasonMsg} + + ); + }, + unban(actor, user) { + return ( + <> + {actor} + {' unbanned '} + {user} + + ); + }, + avatarSets(user) { + return ( + <> + {user} + {' set the avatar'} + + ); + }, + avatarChanged(user) { + return ( + <> + {user} + {' changed the avatar'} + + ); + }, + avatarRemoved(user) { + return ( + <> + {user} + {' removed the avatar'} + + ); + }, + nameSets(user, newName) { + return ( + <> + {user} + {' set the display name to '} + {newName} + + ); + }, + nameChanged(user, newName) { + return ( + <> + {user} + {' changed the display name to '} + {newName} + + ); + }, + nameRemoved(user, lastName) { + return ( + <> + {user} + {' removed the display name '} + {lastName} + + ); + }, + }; +} + +function getUsersActionJsx(userIds, actionStr) { + const getUserJSX = (username) => {getUsername(username)}; + 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, +};