mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +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,
|
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;
|
||||||
|
|||||||
Reference in new issue
Block a user