/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see .
*/
import debounce from 'lodash/debounce';
import React, {createRef} from 'react';
import * as MessageActionCreators from '~/actions/MessageActionCreators';
import {MAX_MESSAGES_PER_CHANNEL, NEW_MESSAGES_BAR_BUFFER} from '~/Constants';
import type {ScrollerHandle} from '~/components/uikit/Scroller';
import type {ChannelMessages} from '~/lib/ChannelMessages';
import {evaluateScrollPinning, type ScrollPinResult} from '~/lib/scroll/scrollPosition';
import {Routes} from '~/Routes';
import type {ChannelRecord} from '~/records/ChannelRecord';
import AccessibilityStore from '~/stores/AccessibilityStore';
import DimensionStore from '~/stores/DimensionStore';
import KeyboardModeStore from '~/stores/KeyboardModeStore';
import MessageStore from '~/stores/MessageStore';
import * as RouterUtils from '~/utils/RouterUtils';
import SnowflakeUtils from '~/utils/SnowflakeUtil';
type ScrollerRef = React.RefObject | React.RefObject;
type DebouncedFunction = T extends (...args: infer P) => infer R
? {
(...args: P): R;
cancel(): void;
flush(): void;
}
: never;
interface AnchorData {
id: string;
offsetFromTop: number;
offsetTop: number;
offsetHeight: number;
clamped: boolean;
}
interface ScrollerState {
scrollTop: number;
scrollHeight: number;
offsetHeight: number;
}
export interface ScrollManagerProps {
messages: ChannelMessages;
channel: ChannelRecord;
compact: boolean;
hasUnreads: boolean;
focusId: string | null;
placeholderHeight: number;
canLoadMore: boolean;
windowId: string;
handleScrollToBottom: () => void;
handleScrollFromBottom: () => void;
additionalMessagePadding: number;
canAutoAck: boolean;
}
const DEFAULT_SCROLLER_STATE: ScrollerState = {
scrollTop: 0,
scrollHeight: 0,
offsetHeight: 0,
};
const BOTTOM_LOCK_TOLERANCE = 8;
enum ScrollRegion {
None = 0,
Top = 1,
Bottom = 2,
}
function resolveJumpTargetId(messages: ChannelMessages): string | null {
const {jumpTargetId, jumpTargetOffset} = messages;
if (!jumpTargetId || !messages.ready) return null;
if (
messages.has(jumpTargetId) ||
(!messages.hasMoreBefore && jumpTargetId === SnowflakeUtils.castChannelIdAsMessageId(messages.channelId))
) {
if (jumpTargetOffset === 0) {
return jumpTargetId;
}
const index = messages.indexOf(jumpTargetId);
const targetMessage = messages.getByIndex(index + jumpTargetOffset);
return targetMessage?.id ?? jumpTargetId;
}
const allIds = [jumpTargetId, ...messages.map((m) => m.id)].sort(SnowflakeUtils.compare);
const jumpIndex = allIds.indexOf(jumpTargetId);
const offset = Math.abs(jumpTargetOffset) > 0 ? jumpTargetOffset : 1;
const closestId = allIds[jumpIndex + offset] ?? allIds[jumpIndex - 1];
return closestId ?? null;
}
export class ScrollManager {
ref: ScrollerRef = createRef();
props: ScrollManagerProps;
private automaticAnchor: AnchorData | null = null;
private messageFetchAnchor: AnchorData | null = null;
private focusAnchor: AnchorData | null = null;
private bottomAnchor: AnchorData | null = null;
private isLoadingMoreMessages: boolean;
private isJumpingToMessage = false;
private isPinnedToBottom!: boolean;
private isUserDragging = false;
private hadSavedScrollPosition = false;
private isCurrentlyAtBottom = false;
private isProgrammaticallyScrollingToBottom = false;
private isDisposed = false;
private scrollAnchorTimeoutId: number | null = null;
private pendingInitialScrollTop: number | null | undefined = null;
private cachedOffsetHeight = 0;
private cachedScrollHeight = 0;
private cachedScrollTop = -1;
private previousScrollTop: number | null = null;
private prependScrollSnapshot: {scrollTop: number; scrollHeight: number} | null = null;
private lastMessageLoadDirection: 'before' | 'after' | null = null;
private resizeAnimationFrameId: number | null = null;
private resizeScrollSnapshot: ScrollerState | null = null;
private resizeSnapshotWasPinned = false;
private automaticAnchorListeners: Array<(anchor: AnchorData | null, bottom: AnchorData | null) => void> = [];
private scrollCompleteListeners: Array<() => void> = [];
private updateStoreDimensionsDebounced: DebouncedFunction<() => void>;
constructor(props: ScrollManagerProps) {
this.props = props;
this.isLoadingMoreMessages = props.messages.loadingMore;
if (props.messages.jumpTargetId != null) {
this.isPinnedToBottom = false;
} else {
const stored = DimensionStore.getChannelDimensions(props.channel.id);
this.hadSavedScrollPosition = stored != null;
const isAtBottom = DimensionStore.isAtBottom(props.channel.id);
this.isPinnedToBottom = isAtBottom ?? true;
this.pendingInitialScrollTop = this.isPinnedToBottom ? null : (stored?.scrollTop ?? null);
}
this.updateStoreDimensionsDebounced = debounce(this.updateStoreDimensions.bind(this), 200);
}
isReady(): boolean {
return this.props.messages.ready;
}
isLoading(): boolean {
return this.isLoadingMoreMessages || this.props.messages.loadingMore;
}
isPinned(): boolean {
return this.isPinnedToBottom;
}
isJumping(): boolean {
return this.isJumpingToMessage;
}
isDragging(): boolean {
return this.isUserDragging;
}
isInitialized(): boolean {
return this.pendingInitialScrollTop === undefined;
}
isScrollLoadingDisabled(): boolean {
return (
this.isLoading() || !this.isInitialized() || this.isJumping() || this.isDragging() || !this.props.canLoadMore
);
}
private computePinState(state: ScrollerState): ScrollPinResult {
return evaluateScrollPinning(state, {
tolerance: BOTTOM_LOCK_TOLERANCE,
wasPinned: this.isPinnedToBottom,
hasMoreAfter: this.props.messages.hasMoreAfter,
allowPinWhenHasMoreAfter: true,
});
}
getDocument(): Document | undefined {
const node = this.ref.current?.getScrollerNode();
return node?.ownerDocument;
}
getScrollerState(): ScrollerState {
return this.ref.current?.getScrollerState() ?? DEFAULT_SCROLLER_STATE;
}
isScrolledToBottom(state: ScrollerState = this.getScrollerState()): boolean {
const pinState = this.computePinState(state);
return pinState.isPinned;
}
getElementFromMessageId(messageId: string): HTMLElement | null {
const doc = this.getDocument();
const {channel} = this.props;
if (!doc) return null;
const elementId = `chat-messages-${channel.id}-${messageId}`;
return doc.getElementById(elementId) as HTMLElement | null;
}
private getOffsetTop(element: HTMLElement, container: HTMLElement): number {
const elRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return container.scrollTop + (elRect.top - containerRect.top);
}
private getJumpBreathingRoom(): number {
return NEW_MESSAGES_BAR_BUFFER;
}
private lockNodeTopToPadding(node: HTMLElement, padding: number, animate: boolean, callback?: () => void): void {
const scrollerNode = this.ref.current?.getScrollerNode();
if (!scrollerNode) {
callback?.();
return;
}
const doScroll = (targetScrollTop: number, animated: boolean, cb?: () => void) => {
this.scrollTo(targetScrollTop, animated, cb);
};
const computeTargetScrollTop = (): number => {
const state = this.getScrollerState();
const containerRect = scrollerNode.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const delta = nodeRect.top - containerRect.top;
const nodeOffsetTop = state.scrollTop + delta;
return nodeOffsetTop - padding;
};
const correctOnce = () => {
const containerRect = scrollerNode.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const currentY = nodeRect.top - containerRect.top;
const diff = currentY - padding;
if (Math.abs(diff) <= 0.5) return;
this.mergeTo(scrollerNode.scrollTop + diff);
};
const target = computeTargetScrollTop();
doScroll(target, animate, () => {
correctOnce();
requestAnimationFrame(() => {
if (this.isDisposed) return;
correctOnce();
callback?.();
});
});
}
getAnchorData(messageId: string, scrollTop: number, clampTo?: number): AnchorData | null {
const element = this.getElementFromMessageId(messageId);
const scrollerNode = this.ref.current?.getScrollerNode();
if (!element || !scrollerNode) return null;
const offsetHeight = element.offsetHeight;
const offsetTop = this.getOffsetTop(element, scrollerNode);
let offsetFromTop = offsetTop - scrollTop;
if (clampTo != null) {
offsetFromTop = Math.max(-offsetHeight, Math.min(clampTo, offsetFromTop));
}
return {
id: messageId,
offsetFromTop,
offsetTop,
offsetHeight,
clamped: clampTo != null,
};
}
getNewMessageBarBuffer(): number {
return NEW_MESSAGES_BAR_BUFFER;
}
setAutomaticAnchor(anchor: AnchorData | null): void {
this.automaticAnchor = anchor;
for (const cb of this.automaticAnchorListeners) {
cb(this.automaticAnchor, this.bottomAnchor);
}
}
clearAutomaticAnchor(): void {
this.setAutomaticAnchor(null);
}
findTopVisibleAnchor(): AnchorData | null {
const {messages, hasUnreads, channel} = this.props;
const state = this.getScrollerState();
const {scrollTop, offsetHeight} = state;
const buffer = hasUnreads && scrollTop >= this.getNewMessageBarBuffer() ? this.getNewMessageBarBuffer() : 0;
let anchor: AnchorData | null = null;
let index = -1;
let foundAnchor = false;
const getMessageId = (idx: number): string | undefined => {
if (idx === -1) {
return SnowflakeUtils.castChannelIdAsMessageId(channel.id);
}
return messages.getByIndex(idx)?.id;
};
while (true) {
const messageId = getMessageId(index);
if (!messageId) break;
const anchorData = this.getAnchorData(messageId, scrollTop);
this.bottomAnchor = anchorData;
if (foundAnchor && anchorData != null && anchorData.offsetTop > scrollTop + buffer + offsetHeight) {
break;
}
if (foundAnchor) {
index++;
continue;
}
if (anchorData != null && (anchorData.offsetTop >= scrollTop + buffer || index === messages.length - 1)) {
anchor = anchorData;
foundAnchor = true;
}
index++;
}
return anchor;
}
findLoadMoreAnchor(isBefore: boolean): AnchorData | null {
const {messages} = this.props;
const {scrollTop} = this.getScrollerState();
const direction = isBefore ? -1 : 1;
const startIndex = isBefore ? messages.length - 1 : 0;
let anchor: AnchorData | null = null;
for (let i = startIndex; messages.getByIndex(i) != null; i += direction) {
const msg = messages.getByIndex(i)!;
const data = this.getAnchorData(msg.id, scrollTop);
if (data) {
anchor = data;
break;
}
}
return anchor;
}
getAnchorFixData(): {node: HTMLElement; fixedScrollTop: number} | null {
const candidates = [this.focusAnchor, this.isLoading() ? null : this.messageFetchAnchor, this.automaticAnchor];
const currentScrollTop = this.getScrollerState().scrollTop;
for (const anchor of candidates) {
if (!anchor) continue;
const element = this.getElementFromMessageId(anchor.id);
if (!element) continue;
const heightDiff = anchor === this.messageFetchAnchor ? anchor.offsetHeight - element.offsetHeight : 0;
const currentOffsetFromTop = Math.max(0, element.offsetTop - currentScrollTop);
return {
node: element,
fixedScrollTop: element.offsetTop - (currentOffsetFromTop + heightDiff),
};
}
return null;
}
fixAnchorScrollPosition(): void {
const anchorData = this.getAnchorFixData();
if (!anchorData) {
this.handleScroll();
return;
}
const {node, fixedScrollTop} = anchorData;
if (this.focusAnchor) {
if (this.isPinned()) {
this.scrollTo(Number.MAX_SAFE_INTEGER, false, this.handleScroll);
} else {
this.mergeTo(fixedScrollTop, this.handleScroll);
}
this.ref.current?.scrollIntoViewNode({
node,
padding: 16 + this.props.additionalMessagePadding,
callback: this.handleScroll,
});
if (KeyboardModeStore.keyboardModeEnabled && this.focusAnchor) {
const elementToFocus = this.getElementFromMessageId(this.focusAnchor.id);
if (elementToFocus) {
elementToFocus.focus({preventScroll: true});
}
}
if (!this.isLoading()) {
this.focusAnchor = null;
}
} else {
this.mergeTo(fixedScrollTop, this.handleScroll);
}
if (!this.isLoading()) {
this.messageFetchAnchor = null;
}
}
hasAnchor(): boolean {
return !!this.focusAnchor || !!this.messageFetchAnchor || !!this.automaticAnchor;
}
updateFocusAnchor(messageId: string | null | undefined, scrollTop: number, offsetHeight: number): void {
if (messageId) {
this.focusAnchor = this.getAnchorData(messageId, scrollTop);
}
const anchor = this.focusAnchor;
if (!anchor) return;
if (anchor.offsetFromTop >= offsetHeight || scrollTop > anchor.offsetTop + anchor.offsetHeight) {
this.focusAnchor = null;
}
}
handleFocusAnchorScroll(scrollTop: number, offsetHeight: number): void {
this.updateFocusAnchor(this.focusAnchor?.id ?? null, scrollTop, offsetHeight);
}
updateFetchAnchor(scrollTop: number, offsetHeight: number, scrollHeight: number): void {
const scrollerNode = this.ref.current?.getScrollerNode();
if (!this.messageFetchAnchor || !scrollerNode) return;
const region = this.isInPlaceholderRegion({scrollTop, offsetHeight, scrollHeight});
const clampTo = region !== ScrollRegion.None ? offsetHeight : undefined;
this.messageFetchAnchor = this.getAnchorData(this.messageFetchAnchor.id, scrollTop, clampTo);
}
updateAutomaticAnchor(scrollTop: number): void {
const scrollerNode = this.ref.current?.getScrollerNode();
if (!this.automaticAnchor || !scrollerNode) return;
const anchorData = this.getAnchorData(this.automaticAnchor.id, scrollTop);
if (!anchorData) {
this.setAutomaticAnchor(null);
return;
}
this.setAutomaticAnchor(anchorData);
}
isHeightChange(offsetHeight: number, scrollHeight: number): boolean {
return offsetHeight !== this.cachedOffsetHeight || scrollHeight !== this.cachedScrollHeight;
}
isInPlaceholderRegion(state: ScrollerState): ScrollRegion {
const {scrollTop, offsetHeight, scrollHeight} = state;
const {messages, placeholderHeight} = this.props;
if (messages.hasMoreBefore && scrollTop < placeholderHeight && scrollHeight > offsetHeight) {
return ScrollRegion.Top;
}
if (messages.hasMoreAfter && scrollTop >= scrollHeight - offsetHeight - placeholderHeight) {
return ScrollRegion.Bottom;
}
return ScrollRegion.None;
}
getOffsetToTriggerLoading(edge: 'top' | 'bottom', state: ScrollerState): number {
const {scrollHeight, offsetHeight} = state;
const {messages, hasUnreads, placeholderHeight} = this.props;
if (edge === 'top') {
if (!messages.hasMoreBefore) {
return 0;
}
return hasUnreads ? placeholderHeight - NEW_MESSAGES_BAR_BUFFER - 2 : placeholderHeight + 500;
}
return messages.hasMoreAfter ? scrollHeight - offsetHeight - placeholderHeight - 500 : scrollHeight - offsetHeight;
}
getOffsetToPreventLoading(edge: 'top' | 'bottom'): number {
const {messages} = this.props;
let delta = 0;
if (edge === 'top' && messages.hasMoreBefore) {
delta = 2;
} else if (edge === 'bottom' && messages.hasMoreAfter) {
delta = -2;
}
return this.getOffsetToTriggerLoading(edge, this.getScrollerState()) + delta;
}
isInScrollTriggerLoadingRegion(state: ScrollerState): ScrollRegion {
const {scrollTop, offsetHeight, scrollHeight} = state;
const {messages} = this.props;
if (
messages.hasMoreBefore &&
scrollTop <= this.getOffsetToTriggerLoading('top', state) &&
scrollHeight > offsetHeight
) {
return ScrollRegion.Top;
}
if (messages.hasMoreAfter && scrollTop >= this.getOffsetToTriggerLoading('bottom', state)) {
return ScrollRegion.Bottom;
}
return ScrollRegion.None;
}
handleScrollSpeed(state: ScrollerState): void {
if (this.isJumping() || this.isDragging() || !this.props.canLoadMore) {
return;
}
const {scrollTop, offsetHeight, scrollHeight} = state;
const prev = this.previousScrollTop;
const {placeholderHeight} = this.props;
this.previousScrollTop = scrollTop;
if (prev == null) return;
const region = this.isInPlaceholderRegion(state);
const delta = scrollTop - prev;
if (region === ScrollRegion.None || delta === 0) return;
if (region === ScrollRegion.Top && scrollTop + delta <= 0) {
const newTop = placeholderHeight - offsetHeight;
this.mergeTo(newTop);
this.previousScrollTop = newTop;
} else if (region === ScrollRegion.Bottom && scrollTop + delta >= scrollHeight - offsetHeight) {
const newTop = scrollHeight - placeholderHeight;
this.mergeTo(newTop);
this.previousScrollTop = newTop;
}
}
private loadMore = (loadAfter = false): void => {
const {messages, channel} = this.props;
let beforeId: string | undefined;
let afterId: string | undefined;
if (loadAfter) {
const last = messages.last();
if (last) afterId = last.id;
} else {
const first = messages.first();
if (first) beforeId = first.id;
}
const {scrollTop, scrollHeight} = this.getScrollerState();
if (!loadAfter) {
this.prependScrollSnapshot = {
scrollTop,
scrollHeight,
};
this.lastMessageLoadDirection = 'before';
} else {
this.prependScrollSnapshot = null;
this.lastMessageLoadDirection = 'after';
}
if (KeyboardModeStore.keyboardModeEnabled) {
const focusedElement = document.activeElement;
const scrollerNode = this.ref.current?.getScrollerNode();
if (focusedElement && scrollerNode?.contains(focusedElement)) {
const messageId = (focusedElement as HTMLElement).dataset?.messageId;
if (messageId) {
const {scrollTop} = this.getScrollerState();
const anchor = this.getAnchorData(messageId, scrollTop);
if (anchor) {
this.focusAnchor = anchor;
}
}
}
}
this.messageFetchAnchor = this.findLoadMoreAnchor(loadAfter);
this.isLoadingMoreMessages = true;
MessageActionCreators.fetchMessages(channel.id, beforeId ?? null, afterId ?? null, MAX_MESSAGES_PER_CHANNEL);
};
loadMoreForKeyboardNavigation(loadAfter: boolean): void {
if (this.isLoadingMoreMessages || this.props.messages.loadingMore) return;
const focusedElement = document.activeElement;
const scrollerNode = this.ref.current?.getScrollerNode();
if (focusedElement && scrollerNode?.contains(focusedElement)) {
const messageId = (focusedElement as HTMLElement).dataset?.messageId;
if (messageId) {
const {scrollTop} = this.getScrollerState();
const anchor = this.getAnchorData(messageId, scrollTop);
if (anchor) {
this.focusAnchor = anchor;
}
}
}
this.loadMore(loadAfter);
}
handleScroll = (event?: React.UIEvent | Event): void => {
if (this.isDisposed) return;
if (!this.isInitialized()) return;
const state = this.getScrollerState();
const pinState = this.computePinState(state);
const prevPinned = this.isPinnedToBottom;
const heightChanged =
state.offsetHeight !== this.cachedOffsetHeight || state.scrollHeight !== this.cachedScrollHeight;
const isAtBottom =
(heightChanged && this.isPinnedToBottom && !this.isLoading()) || this.isProgrammaticallyScrollingToBottom
? true
: pinState.isPinned;
const nextPinned = isAtBottom ? true : pinState.isPinned;
if (isAtBottom !== this.isCurrentlyAtBottom) {
this.isCurrentlyAtBottom = isAtBottom;
if (isAtBottom) {
this.props.handleScrollToBottom();
} else {
this.props.handleScrollFromBottom();
}
}
if (heightChanged) {
if (this.scrollAnchorTimeoutId != null) {
clearTimeout(this.scrollAnchorTimeoutId);
this.scrollAnchorTimeoutId = null;
}
if (!this.isPinned()) {
if (!this.automaticAnchor) {
this.setAutomaticAnchor(this.findTopVisibleAnchor());
} else {
this.updateAutomaticAnchor(state.scrollTop);
}
}
this.cachedScrollTop = state.scrollTop;
this.fixScrollPosition(state.offsetHeight, state.scrollHeight);
} else {
if (event && event.target !== this.ref.current?.getScrollerNode()) {
return;
}
if (this.cachedScrollTop !== state.scrollTop) {
this.isPinnedToBottom = nextPinned;
if (this.isPinnedToBottom) {
this.clearAutomaticAnchor();
} else if (this.automaticAnchor) {
this.updateAutomaticAnchor(state.scrollTop);
} else {
this.setAutomaticAnchor(this.findTopVisibleAnchor());
}
this.cachedScrollTop = state.scrollTop;
if (this.scrollAnchorTimeoutId != null) {
clearTimeout(this.scrollAnchorTimeoutId);
}
this.scrollAnchorTimeoutId = window.setTimeout(() => {
this.scrollAnchorTimeoutId = null;
this.previousScrollTop = null;
const {scrollHeight, offsetHeight} = this.getScrollerState();
if (this.isHeightChange(offsetHeight, scrollHeight)) {
this.handleScroll();
} else if (!this.isPinned() && !this.automaticAnchor) {
this.setAutomaticAnchor(this.findTopVisibleAnchor());
}
}, 35);
}
}
if (prevPinned !== nextPinned) {
this.isPinnedToBottom = nextPinned;
}
this.handleFocusAnchorScroll(state.scrollTop, state.offsetHeight);
this.updateStoreDimensionsDebounced();
if (this.isScrollLoadingDisabled()) {
this.handleScrollSpeed(state);
return;
}
const loadingRegion = this.isInScrollTriggerLoadingRegion(state);
if (loadingRegion === ScrollRegion.Top) {
this.loadMore();
} else if (loadingRegion === ScrollRegion.Bottom) {
this.loadMore(true);
}
this.handleScrollSpeed(state);
};
handleResize = (_entry: ResizeObserverEntry, _type: 'container' | 'content'): void => {
if (this.isDisposed) return;
const state = this.getScrollerState();
const wasPinned = this.isPinned() || this.isScrolledToBottom(state);
const messageCountBefore = this.props.messages.length;
this.resizeScrollSnapshot = state;
this.resizeSnapshotWasPinned = wasPinned;
if (this.resizeAnimationFrameId != null) {
cancelAnimationFrame(this.resizeAnimationFrameId);
this.resizeAnimationFrameId = null;
}
this.resizeAnimationFrameId = window.requestAnimationFrame(() => {
this.resizeAnimationFrameId = null;
const snapshot = this.resizeScrollSnapshot;
const pinnedFromResize = this.resizeSnapshotWasPinned;
this.resizeScrollSnapshot = null;
this.resizeSnapshotWasPinned = false;
if (!snapshot) return;
const currentState = this.getScrollerState();
const messageCountAfter = this.props.messages.length;
const contentChanged = messageCountBefore !== messageCountAfter;
const currentPinState = this.computePinState(currentState);
const shouldForceBottom = contentChanged ? currentPinState.isPinned : pinnedFromResize;
if (this.isHeightChange(currentState.offsetHeight, currentState.scrollHeight)) {
if (!this.isPinned() && this.automaticAnchor) {
this.updateAutomaticAnchor(currentState.scrollTop);
}
this.fixScrollPosition(currentState.offsetHeight, currentState.scrollHeight, shouldForceBottom);
}
});
};
handleMouseDown = (event: React.MouseEvent): void => {
if (this.isDisposed) return;
if (event.target === event.currentTarget) {
this.isUserDragging = true;
}
};
handleMouseUp = (): void => {
if (this.isDisposed) return;
this.isUserDragging = false;
this.handleScroll();
};
fixScrollPosition(offsetHeight: number, scrollHeight: number, forceAtBottom = false): void {
this.cachedOffsetHeight = offsetHeight;
this.cachedScrollHeight = scrollHeight;
if (this.prependScrollSnapshot && !this.isLoading() && this.lastMessageLoadDirection === 'before') {
const {scrollTop: prevScrollTop, scrollHeight: prevScrollHeight} = this.prependScrollSnapshot;
this.prependScrollSnapshot = null;
this.lastMessageLoadDirection = null;
const addedHeight = scrollHeight - prevScrollHeight;
if (addedHeight !== 0) {
const currentState = this.getScrollerState();
const maxScroll = Math.max(0, scrollHeight - currentState.offsetHeight);
const targetScrollTop = Math.max(0, Math.min(prevScrollTop + addedHeight, maxScroll));
this.isPinnedToBottom = false;
this.mergeTo(targetScrollTop, this.handleScroll);
return;
}
}
if (this.isJumping()) {
this.fixJumpTarget();
return;
}
const currentState = this.getScrollerState();
const currentPinState = this.computePinState(currentState);
const atBottom = forceAtBottom || this.isPinned() || currentPinState.isPinned;
if (atBottom) {
this.isProgrammaticallyScrollingToBottom = true;
this.scrollTo(Number.MAX_SAFE_INTEGER, false, () => {
this.isProgrammaticallyScrollingToBottom = false;
this.isPinnedToBottom = true;
this.handleScroll();
});
return;
}
this.fixAnchorScrollPosition();
}
private fixJumpTarget(): void {
const {messages} = this.props;
const targetId = messages.jumpTargetId ? resolveJumpTargetId(messages) : null;
if (targetId) {
const element = this.getElementFromMessageId(targetId);
if (element) {
const padding = this.getJumpBreathingRoom();
const scrollerNode = this.ref.current?.getScrollerNode();
if (!scrollerNode) return;
const containerRect = scrollerNode.getBoundingClientRect();
const nodeRect = element.getBoundingClientRect();
const delta = nodeRect.top - containerRect.top;
const targetScrollTop = scrollerNode.scrollTop + delta - padding;
this.mergeTo(targetScrollTop);
return;
}
this.scrollToNewMessages('top', undefined, false);
return;
}
this.scrollTo(Number.MAX_SAFE_INTEGER, false);
}
scrollToNewMessages(
orientation: 'top' | 'middle' = 'top',
callback?: () => void,
animate = true,
suppressPadding = false,
): void {
const doc = this.getDocument();
const newMessagesBar = doc?.getElementById('new-messages-bar');
const onComplete = () => {
this.isJumpingToMessage = false;
this.setAutomaticAnchor(this.findTopVisibleAnchor());
callback?.();
this.handleScroll();
for (const cb of this.scrollCompleteListeners) {
cb();
}
};
this.isPinnedToBottom = false;
this.isJumpingToMessage = true;
const padding = suppressPadding ? 0 : this.getJumpBreathingRoom();
if (newMessagesBar) {
if (orientation === 'middle') {
const scrollerNode = this.ref.current?.getScrollerNode();
if (!scrollerNode) {
this.scrollTo(Number.MAX_SAFE_INTEGER, animate, onComplete);
return;
}
const {offsetHeight} = this.getScrollerState();
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.lockNodeTopToPadding(newMessagesBar, padding, animate, onComplete);
} else {
this.scrollTo(Number.MAX_SAFE_INTEGER, animate, onComplete);
}
}
scrollToBelowUnreadDivider(): void {
const doc = this.getDocument();
const scrollerNode = this.ref.current?.getScrollerNode();
const newMessagesBar = doc?.getElementById('new-messages-bar');
if (!scrollerNode || !newMessagesBar) {
this.setScrollToBottom();
return;
}
const offsetTop = this.getOffsetTop(newMessagesBar, scrollerNode);
const elementHeight = newMessagesBar.offsetHeight;
const targetScrollTop = offsetTop + elementHeight + 2;
this.isPinnedToBottom = false;
this.scrollTo(targetScrollTop, false, () => {
this.setAutomaticAnchor(this.findTopVisibleAnchor());
this.handleScroll();
});
}
restoreScroll(): void {
if (this.isInitialized()) return;
const {pendingInitialScrollTop} = this;
this.pendingInitialScrollTop = undefined;
const targetId = resolveJumpTargetId(this.props.messages);
if (targetId != null) {
this.scrollToMessage(targetId, false);
} else if (pendingInitialScrollTop != null) {
const targetScroll = pendingInitialScrollTop + this.props.placeholderHeight;
this.scrollTo(targetScroll, false, this.handleScroll);
} else if (!this.hadSavedScrollPosition && this.props.hasUnreads) {
this.scrollToBelowUnreadDivider();
} else {
this.setScrollToBottom();
}
}
scrollTo(position: number, animate = false, callback?: () => void): void {
if (this.isDisposed) return;
this.ref.current?.scrollTo({
to: position,
animate: !AccessibilityStore.useReducedMotion && animate,
callback,
});
if (this.isPinned()) {
this.updateStoreDimensions();
} else {
this.updateStoreDimensionsDebounced();
}
}
mergeTo(position: number, callback?: () => void): void {
if (this.isDisposed) return;
this.ref.current?.mergeTo({
to: position,
callback,
});
if (this.isPinned()) {
this.updateStoreDimensions();
} else {
this.updateStoreDimensionsDebounced();
}
}
setScrollToBottom(animate = false): void {
if (this.isDisposed) return;
const {messages, channel} = this.props;
const channelPath = channel.guildId
? Routes.guildChannel(channel.guildId, channel.id)
: Routes.dmChannel(channel.id);
DimensionStore.updateChannelDimensions(channel.id, 1, 1, 0);
if (messages.hasMoreAfter) {
MessageActionCreators.jumpToPresent(channel.id, MAX_MESSAGES_PER_CHANNEL);
RouterUtils.transitionTo(channelPath);
} else {
this.isProgrammaticallyScrollingToBottom = true;
this.isPinnedToBottom = true;
this.scrollTo(Number.MAX_SAFE_INTEGER, animate, () => {
this.isJumpingToMessage = false;
this.isProgrammaticallyScrollingToBottom = false;
this.handleScroll();
});
}
}
applyLayoutShift(heightDelta: number): boolean {
if (this.isDisposed || !this.isInitialized()) return false;
if (heightDelta === 0) return false;
const scrollerNode = this.ref.current?.getScrollerNode();
if (!scrollerNode) return false;
const state = this.getScrollerState();
const distanceFromBottom = Math.max(state.scrollHeight - state.offsetHeight - state.scrollTop, 0);
const stickThreshold = Math.max(Math.abs(heightDelta) + BOTTOM_LOCK_TOLERANCE, 32);
const shouldStick = this.isPinned() || distanceFromBottom <= stickThreshold;
if (!shouldStick) return false;
const maxScrollTop = Math.max(0, scrollerNode.scrollHeight - scrollerNode.offsetHeight);
const targetScrollTop = Math.max(0, Math.min(state.scrollTop + heightDelta, maxScrollTop));
this.mergeTo(targetScrollTop, this.handleScroll);
return true;
}
private updateStoreDimensions(callback?: () => void): void {
if (this.isDisposed) return;
if (this.isJumping() || !this.isInitialized()) return;
const {channel, placeholderHeight} = this.props;
if (this.isPinned()) {
DimensionStore.updateChannelDimensions(channel.id, 1, 1, 0, callback);
} else {
const {scrollTop, scrollHeight, offsetHeight} = this.getScrollerState();
const adjustedScrollTop = scrollTop - placeholderHeight;
const adjustedScrollHeight = scrollHeight - placeholderHeight;
DimensionStore.updateChannelDimensions(
channel.id,
adjustedScrollTop,
adjustedScrollHeight,
offsetHeight,
callback,
);
}
}
focusMessage(messageId: string): void {
const element = this.getElementFromMessageId(messageId);
if (!element) return;
const scrollerNode = this.ref.current?.getScrollerNode();
if (!scrollerNode) return;
const elementOffset = this.getOffsetTop(element, scrollerNode);
const elementHeight = element.offsetHeight;
const {scrollTop, offsetHeight} = this.getScrollerState();
const topPadding = 80;
const bottomPadding = 120;
const elementTop = elementOffset;
const elementBottom = elementOffset + elementHeight;
const viewportTop = scrollTop + topPadding;
const viewportBottom = scrollTop + offsetHeight - bottomPadding;
let targetScrollTop: number | null = null;
if (elementTop < viewportTop) {
targetScrollTop = elementOffset + elementHeight - offsetHeight + bottomPadding;
} else if (elementBottom > viewportBottom) {
targetScrollTop = elementOffset - topPadding;
}
if (targetScrollTop !== null) {
const maxScroll = scrollerNode.scrollHeight - offsetHeight;
targetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScroll));
scrollerNode.scrollTop = targetScrollTop;
}
const newScrollTop = targetScrollTop ?? scrollTop;
const anchor = this.getAnchorData(messageId, newScrollTop);
if (anchor) {
this.focusAnchor = anchor;
}
if (KeyboardModeStore.keyboardModeEnabled) {
if (element.tabIndex < 0) {
element.tabIndex = -1;
}
element.focus({preventScroll: true});
}
}
scrollPageUp(animate = false): void {
this.ref.current?.scrollPageUp({animate});
}
scrollPageDown(animate = false): void {
this.ref.current?.scrollPageDown({animate});
}
scrollToMessage(messageId: string, animate = true, previousTimestamp?: number): void {
if (!this.ref.current) return;
if (messageId === this.props.channel.id) {
this.scrollTo(0);
return;
}
const element = this.getElementFromMessageId(messageId);
if (!this.isJumping() && animate && previousTimestamp != null && !AccessibilityStore.useReducedMotion) {
const ts = SnowflakeUtils.extractTimestamp(messageId);
if (ts > previousTimestamp) {
this.scrollTo(0, false);
} else {
this.scrollTo(Number.MAX_SAFE_INTEGER, false);
}
}
this.isPinnedToBottom = false;
this.isJumpingToMessage = true;
const onComplete = () => {
this.isJumpingToMessage = false;
MessageStore.handleClearJumpTarget({channelId: this.props.channel.id});
const state = this.getScrollerState();
const pinState = this.computePinState(state);
if (pinState.isPinned) {
this.isPinnedToBottom = true;
}
if (element && KeyboardModeStore.keyboardModeEnabled) {
if (element.tabIndex < 0) {
element.tabIndex = -1;
}
element.focus({preventScroll: true});
}
this.handleScroll();
for (const cb of this.scrollCompleteListeners) {
cb();
}
};
if (element) {
const padding = this.getJumpBreathingRoom();
this.lockNodeTopToPadding(element, padding, animate, onComplete);
} else {
this.scrollToNewMessages('middle', onComplete, animate);
}
}
getSnapshotBeforeUpdate(focusId: string | null): void {
if (!this.hasAnchor() && focusId == null) return;
const {scrollTop, offsetHeight, scrollHeight} = this.getScrollerState();
this.updateFocusAnchor(focusId, scrollTop, offsetHeight);
this.updateFetchAnchor(scrollTop, offsetHeight, scrollHeight);
this.updateAutomaticAnchor(scrollTop);
}
mergePropsAndUpdate(nextProps: ScrollManagerProps): void {
if (this.isDisposed) return;
this.applyPropsUpdate(nextProps);
}
private applyPropsUpdate(nextProps: ScrollManagerProps): void {
const prevMessages = this.props.messages;
const prevFocusId = this.props.focusId;
this.props = {...nextProps};
const {offsetHeight, scrollHeight} = this.getScrollerState();
const heightChanged = this.isHeightChange(offsetHeight, scrollHeight);
this.cachedOffsetHeight = offsetHeight;
this.cachedScrollHeight = scrollHeight;
this.isLoadingMoreMessages = nextProps.messages.loadingMore;
if (this.isInitialized() || this.isReady()) {
if (!this.isInitialized()) {
this.restoreScroll();
return;
}
} else {
if (nextProps.messages.jumpTargetId == null) {
this.scrollTo(Number.MAX_SAFE_INTEGER);
}
return;
}
if (nextProps.messages.jumpTargetId != null) {
if (this.isLoading()) {
return;
}
const targetId = resolveJumpTargetId(nextProps.messages);
if (targetId == null || this.isJumping() || nextProps.messages.jumpSequenceId === prevMessages.jumpSequenceId) {
if (this.isJumping()) {
if (targetId != null) {
this.scrollToMessage(targetId, true);
} else {
this.isJumpingToMessage = false;
}
}
return;
}
let previousTimestamp: number | undefined;
const prevFirst = prevMessages.first();
if (
prevFirst != null &&
nextProps.messages.last() !== prevMessages.last() &&
nextProps.messages.first() !== prevMessages.first()
) {
previousTimestamp = SnowflakeUtils.extractTimestamp(prevFirst.id);
}
this.scrollToMessage(targetId, true, previousTimestamp);
return;
}
if (nextProps.messages.jumpedToPresent && prevMessages.jumpSequenceId !== nextProps.messages.jumpSequenceId) {
this.isJumpingToMessage = true;
this.scrollTo(0);
this.setScrollToBottom();
return;
}
const lastMessage = nextProps.messages.last();
const prevLastMessage = prevMessages.last();
if (lastMessage != null && lastMessage.state === 'SENDING' && prevLastMessage?.id !== lastMessage.id) {
if (this.isPinned()) {
this.setScrollToBottom();
}
return;
}
const {focusId} = this.props;
if (focusId != null && prevFocusId !== focusId) {
const el = this.getElementFromMessageId(focusId);
if (el) {
this.ref.current?.scrollIntoViewNode({
node: el,
padding: 16 + this.props.additionalMessagePadding,
callback: this.handleScroll,
});
return;
}
}
if (heightChanged) {
this.fixScrollPosition(offsetHeight, scrollHeight);
}
}
addAutomaticAnchorCallback(
callback: (anchor: AnchorData | null, bottom: AnchorData | null) => void,
immediate = true,
): void {
if (!this.automaticAnchorListeners.includes(callback)) {
this.automaticAnchorListeners.push(callback);
}
if (immediate) {
this.setAutomaticAnchor(this.findTopVisibleAnchor());
}
}
removeAutomaticAnchorCallback(callback: (anchor: AnchorData | null, bottom: AnchorData | null) => void): void {
this.automaticAnchorListeners = this.automaticAnchorListeners.filter((cb) => cb !== callback);
}
addScrollCompleteCallback(callback: () => void): void {
if (!this.scrollCompleteListeners.includes(callback)) {
this.scrollCompleteListeners.push(callback);
}
}
removeScrollCompleteCallback(callback: () => void): void {
this.scrollCompleteListeners = this.scrollCompleteListeners.filter((cb) => cb !== callback);
}
cleanup(): void {
this.isDisposed = true;
this.updateStoreDimensionsDebounced.cancel();
if (this.scrollAnchorTimeoutId != null) {
clearTimeout(this.scrollAnchorTimeoutId);
this.scrollAnchorTimeoutId = null;
}
if (this.resizeAnimationFrameId != null) {
cancelAnimationFrame(this.resizeAnimationFrameId);
this.resizeAnimationFrameId = null;
}
this.resizeScrollSnapshot = null;
this.resizeSnapshotWasPinned = false;
this.prependScrollSnapshot = null;
this.lastMessageLoadDirection = null;
for (const cb of this.automaticAnchorListeners) {
this.removeAutomaticAnchorCallback(cb);
}
}
}
export function useScrollManager(props: ScrollManagerProps): ScrollManager {
const [manager] = React.useState(() => new ScrollManager(props));
manager.getSnapshotBeforeUpdate(props.focusId);
React.useLayoutEffect(() => {
manager.mergePropsAndUpdate(props);
});
React.useLayoutEffect(() => {
return () => manager.cleanup();
}, [manager]);
return manager;
}