From f115ba58739aae80b4a7b2b433fe129320beb9fa Mon Sep 17 00:00:00 2001
From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com>
Date: Tue, 19 Sep 2023 12:01:04 -0700
Subject: [PATCH] Fix timeline scrolling automatically while emoji picker is
open
---
stylesheets/_modules.scss | 11 +--
ts/components/conversation/Timeline.tsx | 47 ++++++++--
.../conversation/TimelineMessage.tsx | 9 ++
ts/hooks/useScrollLock.tsx | 87 +++++++++++++++++++
ts/util/lint/exceptions.json | 7 ++
5 files changed, 150 insertions(+), 11 deletions(-)
create mode 100644 ts/hooks/useScrollLock.tsx
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",