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
This commit is contained in:
Ajay Bura 2025-02-21 19:18:02 +11:00 committed by GitHub
parent 59e8d66255
commit b63868bbb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 82 additions and 37 deletions

View file

@ -586,15 +586,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// so timeline can be updated with evt like: edits, reactions etc // so timeline can be updated with evt like: edits, reactions etc
if (atBottomRef.current) { if (atBottomRef.current) {
if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) { 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()!)); requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!));
} }
if (document.hasFocus()) { if (!document.hasFocus() && !unreadInfo) {
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
} else if (!unreadInfo) {
setUnreadInfo(getRoomUnreadInfo(room)); setUnreadInfo(getRoomUnreadInfo(room));
} }
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
setTimeline((ct) => ({ setTimeline((ct) => ({
...ct, ...ct,
range: { 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( useLiveTimelineRefresh(
room, room,
useCallback(() => { useCallback(() => {
@ -646,16 +680,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
); );
const tryAutoMarkAsRead = useCallback(() => { const tryAutoMarkAsRead = useCallback(() => {
if (!unreadInfo) { const readUptoEventId = readUptoEventIdRef.current;
if (!readUptoEventId) {
requestAnimationFrame(() => markAsRead(mx, room.roomId)); requestAnimationFrame(() => markAsRead(mx, room.roomId));
return; return;
} }
const evtTimeline = getEventTimeline(room, unreadInfo.readUptoEventId); const evtTimeline = getEventTimeline(room, readUptoEventId);
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward); const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
if (latestTimeline === room.getLiveTimeline()) { if (latestTimeline === room.getLiveTimeline()) {
requestAnimationFrame(() => markAsRead(mx, room.roomId)); requestAnimationFrame(() => markAsRead(mx, room.roomId));
} }
}, [mx, room, unreadInfo]); }, [mx, room]);
const debounceSetAtBottom = useDebounce( const debounceSetAtBottom = useDebounce(
useCallback((entry: IntersectionObserverEntry) => { useCallback((entry: IntersectionObserverEntry) => {
@ -672,8 +707,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (targetEntry) debounceSetAtBottom(targetEntry); if (targetEntry) debounceSetAtBottom(targetEntry);
if (targetEntry?.isIntersecting && atLiveEndRef.current) { if (targetEntry?.isIntersecting && atLiveEndRef.current) {
setAtBottom(true); setAtBottom(true);
if (document.hasFocus()) {
tryAutoMarkAsRead(); tryAutoMarkAsRead();
} }
}
}, },
[debounceSetAtBottom, tryAutoMarkAsRead] [debounceSetAtBottom, tryAutoMarkAsRead]
), ),
@ -691,10 +728,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useCallback( useCallback(
(inFocus) => { (inFocus) => {
if (inFocus && atBottomRef.current) { 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] [tryAutoMarkAsRead, unreadInfo, handleOpenEvent]
) )
); );
@ -832,27 +879,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
async (evt) => { async (evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id'); const targetId = evt.currentTarget.getAttribute('data-event-id');
if (!targetId) return; if (!targetId) return;
const replyTimeline = getEventTimeline(room, targetId); handleOpenEvent(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);
}
}, },
[room, timeline, scrollToItem, loadEventTimeline] [handleOpenEvent]
); );
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback( const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(

View file

@ -26,8 +26,23 @@ export type ScrollToOptions = {
stopInView?: boolean; 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; type HandleObserveAnchor = (element: HTMLElement | null) => void;
@ -186,10 +201,10 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
const scrollToElement = useCallback<ScrollToElement>( const scrollToElement = useCallback<ScrollToElement>(
(element, opts) => { (element, opts) => {
const scrollElement = getScrollElement(); const scrollElement = getScrollElement();
if (!scrollElement) return; if (!scrollElement) return false;
if (opts?.stopInView && isInScrollView(scrollElement, element)) { if (opts?.stopInView && isInScrollView(scrollElement, element)) {
return; return false;
} }
let scrollTo = element.offsetTop; let scrollTo = element.offsetTop;
if (opts?.align === 'center' && canFitInScrollView(scrollElement, element)) { if (opts?.align === 'center' && canFitInScrollView(scrollElement, element)) {
@ -207,6 +222,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
top: scrollTo - (opts?.offset ?? 0), top: scrollTo - (opts?.offset ?? 0),
behavior: opts?.behavior, behavior: opts?.behavior,
}); });
return true;
}, },
[getScrollElement] [getScrollElement]
); );
@ -215,7 +231,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
(index, opts) => { (index, opts) => {
const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current; 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 // index is not in range change range
// and trigger scrollToItem in layoutEffect hook // and trigger scrollToItem in layoutEffect hook
if (index < currentRange.start || index >= currentRange.end) { if (index < currentRange.start || index >= currentRange.end) {
@ -227,7 +243,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
index, index,
opts, opts,
}; };
return; return true;
} }
// find target or it's previous rendered element to scroll to // 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, top: opts?.offset ?? 0,
behavior: opts?.behavior, behavior: opts?.behavior,
}); });
return; return true;
} }
scrollToElement(itemElement, opts); return scrollToElement(itemElement, opts);
}, },
[getScrollElement, scrollToElement, getItemElement, onRangeChange] [getScrollElement, scrollToElement, getItemElement, onRangeChange]
); );