diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 66b221b770..25e0ea7c84 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5272,15 +5272,16 @@ button.module-image__border-overlay:focus { // This is a modified version of ["Pin Scrolling to Bottom"][0]. // [0]: https://css-tricks.com/books/greatest-css-tricks/pin-scrolling-to-bottom/ - &--have-newest { + &::after { + content: ''; + height: 1px; // Always show the element to not mess with the height of the scroll area + display: block; + } + &--have-newest:not(&--scroll-locked) { & > * { overflow-anchor: none; } - &::after { - content: ''; - height: 1px; - display: block; overflow-anchor: auto; } } diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index c47627ef9a..8783fe5a9b 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -3,7 +3,7 @@ import { first, get, isNumber, last, throttle } from 'lodash'; import classNames from 'classnames'; -import type { ReactChild, ReactNode, RefObject } from 'react'; +import type { ReactChild, ReactNode, RefObject, UIEvent } from 'react'; import React from 'react'; import type { ReadonlyDeep } from 'type-fest'; @@ -43,6 +43,10 @@ import { import { LastSeenIndicator } from './LastSeenIndicator'; import { MINUTE } from '../../util/durations'; import { SizeObserver } from '../../hooks/useSizeObserver'; +import { + createScrollerLock, + ScrollerLockContext, +} from '../../hooks/useScrollLock'; const AT_BOTTOM_THRESHOLD = 15; const AT_BOTTOM_DETECTOR_STYLE = { height: AT_BOTTOM_THRESHOLD }; @@ -177,6 +181,7 @@ export type PropsType = PropsDataType & PropsActionsType; type StateType = { + scrollLocked: boolean; hasDismissedDirectContactSpoofingWarning: boolean; hasRecentlyScrolled: boolean; lastMeasuredWarningHeight: number; @@ -214,6 +219,7 @@ export class Timeline extends React.Component< // eslint-disable-next-line react/state-in-constructor override state: StateType = { + scrollLocked: false, hasRecentlyScrolled: true, hasDismissedDirectContactSpoofingWarning: false, @@ -222,7 +228,21 @@ export class Timeline extends React.Component< widthBreakpoint: WidthBreakpoint.Wide, }; - private onScroll = (): void => { + private onScrollLockChange = (): void => { + this.setState({ + scrollLocked: this.scrollerLock.isLocked(), + }); + }; + + private scrollerLock = createScrollerLock( + 'Timeline', + this.onScrollLockChange + ); + + private onScroll = (event: UIEvent): void => { + if (event.isTrusted) { + this.scrollerLock.onUserInterrupt('onScroll'); + } this.setState(oldState => // `onScroll` is called frequently, so it's performance-sensitive. We try our best // to return `null` from this updater because [that won't cause a re-render][0]. @@ -237,12 +257,20 @@ export class Timeline extends React.Component< }; private scrollToItemIndex(itemIndex: number): void { + if (this.scrollerLock.isLocked()) { + return; + } + this.messagesRef.current ?.querySelector(`[data-item-index="${itemIndex}"]`) ?.scrollIntoViewIfNeeded(); } private scrollToBottom = (setFocus?: boolean): void => { + if (this.scrollerLock.isLocked()) { + return; + } + const { targetMessage, id, items } = this.props; if (setFocus && items && items.length > 0) { @@ -258,10 +286,15 @@ export class Timeline extends React.Component< }; private onClickScrollDownButton = (): void => { + this.scrollerLock.onUserInterrupt('onClickScrollDownButton'); this.scrollDown(false); }; private scrollDown = (setFocus?: boolean): void => { + if (this.scrollerLock.isLocked()) { + return; + } + const { haveNewest, id, @@ -573,7 +606,7 @@ export class Timeline extends React.Component< } = this.props; const containerEl = this.containerRef.current; - if (containerEl && snapshot) { + if (!this.scrollerLock.isLocked() && containerEl && snapshot) { if (snapshot === scrollToUnreadIndicator) { const lastSeenIndicatorEl = this.lastSeenIndicatorRef.current; if (lastSeenIndicatorEl) { @@ -781,6 +814,7 @@ export class Timeline extends React.Component< unreadMentionsCount, } = this.props; const { + scrollLocked, hasRecentlyScrolled, lastMeasuredWarningHeight, newestBottomVisibleMessageId, @@ -1050,7 +1084,7 @@ export class Timeline extends React.Component< } return ( - <> + { const { isNearBottom } = this.props; @@ -1093,7 +1127,8 @@ export class Timeline extends React.Component< className={classNames( 'module-timeline__messages', haveNewest && 'module-timeline__messages--have-newest', - haveOldest && 'module-timeline__messages--have-oldest' + haveOldest && 'module-timeline__messages--have-oldest', + scrollLocked && 'module-timeline__messages--scroll-locked' )} ref={this.messagesRef} role="list" @@ -1152,7 +1187,7 @@ export class Timeline extends React.Component< )} {contactSpoofingReviewDialog} - + ); } diff --git a/ts/components/conversation/TimelineMessage.tsx b/ts/components/conversation/TimelineMessage.tsx index d45f802daf..d62e9c03fd 100644 --- a/ts/components/conversation/TimelineMessage.tsx +++ b/ts/components/conversation/TimelineMessage.tsx @@ -33,6 +33,7 @@ import { } from '../../hooks/useKeyboardShortcuts'; import { PanelType } from '../../types/Panels'; import type { DeleteMessagesPropsType } from '../../state/ducks/globalModals'; +import { useScrollerLock } from '../../hooks/useScrollLock'; export type PropsData = { canDownload: boolean; @@ -175,6 +176,14 @@ export function TimelineMessage(props: Props): JSX.Element { [reactionPickerRoot] ); + useScrollerLock({ + reason: 'TimelineMessage reactionPicker', + lockScrollWhen: reactionPickerRoot != null, + onUserInterrupt() { + toggleReactionPicker(true); + }, + }); + useEffect(() => { let cleanUpHandler: (() => void) | undefined; if (reactionPickerRoot) { diff --git a/ts/hooks/useScrollLock.tsx b/ts/hooks/useScrollLock.tsx new file mode 100644 index 0000000000..48a7f5e978 --- /dev/null +++ b/ts/hooks/useScrollLock.tsx @@ -0,0 +1,87 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { useContext, createContext, useEffect, useRef } from 'react'; +import * as log from '../logging/log'; + +type ScrollerLock = Readonly<{ + isLocked(): boolean; + lock(reason: string, onUserInterrupt: () => void): () => void; + onUserInterrupt(reason: string): void; +}>; + +export function createScrollerLock( + title: string, + onUpdate: () => void +): ScrollerLock { + const locks = new Set<() => void>(); + + let lastUpdate: boolean | null = null; + function update() { + const isLocked = locks.size > 0; + if (isLocked !== lastUpdate) { + lastUpdate = isLocked; + onUpdate(); + } + } + + return { + isLocked() { + return locks.size > 0; + }, + lock(reason, onUserInterrupt) { + log.info('ScrollerLock: Locking', title, reason); + locks.add(onUserInterrupt); + update(); + function release() { + log.info('ScrollerLock: Releasing', title, reason); + locks.delete(onUserInterrupt); + update(); + } + return release; + }, + onUserInterrupt(reason) { + // Ignore interuptions if we're not locked + if (locks.size > 0) { + log.info('ScrollerLock: User Interrupt', title, reason); + locks.forEach(listener => listener()); + locks.clear(); + update(); + } + }, + }; +} + +export const ScrollerLockContext = createContext(null); + +export type ScrollLockProps = Readonly<{ + reason: string; + lockScrollWhen: boolean; + onUserInterrupt(): void; +}>; + +export function useScrollerLock({ + reason, + lockScrollWhen, + onUserInterrupt, +}: ScrollLockProps): void { + const scrollerLock = useContext(ScrollerLockContext); + + if (scrollerLock == null) { + throw new Error('Missing '); + } + + const onUserInterruptRef = useRef(onUserInterrupt); + useEffect(() => { + onUserInterruptRef.current = onUserInterrupt; + }, [onUserInterrupt]); + + useEffect(() => { + if (lockScrollWhen) { + return scrollerLock.lock(reason, () => { + onUserInterruptRef.current(); + }); + } + return undefined; + }, [reason, scrollerLock, lockScrollWhen, onUserInterrupt]); +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 8dd84fd521..58c3ae3e44 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2853,6 +2853,13 @@ "reasonCategory": "usageTrusted", "updated": "2023-07-25T21:55:26.191Z" }, + { + "rule": "React-useRef", + "path": "ts/hooks/useScrollLock.tsx", + "line": " const onUserInterruptRef = useRef(onUserInterrupt);", + "reasonCategory": "usageTrusted", + "updated": "2023-09-19T17:05:51.321Z" + }, { "rule": "React-useRef", "path": "ts/quill/formatting/menu.tsx",