fix(app): open channels at the unread divider and settle jump scrolling (#1885)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:39:23 +02:00
1 parent 735ecc1cca
commit 406340fa65
4 files changed
+245 -142

No files matched your search

@@ -218,22 +218,22 @@ export const Messages = observer(function Messages({
messages: safeMessages, messages: safeMessages,
channel, channel,
compact: state.messageDisplayCompact, compact: state.messageDisplayCompact,
hasUnreads: state.unreadCount > 0, hasPendingUnreads: state.unreadCount > 0,
focusId: null, focusAnchorId: null,
placeholderHeight: placeholderSpecs.totalHeight, unloadedSpacerHeight: placeholderSpecs.totalHeight,
canLoadMore: true, allowHistoryFetch: true,
windowId, windowId,
handleScrollToBottom: () => { notifyPinnedToBottom: () => {
runInAction(() => { runInAction(() => {
state.isAtBottom = true; state.isAtBottom = true;
}); });
}, },
handleScrollFromBottom: () => { notifyUnpinnedFromBottom: () => {
runInAction(() => { runInAction(() => {
state.isAtBottom = false; state.isAtBottom = false;
}); });
}, },
additionalMessagePadding: 48, extraListPadding: 48,
canAutoAck, canAutoAck,
handleJumpHighlight, handleJumpHighlight,
}); });
@@ -359,8 +359,8 @@ export const Messages = observer(function Messages({
const onForceJumpToPresent = () => { const onForceJumpToPresent = () => {
MessageCommands.jumpToLiveEdge(channel.id, MAX_MESSAGES_PER_CHANNEL); MessageCommands.jumpToLiveEdge(channel.id, MAX_MESSAGES_PER_CHANNEL);
}; };
const onScrollPageUp = () => scrollManager.scrollPageUp(true); const onScrollPageUp = () => scrollManager.pageBackward(true);
const onScrollPageDown = () => scrollManager.scrollPageDown(true); const onScrollPageDown = () => scrollManager.pageForward(true);
const onLayoutResized = (payload?: unknown) => { const onLayoutResized = (payload?: unknown) => {
const data = payload as {channelId?: string} | undefined; const data = payload as {channelId?: string} | undefined;
if (data?.channelId && data.channelId !== channel.id) return; if (data?.channelId && data.channelId !== channel.id) return;
@@ -395,7 +395,7 @@ export const Messages = observer(function Messages({
if (bottomMostVisibleMessage) { if (bottomMostVisibleMessage) {
const messageId = bottomMostVisibleMessage.dataset.messageId; const messageId = bottomMostVisibleMessage.dataset.messageId;
if (messageId) { if (messageId) {
scrollManager.focusOnMessage(messageId); scrollManager.focusRequestForMessage(messageId);
} }
} }
}; };
@@ -461,7 +461,7 @@ export const Messages = observer(function Messages({
containerRef: scrollManager.ref, containerRef: scrollManager.ref,
channelId: channel.id, channelId: channel.id,
onFocusMessage: (messageId) => { onFocusMessage: (messageId) => {
scrollManager.focusOnMessage(messageId); scrollManager.focusRequestForMessage(messageId);
}, },
onLoadMoreBefore: () => { onLoadMoreBefore: () => {
scrollManager.loadMoreForKeyboardNavigation(false); scrollManager.loadMoreForKeyboardNavigation(false);
@@ -11,14 +11,22 @@ import {evaluateScrollPinning, type ScrollPinResult} from '@app/features/platfor
import { import {
type AnchorData, type AnchorData,
BOTTOM_LOCK_TOLERANCE, BOTTOM_LOCK_TOLERANCE,
CENTRE_ALIGNMENT_LIFT,
DEFAULT_SCROLLER_STATE, DEFAULT_SCROLLER_STATE,
type DebouncedFunction, type DebouncedFunction,
InitialScrollIntent,
JumpPreSnap,
MESSAGE_REVEAL_PADDING,
resolveContainerResizeShift, resolveContainerResizeShift,
resolveInitialScrollIntent,
resolveJumpPreSnap,
resolveJumpPreSnapOrigin,
resolveJumpTargetId, resolveJumpTargetId,
type ScrollerRef, type ScrollerRef,
type ScrollerState, type ScrollerState,
ScrollRegion, ScrollRegion,
shouldAnimateMessageJump, shouldAnimateMessageJump,
UNREAD_LOAD_TRIGGER_LIFT,
} from '@app/features/platform/utils/scroll_manager/shared'; } from '@app/features/platform/utils/scroll_manager/shared';
import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx'; import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx';
import Dimension from '@app/features/ui/state/Dimension'; import Dimension from '@app/features/ui/state/Dimension';
@@ -29,6 +37,7 @@ import debounce from 'lodash/debounce';
import {createRef, useLayoutEffect, useState} from 'react'; import {createRef, useLayoutEffect, useState} from 'react';
const ANCHOR_SCROLL_IDLE_MS = 35; const ANCHOR_SCROLL_IDLE_MS = 35;
const LOAD_PAUSE_MIN_GROWTH_PX = 100;
interface ContainerLayout { interface ContainerLayout {
rectTop: number; rectTop: number;
@@ -39,14 +48,14 @@ export interface ScrollManagerProps {
messages: ChannelMessages; messages: ChannelMessages;
channel: Channel; channel: Channel;
compact: boolean; compact: boolean;
hasUnreads: boolean; hasPendingUnreads: boolean;
focusId: string | null; focusAnchorId: string | null;
placeholderHeight: number; unloadedSpacerHeight: number;
canLoadMore: boolean; allowHistoryFetch: boolean;
windowId: string; windowId: string;
handleScrollToBottom: () => void; notifyPinnedToBottom: () => void;
handleScrollFromBottom: () => void; notifyUnpinnedFromBottom: () => void;
additionalMessagePadding: number; extraListPadding: number;
canAutoAck: boolean; canAutoAck: boolean;
handleJumpHighlight: (messageId: string | null, jumpTicket: number) => void; handleJumpHighlight: (messageId: string | null, jumpTicket: number) => void;
} }
@@ -63,7 +72,6 @@ export class ScrollManager {
private jumpIsActive = false; private jumpIsActive = false;
private pinIsAtBottom!: boolean; private pinIsAtBottom!: boolean;
private dragIsActive = false; private dragIsActive = false;
private restoreHadSavedPosition = false;
private pinIsCurrentlyAtBottom = false; private pinIsCurrentlyAtBottom = false;
private pinIsScrollingProgrammatically = false; private pinIsScrollingProgrammatically = false;
private lifecycleIsDisposed = false; private lifecycleIsDisposed = false;
@@ -82,6 +90,8 @@ export class ScrollManager {
scrollHeight: number; scrollHeight: number;
} | null = null; } | null = null;
private loadLastDirection: 'before' | 'after' | null = null; private loadLastDirection: 'before' | 'after' | null = null;
private loadScrollHeightBefore = 0;
private loadPausedUntilUserScroll = false;
private pinPreUpdateState: ScrollPinResult | null = null; private pinPreUpdateState: ScrollPinResult | null = null;
private jumpTicket: number | null = null; private jumpTicket: number | null = null;
private jumpCallbackToken = 0; private jumpCallbackToken = 0;
@@ -94,11 +104,10 @@ export class ScrollManager {
if (props.messages.jumpDestinationId != null) { if (props.messages.jumpDestinationId != null) {
this.pinIsAtBottom = false; this.pinIsAtBottom = false;
} else { } else {
const stored = Dimension.channelDimensionsFor(props.channel.id); const savedPinnedToEnd = Dimension.channelPinnedToEnd(props.channel.id);
this.restoreHadSavedPosition = stored != null; const savedScrollTop = Dimension.channelDimensionsFor(props.channel.id)?.scrollTop ?? null;
const isAtBottom = Dimension.channelPinnedToEnd(props.channel.id); this.pinIsAtBottom = savedPinnedToEnd;
this.pinIsAtBottom = isAtBottom ?? true; this.restorePendingInitialScrollTop = savedPinnedToEnd ? null : savedScrollTop;
this.restorePendingInitialScrollTop = this.pinIsAtBottom ? null : (stored?.scrollTop ?? null);
} }
this.dimensionPersistDebounced = debounce(this.dimensionPersist.bind(this), 200); this.dimensionPersistDebounced = debounce(this.dimensionPersist.bind(this), 200);
} }
@@ -135,11 +144,12 @@ export class ScrollManager {
loadIsDisabled(): boolean { loadIsDisabled(): boolean {
return ( return (
this.loadPausedUntilUserScroll ||
this.loadIsInProgress() || this.loadIsInProgress() ||
!this.lifecycleIsInitialized() || !this.lifecycleIsInitialized() ||
this.jumpIsActiveNow() || this.jumpIsActiveNow() ||
this.dragIsActiveNow() || this.dragIsActiveNow() ||
!this.props.canLoadMore !this.props.allowHistoryFetch
); );
} }
@@ -181,16 +191,16 @@ export class ScrollManager {
return this.ref.current?.readViewportMetrics() ?? DEFAULT_SCROLLER_STATE; return this.ref.current?.readViewportMetrics() ?? DEFAULT_SCROLLER_STATE;
} }
private placeholderHeightGet(): number { private unloadedSpacerHeightGet(): number {
const scrollerHandle = this.ref.current; const scrollerHandle = this.ref.current;
if (scrollerHandle == null) { if (scrollerHandle == null) {
return this.props.placeholderHeight; return this.props.unloadedSpacerHeight;
} }
const scrollerNode = scrollerHandle.getViewportElement(); const scrollerNode = scrollerHandle.getViewportElement();
if (scrollerNode == null) { if (scrollerNode == null) {
return this.props.placeholderHeight; return this.props.unloadedSpacerHeight;
} }
return this.props.placeholderHeight * getRemScaleForDocument(scrollerNode.ownerDocument); return this.props.unloadedSpacerHeight * getRemScaleForDocument(scrollerNode.ownerDocument);
} }
pinIsAtBottomFor(state: ScrollerState = this.scrollGetState()): boolean { pinIsAtBottomFor(state: ScrollerState = this.scrollGetState()): boolean {
@@ -219,8 +229,8 @@ export class ScrollManager {
return containerLayout.scrollTop + (elRect.top - containerLayout.rectTop); return containerLayout.scrollTop + (elRect.top - containerLayout.rectTop);
} }
private jumpGetBreathingRoom(): number { private jumpGetCentrePadding(): number {
return NEW_MESSAGES_BAR_BUFFER; return this.props.hasPendingUnreads ? this.layoutGetNewMessageBarBuffer() : MESSAGE_REVEAL_PADDING;
} }
private layoutGetNodeAlignedScrollTop( private layoutGetNodeAlignedScrollTop(
@@ -235,7 +245,7 @@ export class ScrollManager {
const delta = nodeRect.top - containerRect.top; const delta = nodeRect.top - containerRect.top;
const nodeOffsetTop = state.scrollTop + delta; const nodeOffsetTop = state.scrollTop + delta;
if (alignment === 'center') { if (alignment === 'center') {
const centered = nodeOffsetTop - (state.offsetHeight - nodeRect.height) / 2; const centered = nodeOffsetTop - (state.offsetHeight - nodeRect.height) / 2 - CENTRE_ALIGNMENT_LIFT;
return Math.min(centered, nodeOffsetTop - padding); return Math.min(centered, nodeOffsetTop - padding);
} }
return nodeOffsetTop - padding; return nodeOffsetTop - padding;
@@ -296,13 +306,13 @@ export class ScrollManager {
} }
anchorFindTopVisible(): AnchorData | null { anchorFindTopVisible(): AnchorData | null {
const {messages, hasUnreads, channel} = this.props; const {messages, hasPendingUnreads, channel} = this.props;
const state = this.scrollGetState(); const state = this.scrollGetState();
const {scrollTop, offsetHeight} = state; const {scrollTop, offsetHeight} = state;
const scrollerNode = this.ref.current?.getViewportElement(); const scrollerNode = this.ref.current?.getViewportElement();
const layout = scrollerNode ? this.layoutGetContainerLayout(scrollerNode) : undefined; const layout = scrollerNode ? this.layoutGetContainerLayout(scrollerNode) : undefined;
const buffer = const buffer =
hasUnreads && scrollTop >= this.layoutGetNewMessageBarBuffer() ? this.layoutGetNewMessageBarBuffer() : 0; hasPendingUnreads && scrollTop >= this.layoutGetNewMessageBarBuffer() ? this.layoutGetNewMessageBarBuffer() : 0;
let anchor: AnchorData | null = null; let anchor: AnchorData | null = null;
let index = -1; let index = -1;
let foundAnchor = false; let foundAnchor = false;
@@ -358,7 +368,7 @@ export class ScrollManager {
anchorGetFixData(): { anchorGetFixData(): {
node: HTMLElement; node: HTMLElement;
fixedScrollTop: number; correctedScrollTop: number;
} | null { } | null {
const candidates = [ const candidates = [
this.anchorFocus, this.anchorFocus,
@@ -374,10 +384,10 @@ export class ScrollManager {
const element = this.layoutGetElementFromMessageId(anchor.id); const element = this.layoutGetElementFromMessageId(anchor.id);
if (!element) continue; if (!element) continue;
const currentOffsetTop = this.layoutGetOffsetTop(element, scrollerNode, layout); const currentOffsetTop = this.layoutGetOffsetTop(element, scrollerNode, layout);
const fixedScrollTop = Math.max(0, currentOffsetTop - anchor.offsetFromTop); const correctedScrollTop = Math.max(0, currentOffsetTop - anchor.offsetFromTop);
return { return {
node: element, node: element,
fixedScrollTop, correctedScrollTop,
}; };
} }
return null; return null;
@@ -411,16 +421,16 @@ export class ScrollManager {
this.scrollHandle(); this.scrollHandle();
return false; return false;
} }
const {node, fixedScrollTop} = anchorData; const {node, correctedScrollTop} = anchorData;
if (this.anchorFocus) { if (this.anchorFocus) {
if (this.pinIsAtBottomNow()) { if (this.pinIsAtBottomNow()) {
this.scrollTo(Number.MAX_SAFE_INTEGER, false, this.scrollHandle); this.scrollTo(Number.MAX_SAFE_INTEGER, false, this.scrollHandle);
} else { } else {
this.scrollMergeTo(fixedScrollTop, this.scrollHandle); this.scrollMergeTo(correctedScrollTop, this.scrollHandle);
} }
this.ref.current?.revealElement({ this.ref.current?.revealElement({
node, node,
padding: 16 + this.props.additionalMessagePadding, padding: MESSAGE_REVEAL_PADDING + this.props.extraListPadding,
callback: this.scrollHandle, callback: this.scrollHandle,
}); });
if (KeyboardMode.keyboardModeEnabled && this.anchorFocus) { if (KeyboardMode.keyboardModeEnabled && this.anchorFocus) {
@@ -433,7 +443,7 @@ export class ScrollManager {
this.anchorFocus = null; this.anchorFocus = null;
} }
} else { } else {
this.scrollMergeTo(fixedScrollTop, this.scrollHandle); this.scrollMergeTo(correctedScrollTop, this.scrollHandle);
} }
if (!this.loadIsInProgress()) { if (!this.loadIsInProgress()) {
this.anchorFetch = null; this.anchorFetch = null;
@@ -498,11 +508,11 @@ export class ScrollManager {
scrollIsInPlaceholderRegion(state: ScrollerState): ScrollRegion { scrollIsInPlaceholderRegion(state: ScrollerState): ScrollRegion {
const {scrollTop, offsetHeight, scrollHeight} = state; const {scrollTop, offsetHeight, scrollHeight} = state;
const {messages} = this.props; const {messages} = this.props;
const placeholderHeight = this.placeholderHeightGet(); const unloadedSpacerHeight = this.unloadedSpacerHeightGet();
if (messages.hasMoreBefore && scrollTop < placeholderHeight && scrollHeight > offsetHeight) { if (messages.hasMoreBefore && scrollTop < unloadedSpacerHeight && scrollHeight > offsetHeight) {
return ScrollRegion.Top; return ScrollRegion.Top;
} }
if (messages.hasMoreAfter && scrollTop >= scrollHeight - offsetHeight - placeholderHeight) { if (messages.hasMoreAfter && scrollTop >= scrollHeight - offsetHeight - unloadedSpacerHeight) {
return ScrollRegion.Bottom; return ScrollRegion.Bottom;
} }
return ScrollRegion.None; return ScrollRegion.None;
@@ -510,15 +520,17 @@ export class ScrollManager {
loadGetOffsetToTrigger(edge: 'top' | 'bottom', state: ScrollerState): number { loadGetOffsetToTrigger(edge: 'top' | 'bottom', state: ScrollerState): number {
const {scrollHeight, offsetHeight} = state; const {scrollHeight, offsetHeight} = state;
const {messages, hasUnreads} = this.props; const {messages, hasPendingUnreads} = this.props;
const placeholderHeight = this.placeholderHeightGet(); const unloadedSpacerHeight = this.unloadedSpacerHeightGet();
if (edge === 'top') { if (edge === 'top') {
if (!messages.hasMoreBefore) { if (!messages.hasMoreBefore) {
return 0; return 0;
} }
return hasUnreads ? placeholderHeight - NEW_MESSAGES_BAR_BUFFER - 2 : placeholderHeight + 500; return hasPendingUnreads ? unloadedSpacerHeight - UNREAD_LOAD_TRIGGER_LIFT - 2 : unloadedSpacerHeight + 500;
} }
return messages.hasMoreAfter ? scrollHeight - offsetHeight - placeholderHeight - 500 : scrollHeight - offsetHeight; return messages.hasMoreAfter
? scrollHeight - offsetHeight - unloadedSpacerHeight - 500
: scrollHeight - offsetHeight;
} }
loadGetOffsetToPrevent(edge: 'top' | 'bottom'): number { loadGetOffsetToPrevent(edge: 'top' | 'bottom'): number {
@@ -549,23 +561,23 @@ export class ScrollManager {
} }
scrollHandleSpeed(state: ScrollerState): void { scrollHandleSpeed(state: ScrollerState): void {
if (this.jumpIsActiveNow() || this.dragIsActiveNow() || this.loadIsInProgress() || !this.props.canLoadMore) { if (this.jumpIsActiveNow() || this.dragIsActiveNow() || this.loadIsInProgress() || !this.props.allowHistoryFetch) {
return; return;
} }
const {scrollTop, offsetHeight, scrollHeight} = state; const {scrollTop, offsetHeight, scrollHeight} = state;
const prev = this.cachePreviousScrollTop; const prev = this.cachePreviousScrollTop;
const placeholderHeight = this.placeholderHeightGet(); const unloadedSpacerHeight = this.unloadedSpacerHeightGet();
this.cachePreviousScrollTop = scrollTop; this.cachePreviousScrollTop = scrollTop;
if (prev == null) return; if (prev == null) return;
const region = this.scrollIsInPlaceholderRegion(state); const region = this.scrollIsInPlaceholderRegion(state);
const delta = scrollTop - prev; const delta = scrollTop - prev;
if (region === ScrollRegion.None || delta === 0) return; if (region === ScrollRegion.None || delta === 0) return;
if (region === ScrollRegion.Top && scrollTop + delta <= 0) { if (region === ScrollRegion.Top && scrollTop + delta <= 0) {
const newTop = placeholderHeight - offsetHeight; const newTop = unloadedSpacerHeight - offsetHeight;
this.scrollMergeTo(newTop); this.scrollMergeTo(newTop);
this.cachePreviousScrollTop = newTop; this.cachePreviousScrollTop = newTop;
} else if (region === ScrollRegion.Bottom && scrollTop + delta >= scrollHeight - offsetHeight) { } else if (region === ScrollRegion.Bottom && scrollTop + delta >= scrollHeight - offsetHeight) {
const newTop = scrollHeight - placeholderHeight; const newTop = scrollHeight - unloadedSpacerHeight;
this.scrollMergeTo(newTop); this.scrollMergeTo(newTop);
this.cachePreviousScrollTop = newTop; this.cachePreviousScrollTop = newTop;
} }
@@ -630,6 +642,7 @@ export class ScrollManager {
this.anchorCaptureFocused(); this.anchorCaptureFocused();
} }
this.anchorUpdateFetchForLoad(loadAfter); this.anchorUpdateFetchForLoad(loadAfter);
this.loadScrollHeightBefore = this.cacheScrollHeight;
this.cachePreviousScrollTop = null; this.cachePreviousScrollTop = null;
this.loadIsActive = true; this.loadIsActive = true;
MessageCommands.fetchMessages(channel.id, beforeId ?? null, afterId ?? null, MAX_MESSAGES_PER_CHANNEL); MessageCommands.fetchMessages(channel.id, beforeId ?? null, afterId ?? null, MAX_MESSAGES_PER_CHANNEL);
@@ -675,14 +688,14 @@ export class ScrollManager {
const pinState = this.pinComputeState(state); const pinState = this.pinComputeState(state);
const heightChanged = const heightChanged =
state.offsetHeight !== this.cacheOffsetHeight || state.scrollHeight !== this.cacheScrollHeight; state.offsetHeight !== this.cacheOffsetHeight || state.scrollHeight !== this.cacheScrollHeight;
const isAtBottom = const pinnedToEnd =
(heightChanged && this.pinIsAtBottom) || this.pinIsScrollingProgrammatically ? true : pinState.isPinned; (heightChanged && this.pinIsAtBottom) || this.pinIsScrollingProgrammatically ? true : pinState.isPinned;
if (isAtBottom !== this.pinIsCurrentlyAtBottom) { if (pinnedToEnd !== this.pinIsCurrentlyAtBottom) {
this.pinIsCurrentlyAtBottom = isAtBottom; this.pinIsCurrentlyAtBottom = pinnedToEnd;
if (isAtBottom) { if (pinnedToEnd) {
this.props.handleScrollToBottom(); this.props.notifyPinnedToBottom();
} else { } else {
this.props.handleScrollFromBottom(); this.props.notifyUnpinnedFromBottom();
} }
} }
if (heightChanged) { if (heightChanged) {
@@ -695,13 +708,16 @@ export class ScrollManager {
this.anchorSetAutomatic(this.anchorFindTopVisible()); this.anchorSetAutomatic(this.anchorFindTopVisible());
} }
this.cacheScrollTop = state.scrollTop; this.cacheScrollTop = state.scrollTop;
this.scrollFixPosition(state.offsetHeight, state.scrollHeight, isAtBottom); this.scrollFixPosition(state.offsetHeight, state.scrollHeight, pinnedToEnd);
} else { } else {
if (event && event.target !== this.ref.current?.getViewportElement()) { if (event && event.target !== this.ref.current?.getViewportElement()) {
return; return;
} }
if (this.cacheScrollTop !== state.scrollTop) { if (this.cacheScrollTop !== state.scrollTop) {
this.pinIsAtBottom = isAtBottom; if (this.loadPausedUntilUserScroll && event != null && this.loadIsInTriggerRegion(state) !== ScrollRegion.Top) {
this.loadPausedUntilUserScroll = false;
}
this.pinIsAtBottom = pinnedToEnd;
if (this.pinIsAtBottom) { if (this.pinIsAtBottom) {
this.anchorClearAutomatic(); this.anchorClearAutomatic();
} else if (this.anchorAutomatic) { } else if (this.anchorAutomatic) {
@@ -727,13 +743,13 @@ export class ScrollManager {
} }
this.scrollHandleSpeed(state); this.scrollHandleSpeed(state);
}; };
scrollHandleResize = (_entry: ResizeObserverEntry, type: 'container' | 'content'): void => { scrollHandleResize = (entry: ResizeObserverEntry, type: 'container' | 'content'): void => {
if (this.lifecycleIsDisposed) return; if (this.lifecycleIsDisposed) return;
if (this.resizeReentryGuard) return; if (this.resizeReentryGuard) return;
this.resizeReentryGuard = true; this.resizeReentryGuard = true;
try { try {
if (type === 'container') { if (type === 'container') {
const {offsetHeight} = this.scrollGetState(); const offsetHeight = entry.contentRect.height;
const previousHeight = this.resizeContainerHeight; const previousHeight = this.resizeContainerHeight;
this.resizeContainerHeight = offsetHeight; this.resizeContainerHeight = offsetHeight;
const heightDelta = previousHeight === -1 ? 0 : previousHeight - offsetHeight; const heightDelta = previousHeight === -1 ? 0 : previousHeight - offsetHeight;
@@ -747,7 +763,15 @@ export class ScrollManager {
}; };
layoutHandleResized(heightDelta?: number): void { layoutHandleResized(heightDelta?: number): void {
if (this.lifecycleIsDisposed || !this.lifecycleIsInitialized()) return; if (this.lifecycleIsDisposed) return;
if (!this.lifecycleIsInitialized()) {
const placeholderState = this.scrollGetState();
if (!this.layoutIsHeightChange(placeholderState.offsetHeight, placeholderState.scrollHeight)) return;
this.cacheOffsetHeight = placeholderState.offsetHeight;
this.cacheScrollHeight = placeholderState.scrollHeight;
this.restoreApplyPlaceholderPosition();
return;
}
if (typeof heightDelta === 'number' && this.layoutApplyShift(heightDelta)) { if (typeof heightDelta === 'number' && this.layoutApplyShift(heightDelta)) {
return; return;
} }
@@ -781,7 +805,7 @@ export class ScrollManager {
this.cacheOffsetHeight = offsetHeight; this.cacheOffsetHeight = offsetHeight;
this.cacheScrollHeight = scrollHeight; this.cacheScrollHeight = scrollHeight;
if (this.loadPrependSnapshot && !this.loadIsInProgress() && this.loadLastDirection === 'before') { if (this.loadPrependSnapshot && !this.loadIsInProgress() && this.loadLastDirection === 'before') {
const {scrollTop: prevScrollTop, scrollHeight: prevScrollHeight} = this.loadPrependSnapshot; const {scrollTop: snapshotScrollTop, scrollHeight: prevScrollHeight} = this.loadPrependSnapshot;
this.loadPrependSnapshot = null; this.loadPrependSnapshot = null;
this.loadLastDirection = null; this.loadLastDirection = null;
if (this.anchorGetFixData()) { if (this.anchorGetFixData()) {
@@ -792,7 +816,7 @@ export class ScrollManager {
if (addedHeight !== 0) { if (addedHeight !== 0) {
const currentState = this.scrollGetState(); const currentState = this.scrollGetState();
const maxScroll = Math.max(0, scrollHeight - currentState.offsetHeight); const maxScroll = Math.max(0, scrollHeight - currentState.offsetHeight);
const targetScrollTop = Math.max(0, Math.min(prevScrollTop + addedHeight, maxScroll)); const targetScrollTop = Math.max(0, Math.min(snapshotScrollTop + addedHeight, maxScroll));
this.pinIsAtBottom = false; this.pinIsAtBottom = false;
this.scrollMergeTo(targetScrollTop, this.scrollHandle); this.scrollMergeTo(targetScrollTop, this.scrollHandle);
return; return;
@@ -832,14 +856,14 @@ export class ScrollManager {
if (targetId) { if (targetId) {
const element = this.layoutGetElementFromMessageId(targetId); const element = this.layoutGetElementFromMessageId(targetId);
if (element) { if (element) {
const padding = this.jumpGetBreathingRoom(); const padding = this.jumpGetCentrePadding();
const scrollerNode = this.ref.current?.getViewportElement(); const scrollerNode = this.ref.current?.getViewportElement();
if (!scrollerNode) return; if (!scrollerNode) return;
const targetScrollTop = this.layoutGetNodeAlignedScrollTop(element, 'center', padding, scrollerNode); const targetScrollTop = this.layoutGetNodeAlignedScrollTop(element, 'center', padding, scrollerNode);
this.scrollMergeTo(targetScrollTop, () => this.jumpComplete(element, jumpToken)); this.scrollMergeTo(targetScrollTop, () => this.jumpComplete(element, jumpToken));
return; return;
} }
this.scrollToNewMessages('top', () => this.jumpComplete(null, jumpToken), false, false, jumpToken); this.scrollToUnreadBoundary('top', () => this.jumpComplete(null, jumpToken), false, false, jumpToken);
return; return;
} }
if (!messages.hasMoreAfter) { if (!messages.hasMoreAfter) {
@@ -847,7 +871,7 @@ export class ScrollManager {
} }
} }
scrollToNewMessages( scrollToUnreadBoundary(
orientation: 'top' | 'middle' = 'top', orientation: 'top' | 'middle' = 'top',
callback?: () => void, callback?: () => void,
animate = true, animate = true,
@@ -855,7 +879,7 @@ export class ScrollManager {
jumpToken = this.jumpBegin(this.jumpTicket), jumpToken = this.jumpBegin(this.jumpTicket),
): void { ): void {
const doc = this.scrollGetDocument(); const doc = this.scrollGetDocument();
const newMessagesBar = doc?.getElementById('new-messages-bar'); const unreadDividerNode = doc?.getElementById('new-messages-bar');
const shouldContinue = () => this.jumpIsCurrent(jumpToken); const shouldContinue = () => this.jumpIsCurrent(jumpToken);
const onComplete = () => { const onComplete = () => {
if (!shouldContinue()) { if (!shouldContinue()) {
@@ -871,67 +895,43 @@ export class ScrollManager {
this.scrollHandle(); this.scrollHandle();
}; };
this.pinIsAtBottom = false; this.pinIsAtBottom = false;
const padding = suppressPadding ? 0 : this.jumpGetBreathingRoom(); this.jumpIsActive = animate;
if (newMessagesBar) { const padding = suppressPadding ? 0 : this.layoutGetNewMessageBarBuffer();
if (orientation === 'middle') { if (unreadDividerNode) {
const scrollerNode = this.ref.current?.getViewportElement(); const alignment = orientation === 'middle' ? 'center' : 'start';
if (!scrollerNode) { this.layoutLockNodeToAlignment(unreadDividerNode, alignment, padding, animate, onComplete, shouldContinue);
this.scrollTo(Number.MAX_SAFE_INTEGER, animate, onComplete);
return;
}
const {offsetHeight} = this.scrollGetState();
const containerRect = scrollerNode.getBoundingClientRect();
const nodeRect = newMessagesBar.getBoundingClientRect();
const delta = nodeRect.top - containerRect.top;
const nodeOffsetTop = scrollerNode.scrollTop + delta;
const middleTarget = nodeOffsetTop - 0.5 * offsetHeight + 0.5 * nodeRect.height;
const target = Math.min(middleTarget, nodeOffsetTop - padding);
this.scrollTo(target, animate, onComplete);
return;
}
this.layoutLockNodeToAlignment(newMessagesBar, 'start', padding, animate, onComplete, shouldContinue);
} else if (!this.props.messages.hasMoreAfter) {
this.scrollTo(Number.MAX_SAFE_INTEGER, animate, onComplete);
} else { } else {
onComplete(); this.scrollTo(this.loadGetOffsetToPrevent('top'), animate, onComplete);
} }
} }
scrollToBelowUnreadDivider(): void {
const doc = this.scrollGetDocument();
const scrollerNode = this.ref.current?.getViewportElement();
const newMessagesBar = doc?.getElementById('new-messages-bar');
if (!scrollerNode || !newMessagesBar) {
this.scrollSetToBottom();
return;
}
const offsetTop = this.layoutGetOffsetTop(newMessagesBar, scrollerNode);
const elementHeight = newMessagesBar.offsetHeight;
const targetScrollTop = offsetTop + elementHeight + 2;
this.pinIsAtBottom = false;
this.scrollTo(targetScrollTop, false, () => {
this.anchorSetAutomatic(this.anchorFindTopVisible());
this.scrollHandle();
});
}
restoreInitial(): void { restoreInitial(): void {
if (this.lifecycleIsInitialized()) return; if (this.lifecycleIsInitialized()) return;
const {restorePendingInitialScrollTop} = this; const rememberedScrollTop = this.restorePendingInitialScrollTop ?? null;
this.restorePendingInitialScrollTop = undefined; this.restorePendingInitialScrollTop = undefined;
const targetId = resolveJumpTargetId(this.props.messages); const targetId = resolveJumpTargetId(this.props.messages);
const intent = resolveInitialScrollIntent({
channelType: this.props.channel.type,
rememberedScrollTop,
hasPendingUnreads: this.props.hasPendingUnreads,
});
if (targetId != null) { if (targetId != null) {
this.scrollToMessage(targetId, false); this.scrollAlignToMessage(targetId, false);
} else if (restorePendingInitialScrollTop != null) { } else if (intent === InitialScrollIntent.UNREAD_BOUNDARY) {
const targetScroll = restorePendingInitialScrollTop + this.placeholderHeightGet(); this.scrollToUnreadBoundary('top', undefined, false);
} else if (intent === InitialScrollIntent.SAVED_OFFSET && rememberedScrollTop != null) {
const targetScroll = rememberedScrollTop + this.unloadedSpacerHeightGet();
this.scrollTo(targetScroll, false, this.scrollHandle); this.scrollTo(targetScroll, false, this.scrollHandle);
} else if (!this.restoreHadSavedPosition && this.props.hasUnreads) {
this.scrollToBelowUnreadDivider();
} else { } else {
this.scrollSetToBottom(); this.scrollSetToBottom();
} }
} }
private restoreApplyPlaceholderPosition(): void {
if (this.props.messages.jumpDestinationId != null) return;
this.scrollTo(Number.MAX_SAFE_INTEGER);
}
scrollTo(position: number, animate = false, callback?: () => void): void { scrollTo(position: number, animate = false, callback?: () => void): void {
if (this.lifecycleIsDisposed) return; if (this.lifecycleIsDisposed) return;
this.ref.current?.scrollTo({ this.ref.current?.scrollTo({
@@ -961,6 +961,10 @@ export class ScrollManager {
scrollSetToBottom(animate = false): void { scrollSetToBottom(animate = false): void {
if (this.lifecycleIsDisposed) return; if (this.lifecycleIsDisposed) return;
if (!this.lifecycleIsInitialized()) {
this.restoreApplyPlaceholderPosition();
return;
}
const {messages, channel} = this.props; const {messages, channel} = this.props;
this.jumpStop(true); this.jumpStop(true);
Dimension.recordChannelDimensions(channel.id, 1, 1, 0); Dimension.recordChannelDimensions(channel.id, 1, 1, 0);
@@ -1077,18 +1081,19 @@ export class ScrollManager {
if (this.lifecycleIsDisposed) return; if (this.lifecycleIsDisposed) return;
if (this.jumpIsActiveNow() || !this.lifecycleIsInitialized()) return; if (this.jumpIsActiveNow() || !this.lifecycleIsInitialized()) return;
const {channel} = this.props; const {channel} = this.props;
const placeholderHeight = this.placeholderHeightGet(); const unloadedSpacerHeight = this.unloadedSpacerHeightGet();
if (this.pinIsAtBottomNow()) { if (this.pinIsAtBottomNow()) {
Dimension.recordChannelDimensions(channel.id, 1, 1, 0, callback); Dimension.recordChannelDimensions(channel.id, 1, 1, 0, callback);
} else { } else {
const {scrollTop, scrollHeight, offsetHeight} = this.scrollGetState(); const {scrollTop, scrollHeight, offsetHeight} = this.scrollGetState();
const adjustedScrollTop = scrollTop - placeholderHeight; const adjustedScrollTop = scrollTop - unloadedSpacerHeight;
const adjustedScrollHeight = scrollHeight - placeholderHeight; const adjustedScrollHeight = scrollHeight - unloadedSpacerHeight;
Dimension.recordChannelDimensions(channel.id, adjustedScrollTop, adjustedScrollHeight, offsetHeight, callback); Dimension.recordChannelDimensions(channel.id, adjustedScrollTop, adjustedScrollHeight, offsetHeight, callback);
} }
} }
focusOnMessage(messageId: string): void { focusRequestForMessage(messageId: string): void {
if (this.lifecycleIsDisposed || !this.lifecycleIsInitialized()) return;
const element = this.layoutGetElementFromMessageId(messageId); const element = this.layoutGetElementFromMessageId(messageId);
if (!element) return; if (!element) return;
const scrollerNode = this.ref.current?.getViewportElement(); const scrollerNode = this.ref.current?.getViewportElement();
@@ -1111,7 +1116,7 @@ export class ScrollManager {
if (targetScrollTop !== null) { if (targetScrollTop !== null) {
const maxScroll = scrollerNode.scrollHeight - offsetHeight; const maxScroll = scrollerNode.scrollHeight - offsetHeight;
targetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScroll)); targetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScroll));
scrollerNode.scrollTop = targetScrollTop; this.scrollTo(targetScrollTop);
} }
const newScrollTop = targetScrollTop ?? scrollTop; const newScrollTop = targetScrollTop ?? scrollTop;
const anchor = this.anchorGetData(messageId, newScrollTop); const anchor = this.anchorGetData(messageId, newScrollTop);
@@ -1124,15 +1129,17 @@ export class ScrollManager {
element.focus({preventScroll: true}); element.focus({preventScroll: true});
} }
scrollPageUp(animate = false): void { pageBackward(animate = false): void {
if (this.lifecycleIsDisposed || !this.lifecycleIsInitialized()) return;
this.ref.current?.pageBackward({animate}); this.ref.current?.pageBackward({animate});
} }
scrollPageDown(animate = false): void { pageForward(animate = false): void {
if (this.lifecycleIsDisposed || !this.lifecycleIsInitialized()) return;
this.ref.current?.pageForward({animate}); this.ref.current?.pageForward({animate});
} }
scrollToMessage(messageId: string, animate = true): void { scrollAlignToMessage(messageId: string, animate = true, preSnap: JumpPreSnap = JumpPreSnap.NONE): void {
const scrollerHandle = this.ref.current; const scrollerHandle = this.ref.current;
if (!scrollerHandle) return; if (!scrollerHandle) return;
const {jumpHighlight, jumpTicket, jumpDestinationId} = this.props.messages; const {jumpHighlight, jumpTicket, jumpDestinationId} = this.props.messages;
@@ -1149,10 +1156,15 @@ export class ScrollManager {
const shouldContinue = () => this.jumpIsCurrent(jumpToken); const shouldContinue = () => this.jumpIsCurrent(jumpToken);
const onComplete = () => this.jumpComplete(element, jumpToken); const onComplete = () => this.jumpComplete(element, jumpToken);
if (!element || !scrollerNode) { if (!element || !scrollerNode) {
this.scrollToNewMessages('middle', onComplete, animate, false, jumpToken); this.scrollToUnreadBoundary('middle', onComplete, animate, false, jumpToken);
return; return;
} }
const padding = this.jumpGetBreathingRoom(); if (preSnap === JumpPreSnap.START) {
this.scrollTo(0);
} else if (preSnap === JumpPreSnap.END) {
this.scrollTo(Number.MAX_SAFE_INTEGER);
}
const padding = this.jumpGetCentrePadding();
const target = this.layoutGetNodeAlignedScrollTop(element, 'center', padding, scrollerNode); const target = this.layoutGetNodeAlignedScrollTop(element, 'center', padding, scrollerNode);
this.anchorFocus = this.anchorGetData(messageId, target); this.anchorFocus = this.anchorGetData(messageId, target);
this.scrollTo(target, animate, () => { this.scrollTo(target, animate, () => {
@@ -1161,8 +1173,8 @@ export class ScrollManager {
}); });
} }
lifecycleGetSnapshotBeforeUpdate(focusId: string | null): void { lifecycleGetSnapshotBeforeUpdate(focusAnchorId: string | null): void {
if (!this.anchorHasAny() && focusId == null) { if (!this.anchorHasAny() && focusAnchorId == null) {
this.pinPreUpdateState = this.pinPreUpdateState =
this.pinIsAtBottomNow() || this.pinIsCurrentlyAtBottom || this.pinIsScrollingProgrammatically this.pinIsAtBottomNow() || this.pinIsCurrentlyAtBottom || this.pinIsScrollingProgrammatically
? { ? {
@@ -1177,7 +1189,7 @@ export class ScrollManager {
const scrollerNode = this.ref.current?.getViewportElement(); const scrollerNode = this.ref.current?.getViewportElement();
const layout = scrollerNode ? this.layoutGetContainerLayout(scrollerNode) : undefined; const layout = scrollerNode ? this.layoutGetContainerLayout(scrollerNode) : undefined;
this.pinPreUpdateState = this.pinComputeState({scrollTop, offsetHeight, scrollHeight}); this.pinPreUpdateState = this.pinComputeState({scrollTop, offsetHeight, scrollHeight});
this.anchorUpdateFocus(focusId, scrollTop, offsetHeight, layout); this.anchorUpdateFocus(focusAnchorId, scrollTop, offsetHeight, layout);
this.anchorUpdateFetch(scrollTop, offsetHeight, scrollHeight, layout); this.anchorUpdateFetch(scrollTop, offsetHeight, scrollHeight, layout);
this.anchorUpdateAutomatic(scrollTop, layout); this.anchorUpdateAutomatic(scrollTop, layout);
} }
@@ -1189,7 +1201,7 @@ export class ScrollManager {
private propsApplyUpdate(nextProps: ScrollManagerProps): void { private propsApplyUpdate(nextProps: ScrollManagerProps): void {
const prevMessages = this.props.messages; const prevMessages = this.props.messages;
const prevFocusId = this.props.focusId; const prevFocusId = this.props.focusAnchorId;
const pinPreUpdateState = this.pinTakePreUpdateState(); const pinPreUpdateState = this.pinTakePreUpdateState();
this.props = {...nextProps}; this.props = {...nextProps};
const {offsetHeight, scrollHeight} = this.scrollGetState(); const {offsetHeight, scrollHeight} = this.scrollGetState();
@@ -1198,15 +1210,18 @@ export class ScrollManager {
this.cacheOffsetHeight = offsetHeight; this.cacheOffsetHeight = offsetHeight;
this.cacheScrollHeight = scrollHeight; this.cacheScrollHeight = scrollHeight;
this.loadIsActive = nextProps.messages.loadingMore; this.loadIsActive = nextProps.messages.loadingMore;
if (prevMessages.channelId !== nextProps.messages.channelId) {
this.loadPausedUntilUserScroll = false;
} else if (prevMessages.loadingMore && !nextProps.messages.loadingMore) {
this.loadPausedUntilUserScroll = Math.abs(scrollHeight - this.loadScrollHeightBefore) < LOAD_PAUSE_MIN_GROWTH_PX;
}
if (this.lifecycleIsInitialized() || this.lifecycleIsReady()) { if (this.lifecycleIsInitialized() || this.lifecycleIsReady()) {
if (!this.lifecycleIsInitialized()) { if (!this.lifecycleIsInitialized()) {
this.restoreInitial(); this.restoreInitial();
return; return;
} }
} else { } else {
if (nextProps.messages.jumpDestinationId == null) { this.restoreApplyPlaceholderPosition();
this.scrollTo(Number.MAX_SAFE_INTEGER);
}
return; return;
} }
if (nextProps.messages.jumpDestinationId != null) { if (nextProps.messages.jumpDestinationId != null) {
@@ -1225,13 +1240,23 @@ export class ScrollManager {
if (nextProps.messages.jumpTicket === prevMessages.jumpTicket && this.jumpIsActiveNow()) { if (nextProps.messages.jumpTicket === prevMessages.jumpTicket && this.jumpIsActiveNow()) {
return; return;
} }
this.scrollToMessage(targetId, shouldAnimateMessageJump(nextProps.messages.jumpType)); const animate = shouldAnimateMessageJump(nextProps.messages.jumpType);
this.scrollAlignToMessage(
targetId,
animate,
resolveJumpPreSnap({
targetId,
fromTimestamp: resolveJumpPreSnapOrigin(prevMessages, nextProps.messages),
animate,
alreadyJumping: this.jumpIsActiveNow(),
reducedMotion: !Accessibility.useSmoothScrolling,
}),
);
return; return;
} }
if (nextProps.messages.jumpedToPresent && prevMessages.jumpTicket !== nextProps.messages.jumpTicket) { if (nextProps.messages.landedAtLiveEdge && prevMessages.jumpTicket !== nextProps.messages.jumpTicket) {
nextProps.handleJumpHighlight(null, nextProps.messages.jumpTicket); nextProps.handleJumpHighlight(null, nextProps.messages.jumpTicket);
this.jumpBegin(nextProps.messages.jumpTicket); this.jumpBegin(nextProps.messages.jumpTicket);
this.scrollTo(0);
this.scrollSetToBottom(); this.scrollSetToBottom();
return; return;
} }
@@ -1243,13 +1268,13 @@ export class ScrollManager {
} }
return; return;
} }
const {focusId} = this.props; const {focusAnchorId} = this.props;
if (focusId != null && prevFocusId !== focusId) { if (focusAnchorId != null && prevFocusId !== focusAnchorId) {
const el = this.layoutGetElementFromMessageId(focusId); const el = this.layoutGetElementFromMessageId(focusAnchorId);
if (el) { if (el) {
this.ref.current?.revealElement({ this.ref.current?.revealElement({
node: el, node: el,
padding: 16 + this.props.additionalMessagePadding, padding: MESSAGE_REVEAL_PADDING + this.props.extraListPadding,
callback: this.scrollHandle, callback: this.scrollHandle,
}); });
return; return;
@@ -1300,7 +1325,7 @@ export class ScrollManager {
export function useScrollManager(props: ScrollManagerProps): ScrollManager { export function useScrollManager(props: ScrollManagerProps): ScrollManager {
const [manager] = useState(() => new ScrollManager(props)); const [manager] = useState(() => new ScrollManager(props));
manager.lifecycleGetSnapshotBeforeUpdate(props.focusId); manager.lifecycleGetSnapshotBeforeUpdate(props.focusAnchorId);
useLayoutEffect(() => { useLayoutEffect(() => {
manager.lifecycleMergeProps(props); manager.lifecycleMergeProps(props);
}); });
@@ -1,8 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {JumpTypes} from '@fluxer/constants/src/JumpConstants'; import {JumpTypes} from '@fluxer/constants/src/JumpConstants';
import {NEW_MESSAGES_BAR_BUFFER} from '@fluxer/constants/src/LimitConstants';
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {resolveContainerResizeShift, type ScrollerState, shouldAnimateMessageJump} from './shared'; import {
CENTRE_ALIGNMENT_LIFT,
MESSAGE_REVEAL_PADDING,
resolveContainerResizeShift,
type ScrollerState,
shouldAnimateMessageJump,
} from './shared';
describe('the scroll geometry constants', () => {
it('lifts a centred node by exactly eight pixels', () => {
expect(CENTRE_ALIGNMENT_LIFT).toBe(8);
});
it('keeps the two centre paddings distinct, with the unread buffer the larger of the two', () => {
expect(MESSAGE_REVEAL_PADDING).toBe(16);
expect(NEW_MESSAGES_BAR_BUFFER).toBe(32);
expect(NEW_MESSAGES_BAR_BUFFER).toBeGreaterThan(MESSAGE_REVEAL_PADDING);
});
});
describe('shouldAnimateMessageJump', () => { describe('shouldAnimateMessageJump', () => {
it('animates only for explicit ANIMATED jumps', () => { it('animates only for explicit ANIMATED jumps', () => {
@@ -2,8 +2,9 @@
import type {ChannelMessages} from '@app/features/messaging/state/ChannelMessages'; import type {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
import type {ScrollerHandle} from '@app/features/ui/components/Scroller'; import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {type JumpType, JumpTypes} from '@fluxer/constants/src/JumpConstants'; import {type JumpType, JumpTypes} from '@fluxer/constants/src/JumpConstants';
import {compare} from '@fluxer/snowflake/src/SnowflakeUtils'; import {compare, extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
export type ScrollerRef = React.RefObject<ScrollerHandle | null> | React.RefObject<ScrollerHandle>; export type ScrollerRef = React.RefObject<ScrollerHandle | null> | React.RefObject<ScrollerHandle>;
export type DebouncedFunction<T> = T extends (...args: infer P) => infer R export type DebouncedFunction<T> = T extends (...args: infer P) => infer R
@@ -34,6 +35,9 @@ export const DEFAULT_SCROLLER_STATE: ScrollerState = {
offsetHeight: 0, offsetHeight: 0,
}; };
export const BOTTOM_LOCK_TOLERANCE = 8; export const BOTTOM_LOCK_TOLERANCE = 8;
export const CENTRE_ALIGNMENT_LIFT = 8;
export const MESSAGE_REVEAL_PADDING = 16;
export const UNREAD_LOAD_TRIGGER_LIFT = 16;
export const RESIZE_STICK_MIN_THRESHOLD = 64; export const RESIZE_STICK_MIN_THRESHOLD = 64;
export type ContainerResizeShift = {kind: 'none'} | {kind: 'pin'} | {kind: 'shift'; targetScrollTop: number}; export type ContainerResizeShift = {kind: 'none'} | {kind: 'pin'} | {kind: 'shift'; targetScrollTop: number};
@@ -64,6 +68,29 @@ export function resolveContainerResizeShift(options: {
return {kind: 'shift', targetScrollTop}; return {kind: 'shift', targetScrollTop};
} }
export const InitialScrollIntent = Object.freeze({
SAVED_OFFSET: 'savedOffset',
UNREAD_BOUNDARY: 'unreadBoundary',
BOTTOM: 'bottom',
} as const);
export type InitialScrollIntent = (typeof InitialScrollIntent)[keyof typeof InitialScrollIntent];
export const UNREAD_ANCHOR_EXEMPT_CHANNEL_TYPES: ReadonlySet<number> = new Set<number>([ChannelTypes.GUILD_VOICE]);
export interface InitialScrollIntentInput {
readonly channelType: number | null | undefined;
readonly rememberedScrollTop: number | null;
readonly hasPendingUnreads: boolean;
}
export function resolveInitialScrollIntent(input: InitialScrollIntentInput): InitialScrollIntent {
if (input.hasPendingUnreads && !UNREAD_ANCHOR_EXEMPT_CHANNEL_TYPES.has(input.channelType ?? -1)) {
return InitialScrollIntent.UNREAD_BOUNDARY;
}
return input.rememberedScrollTop != null ? InitialScrollIntent.SAVED_OFFSET : InitialScrollIntent.BOTTOM;
}
export enum ScrollRegion { export enum ScrollRegion {
None = 0, None = 0,
Top = 1, Top = 1,
@@ -74,6 +101,38 @@ export function shouldAnimateMessageJump(jumpType: JumpType): boolean {
return jumpType === JumpTypes.ANIMATED; return jumpType === JumpTypes.ANIMATED;
} }
export const JumpPreSnap = Object.freeze({
NONE: 'none',
START: 'start',
END: 'end',
} as const);
export type JumpPreSnap = (typeof JumpPreSnap)[keyof typeof JumpPreSnap];
export interface JumpPreSnapInput {
readonly targetId: string;
readonly fromTimestamp: number | null;
readonly animate: boolean;
readonly alreadyJumping: boolean;
readonly reducedMotion: boolean;
}
export function resolveJumpPreSnap(input: JumpPreSnapInput): JumpPreSnap {
if (input.alreadyJumping) return JumpPreSnap.NONE;
if (!input.animate) return JumpPreSnap.NONE;
if (input.fromTimestamp == null) return JumpPreSnap.NONE;
if (input.reducedMotion) return JumpPreSnap.NONE;
return extractTimestamp(input.targetId) > input.fromTimestamp ? JumpPreSnap.START : JumpPreSnap.END;
}
export function resolveJumpPreSnapOrigin(previous: ChannelMessages, next: ChannelMessages): number | null {
const previousOldest = previous.first();
if (previousOldest == null) return null;
if (next.last()?.id === previous.last()?.id) return null;
if (next.first()?.id === previous.first()?.id) return null;
return extractTimestamp(previousOldest.id);
}
export function resolveJumpTargetId(messages: ChannelMessages): string | null { export function resolveJumpTargetId(messages: ChannelMessages): string | null {
const {jumpDestinationId, jumpDestinationOffset} = messages; const {jumpDestinationId, jumpDestinationOffset} = messages;
if (!jumpDestinationId || !messages.ready) return null; if (!jumpDestinationId || !messages.ready) return null;