From b63868bbb56294cb800af05fc7a6cb975deb3a05 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Fri, 21 Feb 2025 19:18:02 +1100 Subject: [PATCH] scroll to bottom in unfocused window but stop sending read receipt (#2214) * 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 | 87 +++++++++++++++++--------- src/app/hooks/useVirtualPaginator.ts | 32 +++++++--- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 38b67baa..f6854b43 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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 = useCallback( diff --git a/src/app/hooks/useVirtualPaginator.ts b/src/app/hooks/useVirtualPaginator.ts index 9ffc7f91..5ad056a6 100644 --- a/src/app/hooks/useVirtualPaginator.ts +++ b/src/app/hooks/useVirtualPaginator.ts @@ -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 = ( const scrollToElement = useCallback( (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 = ( top: scrollTo - (opts?.offset ?? 0), behavior: opts?.behavior, }); + return true; }, [getScrollElement] ); @@ -215,7 +231,7 @@ export const useVirtualPaginator = ( (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 = ( index, opts, }; - return; + return true; } // find target or it's previous rendered element to scroll to @@ -241,9 +257,9 @@ export const useVirtualPaginator = ( top: opts?.offset ?? 0, behavior: opts?.behavior, }); - return; + return true; } - scrollToElement(itemElement, opts); + return scrollToElement(itemElement, opts); }, [getScrollElement, scrollToElement, getItemElement, onRangeChange] );