fix(app): keep the chat scroller pinned to the end while it resizes (#1860)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:33:06 +02:00
1 parent a2099fb0e2
commit 2af0405317
36 files changed
+171 -191

No files matched your search

@@ -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;
@@ -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,
});
});
@@ -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;
@@ -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;
}
@@ -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<HTMLElement>('[data-message-id]');
@@ -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);
@@ -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) => {
@@ -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) => {
@@ -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) => {
@@ -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) => {
@@ -367,7 +367,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
clearChannelSearchHighlight();
return;
}
const container = scrollerRef.current?.getScrollerNode();
const container = scrollerRef.current?.getViewportElement();
if (!container) {
return;
}
@@ -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();
});
});
@@ -60,7 +60,7 @@ export function useScrollerViewport(scrollerRef: React.RefObject<ScrollerHandle
[scheduleViewportSize],
);
useEffect(() => {
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]);