Fix marking tall messages read

This commit is contained in:
Evan Hahn 2022-03-09 14:05:07 -06:00 committed by GitHub
parent ed5da924e6
commit 80e445389f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 100 additions and 51 deletions

View file

@ -5469,6 +5469,7 @@ button.module-image__border-overlay:focus {
.module-timeline__messages { .module-timeline__messages {
flex: 1 1; flex: 1 1;
padding-bottom: 6px; padding-bottom: 6px;
position: relative;
// 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/
@ -5484,6 +5485,11 @@ button.module-image__border-overlay:focus {
overflow-anchor: auto; overflow-anchor: auto;
} }
} }
&__at-bottom-detector {
position: absolute;
bottom: 0;
}
} }
.ReactVirtualized__List { .ReactVirtualized__List {

View file

@ -44,6 +44,8 @@ import {
import { LastSeenIndicator } from './LastSeenIndicator'; import { LastSeenIndicator } from './LastSeenIndicator';
const AT_BOTTOM_THRESHOLD = 15; const AT_BOTTOM_THRESHOLD = 15;
const AT_BOTTOM_DETECTOR_STYLE = { height: AT_BOTTOM_THRESHOLD };
const MIN_ROW_HEIGHT = 18; const MIN_ROW_HEIGHT = 18;
const SCROLL_DOWN_BUTTON_THRESHOLD = 8; const SCROLL_DOWN_BUTTON_THRESHOLD = 8;
@ -171,7 +173,7 @@ export type PropsType = PropsDataType &
type StateType = { type StateType = {
hasDismissedDirectContactSpoofingWarning: boolean; hasDismissedDirectContactSpoofingWarning: boolean;
hasRecentlyScrolled: boolean; hasRecentlyScrolled: boolean;
newestFullyVisibleMessageId?: string; newestBottomVisibleMessageId?: string;
oldestPartiallyVisibleMessageId?: string; oldestPartiallyVisibleMessageId?: string;
widthBreakpoint: WidthBreakpoint; widthBreakpoint: WidthBreakpoint;
}; };
@ -260,6 +262,7 @@ export class Timeline extends React.Component<
> { > {
private readonly containerRef = React.createRef<HTMLDivElement>(); private readonly containerRef = React.createRef<HTMLDivElement>();
private readonly messagesRef = React.createRef<HTMLDivElement>(); private readonly messagesRef = React.createRef<HTMLDivElement>();
private readonly atBottomDetectorRef = React.createRef<HTMLDivElement>();
private readonly lastSeenIndicatorRef = React.createRef<HTMLDivElement>(); private readonly lastSeenIndicatorRef = React.createRef<HTMLDivElement>();
private intersectionObserver?: IntersectionObserver; private intersectionObserver?: IntersectionObserver;
@ -278,10 +281,6 @@ export class Timeline extends React.Component<
}; };
private onScroll = (): void => { private onScroll = (): void => {
const { id, setIsNearBottom } = this.props;
setIsNearBottom(id, this.isAtBottom());
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].
@ -330,7 +329,7 @@ export class Timeline extends React.Component<
oldestUnreadIndex, oldestUnreadIndex,
selectMessage, selectMessage,
} = this.props; } = this.props;
const { newestFullyVisibleMessageId } = this.state; const { newestBottomVisibleMessageId } = this.state;
if (!items || items.length < 1) { if (!items || items.length < 1) {
return; return;
@ -342,9 +341,9 @@ export class Timeline extends React.Component<
} }
if ( if (
newestFullyVisibleMessageId && newestBottomVisibleMessageId &&
isNumber(oldestUnreadIndex) && isNumber(oldestUnreadIndex) &&
items.findIndex(item => item === newestFullyVisibleMessageId) < items.findIndex(item => item === newestBottomVisibleMessageId) <
oldestUnreadIndex oldestUnreadIndex
) { ) {
if (setFocus) { if (setFocus) {
@ -370,75 +369,106 @@ export class Timeline extends React.Component<
); );
} }
/**
* Re-initialize our `IntersectionObserver`. This replaces the old observer because (1)
* we don't want stale references to old props (2) we care about the order of the
* `IntersectionObserverEntry`s.
*
* This isn't the only way to solve this problem. For example, we could have a single
* observer for the lifetime of the component and update it intelligently. This approach
* seems to work, though!
*/
private updateIntersectionObserver(): void { private updateIntersectionObserver(): void {
const containerEl = this.containerRef.current; const containerEl = this.containerRef.current;
const messagesEl = this.messagesRef.current; const messagesEl = this.messagesRef.current;
if (!containerEl || !messagesEl) { const atBottomDetectorEl = this.atBottomDetectorRef.current;
if (!containerEl || !messagesEl || !atBottomDetectorEl) {
return; return;
} }
const { const {
haveNewest, haveNewest,
haveOldest, haveOldest,
id,
isLoadingMessages, isLoadingMessages,
items, items,
loadNewerMessages, loadNewerMessages,
loadOlderMessages, loadOlderMessages,
setIsNearBottom,
} = this.props; } = this.props;
// We re-initialize the `IntersectionObserver`. We don't want stale references to old
// props, and we care about the order of `IntersectionObserverEntry`s. (We could do
// this another way, but this approach works.)
this.intersectionObserver?.disconnect(); this.intersectionObserver?.disconnect();
// Keys are message IDs. Values are intersection ratios. const intersectionRatios = new Map<Element, number>();
const visibleMessages = new Map<string, number>();
const intersectionObserverCallback: IntersectionObserverCallback = const intersectionObserverCallback: IntersectionObserverCallback =
entries => { entries => {
// The first time this callback is called, we'll get entries in observation order
// (which should match DOM order). We don't want to delete anything from our map
// because we don't want the order to change at all.
entries.forEach(entry => { entries.forEach(entry => {
const { intersectionRatio, target } = entry; intersectionRatios.set(entry.target, entry.intersectionRatio);
const {
dataset: { messageId },
} = target as HTMLElement;
if (!messageId) {
return;
}
visibleMessages.set(messageId, intersectionRatio);
}); });
let oldestPartiallyVisibleMessageId: undefined | string; let newIsNearBottom = false;
let newestFullyVisibleMessageId: undefined | string; let oldestPartiallyVisible: undefined | Element;
let newestPartiallyVisible: undefined | Element;
let newestFullyVisible: undefined | Element;
for (const [messageId, intersectionRatio] of visibleMessages) { for (const [element, intersectionRatio] of intersectionRatios) {
if (intersectionRatio > 0 && !oldestPartiallyVisibleMessageId) { if (intersectionRatio === 0) {
oldestPartiallyVisibleMessageId = messageId; continue;
} }
if (intersectionRatio >= 1) {
newestFullyVisibleMessageId = messageId; // We use this "at bottom detector" for two reasons, both for performance. It's
// usually faster to use an `IntersectionObserver` instead of a scroll event,
// and we want to do that here.
//
// 1. We can determine whether we're near the bottom without `onScroll`
// 2. We need this information when deciding whether the bottom of the last
// message is visible. We want to get an intersection observer event when the
// bottom of the container comes into view.
if (element === atBottomDetectorEl) {
newIsNearBottom = true;
} else {
oldestPartiallyVisible = oldestPartiallyVisible || element;
newestPartiallyVisible = element;
if (intersectionRatio === 1) {
newestFullyVisible = element;
}
} }
} }
// If a message is fully visible, then you can see its bottom. If not, there's a
// very tall message around. We assume you can see the bottom of a message if
// (1) another message is partly visible right below it, or (2) you're near the
// bottom of the scrollable container.
let newestBottomVisible: undefined | Element;
if (newestFullyVisible) {
newestBottomVisible = newestFullyVisible;
} else if (
newIsNearBottom ||
newestPartiallyVisible !== oldestPartiallyVisible
) {
newestBottomVisible = oldestPartiallyVisible;
}
const oldestPartiallyVisibleMessageId = getMessageIdFromElement(
oldestPartiallyVisible
);
const newestBottomVisibleMessageId =
getMessageIdFromElement(newestBottomVisible);
this.setState({ this.setState({
oldestPartiallyVisibleMessageId, oldestPartiallyVisibleMessageId,
newestFullyVisibleMessageId, newestBottomVisibleMessageId,
}); });
if (newestFullyVisibleMessageId) { setIsNearBottom(id, newIsNearBottom);
this.markNewestFullyVisibleMessageRead();
if (newestBottomVisibleMessageId) {
this.markNewestBottomVisibleMessageRead();
if ( if (
!isLoadingMessages && !isLoadingMessages &&
!haveNewest && !haveNewest &&
newestFullyVisibleMessageId === last(items) newestBottomVisibleMessageId === last(items)
) { ) {
loadNewerMessages(newestFullyVisibleMessageId); loadNewerMessages(newestBottomVisibleMessageId);
} }
} }
@ -465,14 +495,15 @@ export class Timeline extends React.Component<
this.intersectionObserver.observe(child); this.intersectionObserver.observe(child);
} }
} }
this.intersectionObserver.observe(atBottomDetectorEl);
} }
private markNewestFullyVisibleMessageRead = throttle( private markNewestBottomVisibleMessageRead = throttle(
(): void => { (): void => {
const { markMessageRead } = this.props; const { markMessageRead } = this.props;
const { newestFullyVisibleMessageId } = this.state; const { newestBottomVisibleMessageId } = this.state;
if (newestFullyVisibleMessageId) { if (newestBottomVisibleMessageId) {
markMessageRead(newestFullyVisibleMessageId); markMessageRead(newestBottomVisibleMessageId);
} }
}, },
500, 500,
@ -489,7 +520,7 @@ export class Timeline extends React.Component<
this.updateIntersectionObserver(); this.updateIntersectionObserver();
window.registerForActive(this.markNewestFullyVisibleMessageRead); window.registerForActive(this.markNewestBottomVisibleMessageRead);
this.delayedPeekTimeout = setTimeout(() => { this.delayedPeekTimeout = setTimeout(() => {
const { id, peekGroupCallForTheFirstTime } = this.props; const { id, peekGroupCallForTheFirstTime } = this.props;
@ -500,7 +531,7 @@ export class Timeline extends React.Component<
public override componentWillUnmount(): void { public override componentWillUnmount(): void {
const { delayedPeekTimeout } = this; const { delayedPeekTimeout } = this;
window.unregisterForActive(this.markNewestFullyVisibleMessageRead); window.unregisterForActive(this.markNewestBottomVisibleMessageRead);
this.intersectionObserver?.disconnect(); this.intersectionObserver?.disconnect();
@ -761,7 +792,7 @@ export class Timeline extends React.Component<
} = this.props; } = this.props;
const { const {
hasRecentlyScrolled, hasRecentlyScrolled,
newestFullyVisibleMessageId, newestBottomVisibleMessageId,
oldestPartiallyVisibleMessageId, oldestPartiallyVisibleMessageId,
widthBreakpoint, widthBreakpoint,
} = this.state; } = this.state;
@ -777,15 +808,15 @@ export class Timeline extends React.Component<
const areAnyMessagesBelowCurrentPosition = const areAnyMessagesBelowCurrentPosition =
!haveNewest || !haveNewest ||
Boolean( Boolean(
newestFullyVisibleMessageId && newestBottomVisibleMessageId &&
newestFullyVisibleMessageId !== last(items) newestBottomVisibleMessageId !== last(items)
); );
const areSomeMessagesBelowCurrentPosition = const areSomeMessagesBelowCurrentPosition =
!haveNewest || !haveNewest ||
(newestFullyVisibleMessageId && (newestBottomVisibleMessageId &&
!items !items
.slice(-SCROLL_DOWN_BUTTON_THRESHOLD) .slice(-SCROLL_DOWN_BUTTON_THRESHOLD)
.includes(newestFullyVisibleMessageId)); .includes(newestBottomVisibleMessageId));
const areUnreadBelowCurrentPosition = Boolean( const areUnreadBelowCurrentPosition = Boolean(
areThereAnyMessages && areThereAnyMessages &&
@ -1055,6 +1086,12 @@ export class Timeline extends React.Component<
{messageNodes} {messageNodes}
{isSomeoneTyping && renderTypingBubble(id)} {isSomeoneTyping && renderTypingBubble(id)}
<div
className="module-timeline__messages__at-bottom-detector"
ref={this.atBottomDetectorRef}
style={AT_BOTTOM_DETECTOR_STYLE}
/>
</div> </div>
</div> </div>
@ -1111,6 +1148,12 @@ export class Timeline extends React.Component<
} }
} }
function getMessageIdFromElement(
element: undefined | Element
): undefined | string {
return element instanceof HTMLElement ? element.dataset.messageId : undefined;
}
function showDebugLog() { function showDebugLog() {
window.showDebugLog(); window.showDebugLog();
} }