From 2af0405317f4cbe0f6c8f7cb8bba6ddd829bd044 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:33:06 +0200 Subject: [PATCH] fix(app): keep the chat scroller pinned to the end while it resizes (#1860) --- .../shared/MobileSettingsComponents.tsx | 2 +- .../dialogs/shared/SettingsModalLayout.tsx | 2 +- .../floating/UnreadChannelsContent.tsx | 2 +- .../app/components/layout/AuthLayout.tsx | 2 +- .../components/layout/ChannelListContent.tsx | 15 ++- .../layout/FavoritesChannelListContent.tsx | 2 +- .../layout/FloatingUnreadIndicators.tsx | 8 +- .../app/components/layout/GuildsLayout.tsx | 6 +- .../components/skeleton/GuildRailSkeleton.tsx | 2 +- .../components/whats_new/WhatsNewModal.tsx | 4 +- .../features/app/hooks/useNavigationList.ts | 4 +- .../channel/components/Autocomplete.tsx | 4 +- .../channel/components/AutocompleteGif.tsx | 4 +- .../components/ChannelAttachmentArea.tsx | 2 +- .../channel/components/ChannelMembers.tsx | 2 +- .../channel/components/ChannelMessages.tsx | 6 +- .../components/ChannelSearchResults.tsx | 4 +- .../channel/components/EmojiPicker.tsx | 2 +- .../channel/components/MobileEmojiPicker.tsx | 2 +- .../components/MobileStickersPicker.tsx | 2 +- .../channel/components/StickersPicker.tsx | 2 +- .../bottomsheets/ChannelSearchBottomSheet.tsx | 2 +- .../shared/MasonryListComputer.bench.ts | 46 ------- .../pickers/shared/useScrollerViewport.ts | 2 +- .../discovery/discovery/DiscoveryPage.tsx | 8 +- .../hooks/useMessageListKeyboardNavigation.ts | 10 +- .../messaging/state/MessagingMessages.ts | 14 +-- .../notification/state/NotificationAutoAck.ts | 2 +- .../features/platform/utils/ScrollManager.ts | 56 ++++----- .../features/read_state/state/ReadStates.ts | 2 +- .../bottomsheets/QuickSwitcherBottomSheet.tsx | 2 +- .../quick_switcher/QuickSwitcherModal.tsx | 2 +- .../src/features/ui/components/Scroller.tsx | 113 +++++++++++------- .../src/features/ui/hooks/useScrollAnchor.ts | 2 +- fluxer_app/src/features/ui/state/Dimension.ts | 20 ++-- .../voice/hooks/useWindowFocusVideoControl.ts | 2 +- 36 files changed, 171 insertions(+), 191 deletions(-) delete mode 100644 fluxer_app/src/features/channel/components/pickers/shared/MasonryListComputer.bench.ts diff --git a/fluxer_app/src/features/app/components/dialogs/shared/MobileSettingsComponents.tsx b/fluxer_app/src/features/app/components/dialogs/shared/MobileSettingsComponents.tsx index 54d5cb386..c89a0954d 100644 --- a/fluxer_app/src/features/app/components/dialogs/shared/MobileSettingsComponents.tsx +++ b/fluxer_app/src/features/app/components/dialogs/shared/MobileSettingsComponents.tsx @@ -392,7 +392,7 @@ export const MobileSectionNav: React.FC = observer( const scrollerRef = useRef(null); useEffect(() => { if (!activeSectionId) return; - const node = scrollerRef.current?.getScrollerNode(); + const node = scrollerRef.current?.getViewportElement(); if (!node) return; const activeButton = node.querySelector(`[data-section-id="${activeSectionId}"]`); if (activeButton instanceof HTMLElement) { diff --git a/fluxer_app/src/features/app/components/dialogs/shared/SettingsModalLayout.tsx b/fluxer_app/src/features/app/components/dialogs/shared/SettingsModalLayout.tsx index bf6b9b690..56b6877fa 100644 --- a/fluxer_app/src/features/app/components/dialogs/shared/SettingsModalLayout.tsx +++ b/fluxer_app/src/features/app/components/dialogs/shared/SettingsModalLayout.tsx @@ -125,7 +125,7 @@ export const SettingsModalDesktopScroll: React.FC('[data-inbox-channel-section]')); diff --git a/fluxer_app/src/features/app/components/layout/AuthLayout.tsx b/fluxer_app/src/features/app/components/layout/AuthLayout.tsx index b9980c92a..a01609923 100644 --- a/fluxer_app/src/features/app/components/layout/AuthLayout.tsx +++ b/fluxer_app/src/features/app/components/layout/AuthLayout.tsx @@ -69,7 +69,7 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child }; }, []); useEffect(() => { - scrollerRef.current?.scrollToTop(); + scrollerRef.current?.jumpToStartEdge(); }, [location.pathname]); const splashScale = useMemo(() => { if (!splashDimensions) return null; diff --git a/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx b/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx index 1d26de6b6..b364313a4 100644 --- a/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx +++ b/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx @@ -242,7 +242,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr () => [channels.length, ReadStates.version, userGuildSettings, hideMutedChannels, showFadedUnreadOnMutedChannels], [channels.length, ReadStates.version, userGuildSettings, hideMutedChannels, showFadedUnreadOnMutedChannels], ); - const getChannelScrollContainer = useCallback(() => scrollerRef.current?.getScrollerNode() ?? null, [scrollerRef]); + const getChannelScrollContainer = useCallback(() => scrollerRef.current?.getViewportElement() ?? null, [scrollerRef]); useDragAutoScroll({active: isDraggingAnything, getScrollElement: getChannelScrollContainer}); const handleChannelDrop = useCallback( (item: DragItem, result: DropResult) => { @@ -306,15 +306,15 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr }, [guild.id]); const handleResize = useCallback((_entry: ResizeObserverEntry, _type: 'container' | 'content') => { if (stickToBottomRef.current && scrollerRef.current) { - scrollerRef.current.scrollToBottom({animate: false}); + scrollerRef.current.jumpToEndEdge({animate: false}); } }, []); useEffect(() => { - const guildDimensions = Dimension.getGuildDimensions(guild.id); + const guildDimensions = Dimension.guildDimensionsFor(guild.id); if (guildDimensions.scrollTo) { const element = document.querySelector(`[data-channel-id="${guildDimensions.scrollTo}"]`); if (element && scrollerRef.current) { - scrollerRef.current.scrollIntoViewNode({node: element as HTMLElement, shouldScrollToStart: false}); + scrollerRef.current.revealElement({node: element as HTMLElement, preferStartEdge: false}); } DimensionCommands.clearChannelListScrollTo(guild.id); } else if (guildDimensions.scrollTop && guildDimensions.scrollTop > 0 && scrollerRef.current) { @@ -337,7 +337,8 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr const unreadCount = ReadStates.getUnreadCount(channelId); const mentionCount = ReadStates.getMentionCount(channelId); const isMuted = - UserGuildSettings.isParentCategoryMuted(guild.id, channelId) || UserGuildSettings.isChannelDirectlyMuted(guild.id, channelId); + UserGuildSettings.isParentCategoryMuted(guild.id, channelId) || + UserGuildSettings.isChannelDirectlyMuted(guild.id, channelId); const channel = Channels.getChannel(channelId); const unreadBadgesLevel = channel ? UserGuildSettings.resolvedUnreadBadgesLevel({ @@ -360,7 +361,9 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr for (const group of channelGroups) { const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false; const isNullSpace = !group.category; - const isCategoryMuted = group.category ? UserGuildSettings.isChannelDirectlyMuted(guild.id, group.category.id) : false; + const isCategoryMuted = group.category + ? UserGuildSettings.isChannelDirectlyMuted(guild.id, group.category.id) + : false; let filteredTextChannels: Array; let filteredVoiceChannels: Array; if (hideMutedChannels) { diff --git a/fluxer_app/src/features/app/components/layout/FavoritesChannelListContent.tsx b/fluxer_app/src/features/app/components/layout/FavoritesChannelListContent.tsx index dc8b14c6f..684d6c4fa 100644 --- a/fluxer_app/src/features/app/components/layout/FavoritesChannelListContent.tsx +++ b/fluxer_app/src/features/app/components/layout/FavoritesChannelListContent.tsx @@ -651,7 +651,7 @@ export const FavoritesChannelListContent = observer(() => { const getScrollElement = useCallback(() => { const scroller = scrollerRef.current; if (scroller == null) return null; - return scroller.getScrollerNode(); + return scroller.getViewportElement(); }, []); useDragAutoScroll({active: isDraggingFavorite, getScrollElement}); const favorites = Favorites.sortedChannels; diff --git a/fluxer_app/src/features/app/components/layout/FloatingUnreadIndicators.tsx b/fluxer_app/src/features/app/components/layout/FloatingUnreadIndicators.tsx index 352c5e8c9..a33542d76 100644 --- a/fluxer_app/src/features/app/components/layout/FloatingUnreadIndicators.tsx +++ b/fluxer_app/src/features/app/components/layout/FloatingUnreadIndicators.tsx @@ -164,7 +164,7 @@ function resolveScrollerNode(scroller: ScrollerHandle | null): HTMLElement | nul if (scroller == null) { return null; } - return scroller.getScrollerNode(); + return scroller.getViewportElement(); } function resolveOwnerWindow(container: HTMLElement): Window & typeof globalThis { @@ -221,7 +221,7 @@ function cancelOwnedSettleTimer(timer: OwnedSettleTimer | null): void { } function alignMountedTarget({animate, direction, scroller, targetNode}: AlignMountedTargetRequest): boolean { - const container = scroller.getScrollerNode(); + const container = scroller.getViewportElement(); if (container == null) return false; const containerRect = container.getBoundingClientRect(); const targetRect = targetNode.getBoundingClientRect(); @@ -451,7 +451,7 @@ export const FloatingUnreadIndicators = observer( if (scrollTarget(edge.id, direction)) { scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY); if (scroller == null) return; - const scrollNode = scroller.getScrollerNode(); + const scrollNode = scroller.getViewportElement(); if (scrollNode == null) return; const ownerWindow = resolveOwnerWindow(scrollNode); cancelOwnedAnimationFrame(targetAlignmentFrameRef.current); @@ -467,7 +467,7 @@ export const FloatingUnreadIndicators = observer( } scheduleRefresh(FloatingUnreadRefreshScope.SCROLL); if (scroller == null) return; - const scrollNode = scroller.getScrollerNode(); + const scrollNode = scroller.getViewportElement(); if (scrollNode == null) return; const ownerWindow = resolveOwnerWindow(scrollNode); cancelOwnedSettleTimer(settleTimerRef.current); diff --git a/fluxer_app/src/features/app/components/layout/GuildsLayout.tsx b/fluxer_app/src/features/app/components/layout/GuildsLayout.tsx index 1820985bf..f12f101f5 100644 --- a/fluxer_app/src/features/app/components/layout/GuildsLayout.tsx +++ b/fluxer_app/src/features/app/components/layout/GuildsLayout.tsx @@ -184,7 +184,7 @@ function resolveGuildFolderId(folderId: number | null): number { function resolveGuildListScrollerNode(scroller: ScrollerHandle | null): HTMLElement | null { if (scroller == null) return null; - return scroller.getScrollerNode(); + return scroller.getViewportElement(); } type GuildNavigationRow = @@ -1583,7 +1583,7 @@ const GuildList = observer(() => { const visibleDMListRows = useFrameBatchedDMListRows(targetDMListRows); const shouldRenderGuildListItems = hasUnavailableGuilds || organizedItems.length > 0; const initialGuildListScrollTop = useMemo(() => { - const liveScrollTop = untracked(() => Dimension.getGuildListDimensions().scrollTop); + const liveScrollTop = untracked(() => Dimension.currentGuildListDimensions().scrollTop); if (liveScrollTop > 0) { return liveScrollTop; } @@ -1859,7 +1859,7 @@ const GuildList = observer(() => { const node = scrollTargetRegistry.getTargetNode(targetId); const scroller = scrollRef.current; if (node == null || scroller == null) return false; - scroller.scrollIntoViewNode({ + scroller.revealElement({ node, alignment, animate: Accessibility.useSmoothScrolling, diff --git a/fluxer_app/src/features/app/components/skeleton/GuildRailSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/GuildRailSkeleton.tsx index 3a71b05e8..83257635b 100644 --- a/fluxer_app/src/features/app/components/skeleton/GuildRailSkeleton.tsx +++ b/fluxer_app/src/features/app/components/skeleton/GuildRailSkeleton.tsx @@ -467,7 +467,7 @@ export const GuildRailSkeleton: React.FC = ({ const [mountState] = useState(() => { const rememberedLayout = getRememberedSkeletonGuildRailLayout(); const remScale = getRemScaleForDocument(document); - const liveScrollTop = Math.max(0, Dimension.getGuildListDimensions().scrollTop); + const liveScrollTop = Math.max(0, Dimension.currentGuildListDimensions().scrollTop); let scrollTop = liveScrollTop; if (liveScrollTop === 0 && rememberedLayout != null) { scrollTop = rememberedLayout.scrollTopPx * remScale; diff --git a/fluxer_app/src/features/app/components/whats_new/WhatsNewModal.tsx b/fluxer_app/src/features/app/components/whats_new/WhatsNewModal.tsx index 8c0141771..e1129ef21 100644 --- a/fluxer_app/src/features/app/components/whats_new/WhatsNewModal.tsx +++ b/fluxer_app/src/features/app/components/whats_new/WhatsNewModal.tsx @@ -72,7 +72,7 @@ export function WhatsNewModal({entry}: WhatsNewModalProps) { ModalCommands.pop(); }, [entry.id]); const updateScrollHintVisibility = useCallback(() => { - const scrollerNode = scrollerRef.current?.getScrollerNode(); + const scrollerNode = scrollerRef.current?.getViewportElement(); if (!scrollerNode) { setIsScrollHintVisible(false); return; @@ -89,7 +89,7 @@ export function WhatsNewModal({entry}: WhatsNewModalProps) { updateScrollHintVisibility(); }, [updateScrollHintVisibility]); const handleScrollToBottom = useCallback(() => { - scrollerRef.current?.scrollToBottom({animate: true}); + scrollerRef.current?.jumpToEndEdge({animate: true}); }, []); useEffect(() => { updateScrollHintVisibility(); diff --git a/fluxer_app/src/features/app/hooks/useNavigationList.ts b/fluxer_app/src/features/app/hooks/useNavigationList.ts index 7db57692b..505b7d274 100644 --- a/fluxer_app/src/features/app/hooks/useNavigationList.ts +++ b/fluxer_app/src/features/app/hooks/useNavigationList.ts @@ -265,7 +265,7 @@ function resolveScrollNode(scrollerRef: React.RefObject): if (scroller == null) { return null; } - return scroller.getScrollerNode(); + return scroller.getViewportElement(); } interface ResolveNavigationRowNodeQuery { @@ -311,7 +311,7 @@ function scrollNavigationRowIntoView({ if (node == null) { return false; } - scroller.scrollIntoViewNode({node, alignment: resolveNavigationScrollerAlignment(align)}); + scroller.revealElement({node, alignment: resolveNavigationScrollerAlignment(align)}); return true; } diff --git a/fluxer_app/src/features/channel/components/Autocomplete.tsx b/fluxer_app/src/features/channel/components/Autocomplete.tsx index 27c0f6778..ecc8dba3a 100644 --- a/fluxer_app/src/features/channel/components/Autocomplete.tsx +++ b/fluxer_app/src/features/channel/components/Autocomplete.tsx @@ -308,8 +308,8 @@ export const Autocomplete = observer( const scrollChildIntoView = useCallback((node: HTMLElement | null, margin = 32) => { if (!node) return; const scroller = scrollerRef.current as ScrollerWithScrollableElement | null; - if (scroller && typeof scroller.scrollIntoViewNode === 'function') { - scroller.scrollIntoViewNode({node, padding: margin}); + if (scroller && typeof scroller.revealElement === 'function') { + scroller.revealElement({node, padding: margin}); return; } let scrollerEl: HTMLElement | null = null; diff --git a/fluxer_app/src/features/channel/components/AutocompleteGif.tsx b/fluxer_app/src/features/channel/components/AutocompleteGif.tsx index cf9ceeeb7..5656f5331 100644 --- a/fluxer_app/src/features/channel/components/AutocompleteGif.tsx +++ b/fluxer_app/src/features/channel/components/AutocompleteGif.tsx @@ -64,9 +64,9 @@ export const AutocompleteGif = observer( if (!scrollerRef.current) { return; } - scrollerRef.current.scrollIntoViewNode({ + scrollerRef.current.revealElement({ node: selectedElement, - shouldScrollToStart: false, + preferStartEdge: false, padding: 0, }); }); diff --git a/fluxer_app/src/features/channel/components/ChannelAttachmentArea.tsx b/fluxer_app/src/features/channel/components/ChannelAttachmentArea.tsx index 55e9fcc47..de9d7be15 100644 --- a/fluxer_app/src/features/channel/components/ChannelAttachmentArea.tsx +++ b/fluxer_app/src/features/channel/components/ChannelAttachmentArea.tsx @@ -597,7 +597,7 @@ export const ChannelAttachmentArea = observer(({channelId}: {channelId: string}) const getScrollElement = useCallback(() => { const scroller = scrollerRef.current; if (scroller == null) return null; - return scroller.getScrollerNode(); + return scroller.getViewportElement(); }, []); useDragAutoScroll({active: isDragging, axis: 'horizontal', getScrollElement}); channelIdRef.current = channelId; diff --git a/fluxer_app/src/features/channel/components/ChannelMembers.tsx b/fluxer_app/src/features/channel/components/ChannelMembers.tsx index b49202518..b8d60b55c 100644 --- a/fluxer_app/src/features/channel/components/ChannelMembers.tsx +++ b/fluxer_app/src/features/channel/components/ChannelMembers.tsx @@ -374,7 +374,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe [flushScrollRangeUpdate], ); const scheduleRangeUpdateFromScroller = useCallback(() => { - const scrollerState = scrollerRef.current?.getScrollerState(); + const scrollerState = scrollerRef.current?.readViewportMetrics(); if (!scrollerState) { return; } diff --git a/fluxer_app/src/features/channel/components/ChannelMessages.tsx b/fluxer_app/src/features/channel/components/ChannelMessages.tsx index 4be6cb332..8c056ed5d 100644 --- a/fluxer_app/src/features/channel/components/ChannelMessages.tsx +++ b/fluxer_app/src/features/channel/components/ChannelMessages.tsx @@ -254,7 +254,7 @@ export const Messages = observer(function Messages({ }, [channel.id, state]); const onMessageEdit = useCallback( (targetNode: HTMLElement) => { - const scrollerNode = scrollManager.ref.current?.getScrollerNode(); + const scrollerNode = scrollManager.ref.current?.getViewportElement(); if (!scrollerNode) return; if (scrollManager.pinIsAtBottomNow()) { return; @@ -272,7 +272,7 @@ export const Messages = observer(function Messages({ const isAbove = targetRect.top < scrollerRect.top; const isBelow = targetRect.bottom > scrollerRect.bottom; if (isAbove || isBelow) { - scrollManager.ref.current?.scrollIntoViewNode({ + scrollManager.ref.current?.revealElement({ node: targetNode, padding: 80, animate: false, @@ -369,7 +369,7 @@ export const Messages = observer(function Messages({ const onFocusBottommostMessage = (payload?: unknown) => { const data = (payload ?? {}) as {channelId?: string}; if (!data.channelId || data.channelId !== channel.id) return; - const scroller = scrollManager.ref.current?.getScrollerNode(); + const scroller = scrollManager.ref.current?.getViewportElement(); const scrollerInner = scrollerInnerRef.current; if (!scroller || !scrollerInner) return; const messageElements = scrollerInner.querySelectorAll('[data-message-id]'); diff --git a/fluxer_app/src/features/channel/components/ChannelSearchResults.tsx b/fluxer_app/src/features/channel/components/ChannelSearchResults.tsx index de59517c7..ec4abe7ad 100644 --- a/fluxer_app/src/features/channel/components/ChannelSearchResults.tsx +++ b/fluxer_app/src/features/channel/components/ChannelSearchResults.tsx @@ -206,7 +206,7 @@ export const ChannelSearchResults = observer( setSortMode(cachedSortMode); }, [contextId, searchContext?.lastSearchSortMode]); const getScrollPosition = useCallback((): number => { - const node = scrollerRef.current?.getScrollerNode(); + const node = scrollerRef.current?.getViewportElement(); return node ? node.scrollTop : 0; }, []); const updateScrollPosition = useCallback( @@ -827,7 +827,7 @@ export const ChannelSearchResults = observer( clearChannelSearchHighlight(); return; } - const scrollerNode = scrollerRef.current?.getScrollerNode(); + const scrollerNode = scrollerRef.current?.getViewportElement(); if (!scrollerNode) return; const timer = setTimeout(() => { const terms = tokenizeSearchQuery(searchQuery); diff --git a/fluxer_app/src/features/channel/components/EmojiPicker.tsx b/fluxer_app/src/features/channel/components/EmojiPicker.tsx index 436de0538..dd51db49a 100644 --- a/fluxer_app/src/features/channel/components/EmojiPicker.tsx +++ b/fluxer_app/src/features/channel/components/EmojiPicker.tsx @@ -187,7 +187,7 @@ export const EmojiPicker = observer( const handleCategoryClick = (category: string) => { const element = categoryRefs.current.get(category); if (element) { - scrollerRef.current?.scrollIntoViewNode({node: element, shouldScrollToStart: true}); + scrollerRef.current?.revealElement({node: element, preferStartEdge: true}); } }; const handleHover = (emoji: FlatEmoji | null, row?: number, column?: number) => { diff --git a/fluxer_app/src/features/channel/components/MobileEmojiPicker.tsx b/fluxer_app/src/features/channel/components/MobileEmojiPicker.tsx index 7476ffa5e..d98202a53 100644 --- a/fluxer_app/src/features/channel/components/MobileEmojiPicker.tsx +++ b/fluxer_app/src/features/channel/components/MobileEmojiPicker.tsx @@ -223,7 +223,7 @@ export const MobileEmojiPicker = observer( const handleCategoryClick = (category: string) => { const element = categoryRefs.current.get(category); if (element) { - scrollerRef.current?.scrollIntoViewNode({node: element, shouldScrollToStart: true}); + scrollerRef.current?.revealElement({node: element, preferStartEdge: true}); } }; const handleHover = (emoji: FlatEmoji | null) => { diff --git a/fluxer_app/src/features/channel/components/MobileStickersPicker.tsx b/fluxer_app/src/features/channel/components/MobileStickersPicker.tsx index 1a29704cb..24414af85 100644 --- a/fluxer_app/src/features/channel/components/MobileStickersPicker.tsx +++ b/fluxer_app/src/features/channel/components/MobileStickersPicker.tsx @@ -173,7 +173,7 @@ export const MobileStickersPicker = observer( const handleCategoryClick = (category: string) => { const element = categoryRefs.current.get(category); if (element) { - scrollerRef.current?.scrollIntoViewNode({node: element, shouldScrollToStart: true}); + scrollerRef.current?.revealElement({node: element, preferStartEdge: true}); } }; const handleHover = (sticker: GuildSticker | null) => { diff --git a/fluxer_app/src/features/channel/components/StickersPicker.tsx b/fluxer_app/src/features/channel/components/StickersPicker.tsx index c19fa248f..4d9ed4246 100644 --- a/fluxer_app/src/features/channel/components/StickersPicker.tsx +++ b/fluxer_app/src/features/channel/components/StickersPicker.tsx @@ -172,7 +172,7 @@ export const StickersPicker = observer( const handleCategoryClick = (category: string) => { const element = categoryRefs.current.get(category); if (element) { - scrollerRef.current?.scrollIntoViewNode({node: element, shouldScrollToStart: true}); + scrollerRef.current?.revealElement({node: element, preferStartEdge: true}); } }; const handleHover = (sticker: GuildSticker | null, row?: number, column?: number) => { diff --git a/fluxer_app/src/features/channel/components/bottomsheets/ChannelSearchBottomSheet.tsx b/fluxer_app/src/features/channel/components/bottomsheets/ChannelSearchBottomSheet.tsx index a640a848a..9afdb3192 100644 --- a/fluxer_app/src/features/channel/components/bottomsheets/ChannelSearchBottomSheet.tsx +++ b/fluxer_app/src/features/channel/components/bottomsheets/ChannelSearchBottomSheet.tsx @@ -367,7 +367,7 @@ export const ChannelSearchBottomSheet: React.FC = clearChannelSearchHighlight(); return; } - const container = scrollerRef.current?.getScrollerNode(); + const container = scrollerRef.current?.getViewportElement(); if (!container) { return; } diff --git a/fluxer_app/src/features/channel/components/pickers/shared/MasonryListComputer.bench.ts b/fluxer_app/src/features/channel/components/pickers/shared/MasonryListComputer.bench.ts deleted file mode 100644 index 3a76988ad..000000000 --- a/fluxer_app/src/features/channel/components/pickers/shared/MasonryListComputer.bench.ts +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {MasonryListComputer} from '@app/features/channel/components/MasonryListComputer'; -import {bench, describe} from 'vitest'; - -const ITEM_COUNT = 500; -const VIEWPORT_WIDTH = 520; -const VIEWPORT_HEIGHT = 420; -const COLUMNS = 3; -const ITEM_GUTTER = 8; -const ITEM_KEYS = Array.from({length: ITEM_COUNT}, (_, index) => `gif-${index}`); -const ITEM_HEIGHTS = Array.from({length: ITEM_COUNT}, (_, index) => 90 + ((index * 37) % 180)); -const SCROLL_OFFSETS = Array.from({length: 1_000}, (_, index) => (index * 173) % 30_000); - -function configureComputer(computer: MasonryListComputer, version: number): void { - computer.mergeProps({ - sections: [ITEM_COUNT], - columns: COLUMNS, - itemGutter: ITEM_GUTTER, - bufferWidth: VIEWPORT_WIDTH, - padding: {left: 12, right: 12, top: 0, bottom: 0}, - version, - getItemKey: (_section, itemIndex) => ITEM_KEYS[itemIndex] ?? null, - getItemHeight: (_section, itemIndex) => ITEM_HEIGHTS[itemIndex] ?? 0, - getSectionHeight: () => 0, - }); -} - -describe('MasonryListComputer benchmarks', () => { - bench('computes full masonry layout for 500 GIFs', () => { - const computer = new MasonryListComputer(); - configureComputer(computer, 1); - computer.computeVisibleSections(0, VIEWPORT_HEIGHT); - computer.getState(); - }); - - bench('computes 1k visible windows over a stable GIF layout', () => { - const computer = new MasonryListComputer(); - configureComputer(computer, 1); - computer.computeVisibleSections(0, VIEWPORT_HEIGHT); - for (const scrollTop of SCROLL_OFFSETS) { - computer.computeVisibleSections(scrollTop, scrollTop + VIEWPORT_HEIGHT + 480); - } - computer.getState(); - }); -}); diff --git a/fluxer_app/src/features/channel/components/pickers/shared/useScrollerViewport.ts b/fluxer_app/src/features/channel/components/pickers/shared/useScrollerViewport.ts index f5e959ea8..484aa99b1 100644 --- a/fluxer_app/src/features/channel/components/pickers/shared/useScrollerViewport.ts +++ b/fluxer_app/src/features/channel/components/pickers/shared/useScrollerViewport.ts @@ -60,7 +60,7 @@ export function useScrollerViewport(scrollerRef: React.RefObject { - const state = scrollerRef.current?.getScrollerState(); + const state = scrollerRef.current?.readViewportMetrics(); if (!state || state.offsetWidth === 0 || state.offsetHeight === 0) return; scheduleViewportSize({width: state.offsetWidth, height: state.offsetHeight}); }, [scrollerRef, scheduleViewportSize]); diff --git a/fluxer_app/src/features/discovery/discovery/DiscoveryPage.tsx b/fluxer_app/src/features/discovery/discovery/DiscoveryPage.tsx index 9d9b0a736..0ff01aacb 100644 --- a/fluxer_app/src/features/discovery/discovery/DiscoveryPage.tsx +++ b/fluxer_app/src/features/discovery/discovery/DiscoveryPage.tsx @@ -104,7 +104,7 @@ export const DiscoveryPage = observer(function DiscoveryPage() { const hasMore = guilds.length < Discovery.total; const virtualizer = useVirtualizer({ count: rowCount, - getScrollElement: () => scrollerRef.current?.getScrollerNode() ?? null, + getScrollElement: () => scrollerRef.current?.getViewportElement() ?? null, estimateSize: () => SKELETON_DISCOVERY_ESTIMATED_ROW_HEIGHT_PX, overscan: OVERSCAN_ROWS, scrollMargin: gridOffsetTop, @@ -131,7 +131,7 @@ export const DiscoveryPage = observer(function DiscoveryPage() { return; } setContainerWidth(node.getBoundingClientRect().width); - setGridOffsetTop(resolveScrollOffsetTop(node, scrollerRef.current?.getScrollerNode() ?? null)); + setGridOffsetTop(resolveScrollOffsetTop(node, scrollerRef.current?.getViewportElement() ?? null)); }, []); useEffect(() => { if (!resultsColumnNode) return; @@ -149,7 +149,7 @@ export const DiscoveryPage = observer(function DiscoveryPage() { }, [resultsColumnNode, searchActive]); useEffect(() => { if (!resultsColumnNode) return; - setGridOffsetTop(resolveScrollOffsetTop(resultsColumnNode, scrollerRef.current?.getScrollerNode() ?? null)); + setGridOffsetTop(resolveScrollOffsetTop(resultsColumnNode, scrollerRef.current?.getViewportElement() ?? null)); }, [resultsColumnNode, searchActive, containerWidth]); const scrollResultsToTop = useCallback(() => { scrollerRef.current?.scrollTo({to: 0}); @@ -185,7 +185,7 @@ export const DiscoveryPage = observer(function DiscoveryPage() { if (searchActive || columns <= 0) { return; } - const viewportHeightPx = measureSkeletonHeightPx(scrollerRef.current?.getScrollerNode() ?? null); + const viewportHeightPx = measureSkeletonHeightPx(scrollerRef.current?.getViewportElement() ?? null); reportSkeletonDiscoveryGrid(columns, Math.ceil(viewportHeightPx / SKELETON_DISCOVERY_ESTIMATED_ROW_HEIGHT_PX)); }, `${searchActive}:${columns}:${containerWidth}`); const virtualRows = virtualizer.getVirtualItems(); diff --git a/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.ts b/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.ts index 98a73a7d6..f48352d60 100644 --- a/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.ts +++ b/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.ts @@ -18,10 +18,10 @@ interface MessageListKeyboardNavigationOptions { allowWhenInactive?: boolean; } -const getScrollerNode = (value: ScrollerHandle | HTMLElement | null | undefined): HTMLElement | null => { +const getViewportElement = (value: ScrollerHandle | HTMLElement | null | undefined): HTMLElement | null => { if (!value) return null; - if ('getScrollerNode' in value && typeof value.getScrollerNode === 'function') { - return value.getScrollerNode(); + if ('getViewportElement' in value && typeof value.getViewportElement === 'function') { + return value.getViewportElement(); } if (value instanceof HTMLElement) { return value; @@ -109,7 +109,7 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav const getMessageElementsSnapshot = (): MessageNodesSnapshot => { const now = Date.now(); const cache = messageNodesCache; - const container = getScrollerNode(containerRef?.current ?? null); + const container = getViewportElement(containerRef?.current ?? null); if (!container && containerRef) { messageNodesCache = { ...EMPTY_MESSAGE_NODES_SNAPSHOT, @@ -202,7 +202,7 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav const isNavigationKey = delta !== 0; if (isNavigationKey && hasShortcutModifier(event)) return; if (!isNavigationKey && event.key !== 'Escape') return; - const container = getScrollerNode(containerRef?.current ?? null); + const container = getViewportElement(containerRef?.current ?? null); if (container && !canHandleInsideContainer(container)) { return; } diff --git a/fluxer_app/src/features/messaging/state/MessagingMessages.ts b/fluxer_app/src/features/messaging/state/MessagingMessages.ts index bb1232a21..472960d6a 100644 --- a/fluxer_app/src/features/messaging/state/MessagingMessages.ts +++ b/fluxer_app/src/features/messaging/state/MessagingMessages.ts @@ -273,7 +273,7 @@ class Messages { ChannelMessages.forEach((messages) => channelIds.push(messages.channelId)); for (const channelId of channelIds) { this.clearMessages(channelId); - Dimension.clearChannelDimensions(channelId); + Dimension.forgetChannelDimensions(channelId); } this.messageRefsByAuthor.clear(); this.indexedAuthorsByChannel.clear(); @@ -314,7 +314,7 @@ class Messages { didHydrateSelectedChannel = true; } else { this.clearMessages(messages.channelId); - Dimension.clearChannelDimensions(messages.channelId); + Dimension.forgetChannelDimensions(messages.channelId); } }); if (this.pendingFullHydration && !didHydrateSelectedChannel && selectedChannelId) { @@ -357,7 +357,7 @@ class Messages { const messages = ChannelMessages.getOrCreate(channelId); this.commitMessages(messages.mutate({loadingMore: true, ready: false, error: false})); if (forceScrollToBottom) { - Dimension.updateChannelDimensions(channelId, 1, 1, 0); + Dimension.recordChannelDimensions(channelId, 1, 1, 0); MessageCommands.fetchMessages(channelId, null, null, MAX_MESSAGES_PER_CHANNEL); return; } @@ -402,7 +402,7 @@ class Messages { return false; } if (!GatewayConnection.isConnected || messages.loadingMore || messages.ready) { - if (messages.ready && Dimension.isAtBottom(channelId)) { + if (messages.ready && Dimension.channelPinnedToEnd(channelId)) { this.commitMessages(messages.truncateTop(MAX_MESSAGES_PER_CHANNEL)); this.notifyChange(); } @@ -450,7 +450,7 @@ class Messages { const channel = Channels.getChannel(channelId); if (channel && channel.guildId === guildId) { this.clearMessages(channelId); - Dimension.clearChannelDimensions(channelId); + Dimension.forgetChannelDimensions(channelId); didUpdate = true; if (channelId === selectedChannelId) { selectedChannelAffected = true; @@ -593,7 +593,7 @@ class Messages { if (!existing?.ready) { return false; } - const updated = existing.receiveMessage(action.message, Dimension.isAtBottom(action.channelId)); + const updated = existing.receiveMessage(action.message, Dimension.channelPinnedToEnd(action.channelId)); this.commitMessages(updated); this.notifyChange(); return false; @@ -725,7 +725,7 @@ class Messages { ChannelMessages.forEach(({channelId}) => { if (Channels.getChannel(channelId) == null) { this.clearMessages(channelId); - Dimension.clearChannelDimensions(channelId); + Dimension.forgetChannelDimensions(channelId); } }); this.notifyChange(); diff --git a/fluxer_app/src/features/notification/state/NotificationAutoAck.ts b/fluxer_app/src/features/notification/state/NotificationAutoAck.ts index 9264d0301..969d9a8a3 100644 --- a/fluxer_app/src/features/notification/state/NotificationAutoAck.ts +++ b/fluxer_app/src/features/notification/state/NotificationAutoAck.ts @@ -48,7 +48,7 @@ class AutoAck { if (!channelId) { return {windowId, channelId: null, isAtBottom: false, canAutoAck: false}; } - const isAtBottom = Dimension.isAtBottom(channelId) ?? false; + const isAtBottom = Dimension.channelPinnedToEnd(channelId) ?? false; const readState = ReadStates.getIfExists(channelId); const ackedManually = readState?.ackedManually ?? false; const channel = Channels.getChannel(channelId); diff --git a/fluxer_app/src/features/platform/utils/ScrollManager.ts b/fluxer_app/src/features/platform/utils/ScrollManager.ts index e40c628c3..7fbc83471 100644 --- a/fluxer_app/src/features/platform/utils/ScrollManager.ts +++ b/fluxer_app/src/features/platform/utils/ScrollManager.ts @@ -94,9 +94,9 @@ export class ScrollManager { if (props.messages.jumpTargetId != null) { this.pinIsAtBottom = false; } else { - const stored = Dimension.getChannelDimensions(props.channel.id); + const stored = Dimension.channelDimensionsFor(props.channel.id); this.restoreHadSavedPosition = stored != null; - const isAtBottom = Dimension.isAtBottom(props.channel.id); + const isAtBottom = Dimension.channelPinnedToEnd(props.channel.id); this.pinIsAtBottom = isAtBottom ?? true; this.restorePendingInitialScrollTop = this.pinIsAtBottom ? null : (stored?.scrollTop ?? null); } @@ -173,12 +173,12 @@ export class ScrollManager { } scrollGetDocument(): Document | undefined { - const node = this.ref.current?.getScrollerNode(); + const node = this.ref.current?.getViewportElement(); return node?.ownerDocument; } scrollGetState(): ScrollerState { - return this.ref.current?.getScrollerState() ?? DEFAULT_SCROLLER_STATE; + return this.ref.current?.readViewportMetrics() ?? DEFAULT_SCROLLER_STATE; } private placeholderHeightGet(): number { @@ -186,7 +186,7 @@ export class ScrollManager { if (scrollerHandle == null) { return this.props.placeholderHeight; } - const scrollerNode = scrollerHandle.getScrollerNode(); + const scrollerNode = scrollerHandle.getViewportElement(); if (scrollerNode == null) { return this.props.placeholderHeight; } @@ -249,7 +249,7 @@ export class ScrollManager { callback?: () => void, shouldContinue?: () => boolean, ): void { - const scrollerNode = this.ref.current?.getScrollerNode(); + const scrollerNode = this.ref.current?.getViewportElement(); if (!scrollerNode) { callback?.(); return; @@ -263,7 +263,7 @@ export class ScrollManager { anchorGetData(messageId: string, scrollTop: number, clampTo?: number, layout?: ContainerLayout): AnchorData | null { const element = this.layoutGetElementFromMessageId(messageId); - const scrollerNode = this.ref.current?.getScrollerNode(); + const scrollerNode = this.ref.current?.getViewportElement(); if (!element || !scrollerNode) return null; const offsetHeight = element.offsetHeight; const offsetTop = this.layoutGetOffsetTop(element, scrollerNode, layout); @@ -299,7 +299,7 @@ export class ScrollManager { const {messages, hasUnreads, channel} = this.props; const state = this.scrollGetState(); const {scrollTop, offsetHeight} = state; - const scrollerNode = this.ref.current?.getScrollerNode(); + const scrollerNode = this.ref.current?.getViewportElement(); const layout = scrollerNode ? this.layoutGetContainerLayout(scrollerNode) : undefined; const buffer = hasUnreads && scrollTop >= this.layoutGetNewMessageBarBuffer() ? this.layoutGetNewMessageBarBuffer() : 0; @@ -336,7 +336,7 @@ export class ScrollManager { anchorFindLoadMore(isBefore: boolean): AnchorData | null { const {messages} = this.props; const {scrollTop} = this.scrollGetState(); - const scrollerNode = this.ref.current?.getScrollerNode(); + const scrollerNode = this.ref.current?.getViewportElement(); const layout = scrollerNode ? this.layoutGetContainerLayout(scrollerNode) : undefined; const direction = isBefore ? 1 : -1; const startIndex = isBefore ? 0 : messages.length - 1; @@ -366,7 +366,7 @@ export class ScrollManager { this.loadIsInProgress() ? null : this.anchorFetchFallback, this.anchorAutomatic, ]; - const scrollerNode = this.ref.current?.getScrollerNode(); + const scrollerNode = this.ref.current?.getViewportElement(); if (!scrollerNode) return null; const layout = this.layoutGetContainerLayout(scrollerNode); for (const anchor of candidates) { @@ -418,7 +418,7 @@ export class ScrollManager { } else { this.scrollMergeTo(fixedScrollTop, this.scrollHandle); } - this.ref.current?.scrollIntoViewNode({ + this.ref.current?.revealElement({ node, padding: 16 + this.props.additionalMessagePadding, callback: this.scrollHandle, @@ -467,7 +467,7 @@ export class ScrollManager { } anchorUpdateFetch(scrollTop: number, offsetHeight: number, scrollHeight: number, passLayout?: ContainerLayout): void { - const scrollerNode = this.ref.current?.getScrollerNode(); + const scrollerNode = this.ref.current?.getViewportElement(); if ((!this.anchorFetch && !this.anchorFetchFallback) || !scrollerNode) return; const region = this.scrollIsInPlaceholderRegion({scrollTop, offsetHeight, scrollHeight}); const clampTo = region !== ScrollRegion.None ? offsetHeight : undefined; @@ -481,7 +481,7 @@ export class ScrollManager { } anchorUpdateAutomatic(scrollTop: number, layout?: ContainerLayout): void { - const scrollerNode = this.ref.current?.getScrollerNode(); + const scrollerNode = this.ref.current?.getViewportElement(); if (!this.anchorAutomatic || !scrollerNode) return; const anchorData = this.anchorGetData(this.anchorAutomatic.id, scrollTop, undefined, layout); if (!anchorData) { @@ -573,7 +573,7 @@ export class ScrollManager { private focusGetMessageIdInScroller(): string | null { const focusedElement = document.activeElement; - const scrollerNode = this.ref.current?.getScrollerNode(); + const scrollerNode = this.ref.current?.getViewportElement(); if (!focusedElement || !scrollerNode?.contains(focusedElement)) { return null; } @@ -697,7 +697,7 @@ export class ScrollManager { this.cacheScrollTop = state.scrollTop; this.scrollFixPosition(state.offsetHeight, state.scrollHeight, isAtBottom); } else { - if (event && event.target !== this.ref.current?.getScrollerNode()) { + if (event && event.target !== this.ref.current?.getViewportElement()) { return; } if (this.cacheScrollTop !== state.scrollTop) { @@ -833,7 +833,7 @@ export class ScrollManager { const element = this.layoutGetElementFromMessageId(targetId); if (element) { const padding = this.jumpGetBreathingRoom(); - const scrollerNode = this.ref.current?.getScrollerNode(); + const scrollerNode = this.ref.current?.getViewportElement(); if (!scrollerNode) return; const targetScrollTop = this.layoutGetNodeAlignedScrollTop(element, 'center', padding, scrollerNode); this.scrollMergeTo(targetScrollTop, () => this.jumpComplete(element, jumpToken)); @@ -874,7 +874,7 @@ export class ScrollManager { const padding = suppressPadding ? 0 : this.jumpGetBreathingRoom(); if (newMessagesBar) { if (orientation === 'middle') { - const scrollerNode = this.ref.current?.getScrollerNode(); + const scrollerNode = this.ref.current?.getViewportElement(); if (!scrollerNode) { this.scrollTo(Number.MAX_SAFE_INTEGER, animate, onComplete); return; @@ -899,7 +899,7 @@ export class ScrollManager { scrollToBelowUnreadDivider(): void { const doc = this.scrollGetDocument(); - const scrollerNode = this.ref.current?.getScrollerNode(); + const scrollerNode = this.ref.current?.getViewportElement(); const newMessagesBar = doc?.getElementById('new-messages-bar'); if (!scrollerNode || !newMessagesBar) { this.scrollSetToBottom(); @@ -963,7 +963,7 @@ export class ScrollManager { if (this.lifecycleIsDisposed) return; const {messages, channel} = this.props; this.jumpStop(true); - Dimension.updateChannelDimensions(channel.id, 1, 1, 0); + Dimension.recordChannelDimensions(channel.id, 1, 1, 0); if (messages.hasMoreAfter) { MessageCommands.jumpToPresent(channel.id, MAX_MESSAGES_PER_CHANNEL); } else { @@ -1045,7 +1045,7 @@ export class ScrollManager { layoutApplyShift(heightDelta: number): boolean { if (this.lifecycleIsDisposed || !this.lifecycleIsInitialized()) return false; - const scrollerNode = this.ref.current?.getScrollerNode(); + const scrollerNode = this.ref.current?.getViewportElement(); if (!scrollerNode) return false; const action = resolveContainerResizeShift({ heightDelta, @@ -1079,19 +1079,19 @@ export class ScrollManager { const {channel} = this.props; const placeholderHeight = this.placeholderHeightGet(); if (this.pinIsAtBottomNow()) { - Dimension.updateChannelDimensions(channel.id, 1, 1, 0, callback); + Dimension.recordChannelDimensions(channel.id, 1, 1, 0, callback); } else { const {scrollTop, scrollHeight, offsetHeight} = this.scrollGetState(); const adjustedScrollTop = scrollTop - placeholderHeight; const adjustedScrollHeight = scrollHeight - placeholderHeight; - Dimension.updateChannelDimensions(channel.id, adjustedScrollTop, adjustedScrollHeight, offsetHeight, callback); + Dimension.recordChannelDimensions(channel.id, adjustedScrollTop, adjustedScrollHeight, offsetHeight, callback); } } focusOnMessage(messageId: string): void { const element = this.layoutGetElementFromMessageId(messageId); if (!element) return; - const scrollerNode = this.ref.current?.getScrollerNode(); + const scrollerNode = this.ref.current?.getViewportElement(); if (!scrollerNode) return; const elementOffset = this.layoutGetOffsetTop(element, scrollerNode); const elementHeight = element.offsetHeight; @@ -1125,11 +1125,11 @@ export class ScrollManager { } scrollPageUp(animate = false): void { - this.ref.current?.scrollPageUp({animate}); + this.ref.current?.pageBackward({animate}); } scrollPageDown(animate = false): void { - this.ref.current?.scrollPageDown({animate}); + this.ref.current?.pageForward({animate}); } scrollToMessage(messageId: string, animate = true): void { @@ -1143,7 +1143,7 @@ export class ScrollManager { return; } const element = this.layoutGetElementFromMessageId(messageId); - const scrollerNode = scrollerHandle.getScrollerNode(); + const scrollerNode = scrollerHandle.getViewportElement(); this.pinIsAtBottom = false; const jumpToken = this.jumpBegin(this.props.messages.jumpSequenceId); const shouldContinue = () => this.jumpIsCurrent(jumpToken); @@ -1174,7 +1174,7 @@ export class ScrollManager { return; } const {scrollTop, offsetHeight, scrollHeight} = this.scrollGetState(); - const scrollerNode = this.ref.current?.getScrollerNode(); + const scrollerNode = this.ref.current?.getViewportElement(); const layout = scrollerNode ? this.layoutGetContainerLayout(scrollerNode) : undefined; this.pinPreUpdateState = this.pinComputeState({scrollTop, offsetHeight, scrollHeight}); this.anchorUpdateFocus(focusId, scrollTop, offsetHeight, layout); @@ -1247,7 +1247,7 @@ export class ScrollManager { if (focusId != null && prevFocusId !== focusId) { const el = this.layoutGetElementFromMessageId(focusId); if (el) { - this.ref.current?.scrollIntoViewNode({ + this.ref.current?.revealElement({ node: el, padding: 16 + this.props.additionalMessagePadding, callback: this.scrollHandle, diff --git a/fluxer_app/src/features/read_state/state/ReadStates.ts b/fluxer_app/src/features/read_state/state/ReadStates.ts index be1905757..211729e7c 100644 --- a/fluxer_app/src/features/read_state/state/ReadStates.ts +++ b/fluxer_app/src/features/read_state/state/ReadStates.ts @@ -496,7 +496,7 @@ class ReadStates { const decision = resolveReadStateIncomingMessageDecision({ isCurrentUserAuthor: currentUser != null && action.message.author.id === currentUser.id, automaticAckEnabled: this.isAutomaticAckEnabled(action.channelId), - isAtBottom: Dimension.isAtBottom(action.channelId), + isAtBottom: Dimension.channelPinnedToEnd(action.channelId), authorBlocked, hadUnreadOrMentions, readStateKnown: state.readStateKnown, diff --git a/fluxer_app/src/features/search/components/bottomsheets/QuickSwitcherBottomSheet.tsx b/fluxer_app/src/features/search/components/bottomsheets/QuickSwitcherBottomSheet.tsx index b4ad60bcb..614fce866 100644 --- a/fluxer_app/src/features/search/components/bottomsheets/QuickSwitcherBottomSheet.tsx +++ b/fluxer_app/src/features/search/components/bottomsheets/QuickSwitcherBottomSheet.tsx @@ -341,7 +341,7 @@ export const QuickSwitcherBottomSheet: React.FC = shouldScrollToSelection.current = false; const node = rowRefs.current[keyboardFocusIndex]; if (node) { - scrollerRef.current?.scrollIntoViewNode({node: node as HTMLElement, padding: 32}); + scrollerRef.current?.revealElement({node: node as HTMLElement, padding: 32}); } }, [keyboardFocusIndex, activeTab]); const sections = useMemo(() => createSections(results), [results]); diff --git a/fluxer_app/src/features/search/components/quick_switcher/QuickSwitcherModal.tsx b/fluxer_app/src/features/search/components/quick_switcher/QuickSwitcherModal.tsx index ad8f4ccd1..e56e1d25a 100644 --- a/fluxer_app/src/features/search/components/quick_switcher/QuickSwitcherModal.tsx +++ b/fluxer_app/src/features/search/components/quick_switcher/QuickSwitcherModal.tsx @@ -391,7 +391,7 @@ const QuickSwitcherModalComponent: React.FC = observer(() => { shouldScrollToSelection.current = false; const node = rowRefs.current[keyboardFocusIndex]; if (node) { - scrollerRef.current?.scrollIntoViewNode({node: node as HTMLElement, padding: 32}); + scrollerRef.current?.revealElement({node: node as HTMLElement, padding: 32}); } }, [keyboardFocusIndex]); const sections = useMemo(() => createSections(results), [results]); diff --git a/fluxer_app/src/features/ui/components/Scroller.tsx b/fluxer_app/src/features/ui/components/Scroller.tsx index 9ec279988..1efe46647 100644 --- a/fluxer_app/src/features/ui/components/Scroller.tsx +++ b/fluxer_app/src/features/ui/components/Scroller.tsx @@ -28,6 +28,7 @@ import { useRef, useState, } from 'react'; +import {flushSync} from 'react-dom'; export interface ScrollerState { scrollTop: number; @@ -39,42 +40,43 @@ export interface ScrollerState { } export interface ScrollerHandle { - getScrollerNode(): HTMLElement | null; - getScrollerState(): ScrollerState; + getViewportElement(): HTMLElement | null; + readViewportMetrics(): ScrollerState; cancelScroll(): void; scrollTo(options: {to: number; animate?: boolean; callback?: () => void}): void; mergeTo(options: {to: number; callback?: () => void}): void; - scrollIntoViewRect(options: { + revealRange(options: { start: number; end: number; - shouldScrollToStart?: boolean; + preferStartEdge?: boolean; alignment?: 'start' | 'center' | 'end' | 'nearest'; padding?: number; animate?: boolean; callback?: () => void; }): void; - scrollIntoViewNode(options: { + revealElement(options: { node: HTMLElement; - shouldScrollToStart?: boolean; + preferStartEdge?: boolean; alignment?: 'start' | 'center' | 'end' | 'nearest'; padding?: number; animate?: boolean; callback?: () => void; }): void; - scrollPageUp(options?: {animate?: boolean; callback?: () => void}): void; - scrollPageDown(options?: {animate?: boolean; callback?: () => void}): void; - scrollToTop(options?: {animate?: boolean; callback?: () => void}): void; - scrollToBottom(options?: {animate?: boolean; callback?: () => void}): void; - isScrolledToTop(): boolean; - isScrolledToBottom(): boolean; - getDistanceFromTop(): number; - getDistanceFromBottom(): number; + pageBackward(options?: {animate?: boolean; callback?: () => void}): void; + pageForward(options?: {animate?: boolean; callback?: () => void}): void; + jumpToStartEdge(options?: {animate?: boolean; callback?: () => void}): void; + jumpToEndEdge(options?: {animate?: boolean; callback?: () => void}): void; + atStartEdge(): boolean; + atEndEdge(): boolean; + remainingToStartEdge(): number; + remainingToEndEdge(): number; } type ScrollAxis = 'vertical' | 'horizontal'; type ScrollOverflow = 'scroll' | 'auto' | 'hidden'; type ScrollbarTrackMode = 'overlay' | 'reserve'; const LINE_DELTA_PX = 16; +const RESIZE_OBSERVE_OPTIONS = Object.freeze({box: 'border-box'} as const); interface ScrollerProps extends Omit< @@ -323,6 +325,13 @@ export const Scroller = forwardRef(function Scrol }, [getMaxScroll], ); + const resolveScrollTarget = useCallback( + (node: HTMLDivElement, value: number): number => { + const max = getMaxScroll(node); + return value >= max ? max + 1 : Math.max(0, value); + }, + [getMaxScroll], + ); const springScrollTo = useCallback( (node: HTMLDivElement, target: number, callback?: () => void, config: SpringConfig = DEFAULT_SPRING) => { cancelAnimation(); @@ -368,20 +377,24 @@ export const Scroller = forwardRef(function Scrol [cancelAnimation, clampToRange, getScrollPos, setScrollPos], ); useLayoutEffect(() => { - const containerEl = rootRef.current; + const containerEl = scrollRef.current; const contentEl = contentRef.current; if (!containerEl || !contentEl) return; const ownerWindow = containerEl.ownerDocument.defaultView ?? window; const containerObserver = new ownerWindow.ResizeObserver((entries) => { scheduleThumbRefresh(); for (const entry of entries) { - onResizeRef.current?.(entry, 'container'); + flushSync(() => { + onResizeRef.current?.(entry, 'container'); + }); } }); const contentObserver = new ownerWindow.ResizeObserver((entries) => { scheduleThumbRefresh(); for (const entry of entries) { - onResizeRef.current?.(entry, 'content'); + flushSync(() => { + onResizeRef.current?.(entry, 'content'); + }); } }); const childObserver = new ownerWindow.ResizeObserver((entries) => { @@ -417,8 +430,8 @@ export const Scroller = forwardRef(function Scrol refreshThumbState(); }); }); - containerObserver.observe(containerEl); - contentObserver.observe(contentEl); + containerObserver.observe(containerEl, RESIZE_OBSERVE_OPTIONS); + contentObserver.observe(contentEl, RESIZE_OBSERVE_OPTIONS); syncChildObservers(); childListObserver.observe(contentEl, {childList: true, subtree: true}); refreshThumbState(); @@ -470,8 +483,8 @@ export const Scroller = forwardRef(function Scrol useImperativeHandle( forwardedRef, () => ({ - getScrollerNode: () => scrollRef.current, - getScrollerState: () => getScrollState(scrollRef.current), + getViewportElement: () => scrollRef.current, + readViewportMetrics: () => getScrollState(scrollRef.current), cancelScroll() { cancelAnimation(); }, @@ -481,7 +494,7 @@ export const Scroller = forwardRef(function Scrol callback?.(); return; } - const clampedTo = clampToRange(node, to); + const clampedTo = resolveScrollTarget(node, to); if (!animate || !shouldAnimateScroll()) { cancelAnimation(); setScrollPos(node, clampedTo); @@ -496,11 +509,19 @@ export const Scroller = forwardRef(function Scrol callback?.(); return; } - cancelAnimation(); - setScrollPos(node, clampToRange(node, to)); + const merged = clampToRange(node, to); + const anim = animRef.current; + if (anim == null) { + setScrollPos(node, merged); + callback?.(); + return; + } + const delta = merged - getScrollPos(node); + setScrollPos(node, merged); + anim.target += delta; callback?.(); }, - scrollIntoViewRect({start, end, shouldScrollToStart = false, alignment, padding = 0, animate = false, callback}) { + revealRange({start, end, preferStartEdge = false, alignment, padding = 0, animate = false, callback}) { const node = scrollRef.current; if (!node) { callback?.(); @@ -508,7 +529,7 @@ export const Scroller = forwardRef(function Scrol } const scrollPos = getScrollPos(node); const viewportSize = orientation === 'vertical' ? node.offsetHeight : node.offsetWidth; - const resolvedAlignment = alignment ?? (shouldScrollToStart ? 'start' : 'nearest'); + const resolvedAlignment = alignment ?? (preferStartEdge ? 'start' : 'nearest'); if (resolvedAlignment === 'center') { const targetScroll = start - (viewportSize - (end - start)) / 2; this.scrollTo({to: targetScroll, animate, callback}); @@ -537,14 +558,7 @@ export const Scroller = forwardRef(function Scrol } this.scrollTo({to: targetScroll, animate, callback}); }, - scrollIntoViewNode({ - node: targetNode, - shouldScrollToStart = false, - alignment, - padding = 0, - animate = false, - callback, - }) { + revealElement({node: targetNode, preferStartEdge = false, alignment, padding = 0, animate = false, callback}) { const container = scrollRef.current; if (!container) { callback?.(); @@ -552,17 +566,17 @@ export const Scroller = forwardRef(function Scrol } const offset = measureElementOffset(targetNode, orientation, container); const size = orientation === 'vertical' ? targetNode.offsetHeight : targetNode.offsetWidth; - this.scrollIntoViewRect({ + this.revealRange({ start: offset, end: offset + size, - shouldScrollToStart, + preferStartEdge, alignment, padding, animate, callback, }); }, - scrollPageUp({animate = false, callback} = {}) { + pageBackward({animate = false, callback} = {}) { const node = scrollRef.current; if (!node) { callback?.(); @@ -573,7 +587,7 @@ export const Scroller = forwardRef(function Scrol const target = Math.max(0, scrollPos - 0.9 * viewportSize); this.scrollTo({to: target, animate, callback}); }, - scrollPageDown({animate = false, callback} = {}) { + pageForward({animate = false, callback} = {}) { const node = scrollRef.current; if (!node) { callback?.(); @@ -585,10 +599,10 @@ export const Scroller = forwardRef(function Scrol const target = Math.min(maxScroll, scrollPos + 0.9 * viewportSize); this.scrollTo({to: target, animate, callback}); }, - scrollToTop({animate = false, callback} = {}) { + jumpToStartEdge({animate = false, callback} = {}) { this.scrollTo({to: 0, animate, callback}); }, - scrollToBottom({animate = false, callback} = {}) { + jumpToEndEdge({animate = false, callback} = {}) { const node = scrollRef.current; if (!node) { callback?.(); @@ -596,24 +610,24 @@ export const Scroller = forwardRef(function Scrol } this.scrollTo({to: getMaxScroll(node), animate, callback}); }, - isScrolledToTop() { + atStartEdge() { const node = scrollRef.current; if (!node) return false; return getScrollPos(node) === 0; }, - isScrolledToBottom() { + atEndEdge() { const node = scrollRef.current; if (!node) return false; const scrollPos = getScrollPos(node); const maxScroll = getMaxScroll(node); return scrollPos >= maxScroll; }, - getDistanceFromTop() { + remainingToStartEdge() { const node = scrollRef.current; if (!node) return 0; return Math.max(0, getScrollPos(node)); }, - getDistanceFromBottom() { + remainingToEndEdge() { const node = scrollRef.current; if (!node) return 0; const scrollPos = getScrollPos(node); @@ -621,7 +635,16 @@ export const Scroller = forwardRef(function Scrol return Math.max(0, maxScroll - scrollPos); }, }), - [orientation, cancelAnimation, clampToRange, getMaxScroll, getScrollPos, setScrollPos, springScrollTo], + [ + orientation, + cancelAnimation, + clampToRange, + getMaxScroll, + getScrollPos, + resolveScrollTarget, + setScrollPos, + springScrollTo, + ], ); const handleScroll = useCallback( (event: UIEvent) => { diff --git a/fluxer_app/src/features/ui/hooks/useScrollAnchor.ts b/fluxer_app/src/features/ui/hooks/useScrollAnchor.ts index 5e479ed30..1b47f8b7f 100644 --- a/fluxer_app/src/features/ui/hooks/useScrollAnchor.ts +++ b/fluxer_app/src/features/ui/hooks/useScrollAnchor.ts @@ -71,7 +71,7 @@ function findScrollContainer(el: HTMLElement, selector: string | undefined): HTM function resolveScrollerNode(scrollerRef: RefObject): HTMLElement | null { const scroller = scrollerRef.current; if (scroller == null) return null; - return scroller.getScrollerNode(); + return scroller.getViewportElement(); } function compareListScrollAnchorPositions(left: ListScrollAnchorPosition, right: ListScrollAnchorPosition): number { diff --git a/fluxer_app/src/features/ui/state/Dimension.ts b/fluxer_app/src/features/ui/state/Dimension.ts index 2c4a540fb..09cb42768 100644 --- a/fluxer_app/src/features/ui/state/Dimension.ts +++ b/fluxer_app/src/features/ui/state/Dimension.ts @@ -36,14 +36,14 @@ class Dimension { constructor() { makeObservable(this, { - updateChannelDimensions: action, + recordChannelDimensions: action, updateGuildDimensions: action, updateGuildListDimensions: action, - clearChannelDimensions: action, + forgetChannelDimensions: action, }); } - percentageScrolled(channelId: string): number { + scrollProgressRatio(channelId: string): number { const dimensions = this.channelDimensions.get(channelId); if (dimensions != null) { const {scrollTop, scrollHeight} = dimensions; @@ -52,11 +52,11 @@ class Dimension { return 1; } - getChannelDimensions(channelId: string): ChannelDimensions | undefined { + channelDimensionsFor(channelId: string): ChannelDimensions | undefined { return this.channelDimensions.get(channelId); } - getGuildDimensions(guildId: string): GuildDimensions { + guildDimensionsFor(guildId: string): GuildDimensions { const existing = this.guildDimensions.get(guildId); if (existing != null) { return existing; @@ -64,11 +64,11 @@ class Dimension { return createDefaultGuildDimensions(guildId); } - getGuildListDimensions(): GuildListDimensions { + currentGuildListDimensions(): GuildListDimensions { return this.guildListDimensions.get(); } - isAtBottom(channelId: string): boolean { + channelPinnedToEnd(channelId: string): boolean { const dimensions = this.channelDimensions.get(channelId); if (dimensions == null) { return true; @@ -77,7 +77,7 @@ class Dimension { return scrollTop >= scrollHeight - offsetHeight - BOTTOM_TOLERANCE; } - updateChannelDimensions( + recordChannelDimensions( channelId: string, scrollTop: number | null, scrollHeight: number | null, @@ -129,7 +129,7 @@ class Dimension { this.guildListDimensions.set({scrollTop}); } - clearChannelDimensions(channelId: string): void { + forgetChannelDimensions(channelId: string): void { this.channelDimensions.delete(channelId); } @@ -142,7 +142,7 @@ class Dimension { } handleCallCreate(channelId: string): void { - this.clearChannelDimensions(channelId); + this.forgetChannelDimensions(channelId); } } diff --git a/fluxer_app/src/features/voice/hooks/useWindowFocusVideoControl.ts b/fluxer_app/src/features/voice/hooks/useWindowFocusVideoControl.ts index 7b901f8bb..b931a4ce6 100644 --- a/fluxer_app/src/features/voice/hooks/useWindowFocusVideoControl.ts +++ b/fluxer_app/src/features/voice/hooks/useWindowFocusVideoControl.ts @@ -46,7 +46,7 @@ export function useWindowFocusVideoControl({ } }; const handleBlur = () => { - const node = scrollerRefRef.current.current?.getScrollerNode(); + const node = scrollerRefRef.current.current?.getViewportElement(); if (document.activeElement instanceof HTMLElement && node?.contains(document.activeElement)) { const scrollTop = node.scrollTop; document.activeElement.blur();