From 4b6b9cce011dfc7e4ac077a7d58c0d3b4745c01f Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Tue, 8 Mar 2022 15:54:27 -0600 Subject: [PATCH] Fix timeline not scrolling to bottom reliably --- stylesheets/_modules.scss | 15 +++++++++ ts/components/conversation/Message.tsx | 42 +++++++++++++++++++++++-- ts/components/conversation/Timeline.tsx | 20 +++--------- 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 6262334731..b975edd27c 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5471,6 +5471,21 @@ button.module-image__border-overlay:focus { .module-timeline__messages { flex: 1 1; padding-bottom: 6px; + + // 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 { + & > * { + overflow-anchor: none; + } + + &::after { + content: ''; + height: 1px; + display: block; + overflow-anchor: auto; + } + } } .ReactVirtualized__List { diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 97778f6349..8701ee08de 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -92,7 +92,19 @@ type Trigger = { handleContextClick: (event: React.MouseEvent) => void; }; -const DEFAULT_METADATA_WIDTH = 20; +const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 10; +const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18; +const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record = { + delivered: 24, + error: 24, + paused: 18, + 'partial-sent': 24, + read: 24, + sending: 18, + sent: 24, + viewed: 24, +}; + const EXPIRATION_CHECK_MINIMUM = 2000; const EXPIRED_DELAY = 600; const GROUP_AVATAR_SIZE = AvatarSize.TWENTY_EIGHT; @@ -352,7 +364,7 @@ export class Message extends React.PureComponent { super(props); this.state = { - metadataWidth: DEFAULT_METADATA_WIDTH, + metadataWidth: this.guessMetadataWidth(), expiring: false, expired: false, @@ -552,6 +564,32 @@ export class Message extends React.PureComponent { return MetadataPlacement.InlineWithText; } + /** + * A lot of the time, we add an invisible inline spacer for messages. This spacer is the + * same size as the message metadata. Unfortunately, we don't know how wide it is until + * we render it. + * + * This will probably guess wrong, but it's valuable to get close to the real value + * because it can reduce layout jumpiness. + */ + private guessMetadataWidth(): number { + const { direction, expirationLength, expirationTimestamp, status } = + this.props; + + let result = GUESS_METADATA_WIDTH_TIMESTAMP_SIZE; + + const hasExpireTimer = Boolean(expirationLength && expirationTimestamp); + if (hasExpireTimer) { + result += GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE; + } + + if (direction === 'outgoing' && status) { + result += GUESS_METADATA_WIDTH_OUTGOING_SIZE[status]; + } + + return result; + } + public startSelectedTimer(): void { const { clearSelectedMessage, interactionMode } = this.props; const { isSelected } = this.state; diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 51a6a93cc5..592f7c195b 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -263,7 +263,6 @@ export class Timeline extends React.Component< private readonly messagesRef = React.createRef(); private readonly lastSeenIndicatorRef = React.createRef(); private intersectionObserver?: IntersectionObserver; - private messagesResizeObserver?: ResizeObserver; // This is a best guess. It will likely be overridden when the timeline is measured. private maxVisibleRows = Math.ceil(window.innerHeight / MIN_ROW_HEIGHT); @@ -490,19 +489,6 @@ export class Timeline extends React.Component< ' mounted without some refs' ); - // This observer is necessary to keep the scroll position locked to the bottom when - // messages change height without "telling" the timeline about it. This can happen - // if messages animate their height, if reactions are changed, etc. - // - // We do this synchronously (i.e., without react-measure) to avoid jitter. - this.messagesResizeObserver = new ResizeObserver(() => { - const { haveNewest } = this.props; - if (haveNewest && this.isAtBottom()) { - scrollToBottom(containerEl); - } - }); - this.messagesResizeObserver.observe(messagesEl); - this.updateIntersectionObserver(); window.registerForActive(this.markNewestFullyVisibleMessageRead); @@ -518,7 +504,6 @@ export class Timeline extends React.Component< window.unregisterForActive(this.markNewestFullyVisibleMessageRead); - this.messagesResizeObserver?.disconnect(); this.intersectionObserver?.disconnect(); clearTimeoutIfNecessary(delayedPeekTimeout); @@ -1082,7 +1067,10 @@ export class Timeline extends React.Component< ref={this.containerRef} >
{haveOldest && (