perf(app): load sheet and popout media against their own scrollers (#1900)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:42:09 +02:00
1 parent 94bbbd1021
commit bb34c73069
3 files changed
+217 -200

No files matched your search

@@ -13,6 +13,7 @@ import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import {ensureMembersForMessages} from '@app/features/messaging/commands/MessageCommands'; import {ensureMembersForMessages} from '@app/features/messaging/commands/MessageCommands';
import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation'; import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation';
import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy'; 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 type {Message} from '@app/features/messaging/models/MessagingMessage';
import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils/ChannelTextareaFocusUtils'; import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils/ChannelTextareaFocusUtils';
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator'; import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
@@ -79,6 +80,7 @@ export const ChannelPinsContent = observer(({channel, onJump}: ChannelPinsConten
const canUnpin = isDMChannel || Permission.can(Permissions.MANAGE_MESSAGES, channel); const canUnpin = isDMChannel || Permission.can(Permissions.MANAGE_MESSAGES, channel);
const mobileLayout = MobileLayout; const mobileLayout = MobileLayout;
const scrollerRef = useRef<ScrollerHandle | null>(null); const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolvePinnedScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null); const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
const pinMessagesPermissionLabel = formatPermissionLabel(i18n, Permissions.PIN_MESSAGES); const pinMessagesPermissionLabel = formatPermissionLabel(i18n, Permissions.PIN_MESSAGES);
@@ -187,107 +189,114 @@ export const ChannelPinsContent = observer(({channel, onJump}: ChannelPinsConten
} }
return ( return (
<> <>
<Scroller <NearViewportSurfaceContext.Provider value={resolvePinnedScrollSurface}>
className={clsx(previewStyles.scroller, mobileLayout.enabled && previewStyles.scrollerMobile)} <Scroller
key="channel-pins-scroller" className={clsx(previewStyles.scroller, mobileLayout.enabled && previewStyles.scrollerMobile)}
onScroll={handleScroll} key="channel-pins-scroller"
ref={scrollerRef} onScroll={handleScroll}
onCopy={onCopySelectedMessages} ref={scrollerRef}
data-message-selection-root="true" onCopy={onCopySelectedMessages}
data-flx="app.channel-pins-content.scroller" data-message-selection-root="true"
> data-flx="app.channel-pins-content.scroller"
{mobileLayout.enabled && <div className={previewStyles.topSpacer} data-flx="app.channel-pins-content.div--5" />} >
{pinnedPins.slice().map(({message}) => { {mobileLayout.enabled && (
const cardClasses = clsx(previewStyles.previewCard, mobileLayout.enabled && previewStyles.previewCardMobile); <div className={previewStyles.topSpacer} data-flx="app.channel-pins-content.div--5" />
const messageChannel = )}
Channels.getChannel(message.channelId) ?? (message.channelId === channel.id ? channel : null); {pinnedPins.slice().map(({message}) => {
if (!messageChannel) return null; const cardClasses = clsx(
if (mobileLayout.enabled) { previewStyles.previewCard,
mobileLayout.enabled && previewStyles.previewCardMobile,
);
const messageChannel =
Channels.getChannel(message.channelId) ?? (message.channelId === channel.id ? channel : null);
if (!messageChannel) return null;
if (mobileLayout.enabled) {
return (
<LongPressable
key={message.id}
className={cardClasses}
data-message-id={message.id}
data-is-group-start="true"
role="button"
tabIndex={0}
onClick={() => handleTap(message)}
onKeyDown={(e) => {
if (isKeyboardActivationKey(e.key)) {
e.preventDefault();
handleTap(message);
}
}}
onLongPress={() => {
if (!canUnpin) return;
setSelectedMessage(message);
setMenuOpen(true);
}}
data-flx="app.channel-pins-content.button.tap"
>
<MessageComponent
message={message}
channel={messageChannel}
previewContext={MessagePreviewContext.LIST_POPOUT}
data-flx="app.channel-pins-content.message-component"
/>
</LongPressable>
);
}
return ( return (
<LongPressable <div
key={message.id} key={message.id}
className={cardClasses} className={cardClasses}
data-message-id={message.id} data-message-id={message.id}
data-is-group-start="true" data-is-group-start="true"
role="button" data-flx="app.channel-pins-content.div--6"
tabIndex={0}
onClick={() => handleTap(message)}
onKeyDown={(e) => {
if (isKeyboardActivationKey(e.key)) {
e.preventDefault();
handleTap(message);
}
}}
onLongPress={() => {
if (!canUnpin) return;
setSelectedMessage(message);
setMenuOpen(true);
}}
data-flx="app.channel-pins-content.button.tap"
> >
<MessageComponent <MessageComponent
message={message} message={message}
channel={messageChannel} channel={messageChannel}
previewContext={MessagePreviewContext.LIST_POPOUT} previewContext={MessagePreviewContext.LIST_POPOUT}
data-flx="app.channel-pins-content.message-component" data-flx="app.channel-pins-content.message-component--2"
/> />
</LongPressable> <div className={previewStyles.actionButtons} data-flx="app.channel-pins-content.div--7">
<FocusRing offset={-2} data-flx="app.channel-pins-content.focus-ring">
<button
type="button"
className={previewStyles.actionButton}
onClick={() => handleJump(message.channelId, message.id)}
data-flx="app.channel-pins-content.button.jump"
>
{i18n._(JUMP_DESCRIPTOR)}
</button>
</FocusRing>
{renderUnpinButton(message)}
</div>
</div>
); );
} })}
return ( {isLoading && (
<div <div className={previewStyles.loadingState} data-flx="app.channel-pins-content.div--8">
key={message.id} <Spinner data-flx="app.channel-pins-content.spinner--2" />
className={cardClasses} </div>
data-message-id={message.id} )}
data-is-group-start="true" {!hasMore && (
data-flx="app.channel-pins-content.div--6" <div className={previewStyles.endState} data-flx="app.channel-pins-content.div--9">
> <div className={previewStyles.endStateContent} data-flx="app.channel-pins-content.div--10">
<MessageComponent <FlagCheckeredIcon
message={message} className={previewStyles.endStateIcon}
channel={messageChannel} data-flx="app.channel-pins-content.flag-checkered-icon"
previewContext={MessagePreviewContext.LIST_POPOUT} />
data-flx="app.channel-pins-content.message-component--2" <div className={previewStyles.endStateTextContainer} data-flx="app.channel-pins-content.div--11">
/> <h3 className={previewStyles.endStateTitle} data-flx="app.channel-pins-content.h3--2">
<div className={previewStyles.actionButtons} data-flx="app.channel-pins-content.div--7"> {i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
<FocusRing offset={-2} data-flx="app.channel-pins-content.focus-ring"> </h3>
<button <p className={previewStyles.endStateDescription} data-flx="app.channel-pins-content.p--2">
type="button" {endStateDescription}
className={previewStyles.actionButton} </p>
onClick={() => handleJump(message.channelId, message.id)} </div>
data-flx="app.channel-pins-content.button.jump"
>
{i18n._(JUMP_DESCRIPTOR)}
</button>
</FocusRing>
{renderUnpinButton(message)}
</div> </div>
</div> </div>
); )}
})} </Scroller>
{isLoading && ( </NearViewportSurfaceContext.Provider>
<div className={previewStyles.loadingState} data-flx="app.channel-pins-content.div--8">
<Spinner data-flx="app.channel-pins-content.spinner--2" />
</div>
)}
{!hasMore && (
<div className={previewStyles.endState} data-flx="app.channel-pins-content.div--9">
<div className={previewStyles.endStateContent} data-flx="app.channel-pins-content.div--10">
<FlagCheckeredIcon
className={previewStyles.endStateIcon}
data-flx="app.channel-pins-content.flag-checkered-icon"
/>
<div className={previewStyles.endStateTextContainer} data-flx="app.channel-pins-content.div--11">
<h3 className={previewStyles.endStateTitle} data-flx="app.channel-pins-content.h3--2">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p className={previewStyles.endStateDescription} data-flx="app.channel-pins-content.p--2">
{endStateDescription}
</p>
</div>
</div>
</div>
)}
</Scroller>
{mobileLayout.enabled && selectedMessage && ( {mobileLayout.enabled && selectedMessage && (
<MenuBottomSheet <MenuBottomSheet
isOpen={menuOpen} isOpen={menuOpen}
@@ -27,6 +27,7 @@ import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands'; import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation'; import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation';
import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy'; 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 type {Message} from '@app/features/messaging/models/MessagingMessage';
import { import {
applyChannelSearchHighlight, applyChannelSearchHighlight,
@@ -67,7 +68,7 @@ import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
import {Trans, useLingui} from '@lingui/react/macro'; import {Trans, useLingui} from '@lingui/react/macro';
import {HashIcon} from '@phosphor-icons/react'; import {HashIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import React, {useCallback, useEffect, useRef, useState} from 'react'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
const NEWEST_DESCRIPTOR = msg({ const NEWEST_DESCRIPTOR = msg({
message: 'Newest', message: 'Newest',
@@ -189,6 +190,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null); const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
const [selectedMessageChannel, setSelectedMessageChannel] = useState<Channel | null>(null); const [selectedMessageChannel, setSelectedMessageChannel] = useState<Channel | null>(null);
const scrollerRef = useRef<ScrollerHandle | null>(null); const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolveSearchSheetScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [hasFilters, setHasFilters] = useState<Array<HasFilterType>>([]); const [hasFilters, setHasFilters] = useState<Array<HasFilterType>>([]);
const [fromUserIds, setFromUserIds] = useState<Array<string>>([]); const [fromUserIds, setFromUserIds] = useState<Array<string>>([]);
@@ -605,89 +607,91 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
value={collapsedMessageVisibility} value={collapsedMessageVisibility}
data-flx="channel.channel-search-bottom-sheet.render-content.collapsed-message-visibility-provider" data-flx="channel.channel-search-bottom-sheet.render-content.collapsed-message-visibility-provider"
> >
<Scroller <NearViewportSurfaceContext.Provider value={resolveSearchSheetScrollSurface}>
ref={scrollerRef} <Scroller
className={styles.resultsScroller} ref={scrollerRef}
key="channel-search-results-scroller" className={styles.resultsScroller}
onCopy={onCopySelectedMessages} key="channel-search-results-scroller"
data-message-selection-root="true" onCopy={onCopySelectedMessages}
data-flx="channel.channel-search-bottom-sheet.render-content.results-scroller" data-message-selection-root="true"
> data-flx="channel.channel-search-bottom-sheet.render-content.results-scroller"
{resultGroups.map((resultGroup) => { >
const messageChannel = {resultGroups.map((resultGroup) => {
searchChannelsById.get(resultGroup.channelId) ?? Channels.getChannel(resultGroup.channelId); const messageChannel =
if (!messageChannel) { searchChannelsById.get(resultGroup.channelId) ?? Channels.getChannel(resultGroup.channelId);
return null; if (!messageChannel) {
} return null;
const renderMessageWrapper = ({ }
message, const renderMessageWrapper = ({
index, message,
isGroupStart, index,
children, isGroupStart,
}: MessageGroupRenderWrapperProps) => ( children,
<LongPressable }: MessageGroupRenderWrapperProps) => (
data-message-index={index} <LongPressable
data-message-id={message.id} data-message-index={index}
data-is-group-start={isGroupStart} data-message-id={message.id}
className={styles.searchResultItem} data-is-group-start={isGroupStart}
role="button" className={styles.searchResultItem}
tabIndex={0} role="button"
onClick={() => handleTap(message)} tabIndex={0}
onKeyDown={(e) => { onClick={() => handleTap(message)}
if (isKeyboardActivationKey(e.key)) { onKeyDown={(e) => {
e.preventDefault(); if (isKeyboardActivationKey(e.key)) {
handleTap(message); e.preventDefault();
} handleTap(message);
}} }
onLongPress={() => { }}
setSelectedMessage(message); onLongPress={() => {
setMenuOpen(true); setSelectedMessage(message);
}} setMenuOpen(true);
data-flx="channel.channel-search-bottom-sheet.render-message-wrapper.search-result-item.tap" }}
> data-flx="channel.channel-search-bottom-sheet.render-message-wrapper.search-result-item.tap"
{children} >
</LongPressable> {children}
); </LongPressable>
return ( );
<React.Fragment key={resultGroup.key}> return (
{hasMultipleChannels && ( <React.Fragment key={resultGroup.key}>
<div {hasMultipleChannels && (
className={styles.channelSection} <div
data-flx="channel.channel-search-bottom-sheet.render-content.channel-section" className={styles.channelSection}
> data-flx="channel.channel-search-bottom-sheet.render-content.channel-section"
{ChannelUtils.getIcon(messageChannel, {
className: styles.channelIcon,
})}
<span
className={styles.channelName}
data-flx="channel.channel-search-bottom-sheet.render-content.channel-name"
> >
{messageChannel.name || 'Unnamed Channel'} {ChannelUtils.getIcon(messageChannel, {
</span> className: styles.channelIcon,
</div> })}
)} <span
<SearchResultMessageList className={styles.channelName}
channel={messageChannel} data-flx="channel.channel-search-bottom-sheet.render-content.channel-name"
messages={resultGroup.messages} >
revealedGroupKeys={revealedGroupKeys} {messageChannel.name || 'Unnamed Channel'}
onGroupRevealChange={handleCollapsedGroupRevealChange} </span>
collapsedGroupClassName={styles.collapsedMessageGroup} </div>
messagePreviewContext={MessagePreviewContext.LIST_POPOUT}
renderMessageWrapper={renderMessageWrapper}
spammerOverrideVersion={spammerOverrideVersion}
renderMessage={(message) => (
<SearchResultItem
message={message}
messageChannel={messageChannel}
data-flx="channel.channel-search-bottom-sheet.render-content.search-result-item"
/>
)} )}
data-flx="channel.channel-search-bottom-sheet.render-content.search-result-message-list" <SearchResultMessageList
/> channel={messageChannel}
</React.Fragment> messages={resultGroup.messages}
); revealedGroupKeys={revealedGroupKeys}
})} onGroupRevealChange={handleCollapsedGroupRevealChange}
</Scroller> collapsedGroupClassName={styles.collapsedMessageGroup}
messagePreviewContext={MessagePreviewContext.LIST_POPOUT}
renderMessageWrapper={renderMessageWrapper}
spammerOverrideVersion={spammerOverrideVersion}
renderMessage={(message) => (
<SearchResultItem
message={message}
messageChannel={messageChannel}
data-flx="channel.channel-search-bottom-sheet.render-content.search-result-item"
/>
)}
data-flx="channel.channel-search-bottom-sheet.render-content.search-result-message-list"
/>
</React.Fragment>
);
})}
</Scroller>
</NearViewportSurfaceContext.Provider>
</CollapsedMessageVisibilityProvider> </CollapsedMessageVisibilityProvider>
{totalPages > 1 && ( {totalPages > 1 && (
<div <div
@@ -10,6 +10,7 @@ import {ensureMembersForMessages} from '@app/features/messaging/commands/Message
import * as SavedMessageCommands from '@app/features/messaging/commands/SavedMessageCommands'; import * as SavedMessageCommands from '@app/features/messaging/commands/SavedMessageCommands';
import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation'; import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation';
import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy'; 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 type {Message} from '@app/features/messaging/models/MessagingMessage';
import SavedMessages from '@app/features/messaging/state/SavedMessages'; import SavedMessages from '@app/features/messaging/state/SavedMessages';
import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils/ChannelTextareaFocusUtils'; import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils/ChannelTextareaFocusUtils';
@@ -23,7 +24,7 @@ import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro'; import {Trans, useLingui} from '@lingui/react/macro';
import {ArrowSquareOutIcon, TrashIcon} from '@phosphor-icons/react'; import {ArrowSquareOutIcon, TrashIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import {useEffect, useRef, useState} from 'react'; import {useEffect, useMemo, useRef, useState} from 'react';
const JUMP_TO_MESSAGE_DESCRIPTOR = msg({ const JUMP_TO_MESSAGE_DESCRIPTOR = msg({
message: 'Jump to message', message: 'Jump to message',
@@ -44,6 +45,7 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
const {savedMessages, missingSavedMessages, fetched} = SavedMessages; const {savedMessages, missingSavedMessages, fetched} = SavedMessages;
const hasBookmarks = savedMessages.length > 0 || missingSavedMessages.length > 0; const hasBookmarks = savedMessages.length > 0 || missingSavedMessages.length > 0;
const scrollerRef = useRef<ScrollerHandle | null>(null); const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolveBookmarksScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null); const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(savedMessages); const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(savedMessages);
@@ -122,39 +124,41 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
data-flx="channel.bookmarks-bottom-sheet.bottom-sheet" data-flx="channel.bookmarks-bottom-sheet.bottom-sheet"
> >
{hasBookmarks ? ( {hasBookmarks ? (
<Scroller <NearViewportSurfaceContext.Provider value={resolveBookmarksScrollSurface}>
className={styles.messageList} <Scroller
key="bookmarks-bottom-sheet-scroller" className={styles.messageList}
ref={scrollerRef} key="bookmarks-bottom-sheet-scroller"
onCopy={onCopySelectedMessages} ref={scrollerRef}
data-message-selection-root="true" onCopy={onCopySelectedMessages}
data-flx="channel.bookmarks-bottom-sheet.message-list" data-message-selection-root="true"
> data-flx="channel.bookmarks-bottom-sheet.message-list"
{missingSavedMessages.length > 0 && ( >
<div className={styles.missingList} data-flx="channel.bookmarks-bottom-sheet.missing-list"> {missingSavedMessages.length > 0 && (
{missingSavedMessages.map((entry) => ( <div className={styles.missingList} data-flx="channel.bookmarks-bottom-sheet.missing-list">
<SavedMessageMissingCard {missingSavedMessages.map((entry) => (
key={entry.id} <SavedMessageMissingCard
entryId={entry.id} key={entry.id}
onRemove={() => SavedMessageCommands.remove(i18n, entry.id)} entryId={entry.id}
data-flx="channel.bookmarks-bottom-sheet.saved-message-missing-card" onRemove={() => SavedMessageCommands.remove(i18n, entry.id)}
data-flx="channel.bookmarks-bottom-sheet.saved-message-missing-card"
/>
))}
</div>
)}
<div className={styles.topSpacer} data-flx="channel.bookmarks-bottom-sheet.top-spacer" />
<div className={styles.messagesContainer} data-flx="channel.bookmarks-bottom-sheet.messages-container">
{savedMessages.map((message) => (
<MessageWithLongPress
key={message.id}
message={message}
onLongPress={handleLongPress}
onClick={handleJumpToMessage}
data-flx="channel.bookmarks-bottom-sheet.message-with-long-press.jump-to-message"
/> />
))} ))}
</div> </div>
)} </Scroller>
<div className={styles.topSpacer} data-flx="channel.bookmarks-bottom-sheet.top-spacer" /> </NearViewportSurfaceContext.Provider>
<div className={styles.messagesContainer} data-flx="channel.bookmarks-bottom-sheet.messages-container">
{savedMessages.map((message) => (
<MessageWithLongPress
key={message.id}
message={message}
onLongPress={handleLongPress}
onClick={handleJumpToMessage}
data-flx="channel.bookmarks-bottom-sheet.message-with-long-press.jump-to-message"
/>
))}
</div>
</Scroller>
) : ( ) : (
<div className={styles.emptyState} data-flx="channel.bookmarks-bottom-sheet.empty-state"> <div className={styles.emptyState} data-flx="channel.bookmarks-bottom-sheet.empty-state">
<div className={styles.emptyContent} data-flx="channel.bookmarks-bottom-sheet.empty-content"> <div className={styles.emptyContent} data-flx="channel.bookmarks-bottom-sheet.empty-content">