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
- {/* */}
-
-
- >
- )}
- 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' &&
}
- {fileType === 'video' &&
}
- {fileType === 'audio' &&
}
- {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' &&
}
-
-
- {attachment.name}
- {`size: ${bytesToSize(attachment.size)}`}
-
-
- );
- }
-
- return (
- <>
- { attachment !== null && attachFile() }
-
- >
- );
-}
-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
+ {/* */}
+
+
+ >
+ )}
+ 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' &&
}
+ {fileType === 'video' &&
}
+ {fileType === 'audio' &&
}
+ {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' &&
}
+
+
+ {attachment.name}
+ {`size: ${bytesToSize(attachment.size)}`}
+
+
+ );
+ }
+
+ return (
+ <>
+ { attachment !== null && attachFile() }
+
+ >
+ );
+}
+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,
+};