Files
Desktop/ts/components/conversation/Timeline.dom.tsx

1355 lines
43 KiB
TypeScript

// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import lodash from 'lodash';
import classNames from 'classnames';
import type { ReactNode, UIEvent } from 'react';
import React from 'react';
import {
ScrollDownButton,
ScrollDownButtonVariant,
} from './ScrollDownButton.dom.js';
import type { LocalizerType, ThemeType } from '../../types/Util.std.js';
import type { ConversationType } from '../../state/ducks/conversations.preload.js';
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges.preload.js';
import { assertDev, strictAssert } from '../../util/assert.std.js';
import { missingCaseError } from '../../util/missingCaseError.std.js';
import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary.std.js';
import { WidthBreakpoint } from '../_util.std.js';
import { ErrorBoundary } from './ErrorBoundary.dom.js';
import { NewlyCreatedGroupInvitedContactsDialog } from '../NewlyCreatedGroupInvitedContactsDialog.dom.js';
import type { PropsType as SmartContactSpoofingReviewDialogPropsType } from '../../state/smart/ContactSpoofingReviewDialog.preload.js';
import { TimelineFloatingHeader } from './TimelineFloatingHeader.dom.js';
import {
getScrollAnchorBeforeUpdate,
getWidthBreakpoint,
ScrollAnchor,
TimelineMessageLoadingState,
UnreadIndicatorPlacement,
} from '../../util/timelineUtil.std.js';
import {
getScrollBottom,
scrollToBottom,
setScrollBottom,
} from '../../util/scrollUtil.std.js';
import { LastSeenIndicator } from './LastSeenIndicator.dom.js';
import { MINUTE, SECOND } from '../../util/durations/index.std.js';
import { SizeObserver } from '../../hooks/useSizeObserver.dom.js';
import {
createScrollerLock,
ScrollerLockContext,
} from '../../hooks/useScrollLock.dom.js';
import { MessageInteractivity } from './Message.dom.js';
import type { RenderItemProps } from '../../state/smart/TimelineItem.preload.js';
import type { CollapseSet } from '../../util/CollapseSet.std.js';
const { first, get, isNumber, last, throttle } = lodash;
const AT_BOTTOM_THRESHOLD = 15;
const AT_BOTTOM_DETECTOR_STYLE = { height: AT_BOTTOM_THRESHOLD };
const MIN_ROW_HEIGHT = 18;
const SCROLL_DOWN_BUTTON_THRESHOLD = 8;
const LOAD_NEWER_THRESHOLD = 5;
const DELAY_BEFORE_MARKING_READ_AFTER_FOCUS = SECOND;
export type PropsDataType = {
haveNewest: boolean;
haveOldest: boolean;
messageChangeCounter: number;
messageLoadingState: TimelineMessageLoadingState | null;
isNearBottom: boolean | null;
items: ReadonlyArray<CollapseSet>;
oldestUnseenIndex: number | null;
scrollToIndex: number | null;
scrollToIndexCounter: number;
totalUnseen: number;
};
type PropsHousekeepingType = {
id: string;
isBlocked: boolean;
isConversationSelected: boolean;
isGroupV1AndDisabled?: boolean;
isInFullScreenCall: boolean;
isIncomingMessageRequest: boolean;
isSomeoneTyping: boolean;
unreadCount?: number;
unreadMentionsCount?: number;
conversationType: 'direct' | 'group';
targetedMessageId?: string;
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
selectedMessageId?: string;
hasContactSpoofingReview: boolean | undefined;
discardMessages: (
_: Readonly<
| {
conversationId: string;
numberToKeepAtBottom: number;
}
| { conversationId: string; numberToKeepAtTop: number }
>
) => void;
getTimestampForMessage: (messageId: string) => undefined | number;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
theme: ThemeType;
updateVisibleMessages?: (messageIds: Array<string>) => void;
renderContactSpoofingReviewDialog: (
props: SmartContactSpoofingReviewDialogPropsType
) => React.JSX.Element;
renderHeroRow: (id: string) => React.JSX.Element;
renderItem: (props: RenderItemProps) => React.JSX.Element;
renderTypingBubble: (id: string) => React.JSX.Element;
};
export type PropsActionsType = {
clearInvitedServiceIdsForNewlyCreatedGroup: () => void;
clearTargetedMessage: () => unknown;
closeContactSpoofingReview: () => void;
loadOlderMessages: (conversationId: string, messageId: string) => unknown;
loadNewerMessages: (conversationId: string, messageId: string) => unknown;
loadNewestMessages: (
conversationId: string,
messageId: string,
setFocus?: boolean
) => unknown;
markMessageRead: (conversationId: string, messageId: string) => unknown;
maybePeekGroupCall: (conversationId: string) => unknown;
targetMessage: (messageId: string, conversationId: string) => unknown;
setCenterMessage: (
conversationId: string,
messageId: string | undefined
) => void;
setIsNearBottom: (conversationId: string, isNearBottom: boolean) => void;
scrollToOldestUnreadMention: (conversationId: string) => unknown;
};
export type PropsType = PropsDataType &
PropsHousekeepingType &
PropsActionsType;
type StateType = {
scrollLocked: boolean;
scrollLockHeight: number | undefined;
hasRecentlyScrolled: boolean;
newestBottomVisibleMessageId?: string;
oldestPartiallyVisibleMessageId?: string;
widthBreakpoint: WidthBreakpoint;
};
const scrollToUnreadIndicator = Symbol('scrollToUnreadIndicator');
type SnapshotType =
| null
| typeof scrollToUnreadIndicator
| { scrollToIndex: number }
| { scrollTop: number }
| { scrollBottom: number };
export class Timeline extends React.Component<
PropsType,
StateType,
SnapshotType
> {
readonly #containerRef = React.createRef<HTMLDivElement>();
readonly #messagesRef = React.createRef<HTMLDivElement>();
readonly #atBottomDetectorRef = React.createRef<HTMLDivElement>();
readonly #lastSeenIndicatorRef = React.createRef<HTMLDivElement>();
#intersectionObserver?: IntersectionObserver;
#intersectionRatios: Map<Element, number> = new Map();
// This is a best guess. It will likely be overridden when the timeline is measured.
#maxVisibleRows = Math.ceil(window.innerHeight / MIN_ROW_HEIGHT);
#hasRecentlyScrolledTimeout?: NodeJS.Timeout;
#delayedPeekTimeout?: NodeJS.Timeout;
#peekInterval?: NodeJS.Timeout;
// eslint-disable-next-line react/state-in-constructor
override state: StateType = {
scrollLocked: false,
scrollLockHeight: undefined,
hasRecentlyScrolled: true,
widthBreakpoint: WidthBreakpoint.Wide,
};
#onScrollLockChange = (): void => {
const scrollLocked = this.#scrollerLock.isLocked();
this.setState(() => {
// Prevent scroll due to elements shrinking or disappearing (e.g. typing indicators)
const scrollLockHeight = scrollLocked
? this.#messagesRef.current?.getBoundingClientRect().height
: undefined;
return {
scrollLocked,
scrollLockHeight,
};
});
};
#scrollerLock = createScrollerLock('Timeline', this.#onScrollLockChange);
#onScroll = (event: UIEvent): void => {
// When content is removed from the viewport, such as typing indicators leaving
// or messages being edited smaller or deleted, scroll events are generated and
// they are marked as user-generated (isTrusted === true). Actual user generated
// scroll events with movement must scroll a nonbottom state at some point.
const isAtBottom = this.#isAtBottom();
if (event.isTrusted && !isAtBottom) {
this.#scrollerLock.onUserInterrupt('onScroll');
}
// hasRecentlyScrolled is used to show the floating date header, which we only
// want to show when scrolling through history or on conversation first open.
// Checking bottom prevents new messages and typing from showing the header.
if (!this.state.hasRecentlyScrolled && this.#isAtBottom()) {
return;
}
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].
//
// [0]: https://github.com/facebook/react/blob/29b7b775f2ecf878eaf605be959d959030598b07/packages/react-reconciler/src/ReactUpdateQueue.js#L401-L404
oldState.hasRecentlyScrolled ? null : { hasRecentlyScrolled: true }
);
clearTimeoutIfNecessary(this.#hasRecentlyScrolledTimeout);
this.#hasRecentlyScrolledTimeout = setTimeout(() => {
this.setState({ hasRecentlyScrolled: false });
}, 3000);
// Because CollapseSets might house many unseen messages, we can't wait for our
// intersection observer to tell us that a new item is now fully visible or fully not
// visible. We need to check more often to see how many messages are visible within
// a given CollapseSet.
this.#markNewestBottomVisibleMessageReadAfterDelay();
};
#scrollToItemIndex(itemIndex: number): void {
if (this.#scrollerLock.isLocked()) {
return;
}
this.#messagesRef.current
?.querySelector(`[data-item-index="${itemIndex}"]`)
?.scrollIntoView({ block: 'center' });
}
#scrollToBottom = (setFocus?: boolean): void => {
if (this.#scrollerLock.isLocked()) {
return;
}
const { targetMessage, id, items } = this.props;
if (setFocus && items && items.length > 0) {
const lastIndex = items.length - 1;
const lastItem = items[lastIndex];
strictAssert(lastItem, 'Missing lastItem');
targetMessage(lastItem.id, id);
} else {
const containerEl = this.#containerRef.current;
if (containerEl) {
scrollToBottom(containerEl);
}
}
};
#onClickScrollDownButton = (): void => {
this.#scrollerLock.onUserInterrupt('onClickScrollDownButton');
this.#scrollDown(false);
};
#scrollDown = (setFocus?: boolean): void => {
if (this.#scrollerLock.isLocked()) {
return;
}
const {
haveNewest,
id,
items,
loadNewestMessages,
messageLoadingState,
oldestUnseenIndex,
targetMessage,
} = this.props;
const { newestBottomVisibleMessageId } = this.state;
if (!items || items.length < 1) {
return;
}
if (messageLoadingState) {
this.#scrollToBottom(setFocus);
return;
}
if (
newestBottomVisibleMessageId &&
isNumber(oldestUnseenIndex) &&
items.findIndex(item => item.id === newestBottomVisibleMessageId) <
oldestUnseenIndex
) {
if (setFocus) {
const item = items[oldestUnseenIndex];
strictAssert(item, 'Missing item at oldestUnseenIndex');
targetMessage(item.id, id);
} else {
this.#lastSeenIndicatorRef.current?.scrollIntoView();
}
} else if (haveNewest) {
this.#scrollToBottom(setFocus);
} else {
const lastItem = last(items);
if (lastItem) {
loadNewestMessages(id, lastItem.id, setFocus);
}
}
};
#isAtBottom(): boolean {
const containerEl = this.#containerRef.current;
if (!containerEl) {
return false;
}
const isScrolledNearBottom =
getScrollBottom(containerEl) <= AT_BOTTOM_THRESHOLD;
const hasScrollbars = containerEl.clientHeight < containerEl.scrollHeight;
return isScrolledNearBottom || !hasScrollbars;
}
#updateIntersectionObserver(): void {
const containerEl = this.#containerRef.current;
const messagesEl = this.#messagesRef.current;
const atBottomDetectorEl = this.#atBottomDetectorRef.current;
if (!containerEl || !messagesEl || !atBottomDetectorEl) {
return;
}
const {
haveNewest,
haveOldest,
id,
items,
loadNewerMessages,
loadOlderMessages,
messageLoadingState,
setIsNearBottom,
} = 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.#intersectionRatios = new Map();
this.props.updateVisibleMessages?.([]);
const intersectionObserverCallback: IntersectionObserverCallback =
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 => {
this.#intersectionRatios.set(entry.target, entry.intersectionRatio);
});
let newIsNearBottom = false;
let oldestPartiallyVisible: undefined | Element;
let newestPartiallyVisible: undefined | Element;
let newestFullyVisible: undefined | Element;
const visibleMessageIds: Array<string> = [];
for (const [element, intersectionRatio] of this.#intersectionRatios) {
if (intersectionRatio === 0) {
continue;
}
const messageId = getMessageIdFromElement(element);
if (messageId) {
visibleMessageIds.push(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;
}
}
}
this.props.updateVisibleMessages?.(visibleMessageIds);
// 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({
oldestPartiallyVisibleMessageId,
newestBottomVisibleMessageId,
});
setIsNearBottom(id, newIsNearBottom);
if (newestBottomVisibleMessageId) {
this.#markNewestBottomVisibleMessageRead(
newestBottomVisibleMessageId
);
const rowIndex = getRowIndexFromElement(newestBottomVisible);
const maxRowIndex = items.length - 1;
if (
!messageLoadingState &&
!haveNewest &&
isNumber(rowIndex) &&
maxRowIndex >= 0 &&
rowIndex >= maxRowIndex - LOAD_NEWER_THRESHOLD
) {
let targetMessageId = newestBottomVisibleMessageId;
const newestItem = items.find(
item => item.id === newestBottomVisibleMessageId
);
if (newestItem && newestItem.type !== 'none') {
const lastItem = last(newestItem.messages);
strictAssert(lastItem, 'lastItem in newestItem.messages array');
targetMessageId = lastItem.id;
}
loadNewerMessages(id, targetMessageId);
}
}
if (
!messageLoadingState &&
!haveOldest &&
oldestPartiallyVisibleMessageId &&
oldestPartiallyVisibleMessageId === items[0]?.id
) {
loadOlderMessages(id, oldestPartiallyVisibleMessageId);
}
};
this.#intersectionObserver = new IntersectionObserver(
(entries, observer) => {
assertDev(
this.#intersectionObserver === observer,
'observer.disconnect() should prevent callbacks from firing'
);
// Observer was updated from under us
if (this.#intersectionObserver !== observer) {
return;
}
intersectionObserverCallback(entries, observer);
},
{
root: containerEl,
threshold: [0, 1],
}
);
for (const child of messagesEl.children) {
if ((child as HTMLElement).dataset.messageId) {
this.#intersectionObserver.observe(child);
}
}
this.#intersectionObserver.observe(atBottomDetectorEl);
}
#getCenterMessageId(): string | undefined {
const containerEl = this.#containerRef.current;
if (!containerEl) {
return;
}
const containerElRectTop = containerEl.getBoundingClientRect().top;
const containerElMidline = containerEl.clientHeight / 2;
const atBottomDetectorEl = this.#atBottomDetectorRef.current;
let centerMessageId: undefined | string;
let centerMessageRelativeTop = 0;
for (const [element, intersectionRatio] of this.#intersectionRatios) {
if (intersectionRatio === 0) {
continue;
}
if (element === atBottomDetectorEl) {
return;
}
const messageId = getMessageIdFromElement(element);
if (!messageId) {
continue;
}
const relativeTop =
element.getBoundingClientRect().top - containerElRectTop;
if (
!centerMessageId ||
(relativeTop > centerMessageRelativeTop &&
relativeTop < containerElMidline)
) {
centerMessageId = messageId;
centerMessageRelativeTop = relativeTop;
}
}
return centerMessageId;
}
#markNewestBottomVisibleMessageRead = throttle((itemId?: string): void => {
const { id, items, markMessageRead } = this.props;
const messageIdToMarkRead =
itemId ?? this.state.newestBottomVisibleMessageId;
if (!messageIdToMarkRead) {
return;
}
const lastIndex = items.length - 1;
const newestBottomVisibleItemIndex = items.findIndex(
item => item.id === messageIdToMarkRead
);
// Mark the newest visible message read if we're at the bottom, or override provided
if (
messageIdToMarkRead &&
(itemId || lastIndex === newestBottomVisibleItemIndex)
) {
markMessageRead(id, messageIdToMarkRead);
return;
}
// We can return early if the newest partially-visible item is not a CollapseSet
const newestPartiallyVisibleIndex = newestBottomVisibleItemIndex + 1;
const newestPartiallyVisibleItem = items[newestPartiallyVisibleIndex];
if (
newestPartiallyVisibleItem &&
newestPartiallyVisibleItem.type === 'none'
) {
markMessageRead(id, messageIdToMarkRead);
return;
}
// Now we need to figure out which of the CollapseSet's inner messages are visible
const collapseSetEl = this.#messagesRef.current?.querySelector(
`[data-item-index="${newestPartiallyVisibleIndex}"]`
);
const containerWindowRect =
this.#containerRef.current?.getBoundingClientRect();
if (!collapseSetEl || !containerWindowRect) {
markMessageRead(id, messageIdToMarkRead);
return;
}
const messageEls = collapseSetEl.querySelectorAll('[data-message-id]');
const containerWindowBottom =
containerWindowRect.y + containerWindowRect.height;
let newestFullyVisibleMessage;
for (let i = messageEls.length - 1; i >= 0; i -= 1) {
const messageEl = messageEls[i];
strictAssert(messageEl, 'No messageEl at index i');
// The messages might be rendered, but opacity = 0
if (!messageEl.checkVisibility({ opacityProperty: true })) {
break;
}
// Make sure the messages are scrolled into view
const messageRect = messageEl.getBoundingClientRect();
const bottom = messageRect.y + messageRect.height;
if (bottom <= containerWindowBottom) {
newestFullyVisibleMessage = messageEl;
break;
}
}
if (!newestFullyVisibleMessage) {
markMessageRead(id, messageIdToMarkRead);
return;
}
const messageId = newestFullyVisibleMessage.getAttribute('data-message-id');
markMessageRead(id, messageId || messageIdToMarkRead);
}, 500);
// When the the window becomes active, or when a fullsceen call is ended, we mark read
// with a delay, to allow users to navigate away quickly without marking messages read
#markNewestBottomVisibleMessageReadAfterDelay = throttle(
this.#markNewestBottomVisibleMessageRead,
DELAY_BEFORE_MARKING_READ_AFTER_FOCUS,
{
leading: false,
trailing: true,
}
);
#setupGroupCallPeekTimeouts(): void {
this.#cleanupGroupCallPeekTimeouts();
this.#delayedPeekTimeout = setTimeout(() => {
const { id, maybePeekGroupCall } = this.props;
this.#delayedPeekTimeout = undefined;
maybePeekGroupCall(id);
this.#peekInterval = setInterval(() => {
maybePeekGroupCall(id);
}, MINUTE);
}, 500);
}
#cleanupGroupCallPeekTimeouts(): void {
const peekInterval = this.#peekInterval;
const delayedPeekTimeout = this.#delayedPeekTimeout;
clearTimeoutIfNecessary(delayedPeekTimeout);
this.#delayedPeekTimeout = undefined;
if (peekInterval) {
clearInterval(peekInterval);
this.#peekInterval = undefined;
}
}
public override componentDidMount(): void {
const containerEl = this.#containerRef.current;
const messagesEl = this.#messagesRef.current;
const { conversationType, isConversationSelected } = this.props;
strictAssert(
// We don't render anything unless the conversation is selected
(containerEl && messagesEl) || !isConversationSelected,
'<Timeline> mounted without some refs'
);
if (containerEl) {
this.setState(
{
widthBreakpoint: getWidthBreakpoint(containerEl.offsetWidth),
},
() => {
this.#updateIntersectionObserver();
}
);
} else {
this.#updateIntersectionObserver();
}
window.SignalContext.activeWindowService.registerForActive(
this.#markNewestBottomVisibleMessageReadAfterDelay
);
if (conversationType === 'group') {
this.#setupGroupCallPeekTimeouts();
}
}
public override componentWillUnmount(): void {
const { id, setCenterMessage, updateVisibleMessages } = this.props;
window.SignalContext.activeWindowService.unregisterForActive(
this.#markNewestBottomVisibleMessageReadAfterDelay
);
this.#markNewestBottomVisibleMessageReadAfterDelay.cancel();
this.#markNewestBottomVisibleMessageRead.cancel();
this.#intersectionObserver?.disconnect();
this.#cleanupGroupCallPeekTimeouts();
updateVisibleMessages?.([]);
setCenterMessage(id, this.#getCenterMessageId());
}
public override getSnapshotBeforeUpdate(
prevProps: Readonly<PropsType>
): SnapshotType {
const containerEl = this.#containerRef.current;
if (!containerEl) {
return null;
}
const { props } = this;
const { scrollToIndex } = props;
const scrollAnchor = getScrollAnchorBeforeUpdate(
prevProps,
props,
this.#isAtBottom()
);
switch (scrollAnchor) {
case ScrollAnchor.ChangeNothing:
return null;
case ScrollAnchor.ScrollToBottom:
return { scrollBottom: 0 };
case ScrollAnchor.ScrollToIndex:
if (scrollToIndex == null) {
assertDev(
false,
'<Timeline> got "scroll to index" scroll anchor, but no index'
);
}
return { scrollToIndex };
case ScrollAnchor.ScrollToUnreadIndicator:
return scrollToUnreadIndicator;
case ScrollAnchor.Top:
return { scrollTop: containerEl.scrollTop };
case ScrollAnchor.Bottom:
return { scrollBottom: getScrollBottom(containerEl) };
default:
throw missingCaseError(scrollAnchor);
}
}
public override componentDidUpdate(
prevProps: Readonly<PropsType>,
_prevState: Readonly<StateType>,
snapshot: Readonly<SnapshotType>
): void {
const {
conversationType: previousConversationType,
isInFullScreenCall: previousIsInFullScreenCall,
items: oldItems,
messageChangeCounter: previousMessageChangeCounter,
messageLoadingState: previousMessageLoadingState,
} = prevProps;
const {
conversationType,
discardMessages,
id,
isInFullScreenCall,
items: newItems,
messageChangeCounter,
messageLoadingState,
oldestUnseenIndex,
} = this.props;
const containerEl = this.#containerRef.current;
if (!this.#scrollerLock.isLocked() && containerEl && snapshot) {
if (snapshot === scrollToUnreadIndicator) {
const lastSeenIndicatorEl = this.#lastSeenIndicatorRef.current;
if (lastSeenIndicatorEl) {
lastSeenIndicatorEl.scrollIntoView();
} else {
scrollToBottom(containerEl);
assertDev(
false,
'<Timeline> expected a last seen indicator but it was not found'
);
}
} else if ('scrollToIndex' in snapshot) {
// Wait to scroll until after another render has completed, to allow for message
// sizes to measured & stabilize
this.setState(
state => state,
() => this.#scrollToItemIndex(snapshot.scrollToIndex)
);
} else if ('scrollTop' in snapshot) {
containerEl.scrollTop = snapshot.scrollTop;
} else {
setScrollBottom(containerEl, snapshot.scrollBottom);
}
}
// We know that all items will be in order and that items can only be added at either
// end, so we can check for equality without checking each item in the array
const haveItemsChanged =
oldItems.length !== newItems.length ||
oldItems.at(0) !== newItems.at(0) ||
oldItems.at(-1) !== newItems.at(-1);
if (haveItemsChanged) {
this.#updateIntersectionObserver();
// This condition is somewhat arbitrary.
const numberToKeepAtBottom = this.#maxVisibleRows * 2;
const shouldDiscardOlderMessages: boolean =
this.#isAtBottom() && newItems.length > numberToKeepAtBottom;
if (shouldDiscardOlderMessages) {
discardMessages({
conversationId: id,
numberToKeepAtBottom,
});
}
const loadingStateThatJustFinished:
| undefined
| TimelineMessageLoadingState =
!messageLoadingState && previousMessageLoadingState
? previousMessageLoadingState
: undefined;
const numberToKeepAtTop = this.#maxVisibleRows * 5;
const shouldDiscardNewerMessages: boolean =
!this.#isAtBottom() &&
oldestUnseenIndex == null &&
loadingStateThatJustFinished ===
TimelineMessageLoadingState.LoadingOlderMessages &&
newItems.length > numberToKeepAtTop;
if (shouldDiscardNewerMessages) {
discardMessages({
conversationId: id,
numberToKeepAtTop,
});
}
}
if (previousMessageChangeCounter !== messageChangeCounter) {
this.#markNewestBottomVisibleMessageRead();
}
if (previousIsInFullScreenCall && !isInFullScreenCall) {
this.#markNewestBottomVisibleMessageReadAfterDelay();
}
if (previousConversationType !== conversationType) {
this.#cleanupGroupCallPeekTimeouts();
if (conversationType === 'group') {
this.#setupGroupCallPeekTimeouts();
}
}
}
#handleBlur = (event: React.FocusEvent): void => {
const { clearTargetedMessage } = this.props;
const { currentTarget } = event;
// Thanks to https://gist.github.com/pstoica/4323d3e6e37e8a23dd59
setTimeout(() => {
// If focus moved to one of our portals, we do not clear the targeted
// message so that focus stays inside the portal. We need to be careful
// to not create colliding keyboard shortcuts between targeted messages
// and our portals!
const portals = Array.from(
document.querySelectorAll('body > div:not(.inbox)')
);
if (portals.some(el => el.contains(document.activeElement))) {
return;
}
if (!currentTarget.contains(document.activeElement)) {
clearTargetedMessage();
}
}, 0);
};
#handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
const { targetMessage, targetedMessageId, items, id } = this.props;
const commandKey = get(window, 'platform') === 'darwin' && event.metaKey;
const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey;
const commandOrCtrl = commandKey || controlKey;
if (!items || items.length < 1) {
return;
}
if (
targetedMessageId &&
!commandOrCtrl &&
(event.key === 'ArrowUp' || event.key === 'ArrowDown')
) {
const direction = event.key === 'ArrowUp' ? -1 : 1;
const currentTargetIndex = items.findIndex(
item =>
item.id === targetedMessageId ||
item.messages?.some(message => message.id === targetedMessageId)
);
if (currentTargetIndex < 0) {
return;
}
const currentItem = items[currentTargetIndex];
strictAssert(currentItem, 'No item at currentTargetIndex');
if (currentItem.type !== 'none') {
const innerIndex = currentItem.messages.findIndex(
message => message.id === targetedMessageId
);
const targetIndex = innerIndex + direction;
if (targetIndex >= 0 && targetIndex < currentItem.messages.length) {
const targetInnerMessage = currentItem.messages[targetIndex];
strictAssert(
targetInnerMessage,
'No message at targetIndex in items.messages'
);
targetMessage(targetInnerMessage.id, id);
event.preventDefault();
event.stopPropagation();
return;
}
}
const targetIndex = currentTargetIndex + direction;
if (targetIndex < 0 || targetIndex >= items.length) {
return;
}
const targetItem = items[targetIndex];
strictAssert(targetItem, 'Missing item at targetIndex');
if (targetItem.type === 'none') {
targetMessage(targetItem.id, id);
event.preventDefault();
event.stopPropagation();
return;
}
const targetInnerMessage =
direction === -1
? last(targetItem.messages)
: first(targetItem.messages);
strictAssert(targetInnerMessage, 'Expect to get first/last of target');
targetMessage(targetInnerMessage.id, id);
event.preventDefault();
event.stopPropagation();
return;
}
if (
targetedMessageId &&
!commandOrCtrl &&
(event.key === 'PageUp' || event.key === 'PageDown')
) {
if (!this.#containerRef.current) {
return;
}
const direction = event.key === 'PageUp' ? -1 : 1;
const currentTargetIndex = items.findIndex(
item =>
item.id === targetedMessageId ||
item.messages?.some(message => message.id === targetedMessageId)
);
if (currentTargetIndex < 0) {
return;
}
const currentItem = items[currentTargetIndex];
strictAssert(currentItem, 'No item at currentTargetIndex');
let startingEl = this.#containerRef.current.querySelector(
`[data-item-index='${currentTargetIndex}']`
);
if (currentItem.type !== 'none') {
const innerIndex = currentItem.messages.findIndex(
message => message.id === targetedMessageId
);
const message = currentItem.messages[innerIndex];
strictAssert(message, 'No message found at innerIndex');
startingEl = this.#containerRef.current.querySelector(
`[data-message-id='${message.id}']`
);
}
if (!startingEl) {
return;
}
const allMessageList =
this.#containerRef.current.querySelectorAll('[data-message-id]');
if (!allMessageList) {
return;
}
const allMessageEls = Array.from(allMessageList);
const startingIndex = allMessageEls.findIndex(el => el === startingEl);
if (startingIndex < 0) {
return;
}
const containerRect = this.#containerRef.current.getBoundingClientRect();
const startingRect = startingEl.getBoundingClientRect();
const targetTop = startingRect.y - containerRect.height;
const targetBottom = startingRect.y + containerRect.height;
let index = startingIndex + direction;
const max = allMessageEls.length;
while (index >= 0 && index < max) {
const currentEl = allMessageEls[index];
strictAssert(currentEl, 'No currentEl in allMessageEls at index');
const currentMessageId = currentEl.getAttribute('data-message-id');
strictAssert(currentMessageId, 'No data-message-id in currentEl');
const currentRect = currentEl.getBoundingClientRect();
const currentTop = currentRect.y;
if (direction === -1) {
if (currentTop <= targetTop || index === 0) {
targetMessage(currentMessageId, id);
event.preventDefault();
event.stopPropagation();
return;
}
} else {
const currentBottom = currentTop + currentRect.height;
if (currentBottom > targetBottom || index === max - 1) {
targetMessage(currentMessageId, id);
event.preventDefault();
event.stopPropagation();
return;
}
}
index += direction;
}
}
if (event.key === 'Home' || (commandOrCtrl && event.key === 'ArrowUp')) {
const firstMessageId = first(items);
if (firstMessageId) {
targetMessage(firstMessageId.id, id);
event.preventDefault();
event.stopPropagation();
}
return;
}
if (event.key === 'End' || (commandOrCtrl && event.key === 'ArrowDown')) {
this.#scrollDown(true);
event.preventDefault();
event.stopPropagation();
}
};
public override render(): React.JSX.Element | null {
const {
clearInvitedServiceIdsForNewlyCreatedGroup,
closeContactSpoofingReview,
conversationType,
hasContactSpoofingReview,
getPreferredBadge,
getTimestampForMessage,
haveNewest,
haveOldest,
i18n,
id,
invitedContactsForNewlyCreatedGroup,
isBlocked,
isConversationSelected,
isGroupV1AndDisabled,
items,
messageLoadingState,
oldestUnseenIndex,
renderContactSpoofingReviewDialog,
renderHeroRow,
renderItem,
renderTypingBubble,
scrollToOldestUnreadMention,
theme,
totalUnseen,
unreadCount,
unreadMentionsCount,
} = this.props;
const {
scrollLocked,
scrollLockHeight,
hasRecentlyScrolled,
newestBottomVisibleMessageId,
oldestPartiallyVisibleMessageId,
widthBreakpoint,
} = this.state;
// As a performance optimization, we don't need to render anything if this
// conversation isn't the active one.
if (!isConversationSelected) {
return null;
}
const isGroup = conversationType === 'group';
const areThereAnyMessages = items.length > 0;
const areAnyMessagesUnread = Boolean(unreadCount);
const lastItem = last(items);
const areAnyMessagesBelowCurrentPosition =
!haveNewest ||
Boolean(
newestBottomVisibleMessageId &&
newestBottomVisibleMessageId !== lastItem?.id
);
const areAboveScrollDownButtonThreshold =
!haveNewest ||
(newestBottomVisibleMessageId &&
!items
.slice(-SCROLL_DOWN_BUTTON_THRESHOLD)
.find(item => item.id === newestBottomVisibleMessageId));
const areUnreadBelowCurrentPosition = Boolean(
areThereAnyMessages &&
areAnyMessagesUnread &&
areAnyMessagesBelowCurrentPosition
);
const shouldShowScrollDownButtons = Boolean(
areThereAnyMessages &&
(areUnreadBelowCurrentPosition || areAboveScrollDownButtonThreshold)
);
let floatingHeader: ReactNode;
// It's possible that a message was removed from `items` but we still have its ID in
// state. `getTimestampForMessage` might return undefined in that case.
const oldestPartiallyVisibleMessageTimestamp =
oldestPartiallyVisibleMessageId
? getTimestampForMessage(oldestPartiallyVisibleMessageId)
: undefined;
if (
oldestPartiallyVisibleMessageId &&
oldestPartiallyVisibleMessageTimestamp
) {
const isLoadingMessages = Boolean(messageLoadingState);
floatingHeader = (
<TimelineFloatingHeader
i18n={i18n}
isLoading={isLoadingMessages}
timestamp={oldestPartiallyVisibleMessageTimestamp}
visible={
(hasRecentlyScrolled || isLoadingMessages) &&
(!haveOldest || oldestPartiallyVisibleMessageId !== items[0]?.id)
}
/>
);
}
const messageNodes: Array<ReactNode> = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
const previousItemIndex = itemIndex - 1;
const nextItemIndex = itemIndex + 1;
const previousItem: CollapseSet | undefined = items[previousItemIndex];
const nextItem: CollapseSet | undefined = items[nextItemIndex];
const item = items[itemIndex];
if (!item) {
assertDev(
false,
'<Timeline> iterated through items and got an empty message ID'
);
continue;
}
let unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
if (oldestUnseenIndex === itemIndex) {
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustAbove;
messageNodes.push(
<LastSeenIndicator
key="last seen indicator"
count={totalUnseen}
i18n={i18n}
ref={this.#lastSeenIndicatorRef}
/>
);
} else if (oldestUnseenIndex === nextItemIndex) {
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustBelow;
}
messageNodes.push(
<div
key={item.id}
className={
itemIndex === items.length - 1
? 'module-timeline__last-message'
: undefined
}
data-supertab={
oldestUnseenIndex === itemIndex ||
(!oldestUnseenIndex && itemIndex === items.length - 1)
}
data-item-index={itemIndex}
data-message-id={item.id}
role="listitem"
>
<ErrorBoundary i18n={i18n} showDebugLog={showDebugLog}>
{renderItem({
containerElementRef: this.#containerRef,
containerWidthBreakpoint: widthBreakpoint,
conversationId: id,
isBlocked,
interactivity: MessageInteractivity.Normal,
isGroup,
isOldestTimelineItem: haveOldest && itemIndex === 0,
item,
nextMessageId: nextItem?.id,
previousMessageId: previousItem?.id,
unreadIndicatorPlacement,
})}
</ErrorBoundary>
</div>
);
}
let contactSpoofingReviewDialog: ReactNode;
if (hasContactSpoofingReview) {
contactSpoofingReviewDialog = renderContactSpoofingReviewDialog({
conversationId: id,
onClose: closeContactSpoofingReview,
});
}
return (
<ScrollerLockContext.Provider value={this.#scrollerLock}>
<SizeObserver
onSizeChange={size => {
if (size.hidden) {
// triggered when timeline is hidden via display: none
return;
}
const { isNearBottom } = this.props;
this.setState({
widthBreakpoint: getWidthBreakpoint(size.width),
});
this.#maxVisibleRows = Math.ceil(size.height / MIN_ROW_HEIGHT);
const containerEl = this.#containerRef.current;
if (containerEl && isNearBottom) {
scrollToBottom(containerEl);
}
}}
>
{ref => (
<div
className={classNames(
'module-timeline',
isGroupV1AndDisabled ? 'module-timeline--disabled' : null,
`module-timeline--width-${widthBreakpoint}`
)}
role="presentation"
tabIndex={-1}
onBlur={this.#handleBlur}
onKeyDown={this.#handleKeyDown}
ref={ref}
>
{floatingHeader}
<main
className="module-timeline__messages__container"
onScroll={this.#onScroll}
ref={this.#containerRef}
>
<div
className={classNames(
'module-timeline__messages',
haveNewest && 'module-timeline__messages--have-newest',
haveOldest && 'module-timeline__messages--have-oldest',
scrollLocked && 'module-timeline__messages--scroll-locked'
)}
ref={this.#messagesRef}
role="list"
style={
scrollLockHeight
? { flexBasis: scrollLockHeight }
: undefined
}
>
{haveOldest && renderHeroRow(id)}
{messageNodes}
{haveNewest && renderTypingBubble(id)}
<div
className="module-timeline__messages__at-bottom-detector"
ref={this.#atBottomDetectorRef}
style={AT_BOTTOM_DETECTOR_STYLE}
/>
</div>
</main>
{shouldShowScrollDownButtons ? (
<div className="module-timeline__scrolldown-buttons">
{unreadMentionsCount ? (
<ScrollDownButton
variant={ScrollDownButtonVariant.UNREAD_MENTIONS}
count={unreadMentionsCount}
onClick={() => scrollToOldestUnreadMention(id)}
i18n={i18n}
/>
) : null}
<ScrollDownButton
variant={ScrollDownButtonVariant.UNREAD_MESSAGES}
count={areUnreadBelowCurrentPosition ? unreadCount : 0}
onClick={this.#onClickScrollDownButton}
i18n={i18n}
/>
</div>
) : null}
</div>
)}
</SizeObserver>
{Boolean(invitedContactsForNewlyCreatedGroup.length) && (
<NewlyCreatedGroupInvitedContactsDialog
contacts={invitedContactsForNewlyCreatedGroup}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
onClose={clearInvitedServiceIdsForNewlyCreatedGroup}
theme={theme}
/>
)}
{contactSpoofingReviewDialog}
</ScrollerLockContext.Provider>
);
}
}
function getMessageIdFromElement(
element: undefined | Element
): undefined | string {
return element instanceof HTMLElement ? element.dataset.messageId : undefined;
}
function getRowIndexFromElement(
element: undefined | Element
): undefined | number {
return element instanceof HTMLElement && element.dataset.itemIndex
? parseInt(element.dataset.itemIndex, 10)
: undefined;
}
function showDebugLog() {
window.IPC.showDebugLog();
}