fix(app): scope near-viewport loading to the surrounding scroller (#1863)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:33:39 +02:00
1 parent 943b948de7
commit 15f4417c68
4 files changed
+265 -222

No files matched your search

@@ -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<ScrollerHandle | null>(null);
const resolveListPageScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(messages);
const leftContent = (
<div className={styles.header} data-flx="messaging.message-list-page.header">
@@ -73,77 +75,79 @@ export const MessageListPage = observer(
/>
<div className={styles.content} data-flx="messaging.message-list-page.content">
{messages.length > 0 ? (
<Scroller
className={styles.scroller}
key="message-list-page-scroller"
ref={scrollerRef}
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="messaging.message-list-page.scroller"
>
{messages.map((message) => {
const channel = Channels.getChannel(message.channelId);
if (!channel) {
if (renderMissingMessage) {
return renderMissingMessage(message);
<NearViewportSurfaceContext.Provider value={resolveListPageScrollSurface}>
<Scroller
className={styles.scroller}
key="message-list-page-scroller"
ref={scrollerRef}
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="messaging.message-list-page.scroller"
>
{messages.map((message) => {
const channel = Channels.getChannel(message.channelId);
if (!channel) {
if (renderMissingMessage) {
return renderMissingMessage(message);
}
return null;
}
return null;
}
return (
<div
key={message.id}
className={previewStyles.previewCard}
data-message-id={message.id}
data-is-group-start="true"
data-flx="messaging.message-list-page.div"
>
<MessageComponent
message={message}
channel={channel}
previewContext={MessagePreviewContext.LIST_POPOUT}
data-flx="messaging.message-list-page.message-component"
return (
<div
key={message.id}
className={previewStyles.previewCard}
data-message-id={message.id}
data-is-group-start="true"
data-flx="messaging.message-list-page.div"
>
<MessageComponent
message={message}
channel={channel}
previewContext={MessagePreviewContext.LIST_POPOUT}
data-flx="messaging.message-list-page.message-component"
/>
<div className={previewStyles.actionButtons} data-flx="messaging.message-list-page.div--2">
<button
type="button"
className={previewStyles.actionButton}
onClick={() => {
const path = channel.guildId
? Routes.guildChannel(channel.guildId, channel.id)
: Routes.dmChannel(channel.id);
RouterUtils.transitionTo(path);
goToMessage(message.channelId, message.id);
focusChannelTextareaAfterNavigation(message.channelId);
}}
data-flx="messaging.message-list-page.button.transition-to"
>
{i18n._(JUMP_DESCRIPTOR)}
</button>
{renderActionButtons(message)}
</div>
</div>
);
})}
<div className={styles.endState} data-flx="messaging.message-list-page.end-state">
<div className={styles.endStateContent} data-flx="messaging.message-list-page.end-state-content">
<FlagCheckeredIcon
className={styles.endStateIcon}
data-flx="messaging.message-list-page.end-state-icon"
/>
<div className={previewStyles.actionButtons} data-flx="messaging.message-list-page.div--2">
<button
type="button"
className={previewStyles.actionButton}
onClick={() => {
const path = channel.guildId
? Routes.guildChannel(channel.guildId, channel.id)
: Routes.dmChannel(channel.id);
RouterUtils.transitionTo(path);
goToMessage(message.channelId, message.id);
focusChannelTextareaAfterNavigation(message.channelId);
}}
data-flx="messaging.message-list-page.button.transition-to"
<div className={styles.endStateText} data-flx="messaging.message-list-page.end-state-text">
<h3 className={styles.endStateTitle} data-flx="messaging.message-list-page.end-state-title">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p
className={styles.endStateDescription}
data-flx="messaging.message-list-page.end-state-description"
>
{i18n._(JUMP_DESCRIPTOR)}
</button>
{renderActionButtons(message)}
{endStateDescription}
</p>
</div>
</div>
);
})}
<div className={styles.endState} data-flx="messaging.message-list-page.end-state">
<div className={styles.endStateContent} data-flx="messaging.message-list-page.end-state-content">
<FlagCheckeredIcon
className={styles.endStateIcon}
data-flx="messaging.message-list-page.end-state-icon"
/>
<div className={styles.endStateText} data-flx="messaging.message-list-page.end-state-text">
<h3 className={styles.endStateTitle} data-flx="messaging.message-list-page.end-state-title">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p
className={styles.endStateDescription}
data-flx="messaging.message-list-page.end-state-description"
>
{endStateDescription}
</p>
</div>
</div>
</div>
</Scroller>
</Scroller>
</NearViewportSurfaceContext.Provider>
) : (
<div className={styles.emptyState} data-flx="messaging.message-list-page.empty-state">
<div className={styles.emptyStateContent} data-flx="messaging.message-list-page.empty-state-content">
@@ -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<HTMLButtonElement>(null);
const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolveMentionsScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const {isOpen: isFilterMenuOpen, withTracking} = useContextMenuTrigger();
const accessibleMentions = MentionFeed.getAccessibleMentions();
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(accessibleMentions);
@@ -354,64 +356,69 @@ export const RecentMentionsContent = observer(
);
}
return (
<Scroller
className={styles.scroller}
onScroll={handleScroll}
key="recent-mentions-scroller"
ref={scrollerRef}
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="messaging.recent-mentions-content.scroller"
>
{groupedMentions.map((group) => {
return (
<section
key={group.key}
className={styles.guildGroup}
aria-label={group.label}
data-flx="messaging.recent-mentions-content.guild-group"
>
{group.mentions.map((message) => {
const channel = Channels.getChannel(message.channelId);
if (!channel) return null;
return (
<MentionMessageCard
key={message.id}
message={message}
channel={channel}
onJump={handleJumpToMessage}
onRemove={handleRemoveMention}
data-flx="messaging.recent-mentions-content.mention-message-card"
/>
);
})}
</section>
);
})}
{isLoadingMore && (
<div className={previewStyles.loadingState} data-flx="messaging.recent-mentions-content.div--7">
<Spinner data-flx="messaging.recent-mentions-content.spinner--2" />
</div>
)}
{!hasMore && !isLoadingMore && (
<div className={previewStyles.endState} data-flx="messaging.recent-mentions-content.div--8">
<div className={previewStyles.endStateContent} data-flx="messaging.recent-mentions-content.div--9">
<FlagCheckeredIcon
className={previewStyles.endStateIcon}
data-flx="messaging.recent-mentions-content.flag-checkered-icon"
/>
<div className={previewStyles.endStateTextContainer} data-flx="messaging.recent-mentions-content.div--10">
<h3 className={previewStyles.endStateTitle} data-flx="messaging.recent-mentions-content.h3--2">
<Trans>You've reached the end</Trans>
</h3>
<p className={previewStyles.endStateDescription} data-flx="messaging.recent-mentions-content.p--2">
<Trans>You've seen all your recent mentions. More will appear here soon.</Trans>
</p>
<NearViewportSurfaceContext.Provider value={resolveMentionsScrollSurface}>
<Scroller
className={styles.scroller}
onScroll={handleScroll}
key="recent-mentions-scroller"
ref={scrollerRef}
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="messaging.recent-mentions-content.scroller"
>
{groupedMentions.map((group) => {
return (
<section
key={group.key}
className={styles.guildGroup}
aria-label={group.label}
data-flx="messaging.recent-mentions-content.guild-group"
>
{group.mentions.map((message) => {
const channel = Channels.getChannel(message.channelId);
if (!channel) return null;
return (
<MentionMessageCard
key={message.id}
message={message}
channel={channel}
onJump={handleJumpToMessage}
onRemove={handleRemoveMention}
data-flx="messaging.recent-mentions-content.mention-message-card"
/>
);
})}
</section>
);
})}
{isLoadingMore && (
<div className={previewStyles.loadingState} data-flx="messaging.recent-mentions-content.div--7">
<Spinner data-flx="messaging.recent-mentions-content.spinner--2" />
</div>
)}
{!hasMore && !isLoadingMore && (
<div className={previewStyles.endState} data-flx="messaging.recent-mentions-content.div--8">
<div className={previewStyles.endStateContent} data-flx="messaging.recent-mentions-content.div--9">
<FlagCheckeredIcon
className={previewStyles.endStateIcon}
data-flx="messaging.recent-mentions-content.flag-checkered-icon"
/>
<div
className={previewStyles.endStateTextContainer}
data-flx="messaging.recent-mentions-content.div--10"
>
<h3 className={previewStyles.endStateTitle} data-flx="messaging.recent-mentions-content.h3--2">
<Trans>You've reached the end</Trans>
</h3>
<p className={previewStyles.endStateDescription} data-flx="messaging.recent-mentions-content.p--2">
<Trans>You've seen all your recent mentions. More will appear here soon.</Trans>
</p>
</div>
</div>
</div>
</div>
)}
</Scroller>
)}
</Scroller>
</NearViewportSurfaceContext.Provider>
);
},
);
@@ -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<ScrollerHandle | null>(null);
const resolveSavedScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(savedMessages);
const renderMissingSavedMessage = useCallback(
(entryId: string) => (
@@ -105,99 +107,105 @@ export const SavedMessagesContent = observer(() => {
);
}
return (
<Scroller
className={styles.scroller}
key="saved-messages-scroller"
ref={scrollerRef}
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="messaging.saved-messages-content.scroller"
>
{missingSavedMessages.map((entry) => renderMissingSavedMessage(entry.id))}
{savedMessages.map((message) => {
const channel = Channels.getChannel(message.channelId);
if (!channel) {
return renderMissingSavedMessage(message.id);
}
return (
<div key={message.id} className={styles.messageCard} data-flx="messaging.saved-messages-content.message-card">
<InboxMessageHeader
channel={channel}
onClick={() => handleJumpToMessage(message.channelId, message.id)}
rightActions={
<Tooltip
text={i18n._(REMOVE_BOOKMARK_DESCRIPTOR)}
position="top"
data-flx="messaging.saved-messages-content.tooltip"
>
<FocusRing offset={-2} data-flx="messaging.saved-messages-content.focus-ring">
<NearViewportSurfaceContext.Provider value={resolveSavedScrollSurface}>
<Scroller
className={styles.scroller}
key="saved-messages-scroller"
ref={scrollerRef}
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="messaging.saved-messages-content.scroller"
>
{missingSavedMessages.map((entry) => renderMissingSavedMessage(entry.id))}
{savedMessages.map((message) => {
const channel = Channels.getChannel(message.channelId);
if (!channel) {
return renderMissingSavedMessage(message.id);
}
return (
<div
key={message.id}
className={styles.messageCard}
data-flx="messaging.saved-messages-content.message-card"
>
<InboxMessageHeader
channel={channel}
onClick={() => handleJumpToMessage(message.channelId, message.id)}
rightActions={
<Tooltip
text={i18n._(REMOVE_BOOKMARK_DESCRIPTOR)}
position="top"
data-flx="messaging.saved-messages-content.tooltip"
>
<FocusRing offset={-2} data-flx="messaging.saved-messages-content.focus-ring">
<button
type="button"
className={headerStyles.headerIconButton}
onClick={() => SavedMessageCommands.remove(i18n, message.id)}
aria-label={i18n._(REMOVE_BOOKMARK_DESCRIPTOR)}
data-flx="messaging.saved-messages-content.button.remove"
>
<XIcon
weight="bold"
className={headerStyles.headerIcon}
data-flx="messaging.saved-messages-content.x-icon"
/>
</button>
</FocusRing>
</Tooltip>
}
data-flx="messaging.saved-messages-content.inbox-message-header.jump-to-message"
/>
<div
className={previewStyles.previewCard}
data-message-id={message.id}
data-is-group-start="true"
data-flx="messaging.saved-messages-content.div--5"
>
<Message
message={message}
channel={channel}
previewContext={MessagePreviewContext.LIST_POPOUT}
behaviorOverrides={readonlyBehaviorOverrides}
suppressMessageActions
onHeadingActivate={() => handleJumpToMessage(message.channelId, message.id)}
data-flx="messaging.saved-messages-content.message"
/>
<div className={previewStyles.actionButtons} data-flx="messaging.saved-messages-content.div--6">
<FocusRing offset={-2} data-flx="messaging.saved-messages-content.focus-ring--2">
<button
type="button"
className={headerStyles.headerIconButton}
onClick={() => SavedMessageCommands.remove(i18n, message.id)}
aria-label={i18n._(REMOVE_BOOKMARK_DESCRIPTOR)}
data-flx="messaging.saved-messages-content.button.remove"
className={previewStyles.actionButton}
onClick={() => {
handleJumpToMessage(message.channelId, message.id);
}}
data-flx="messaging.saved-messages-content.button.jump-to-message"
>
<XIcon
weight="bold"
className={headerStyles.headerIcon}
data-flx="messaging.saved-messages-content.x-icon"
/>
{i18n._(JUMP_DESCRIPTOR)}
</button>
</FocusRing>
</Tooltip>
}
data-flx="messaging.saved-messages-content.inbox-message-header.jump-to-message"
/>
<div
className={previewStyles.previewCard}
data-message-id={message.id}
data-is-group-start="true"
data-flx="messaging.saved-messages-content.div--5"
>
<Message
message={message}
channel={channel}
previewContext={MessagePreviewContext.LIST_POPOUT}
behaviorOverrides={readonlyBehaviorOverrides}
suppressMessageActions
onHeadingActivate={() => handleJumpToMessage(message.channelId, message.id)}
data-flx="messaging.saved-messages-content.message"
/>
<div className={previewStyles.actionButtons} data-flx="messaging.saved-messages-content.div--6">
<FocusRing offset={-2} data-flx="messaging.saved-messages-content.focus-ring--2">
<button
type="button"
className={previewStyles.actionButton}
onClick={() => {
handleJumpToMessage(message.channelId, message.id);
}}
data-flx="messaging.saved-messages-content.button.jump-to-message"
>
{i18n._(JUMP_DESCRIPTOR)}
</button>
</FocusRing>
</div>
</div>
</div>
</div>
);
})}
<div className={previewStyles.endState} data-flx="messaging.saved-messages-content.div--7">
<div className={previewStyles.endStateContent} data-flx="messaging.saved-messages-content.div--8">
<FlagCheckeredIcon
className={previewStyles.endStateIcon}
data-flx="messaging.saved-messages-content.flag-checkered-icon"
/>
<div className={previewStyles.endStateTextContainer} data-flx="messaging.saved-messages-content.div--9">
<h3 className={previewStyles.endStateTitle} data-flx="messaging.saved-messages-content.h3--2">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p className={previewStyles.endStateDescription} data-flx="messaging.saved-messages-content.p--2">
{i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)}
</p>
);
})}
<div className={previewStyles.endState} data-flx="messaging.saved-messages-content.div--7">
<div className={previewStyles.endStateContent} data-flx="messaging.saved-messages-content.div--8">
<FlagCheckeredIcon
className={previewStyles.endStateIcon}
data-flx="messaging.saved-messages-content.flag-checkered-icon"
/>
<div className={previewStyles.endStateTextContainer} data-flx="messaging.saved-messages-content.div--9">
<h3 className={previewStyles.endStateTitle} data-flx="messaging.saved-messages-content.h3--2">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p className={previewStyles.endStateDescription} data-flx="messaging.saved-messages-content.p--2">
{i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)}
</p>
</div>
</div>
</div>
</div>
</Scroller>
</Scroller>
</NearViewportSurfaceContext.Provider>
);
});
@@ -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<string, true>({
max: REMEMBERED_VIEWPORT_KEY_LIMIT,
});
export type ScrollSurfaceResolver = () => Element | null;
const resolveNoScrollSurface: ScrollSurfaceResolver = () => null;
export const NearViewportSurfaceContext = createContext<ScrollSurfaceResolver>(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<T extends Element>({
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<T | null>(null);
@@ -36,24 +50,34 @@ export function useNearViewport<T extends Element>({
}
}, [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};
}