diff --git a/src/app/molecules/message/Message.scss b/src/app/molecules/message/Message.scss index dbc13c9..f523ed6 100644 --- a/src/app/molecules/message/Message.scss +++ b/src/app/molecules/message/Message.scss @@ -26,6 +26,7 @@ & button { cursor: pointer; + display: flex; } [dir=rtl] & { diff --git a/src/app/organisms/room/RoomView.jsx b/src/app/organisms/room/RoomView.jsx index edb427d..867073a 100644 --- a/src/app/organisms/room/RoomView.jsx +++ b/src/app/organisms/room/RoomView.jsx @@ -5,6 +5,7 @@ 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'; @@ -14,98 +15,125 @@ import RoomViewFloating from './RoomViewFloating'; import RoomViewInput from './RoomViewInput'; import RoomViewCmdBar from './RoomViewCmdBar'; -import { scrollToBottom, isAtBottom, autoScrollToBottom } from './common'; - const viewEvent = new EventEmitter(); -let lastScrollTop = 0; -let lastScrollHeight = 0; -let isReachedBottom = true; -let isReachedTop = false; function RoomView({ roomId }) { const [roomTimeline, updateRoomTimeline] = useState(null); + const [debounce] = useState(new Debounce()); const timelineSVRef = useRef(null); useEffect(() => { roomTimeline?.removeInternalListeners(); updateRoomTimeline(new RoomTimeline(roomId)); - isReachedBottom = true; - isReachedTop = false; }, [roomId]); const timelineScroll = { reachBottom() { - scrollToBottom(timelineSVRef); + 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() { - autoScrollToBottom(timelineSVRef); + if (timelineScroll.position === 'BOTTOM') timelineScroll.reachBottom(); }, tryRestoringScroll() { + timelineScroll.isOngoing = true; const sv = timelineSVRef.current; - const { scrollHeight } = sv; + const { + lastTopMsg, lastBottomMsg, + diff, isInTopHalf, lastTop, + } = timelineScroll; - if (lastScrollHeight === scrollHeight) return; - - if (lastScrollHeight < scrollHeight) { - sv.scrollTop = lastScrollTop + (scrollHeight - lastScrollHeight); - } else { - timelineScroll.reachBottom(); + 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; }, - enableSmoothScroll() { - timelineSVRef.current.style.scrollBehavior = 'smooth'; - }, - disableSmoothScroll() { - timelineSVRef.current.style.scrollBehavior = 'auto'; - }, - isScrollable() { - const oHeight = timelineSVRef.current.offsetHeight; - const sHeight = timelineSVRef.current.scrollHeight; - if (sHeight > oHeight) return true; - return false; - }, + position: 'BOTTOM', + isScrollable: false, + isInTopHalf: false, + maxEvents: 50, + lastTop: 0, + lastHeight: 0, + lastViewHeight: 0, + lastTopMsg: null, + lastBottomMsg: null, + diff: 0, + isOngoing: false, }; - function onTimelineScroll(e) { - const { scrollTop, scrollHeight, offsetHeight } = e.target; - const scrollBottom = scrollTop + offsetHeight; - lastScrollTop = scrollTop; - lastScrollHeight = scrollHeight; - - const PLACEHOLDER_HEIGHT = 96; - const PLACEHOLDER_COUNT = 3; - - const topPagKeyPoint = PLACEHOLDER_COUNT * PLACEHOLDER_HEIGHT; - const bottomPagKeyPoint = scrollHeight - (offsetHeight / 2); - - if (!isReachedBottom && isAtBottom(timelineSVRef)) { - isReachedBottom = true; - viewEvent.emit('toggle-reached-bottom', true); - } - if (isReachedBottom && !isAtBottom(timelineSVRef)) { - isReachedBottom = false; - viewEvent.emit('toggle-reached-bottom', false); - } - // TOP of timeline - if (scrollTop < topPagKeyPoint && isReachedTop === false) { - isReachedTop = true; - viewEvent.emit('reached-top'); + const calcScroll = (target) => { + if (timelineScroll.isOngoing) { + timelineScroll.isOngoing = false; return; } - isReachedTop = false; + const PLACEHOLDER_COUNT = 2; + const PLACEHOLDER_HEIGHT = 96 * PLACEHOLDER_COUNT; + const SMALLEST_MSG_HEIGHT = 32; + const scroll = getScrollInfo(target); - // BOTTOM of timeline - if (scrollBottom > bottomPagKeyPoint) { - // TODO: + 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); + }; return (
- + {roomTimeline !== null && ( )} diff --git a/src/app/organisms/room/RoomViewCmdBar.jsx b/src/app/organisms/room/RoomViewCmdBar.jsx index 1f63281..68b2e52 100644 --- a/src/app/organisms/room/RoomViewCmdBar.jsx +++ b/src/app/organisms/room/RoomViewCmdBar.jsx @@ -146,7 +146,8 @@ function FollowingMembers({ roomId, roomTimeline, viewEvent }) { }; }, [roomTimeline]); - const lastMEvent = roomTimeline.timeline[roomTimeline.timeline.length - 1]; + const { timeline } = roomTimeline.room; + const lastMEvent = timeline[timeline.length - 1]; return followingMembers.length !== 0 && ( - - - - + + + + ); } @@ -182,96 +181,149 @@ function pickEmoji(e, roomId, eventId, roomTimeline) { }); } -let wasAtBottom = true; +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); - const [onPagination, setOnPagination] = useState(null); const [editEvent, setEditEvent] = useState(null); const mx = initMatrix.matrixClient; const noti = initMatrix.notifications; + if (scroll.limit === 0) { + const from = roomTimeline.timeline.size - timelineScroll.maxEvents; + scroll.from = (from < 0) ? 0 : from; + scroll.limit = timelineScroll.maxEvents; + } function autoLoadTimeline() { - if (timelineScroll.isScrollable() === true) return; + if (timelineScroll.isScrollable === true) return; roomTimeline.paginateBack(); } function trySendingReadReceipt() { - const { room, timeline } = roomTimeline; + const { timeline } = roomTimeline.room; if ( - (noti.doesRoomHaveUnread(room) || noti.hasNoti(roomId)) + (noti.doesRoomHaveUnread(roomTimeline.room) || noti.hasNoti(roomId)) && timeline.length !== 0) { mx.sendReadReceipt(timeline[timeline.length - 1]); } } - function onReachedTop() { - if (roomTimeline.isOngoingPagination || isReachedTimelineEnd) return; - roomTimeline.paginateBack(); - } - function toggleOnReachedBottom(isBottom) { - wasAtBottom = isBottom; - if (!isBottom) return; - trySendingReadReceipt(); - } + const 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; - const updatePAG = (canPagMore) => { - if (!canPagMore) { - setIsReachedTimelineEnd(true); - } else { - setOnPagination({}); - autoLoadTimeline(); + if (position === 'TOP' && doPaginate) newFrom -= newEventCount; + if (position === 'BOTTOM' && doPaginate) newFrom += newEventCount; + + if (newFrom >= tSize || scroll.getEndIndex() >= tSize) newFrom = tSize - scroll.limit - 1; + if (newFrom < 0) newFrom = 0; + return newFrom; + }; + + const handleTimelineScroll = (position) => { + const tSize = roomTimeline.timeline.size; + if (position === 'BETWEEN') return; + if (position === 'BOTTOM' && scroll.getEndIndex() + 1 === tSize) return; + + if (scroll.from === 0 && position === 'TOP') { + // Fetch back history. + if (roomTimeline.isOngoingPagination || isReachedTimelineEnd) return; + roomTimeline.paginateBack(); + return; } + + scroll.from = getNewFrom(position); + updateState({}); + + if (scroll.getEndIndex() + 1 >= tSize) { + trySendingReadReceipt(); + } + }; + + 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 on cons.events.roomTimeline.EVENT const updateRT = () => { - if (wasAtBottom) { + 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 = () => { + scroll.from = roomTimeline.timeline.size - scroll.limit - 1; + if (scroll.from < 0) scroll.from = 0; + scroll.isNewEvent = true; + updateState({}); + }; + useEffect(() => { - setIsReachedTimelineEnd(false); - wasAtBottom = true; + trySendingReadReceipt(); + return () => { + setIsReachedTimelineEnd(false); + scroll.limit = 0; + }; }, [roomId]); - useEffect(() => trySendingReadReceipt(), [roomTimeline]); // init room setup completed. // listen for future. setup stateUpdate listener. useEffect(() => { roomTimeline.on(cons.events.roomTimeline.EVENT, updateRT); roomTimeline.on(cons.events.roomTimeline.PAGINATED, updatePAG); - viewEvent.on('reached-top', onReachedTop); - viewEvent.on('toggle-reached-bottom', toggleOnReachedBottom); + 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('reached-top', onReachedTop); - viewEvent.removeListener('toggle-reached-bottom', toggleOnReachedBottom); + viewEvent.removeListener('timeline-scroll', handleTimelineScroll); + viewEvent.removeListener('scroll-to-live', handleScrollToLive); }; - }, [roomTimeline, isReachedTimelineEnd, onPagination]); + }, [roomTimeline, isReachedTimelineEnd]); useLayoutEffect(() => { timelineScroll.reachBottom(); autoLoadTimeline(); + trySendingReadReceipt(); }, [roomTimeline]); useLayoutEffect(() => { - if (onPagination === null) return; - timelineScroll.tryRestoringScroll(); - }, [onPagination]); - - useEffect(() => { - if (onStateUpdate === null) return; - if (wasAtBottom) timelineScroll.reachBottom(); + if (onStateUpdate === null || scroll.isNewEvent) { + scroll.isNewEvent = false; + timelineScroll.reachBottom(); + return; + } + if (timelineScroll.isScrollable) { + timelineScroll.tryRestoringScroll(); + } else { + timelineScroll.reachBottom(); + autoLoadTimeline(); + } }, [onStateUpdate]); let prevMEvent = null; function genMessage(mEvent) { - const myPowerlevel = roomTimeline.room.getMember(mx.getUserId()).powerLevel; + const myPowerlevel = roomTimeline.room.getMember(mx.getUserId())?.powerLevel; const canIRedact = roomTimeline.room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel); const isContentOnly = ( @@ -521,18 +573,12 @@ function RoomViewContent({ } function renderMessage(mEvent) { - if (mEvent.getType() === 'm.room.create') return genRoomIntro(mEvent, roomTimeline); - if ( - mEvent.getType() !== 'm.room.message' - && mEvent.getType() !== 'm.room.encrypted' - && mEvent.getType() !== 'm.room.member' - && mEvent.getType() !== 'm.sticker' - ) return false; + if (!cons.supportEventTypes.includes(mEvent.getType())) return false; if (mEvent.getRelation()?.rel_type === 'm.replace') return false; - - // ignore if message is deleted if (mEvent.isRedacted()) return false; + if (mEvent.getType() === 'm.room.create') return genRoomIntro(mEvent, roomTimeline); + let divider = null; if (prevMEvent !== null && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate())) { divider = ; @@ -551,7 +597,7 @@ function RoomViewContent({ prevMEvent = mEvent; const timelineChange = parseTimelineChange(mEvent); - if (timelineChange === null) return null; + if (timelineChange === null) return false; return ( {divider} @@ -565,12 +611,33 @@ function RoomViewContent({ ); } + 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)); + } + tl.push(renderMessage(mEvent)); + } + i += 1; + if (i > scroll.getEndIndex()) break; + } + if (i < timeline.size) tl.push(genPlaceholders(2)); + + return tl; + }; + return (
- { roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && genPlaceholders() } - { roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && genRoomIntro(undefined, roomTimeline)} - { roomTimeline.timeline.map(renderMessage) } + { renderTimeline() }
); diff --git a/src/app/organisms/room/RoomViewFloating.jsx b/src/app/organisms/room/RoomViewFloating.jsx index 6ee6699..4ba79a4 100644 --- a/src/app/organisms/room/RoomViewFloating.jsx +++ b/src/app/organisms/room/RoomViewFloating.jsx @@ -14,7 +14,7 @@ import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.s import { getUsersActionJsx } from './common'; function RoomViewFloating({ - roomId, roomTimeline, timelineScroll, viewEvent, + roomId, roomTimeline, viewEvent, }) { const [reachedBottom, setReachedBottom] = useState(true); const [typingMembers, setTypingMembers] = useState(new Set()); @@ -36,12 +36,15 @@ function RoomViewFloating({ function updateTyping(members) { setTypingMembers(members); } + const handleTimelineScroll = (position) => { + setReachedBottom(position === 'BOTTOM'); + }; useEffect(() => { setReachedBottom(true); setTypingMembers(new Set()); - viewEvent.on('toggle-reached-bottom', setReachedBottom); - return () => viewEvent.removeListener('toggle-reached-bottom', setReachedBottom); + viewEvent.on('timeline-scroll', handleTimelineScroll); + return () => viewEvent.removeListener('timeline-scroll', handleTimelineScroll); }, [roomId]); useEffect(() => { @@ -60,9 +63,8 @@ function RoomViewFloating({
{ - timelineScroll.enableSmoothScroll(); - timelineScroll.reachBottom(); - timelineScroll.disableSmoothScroll(); + viewEvent.emit('scroll-to-live'); + setReachedBottom(true); }} src={ChevronBottomIC} tooltip="Scroll to Bottom" @@ -74,9 +76,6 @@ function RoomViewFloating({ RoomViewFloating.propTypes = { roomId: PropTypes.string.isRequired, roomTimeline: PropTypes.shape({}).isRequired, - timelineScroll: PropTypes.shape({ - reachBottom: PropTypes.func, - }).isRequired, viewEvent: PropTypes.shape({}).isRequired, }; diff --git a/src/app/organisms/room/RoomViewInput.jsx b/src/app/organisms/room/RoomViewInput.jsx index e23b5c1..5b18fcb 100644 --- a/src/app/organisms/room/RoomViewInput.jsx +++ b/src/app/organisms/room/RoomViewInput.jsx @@ -210,7 +210,7 @@ function RoomViewInput({ focusInput(); textAreaRef.current.value = roomsInput.getMessage(roomId); - timelineScroll.reachBottom(); + viewEvent.emit('scroll-to-live'); viewEvent.emit('message_sent'); textAreaRef.current.style.height = 'unset'; if (replyTo !== null) setReplyTo(null); @@ -433,13 +433,7 @@ function RoomViewInput({ RoomViewInput.propTypes = { roomId: PropTypes.string.isRequired, roomTimeline: PropTypes.shape({}).isRequired, - timelineScroll: PropTypes.shape({ - reachBottom: PropTypes.func, - autoReachBottom: PropTypes.func, - tryRestoringScroll: PropTypes.func, - enableSmoothScroll: PropTypes.func, - disableSmoothScroll: PropTypes.func, - }).isRequired, + timelineScroll: PropTypes.shape({}).isRequired, viewEvent: PropTypes.shape({}).isRequired, }; diff --git a/src/app/organisms/room/common.jsx b/src/app/organisms/room/common.jsx index 2d876d7..e25eabb 100644 --- a/src/app/organisms/room/common.jsx +++ b/src/app/organisms/room/common.jsx @@ -234,39 +234,9 @@ function parseTimelineChange(mEvent) { } } -function scrollToBottom(ref) { - const maxScrollTop = ref.current.scrollHeight - ref.current.offsetHeight; - // eslint-disable-next-line no-param-reassign - ref.current.scrollTop = maxScrollTop; -} - -function isAtBottom(ref) { - const { scrollHeight, scrollTop, offsetHeight } = ref.current; - const scrollUptoBottom = scrollTop + offsetHeight; - - // scroll view have to div inside div which contains messages - const lastMessage = ref.current.lastElementChild.lastElementChild.lastElementChild; - const lastChildHeight = lastMessage.offsetHeight; - - // auto scroll to bottom even if user has EXTRA_SPACE left to scroll - const EXTRA_SPACE = 48; - - if (scrollHeight - scrollUptoBottom <= lastChildHeight + EXTRA_SPACE) { - return true; - } - return false; -} - -function autoScrollToBottom(ref) { - if (isAtBottom(ref)) scrollToBottom(ref); -} - export { getTimelineJSXMessages, getUsersActionJsx, parseReply, parseTimelineChange, - scrollToBottom, - isAtBottom, - autoScrollToBottom, }; diff --git a/src/client/initMatrix.js b/src/client/initMatrix.js index 05f499c..d82b28a 100644 --- a/src/client/initMatrix.js +++ b/src/client/initMatrix.js @@ -1,5 +1,6 @@ import EventEmitter from 'events'; import * as sdk from 'matrix-js-sdk'; +import { logger } from 'matrix-js-sdk/lib/logger'; import { secret } from './state/auth'; import RoomList from './state/RoomList'; @@ -8,6 +9,8 @@ import Notifications from './state/Notifications'; global.Olm = require('@matrix-org/olm'); +logger.disableAll(); + class InitMatrix extends EventEmitter { async init() { await this.startClient(); diff --git a/src/client/state/Notifications.js b/src/client/state/Notifications.js index 1ab9b5b..95ba2ff 100644 --- a/src/client/state/Notifications.js +++ b/src/client/state/Notifications.js @@ -5,6 +5,7 @@ class Notifications extends EventEmitter { constructor(roomList) { super(); + this.supportEvents = ['m.room.message', 'm.room.encrypted', 'm.sticker']; this.matrixClient = roomList.matrixClient; this.roomList = roomList; @@ -33,7 +34,6 @@ class Notifications extends EventEmitter { doesRoomHaveUnread(room) { const userId = this.matrixClient.getUserId(); const readUpToId = room.getEventReadUpTo(userId); - const supportEvents = ['m.room.message', 'm.room.encrypted', 'm.sticker']; if (room.timeline.length && room.timeline[room.timeline.length - 1].sender @@ -47,7 +47,7 @@ class Notifications extends EventEmitter { if (event.getId() === readUpToId) return false; - if (supportEvents.includes(event.getType())) { + if (this.supportEvents.includes(event.getType())) { return true; } } @@ -149,8 +149,7 @@ class Notifications extends EventEmitter { _listenEvents() { this.matrixClient.on('Room.timeline', (mEvent, room) => { - const supportEvents = ['m.room.message', 'm.room.encrypted', 'm.sticker']; - if (!supportEvents.includes(mEvent.getType())) return; + if (!this.supportEvents.includes(mEvent.getType())) return; const lastTimelineEvent = room.timeline[room.timeline.length - 1]; if (lastTimelineEvent.getId() !== mEvent.getId()) return; diff --git a/src/client/state/RoomTimeline.js b/src/client/state/RoomTimeline.js index f434ee8..a6ea59a 100644 --- a/src/client/state/RoomTimeline.js +++ b/src/client/state/RoomTimeline.js @@ -2,15 +2,39 @@ import EventEmitter from 'events'; import initMatrix from '../initMatrix'; import cons from './cons'; +function isEdited(mEvent) { + return mEvent.getRelation()?.rel_type === 'm.replace'; +} + +function isReaction(mEvent) { + return mEvent.getType() === 'm.reaction'; +} + +function getRelateToId(mEvent) { + const relation = mEvent.getRelation(); + return relation && relation.event_id; +} + +function addToMap(myMap, mEvent) { + const relateToId = getRelateToId(mEvent); + if (relateToId === null) return null; + + if (typeof myMap.get(relateToId) === 'undefined') myMap.set(relateToId, []); + myMap.get(relateToId).push(mEvent); + return mEvent; +} + class RoomTimeline extends EventEmitter { constructor(roomId) { super(); this.matrixClient = initMatrix.matrixClient; this.roomId = roomId; this.room = this.matrixClient.getRoom(roomId); - this.timeline = this.room.timeline; - this.editedTimeline = this.getEditedTimeline(); - this.reactionTimeline = this.getReactionTimeline(); + + this.timeline = new Map(); + this.editedTimeline = new Map(); + this.reactionTimeline = new Map(); + this.isOngoingPagination = false; this.ongoingDecryptionCount = 0; this.typingMembers = new Set(); @@ -23,31 +47,30 @@ class RoomTimeline extends EventEmitter { return; } - this.timeline = this.room.timeline; - if (this.isEdited(event)) { - this.addToMap(this.editedTimeline, event); - } - if (this.isReaction(event)) { - this.addToMap(this.reactionTimeline, event); - } - if (this.ongoingDecryptionCount !== 0) return; if (this.isOngoingPagination) return; - this.emit(cons.events.roomTimeline.EVENT); - }; - this._listenRedaction = (event, room) => { - if (room.roomId !== this.roomId) return; + this.addToTimeline(event); this.emit(cons.events.roomTimeline.EVENT); }; this._listenDecryptEvent = (event) => { if (event.getRoomId() !== this.roomId) return; - if (this.ongoingDecryptionCount > 0) this.ongoingDecryptionCount -= 1; - this.timeline = this.room.timeline; + if (this.ongoingDecryptionCount > 0) { + this.ongoingDecryptionCount -= 1; + } + if (this.ongoingDecryptionCount > 0) return; - if (this.ongoingDecryptionCount !== 0) return; + if (this.isOngoingPagination) return; + this.emit(cons.events.roomTimeline.EVENT); + }; + + this._listenRedaction = (event, room) => { + if (room.roomId !== this.roomId) return; + this.timeline.delete(event.getId()); + this.editedTimeline.delete(event.getId()); + this.reactionTimeline.delete(event.getId()); this.emit(cons.events.roomTimeline.EVENT); }; @@ -63,7 +86,7 @@ class RoomTimeline extends EventEmitter { if (room.roomId !== this.roomId) return; const receiptContent = event.getContent(); if (this.timeline.length === 0) return; - const tmlLastEvent = this.timeline[this.timeline.length - 1]; + const tmlLastEvent = room.timeline[room.timeline.length - 1]; const lastEventId = tmlLastEvent.getId(); const lastEventRecipt = receiptContent[lastEventId]; if (typeof lastEventRecipt === 'undefined') return; @@ -82,78 +105,53 @@ class RoomTimeline extends EventEmitter { window.selectedRoom = this; if (this.isEncryptedRoom()) this.room.decryptAllEvents(); + this._populateTimelines(); } isEncryptedRoom() { return this.matrixClient.isRoomEncrypted(this.roomId); } - // eslint-disable-next-line class-methods-use-this - isEdited(mEvent) { - return mEvent.getRelation()?.rel_type === 'm.replace'; + 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); } - // eslint-disable-next-line class-methods-use-this - getRelateToId(mEvent) { - const relation = mEvent.getRelation(); - return relation && relation.event_id; - } - - addToMap(myMap, mEvent) { - const relateToId = this.getRelateToId(mEvent); - if (relateToId === null) return null; - - if (typeof myMap.get(relateToId) === 'undefined') myMap.set(relateToId, []); - myMap.get(relateToId).push(mEvent); - return mEvent; - } - - getEditedTimeline() { - const mReplace = new Map(); - this.timeline.forEach((mEvent) => { - if (this.isEdited(mEvent)) { - this.addToMap(mReplace, mEvent); - } - }); - - return mReplace; - } - - // eslint-disable-next-line class-methods-use-this - isReaction(mEvent) { - return mEvent.getType() === 'm.reaction'; - } - - getReactionTimeline() { - const mReaction = new Map(); - this.timeline.forEach((mEvent) => { - if (this.isReaction(mEvent)) { - this.addToMap(mReaction, mEvent); - } - }); - - return mReaction; + _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); + this.emit(cons.events.roomTimeline.PAGINATED, false, 0); return; } - this.editedTimeline = this.getEditedTimeline(); - this.reactionTimeline = this.getReactionTimeline(); + this._populateTimelines(); + const loaded = this.timeline.size - oldSize; - this.isOngoingPagination = false; if (this.isEncryptedRoom()) await this.room.decryptAllEvents(); - this.emit(cons.events.roomTimeline.PAGINATED, true); + this.isOngoingPagination = false; + this.emit(cons.events.roomTimeline.PAGINATED, true, loaded); }); } diff --git a/src/client/state/cons.js b/src/client/state/cons.js index 9a30d1e..412cbb7 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -12,6 +12,7 @@ const cons = { HOME: 'home', DIRECTS: 'dm', }, + supportEventTypes: ['m.room.create', 'm.room.message', 'm.room.encrypted', 'm.room.member', 'm.sticker'], notifs: { DEFAULT: 'default', ALL_MESSAGES: 'all_messages', diff --git a/src/util/common.js b/src/util/common.js index 2c8942b..00e3ad4 100644 --- a/src/util/common.js +++ b/src/util/common.js @@ -84,3 +84,13 @@ export function getUrlPrams(paramName) { const urlParams = new URLSearchParams(queryString); return urlParams.get(paramName); } + +export function getScrollInfo(target) { + const scroll = {}; + 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; +}