From 38cbb87a62bf1d49cba344f84d6aad23694a4309 Mon Sep 17 00:00:00 2001 From: Ajay Bura Date: Fri, 3 Dec 2021 18:32:10 +0530 Subject: [PATCH] Added unread indicator (#67), reply link back to original (#96) Signed-off-by: Ajay Bura --- src/app/hooks/useForceUpdate.js | 8 + src/app/hooks/useStore.js | 22 + .../profile-viewer/ProfileViewer.jsx | 2 +- .../organisms/read-receipts/ReadReceipts.jsx | 42 +- src/app/organisms/room/Room.jsx | 35 +- src/app/organisms/room/RoomView.jsx | 182 ++---- src/app/organisms/room/RoomViewCmdBar.jsx | 24 +- src/app/organisms/room/RoomViewContent.jsx | 560 ++++++++++++------ src/app/organisms/room/RoomViewContent.scss | 25 + src/app/organisms/room/RoomViewFloating.jsx | 124 ++-- src/app/organisms/room/RoomViewFloating.scss | 31 + src/app/organisms/room/RoomViewInput.jsx | 7 +- src/client/action/navigation.js | 7 +- src/client/initMatrix.js | 4 +- src/client/state/Notifications.js | 16 +- src/client/state/RoomTimeline.js | 328 +++++++--- src/client/state/cons.js | 3 +- src/client/state/navigation.js | 9 +- src/index.scss | 2 + src/util/common.js | 1 - src/util/matrixUtil.js | 23 +- 21 files changed, 948 insertions(+), 507 deletions(-) create mode 100644 src/app/hooks/useForceUpdate.js create mode 100644 src/app/hooks/useStore.js diff --git a/src/app/hooks/useForceUpdate.js b/src/app/hooks/useForceUpdate.js new file mode 100644 index 0000000..2eb5c3c --- /dev/null +++ b/src/app/hooks/useForceUpdate.js @@ -0,0 +1,8 @@ +/* eslint-disable import/prefer-default-export */ +import { useState } from 'react'; + +export function useForceUpdate() { + const [, setData] = useState(null); + + return () => setData({}); +} diff --git a/src/app/hooks/useStore.js b/src/app/hooks/useStore.js new file mode 100644 index 0000000..f216406 --- /dev/null +++ b/src/app/hooks/useStore.js @@ -0,0 +1,22 @@ +/* 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 }; +} diff --git a/src/app/organisms/profile-viewer/ProfileViewer.jsx b/src/app/organisms/profile-viewer/ProfileViewer.jsx index c8191ff..66fa396 100644 --- a/src/app/organisms/profile-viewer/ProfileViewer.jsx +++ b/src/app/organisms/profile-viewer/ProfileViewer.jsx @@ -95,7 +95,7 @@ function ProfileFooter({ roomId, userId, onRequestClose }) { 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) => { diff --git a/src/app/organisms/read-receipts/ReadReceipts.jsx b/src/app/organisms/read-receipts/ReadReceipts.jsx index 689a045..6460108 100644 --- a/src/app/organisms/read-receipts/ReadReceipts.jsx +++ b/src/app/organisms/read-receipts/ReadReceipts.jsx @@ -15,27 +15,15 @@ import { openProfileViewer } from '../../../client/action/navigation'; 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); @@ -44,28 +32,28 @@ function ReadReceipts() { 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 ( { 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)} /> ); } @@ -78,7 +66,7 @@ function ReadReceipts() { contentOptions={ setIsOpen(false)} tooltip="Close" />} > { - readReceipts.map(renderPeople) + readers.map(renderPeople) } ); diff --git a/src/app/organisms/room/Room.jsx b/src/app/organisms/room/Room.jsx index 7cb30cd..0157ad8 100644 --- a/src/app/organisms/room/Room.jsx +++ b/src/app/organisms/room/Room.jsx @@ -1,39 +1,50 @@ 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 ; + if (roomTimeline === null) return ; return (
- - { isDrawerVisible && } + + { isDrawer && }
); } diff --git a/src/app/organisms/room/RoomView.jsx b/src/app/organisms/room/RoomView.jsx index 867073a..ba4ae09 100644 --- a/src/app/organisms/room/RoomView.jsx +++ b/src/app/organisms/room/RoomView.jsx @@ -1,14 +1,9 @@ -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'; @@ -17,161 +12,50 @@ import RoomViewCmdBar from './RoomViewCmdBar'; 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 (
- - {roomTimeline !== null && ( - - )} - - {roomTimeline !== null && ( - - )} + + +
+
+ +
- {roomTimeline !== null && ( -
- - -
- )}
); } + +RoomView.defaultProps = { + eventId: null, +}; RoomView.propTypes = { - roomId: PropTypes.string.isRequired, + roomTimeline: PropTypes.shape({}).isRequired, + eventId: PropTypes.string, }; export default RoomView; diff --git a/src/app/organisms/room/RoomViewCmdBar.jsx b/src/app/organisms/room/RoomViewCmdBar.jsx index 68b2e52..33aceb1 100644 --- a/src/app/organisms/room/RoomViewCmdBar.jsx +++ b/src/app/organisms/room/RoomViewCmdBar.jsx @@ -123,37 +123,29 @@ function FollowingMembers({ roomId, roomTimeline, viewEvent }) { 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 && ( openReadReceipts(roomId, lastMEvent.getId())} + onClick={() => openReadReceipts(roomId, followingMembers)} /> ); } diff --git a/src/app/organisms/room/RoomViewContent.jsx b/src/app/organisms/room/RoomViewContent.jsx index 25577d3..65861dd 100644 --- a/src/app/organisms/room/RoomViewContent.jsx +++ b/src/app/organisms/room/RoomViewContent.jsx @@ -1,31 +1,52 @@ /* 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(); + } + return pl; + }; -function genPlaceholders(key) { return ( - - + {genPlaceholders()} ); } @@ -49,228 +70,421 @@ function genRoomIntro(mEvent, roomTimeline) { ); } -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() + ); + + if (mEvent.getType() === 'm.room.member') { + const timelineChange = parseTimelineChange(mEvent); + if (timelineChange === null) return false; + return ( + + ); } + return ( + + ); +} - function autoLoadTimeline() { - if (timelineScroll.isScrollable === true) return; - roomTimeline.paginateBack(); - } - function trySendingReadReceipt() { - const { timeline } = roomTimeline.room; - if ( - (noti.doesRoomHaveUnread(roomTimeline.room) || noti.hasNoti(roomId)) - && timeline.length !== 0) { - mx.sendReadReceipt(timeline[timeline.length - 1]); +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(); + 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 }; + + const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight; + + sInfo.top = (scrollTop > maxScrollTop) ? maxScrollTop : scrollTop; + this._updateCalc(sInfo); + } + + // 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; + } + + _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; + } + + this._updateTopBottomMsg(); + this.diff = this._calcDiff(scrollInfo); + } + + calcScroll() { + if (this.scrolledByCode) { + this.scrolledByCode = false; return; } - scroll.from = getNewFrom(position); - updateState({}); + const scrollInfo = getScrollInfo(this.scroll); + this._updateCalc(scrollInfo); - if (scroll.getEndIndex() + 1 >= tSize) { - trySendingReadReceipt(); + 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 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. } - }; - - 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; - } - updateState({}); - }; - - const handleScrollToLive = () => { - trySendingReadReceipt(); - scroll.from = roomTimeline.timeline.size - scroll.limit - 1; - if (scroll.from < 0) scroll.from = 0; - scroll.isNewEvent = true; - updateState({}); + 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); - - 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); + const handleOnPagination = (backwards, loaded, canLoadMore) => { + setInfo({ + backwards, + loaded, + canLoadMore, + }); }; - }, [roomTimeline, isReachedTimelineEnd]); + roomTimeline.on(cons.events.roomTimeline.PAGINATED, handleOnPagination); + return () => { + roomTimeline.on(cons.events.roomTimeline.PAGINATED, handleOnPagination); + }; + }, [roomTimeline]); - useLayoutEffect(() => { - timelineScroll.reachBottom(); - autoLoadTimeline(); - trySendingReadReceipt(); + 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, handleEvent); + }; }, [roomTimeline]); useLayoutEffect(() => { - if (onStateUpdate === null || scroll.isNewEvent) { - scroll.isNewEvent = false; - timelineScroll.reachBottom(); + 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; } - if (timelineScroll.isScrollable) { - timelineScroll.tryRestoringScroll(); - } else { - timelineScroll.reachBottom(); - autoLoadTimeline(); + roomTimeline.loadLiveTimeline(); + }, [roomTimeline]); + + useLayoutEffect(() => { + if (!roomTimeline.initialized) { + timelineScroll = new TimelineScroll(timelineSVRef.current); + return undefined; } - }, [onStateUpdate]); - const handleOnClickCapture = (e) => { - const { target } = e; - const userId = target.getAttribute('data-mx-pill'); - if (!userId) return; + if (timeline.length > 0) { + if (focusEventIndex === null) timelineScroll.scrollToBottom(); + else timelineScroll.scrollToIndex(focusEventIndex, 80); + focusEventIndex = null; + } + autoPaginate(); - openProfileViewer(userId, roomId); + 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]); + + 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 = ; + 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 ( - - {DividerComp} - - + if (roomTimeline.hasEventInActiveTimeline(readEventId)) { + return readEventStore.setItem( + roomTimeline.findEventByIdInTimelineSet(readEventId), ); } - return ( - - {DividerComp} - - - ); + 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)); - } - if (mEvent.getType() === 'm.room.create') tl.push(genRoomIntro(mEvent, roomTimeline)); - else tl.push(renderMessage(mEvent)); - } - i += 1; - if (i > scroll.getEndIndex()) break; + 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; + } + } + const unreadDivider = (readEvent + && prevMEvent?.getTs() <= readEvent.getTs() + && readEvent.getTs() < mEvent.getTs()); + if (unreadDivider) { + tl.push(); + if (focusEventIndex === null) focusEventIndex = i + extraItemCount; + } + const dayDivider = prevMEvent && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate()); + if (dayDivider) { + tl.push(); + 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 ( -
-
- { renderTimeline() } + +
+
+ { roomTimeline.initialized ? renderTimeline() : loadingMsgPlaceholders('loading', 3) } +
-
+ ); } + +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, }; diff --git a/src/app/organisms/room/RoomViewContent.scss b/src/app/organisms/room/RoomViewContent.scss index cfb328c..285ec27 100644 --- a/src/app/organisms/room/RoomViewContent.scss +++ b/src/app/organisms/room/RoomViewContent.scss @@ -9,5 +9,30 @@ 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 diff --git a/src/app/organisms/room/RoomViewFloating.jsx b/src/app/organisms/room/RoomViewFloating.jsx index 4ba79a4..8ddcce2 100644 --- a/src/app/organisms/room/RoomViewFloating.jsx +++ b/src/app/organisms/room/RoomViewFloating.jsx @@ -7,65 +7,115 @@ import initMatrix from '../../../client/initMatrix'; 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 ( <> -
-
- {getTypingMessage(typingMembers)} -
-
+
+ { - viewEvent.emit('scroll-to-live'); - setReachedBottom(true); - }} + onClick={cancelJumpToEvent} + variant="primary" + size="extra-small" + src={CrossIC} + tooltipPlacement="bottom" + tooltip="Cancel" + /> +
+
0 ? ' room-view__typing--open' : ''}`}> +
+ {getUsersActionJsx(roomId, [...typingMembers], 'typing...')} +
+
+ diff --git a/src/app/organisms/room/RoomViewFloating.scss b/src/app/organisms/room/RoomViewFloating.scss index 8c4ba7d..89a00ee 100644 --- a/src/app/organisms/room/RoomViewFloating.scss +++ b/src/app/organisms/room/RoomViewFloating.scss @@ -88,4 +88,35 @@ 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 diff --git a/src/app/organisms/room/RoomViewInput.jsx b/src/app/organisms/room/RoomViewInput.jsx index 0f1ca94..dd4f0d8 100644 --- a/src/app/organisms/room/RoomViewInput.jsx +++ b/src/app/organisms/room/RoomViewInput.jsx @@ -35,7 +35,7 @@ let isTyping = false; 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); @@ -211,7 +211,6 @@ function RoomViewInput({ 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); @@ -344,14 +343,13 @@ function RoomViewInput({
- {roomTimeline.isEncryptedRoom() && } + {roomTimeline.isEncrypted() && } timelineScroll.autoReachBottom()} onKeyDown={handleKeyDown} placeholder="Send a message..." /> @@ -434,7 +432,6 @@ function RoomViewInput({ RoomViewInput.propTypes = { roomId: PropTypes.string.isRequired, roomTimeline: PropTypes.shape({}).isRequired, - timelineScroll: PropTypes.shape({}).isRequired, viewEvent: PropTypes.shape({}).isRequired, }; diff --git a/src/client/action/navigation.js b/src/client/action/navigation.js index 0f9f4c1..ec85044 100644 --- a/src/client/action/navigation.js +++ b/src/client/action/navigation.js @@ -15,10 +15,11 @@ function selectSpace(roomId) { }); } -function selectRoom(roomId) { +function selectRoom(roomId, eventId) { appDispatcher.dispatch({ type: cons.actions.navigation.SELECT_ROOM, roomId, + eventId, }); } @@ -71,11 +72,11 @@ function openEmojiBoard(cords, requestEmojiCallback) { }); } -function openReadReceipts(roomId, eventId) { +function openReadReceipts(roomId, userIds) { appDispatcher.dispatch({ type: cons.actions.navigation.OPEN_READRECEIPTS, roomId, - eventId, + userIds, }); } diff --git a/src/client/initMatrix.js b/src/client/initMatrix.js index d82b28a..b65f894 100644 --- a/src/client/initMatrix.js +++ b/src/client/initMatrix.js @@ -1,6 +1,6 @@ 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'; @@ -9,7 +9,7 @@ import Notifications from './state/Notifications'; global.Olm = require('@matrix-org/olm'); -logger.disableAll(); +// logger.disableAll(); class InitMatrix extends EventEmitter { async init() { diff --git a/src/client/state/Notifications.js b/src/client/state/Notifications.js index 95ba2ff..6b56757 100644 --- a/src/client/state/Notifications.js +++ b/src/client/state/Notifications.js @@ -34,16 +34,17 @@ class Notifications extends EventEmitter { 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; @@ -150,8 +151,9 @@ class Notifications extends EventEmitter { _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; diff --git a/src/client/state/RoomTimeline.js b/src/client/state/RoomTimeline.js index d393dbf..b9b0c91 100644 --- a/src/client/state/RoomTimeline.js +++ b/src/client/state/RoomTimeline.js @@ -24,52 +24,289 @@ function addToMap(myMap, mEvent) { 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; - this._listenRoomTimeline = (event, room) => { + // 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 })); + + 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); @@ -84,15 +321,18 @@ class RoomTimeline extends EventEmitter { 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); } }; @@ -101,62 +341,10 @@ class RoomTimeline extends EventEmitter { 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); diff --git a/src/client/state/cons.js b/src/client/state/cons.js index 6443df0..546bb4d 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -81,10 +81,11 @@ const cons = { 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', diff --git a/src/client/state/navigation.js b/src/client/state/navigation.js index d52d96e..4f69fd6 100644 --- a/src/client/state/navigation.js +++ b/src/client/state/navigation.js @@ -50,7 +50,12 @@ class Navigation extends EventEmitter { [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); @@ -80,7 +85,7 @@ class Navigation extends EventEmitter { this.emit( cons.events.navigation.READRECEIPTS_OPENED, action.roomId, - action.eventId, + action.userIds, ); }, [cons.actions.navigation.OPEN_ROOMOPTIONS]: () => { diff --git a/src/index.scss b/src/index.scss index 2c5096d..941f94d 100644 --- a/src/index.scss +++ b/src/index.scss @@ -35,6 +35,7 @@ --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; @@ -187,6 +188,7 @@ --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 */ diff --git a/src/util/common.js b/src/util/common.js index 00e3ad4..a589763 100644 --- a/src/util/common.js +++ b/src/util/common.js @@ -90,7 +90,6 @@ export function getScrollInfo(target) { 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; } diff --git a/src/util/matrixUtil.js b/src/util/matrixUtil.js index 6f33c46..07e703b 100644 --- a/src/util/matrixUtil.js +++ b/src/util/matrixUtil.js @@ -56,7 +56,28 @@ function getPowerLabel(powerLevel) { 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, };