From 15f4417c68f136e87e77bf4a4dde440c24a8c83a Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:33:39 +0200 Subject: [PATCH] fix(app): scope near-viewport loading to the surrounding scroller (#1863) --- .../components/pages/MessageListPage.tsx | 136 ++++++------- .../popouts/RecentMentionsContent.tsx | 119 ++++++------ .../popouts/SavedMessagesContent.tsx | 180 +++++++++--------- .../messaging/hooks/useNearViewport.ts | 52 +++-- 4 files changed, 265 insertions(+), 222 deletions(-) diff --git a/fluxer_app/src/features/messaging/components/pages/MessageListPage.tsx b/fluxer_app/src/features/messaging/components/pages/MessageListPage.tsx index 2aa149718..298eff5f6 100644 --- a/fluxer_app/src/features/messaging/components/pages/MessageListPage.tsx +++ b/fluxer_app/src/features/messaging/components/pages/MessageListPage.tsx @@ -9,6 +9,7 @@ import {JUMP_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors import styles from '@app/features/messaging/components/pages/MessageListPage.module.css'; import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation'; import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy'; +import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils/ChannelTextareaFocusUtils'; import {goToMessage} from '@app/features/messaging/utils/MessageNavigator'; @@ -20,7 +21,7 @@ import {useLingui} from '@lingui/react/macro'; import {FlagCheckeredIcon, SparkleIcon} from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; import type React from 'react'; -import {useRef} from 'react'; +import {useMemo, useRef} from 'react'; const YOU_VE_REACHED_THE_END_DESCRIPTOR = msg({ message: "You've reached the end", @@ -51,6 +52,7 @@ export const MessageListPage = observer( }: MessageListPageProps) => { const {i18n} = useLingui(); const scrollerRef = useRef(null); + const resolveListPageScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []); const onCopySelectedMessages = useMessageSelectionCopyForMessages(messages); const leftContent = (
@@ -73,77 +75,79 @@ export const MessageListPage = observer( />
{messages.length > 0 ? ( - - {messages.map((message) => { - const channel = Channels.getChannel(message.channelId); - if (!channel) { - if (renderMissingMessage) { - return renderMissingMessage(message); + + + {messages.map((message) => { + const channel = Channels.getChannel(message.channelId); + if (!channel) { + if (renderMissingMessage) { + return renderMissingMessage(message); + } + return null; } - return null; - } - return ( -
- + +
+ + {renderActionButtons(message)} +
+
+ ); + })} +
+
+ -
- - {renderActionButtons(message)} + {endStateDescription} +

- ); - })} -
-
- -
-

- {i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)} -

-

- {endStateDescription} -

-
-
- + + ) : (
diff --git a/fluxer_app/src/features/messaging/components/popouts/RecentMentionsContent.tsx b/fluxer_app/src/features/messaging/components/popouts/RecentMentionsContent.tsx index ee72aee39..9482f4c19 100644 --- a/fluxer_app/src/features/messaging/components/popouts/RecentMentionsContent.tsx +++ b/fluxer_app/src/features/messaging/components/popouts/RecentMentionsContent.tsx @@ -18,6 +18,7 @@ import headerStyles from '@app/features/messaging/components/popouts/InboxMessag import styles from '@app/features/messaging/components/popouts/RecentMentionsContent.module.css'; import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation'; import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy'; +import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport'; import type {Message as MessagingMessage} from '@app/features/messaging/models/MessagingMessage'; import {goToMessage} from '@app/features/messaging/utils/MessageNavigator'; import * as RecentMentionCommands from '@app/features/notification/commands/RecentMentionCommands'; @@ -217,6 +218,7 @@ export const RecentMentionsContent = observer( const isLoadingMore = MentionFeed.getIsLoadingMore(); const filterButtonRef = useRef(null); const scrollerRef = useRef(null); + const resolveMentionsScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []); const {isOpen: isFilterMenuOpen, withTracking} = useContextMenuTrigger(); const accessibleMentions = MentionFeed.getAccessibleMentions(); const onCopySelectedMessages = useMessageSelectionCopyForMessages(accessibleMentions); @@ -354,64 +356,69 @@ export const RecentMentionsContent = observer( ); } return ( - - {groupedMentions.map((group) => { - return ( -
- {group.mentions.map((message) => { - const channel = Channels.getChannel(message.channelId); - if (!channel) return null; - return ( - - ); - })} -
- ); - })} - {isLoadingMore && ( -
- -
- )} - {!hasMore && !isLoadingMore && ( -
-
- -
-

- You've reached the end -

-

- You've seen all your recent mentions. More will appear here soon. -

+ + + {groupedMentions.map((group) => { + return ( +
+ {group.mentions.map((message) => { + const channel = Channels.getChannel(message.channelId); + if (!channel) return null; + return ( + + ); + })} +
+ ); + })} + {isLoadingMore && ( +
+ +
+ )} + {!hasMore && !isLoadingMore && ( +
+
+ +
+

+ You've reached the end +

+

+ You've seen all your recent mentions. More will appear here soon. +

+
-
- )} - + )} + + ); }, ); diff --git a/fluxer_app/src/features/messaging/components/popouts/SavedMessagesContent.tsx b/fluxer_app/src/features/messaging/components/popouts/SavedMessagesContent.tsx index 3813c3ccf..fbaf1c2a6 100644 --- a/fluxer_app/src/features/messaging/components/popouts/SavedMessagesContent.tsx +++ b/fluxer_app/src/features/messaging/components/popouts/SavedMessagesContent.tsx @@ -13,6 +13,7 @@ import headerStyles from '@app/features/messaging/components/popouts/InboxMessag import styles from '@app/features/messaging/components/popouts/SavedMessagesContent.module.css'; import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation'; import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy'; +import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport'; import SavedMessages from '@app/features/messaging/state/SavedMessages'; import {goToMessage} from '@app/features/messaging/utils/MessageNavigator'; import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller'; @@ -24,7 +25,7 @@ import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import {FlagCheckeredIcon, SparkleIcon, XIcon} from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; -import {useCallback, useEffect, useRef} from 'react'; +import {useCallback, useEffect, useMemo, useRef} from 'react'; const NO_BOOKMARKS_DESCRIPTOR = msg({ message: 'No bookmarks', @@ -49,6 +50,7 @@ export const SavedMessagesContent = observer(() => { const {i18n} = useLingui(); const {savedMessages, missingSavedMessages, fetched} = SavedMessages; const scrollerRef = useRef(null); + const resolveSavedScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []); const onCopySelectedMessages = useMessageSelectionCopyForMessages(savedMessages); const renderMissingSavedMessage = useCallback( (entryId: string) => ( @@ -105,99 +107,105 @@ export const SavedMessagesContent = observer(() => { ); } return ( - - {missingSavedMessages.map((entry) => renderMissingSavedMessage(entry.id))} - {savedMessages.map((message) => { - const channel = Channels.getChannel(message.channelId); - if (!channel) { - return renderMissingSavedMessage(message.id); - } - return ( -
- handleJumpToMessage(message.channelId, message.id)} - rightActions={ - - + + + {missingSavedMessages.map((entry) => renderMissingSavedMessage(entry.id))} + {savedMessages.map((message) => { + const channel = Channels.getChannel(message.channelId); + if (!channel) { + return renderMissingSavedMessage(message.id); + } + return ( +
+ handleJumpToMessage(message.channelId, message.id)} + rightActions={ + + + + + + } + data-flx="messaging.saved-messages-content.inbox-message-header.jump-to-message" + /> +
+ handleJumpToMessage(message.channelId, message.id)} + data-flx="messaging.saved-messages-content.message" + /> +
+ - - } - data-flx="messaging.saved-messages-content.inbox-message-header.jump-to-message" - /> -
- handleJumpToMessage(message.channelId, message.id)} - data-flx="messaging.saved-messages-content.message" - /> -
- - - +
-
- ); - })} -
-
- -
-

- {i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)} -

-

- {i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)} -

+ ); + })} +
+
+ +
+

+ {i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)} +

+

+ {i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)} +

+
-
- + + ); }); diff --git a/fluxer_app/src/features/messaging/hooks/useNearViewport.ts b/fluxer_app/src/features/messaging/hooks/useNearViewport.ts index 981ce546b..ec352d879 100644 --- a/fluxer_app/src/features/messaging/hooks/useNearViewport.ts +++ b/fluxer_app/src/features/messaging/hooks/useNearViewport.ts @@ -2,14 +2,27 @@ import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver'; import {LRUCache} from 'lru-cache'; -import {useCallback, useEffect, useState} from 'react'; +import {createContext, useCallback, useContext, useEffect, useState} from 'react'; -const DEFAULT_ROOT_MARGIN = '900px 0px'; +const DEFAULT_ROOT_MARGIN = '300px'; const REMEMBERED_VIEWPORT_KEY_LIMIT = 1000; const rememberedViewportKeys = new LRUCache({ max: REMEMBERED_VIEWPORT_KEY_LIMIT, }); +export type ScrollSurfaceResolver = () => Element | null; + +const resolveNoScrollSurface: ScrollSurfaceResolver = () => null; + +export const NearViewportSurfaceContext = createContext(resolveNoScrollSurface); + +export function resolveObserverRoot(resolve: ScrollSurfaceResolver, element: Element): Element | null { + const surface = resolve(); + if (surface == null || surface === element) return null; + if (!surface.contains(element)) return null; + return surface; +} + interface UseNearViewportOptions { disabled?: boolean; rememberKey?: string | null; @@ -23,6 +36,7 @@ export function useNearViewport({ rootMargin = DEFAULT_ROOT_MARGIN, threshold = 0, }: UseNearViewportOptions = {}): {ref: (node: T | null) => void; isNearViewport: boolean} { + const resolveScrollSurface = useContext(NearViewportSurfaceContext); const loadImmediately = disabled || typeof IntersectionObserver === 'undefined'; const wasRemembered = rememberKey ? rememberedViewportKeys.has(rememberKey) : false; const [element, setElement] = useState(null); @@ -36,24 +50,34 @@ export function useNearViewport({ } }, [disabled]); useEffect(() => { - if (isNearViewport && rememberKey) { - rememberedViewportKeys.set(rememberKey, true); - } - }, [isNearViewport, rememberKey]); + if (!isNearViewport || !rememberKey) return; + if (disabled || typeof IntersectionObserver === 'undefined') return; + rememberedViewportKeys.set(rememberKey, true); + }, [disabled, isNearViewport, rememberKey]); useEffect(() => { if (disabled || isNearViewport || !element) return undefined; - return observeIntersection( + if (rememberKey && rememberedViewportKeys.has(rememberKey)) { + setIsNearViewport(true); + return undefined; + } + let release: (() => void) | null = null; + const stopObserving = () => { + release?.(); + release = null; + }; + release = observeIntersection( element, (entry) => { - if (entry.isIntersecting || entry.intersectionRatio > 0) { - if (rememberKey) { - rememberedViewportKeys.set(rememberKey, true); - } - setIsNearViewport(true); + if (!entry.isIntersecting && entry.intersectionRatio <= 0) return; + stopObserving(); + if (rememberKey) { + rememberedViewportKeys.set(rememberKey, true); } + setIsNearViewport(true); }, - {rootMargin, threshold}, + {root: resolveObserverRoot(resolveScrollSurface, element), rootMargin, threshold}, ); - }, [disabled, element, isNearViewport, rememberKey, rootMargin, threshold]); + return stopObserving; + }, [disabled, element, isNearViewport, rememberKey, resolveScrollSurface, rootMargin, threshold]); return {ref, isNearViewport}; }