Add pagination in room timeline
authorAjay Bura <ajbura@gmail.com>
Thu, 18 Nov 2021 08:02:12 +0000 (13:32 +0530)
committerAjay Bura <ajbura@gmail.com>
Thu, 18 Nov 2021 08:02:12 +0000 (13:32 +0530)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
12 files changed:
src/app/molecules/message/Message.scss
src/app/organisms/room/RoomView.jsx
src/app/organisms/room/RoomViewCmdBar.jsx
src/app/organisms/room/RoomViewContent.jsx
src/app/organisms/room/RoomViewFloating.jsx
src/app/organisms/room/RoomViewInput.jsx
src/app/organisms/room/common.jsx
src/client/initMatrix.js
src/client/state/Notifications.js
src/client/state/RoomTimeline.js
src/client/state/cons.js
src/util/common.js

index dbc13c9a790d0a0df6966bb9986e2b543364b19e..f523ed61dd03337d3069fbe7d5e6a58d3c78be42 100644 (file)
@@ -26,6 +26,7 @@
 
     & button {
       cursor: pointer;
+      display: flex;
     }
 
     [dir=rtl] & {
index edb427d2eb5183adfbaf4c9c9b38d3a6f8c7f0dc..867073a4667a885235cb4612e66069f576c261e6 100644 (file)
@@ -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;
-
-      if (lastScrollHeight === scrollHeight) return;
-
-      if (lastScrollHeight < scrollHeight) {
-        sv.scrollTop = lastScrollTop + (scrollHeight - lastScrollHeight);
-      } else {
-        timelineScroll.reachBottom();
+      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;
     },
-    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);
+  const calcScroll = (target) => {
+    if (timelineScroll.isOngoing) {
+      timelineScroll.isOngoing = false;
+      return;
     }
-    if (isReachedBottom && !isAtBottom(timelineSVRef)) {
-      isReachedBottom = false;
-      viewEvent.emit('toggle-reached-bottom', false);
+    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;
     }
-    // TOP of timeline
-    if (scrollTop < topPagKeyPoint && isReachedTop === false) {
-      isReachedTop = true;
-      viewEvent.emit('reached-top');
-      return;
+    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;
     }
-    isReachedTop = false;
 
-    // BOTTOM of timeline
-    if (scrollBottom > bottomPagKeyPoint) {
-      // TODO:
+    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 (
     <div className="room-view">
       <RoomViewHeader roomId={roomId} />
       <div className="room-view__content-wrapper">
         <div className="room-view__scrollable">
-          <ScrollView onScroll={onTimelineScroll} ref={timelineSVRef} autoHide>
+          <ScrollView onScroll={handleTimelineScroll} ref={timelineSVRef} autoHide>
             {roomTimeline !== null && (
               <RoomViewContent
                 roomId={roomId}
@@ -119,7 +147,6 @@ function RoomView({ roomId }) {
             <RoomViewFloating
               roomId={roomId}
               roomTimeline={roomTimeline}
-              timelineScroll={timelineScroll}
               viewEvent={viewEvent}
             />
           )}
index 1f632810c7bacdbbfa616f900b79f69f9c55ad79..68b2e52b8858c730153fb42b72bc61eb2749b4ca 100644 (file)
@@ -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 && (
     <TimelineChange
       variant="follow"
index f61a17397cfda921a6836a3b52167c3190bde835..0f5e1bfc67542023265a4156c575136e50bf8acb 100644 (file)
@@ -43,13 +43,12 @@ import { parseReply, parseTimelineChange } from './common';
 
 const MAX_MSG_DIFF_MINUTES = 5;
 
-function genPlaceholders() {
+function genPlaceholders(key) {
   return (
-    <>
-      <PlaceholderMessage key="placeholder-1" />
-      <PlaceholderMessage key="placeholder-2" />
-      <PlaceholderMessage key="placeholder-3" />
-    </>
+    <React.Fragment key={`placeholder-container${key}`}>
+      <PlaceholderMessage key={`placeholder-1${key}`} />
+      <PlaceholderMessage key={`placeholder-2${key}`} />
+    </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({
   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 = <Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />;
@@ -551,7 +597,7 @@ function RoomViewContent({
 
     prevMEvent = mEvent;
     const timelineChange = parseTimelineChange(mEvent);
-    if (timelineChange === null) return null;
+    if (timelineChange === null) return false;
     return (
       <React.Fragment key={`box-${mEvent.getId()}`}>
         {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 (
     <div className="room-view__content">
       <div className="timeline__wrapper">
-        { 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() }
       </div>
     </div>
   );
index 6ee66995c5387dca2550334d475e6b2403e19d9d..4ba79a498befc7647ca2c6d1e635654d2ac9005b 100644 (file)
@@ -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({
       <div className={`room-view__STB${reachedBottom ? '' : ' room-view__STB--open'}`}>
         <IconButton
           onClick={() => {
-            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,
 };
 
index e23b5c1a200b0df576beb70fbbd7eacaab2c2480..5b18fcb9dcf992ad92e65f000e3a4621aec57406 100644 (file)
@@ -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,
 };
 
index 2d876d7e08f39e3afab89e66fcf1cced5c18a79c..e25eabb4ef787eb6bdc13558b79ff9099dd1a09e 100644 (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 {
   getTimelineJSXMessages,
   getUsersActionJsx,
   parseReply,
   parseTimelineChange,
-  scrollToBottom,
-  isAtBottom,
-  autoScrollToBottom,
 };
index 05f499cce7138b4b8f1b57f6234a174ce92b258f..d82b28a7c3a87bd30317bb68ab6d787841f8c270 100644 (file)
@@ -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();
index 1ab9b5b1ba42cdc677bfb9da597d6719a4978abf..95ba2ffe896e1e12d9ea5f55e61b5d4fb0405089 100644 (file)
@@ -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;
index f434ee82cce1ea97db9feac3060d8dd1da67b36a..a6ea59ac43c9925b5e87e7d2fd2333ced4fa59d1 100644 (file)
@@ -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);
     });
   }
 
index 9a30d1eeb517ee18a94cf5e3d569ebfcfdadf0bc..412cbb70eb61306aa62805156d8676431507ea6e 100644 (file)
@@ -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',
index 2c8942b80eb9f565e3310f523b78d6ca7e85c3e9..00e3ad4ad5079d0aad422c70984a98a456466d63 100644 (file)
@@ -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;
+}