Added unread indicator (#67), reply link back to original (#96)
authorAjay Bura <ajbura@gmail.com>
Fri, 3 Dec 2021 13:02:10 +0000 (18:32 +0530)
committerAjay Bura <ajbura@gmail.com>
Fri, 3 Dec 2021 13:02:10 +0000 (18:32 +0530)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
21 files changed:
src/app/hooks/useForceUpdate.js [new file with mode: 0644]
src/app/hooks/useStore.js [new file with mode: 0644]
src/app/organisms/profile-viewer/ProfileViewer.jsx
src/app/organisms/read-receipts/ReadReceipts.jsx
src/app/organisms/room/Room.jsx
src/app/organisms/room/RoomView.jsx
src/app/organisms/room/RoomViewCmdBar.jsx
src/app/organisms/room/RoomViewContent.jsx
src/app/organisms/room/RoomViewContent.scss
src/app/organisms/room/RoomViewFloating.jsx
src/app/organisms/room/RoomViewFloating.scss
src/app/organisms/room/RoomViewInput.jsx
src/client/action/navigation.js
src/client/initMatrix.js
src/client/state/Notifications.js
src/client/state/RoomTimeline.js
src/client/state/cons.js
src/client/state/navigation.js
src/index.scss
src/util/common.js
src/util/matrixUtil.js

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