Fix timeline scrolling automatically while emoji picker is open
This commit is contained in:
parent
01231eb1c6
commit
f115ba5873
5 changed files with 150 additions and 11 deletions
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
87
ts/hooks/useScrollLock.tsx
Normal file
87
ts/hooks/useScrollLock.tsx
Normal 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]);
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
Loading…
Reference in a new issue