scroll to bottom in unfocused window but stop sending read receipt (#2214)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Fri, 21 Feb 2025 08:18:02 +0000 (19:18 +1100)
committerGitHub <noreply@github.com>
Fri, 21 Feb 2025 08:18:02 +0000 (19:18 +1100)
* scroll to bottom in unfocused window but stop sending read receipt

* send read-receipt when new message are in view after regaining focus

src/app/features/room/RoomTimeline.tsx
src/app/hooks/useVirtualPaginator.ts

index 38b67baa472c856d2e455d3df7411b6523a041bd..f6854b43130e72548aed7e14b8a374f86933eb3e 100644 (file)
@@ -586,15 +586,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         // so timeline can be updated with evt like: edits, reactions etc
         if (atBottomRef.current) {
           if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
+            // Check if the document is in focus (user is actively viewing the app),
+            // and either there are no unread messages or the latest message is from the current user.
+            // If either condition is met, trigger the markAsRead function to send a read receipt.
             requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!));
           }
 
-          if (document.hasFocus()) {
-            scrollToBottomRef.current.count += 1;
-            scrollToBottomRef.current.smooth = true;
-          } else if (!unreadInfo) {
+          if (!document.hasFocus() && !unreadInfo) {
             setUnreadInfo(getRoomUnreadInfo(room));
           }
+
+          scrollToBottomRef.current.count += 1;
+          scrollToBottomRef.current.smooth = true;
+
           setTimeline((ct) => ({
             ...ct,
             range: {
@@ -613,6 +617,36 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
     )
   );
 
+  const handleOpenEvent = useCallback(
+    async (
+      evtId: string,
+      highlight = true,
+      onScroll: ((scrolled: boolean) => void) | undefined = undefined
+    ) => {
+      const evtTimeline = getEventTimeline(room, evtId);
+      const absoluteIndex =
+        evtTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, evtTimeline, evtId);
+
+      if (typeof absoluteIndex === 'number') {
+        const scrolled = scrollToItem(absoluteIndex, {
+          behavior: 'smooth',
+          align: 'center',
+          stopInView: true,
+        });
+        if (onScroll) onScroll(scrolled);
+        setFocusItem({
+          index: absoluteIndex,
+          scrollTo: false,
+          highlight,
+        });
+      } else {
+        setTimeline(getEmptyTimeline());
+        loadEventTimeline(evtId);
+      }
+    },
+    [room, timeline, scrollToItem, loadEventTimeline]
+  );
+
   useLiveTimelineRefresh(
     room,
     useCallback(() => {
@@ -646,16 +680,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
   );
 
   const tryAutoMarkAsRead = useCallback(() => {
-    if (!unreadInfo) {
+    const readUptoEventId = readUptoEventIdRef.current;
+    if (!readUptoEventId) {
       requestAnimationFrame(() => markAsRead(mx, room.roomId));
       return;
     }
-    const evtTimeline = getEventTimeline(room, unreadInfo.readUptoEventId);
+    const evtTimeline = getEventTimeline(room, readUptoEventId);
     const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
     if (latestTimeline === room.getLiveTimeline()) {
       requestAnimationFrame(() => markAsRead(mx, room.roomId));
     }
-  }, [mx, room, unreadInfo]);
+  }, [mx, room]);
 
   const debounceSetAtBottom = useDebounce(
     useCallback((entry: IntersectionObserverEntry) => {
@@ -672,7 +707,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         if (targetEntry) debounceSetAtBottom(targetEntry);
         if (targetEntry?.isIntersecting && atLiveEndRef.current) {
           setAtBottom(true);
-          tryAutoMarkAsRead();
+          if (document.hasFocus()) {
+            tryAutoMarkAsRead();
+          }
         }
       },
       [debounceSetAtBottom, tryAutoMarkAsRead]
@@ -691,10 +728,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
     useCallback(
       (inFocus) => {
         if (inFocus && atBottomRef.current) {
+          if (unreadInfo?.inLiveTimeline) {
+            handleOpenEvent(unreadInfo.readUptoEventId, false, (scrolled) => {
+              // the unread event is already in view
+              // so, try mark as read;
+              if (!scrolled) {
+                tryAutoMarkAsRead();
+              }
+            });
+            return;
+          }
           tryAutoMarkAsRead();
         }
       },
-      [tryAutoMarkAsRead]
+      [tryAutoMarkAsRead, unreadInfo, handleOpenEvent]
     )
   );
 
@@ -832,27 +879,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
     async (evt) => {
       const targetId = evt.currentTarget.getAttribute('data-event-id');
       if (!targetId) return;
-      const replyTimeline = getEventTimeline(room, targetId);
-      const absoluteIndex =
-        replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, targetId);
-
-      if (typeof absoluteIndex === 'number') {
-        scrollToItem(absoluteIndex, {
-          behavior: 'smooth',
-          align: 'center',
-          stopInView: true,
-        });
-        setFocusItem({
-          index: absoluteIndex,
-          scrollTo: false,
-          highlight: true,
-        });
-      } else {
-        setTimeline(getEmptyTimeline());
-        loadEventTimeline(targetId);
-      }
+      handleOpenEvent(targetId);
     },
-    [room, timeline, scrollToItem, loadEventTimeline]
+    [handleOpenEvent]
   );
 
   const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
index 9ffc7f914d96941f751d9ec502bc1bb75fe81741..5ad056a6ccf82acb8421dd93c2cd772a937b58fe 100644 (file)
@@ -26,8 +26,23 @@ export type ScrollToOptions = {
   stopInView?: boolean;
 };
 
-export type ScrollToElement = (element: HTMLElement, opts?: ScrollToOptions) => void;
-export type ScrollToItem = (index: number, opts?: ScrollToOptions) => void;
+/**
+ * Scrolls the page to a specified element in the DOM.
+ *
+ * @param {HTMLElement} element - The DOM element to scroll to.
+ * @param {ScrollToOptions} [opts] - Optional configuration for the scroll behavior (e.g., smooth scrolling, alignment).
+ * @returns {boolean} - Returns `true` if the scroll was successful, otherwise returns `false`.
+ */
+export type ScrollToElement = (element: HTMLElement, opts?: ScrollToOptions) => boolean;
+
+/**
+ * Scrolls the page to an item at the specified index within a scrollable container.
+ *
+ * @param {number} index - The index of the item to scroll to.
+ * @param {ScrollToOptions} [opts] - Optional configuration for the scroll behavior (e.g., smooth scrolling, alignment).
+ * @returns {boolean} - Returns `true` if the scroll was successful, otherwise returns `false`.
+ */
+export type ScrollToItem = (index: number, opts?: ScrollToOptions) => boolean;
 
 type HandleObserveAnchor = (element: HTMLElement | null) => void;
 
@@ -186,10 +201,10 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
   const scrollToElement = useCallback<ScrollToElement>(
     (element, opts) => {
       const scrollElement = getScrollElement();
-      if (!scrollElement) return;
+      if (!scrollElement) return false;
 
       if (opts?.stopInView && isInScrollView(scrollElement, element)) {
-        return;
+        return false;
       }
       let scrollTo = element.offsetTop;
       if (opts?.align === 'center' && canFitInScrollView(scrollElement, element)) {
@@ -207,6 +222,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
         top: scrollTo - (opts?.offset ?? 0),
         behavior: opts?.behavior,
       });
+      return true;
     },
     [getScrollElement]
   );
@@ -215,7 +231,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
     (index, opts) => {
       const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current;
 
-      if (index < 0 || index >= currentCount) return;
+      if (index < 0 || index >= currentCount) return false;
       // index is not in range change range
       // and trigger scrollToItem in layoutEffect hook
       if (index < currentRange.start || index >= currentRange.end) {
@@ -227,7 +243,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
           index,
           opts,
         };
-        return;
+        return true;
       }
 
       // find target or it's previous rendered element to scroll to
@@ -241,9 +257,9 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
           top: opts?.offset ?? 0,
           behavior: opts?.behavior,
         });
-        return;
+        return true;
       }
-      scrollToElement(itemElement, opts);
+      return scrollToElement(itemElement, opts);
     },
     [getScrollElement, scrollToElement, getItemElement, onRangeChange]
   );