--- /dev/null
+/* eslint-disable import/prefer-default-export */
+import { useState } from 'react';
+
+export function useForceUpdate() {
+ const [, setData] = useState(null);
+
+ return () => setData({});
+}
--- /dev/null
+/* eslint-disable import/prefer-default-export */
+import { useEffect, useRef } from 'react';
+
+export function useStore(...args) {
+ const itemRef = useRef(null);
+
+ const getItem = () => itemRef.current;
+
+ const setItem = (event) => {
+ itemRef.current = event;
+ return itemRef.current;
+ };
+
+ useEffect(() => {
+ itemRef.current = null;
+ return () => {
+ itemRef.current = null;
+ };
+ }, args);
+
+ return { getItem, setItem };
+}
const [isInvited, setIsInvited] = useState(member?.membership === 'invite');
const myPowerlevel = room.getMember(mx.getUserId()).powerLevel;
- const userPL = room.getMember(userId).powerLevel || 0;
+ const userPL = room.getMember(userId)?.powerLevel || 0;
const canIKick = room.currentState.hasSufficientPowerLevelFor('kick', myPowerlevel) && userPL < myPowerlevel;
const onCreated = (dmRoomId) => {
function ReadReceipts() {
const [isOpen, setIsOpen] = useState(false);
+ const [readers, setReaders] = useState([]);
const [roomId, setRoomId] = useState(null);
- const [readReceipts, setReadReceipts] = useState([]);
-
- function loadReadReceipts(myRoomId, eventId) {
- const mx = initMatrix.matrixClient;
- const room = mx.getRoom(myRoomId);
- const { timeline } = room;
- const myReadReceipts = [];
-
- const myEventIndex = timeline.findIndex((mEvent) => mEvent.getId() === eventId);
-
- for (let eventIndex = myEventIndex; eventIndex < timeline.length; eventIndex += 1) {
- myReadReceipts.push(...room.getReceiptsForEvent(timeline[eventIndex]));
- }
-
- setReadReceipts(myReadReceipts);
- setRoomId(myRoomId);
- setIsOpen(true);
- }
useEffect(() => {
+ const loadReadReceipts = (rId, userIds) => {
+ setReaders(userIds);
+ setRoomId(rId);
+ setIsOpen(true);
+ };
navigation.on(cons.events.navigation.READRECEIPTS_OPENED, loadReadReceipts);
return () => {
navigation.removeListener(cons.events.navigation.READRECEIPTS_OPENED, loadReadReceipts);
useEffect(() => {
if (isOpen === false) {
+ setReaders([]);
setRoomId(null);
- setReadReceipts([]);
}
}, [isOpen]);
- function renderPeople(receipt) {
+ function renderPeople(userId) {
const room = initMatrix.matrixClient.getRoom(roomId);
- const member = room.getMember(receipt.userId);
- const getUserDisplayName = (userId) => {
+ const member = room.getMember(userId);
+ const getUserDisplayName = () => {
if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId));
return getUsername(userId);
};
return (
<PeopleSelector
- key={receipt.userId}
+ key={userId}
onClick={() => {
setIsOpen(false);
- openProfileViewer(receipt.userId, roomId);
+ openProfileViewer(userId, roomId);
}}
avatarSrc={member?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')}
- name={getUserDisplayName(receipt.userId)}
- color={colorMXID(receipt.userId)}
+ name={getUserDisplayName(userId)}
+ color={colorMXID(userId)}
/>
);
}
contentOptions={<IconButton src={CrossIC} onClick={() => setIsOpen(false)} tooltip="Close" />}
>
{
- readReceipts.map(renderPeople)
+ readers.map(renderPeople)
}
</Dialog>
);
import React, { useState, useEffect } from 'react';
import './Room.scss';
+import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import settings from '../../../client/state/settings';
+import RoomTimeline from '../../../client/state/RoomTimeline';
import Welcome from '../welcome/Welcome';
import RoomView from './RoomView';
import PeopleDrawer from './PeopleDrawer';
function Room() {
- const [selectedRoomId, changeSelectedRoomId] = useState(null);
- const [isDrawerVisible, toggleDrawerVisiblity] = useState(settings.isPeopleDrawer);
+ const [roomTimeline, setRoomTimeline] = useState(null);
+ const [eventId, setEventId] = useState(null);
+ const [isDrawer, setIsDrawer] = useState(settings.isPeopleDrawer);
+
+ const mx = initMatrix.matrixClient;
+ const handleRoomSelected = (rId, pRoomId, eId) => {
+ if (mx.getRoom(rId)) {
+ setRoomTimeline(new RoomTimeline(rId));
+ setEventId(eId);
+ } else {
+ // TODO: add ability to join room if roomId is invalid
+ setRoomTimeline(null);
+ setEventId(null);
+ }
+ };
+ const handleDrawerToggling = (visiblity) => setIsDrawer(visiblity);
+
useEffect(() => {
- const handleRoomSelected = (roomId) => {
- changeSelectedRoomId(roomId);
- };
- const handleDrawerToggling = (visiblity) => {
- toggleDrawerVisiblity(visiblity);
- };
navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
settings.on(cons.events.settings.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
-
return () => {
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
settings.removeListener(cons.events.settings.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
+ roomTimeline?.removeInternalListeners();
};
}, []);
- if (selectedRoomId === null) return <Welcome />;
+ if (roomTimeline === null) return <Welcome />;
return (
<div className="room-container">
- <RoomView roomId={selectedRoomId} />
- { isDrawerVisible && <PeopleDrawer roomId={selectedRoomId} />}
+ <RoomView roomTimeline={roomTimeline} eventId={eventId} />
+ { isDrawer && <PeopleDrawer roomId={roomTimeline.roomId} />}
</div>
);
}
-import React, { useState, useEffect, useRef } from 'react';
+import React from 'react';
import PropTypes from 'prop-types';
import './RoomView.scss';
import EventEmitter from 'events';
-import RoomTimeline from '../../../client/state/RoomTimeline';
-import { Debounce, getScrollInfo } from '../../../util/common';
-
-import ScrollView from '../../atoms/scroll/ScrollView';
-
import RoomViewHeader from './RoomViewHeader';
import RoomViewContent from './RoomViewContent';
import RoomViewFloating from './RoomViewFloating';
const viewEvent = new EventEmitter();
-function RoomView({ roomId }) {
- const [roomTimeline, updateRoomTimeline] = useState(null);
- const [debounce] = useState(new Debounce());
- const timelineSVRef = useRef(null);
-
- useEffect(() => {
- roomTimeline?.removeInternalListeners();
- updateRoomTimeline(new RoomTimeline(roomId));
- }, [roomId]);
-
- const timelineScroll = {
- reachBottom() {
- timelineScroll.isOngoing = true;
- const target = timelineSVRef?.current;
- if (!target) return;
- const maxScrollTop = target.scrollHeight - target.offsetHeight;
- target.scrollTop = maxScrollTop;
- timelineScroll.position = 'BOTTOM';
- timelineScroll.isScrollable = maxScrollTop > 0;
- timelineScroll.isInTopHalf = false;
- timelineScroll.lastTopMsg = null;
- timelineScroll.lastBottomMsg = null;
- },
- autoReachBottom() {
- if (timelineScroll.position === 'BOTTOM') timelineScroll.reachBottom();
- },
- tryRestoringScroll() {
- timelineScroll.isOngoing = true;
- const sv = timelineSVRef.current;
- const {
- lastTopMsg, lastBottomMsg,
- diff, isInTopHalf, lastTop,
- } = timelineScroll;
-
- if (lastTopMsg === null) {
- sv.scrollTop = sv.scrollHeight;
- return;
- }
-
- const ot = isInTopHalf ? lastTopMsg?.offsetTop : lastBottomMsg?.offsetTop;
- if (!ot) sv.scrollTop = lastTop;
- else sv.scrollTop = ot - diff;
- },
- position: 'BOTTOM',
- isScrollable: false,
- isInTopHalf: false,
- maxEvents: 50,
- lastTop: 0,
- lastHeight: 0,
- lastViewHeight: 0,
- lastTopMsg: null,
- lastBottomMsg: null,
- diff: 0,
- isOngoing: false,
- };
-
- const calcScroll = (target) => {
- if (timelineScroll.isOngoing) {
- timelineScroll.isOngoing = false;
- return;
- }
- const PLACEHOLDER_COUNT = 2;
- const PLACEHOLDER_HEIGHT = 96 * PLACEHOLDER_COUNT;
- const SMALLEST_MSG_HEIGHT = 32;
- const scroll = getScrollInfo(target);
-
- const isPaginateBack = scroll.top < PLACEHOLDER_HEIGHT;
- const isPaginateForward = scroll.bottom > (scroll.height - PLACEHOLDER_HEIGHT);
- timelineScroll.isInTopHalf = scroll.top + (scroll.viewHeight / 2) < scroll.height / 2;
-
- if (timelineScroll.lastViewHeight !== scroll.viewHeight) {
- timelineScroll.maxEvents = Math.round(scroll.viewHeight / SMALLEST_MSG_HEIGHT) * 3;
- timelineScroll.lastViewHeight = scroll.viewHeight;
- }
- timelineScroll.isScrollable = scroll.isScrollable;
- timelineScroll.lastTop = scroll.top;
- timelineScroll.lastHeight = scroll.height;
- const tChildren = target.lastElementChild.lastElementChild.children;
- const lCIndex = tChildren.length - 1;
-
- timelineScroll.lastTopMsg = tChildren[0]?.className === 'ph-msg'
- ? tChildren[PLACEHOLDER_COUNT]
- : tChildren[0];
- timelineScroll.lastBottomMsg = tChildren[lCIndex]?.className === 'ph-msg'
- ? tChildren[lCIndex - PLACEHOLDER_COUNT]
- : tChildren[lCIndex];
-
- if (timelineScroll.isInTopHalf && timelineScroll.lastBottomMsg) {
- timelineScroll.diff = timelineScroll.lastTopMsg.offsetTop - scroll.top;
- } else {
- timelineScroll.diff = timelineScroll.lastBottomMsg.offsetTop - scroll.top;
- }
-
- if (isPaginateBack) {
- timelineScroll.position = 'TOP';
- viewEvent.emit('timeline-scroll', timelineScroll.position);
- } else if (isPaginateForward) {
- timelineScroll.position = 'BOTTOM';
- viewEvent.emit('timeline-scroll', timelineScroll.position);
- } else {
- timelineScroll.position = 'BETWEEN';
- viewEvent.emit('timeline-scroll', timelineScroll.position);
- }
- };
-
- const handleTimelineScroll = (event) => {
- const { target } = event;
- if (!target) return;
- debounce._(calcScroll, 200)(target);
- };
+function RoomView({ roomTimeline, eventId }) {
+ // eslint-disable-next-line react/prop-types
+ const { roomId } = roomTimeline;
+ console.log('----roomId changed');
return (
<div className="room-view">
<RoomViewHeader roomId={roomId} />
<div className="room-view__content-wrapper">
<div className="room-view__scrollable">
- <ScrollView onScroll={handleTimelineScroll} ref={timelineSVRef} autoHide>
- {roomTimeline !== null && (
- <RoomViewContent
- roomId={roomId}
- roomTimeline={roomTimeline}
- timelineScroll={timelineScroll}
- viewEvent={viewEvent}
- />
- )}
- </ScrollView>
- {roomTimeline !== null && (
- <RoomViewFloating
- roomId={roomId}
- roomTimeline={roomTimeline}
- viewEvent={viewEvent}
- />
- )}
+ <RoomViewContent
+ eventId={eventId}
+ roomTimeline={roomTimeline}
+ viewEvent={viewEvent}
+ />
+ <RoomViewFloating
+ roomId={roomId}
+ roomTimeline={roomTimeline}
+ viewEvent={viewEvent}
+ />
+ </div>
+ <div className="room-view__sticky">
+ <RoomViewInput
+ roomId={roomId}
+ roomTimeline={roomTimeline}
+ viewEvent={viewEvent}
+ />
+ <RoomViewCmdBar
+ roomId={roomId}
+ roomTimeline={roomTimeline}
+ viewEvent={viewEvent}
+ />
</div>
- {roomTimeline !== null && (
- <div className="room-view__sticky">
- <RoomViewInput
- roomId={roomId}
- roomTimeline={roomTimeline}
- timelineScroll={timelineScroll}
- viewEvent={viewEvent}
- />
- <RoomViewCmdBar
- roomId={roomId}
- roomTimeline={roomTimeline}
- viewEvent={viewEvent}
- />
- </div>
- )}
</div>
</div>
);
}
+
+RoomView.defaultProps = {
+ eventId: null,
+};
RoomView.propTypes = {
- roomId: PropTypes.string.isRequired,
+ roomTimeline: PropTypes.shape({}).isRequired,
+ eventId: PropTypes.string,
};
export default RoomView;
const [followingMembers, setFollowingMembers] = useState([]);
const mx = initMatrix.matrixClient;
- function handleOnMessageSent() {
- setFollowingMembers([]);
- }
+ const handleOnMessageSent = () => setFollowingMembers([]);
- function updateFollowingMembers() {
- const room = mx.getRoom(roomId);
- const { timeline } = room;
- const userIds = room.getUsersReadUpTo(timeline[timeline.length - 1]);
+ const updateFollowingMembers = () => {
const myUserId = mx.getUserId();
- setFollowingMembers(userIds.filter((userId) => userId !== myUserId));
- }
-
- useEffect(() => updateFollowingMembers(), [roomId]);
+ setFollowingMembers(roomTimeline.getLiveReaders().filter((userId) => userId !== myUserId));
+ };
useEffect(() => {
- roomTimeline.on(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
+ updateFollowingMembers();
+ roomTimeline.on(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
viewEvent.on('message_sent', handleOnMessageSent);
return () => {
- roomTimeline.removeListener(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
+ roomTimeline.removeListener(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
viewEvent.removeListener('message_sent', handleOnMessageSent);
};
}, [roomTimeline]);
- const { timeline } = roomTimeline.room;
- const lastMEvent = timeline[timeline.length - 1];
return followingMembers.length !== 0 && (
<TimelineChange
variant="follow"
content={getUsersActionJsx(roomId, followingMembers, 'following the conversation.')}
time=""
- onClick={() => openReadReceipts(roomId, lastMEvent.getId())}
+ onClick={() => openReadReceipts(roomId, followingMembers)}
/>
);
}
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable react/prop-types */
-import React, { useState, useEffect, useLayoutEffect } from 'react';
+import React, {
+ useState, useEffect, useLayoutEffect, useCallback, useRef,
+} from 'react';
import PropTypes from 'prop-types';
import './RoomViewContent.scss';
+import EventEmitter from 'events';
import dateFormat from 'dateformat';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
-import { diffMinutes, isNotInSameDay } from '../../../util/common';
+import navigation from '../../../client/state/navigation';
import { openProfileViewer } from '../../../client/action/navigation';
+import {
+ diffMinutes, isNotInSameDay, Throttle, getScrollInfo,
+} from '../../../util/common';
import Divider from '../../atoms/divider/Divider';
+import ScrollView from '../../atoms/scroll/ScrollView';
import { Message, PlaceholderMessage } from '../../molecules/message/Message';
import RoomIntro from '../../molecules/room-intro/RoomIntro';
import TimelineChange from '../../molecules/message/TimelineChange';
+import { useStore } from '../../hooks/useStore';
import { parseTimelineChange } from './common';
const MAX_MSG_DIFF_MINUTES = 5;
+const PLACEHOLDER_COUNT = 2;
+const PLACEHOLDERS_HEIGHT = 96 * PLACEHOLDER_COUNT;
+const SCROLL_TRIGGER_POS = PLACEHOLDERS_HEIGHT * 4;
+
+const SMALLEST_MSG_HEIGHT = 32;
+const PAGES_COUNT = 4;
+
+function loadingMsgPlaceholders(key, count = 2) {
+ const pl = [];
+ const genPlaceholders = () => {
+ for (let i = 0; i < count; i += 1) {
+ pl.push(<PlaceholderMessage key={`placeholder-${i}${key}`} />);
+ }
+ return pl;
+ };
-function genPlaceholders(key) {
return (
<React.Fragment key={`placeholder-container${key}`}>
- <PlaceholderMessage key={`placeholder-1${key}`} />
- <PlaceholderMessage key={`placeholder-2${key}`} />
+ {genPlaceholders()}
</React.Fragment>
);
}
);
}
-const scroll = {
- from: 0,
- limit: 0,
- getEndIndex() {
- return (this.from + this.limit);
- },
- isNewEvent: false,
-};
-function RoomViewContent({
- roomId, roomTimeline, timelineScroll, viewEvent,
-}) {
- const [isReachedTimelineEnd, setIsReachedTimelineEnd] = useState(false);
- const [onStateUpdate, updateState] = useState(null);
+function handleOnClickCapture(e) {
+ const { target } = e;
+ const userId = target.getAttribute('data-mx-pill');
+ if (!userId) return;
- const mx = initMatrix.matrixClient;
- const noti = initMatrix.notifications;
+ const roomId = navigation.selectedRoomId;
+ openProfileViewer(userId, roomId);
+}
- if (scroll.limit === 0) {
- const from = roomTimeline.timeline.size - timelineScroll.maxEvents;
- scroll.from = (from < 0) ? 0 : from;
- scroll.limit = timelineScroll.maxEvents;
- }
+function renderEvent(roomTimeline, mEvent, prevMEvent, isFocus = false) {
+ const isBodyOnly = (prevMEvent !== null && prevMEvent.getType() !== 'm.room.member'
+ && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
+ && prevMEvent.getSender() === mEvent.getSender()
+ );
- function autoLoadTimeline() {
- if (timelineScroll.isScrollable === true) return;
- roomTimeline.paginateBack();
+ if (mEvent.getType() === 'm.room.member') {
+ const timelineChange = parseTimelineChange(mEvent);
+ if (timelineChange === null) return false;
+ return (
+ <TimelineChange
+ key={mEvent.getId()}
+ variant={timelineChange.variant}
+ content={timelineChange.content}
+ time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
+ />
+ );
}
- function trySendingReadReceipt() {
- const { timeline } = roomTimeline.room;
- if (
- (noti.doesRoomHaveUnread(roomTimeline.room) || noti.hasNoti(roomId))
- && timeline.length !== 0) {
- mx.sendReadReceipt(timeline[timeline.length - 1]);
+ return (
+ <Message
+ key={mEvent.getId()}
+ mEvent={mEvent}
+ isBodyOnly={isBodyOnly}
+ roomTimeline={roomTimeline}
+ focus={isFocus}
+ />
+ );
+}
+
+class TimelineScroll extends EventEmitter {
+ constructor(target) {
+ super();
+ if (target === null) {
+ throw new Error('Can not initialize TimelineScroll, target HTMLElement in null');
}
+ this.scroll = target;
+
+ this.backwards = false;
+ this.inTopHalf = false;
+ this.maxEvents = 50;
+
+ this.isScrollable = false;
+ this.top = 0;
+ this.bottom = 0;
+ this.height = 0;
+ this.viewHeight = 0;
+
+ this.topMsg = null;
+ this.bottomMsg = null;
+ this.diff = 0;
}
- const getNewFrom = (position) => {
- let newFrom = scroll.from;
- const tSize = roomTimeline.timeline.size;
- const doPaginate = tSize > timelineScroll.maxEvents;
- if (!doPaginate || scroll.from < 0) newFrom = 0;
- const newEventCount = Math.round(timelineScroll.maxEvents / 2);
- scroll.limit = timelineScroll.maxEvents;
+ scrollToBottom() {
+ const scrollInfo = getScrollInfo(this.scroll);
+ const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight;
- if (position === 'TOP' && doPaginate) newFrom -= newEventCount;
- if (position === 'BOTTOM' && doPaginate) newFrom += newEventCount;
+ this._scrollTo(scrollInfo, maxScrollTop);
+ }
- if (newFrom >= tSize || scroll.getEndIndex() >= tSize) newFrom = tSize - scroll.limit - 1;
- if (newFrom < 0) newFrom = 0;
- return newFrom;
- };
+ // restore scroll using previous calc by this._updateTopBottomMsg() and this._calcDiff.
+ tryRestoringScroll() {
+ const scrollInfo = getScrollInfo(this.scroll);
- const handleTimelineScroll = (position) => {
- const tSize = roomTimeline.timeline.size;
- if (position === 'BETWEEN') return;
- if (position === 'BOTTOM' && scroll.getEndIndex() + 1 === tSize) return;
+ let scrollTop = 0;
+ const ot = this.inTopHalf ? this.topMsg?.offsetTop : this.bottomMsg?.offsetTop;
+ if (!ot) scrollTop = this.top;
+ else scrollTop = ot - this.diff;
- if (scroll.from === 0 && position === 'TOP') {
- // Fetch back history.
- if (roomTimeline.isOngoingPagination || isReachedTimelineEnd) return;
- roomTimeline.paginateBack();
- return;
+ this._scrollTo(scrollInfo, scrollTop);
+ }
+
+ scrollToIndex(index, offset = 0) {
+ const scrollInfo = getScrollInfo(this.scroll);
+ const msgs = this.scroll.lastElementChild.lastElementChild.children;
+ const offsetTop = msgs[index]?.offsetTop;
+
+ if (offsetTop === undefined) return;
+ // if msg is already in visible are we don't need to scroll to that
+ if (offsetTop > scrollInfo.top && offsetTop < (scrollInfo.top + scrollInfo.viewHeight)) return;
+ const to = offsetTop - offset;
+
+ this._scrollTo(scrollInfo, to);
+ }
+
+ _scrollTo(scrollInfo, scrollTop) {
+ this.scroll.scrollTop = scrollTop;
+
+ // browser emit 'onscroll' event only if the 'element.scrollTop' value changes.
+ // so here we flag that the upcoming 'onscroll' event is
+ // emitted as side effect of assigning 'this.scroll.scrollTop' above
+ // only if it's changes.
+ // by doing so we prevent this._updateCalc() from calc again.
+ if (scrollTop !== this.top) {
+ this.scrolledByCode = true;
}
+ const sInfo = { ...scrollInfo };
- scroll.from = getNewFrom(position);
- updateState({});
+ const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight;
+
+ sInfo.top = (scrollTop > maxScrollTop) ? maxScrollTop : scrollTop;
+ this._updateCalc(sInfo);
+ }
- if (scroll.getEndIndex() + 1 >= tSize) {
- trySendingReadReceipt();
+ // we maintain reference of top and bottom messages
+ // to restore the scroll position when
+ // messages gets removed from either end and added to other.
+ _updateTopBottomMsg() {
+ const msgs = this.scroll.lastElementChild.lastElementChild.children;
+ const lMsgIndex = msgs.length - 1;
+
+ this.topMsg = msgs[0]?.className === 'ph-msg'
+ ? msgs[PLACEHOLDER_COUNT]
+ : msgs[0];
+ this.bottomMsg = msgs[lMsgIndex]?.className === 'ph-msg'
+ ? msgs[lMsgIndex - PLACEHOLDER_COUNT]
+ : msgs[lMsgIndex];
+ }
+
+ // we calculate the difference between first/last message and current scrollTop.
+ // if we are going above we calc diff between first and scrollTop
+ // else otherwise.
+ // NOTE: This will help to restore the scroll when msgs get's removed
+ // from one end and added to other end
+ _calcDiff(scrollInfo) {
+ if (!this.topMsg || !this.bottomMsg) return 0;
+ if (this.inTopHalf) {
+ return this.topMsg.offsetTop - scrollInfo.top;
}
- };
+ return this.bottomMsg.offsetTop - scrollInfo.top;
+ }
- const updatePAG = (canPagMore, loaded) => {
- if (canPagMore) {
- scroll.from += loaded;
- scroll.from = getNewFrom(timelineScroll.position);
- if (roomTimeline.ongoingDecryptionCount === 0) updateState({});
- } else setIsReachedTimelineEnd(true);
- };
- // force update RoomTimeline
- const updateRT = () => {
- if (timelineScroll.position === 'BOTTOM') {
- trySendingReadReceipt();
- scroll.from = roomTimeline.timeline.size - scroll.limit - 1;
- if (scroll.from < 0) scroll.from = 0;
- scroll.isNewEvent = true;
+ _calcMaxEvents(scrollInfo) {
+ return Math.round(scrollInfo.viewHeight / SMALLEST_MSG_HEIGHT) * PAGES_COUNT;
+ }
+
+ _updateCalc(scrollInfo) {
+ const halfViewHeight = Math.round(scrollInfo.viewHeight / 2);
+ const scrollMiddle = scrollInfo.top + halfViewHeight;
+ const lastMiddle = this.top + halfViewHeight;
+
+ this.backwards = scrollMiddle < lastMiddle;
+ this.inTopHalf = scrollMiddle < scrollInfo.height / 2;
+
+ this.isScrollable = scrollInfo.isScrollable;
+ this.top = scrollInfo.top;
+ this.bottom = scrollInfo.height - (scrollInfo.top + scrollInfo.viewHeight);
+ this.height = scrollInfo.height;
+
+ // only calculate maxEvents if viewHeight change
+ if (this.viewHeight !== scrollInfo.viewHeight) {
+ this.maxEvents = this._calcMaxEvents(scrollInfo);
+ this.viewHeight = scrollInfo.viewHeight;
}
- updateState({});
+
+ this._updateTopBottomMsg();
+ this.diff = this._calcDiff(scrollInfo);
+ }
+
+ calcScroll() {
+ if (this.scrolledByCode) {
+ this.scrolledByCode = false;
+ return;
+ }
+
+ const scrollInfo = getScrollInfo(this.scroll);
+ this._updateCalc(scrollInfo);
+
+ this.emit('scroll', this.backwards);
+ }
+}
+
+let timelineScroll = null;
+let focusEventIndex = null;
+const throttle = new Throttle();
+
+function useTimeline(roomTimeline, eventId) {
+ const [timelineInfo, setTimelineInfo] = useState(null);
+
+ const initTimeline = (eId) => {
+ setTimelineInfo({
+ focusEventId: eId,
+ });
};
- const handleScrollToLive = () => {
- trySendingReadReceipt();
- scroll.from = roomTimeline.timeline.size - scroll.limit - 1;
- if (scroll.from < 0) scroll.from = 0;
- scroll.isNewEvent = true;
- updateState({});
+ const setEventTimeline = async (eId) => {
+ if (typeof eId === 'string') {
+ const isLoaded = await roomTimeline.loadEventTimeline(eId);
+ if (isLoaded) return;
+ // if eventTimeline failed to load,
+ // we will load live timeline as fallback.
+ }
+ roomTimeline.loadLiveTimeline();
};
useEffect(() => {
- trySendingReadReceipt();
+ roomTimeline.on(cons.events.roomTimeline.READY, initTimeline);
+ setEventTimeline(eventId);
return () => {
- setIsReachedTimelineEnd(false);
- scroll.limit = 0;
+ roomTimeline.removeListener(cons.events.roomTimeline.READY, initTimeline);
+ roomTimeline.removeInternalListeners();
};
- }, [roomId]);
+ }, [roomTimeline, eventId]);
+
+ return timelineInfo;
+}
+
+function useOnPaginate(roomTimeline) {
+ const [info, setInfo] = useState(null);
- // 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('timeline-scroll', handleTimelineScroll);
- viewEvent.on('scroll-to-live', handleScrollToLive);
+ const handleOnPagination = (backwards, loaded, canLoadMore) => {
+ setInfo({
+ backwards,
+ loaded,
+ canLoadMore,
+ });
+ };
+ roomTimeline.on(cons.events.roomTimeline.PAGINATED, handleOnPagination);
+ return () => {
+ roomTimeline.on(cons.events.roomTimeline.PAGINATED, handleOnPagination);
+ };
+ }, [roomTimeline]);
+
+ return info;
+}
+
+function useAutoPaginate(roomTimeline) {
+ return useCallback(() => {
+ if (roomTimeline.isOngoingPagination) return;
+
+ if (timelineScroll.bottom < SCROLL_TRIGGER_POS && roomTimeline.canPaginateForward()) {
+ roomTimeline.paginateTimeline(false);
+ return;
+ }
+ if (timelineScroll.top < SCROLL_TRIGGER_POS && roomTimeline.canPaginateBackward()) {
+ roomTimeline.paginateTimeline(true);
+ }
+ }, [roomTimeline]);
+}
+function useHandleScroll(roomTimeline, autoPaginate, viewEvent) {
+ return useCallback(() => {
+ requestAnimationFrame(() => {
+ // emit event to toggle scrollToBottom button visibility
+ const isAtBottom = timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward();
+ viewEvent.emit('at-bottom', isAtBottom);
+ });
+ autoPaginate();
+ }, [roomTimeline]);
+}
+
+function useEventArrive(roomTimeline) {
+ const [newEvent, setEvent] = useState(null);
+ useEffect(() => {
+ const handleEvent = (event) => {
+ setEvent(event);
+ };
+ roomTimeline.on(cons.events.roomTimeline.EVENT, handleEvent);
return () => {
- roomTimeline.removeListener(cons.events.roomTimeline.EVENT, updateRT);
- roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, updatePAG);
- viewEvent.removeListener('timeline-scroll', handleTimelineScroll);
- viewEvent.removeListener('scroll-to-live', handleScrollToLive);
+ roomTimeline.removeListener(cons.events.roomTimeline.EVENT, handleEvent);
};
- }, [roomTimeline, isReachedTimelineEnd]);
+ }, [roomTimeline]);
useLayoutEffect(() => {
- timelineScroll.reachBottom();
- autoLoadTimeline();
- trySendingReadReceipt();
+ if (!roomTimeline.initialized) return;
+ if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()) {
+ timelineScroll.scrollToBottom();
+ }
+ }, [newEvent, roomTimeline]);
+}
+
+function RoomViewContent({
+ eventId, roomTimeline, viewEvent,
+}) {
+ const timelineSVRef = useRef(null);
+ const timelineInfo = useTimeline(roomTimeline, eventId);
+ const readEventStore = useStore(roomTimeline);
+ const paginateInfo = useOnPaginate(roomTimeline);
+ const autoPaginate = useAutoPaginate(roomTimeline);
+ const handleScroll = useHandleScroll(roomTimeline, autoPaginate, viewEvent);
+ useEventArrive(roomTimeline);
+ const { timeline } = roomTimeline;
+
+ const handleScrollToLive = useCallback(() => {
+ if (roomTimeline.isServingLiveTimeline()) {
+ timelineScroll.scrollToBottom();
+ return;
+ }
+ roomTimeline.loadLiveTimeline();
}, [roomTimeline]);
useLayoutEffect(() => {
- if (onStateUpdate === null || scroll.isNewEvent) {
- scroll.isNewEvent = false;
- timelineScroll.reachBottom();
- return;
+ if (!roomTimeline.initialized) {
+ timelineScroll = new TimelineScroll(timelineSVRef.current);
+ return undefined;
}
- if (timelineScroll.isScrollable) {
- timelineScroll.tryRestoringScroll();
- } else {
- timelineScroll.reachBottom();
- autoLoadTimeline();
+
+ if (timeline.length > 0) {
+ if (focusEventIndex === null) timelineScroll.scrollToBottom();
+ else timelineScroll.scrollToIndex(focusEventIndex, 80);
+ focusEventIndex = null;
}
- }, [onStateUpdate]);
+ autoPaginate();
- const handleOnClickCapture = (e) => {
- const { target } = e;
- const userId = target.getAttribute('data-mx-pill');
- if (!userId) return;
+ timelineScroll.on('scroll', handleScroll);
+ viewEvent.on('scroll-to-live', handleScrollToLive);
+ return () => {
+ if (timelineSVRef.current === null) return;
+ timelineScroll.removeListener('scroll', handleScroll);
+ viewEvent.removeListener('scroll-to-live', handleScrollToLive);
+ };
+ }, [timelineInfo]);
- openProfileViewer(userId, roomId);
+ useLayoutEffect(() => {
+ if (!roomTimeline.initialized) return;
+ // TODO: decide is restore scroll
+ timelineScroll.tryRestoringScroll();
+ autoPaginate();
+ }, [paginateInfo]);
+
+ const handleTimelineScroll = (event) => {
+ const { target } = event;
+ if (!target) return;
+ throttle._(() => timelineScroll?.calcScroll(), 200)(target);
};
- let prevMEvent = null;
- const renderMessage = (mEvent) => {
- const isContentOnly = (prevMEvent !== null && prevMEvent.getType() !== 'm.room.member'
- && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
- && prevMEvent.getSender() === mEvent.getSender()
- );
-
- let DividerComp = null;
- if (prevMEvent !== null && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate())) {
- DividerComp = <Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />;
+ const getReadEvent = () => {
+ const readEventId = roomTimeline.getReadUpToEventId();
+ if (readEventStore.getItem()?.getId() === readEventId) {
+ return readEventStore.getItem();
}
- prevMEvent = mEvent;
-
- if (mEvent.getType() === 'm.room.member') {
- const timelineChange = parseTimelineChange(mEvent);
- if (timelineChange === null) return false;
- return (
- <React.Fragment key={`box-${mEvent.getId()}`}>
- {DividerComp}
- <TimelineChange
- key={mEvent.getId()}
- variant={timelineChange.variant}
- content={timelineChange.content}
- time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
- />
- </React.Fragment>
+ if (roomTimeline.hasEventInActiveTimeline(readEventId)) {
+ return readEventStore.setItem(
+ roomTimeline.findEventByIdInTimelineSet(readEventId),
);
}
- return (
- <React.Fragment key={`box-${mEvent.getId()}`}>
- {DividerComp}
- <Message mEvent={mEvent} isBodyOnly={isContentOnly} roomTimeline={roomTimeline} />
- </React.Fragment>
- );
+ return readEventStore.setItem(null);
};
const renderTimeline = () => {
- const { timeline } = roomTimeline;
const tl = [];
- if (timeline.size === 0) return tl;
-
- let i = 0;
- // eslint-disable-next-line no-restricted-syntax
- for (const [, mEvent] of timeline.entries()) {
- if (i >= scroll.from) {
- if (i === scroll.from) {
- if (mEvent.getType() !== 'm.room.create' && !isReachedTimelineEnd) tl.push(genPlaceholders(1));
- if (mEvent.getType() !== 'm.room.create' && isReachedTimelineEnd) tl.push(genRoomIntro(undefined, roomTimeline));
+
+ const readEvent = getReadEvent();
+ let extraItemCount = 0;
+ focusEventIndex = null;
+
+ if (roomTimeline.canPaginateBackward()) {
+ tl.push(loadingMsgPlaceholders(1, PLACEHOLDER_COUNT));
+ extraItemCount += PLACEHOLDER_COUNT;
+ }
+ for (let i = 0; i < timeline.length; i += 1) {
+ const mEvent = timeline[i];
+ const prevMEvent = timeline[i - 1] ?? null;
+
+ if (i === 0 && !roomTimeline.canPaginateBackward()) {
+ if (mEvent.getType() === 'm.room.create') {
+ tl.push(genRoomIntro(mEvent, roomTimeline));
+ // eslint-disable-next-line no-continue
+ continue;
+ } else {
+ tl.push(genRoomIntro(undefined, roomTimeline));
+ extraItemCount += 1;
}
- if (mEvent.getType() === 'm.room.create') tl.push(genRoomIntro(mEvent, roomTimeline));
- else tl.push(renderMessage(mEvent));
}
- i += 1;
- if (i > scroll.getEndIndex()) break;
+ const unreadDivider = (readEvent
+ && prevMEvent?.getTs() <= readEvent.getTs()
+ && readEvent.getTs() < mEvent.getTs());
+ if (unreadDivider) {
+ tl.push(<Divider key={`new-${readEvent.getId()}`} variant="positive" text="Unread messages" />);
+ if (focusEventIndex === null) focusEventIndex = i + extraItemCount;
+ }
+ const dayDivider = prevMEvent && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate());
+ if (dayDivider) {
+ tl.push(<Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />);
+ extraItemCount += 1;
+ }
+ const focusId = timelineInfo.focusEventId;
+ const isFocus = focusId === mEvent.getId() && focusId !== readEvent?.getId();
+ if (isFocus) focusEventIndex = i + extraItemCount;
+
+ tl.push(renderEvent(roomTimeline, mEvent, prevMEvent, isFocus));
+ }
+ if (roomTimeline.canPaginateForward()) {
+ tl.push(loadingMsgPlaceholders(2, PLACEHOLDER_COUNT));
}
- if (i < timeline.size) tl.push(genPlaceholders(2));
return tl;
};
return (
- <div className="room-view__content" onClick={handleOnClickCapture}>
- <div className="timeline__wrapper">
- { renderTimeline() }
+ <ScrollView onScroll={handleTimelineScroll} ref={timelineSVRef} autoHide>
+ <div className="room-view__content" onClick={handleOnClickCapture}>
+ <div className="timeline__wrapper">
+ { roomTimeline.initialized ? renderTimeline() : loadingMsgPlaceholders('loading', 3) }
+ </div>
</div>
- </div>
+ </ScrollView>
);
}
+
+RoomViewContent.defaultProps = {
+ eventId: null,
+};
RoomViewContent.propTypes = {
- roomId: PropTypes.string.isRequired,
+ eventId: PropTypes.string,
roomTimeline: PropTypes.shape({}).isRequired,
- timelineScroll: PropTypes.shape({}).isRequired,
viewEvent: PropTypes.shape({}).isRequired,
};
min-height: 0;
min-width: 0;
padding-bottom: var(--typing-noti-height);
+
+ & .message,
+ & .ph-msg,
+ & .timeline-change {
+ border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
+ [dir=rtl] & {
+ border-radius: var(--bo-radius) 0 0 var(--bo-radius);
+ }
+ }
+
+ & > .divider {
+ margin: var(--sp-extra-tight) var(--sp-normal);
+ margin-right: var(--sp-extra-tight);
+ padding-left: calc(var(--av-small) + var(--sp-tight));
+ [dir=rtl] & {
+ padding: {
+ left: 0;
+ right: calc(var(--av-small) + var(--sp-tight));
+ }
+ margin: {
+ left: var(--sp-extra-tight);
+ right: var(--sp-normal);
+ }
+ }
+ }
}
}
\ No newline at end of file
import cons from '../../../client/state/cons';
import Text from '../../atoms/text/Text';
+import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton';
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
+import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import { getUsersActionJsx } from './common';
-function RoomViewFloating({
- roomId, roomTimeline, viewEvent,
-}) {
- const [reachedBottom, setReachedBottom] = useState(true);
+function useJumpToEvent(roomTimeline) {
+ const [eventId, setEventId] = useState(null);
+
+ const jumpToEvent = () => {
+ roomTimeline.loadEventTimeline(eventId);
+ setEventId(null);
+ };
+
+ const cancelJumpToEvent = () => {
+ setEventId(null);
+ roomTimeline.markAsRead();
+ };
+
+ // TODO: if user reaches the unread messages with other ways
+ // like by paginating, or loading timeline for that event by other ways ex: clicking on reply.
+ // then setEventId(null);
+
+ useEffect(() => {
+ const readEventId = roomTimeline.getReadUpToEventId();
+ // we only show "Jump to unread" btn only if the event is not in live timeline.
+ // if event is in live timeline
+ // we will automatically open the timeline from that event
+ if (!roomTimeline.hasEventInLiveTimeline(readEventId)) {
+ setEventId(readEventId);
+ }
+
+ return () => {
+ setEventId(null);
+ };
+ }, [roomTimeline]);
+
+ return [!!eventId, jumpToEvent, cancelJumpToEvent];
+}
+
+function useTypingMembers(roomTimeline) {
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(roomId, [...userIds], 'typing...');
- }
-
- function updateTyping(members) {
+
+ const updateTyping = (members) => {
+ const mx = initMatrix.matrixClient;
+ members.delete(mx.getUserId());
setTypingMembers(members);
- }
- const handleTimelineScroll = (position) => {
- setReachedBottom(position === 'BOTTOM');
};
useEffect(() => {
- setReachedBottom(true);
setTypingMembers(new Set());
- viewEvent.on('timeline-scroll', handleTimelineScroll);
- return () => viewEvent.removeListener('timeline-scroll', handleTimelineScroll);
- }, [roomId]);
-
- useEffect(() => {
roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
return () => {
roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
};
}, [roomTimeline]);
+ return [typingMembers];
+}
+
+function useScrollToBottom(roomId, viewEvent) {
+ const [isAtBottom, setIsAtBottom] = useState(true);
+ const handleAtBottom = (atBottom) => setIsAtBottom(atBottom);
+
+ useEffect(() => {
+ setIsAtBottom(true);
+ viewEvent.on('at-bottom', handleAtBottom);
+ return () => viewEvent.removeListener('at-bottom', handleAtBottom);
+ }, [roomId]);
+
+ return [isAtBottom, setIsAtBottom];
+}
+
+function RoomViewFloating({
+ roomId, roomTimeline, viewEvent,
+}) {
+ const [isJumpToEvent, jumpToEvent, cancelJumpToEvent] = useJumpToEvent(roomTimeline, viewEvent);
+ const [typingMembers] = useTypingMembers(roomTimeline);
+ const [isAtBottom, setIsAtBottom] = useScrollToBottom(roomId, viewEvent);
+
+ const handleScrollToBottom = () => {
+ viewEvent.emit('scroll-to-live');
+ setIsAtBottom(true);
+ };
+
return (
<>
- <div className={`room-view__typing${isSomeoneTyping(typingMembers) ? ' room-view__typing--open' : ''}`}>
+ <div className={`room-view__unread ${isJumpToEvent ? 'room-view__unread--open' : ''}`}>
+ <Button onClick={jumpToEvent} variant="primary">
+ <Text variant="b2">Jump to unread</Text>
+ </Button>
+ <IconButton
+ onClick={cancelJumpToEvent}
+ variant="primary"
+ size="extra-small"
+ src={CrossIC}
+ tooltipPlacement="bottom"
+ tooltip="Cancel"
+ />
+ </div>
+ <div className={`room-view__typing${typingMembers.size > 0 ? ' room-view__typing--open' : ''}`}>
<div className="bouncing-loader"><div /></div>
- <Text variant="b2">{getTypingMessage(typingMembers)}</Text>
+ <Text variant="b2">{getUsersActionJsx(roomId, [...typingMembers], 'typing...')}</Text>
</div>
- <div className={`room-view__STB${reachedBottom ? '' : ' room-view__STB--open'}`}>
+ <div className={`room-view__STB${isAtBottom ? '' : ' room-view__STB--open'}`}>
<IconButton
- onClick={() => {
- viewEvent.emit('scroll-to-live');
- setReachedBottom(true);
- }}
+ onClick={handleScrollToBottom}
src={ChevronBottomIC}
tooltip="Scroll to Bottom"
/>
transform: translateY(-28px) scale(1);
}
}
+
+ &__unread {
+ position: absolute;
+ top: var(--sp-extra-tight);
+ right: var(--sp-extra-tight);
+ z-index: 999;
+
+ display: none;
+ background-color: var(--bg-surface);
+ border-radius: var(--bo-radius);
+ box-shadow: var(--bs-primary-border);
+ overflow: hidden;
+
+ &--open {
+ display: flex;
+ }
+
+ & .ic-btn {
+ padding: 6px var(--sp-extra-tight);
+ border-radius: 0;
+ }
+ & .btn-primary {
+ flex: 1;
+ min-width: 0;
+ border-radius: 0;
+ padding: 0 var(--sp-tight);
+ &:focus {
+ background-color: var(--bg-primary-hover);
+ }
+ }
+ }
}
\ No newline at end of file
let isCmdActivated = false;
let cmdCursorPos = null;
function RoomViewInput({
- roomId, roomTimeline, timelineScroll, viewEvent,
+ roomId, roomTimeline, viewEvent,
}) {
const [attachment, setAttachment] = useState(null);
const [isMarkdown, setIsMarkdown] = useState(settings.isMarkdown);
focusInput();
textAreaRef.current.value = roomsInput.getMessage(roomId);
- viewEvent.emit('scroll-to-live');
viewEvent.emit('message_sent');
textAreaRef.current.style.height = 'unset';
if (replyTo !== null) setReplyTo(null);
<IconButton onClick={handleUploadClick} tooltip={attachment === null ? 'Upload' : 'Cancel'} src={CirclePlusIC} />
</div>
<div ref={inputBaseRef} className="room-input__input-container">
- {roomTimeline.isEncryptedRoom() && <RawIcon size="extra-small" src={ShieldIC} />}
+ {roomTimeline.isEncrypted() && <RawIcon size="extra-small" src={ShieldIC} />}
<ScrollView autoHide>
<Text className="room-input__textarea-wrapper">
<TextareaAutosize
ref={textAreaRef}
onChange={handleMsgTyping}
onPaste={handlePaste}
- onResize={() => timelineScroll.autoReachBottom()}
onKeyDown={handleKeyDown}
placeholder="Send a message..."
/>
RoomViewInput.propTypes = {
roomId: PropTypes.string.isRequired,
roomTimeline: PropTypes.shape({}).isRequired,
- timelineScroll: PropTypes.shape({}).isRequired,
viewEvent: PropTypes.shape({}).isRequired,
};
});
}
-function selectRoom(roomId) {
+function selectRoom(roomId, eventId) {
appDispatcher.dispatch({
type: cons.actions.navigation.SELECT_ROOM,
roomId,
+ eventId,
});
}
});
}
-function openReadReceipts(roomId, eventId) {
+function openReadReceipts(roomId, userIds) {
appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_READRECEIPTS,
roomId,
- eventId,
+ userIds,
});
}
import EventEmitter from 'events';
import * as sdk from 'matrix-js-sdk';
-import { logger } from 'matrix-js-sdk/lib/logger';
+// import { logger } from 'matrix-js-sdk/lib/logger';
import { secret } from './state/auth';
import RoomList from './state/RoomList';
global.Olm = require('@matrix-org/olm');
-logger.disableAll();
+// logger.disableAll();
class InitMatrix extends EventEmitter {
async init() {
doesRoomHaveUnread(room) {
const userId = this.matrixClient.getUserId();
const readUpToId = room.getEventReadUpTo(userId);
+ const liveEvents = room.getLiveTimeline().getEvents();
- if (room.timeline.length
- && room.timeline[room.timeline.length - 1].sender
- && room.timeline[room.timeline.length - 1].sender.userId === userId
- && room.timeline[room.timeline.length - 1].getType() !== 'm.room.member') {
+ if (liveEvents.length
+ && liveEvents[liveEvents.length - 1].sender
+ && liveEvents[liveEvents.length - 1].sender.userId === userId
+ && liveEvents[liveEvents.length - 1].getType() !== 'm.room.member') {
return false;
}
- for (let i = room.timeline.length - 1; i >= 0; i -= 1) {
- const event = room.timeline[i];
+ for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
+ const event = liveEvents[i];
if (event.getId() === readUpToId) return false;
_listenEvents() {
this.matrixClient.on('Room.timeline', (mEvent, room) => {
if (!this.supportEvents.includes(mEvent.getType())) return;
+ const liveEvents = room.getLiveTimeline().getEvents();
- const lastTimelineEvent = room.timeline[room.timeline.length - 1];
+ const lastTimelineEvent = liveEvents[liveEvents.length - 1];
if (lastTimelineEvent.getId() !== mEvent.getId()) return;
if (mEvent.getSender() === this.matrixClient.getUserId()) return;
return mEvent;
}
+function getFirstLinkedTimeline(timeline) {
+ let tm = timeline;
+ while (tm.prevTimeline) {
+ tm = tm.prevTimeline;
+ }
+ return tm;
+}
+function getLastLinkedTimeline(timeline) {
+ let tm = timeline;
+ while (tm.nextTimeline) {
+ tm = tm.nextTimeline;
+ }
+ return tm;
+}
+
+function iterateLinkedTimelines(timeline, backwards, callback) {
+ let tm = timeline;
+ while (tm) {
+ callback(tm);
+ if (backwards) tm = tm.prevTimeline;
+ else tm = tm.nextTimeline;
+ }
+}
+
class RoomTimeline extends EventEmitter {
constructor(roomId) {
super();
+ // These are local timelines
+ this.timeline = [];
+ this.editedTimeline = new Map();
+ this.reactionTimeline = new Map();
+ this.typingMembers = new Set();
+
this.matrixClient = initMatrix.matrixClient;
this.roomId = roomId;
this.room = this.matrixClient.getRoom(roomId);
- this.timeline = new Map();
- this.editedTimeline = new Map();
- this.reactionTimeline = new Map();
+ this.liveTimeline = this.room.getLiveTimeline();
+ this.activeTimeline = this.liveTimeline;
this.isOngoingPagination = false;
this.ongoingDecryptionCount = 0;
- this.typingMembers = new Set();
+ this.initialized = false;
+
+ // TODO: remove below line
+ window.selectedRoom = this;
+ }
+
+ isServingLiveTimeline() {
+ return getLastLinkedTimeline(this.activeTimeline) === this.liveTimeline;
+ }
+
+ canPaginateBackward() {
+ const tm = getFirstLinkedTimeline(this.activeTimeline);
+ return tm.getPaginationToken('b') !== null;
+ }
+
+ canPaginateForward() {
+ return !this.isServingLiveTimeline();
+ }
+
+ isEncrypted() {
+ return this.matrixClient.isRoomEncrypted(this.roomId);
+ }
+
+ clearLocalTimelines() {
+ this.timeline = [];
+ this.reactionTimeline.clear();
+ this.editedTimeline.clear();
+ }
+
+ addToTimeline(mEvent) {
+ if (mEvent.isRedacted()) return;
+ if (isReaction(mEvent)) {
+ addToMap(this.reactionTimeline, mEvent);
+ return;
+ }
+ if (!cons.supportEventTypes.includes(mEvent.getType())) return;
+ if (isEdited(mEvent)) {
+ addToMap(this.editedTimeline, mEvent);
+ return;
+ }
+ this.timeline.push(mEvent);
+ }
+
+ _populateAllLinkedEvents(timeline) {
+ const firstTimeline = getFirstLinkedTimeline(timeline);
+ iterateLinkedTimelines(firstTimeline, false, (tm) => {
+ tm.getEvents().forEach((mEvent) => this.addToTimeline(mEvent));
+ });
+ }
+
+ _populateTimelines() {
+ this.clearLocalTimelines();
+ this._populateAllLinkedEvents(this.activeTimeline);
+ }
+
+ async _reset(eventId) {
+ if (this.isEncrypted()) await this.decryptAllEventsOfTimeline(this.activeTimeline);
+ this._populateTimelines();
+ if (!this.initialized) {
+ this.initialized = true;
+ this._listenEvents();
+ }
+ this.emit(cons.events.roomTimeline.READY, eventId ?? null);
+ }
+
+ async loadLiveTimeline() {
+ this.activeTimeline = this.liveTimeline;
+ await this._reset();
+ return true;
+ }
+
+ async loadEventTimeline(eventId) {
+ // we use first unfiltered EventTimelineSet for room pagination.
+ const timelineSet = this.getUnfilteredTimelineSet();
+ try {
+ const eventTimeline = await this.matrixClient.getEventTimeline(timelineSet, eventId);
+ this.activeTimeline = eventTimeline;
+ await this._reset(eventId);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async paginateTimeline(backwards = false, limit = 30) {
+ if (this.initialized === false) return false;
+ if (this.isOngoingPagination) return false;
+
+ this.isOngoingPagination = true;
+
+ const timelineToPaginate = backwards
+ ? getFirstLinkedTimeline(this.activeTimeline)
+ : getLastLinkedTimeline(this.activeTimeline);
+
+ if (timelineToPaginate.getPaginationToken(backwards ? 'b' : 'f') === null) {
+ this.isOngoingPagination = false;
+ this.emit(cons.events.roomTimeline.PAGINATED, backwards, 0, false);
+ return false;
+ }
+
+ const oldSize = this.timeline.length;
+ try {
+ const canPaginateMore = await this.matrixClient
+ .paginateEventTimeline(timelineToPaginate, { backwards, limit });
+
+ if (this.isEncrypted()) await this.decryptAllEventsOfTimeline(this.activeTimeline);
+ this._populateTimelines();
+
+ const loaded = this.timeline.length - oldSize;
+ this.isOngoingPagination = false;
+ this.emit(cons.events.roomTimeline.PAGINATED, backwards, loaded, canPaginateMore);
+ return true;
+ } catch {
+ this.isOngoingPagination = false;
+ this.emit(cons.events.roomTimeline.PAGINATED, backwards, 0, true);
+ return false;
+ }
+ }
+
+ decryptAllEventsOfTimeline(eventTimeline) {
+ const decryptionPromises = eventTimeline
+ .getEvents()
+ .filter((event) => event.isEncrypted() && !event.clearEvent)
+ .reverse()
+ .map((event) => event.attemptDecryption(this.matrixClient.crypto, { isRetry: true }));
- this._listenRoomTimeline = (event, room) => {
+ return Promise.allSettled(decryptionPromises);
+ }
+
+ markAsRead() {
+ const readEventId = this.getReadUpToEventId();
+ if (this.timeline.length === 0) return;
+ const latestEvent = this.timeline[this.timeline.length - 1];
+ if (readEventId === latestEvent.getId()) return;
+ this.matrixClient.sendReadReceipt(latestEvent);
+ }
+
+ hasEventInLiveTimeline(eventId) {
+ const timelineSet = this.getUnfilteredTimelineSet();
+ return timelineSet.getTimelineForEvent(eventId) === this.liveTimeline;
+ }
+
+ hasEventInActiveTimeline(eventId) {
+ const timelineSet = this.getUnfilteredTimelineSet();
+ return timelineSet.getTimelineForEvent(eventId) === this.activeTimeline;
+ }
+
+ getUnfilteredTimelineSet() {
+ return this.room.getUnfilteredTimelineSet();
+ }
+
+ getLiveReaders() {
+ const lastEvent = this.timeline[this.timeline.length - 1];
+ const liveEvents = this.liveTimeline.getEvents();
+ const lastLiveEvent = liveEvents[liveEvents.length - 1];
+
+ let readers = [];
+ if (lastEvent) readers = this.room.getUsersReadUpTo(lastEvent);
+ if (lastLiveEvent !== lastEvent) {
+ readers.splice(readers.length, 0, ...this.room.getUsersReadUpTo(lastLiveEvent));
+ }
+ return [...new Set(readers)];
+ }
+
+ getEventReaders(eventId) {
+ const readers = [];
+ let eventIndex = this.getEventIndex(eventId);
+ if (eventIndex < 0) return this.getLiveReaders();
+ for (; eventIndex < this.timeline.length; eventIndex += 1) {
+ readers.splice(readers.length, 0, ...this.room.getUsersReadUpTo(this.timeline[eventIndex]));
+ }
+ return [...new Set(readers)];
+ }
+
+ getReadUpToEventId() {
+ return this.room.getEventReadUpTo(this.matrixClient.getUserId());
+ }
+
+ getEventIndex(eventId) {
+ return this.timeline.findIndex((mEvent) => mEvent.getId() === eventId);
+ }
+
+ findEventByIdInTimelineSet(eventId, eventTimelineSet = this.getUnfilteredTimelineSet()) {
+ return eventTimelineSet.findEventById(eventId);
+ }
+
+ findEventById(eventId) {
+ return this.timeline[this.getEventIndex(eventId)] ?? null;
+ }
+
+ deleteFromTimeline(eventId) {
+ const i = this.getEventIndex(eventId);
+ if (i === -1) return undefined;
+ return this.timeline.splice(i, 1);
+ }
+
+ _listenEvents() {
+ this._listenRoomTimeline = (event, room, toStartOfTimeline, removed, data) => {
if (room.roomId !== this.roomId) return;
+ if (this.isOngoingPagination) return;
+
+ // User is currently viewing the old events probably
+ // no need to add this event and emit changes.
+ if (this.isServingLiveTimeline() === false) return;
+
+ // We only process live events here
+ if (!data.liveEvent) return;
if (event.isEncrypted()) {
+ // We will add this event after it is being decrypted.
this.ongoingDecryptionCount += 1;
return;
}
- if (this.ongoingDecryptionCount !== 0) return;
- if (this.isOngoingPagination) return;
+ // FIXME: An unencrypted plain event can come
+ // while previous event is still decrypting
+ // and has not been added to timeline
+ // causing unordered timeline view.
this.addToTimeline(event);
- this.emit(cons.events.roomTimeline.EVENT);
+ this.emit(cons.events.roomTimeline.EVENT, event);
};
this._listenDecryptEvent = (event) => {
if (event.getRoomId() !== this.roomId) return;
+ if (this.isOngoingPagination) return;
+
+ // Not a live event.
+ // so we don't need to process it here
+ if (this.ongoingDecryptionCount === 0) return;
if (this.ongoingDecryptionCount > 0) {
this.ongoingDecryptionCount -= 1;
}
- if (this.ongoingDecryptionCount > 0) return;
-
- if (this.isOngoingPagination) return;
this.addToTimeline(event);
- this.emit(cons.events.roomTimeline.EVENT);
+ this.emit(cons.events.roomTimeline.EVENT, event);
};
this._listenRedaction = (event, room) => {
if (room.roomId !== this.roomId) return;
- this.timeline.delete(event.getId());
+ this.deleteFromTimeline(event.getId());
this.editedTimeline.delete(event.getId());
this.reactionTimeline.delete(event.getId());
this.emit(cons.events.roomTimeline.EVENT);
this.emit(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, new Set([...this.typingMembers]));
};
this._listenReciptEvent = (event, room) => {
+ // we only process receipt for latest message here.
if (room.roomId !== this.roomId) return;
const receiptContent = event.getContent();
- if (this.timeline.length === 0) return;
- const tmlLastEvent = room.timeline[room.timeline.length - 1];
- const lastEventId = tmlLastEvent.getId();
+
+ const mEvents = this.liveTimeline.getEvents();
+ const lastMEvent = mEvents[mEvents.length - 1];
+ const lastEventId = lastMEvent.getId();
const lastEventRecipt = receiptContent[lastEventId];
+
if (typeof lastEventRecipt === 'undefined') return;
if (lastEventRecipt['m.read']) {
- this.emit(cons.events.roomTimeline.READ_RECEIPT);
+ this.emit(cons.events.roomTimeline.LIVE_RECEIPT);
}
};
this.matrixClient.on('Event.decrypted', this._listenDecryptEvent);
this.matrixClient.on('RoomMember.typing', this._listenTypingEvent);
this.matrixClient.on('Room.receipt', this._listenReciptEvent);
-
- // TODO: remove below line when release
- window.selectedRoom = this;
-
- if (this.isEncryptedRoom()) this.room.decryptAllEvents();
- this._populateTimelines();
- }
-
- isEncryptedRoom() {
- return this.matrixClient.isRoomEncrypted(this.roomId);
- }
-
- addToTimeline(mEvent) {
- if (isReaction(mEvent)) {
- addToMap(this.reactionTimeline, mEvent);
- return;
- }
- if (!cons.supportEventTypes.includes(mEvent.getType())) return;
- if (isEdited(mEvent)) {
- addToMap(this.editedTimeline, mEvent);
- return;
- }
- this.timeline.set(mEvent.getId(), mEvent);
- }
-
- _populateTimelines() {
- this.timeline.clear();
- this.reactionTimeline.clear();
- this.editedTimeline.clear();
- this.room.timeline.forEach((mEvent) => this.addToTimeline(mEvent));
- }
-
- paginateBack() {
- if (this.isOngoingPagination) return;
- this.isOngoingPagination = true;
-
- const oldSize = this.timeline.size;
- const MSG_LIMIT = 30;
- this.matrixClient.scrollback(this.room, MSG_LIMIT).then(async (room) => {
- if (room.oldState.paginationToken === null) {
- // We have reached start of the timeline
- this.isOngoingPagination = false;
- if (this.isEncryptedRoom()) await this.room.decryptAllEvents();
- this.emit(cons.events.roomTimeline.PAGINATED, false, 0);
- return;
- }
- this._populateTimelines();
- const loaded = this.timeline.size - oldSize;
-
- if (this.isEncryptedRoom()) await this.room.decryptAllEvents();
- this.isOngoingPagination = false;
- this.emit(cons.events.roomTimeline.PAGINATED, true, loaded);
- });
}
removeInternalListeners() {
+ if (!this.initialized) return;
this.matrixClient.removeListener('Room.timeline', this._listenRoomTimeline);
this.matrixClient.removeListener('Room.redaction', this._listenRedaction);
this.matrixClient.removeListener('Event.decrypted', this._listenDecryptEvent);
FULL_READ: 'FULL_READ',
},
roomTimeline: {
+ READY: 'READY',
EVENT: 'EVENT',
PAGINATED: 'PAGINATED',
TYPING_MEMBERS_UPDATED: 'TYPING_MEMBERS_UPDATED',
- READ_RECEIPT: 'READ_RECEIPT',
+ LIVE_RECEIPT: 'LIVE_RECEIPT',
},
roomsInput: {
MESSAGE_SENT: 'MESSAGE_SENT',
[cons.actions.navigation.SELECT_ROOM]: () => {
const prevSelectedRoomId = this.selectedRoomId;
this.selectedRoomId = action.roomId;
- this.emit(cons.events.navigation.ROOM_SELECTED, this.selectedRoomId, prevSelectedRoomId);
+ this.emit(
+ cons.events.navigation.ROOM_SELECTED,
+ this.selectedRoomId,
+ prevSelectedRoomId,
+ action.eventId,
+ );
},
[cons.actions.navigation.OPEN_INVITE_LIST]: () => {
this.emit(cons.events.navigation.INVITE_LIST_OPENED);
this.emit(
cons.events.navigation.READRECEIPTS_OPENED,
action.roomId,
- action.eventId,
+ action.userIds,
);
},
[cons.actions.navigation.OPEN_ROOMOPTIONS]: () => {
--bg-badge: #989898;
--bg-ping: hsla(137deg, 100%, 68%, 40%);
--bg-ping-hover: hsla(137deg, 100%, 68%, 50%);
+ --bg-divider: hsla(0, 0%, 0%, .1);
/* text color | --tc-[background type]-[priority]: value */
--tc-surface-high: #000000;
--bg-badge: hsl(0, 0%, 75%);
--bg-ping: hsla(137deg, 100%, 38%, 40%);
--bg-ping-hover: hsla(137deg, 100%, 38%, 50%);
+ --bg-divider: hsla(0, 0%, 100%, .1);
/* text color | --tc-[background type]-[priority]: value */
scroll.top = Math.round(target.scrollTop);
scroll.height = Math.round(target.scrollHeight);
scroll.viewHeight = Math.round(target.offsetHeight);
- scroll.bottom = Math.round(scroll.top + scroll.viewHeight);
scroll.isScrollable = scroll.height > scroll.viewHeight;
return scroll;
}
return null;
}
+function parseReply(rawBody) {
+ if (rawBody?.indexOf('>') !== 0) return null;
+ let body = rawBody.slice(rawBody.indexOf('<') + 1);
+ const user = body.slice(0, body.indexOf('>'));
+
+ body = body.slice(body.indexOf('>') + 2);
+ const replyBody = body.slice(0, body.indexOf('\n\n'));
+ body = body.slice(body.indexOf('\n\n') + 2);
+
+ if (user === '') return null;
+
+ const isUserId = user.match(/^@.+:.+/);
+
+ return {
+ userId: isUserId ? user : null,
+ displayName: isUserId ? null : user,
+ replyBody,
+ body,
+ };
+}
+
export {
getBaseUrl, getUsername, getUsernameOfRoomMember,
- isRoomAliasAvailable, getPowerLabel,
+ isRoomAliasAvailable, getPowerLabel, parseReply,
};