mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-04-17 15:23:36 +01:00
1355 lines
43 KiB
TypeScript
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();
|
|
}
|