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

@@ -392,7 +392,7 @@ export const MobileSectionNav: React.FC<MobileSectionNavProps> = observer(
const scrollerRef = useRef<ScrollerHandle | null>(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) {
@@ -125,7 +125,7 @@ export const SettingsModalDesktopScroll: React.FC<SettingsModalDesktopScrollProp
if (!scrollerRef) {
return;
}
const node = internalRef.current?.getScrollerNode() ?? null;
const node = internalRef.current?.getViewportElement() ?? null;
if (typeof scrollerRef === 'function') {
scrollerRef(node);
} else {
@@ -734,7 +734,7 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
const target = event.target as Element | null;
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return;
if (target instanceof HTMLElement && target.isContentEditable) return;
const scrollerNode = scrollerRef.current?.getScrollerNode() ?? null;
const scrollerNode = scrollerRef.current?.getViewportElement() ?? null;
if (!scrollerNode) return;
if (target && !scrollerNode.contains(target) && document.activeElement !== document.body) return;
const sections = Array.from(scrollerNode.querySelectorAll<HTMLElement>('[data-inbox-channel-section]'));
@@ -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;
@@ -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<Channel>;
let filteredVoiceChannels: Array<Channel>;
if (hideMutedChannels) {
@@ -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;
@@ -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);
@@ -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,
@@ -467,7 +467,7 @@ export const GuildRailSkeleton: React.FC<GuildRailSkeletonProps> = ({
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;
@@ -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();
@@ -265,7 +265,7 @@ function resolveScrollNode(scrollerRef: React.RefObject<ScrollerHandle | null>):
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;
}
@@ -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]);
@@ -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();
@@ -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;
}
@@ -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();
@@ -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);
@@ -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,
@@ -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,
@@ -341,7 +341,7 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
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]);
@@ -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]);
@@ -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<ScrollerHandle, ScrollerProps>(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<ScrollerHandle, ScrollerProps>(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<ScrollerHandle, ScrollerProps>(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<ScrollerHandle, ScrollerProps>(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<ScrollerHandle, ScrollerProps>(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<ScrollerHandle, ScrollerProps>(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<ScrollerHandle, ScrollerProps>(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<ScrollerHandle, ScrollerProps>(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<ScrollerHandle, ScrollerProps>(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<ScrollerHandle, ScrollerProps>(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<ScrollerHandle, ScrollerProps>(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<ScrollerHandle, ScrollerProps>(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<ScrollerHandle, ScrollerProps>(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<HTMLDivElement>) => {
@@ -71,7 +71,7 @@ function findScrollContainer(el: HTMLElement, selector: string | undefined): HTM
function resolveScrollerNode(scrollerRef: RefObject<ScrollerHandle | null>): HTMLElement | null {
const scroller = scrollerRef.current;
if (scroller == null) return null;
return scroller.getScrollerNode();
return scroller.getViewportElement();
}
function compareListScrollAnchorPositions(left: ListScrollAnchorPosition, right: ListScrollAnchorPosition): number {
+10 -10
View File
@@ -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);
}
}
@@ -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();