Fix timeline scrolling automatically while emoji picker is open

This commit is contained in:
Jamie Kyle 2023-09-19 12:01:04 -07:00 committed by GitHub
parent 01231eb1c6
commit f115ba5873
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 150 additions and 11 deletions

View file

@ -5272,15 +5272,16 @@ button.module-image__border-overlay:focus {
// This is a modified version of ["Pin Scrolling to Bottom"][0]. // This is a modified version of ["Pin Scrolling to Bottom"][0].
// [0]: https://css-tricks.com/books/greatest-css-tricks/pin-scrolling-to-bottom/ // [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; overflow-anchor: none;
} }
&::after { &::after {
content: '';
height: 1px;
display: block;
overflow-anchor: auto; overflow-anchor: auto;
} }
} }

View file

@ -3,7 +3,7 @@
import { first, get, isNumber, last, throttle } from 'lodash'; import { first, get, isNumber, last, throttle } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactChild, ReactNode, RefObject } from 'react'; import type { ReactChild, ReactNode, RefObject, UIEvent } from 'react';
import React from 'react'; import React from 'react';
import type { ReadonlyDeep } from 'type-fest'; import type { ReadonlyDeep } from 'type-fest';
@ -43,6 +43,10 @@ import {
import { LastSeenIndicator } from './LastSeenIndicator'; import { LastSeenIndicator } from './LastSeenIndicator';
import { MINUTE } from '../../util/durations'; import { MINUTE } from '../../util/durations';
import { SizeObserver } from '../../hooks/useSizeObserver'; import { SizeObserver } from '../../hooks/useSizeObserver';
import {
createScrollerLock,
ScrollerLockContext,
} from '../../hooks/useScrollLock';
const AT_BOTTOM_THRESHOLD = 15; const AT_BOTTOM_THRESHOLD = 15;
const AT_BOTTOM_DETECTOR_STYLE = { height: AT_BOTTOM_THRESHOLD }; const AT_BOTTOM_DETECTOR_STYLE = { height: AT_BOTTOM_THRESHOLD };
@ -177,6 +181,7 @@ export type PropsType = PropsDataType &
PropsActionsType; PropsActionsType;
type StateType = { type StateType = {
scrollLocked: boolean;
hasDismissedDirectContactSpoofingWarning: boolean; hasDismissedDirectContactSpoofingWarning: boolean;
hasRecentlyScrolled: boolean; hasRecentlyScrolled: boolean;
lastMeasuredWarningHeight: number; lastMeasuredWarningHeight: number;
@ -214,6 +219,7 @@ export class Timeline extends React.Component<
// eslint-disable-next-line react/state-in-constructor // eslint-disable-next-line react/state-in-constructor
override state: StateType = { override state: StateType = {
scrollLocked: false,
hasRecentlyScrolled: true, hasRecentlyScrolled: true,
hasDismissedDirectContactSpoofingWarning: false, hasDismissedDirectContactSpoofingWarning: false,
@ -222,7 +228,21 @@ export class Timeline extends React.Component<
widthBreakpoint: WidthBreakpoint.Wide, 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 => this.setState(oldState =>
// `onScroll` is called frequently, so it's performance-sensitive. We try our best // `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]. // 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 { private scrollToItemIndex(itemIndex: number): void {
if (this.scrollerLock.isLocked()) {
return;
}
this.messagesRef.current this.messagesRef.current
?.querySelector(`[data-item-index="${itemIndex}"]`) ?.querySelector(`[data-item-index="${itemIndex}"]`)
?.scrollIntoViewIfNeeded(); ?.scrollIntoViewIfNeeded();
} }
private scrollToBottom = (setFocus?: boolean): void => { private scrollToBottom = (setFocus?: boolean): void => {
if (this.scrollerLock.isLocked()) {
return;
}
const { targetMessage, id, items } = this.props; const { targetMessage, id, items } = this.props;
if (setFocus && items && items.length > 0) { if (setFocus && items && items.length > 0) {
@ -258,10 +286,15 @@ export class Timeline extends React.Component<
}; };
private onClickScrollDownButton = (): void => { private onClickScrollDownButton = (): void => {
this.scrollerLock.onUserInterrupt('onClickScrollDownButton');
this.scrollDown(false); this.scrollDown(false);
}; };
private scrollDown = (setFocus?: boolean): void => { private scrollDown = (setFocus?: boolean): void => {
if (this.scrollerLock.isLocked()) {
return;
}
const { const {
haveNewest, haveNewest,
id, id,
@ -573,7 +606,7 @@ export class Timeline extends React.Component<
} = this.props; } = this.props;
const containerEl = this.containerRef.current; const containerEl = this.containerRef.current;
if (containerEl && snapshot) { if (!this.scrollerLock.isLocked() && containerEl && snapshot) {
if (snapshot === scrollToUnreadIndicator) { if (snapshot === scrollToUnreadIndicator) {
const lastSeenIndicatorEl = this.lastSeenIndicatorRef.current; const lastSeenIndicatorEl = this.lastSeenIndicatorRef.current;
if (lastSeenIndicatorEl) { if (lastSeenIndicatorEl) {
@ -781,6 +814,7 @@ export class Timeline extends React.Component<
unreadMentionsCount, unreadMentionsCount,
} = this.props; } = this.props;
const { const {
scrollLocked,
hasRecentlyScrolled, hasRecentlyScrolled,
lastMeasuredWarningHeight, lastMeasuredWarningHeight,
newestBottomVisibleMessageId, newestBottomVisibleMessageId,
@ -1050,7 +1084,7 @@ export class Timeline extends React.Component<
} }
return ( return (
<> <ScrollerLockContext.Provider value={this.scrollerLock}>
<SizeObserver <SizeObserver
onSizeChange={size => { onSizeChange={size => {
const { isNearBottom } = this.props; const { isNearBottom } = this.props;
@ -1093,7 +1127,8 @@ export class Timeline extends React.Component<
className={classNames( className={classNames(
'module-timeline__messages', 'module-timeline__messages',
haveNewest && 'module-timeline__messages--have-newest', 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} ref={this.messagesRef}
role="list" role="list"
@ -1152,7 +1187,7 @@ export class Timeline extends React.Component<
)} )}
{contactSpoofingReviewDialog} {contactSpoofingReviewDialog}
</> </ScrollerLockContext.Provider>
); );
} }

View file

@ -33,6 +33,7 @@ import {
} from '../../hooks/useKeyboardShortcuts'; } from '../../hooks/useKeyboardShortcuts';
import { PanelType } from '../../types/Panels'; import { PanelType } from '../../types/Panels';
import type { DeleteMessagesPropsType } from '../../state/ducks/globalModals'; import type { DeleteMessagesPropsType } from '../../state/ducks/globalModals';
import { useScrollerLock } from '../../hooks/useScrollLock';
export type PropsData = { export type PropsData = {
canDownload: boolean; canDownload: boolean;
@ -175,6 +176,14 @@ export function TimelineMessage(props: Props): JSX.Element {
[reactionPickerRoot] [reactionPickerRoot]
); );
useScrollerLock({
reason: 'TimelineMessage reactionPicker',
lockScrollWhen: reactionPickerRoot != null,
onUserInterrupt() {
toggleReactionPicker(true);
},
});
useEffect(() => { useEffect(() => {
let cleanUpHandler: (() => void) | undefined; let cleanUpHandler: (() => void) | undefined;
if (reactionPickerRoot) { if (reactionPickerRoot) {

View file

@ -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<ScrollerLock | null>(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 <ScrollLockProvider/>');
}
const onUserInterruptRef = useRef(onUserInterrupt);
useEffect(() => {
onUserInterruptRef.current = onUserInterrupt;
}, [onUserInterrupt]);
useEffect(() => {
if (lockScrollWhen) {
return scrollerLock.lock(reason, () => {
onUserInterruptRef.current();
});
}
return undefined;
}, [reason, scrollerLock, lockScrollWhen, onUserInterrupt]);
}

View file

@ -2853,6 +2853,13 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2023-07-25T21:55:26.191Z" "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", "rule": "React-useRef",
"path": "ts/quill/formatting/menu.tsx", "path": "ts/quill/formatting/menu.tsx",