mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): open channels at the unread divider and settle jump scrolling (#1885)
This commit is contained in:
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,
|
||||
channel,
|
||||
compact: state.messageDisplayCompact,
|
||||
hasUnreads: state.unreadCount > 0,
|
||||
focusId: null,
|
||||
placeholderHeight: placeholderSpecs.totalHeight,
|
||||
canLoadMore: true,
|
||||
hasPendingUnreads: state.unreadCount > 0,
|
||||
focusAnchorId: null,
|
||||
unloadedSpacerHeight: placeholderSpecs.totalHeight,
|
||||
allowHistoryFetch: true,
|
||||
windowId,
|
||||
handleScrollToBottom: () => {
|
||||
notifyPinnedToBottom: () => {
|
||||
runInAction(() => {
|
||||
state.isAtBottom = true;
|
||||
});
|
||||
},
|
||||
handleScrollFromBottom: () => {
|
||||
notifyUnpinnedFromBottom: () => {
|
||||
runInAction(() => {
|
||||
state.isAtBottom = false;
|
||||
});
|
||||
},
|
||||
additionalMessagePadding: 48,
|
||||
extraListPadding: 48,
|
||||
canAutoAck,
|
||||
handleJumpHighlight,
|
||||
});
|
||||
@@ -359,8 +359,8 @@ export const Messages = observer(function Messages({
|
||||
const onForceJumpToPresent = () => {
|
||||
MessageCommands.jumpToLiveEdge(channel.id, MAX_MESSAGES_PER_CHANNEL);
|
||||
};
|
||||
const onScrollPageUp = () => scrollManager.scrollPageUp(true);
|
||||
const onScrollPageDown = () => scrollManager.scrollPageDown(true);
|
||||
const onScrollPageUp = () => scrollManager.pageBackward(true);
|
||||
const onScrollPageDown = () => scrollManager.pageForward(true);
|
||||
const onLayoutResized = (payload?: unknown) => {
|
||||
const data = payload as {channelId?: string} | undefined;
|
||||
if (data?.channelId && data.channelId !== channel.id) return;
|
||||
@@ -395,7 +395,7 @@ export const Messages = observer(function Messages({
|
||||
if (bottomMostVisibleMessage) {
|
||||
const messageId = bottomMostVisibleMessage.dataset.messageId;
|
||||
if (messageId) {
|
||||
scrollManager.focusOnMessage(messageId);
|
||||
scrollManager.focusRequestForMessage(messageId);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -461,7 +461,7 @@ export const Messages = observer(function Messages({
|
||||
containerRef: scrollManager.ref,
|
||||
channelId: channel.id,
|
||||
onFocusMessage: (messageId) => {
|
||||
scrollManager.focusOnMessage(messageId);
|
||||
scrollManager.focusRequestForMessage(messageId);
|
||||
},
|
||||
onLoadMoreBefore: () => {
|
||||
scrollManager.loadMoreForKeyboardNavigation(false);
|
||||
|
||||
@@ -11,14 +11,22 @@ import {evaluateScrollPinning, type ScrollPinResult} from '@app/features/platfor
|
||||
import {
|
||||
type AnchorData,
|
||||
BOTTOM_LOCK_TOLERANCE,
|
||||
CENTRE_ALIGNMENT_LIFT,
|
||||
DEFAULT_SCROLLER_STATE,
|
||||
type DebouncedFunction,
|
||||
InitialScrollIntent,
|
||||
JumpPreSnap,
|
||||
MESSAGE_REVEAL_PADDING,
|
||||
resolveContainerResizeShift,
|
||||
resolveInitialScrollIntent,
|
||||
resolveJumpPreSnap,
|
||||
resolveJumpPreSnapOrigin,
|
||||
resolveJumpTargetId,
|
||||
type ScrollerRef,
|
||||
type ScrollerState,
|
||||
ScrollRegion,
|
||||
shouldAnimateMessageJump,
|
||||
UNREAD_LOAD_TRIGGER_LIFT,
|
||||
} from '@app/features/platform/utils/scroll_manager/shared';
|
||||
import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx';
|
||||
import Dimension from '@app/features/ui/state/Dimension';
|
||||
@@ -29,6 +37,7 @@ import debounce from 'lodash/debounce';
|
||||
import {createRef, useLayoutEffect, useState} from 'react';
|
||||
|
||||
const ANCHOR_SCROLL_IDLE_MS = 35;
|
||||
const LOAD_PAUSE_MIN_GROWTH_PX = 100;
|
||||
|
||||
interface ContainerLayout {
|
||||
rectTop: number;
|
||||
@@ -39,14 +48,14 @@ export interface ScrollManagerProps {
|
||||
messages: ChannelMessages;
|
||||
channel: Channel;
|
||||
compact: boolean;
|
||||
hasUnreads: boolean;
|
||||
focusId: string | null;
|
||||
placeholderHeight: number;
|
||||
canLoadMore: boolean;
|
||||
hasPendingUnreads: boolean;
|
||||
focusAnchorId: string | null;
|
||||
unloadedSpacerHeight: number;
|
||||
allowHistoryFetch: boolean;
|
||||
windowId: string;
|
||||
handleScrollToBottom: () => void;
|
||||
handleScrollFromBottom: () => void;
|
||||
additionalMessagePadding: number;
|
||||
notifyPinnedToBottom: () => void;
|
||||
notifyUnpinnedFromBottom: () => void;
|
||||
extraListPadding: number;
|
||||
canAutoAck: boolean;
|
||||
handleJumpHighlight: (messageId: string | null, jumpTicket: number) => void;
|
||||
}
|
||||
@@ -63,7 +72,6 @@ export class ScrollManager {
|
||||
private jumpIsActive = false;
|
||||
private pinIsAtBottom!: boolean;
|
||||
private dragIsActive = false;
|
||||
private restoreHadSavedPosition = false;
|
||||
private pinIsCurrentlyAtBottom = false;
|
||||
private pinIsScrollingProgrammatically = false;
|
||||
private lifecycleIsDisposed = false;
|
||||
@@ -82,6 +90,8 @@ export class ScrollManager {
|
||||
scrollHeight: number;
|
||||
} | null = null;
|
||||
private loadLastDirection: 'before' | 'after' | null = null;
|
||||
private loadScrollHeightBefore = 0;
|
||||
private loadPausedUntilUserScroll = false;
|
||||
private pinPreUpdateState: ScrollPinResult | null = null;
|
||||
private jumpTicket: number | null = null;
|
||||
private jumpCallbackToken = 0;
|
||||
@@ -94,11 +104,10 @@ export class ScrollManager {
|
||||
if (props.messages.jumpDestinationId != null) {
|
||||
this.pinIsAtBottom = false;
|
||||
} else {
|
||||
const stored = Dimension.channelDimensionsFor(props.channel.id);
|
||||
this.restoreHadSavedPosition = stored != null;
|
||||
const isAtBottom = Dimension.channelPinnedToEnd(props.channel.id);
|
||||
this.pinIsAtBottom = isAtBottom ?? true;
|
||||
this.restorePendingInitialScrollTop = this.pinIsAtBottom ? null : (stored?.scrollTop ?? null);
|
||||
const savedPinnedToEnd = Dimension.channelPinnedToEnd(props.channel.id);
|
||||
const savedScrollTop = Dimension.channelDimensionsFor(props.channel.id)?.scrollTop ?? null;
|
||||
this.pinIsAtBottom = savedPinnedToEnd;
|
||||
this.restorePendingInitialScrollTop = savedPinnedToEnd ? null : savedScrollTop;
|
||||
}
|
||||
this.dimensionPersistDebounced = debounce(this.dimensionPersist.bind(this), 200);
|
||||
}
|
||||
@@ -135,11 +144,12 @@ export class ScrollManager {
|
||||
|
||||
loadIsDisabled(): boolean {
|
||||
return (
|
||||
this.loadPausedUntilUserScroll ||
|
||||
this.loadIsInProgress() ||
|
||||
!this.lifecycleIsInitialized() ||
|
||||
this.jumpIsActiveNow() ||
|
||||
this.dragIsActiveNow() ||
|
||||
!this.props.canLoadMore
|
||||
!this.props.allowHistoryFetch
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,16 +191,16 @@ export class ScrollManager {
|
||||
return this.ref.current?.readViewportMetrics() ?? DEFAULT_SCROLLER_STATE;
|
||||
}
|
||||
|
||||
private placeholderHeightGet(): number {
|
||||
private unloadedSpacerHeightGet(): number {
|
||||
const scrollerHandle = this.ref.current;
|
||||
if (scrollerHandle == null) {
|
||||
return this.props.placeholderHeight;
|
||||
return this.props.unloadedSpacerHeight;
|
||||
}
|
||||
const scrollerNode = scrollerHandle.getViewportElement();
|
||||
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 {
|
||||
@@ -219,8 +229,8 @@ export class ScrollManager {
|
||||
return containerLayout.scrollTop + (elRect.top - containerLayout.rectTop);
|
||||
}
|
||||
|
||||
private jumpGetBreathingRoom(): number {
|
||||
return NEW_MESSAGES_BAR_BUFFER;
|
||||
private jumpGetCentrePadding(): number {
|
||||
return this.props.hasPendingUnreads ? this.layoutGetNewMessageBarBuffer() : MESSAGE_REVEAL_PADDING;
|
||||
}
|
||||
|
||||
private layoutGetNodeAlignedScrollTop(
|
||||
@@ -235,7 +245,7 @@ export class ScrollManager {
|
||||
const delta = nodeRect.top - containerRect.top;
|
||||
const nodeOffsetTop = state.scrollTop + delta;
|
||||
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 nodeOffsetTop - padding;
|
||||
@@ -296,13 +306,13 @@ export class ScrollManager {
|
||||
}
|
||||
|
||||
anchorFindTopVisible(): AnchorData | null {
|
||||
const {messages, hasUnreads, channel} = this.props;
|
||||
const {messages, hasPendingUnreads, channel} = this.props;
|
||||
const state = this.scrollGetState();
|
||||
const {scrollTop, offsetHeight} = state;
|
||||
const scrollerNode = this.ref.current?.getViewportElement();
|
||||
const layout = scrollerNode ? this.layoutGetContainerLayout(scrollerNode) : undefined;
|
||||
const buffer =
|
||||
hasUnreads && scrollTop >= this.layoutGetNewMessageBarBuffer() ? this.layoutGetNewMessageBarBuffer() : 0;
|
||||
hasPendingUnreads && scrollTop >= this.layoutGetNewMessageBarBuffer() ? this.layoutGetNewMessageBarBuffer() : 0;
|
||||
let anchor: AnchorData | null = null;
|
||||
let index = -1;
|
||||
let foundAnchor = false;
|
||||
@@ -358,7 +368,7 @@ export class ScrollManager {
|
||||
|
||||
anchorGetFixData(): {
|
||||
node: HTMLElement;
|
||||
fixedScrollTop: number;
|
||||
correctedScrollTop: number;
|
||||
} | null {
|
||||
const candidates = [
|
||||
this.anchorFocus,
|
||||
@@ -374,10 +384,10 @@ export class ScrollManager {
|
||||
const element = this.layoutGetElementFromMessageId(anchor.id);
|
||||
if (!element) continue;
|
||||
const currentOffsetTop = this.layoutGetOffsetTop(element, scrollerNode, layout);
|
||||
const fixedScrollTop = Math.max(0, currentOffsetTop - anchor.offsetFromTop);
|
||||
const correctedScrollTop = Math.max(0, currentOffsetTop - anchor.offsetFromTop);
|
||||
return {
|
||||
node: element,
|
||||
fixedScrollTop,
|
||||
correctedScrollTop,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -411,16 +421,16 @@ export class ScrollManager {
|
||||
this.scrollHandle();
|
||||
return false;
|
||||
}
|
||||
const {node, fixedScrollTop} = anchorData;
|
||||
const {node, correctedScrollTop} = anchorData;
|
||||
if (this.anchorFocus) {
|
||||
if (this.pinIsAtBottomNow()) {
|
||||
this.scrollTo(Number.MAX_SAFE_INTEGER, false, this.scrollHandle);
|
||||
} else {
|
||||
this.scrollMergeTo(fixedScrollTop, this.scrollHandle);
|
||||
this.scrollMergeTo(correctedScrollTop, this.scrollHandle);
|
||||
}
|
||||
this.ref.current?.revealElement({
|
||||
node,
|
||||
padding: 16 + this.props.additionalMessagePadding,
|
||||
padding: MESSAGE_REVEAL_PADDING + this.props.extraListPadding,
|
||||
callback: this.scrollHandle,
|
||||
});
|
||||
if (KeyboardMode.keyboardModeEnabled && this.anchorFocus) {
|
||||
@@ -433,7 +443,7 @@ export class ScrollManager {
|
||||
this.anchorFocus = null;
|
||||
}
|
||||
} else {
|
||||
this.scrollMergeTo(fixedScrollTop, this.scrollHandle);
|
||||
this.scrollMergeTo(correctedScrollTop, this.scrollHandle);
|
||||
}
|
||||
if (!this.loadIsInProgress()) {
|
||||
this.anchorFetch = null;
|
||||
@@ -498,11 +508,11 @@ export class ScrollManager {
|
||||
scrollIsInPlaceholderRegion(state: ScrollerState): ScrollRegion {
|
||||
const {scrollTop, offsetHeight, scrollHeight} = state;
|
||||
const {messages} = this.props;
|
||||
const placeholderHeight = this.placeholderHeightGet();
|
||||
if (messages.hasMoreBefore && scrollTop < placeholderHeight && scrollHeight > offsetHeight) {
|
||||
const unloadedSpacerHeight = this.unloadedSpacerHeightGet();
|
||||
if (messages.hasMoreBefore && scrollTop < unloadedSpacerHeight && scrollHeight > offsetHeight) {
|
||||
return ScrollRegion.Top;
|
||||
}
|
||||
if (messages.hasMoreAfter && scrollTop >= scrollHeight - offsetHeight - placeholderHeight) {
|
||||
if (messages.hasMoreAfter && scrollTop >= scrollHeight - offsetHeight - unloadedSpacerHeight) {
|
||||
return ScrollRegion.Bottom;
|
||||
}
|
||||
return ScrollRegion.None;
|
||||
@@ -510,15 +520,17 @@ export class ScrollManager {
|
||||
|
||||
loadGetOffsetToTrigger(edge: 'top' | 'bottom', state: ScrollerState): number {
|
||||
const {scrollHeight, offsetHeight} = state;
|
||||
const {messages, hasUnreads} = this.props;
|
||||
const placeholderHeight = this.placeholderHeightGet();
|
||||
const {messages, hasPendingUnreads} = this.props;
|
||||
const unloadedSpacerHeight = this.unloadedSpacerHeightGet();
|
||||
if (edge === 'top') {
|
||||
if (!messages.hasMoreBefore) {
|
||||
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 {
|
||||
@@ -549,23 +561,23 @@ export class ScrollManager {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const {scrollTop, offsetHeight, scrollHeight} = state;
|
||||
const prev = this.cachePreviousScrollTop;
|
||||
const placeholderHeight = this.placeholderHeightGet();
|
||||
const unloadedSpacerHeight = this.unloadedSpacerHeightGet();
|
||||
this.cachePreviousScrollTop = scrollTop;
|
||||
if (prev == null) return;
|
||||
const region = this.scrollIsInPlaceholderRegion(state);
|
||||
const delta = scrollTop - prev;
|
||||
if (region === ScrollRegion.None || delta === 0) return;
|
||||
if (region === ScrollRegion.Top && scrollTop + delta <= 0) {
|
||||
const newTop = placeholderHeight - offsetHeight;
|
||||
const newTop = unloadedSpacerHeight - offsetHeight;
|
||||
this.scrollMergeTo(newTop);
|
||||
this.cachePreviousScrollTop = newTop;
|
||||
} else if (region === ScrollRegion.Bottom && scrollTop + delta >= scrollHeight - offsetHeight) {
|
||||
const newTop = scrollHeight - placeholderHeight;
|
||||
const newTop = scrollHeight - unloadedSpacerHeight;
|
||||
this.scrollMergeTo(newTop);
|
||||
this.cachePreviousScrollTop = newTop;
|
||||
}
|
||||
@@ -630,6 +642,7 @@ export class ScrollManager {
|
||||
this.anchorCaptureFocused();
|
||||
}
|
||||
this.anchorUpdateFetchForLoad(loadAfter);
|
||||
this.loadScrollHeightBefore = this.cacheScrollHeight;
|
||||
this.cachePreviousScrollTop = null;
|
||||
this.loadIsActive = true;
|
||||
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 heightChanged =
|
||||
state.offsetHeight !== this.cacheOffsetHeight || state.scrollHeight !== this.cacheScrollHeight;
|
||||
const isAtBottom =
|
||||
const pinnedToEnd =
|
||||
(heightChanged && this.pinIsAtBottom) || this.pinIsScrollingProgrammatically ? true : pinState.isPinned;
|
||||
if (isAtBottom !== this.pinIsCurrentlyAtBottom) {
|
||||
this.pinIsCurrentlyAtBottom = isAtBottom;
|
||||
if (isAtBottom) {
|
||||
this.props.handleScrollToBottom();
|
||||
if (pinnedToEnd !== this.pinIsCurrentlyAtBottom) {
|
||||
this.pinIsCurrentlyAtBottom = pinnedToEnd;
|
||||
if (pinnedToEnd) {
|
||||
this.props.notifyPinnedToBottom();
|
||||
} else {
|
||||
this.props.handleScrollFromBottom();
|
||||
this.props.notifyUnpinnedFromBottom();
|
||||
}
|
||||
}
|
||||
if (heightChanged) {
|
||||
@@ -695,13 +708,16 @@ export class ScrollManager {
|
||||
this.anchorSetAutomatic(this.anchorFindTopVisible());
|
||||
}
|
||||
this.cacheScrollTop = state.scrollTop;
|
||||
this.scrollFixPosition(state.offsetHeight, state.scrollHeight, isAtBottom);
|
||||
this.scrollFixPosition(state.offsetHeight, state.scrollHeight, pinnedToEnd);
|
||||
} else {
|
||||
if (event && event.target !== this.ref.current?.getViewportElement()) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
this.anchorClearAutomatic();
|
||||
} else if (this.anchorAutomatic) {
|
||||
@@ -727,13 +743,13 @@ export class ScrollManager {
|
||||
}
|
||||
this.scrollHandleSpeed(state);
|
||||
};
|
||||
scrollHandleResize = (_entry: ResizeObserverEntry, type: 'container' | 'content'): void => {
|
||||
scrollHandleResize = (entry: ResizeObserverEntry, type: 'container' | 'content'): void => {
|
||||
if (this.lifecycleIsDisposed) return;
|
||||
if (this.resizeReentryGuard) return;
|
||||
this.resizeReentryGuard = true;
|
||||
try {
|
||||
if (type === 'container') {
|
||||
const {offsetHeight} = this.scrollGetState();
|
||||
const offsetHeight = entry.contentRect.height;
|
||||
const previousHeight = this.resizeContainerHeight;
|
||||
this.resizeContainerHeight = offsetHeight;
|
||||
const heightDelta = previousHeight === -1 ? 0 : previousHeight - offsetHeight;
|
||||
@@ -747,7 +763,15 @@ export class ScrollManager {
|
||||
};
|
||||
|
||||
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)) {
|
||||
return;
|
||||
}
|
||||
@@ -781,7 +805,7 @@ export class ScrollManager {
|
||||
this.cacheOffsetHeight = offsetHeight;
|
||||
this.cacheScrollHeight = scrollHeight;
|
||||
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.loadLastDirection = null;
|
||||
if (this.anchorGetFixData()) {
|
||||
@@ -792,7 +816,7 @@ export class ScrollManager {
|
||||
if (addedHeight !== 0) {
|
||||
const currentState = this.scrollGetState();
|
||||
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.scrollMergeTo(targetScrollTop, this.scrollHandle);
|
||||
return;
|
||||
@@ -832,14 +856,14 @@ export class ScrollManager {
|
||||
if (targetId) {
|
||||
const element = this.layoutGetElementFromMessageId(targetId);
|
||||
if (element) {
|
||||
const padding = this.jumpGetBreathingRoom();
|
||||
const padding = this.jumpGetCentrePadding();
|
||||
const scrollerNode = this.ref.current?.getViewportElement();
|
||||
if (!scrollerNode) return;
|
||||
const targetScrollTop = this.layoutGetNodeAlignedScrollTop(element, 'center', padding, scrollerNode);
|
||||
this.scrollMergeTo(targetScrollTop, () => this.jumpComplete(element, jumpToken));
|
||||
return;
|
||||
}
|
||||
this.scrollToNewMessages('top', () => this.jumpComplete(null, jumpToken), false, false, jumpToken);
|
||||
this.scrollToUnreadBoundary('top', () => this.jumpComplete(null, jumpToken), false, false, jumpToken);
|
||||
return;
|
||||
}
|
||||
if (!messages.hasMoreAfter) {
|
||||
@@ -847,7 +871,7 @@ export class ScrollManager {
|
||||
}
|
||||
}
|
||||
|
||||
scrollToNewMessages(
|
||||
scrollToUnreadBoundary(
|
||||
orientation: 'top' | 'middle' = 'top',
|
||||
callback?: () => void,
|
||||
animate = true,
|
||||
@@ -855,7 +879,7 @@ export class ScrollManager {
|
||||
jumpToken = this.jumpBegin(this.jumpTicket),
|
||||
): void {
|
||||
const doc = this.scrollGetDocument();
|
||||
const newMessagesBar = doc?.getElementById('new-messages-bar');
|
||||
const unreadDividerNode = doc?.getElementById('new-messages-bar');
|
||||
const shouldContinue = () => this.jumpIsCurrent(jumpToken);
|
||||
const onComplete = () => {
|
||||
if (!shouldContinue()) {
|
||||
@@ -871,67 +895,43 @@ export class ScrollManager {
|
||||
this.scrollHandle();
|
||||
};
|
||||
this.pinIsAtBottom = false;
|
||||
const padding = suppressPadding ? 0 : this.jumpGetBreathingRoom();
|
||||
if (newMessagesBar) {
|
||||
if (orientation === 'middle') {
|
||||
const scrollerNode = this.ref.current?.getViewportElement();
|
||||
if (!scrollerNode) {
|
||||
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);
|
||||
this.jumpIsActive = animate;
|
||||
const padding = suppressPadding ? 0 : this.layoutGetNewMessageBarBuffer();
|
||||
if (unreadDividerNode) {
|
||||
const alignment = orientation === 'middle' ? 'center' : 'start';
|
||||
this.layoutLockNodeToAlignment(unreadDividerNode, alignment, padding, animate, onComplete, shouldContinue);
|
||||
} 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 {
|
||||
if (this.lifecycleIsInitialized()) return;
|
||||
const {restorePendingInitialScrollTop} = this;
|
||||
const rememberedScrollTop = this.restorePendingInitialScrollTop ?? null;
|
||||
this.restorePendingInitialScrollTop = undefined;
|
||||
const targetId = resolveJumpTargetId(this.props.messages);
|
||||
const intent = resolveInitialScrollIntent({
|
||||
channelType: this.props.channel.type,
|
||||
rememberedScrollTop,
|
||||
hasPendingUnreads: this.props.hasPendingUnreads,
|
||||
});
|
||||
if (targetId != null) {
|
||||
this.scrollToMessage(targetId, false);
|
||||
} else if (restorePendingInitialScrollTop != null) {
|
||||
const targetScroll = restorePendingInitialScrollTop + this.placeholderHeightGet();
|
||||
this.scrollAlignToMessage(targetId, false);
|
||||
} else if (intent === InitialScrollIntent.UNREAD_BOUNDARY) {
|
||||
this.scrollToUnreadBoundary('top', undefined, false);
|
||||
} else if (intent === InitialScrollIntent.SAVED_OFFSET && rememberedScrollTop != null) {
|
||||
const targetScroll = rememberedScrollTop + this.unloadedSpacerHeightGet();
|
||||
this.scrollTo(targetScroll, false, this.scrollHandle);
|
||||
} else if (!this.restoreHadSavedPosition && this.props.hasUnreads) {
|
||||
this.scrollToBelowUnreadDivider();
|
||||
} else {
|
||||
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 {
|
||||
if (this.lifecycleIsDisposed) return;
|
||||
this.ref.current?.scrollTo({
|
||||
@@ -961,6 +961,10 @@ export class ScrollManager {
|
||||
|
||||
scrollSetToBottom(animate = false): void {
|
||||
if (this.lifecycleIsDisposed) return;
|
||||
if (!this.lifecycleIsInitialized()) {
|
||||
this.restoreApplyPlaceholderPosition();
|
||||
return;
|
||||
}
|
||||
const {messages, channel} = this.props;
|
||||
this.jumpStop(true);
|
||||
Dimension.recordChannelDimensions(channel.id, 1, 1, 0);
|
||||
@@ -1077,18 +1081,19 @@ export class ScrollManager {
|
||||
if (this.lifecycleIsDisposed) return;
|
||||
if (this.jumpIsActiveNow() || !this.lifecycleIsInitialized()) return;
|
||||
const {channel} = this.props;
|
||||
const placeholderHeight = this.placeholderHeightGet();
|
||||
const unloadedSpacerHeight = this.unloadedSpacerHeightGet();
|
||||
if (this.pinIsAtBottomNow()) {
|
||||
Dimension.recordChannelDimensions(channel.id, 1, 1, 0, callback);
|
||||
} else {
|
||||
const {scrollTop, scrollHeight, offsetHeight} = this.scrollGetState();
|
||||
const adjustedScrollTop = scrollTop - placeholderHeight;
|
||||
const adjustedScrollHeight = scrollHeight - placeholderHeight;
|
||||
const adjustedScrollTop = scrollTop - unloadedSpacerHeight;
|
||||
const adjustedScrollHeight = scrollHeight - unloadedSpacerHeight;
|
||||
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);
|
||||
if (!element) return;
|
||||
const scrollerNode = this.ref.current?.getViewportElement();
|
||||
@@ -1111,7 +1116,7 @@ export class ScrollManager {
|
||||
if (targetScrollTop !== null) {
|
||||
const maxScroll = scrollerNode.scrollHeight - offsetHeight;
|
||||
targetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScroll));
|
||||
scrollerNode.scrollTop = targetScrollTop;
|
||||
this.scrollTo(targetScrollTop);
|
||||
}
|
||||
const newScrollTop = targetScrollTop ?? scrollTop;
|
||||
const anchor = this.anchorGetData(messageId, newScrollTop);
|
||||
@@ -1124,15 +1129,17 @@ export class ScrollManager {
|
||||
element.focus({preventScroll: true});
|
||||
}
|
||||
|
||||
scrollPageUp(animate = false): void {
|
||||
pageBackward(animate = false): void {
|
||||
if (this.lifecycleIsDisposed || !this.lifecycleIsInitialized()) return;
|
||||
this.ref.current?.pageBackward({animate});
|
||||
}
|
||||
|
||||
scrollPageDown(animate = false): void {
|
||||
pageForward(animate = false): void {
|
||||
if (this.lifecycleIsDisposed || !this.lifecycleIsInitialized()) return;
|
||||
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;
|
||||
if (!scrollerHandle) return;
|
||||
const {jumpHighlight, jumpTicket, jumpDestinationId} = this.props.messages;
|
||||
@@ -1149,10 +1156,15 @@ export class ScrollManager {
|
||||
const shouldContinue = () => this.jumpIsCurrent(jumpToken);
|
||||
const onComplete = () => this.jumpComplete(element, jumpToken);
|
||||
if (!element || !scrollerNode) {
|
||||
this.scrollToNewMessages('middle', onComplete, animate, false, jumpToken);
|
||||
this.scrollToUnreadBoundary('middle', onComplete, animate, false, jumpToken);
|
||||
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);
|
||||
this.anchorFocus = this.anchorGetData(messageId, target);
|
||||
this.scrollTo(target, animate, () => {
|
||||
@@ -1161,8 +1173,8 @@ export class ScrollManager {
|
||||
});
|
||||
}
|
||||
|
||||
lifecycleGetSnapshotBeforeUpdate(focusId: string | null): void {
|
||||
if (!this.anchorHasAny() && focusId == null) {
|
||||
lifecycleGetSnapshotBeforeUpdate(focusAnchorId: string | null): void {
|
||||
if (!this.anchorHasAny() && focusAnchorId == null) {
|
||||
this.pinPreUpdateState =
|
||||
this.pinIsAtBottomNow() || this.pinIsCurrentlyAtBottom || this.pinIsScrollingProgrammatically
|
||||
? {
|
||||
@@ -1177,7 +1189,7 @@ export class ScrollManager {
|
||||
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);
|
||||
this.anchorUpdateFocus(focusAnchorId, scrollTop, offsetHeight, layout);
|
||||
this.anchorUpdateFetch(scrollTop, offsetHeight, scrollHeight, layout);
|
||||
this.anchorUpdateAutomatic(scrollTop, layout);
|
||||
}
|
||||
@@ -1189,7 +1201,7 @@ export class ScrollManager {
|
||||
|
||||
private propsApplyUpdate(nextProps: ScrollManagerProps): void {
|
||||
const prevMessages = this.props.messages;
|
||||
const prevFocusId = this.props.focusId;
|
||||
const prevFocusId = this.props.focusAnchorId;
|
||||
const pinPreUpdateState = this.pinTakePreUpdateState();
|
||||
this.props = {...nextProps};
|
||||
const {offsetHeight, scrollHeight} = this.scrollGetState();
|
||||
@@ -1198,15 +1210,18 @@ export class ScrollManager {
|
||||
this.cacheOffsetHeight = offsetHeight;
|
||||
this.cacheScrollHeight = scrollHeight;
|
||||
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.restoreInitial();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (nextProps.messages.jumpDestinationId == null) {
|
||||
this.scrollTo(Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
this.restoreApplyPlaceholderPosition();
|
||||
return;
|
||||
}
|
||||
if (nextProps.messages.jumpDestinationId != null) {
|
||||
@@ -1225,13 +1240,23 @@ export class ScrollManager {
|
||||
if (nextProps.messages.jumpTicket === prevMessages.jumpTicket && this.jumpIsActiveNow()) {
|
||||
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;
|
||||
}
|
||||
if (nextProps.messages.jumpedToPresent && prevMessages.jumpTicket !== nextProps.messages.jumpTicket) {
|
||||
if (nextProps.messages.landedAtLiveEdge && prevMessages.jumpTicket !== nextProps.messages.jumpTicket) {
|
||||
nextProps.handleJumpHighlight(null, nextProps.messages.jumpTicket);
|
||||
this.jumpBegin(nextProps.messages.jumpTicket);
|
||||
this.scrollTo(0);
|
||||
this.scrollSetToBottom();
|
||||
return;
|
||||
}
|
||||
@@ -1243,13 +1268,13 @@ export class ScrollManager {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const {focusId} = this.props;
|
||||
if (focusId != null && prevFocusId !== focusId) {
|
||||
const el = this.layoutGetElementFromMessageId(focusId);
|
||||
const {focusAnchorId} = this.props;
|
||||
if (focusAnchorId != null && prevFocusId !== focusAnchorId) {
|
||||
const el = this.layoutGetElementFromMessageId(focusAnchorId);
|
||||
if (el) {
|
||||
this.ref.current?.revealElement({
|
||||
node: el,
|
||||
padding: 16 + this.props.additionalMessagePadding,
|
||||
padding: MESSAGE_REVEAL_PADDING + this.props.extraListPadding,
|
||||
callback: this.scrollHandle,
|
||||
});
|
||||
return;
|
||||
@@ -1300,7 +1325,7 @@ export class ScrollManager {
|
||||
|
||||
export function useScrollManager(props: ScrollManagerProps): ScrollManager {
|
||||
const [manager] = useState(() => new ScrollManager(props));
|
||||
manager.lifecycleGetSnapshotBeforeUpdate(props.focusId);
|
||||
manager.lifecycleGetSnapshotBeforeUpdate(props.focusAnchorId);
|
||||
useLayoutEffect(() => {
|
||||
manager.lifecycleMergeProps(props);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {JumpTypes} from '@fluxer/constants/src/JumpConstants';
|
||||
import {NEW_MESSAGES_BAR_BUFFER} from '@fluxer/constants/src/LimitConstants';
|
||||
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', () => {
|
||||
it('animates only for explicit ANIMATED jumps', () => {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import type {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
|
||||
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 {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 DebouncedFunction<T> = T extends (...args: infer P) => infer R
|
||||
@@ -34,6 +35,9 @@ export const DEFAULT_SCROLLER_STATE: ScrollerState = {
|
||||
offsetHeight: 0,
|
||||
};
|
||||
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 type ContainerResizeShift = {kind: 'none'} | {kind: 'pin'} | {kind: 'shift'; targetScrollTop: number};
|
||||
@@ -64,6 +68,29 @@ export function resolveContainerResizeShift(options: {
|
||||
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 {
|
||||
None = 0,
|
||||
Top = 1,
|
||||
@@ -74,6 +101,38 @@ export function shouldAnimateMessageJump(jumpType: JumpType): boolean {
|
||||
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 {
|
||||
const {jumpDestinationId, jumpDestinationOffset} = messages;
|
||||
if (!jumpDestinationId || !messages.ready) return null;
|
||||
|
||||
Reference in new issue
Block a user