Add pagination in room timeline

Signed-off-by: Ajay Bura <ajbura@gmail.com>
This commit is contained in:
Ajay Bura 2021-11-18 13:32:12 +05:30
parent beb32755a3
commit 57697142a2
12 changed files with 305 additions and 235 deletions

View file

@ -26,6 +26,7 @@
& button { & button {
cursor: pointer; cursor: pointer;
display: flex;
} }
[dir=rtl] & { [dir=rtl] & {

View file

@ -5,6 +5,7 @@ import './RoomView.scss';
import EventEmitter from 'events'; import EventEmitter from 'events';
import RoomTimeline from '../../../client/state/RoomTimeline'; import RoomTimeline from '../../../client/state/RoomTimeline';
import { Debounce, getScrollInfo } from '../../../util/common';
import ScrollView from '../../atoms/scroll/ScrollView'; import ScrollView from '../../atoms/scroll/ScrollView';
@ -14,98 +15,125 @@ import RoomViewFloating from './RoomViewFloating';
import RoomViewInput from './RoomViewInput'; import RoomViewInput from './RoomViewInput';
import RoomViewCmdBar from './RoomViewCmdBar'; import RoomViewCmdBar from './RoomViewCmdBar';
import { scrollToBottom, isAtBottom, autoScrollToBottom } from './common';
const viewEvent = new EventEmitter(); const viewEvent = new EventEmitter();
let lastScrollTop = 0;
let lastScrollHeight = 0;
let isReachedBottom = true;
let isReachedTop = false;
function RoomView({ roomId }) { function RoomView({ roomId }) {
const [roomTimeline, updateRoomTimeline] = useState(null); const [roomTimeline, updateRoomTimeline] = useState(null);
const [debounce] = useState(new Debounce());
const timelineSVRef = useRef(null); const timelineSVRef = useRef(null);
useEffect(() => { useEffect(() => {
roomTimeline?.removeInternalListeners(); roomTimeline?.removeInternalListeners();
updateRoomTimeline(new RoomTimeline(roomId)); updateRoomTimeline(new RoomTimeline(roomId));
isReachedBottom = true;
isReachedTop = false;
}, [roomId]); }, [roomId]);
const timelineScroll = { const timelineScroll = {
reachBottom() { 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() { autoReachBottom() {
autoScrollToBottom(timelineSVRef); if (timelineScroll.position === 'BOTTOM') timelineScroll.reachBottom();
}, },
tryRestoringScroll() { tryRestoringScroll() {
timelineScroll.isOngoing = true;
const sv = timelineSVRef.current; const sv = timelineSVRef.current;
const { scrollHeight } = sv; const {
lastTopMsg, lastBottomMsg,
diff, isInTopHalf, lastTop,
} = timelineScroll;
if (lastScrollHeight === scrollHeight) return; if (lastTopMsg === null) {
sv.scrollTop = sv.scrollHeight;
if (lastScrollHeight < scrollHeight) { return;
sv.scrollTop = lastScrollTop + (scrollHeight - lastScrollHeight);
} else {
timelineScroll.reachBottom();
} }
const ot = isInTopHalf ? lastTopMsg?.offsetTop : lastBottomMsg?.offsetTop;
if (!ot) sv.scrollTop = lastTop;
else sv.scrollTop = ot - diff;
}, },
enableSmoothScroll() { position: 'BOTTOM',
timelineSVRef.current.style.scrollBehavior = 'smooth'; isScrollable: false,
}, isInTopHalf: false,
disableSmoothScroll() { maxEvents: 50,
timelineSVRef.current.style.scrollBehavior = 'auto'; lastTop: 0,
}, lastHeight: 0,
isScrollable() { lastViewHeight: 0,
const oHeight = timelineSVRef.current.offsetHeight; lastTopMsg: null,
const sHeight = timelineSVRef.current.scrollHeight; lastBottomMsg: null,
if (sHeight > oHeight) return true; diff: 0,
return false; isOngoing: false,
},
}; };
function onTimelineScroll(e) { const calcScroll = (target) => {
const { scrollTop, scrollHeight, offsetHeight } = e.target; if (timelineScroll.isOngoing) {
const scrollBottom = scrollTop + offsetHeight; timelineScroll.isOngoing = false;
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');
return; 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 const isPaginateBack = scroll.top < PLACEHOLDER_HEIGHT;
if (scrollBottom > bottomPagKeyPoint) { const isPaginateForward = scroll.bottom > (scroll.height - PLACEHOLDER_HEIGHT);
// TODO: 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 ( return (
<div className="room-view"> <div className="room-view">
<RoomViewHeader roomId={roomId} /> <RoomViewHeader roomId={roomId} />
<div className="room-view__content-wrapper"> <div className="room-view__content-wrapper">
<div className="room-view__scrollable"> <div className="room-view__scrollable">
<ScrollView onScroll={onTimelineScroll} ref={timelineSVRef} autoHide> <ScrollView onScroll={handleTimelineScroll} ref={timelineSVRef} autoHide>
{roomTimeline !== null && ( {roomTimeline !== null && (
<RoomViewContent <RoomViewContent
roomId={roomId} roomId={roomId}
@ -119,7 +147,6 @@ function RoomView({ roomId }) {
<RoomViewFloating <RoomViewFloating
roomId={roomId} roomId={roomId}
roomTimeline={roomTimeline} roomTimeline={roomTimeline}
timelineScroll={timelineScroll}
viewEvent={viewEvent} viewEvent={viewEvent}
/> />
)} )}

View file

@ -146,7 +146,8 @@ function FollowingMembers({ roomId, roomTimeline, viewEvent }) {
}; };
}, [roomTimeline]); }, [roomTimeline]);
const lastMEvent = roomTimeline.timeline[roomTimeline.timeline.length - 1]; const { timeline } = roomTimeline.room;
const lastMEvent = timeline[timeline.length - 1];
return followingMembers.length !== 0 && ( return followingMembers.length !== 0 && (
<TimelineChange <TimelineChange
variant="follow" variant="follow"

View file

@ -43,13 +43,12 @@ import { parseReply, parseTimelineChange } from './common';
const MAX_MSG_DIFF_MINUTES = 5; const MAX_MSG_DIFF_MINUTES = 5;
function genPlaceholders() { function genPlaceholders(key) {
return ( return (
<> <React.Fragment key={`placeholder-container${key}`}>
<PlaceholderMessage key="placeholder-1" /> <PlaceholderMessage key={`placeholder-1${key}`} />
<PlaceholderMessage key="placeholder-2" /> <PlaceholderMessage key={`placeholder-2${key}`} />
<PlaceholderMessage key="placeholder-3" /> </React.Fragment>
</>
); );
} }
@ -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({ function RoomViewContent({
roomId, roomTimeline, timelineScroll, viewEvent, roomId, roomTimeline, timelineScroll, viewEvent,
}) { }) {
const [isReachedTimelineEnd, setIsReachedTimelineEnd] = useState(false); const [isReachedTimelineEnd, setIsReachedTimelineEnd] = useState(false);
const [onStateUpdate, updateState] = useState(null); const [onStateUpdate, updateState] = useState(null);
const [onPagination, setOnPagination] = useState(null);
const [editEvent, setEditEvent] = useState(null); const [editEvent, setEditEvent] = useState(null);
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
const noti = initMatrix.notifications; 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() { function autoLoadTimeline() {
if (timelineScroll.isScrollable() === true) return; if (timelineScroll.isScrollable === true) return;
roomTimeline.paginateBack(); roomTimeline.paginateBack();
} }
function trySendingReadReceipt() { function trySendingReadReceipt() {
const { room, timeline } = roomTimeline; const { timeline } = roomTimeline.room;
if ( if (
(noti.doesRoomHaveUnread(room) || noti.hasNoti(roomId)) (noti.doesRoomHaveUnread(roomTimeline.room) || noti.hasNoti(roomId))
&& timeline.length !== 0) { && timeline.length !== 0) {
mx.sendReadReceipt(timeline[timeline.length - 1]); mx.sendReadReceipt(timeline[timeline.length - 1]);
} }
} }
function onReachedTop() { const getNewFrom = (position) => {
if (roomTimeline.isOngoingPagination || isReachedTimelineEnd) return; let newFrom = scroll.from;
roomTimeline.paginateBack(); const tSize = roomTimeline.timeline.size;
} const doPaginate = tSize > timelineScroll.maxEvents;
function toggleOnReachedBottom(isBottom) { if (!doPaginate || scroll.from < 0) newFrom = 0;
wasAtBottom = isBottom; const newEventCount = Math.round(timelineScroll.maxEvents / 2);
if (!isBottom) return; scroll.limit = timelineScroll.maxEvents;
trySendingReadReceipt();
}
const updatePAG = (canPagMore) => { if (position === 'TOP' && doPaginate) newFrom -= newEventCount;
if (!canPagMore) { if (position === 'BOTTOM' && doPaginate) newFrom += newEventCount;
setIsReachedTimelineEnd(true);
} else { if (newFrom >= tSize || scroll.getEndIndex() >= tSize) newFrom = tSize - scroll.limit - 1;
setOnPagination({}); if (newFrom < 0) newFrom = 0;
autoLoadTimeline(); 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 // force update RoomTimeline on cons.events.roomTimeline.EVENT
const updateRT = () => { const updateRT = () => {
if (wasAtBottom) { if (timelineScroll.position === 'BOTTOM') {
trySendingReadReceipt(); trySendingReadReceipt();
scroll.from = roomTimeline.timeline.size - scroll.limit - 1;
if (scroll.from < 0) scroll.from = 0;
scroll.isNewEvent = true;
} }
updateState({}); updateState({});
}; };
const handleScrollToLive = () => {
scroll.from = roomTimeline.timeline.size - scroll.limit - 1;
if (scroll.from < 0) scroll.from = 0;
scroll.isNewEvent = true;
updateState({});
};
useEffect(() => { useEffect(() => {
setIsReachedTimelineEnd(false); trySendingReadReceipt();
wasAtBottom = true; return () => {
setIsReachedTimelineEnd(false);
scroll.limit = 0;
};
}, [roomId]); }, [roomId]);
useEffect(() => trySendingReadReceipt(), [roomTimeline]);
// init room setup completed. // init room setup completed.
// listen for future. setup stateUpdate listener. // listen for future. setup stateUpdate listener.
useEffect(() => { useEffect(() => {
roomTimeline.on(cons.events.roomTimeline.EVENT, updateRT); roomTimeline.on(cons.events.roomTimeline.EVENT, updateRT);
roomTimeline.on(cons.events.roomTimeline.PAGINATED, updatePAG); roomTimeline.on(cons.events.roomTimeline.PAGINATED, updatePAG);
viewEvent.on('reached-top', onReachedTop); viewEvent.on('timeline-scroll', handleTimelineScroll);
viewEvent.on('toggle-reached-bottom', toggleOnReachedBottom); viewEvent.on('scroll-to-live', handleScrollToLive);
return () => { return () => {
roomTimeline.removeListener(cons.events.roomTimeline.EVENT, updateRT); roomTimeline.removeListener(cons.events.roomTimeline.EVENT, updateRT);
roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, updatePAG); roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, updatePAG);
viewEvent.removeListener('reached-top', onReachedTop); viewEvent.removeListener('timeline-scroll', handleTimelineScroll);
viewEvent.removeListener('toggle-reached-bottom', toggleOnReachedBottom); viewEvent.removeListener('scroll-to-live', handleScrollToLive);
}; };
}, [roomTimeline, isReachedTimelineEnd, onPagination]); }, [roomTimeline, isReachedTimelineEnd]);
useLayoutEffect(() => { useLayoutEffect(() => {
timelineScroll.reachBottom(); timelineScroll.reachBottom();
autoLoadTimeline(); autoLoadTimeline();
trySendingReadReceipt();
}, [roomTimeline]); }, [roomTimeline]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (onPagination === null) return; if (onStateUpdate === null || scroll.isNewEvent) {
timelineScroll.tryRestoringScroll(); scroll.isNewEvent = false;
}, [onPagination]); timelineScroll.reachBottom();
return;
useEffect(() => { }
if (onStateUpdate === null) return; if (timelineScroll.isScrollable) {
if (wasAtBottom) timelineScroll.reachBottom(); timelineScroll.tryRestoringScroll();
} else {
timelineScroll.reachBottom();
autoLoadTimeline();
}
}, [onStateUpdate]); }, [onStateUpdate]);
let prevMEvent = null; let prevMEvent = null;
function genMessage(mEvent) { 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 canIRedact = roomTimeline.room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel);
const isContentOnly = ( const isContentOnly = (
@ -521,18 +573,12 @@ function RoomViewContent({
} }
function renderMessage(mEvent) { function renderMessage(mEvent) {
if (mEvent.getType() === 'm.room.create') return genRoomIntro(mEvent, roomTimeline); if (!cons.supportEventTypes.includes(mEvent.getType())) return false;
if (
mEvent.getType() !== 'm.room.message'
&& mEvent.getType() !== 'm.room.encrypted'
&& mEvent.getType() !== 'm.room.member'
&& mEvent.getType() !== 'm.sticker'
) return false;
if (mEvent.getRelation()?.rel_type === 'm.replace') return false; if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
// ignore if message is deleted
if (mEvent.isRedacted()) return false; if (mEvent.isRedacted()) return false;
if (mEvent.getType() === 'm.room.create') return genRoomIntro(mEvent, roomTimeline);
let divider = null; let divider = null;
if (prevMEvent !== null && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate())) { if (prevMEvent !== null && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate())) {
divider = <Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />; divider = <Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />;
@ -551,7 +597,7 @@ function RoomViewContent({
prevMEvent = mEvent; prevMEvent = mEvent;
const timelineChange = parseTimelineChange(mEvent); const timelineChange = parseTimelineChange(mEvent);
if (timelineChange === null) return null; if (timelineChange === null) return false;
return ( return (
<React.Fragment key={`box-${mEvent.getId()}`}> <React.Fragment key={`box-${mEvent.getId()}`}>
{divider} {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 ( return (
<div className="room-view__content"> <div className="room-view__content">
<div className="timeline__wrapper"> <div className="timeline__wrapper">
{ roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && genPlaceholders() } { renderTimeline() }
{ roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && genRoomIntro(undefined, roomTimeline)}
{ roomTimeline.timeline.map(renderMessage) }
</div> </div>
</div> </div>
); );

View file

@ -14,7 +14,7 @@ import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.s
import { getUsersActionJsx } from './common'; import { getUsersActionJsx } from './common';
function RoomViewFloating({ function RoomViewFloating({
roomId, roomTimeline, timelineScroll, viewEvent, roomId, roomTimeline, viewEvent,
}) { }) {
const [reachedBottom, setReachedBottom] = useState(true); const [reachedBottom, setReachedBottom] = useState(true);
const [typingMembers, setTypingMembers] = useState(new Set()); const [typingMembers, setTypingMembers] = useState(new Set());
@ -36,12 +36,15 @@ function RoomViewFloating({
function updateTyping(members) { function updateTyping(members) {
setTypingMembers(members); setTypingMembers(members);
} }
const handleTimelineScroll = (position) => {
setReachedBottom(position === 'BOTTOM');
};
useEffect(() => { useEffect(() => {
setReachedBottom(true); setReachedBottom(true);
setTypingMembers(new Set()); setTypingMembers(new Set());
viewEvent.on('toggle-reached-bottom', setReachedBottom); viewEvent.on('timeline-scroll', handleTimelineScroll);
return () => viewEvent.removeListener('toggle-reached-bottom', setReachedBottom); return () => viewEvent.removeListener('timeline-scroll', handleTimelineScroll);
}, [roomId]); }, [roomId]);
useEffect(() => { useEffect(() => {
@ -60,9 +63,8 @@ function RoomViewFloating({
<div className={`room-view__STB${reachedBottom ? '' : ' room-view__STB--open'}`}> <div className={`room-view__STB${reachedBottom ? '' : ' room-view__STB--open'}`}>
<IconButton <IconButton
onClick={() => { onClick={() => {
timelineScroll.enableSmoothScroll(); viewEvent.emit('scroll-to-live');
timelineScroll.reachBottom(); setReachedBottom(true);
timelineScroll.disableSmoothScroll();
}} }}
src={ChevronBottomIC} src={ChevronBottomIC}
tooltip="Scroll to Bottom" tooltip="Scroll to Bottom"
@ -74,9 +76,6 @@ function RoomViewFloating({
RoomViewFloating.propTypes = { RoomViewFloating.propTypes = {
roomId: PropTypes.string.isRequired, roomId: PropTypes.string.isRequired,
roomTimeline: PropTypes.shape({}).isRequired, roomTimeline: PropTypes.shape({}).isRequired,
timelineScroll: PropTypes.shape({
reachBottom: PropTypes.func,
}).isRequired,
viewEvent: PropTypes.shape({}).isRequired, viewEvent: PropTypes.shape({}).isRequired,
}; };

View file

@ -210,7 +210,7 @@ function RoomViewInput({
focusInput(); focusInput();
textAreaRef.current.value = roomsInput.getMessage(roomId); textAreaRef.current.value = roomsInput.getMessage(roomId);
timelineScroll.reachBottom(); viewEvent.emit('scroll-to-live');
viewEvent.emit('message_sent'); viewEvent.emit('message_sent');
textAreaRef.current.style.height = 'unset'; textAreaRef.current.style.height = 'unset';
if (replyTo !== null) setReplyTo(null); if (replyTo !== null) setReplyTo(null);
@ -433,13 +433,7 @@ function RoomViewInput({
RoomViewInput.propTypes = { RoomViewInput.propTypes = {
roomId: PropTypes.string.isRequired, roomId: PropTypes.string.isRequired,
roomTimeline: PropTypes.shape({}).isRequired, roomTimeline: PropTypes.shape({}).isRequired,
timelineScroll: PropTypes.shape({ timelineScroll: PropTypes.shape({}).isRequired,
reachBottom: PropTypes.func,
autoReachBottom: PropTypes.func,
tryRestoringScroll: PropTypes.func,
enableSmoothScroll: PropTypes.func,
disableSmoothScroll: PropTypes.func,
}).isRequired,
viewEvent: PropTypes.shape({}).isRequired, viewEvent: PropTypes.shape({}).isRequired,
}; };

View file

@ -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 { export {
getTimelineJSXMessages, getTimelineJSXMessages,
getUsersActionJsx, getUsersActionJsx,
parseReply, parseReply,
parseTimelineChange, parseTimelineChange,
scrollToBottom,
isAtBottom,
autoScrollToBottom,
}; };

View file

@ -1,5 +1,6 @@
import EventEmitter from 'events'; import EventEmitter from 'events';
import * as sdk from 'matrix-js-sdk'; import * as sdk from 'matrix-js-sdk';
import { logger } from 'matrix-js-sdk/lib/logger';
import { secret } from './state/auth'; import { secret } from './state/auth';
import RoomList from './state/RoomList'; import RoomList from './state/RoomList';
@ -8,6 +9,8 @@ import Notifications from './state/Notifications';
global.Olm = require('@matrix-org/olm'); global.Olm = require('@matrix-org/olm');
logger.disableAll();
class InitMatrix extends EventEmitter { class InitMatrix extends EventEmitter {
async init() { async init() {
await this.startClient(); await this.startClient();

View file

@ -5,6 +5,7 @@ class Notifications extends EventEmitter {
constructor(roomList) { constructor(roomList) {
super(); super();
this.supportEvents = ['m.room.message', 'm.room.encrypted', 'm.sticker'];
this.matrixClient = roomList.matrixClient; this.matrixClient = roomList.matrixClient;
this.roomList = roomList; this.roomList = roomList;
@ -33,7 +34,6 @@ class Notifications extends EventEmitter {
doesRoomHaveUnread(room) { doesRoomHaveUnread(room) {
const userId = this.matrixClient.getUserId(); const userId = this.matrixClient.getUserId();
const readUpToId = room.getEventReadUpTo(userId); const readUpToId = room.getEventReadUpTo(userId);
const supportEvents = ['m.room.message', 'm.room.encrypted', 'm.sticker'];
if (room.timeline.length if (room.timeline.length
&& room.timeline[room.timeline.length - 1].sender && room.timeline[room.timeline.length - 1].sender
@ -47,7 +47,7 @@ class Notifications extends EventEmitter {
if (event.getId() === readUpToId) return false; if (event.getId() === readUpToId) return false;
if (supportEvents.includes(event.getType())) { if (this.supportEvents.includes(event.getType())) {
return true; return true;
} }
} }
@ -149,8 +149,7 @@ class Notifications extends EventEmitter {
_listenEvents() { _listenEvents() {
this.matrixClient.on('Room.timeline', (mEvent, room) => { this.matrixClient.on('Room.timeline', (mEvent, room) => {
const supportEvents = ['m.room.message', 'm.room.encrypted', 'm.sticker']; if (!this.supportEvents.includes(mEvent.getType())) return;
if (!supportEvents.includes(mEvent.getType())) return;
const lastTimelineEvent = room.timeline[room.timeline.length - 1]; const lastTimelineEvent = room.timeline[room.timeline.length - 1];
if (lastTimelineEvent.getId() !== mEvent.getId()) return; if (lastTimelineEvent.getId() !== mEvent.getId()) return;

View file

@ -2,15 +2,39 @@ import EventEmitter from 'events';
import initMatrix from '../initMatrix'; import initMatrix from '../initMatrix';
import cons from './cons'; 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 { class RoomTimeline extends EventEmitter {
constructor(roomId) { constructor(roomId) {
super(); super();
this.matrixClient = initMatrix.matrixClient; this.matrixClient = initMatrix.matrixClient;
this.roomId = roomId; this.roomId = roomId;
this.room = this.matrixClient.getRoom(roomId); this.room = this.matrixClient.getRoom(roomId);
this.timeline = this.room.timeline;
this.editedTimeline = this.getEditedTimeline(); this.timeline = new Map();
this.reactionTimeline = this.getReactionTimeline(); this.editedTimeline = new Map();
this.reactionTimeline = new Map();
this.isOngoingPagination = false; this.isOngoingPagination = false;
this.ongoingDecryptionCount = 0; this.ongoingDecryptionCount = 0;
this.typingMembers = new Set(); this.typingMembers = new Set();
@ -23,31 +47,30 @@ class RoomTimeline extends EventEmitter {
return; 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.ongoingDecryptionCount !== 0) return;
if (this.isOngoingPagination) return; if (this.isOngoingPagination) return;
this.emit(cons.events.roomTimeline.EVENT);
};
this._listenRedaction = (event, room) => { this.addToTimeline(event);
if (room.roomId !== this.roomId) return;
this.emit(cons.events.roomTimeline.EVENT); this.emit(cons.events.roomTimeline.EVENT);
}; };
this._listenDecryptEvent = (event) => { this._listenDecryptEvent = (event) => {
if (event.getRoomId() !== this.roomId) return; if (event.getRoomId() !== this.roomId) return;
if (this.ongoingDecryptionCount > 0) this.ongoingDecryptionCount -= 1; if (this.ongoingDecryptionCount > 0) {
this.timeline = this.room.timeline; 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); this.emit(cons.events.roomTimeline.EVENT);
}; };
@ -63,7 +86,7 @@ class RoomTimeline extends EventEmitter {
if (room.roomId !== this.roomId) return; if (room.roomId !== this.roomId) return;
const receiptContent = event.getContent(); const receiptContent = event.getContent();
if (this.timeline.length === 0) return; 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 lastEventId = tmlLastEvent.getId();
const lastEventRecipt = receiptContent[lastEventId]; const lastEventRecipt = receiptContent[lastEventId];
if (typeof lastEventRecipt === 'undefined') return; if (typeof lastEventRecipt === 'undefined') return;
@ -82,78 +105,53 @@ class RoomTimeline extends EventEmitter {
window.selectedRoom = this; window.selectedRoom = this;
if (this.isEncryptedRoom()) this.room.decryptAllEvents(); if (this.isEncryptedRoom()) this.room.decryptAllEvents();
this._populateTimelines();
} }
isEncryptedRoom() { isEncryptedRoom() {
return this.matrixClient.isRoomEncrypted(this.roomId); return this.matrixClient.isRoomEncrypted(this.roomId);
} }
// eslint-disable-next-line class-methods-use-this addToTimeline(mEvent) {
isEdited(mEvent) { if (isReaction(mEvent)) {
return mEvent.getRelation()?.rel_type === 'm.replace'; 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 _populateTimelines() {
getRelateToId(mEvent) { this.timeline.clear();
const relation = mEvent.getRelation(); this.reactionTimeline.clear();
return relation && relation.event_id; this.editedTimeline.clear();
} this.room.timeline.forEach((mEvent) => this.addToTimeline(mEvent));
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;
} }
paginateBack() { paginateBack() {
if (this.isOngoingPagination) return; if (this.isOngoingPagination) return;
this.isOngoingPagination = true; this.isOngoingPagination = true;
const oldSize = this.timeline.size;
const MSG_LIMIT = 30; const MSG_LIMIT = 30;
this.matrixClient.scrollback(this.room, MSG_LIMIT).then(async (room) => { this.matrixClient.scrollback(this.room, MSG_LIMIT).then(async (room) => {
if (room.oldState.paginationToken === null) { if (room.oldState.paginationToken === null) {
// We have reached start of the timeline // We have reached start of the timeline
this.isOngoingPagination = false; this.isOngoingPagination = false;
if (this.isEncryptedRoom()) await this.room.decryptAllEvents(); if (this.isEncryptedRoom()) await this.room.decryptAllEvents();
this.emit(cons.events.roomTimeline.PAGINATED, false); this.emit(cons.events.roomTimeline.PAGINATED, false, 0);
return; return;
} }
this.editedTimeline = this.getEditedTimeline(); this._populateTimelines();
this.reactionTimeline = this.getReactionTimeline(); const loaded = this.timeline.size - oldSize;
this.isOngoingPagination = false;
if (this.isEncryptedRoom()) await this.room.decryptAllEvents(); 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);
}); });
} }

View file

@ -12,6 +12,7 @@ const cons = {
HOME: 'home', HOME: 'home',
DIRECTS: 'dm', DIRECTS: 'dm',
}, },
supportEventTypes: ['m.room.create', 'm.room.message', 'm.room.encrypted', 'm.room.member', 'm.sticker'],
notifs: { notifs: {
DEFAULT: 'default', DEFAULT: 'default',
ALL_MESSAGES: 'all_messages', ALL_MESSAGES: 'all_messages',

View file

@ -84,3 +84,13 @@ export function getUrlPrams(paramName) {
const urlParams = new URLSearchParams(queryString); const urlParams = new URLSearchParams(queryString);
return urlParams.get(paramName); 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;
}