2022-01-26 23:05:26 +00:00
|
|
|
// Copyright 2019-2022 Signal Messenger, LLC
|
2020-10-30 20:34:04 +00:00
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
import { first, get, isNumber, last, pick, throttle } from 'lodash';
|
2020-12-01 16:42:35 +00:00
|
|
|
import classNames from 'classnames';
|
2022-02-10 18:59:09 +00:00
|
|
|
import type { ReactChild, ReactNode, RefObject } from 'react';
|
2022-03-08 20:05:05 +00:00
|
|
|
import React from 'react';
|
2021-08-18 13:34:22 +00:00
|
|
|
import { createSelector } from 'reselect';
|
2021-04-21 16:31:12 +00:00
|
|
|
import Measure from 'react-measure';
|
2019-03-20 17:42:28 +00:00
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
import { ScrollDownButton } from './ScrollDownButton';
|
|
|
|
|
2021-11-20 15:41:21 +00:00
|
|
|
import type { AssertProps, LocalizerType, ThemeType } from '../../types/Util';
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { ConversationType } from '../../state/ducks/conversations';
|
2021-11-20 15:41:21 +00:00
|
|
|
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
|
2022-03-03 20:23:10 +00:00
|
|
|
import { assert, strictAssert } from '../../util/assert';
|
2021-06-01 23:30:25 +00:00
|
|
|
import { missingCaseError } from '../../util/missingCaseError';
|
2022-02-25 18:37:15 +00:00
|
|
|
import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary';
|
2021-10-12 23:59:08 +00:00
|
|
|
import { WidthBreakpoint } from '../_util';
|
2019-03-20 17:42:28 +00:00
|
|
|
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { PropsActions as MessageActionsType } from './Message';
|
|
|
|
import type { PropsActions as UnsupportedMessageActionsType } from './UnsupportedMessage';
|
|
|
|
import type { PropsActionsType as ChatSessionRefreshedNotificationActionsType } from './ChatSessionRefreshedNotification';
|
2021-08-02 20:55:47 +00:00
|
|
|
import { ErrorBoundary } from './ErrorBoundary';
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification';
|
2021-04-21 16:31:12 +00:00
|
|
|
import { Intl } from '../Intl';
|
|
|
|
import { TimelineWarning } from './TimelineWarning';
|
|
|
|
import { TimelineWarnings } from './TimelineWarnings';
|
2021-03-03 20:09:58 +00:00
|
|
|
import { NewlyCreatedGroupInvitedContactsDialog } from '../NewlyCreatedGroupInvitedContactsDialog';
|
2021-06-01 23:30:25 +00:00
|
|
|
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
2021-04-21 16:31:12 +00:00
|
|
|
import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
|
|
|
import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions';
|
2022-01-26 23:05:26 +00:00
|
|
|
import { TimelineFloatingHeader } from './TimelineFloatingHeader';
|
2022-03-08 14:32:42 +00:00
|
|
|
import {
|
|
|
|
getWidthBreakpoint,
|
|
|
|
UnreadIndicatorPlacement,
|
|
|
|
} from '../../util/timelineUtil';
|
2022-01-27 20:10:24 +00:00
|
|
|
import {
|
2022-03-03 20:23:10 +00:00
|
|
|
getScrollBottom,
|
|
|
|
scrollToBottom,
|
|
|
|
setScrollBottom,
|
|
|
|
} from '../../util/scrollUtil';
|
2022-03-08 20:05:05 +00:00
|
|
|
import { LastSeenIndicator } from './LastSeenIndicator';
|
2022-03-03 20:23:10 +00:00
|
|
|
|
2019-09-16 18:56:53 +00:00
|
|
|
const AT_BOTTOM_THRESHOLD = 15;
|
2022-03-03 20:23:10 +00:00
|
|
|
const MIN_ROW_HEIGHT = 18;
|
2019-05-31 22:42:01 +00:00
|
|
|
const SCROLL_DOWN_BUTTON_THRESHOLD = 8;
|
2019-03-20 17:42:28 +00:00
|
|
|
|
2021-06-01 23:30:25 +00:00
|
|
|
export type WarningType =
|
|
|
|
| {
|
|
|
|
type: ContactSpoofingType.DirectConversationWithSameTitle;
|
|
|
|
safeConversation: ConversationType;
|
|
|
|
}
|
|
|
|
| {
|
|
|
|
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle;
|
|
|
|
acknowledgedGroupNameCollisions: GroupNameCollisionsWithIdsByTitle;
|
|
|
|
groupNameCollisions: GroupNameCollisionsWithIdsByTitle;
|
|
|
|
};
|
|
|
|
|
|
|
|
export type ContactSpoofingReviewPropType =
|
|
|
|
| {
|
|
|
|
type: ContactSpoofingType.DirectConversationWithSameTitle;
|
|
|
|
possiblyUnsafeConversation: ConversationType;
|
|
|
|
safeConversation: ConversationType;
|
|
|
|
}
|
|
|
|
| {
|
|
|
|
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle;
|
|
|
|
collisionInfoByTitle: Record<
|
|
|
|
string,
|
|
|
|
Array<{
|
|
|
|
oldName?: string;
|
|
|
|
conversation: ConversationType;
|
|
|
|
}>
|
|
|
|
>;
|
|
|
|
};
|
2021-04-21 16:31:12 +00:00
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
export type PropsDataType = {
|
|
|
|
haveNewest: boolean;
|
|
|
|
haveOldest: boolean;
|
|
|
|
isLoadingMessages: boolean;
|
2021-11-30 11:25:24 +00:00
|
|
|
isNearBottom?: boolean;
|
2021-09-13 02:36:41 +00:00
|
|
|
items: ReadonlyArray<string>;
|
2021-11-30 11:25:24 +00:00
|
|
|
oldestUnreadIndex?: number;
|
|
|
|
scrollToIndex?: number;
|
2019-05-31 22:42:01 +00:00
|
|
|
scrollToIndexCounter: number;
|
|
|
|
totalUnread: number;
|
2019-03-20 17:42:28 +00:00
|
|
|
};
|
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
type PropsHousekeepingType = {
|
|
|
|
id: string;
|
2021-06-01 23:30:25 +00:00
|
|
|
areWeAdmin?: boolean;
|
2022-03-03 20:23:10 +00:00
|
|
|
isConversationSelected: boolean;
|
2020-12-01 16:42:35 +00:00
|
|
|
isGroupV1AndDisabled?: boolean;
|
2021-04-30 22:59:37 +00:00
|
|
|
isIncomingMessageRequest: boolean;
|
2021-11-15 20:01:58 +00:00
|
|
|
typingContactId?: string;
|
2021-04-30 22:59:37 +00:00
|
|
|
unreadCount?: number;
|
2020-12-01 16:42:35 +00:00
|
|
|
|
2019-11-07 21:36:16 +00:00
|
|
|
selectedMessageId?: string;
|
2021-03-03 20:09:58 +00:00
|
|
|
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2021-04-21 16:31:12 +00:00
|
|
|
warning?: WarningType;
|
2021-06-01 23:30:25 +00:00
|
|
|
contactSpoofingReview?: ContactSpoofingReviewPropType;
|
2021-04-21 16:31:12 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
discardMessages: (
|
|
|
|
_: Readonly<{ conversationId: string; numberToKeepAtBottom: number }>
|
|
|
|
) => void;
|
2022-02-07 18:54:15 +00:00
|
|
|
getTimestampForMessage: (messageId: string) => undefined | number;
|
2021-11-20 15:41:21 +00:00
|
|
|
getPreferredBadge: PreferredBadgeSelectorType;
|
2019-03-20 17:42:28 +00:00
|
|
|
i18n: LocalizerType;
|
2021-11-20 15:41:21 +00:00
|
|
|
theme: ThemeType;
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2021-09-10 23:59:41 +00:00
|
|
|
renderItem: (props: {
|
2021-09-13 02:36:41 +00:00
|
|
|
actionProps: PropsActionsType;
|
2021-09-10 23:59:41 +00:00
|
|
|
containerElementRef: RefObject<HTMLElement>;
|
2021-10-12 23:59:08 +00:00
|
|
|
containerWidthBreakpoint: WidthBreakpoint;
|
2021-09-10 23:59:41 +00:00
|
|
|
conversationId: string;
|
2022-01-26 23:05:26 +00:00
|
|
|
isOldestTimelineItem: boolean;
|
2021-09-10 23:59:41 +00:00
|
|
|
messageId: string;
|
|
|
|
nextMessageId: undefined | string;
|
|
|
|
previousMessageId: undefined | string;
|
2022-03-08 14:32:42 +00:00
|
|
|
unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
|
2021-09-10 23:59:41 +00:00
|
|
|
}) => JSX.Element;
|
2020-08-07 00:50:54 +00:00
|
|
|
renderHeroRow: (
|
|
|
|
id: string,
|
2021-04-30 19:40:25 +00:00
|
|
|
unblurAvatar: () => void,
|
2020-08-07 00:50:54 +00:00
|
|
|
updateSharedGroups: () => unknown
|
|
|
|
) => JSX.Element;
|
2019-05-31 22:42:01 +00:00
|
|
|
renderTypingBubble: (id: string) => JSX.Element;
|
2019-03-20 17:42:28 +00:00
|
|
|
};
|
|
|
|
|
2021-08-11 16:23:21 +00:00
|
|
|
export type PropsActionsType = {
|
2021-06-01 23:30:25 +00:00
|
|
|
acknowledgeGroupMemberNameCollisions: (
|
|
|
|
groupNameCollisions: Readonly<GroupNameCollisionsWithIdsByTitle>
|
|
|
|
) => void;
|
2021-10-26 22:59:08 +00:00
|
|
|
clearInvitedUuidsForNewlyCreatedGroup: () => void;
|
2021-04-21 16:31:12 +00:00
|
|
|
closeContactSpoofingReview: () => void;
|
2019-05-31 22:42:01 +00:00
|
|
|
setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown;
|
2021-06-01 23:30:25 +00:00
|
|
|
reviewGroupMemberNameCollision: (groupConversationId: string) => void;
|
2021-04-21 16:31:12 +00:00
|
|
|
reviewMessageRequestNameCollision: (
|
|
|
|
_: Readonly<{
|
|
|
|
safeConversationId: string;
|
|
|
|
}>
|
|
|
|
) => void;
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2021-07-30 20:30:59 +00:00
|
|
|
learnMoreAboutDeliveryIssue: () => unknown;
|
2019-05-31 22:42:01 +00:00
|
|
|
loadAndScroll: (messageId: string) => unknown;
|
|
|
|
loadOlderMessages: (messageId: string) => unknown;
|
|
|
|
loadNewerMessages: (messageId: string) => unknown;
|
2019-11-07 21:36:16 +00:00
|
|
|
loadNewestMessages: (messageId: string, setFocus?: boolean) => unknown;
|
2019-09-19 22:16:46 +00:00
|
|
|
markMessageRead: (messageId: string) => unknown;
|
2021-06-01 23:30:25 +00:00
|
|
|
onBlock: (conversationId: string) => unknown;
|
|
|
|
onBlockAndReportSpam: (conversationId: string) => unknown;
|
|
|
|
onDelete: (conversationId: string) => unknown;
|
|
|
|
onUnblock: (conversationId: string) => unknown;
|
2022-02-08 19:18:51 +00:00
|
|
|
peekGroupCallForTheFirstTime: (conversationId: string) => unknown;
|
2021-06-01 23:30:25 +00:00
|
|
|
removeMember: (conversationId: string) => unknown;
|
2019-11-07 21:36:16 +00:00
|
|
|
selectMessage: (messageId: string, conversationId: string) => unknown;
|
|
|
|
clearSelectedMessage: () => unknown;
|
2021-04-30 19:40:25 +00:00
|
|
|
unblurAvatar: () => void;
|
2020-08-07 00:50:54 +00:00
|
|
|
updateSharedGroups: () => unknown;
|
2022-03-03 20:23:10 +00:00
|
|
|
} & MessageActionsType &
|
2021-08-11 16:23:21 +00:00
|
|
|
SafetyNumberActionsType &
|
|
|
|
UnsupportedMessageActionsType &
|
|
|
|
ChatSessionRefreshedNotificationActionsType;
|
2019-03-20 17:42:28 +00:00
|
|
|
|
2020-12-01 16:42:35 +00:00
|
|
|
export type PropsType = PropsDataType &
|
|
|
|
PropsHousekeepingType &
|
|
|
|
PropsActionsType;
|
2019-03-20 17:42:28 +00:00
|
|
|
|
2020-12-01 16:42:35 +00:00
|
|
|
type StateType = {
|
2021-06-01 23:30:25 +00:00
|
|
|
hasDismissedDirectContactSpoofingWarning: boolean;
|
2022-03-03 20:23:10 +00:00
|
|
|
hasRecentlyScrolled: boolean;
|
|
|
|
newestFullyVisibleMessageId?: string;
|
|
|
|
oldestPartiallyVisibleMessageId?: string;
|
|
|
|
widthBreakpoint: WidthBreakpoint;
|
2019-05-31 22:42:01 +00:00
|
|
|
};
|
|
|
|
|
2022-03-08 20:05:05 +00:00
|
|
|
const scrollToUnreadIndicator = Symbol('scrollToUnreadIndicator');
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
type SnapshotType =
|
|
|
|
| null
|
2022-03-08 20:05:05 +00:00
|
|
|
| typeof scrollToUnreadIndicator
|
2022-03-03 20:23:10 +00:00
|
|
|
| { scrollToIndex: number }
|
|
|
|
| { scrollTop: number }
|
|
|
|
| { scrollBottom: number };
|
|
|
|
|
2021-08-18 13:34:22 +00:00
|
|
|
const getActions = createSelector(
|
|
|
|
// It is expensive to pick so many properties out of the `props` object so we
|
|
|
|
// use `createSelector` to memoize them by the last seen `props` object.
|
2021-11-12 22:37:44 +00:00
|
|
|
(props: PropsType) => props,
|
2021-08-18 13:34:22 +00:00
|
|
|
|
|
|
|
(props: PropsType): PropsActionsType => {
|
|
|
|
const unsafe = pick(props, [
|
|
|
|
'acknowledgeGroupMemberNameCollisions',
|
2021-10-26 22:59:08 +00:00
|
|
|
'clearInvitedUuidsForNewlyCreatedGroup',
|
2021-08-18 13:34:22 +00:00
|
|
|
'closeContactSpoofingReview',
|
|
|
|
'setIsNearBottom',
|
|
|
|
'reviewGroupMemberNameCollision',
|
|
|
|
'reviewMessageRequestNameCollision',
|
|
|
|
'learnMoreAboutDeliveryIssue',
|
|
|
|
'loadAndScroll',
|
|
|
|
'loadOlderMessages',
|
|
|
|
'loadNewerMessages',
|
|
|
|
'loadNewestMessages',
|
|
|
|
'markMessageRead',
|
|
|
|
'markViewed',
|
|
|
|
'onBlock',
|
|
|
|
'onBlockAndReportSpam',
|
|
|
|
'onDelete',
|
|
|
|
'onUnblock',
|
2022-02-08 19:18:51 +00:00
|
|
|
'peekGroupCallForTheFirstTime',
|
2021-08-18 13:34:22 +00:00
|
|
|
'removeMember',
|
|
|
|
'selectMessage',
|
|
|
|
'clearSelectedMessage',
|
|
|
|
'unblurAvatar',
|
|
|
|
'updateSharedGroups',
|
|
|
|
|
|
|
|
'doubleCheckMissingQuoteReference',
|
|
|
|
'checkForAccount',
|
|
|
|
'reactToMessage',
|
|
|
|
'replyToMessage',
|
2022-03-04 19:22:31 +00:00
|
|
|
'retryDeleteForEveryone',
|
2021-08-18 13:34:22 +00:00
|
|
|
'retrySend',
|
|
|
|
'showForwardMessageModal',
|
|
|
|
'deleteMessage',
|
|
|
|
'deleteMessageForEveryone',
|
|
|
|
'showMessageDetail',
|
|
|
|
'openConversation',
|
|
|
|
'showContactDetail',
|
|
|
|
'showContactModal',
|
|
|
|
'kickOffAttachmentDownload',
|
|
|
|
'markAttachmentAsCorrupted',
|
2021-11-11 23:45:47 +00:00
|
|
|
'messageExpanded',
|
2021-08-18 13:34:22 +00:00
|
|
|
'showVisualAttachment',
|
|
|
|
'downloadAttachment',
|
|
|
|
'displayTapToViewMessage',
|
|
|
|
'openLink',
|
|
|
|
'scrollToQuotedMessage',
|
|
|
|
'showExpiredIncomingTapToViewToast',
|
|
|
|
'showExpiredOutgoingTapToViewToast',
|
|
|
|
|
|
|
|
'showIdentity',
|
|
|
|
|
|
|
|
'downloadNewVersion',
|
|
|
|
|
|
|
|
'contactSupport',
|
|
|
|
]);
|
|
|
|
|
|
|
|
const safe: AssertProps<PropsActionsType, typeof unsafe> = unsafe;
|
|
|
|
|
|
|
|
return safe;
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
export class Timeline extends React.Component<
|
|
|
|
PropsType,
|
|
|
|
StateType,
|
|
|
|
SnapshotType
|
|
|
|
> {
|
2021-08-20 19:36:27 +00:00
|
|
|
private readonly containerRef = React.createRef<HTMLDivElement>();
|
2022-03-03 20:23:10 +00:00
|
|
|
private readonly messagesRef = React.createRef<HTMLDivElement>();
|
2022-03-08 20:05:05 +00:00
|
|
|
private readonly lastSeenIndicatorRef = React.createRef<HTMLDivElement>();
|
2022-03-03 20:23:10 +00:00
|
|
|
private intersectionObserver?: IntersectionObserver;
|
2021-08-20 19:36:27 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
// This is a best guess. It will likely be overridden when the timeline is measured.
|
|
|
|
private maxVisibleRows = Math.ceil(window.innerHeight / MIN_ROW_HEIGHT);
|
2020-09-14 19:51:27 +00:00
|
|
|
|
2022-01-26 23:05:26 +00:00
|
|
|
private hasRecentlyScrolledTimeout?: NodeJS.Timeout;
|
2022-02-08 19:18:51 +00:00
|
|
|
private delayedPeekTimeout?: NodeJS.Timeout;
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
override state: StateType = {
|
|
|
|
hasRecentlyScrolled: true,
|
|
|
|
hasDismissedDirectContactSpoofingWarning: false,
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-09 18:47:13 +00:00
|
|
|
// This may be swiftly overridden.
|
2022-03-03 20:23:10 +00:00
|
|
|
widthBreakpoint: WidthBreakpoint.Wide,
|
2019-05-31 22:42:01 +00:00
|
|
|
};
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
private onScroll = (): void => {
|
|
|
|
const { id, setIsNearBottom } = this.props;
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
setIsNearBottom(id, this.isAtBottom());
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
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);
|
2019-05-31 22:42:01 +00:00
|
|
|
};
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
private scrollToItemIndex(itemIndex: number): void {
|
|
|
|
this.messagesRef.current
|
|
|
|
?.querySelector(`[data-item-index="${itemIndex}"]`)
|
|
|
|
?.scrollIntoViewIfNeeded();
|
|
|
|
}
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
private scrollToBottom = (setFocus?: boolean): void => {
|
|
|
|
const { selectMessage, id, items } = this.props;
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
if (setFocus && items && items.length > 0) {
|
|
|
|
const lastIndex = items.length - 1;
|
|
|
|
const lastMessageId = items[lastIndex];
|
|
|
|
selectMessage(lastMessageId, id);
|
2019-08-23 19:56:49 +00:00
|
|
|
} else {
|
2022-03-03 20:23:10 +00:00
|
|
|
const containerEl = this.containerRef.current;
|
|
|
|
if (containerEl) {
|
|
|
|
scrollToBottom(containerEl);
|
|
|
|
}
|
2019-08-23 19:56:49 +00:00
|
|
|
}
|
2019-03-20 17:42:28 +00:00
|
|
|
};
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
private onClickScrollDownButton = (): void => {
|
|
|
|
this.scrollDown(false);
|
2020-05-27 21:37:06 +00:00
|
|
|
};
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
private scrollDown = (setFocus?: boolean): void => {
|
|
|
|
const {
|
|
|
|
haveNewest,
|
|
|
|
id,
|
|
|
|
isLoadingMessages,
|
|
|
|
items,
|
|
|
|
loadNewestMessages,
|
|
|
|
oldestUnreadIndex,
|
|
|
|
selectMessage,
|
|
|
|
} = this.props;
|
|
|
|
const { newestFullyVisibleMessageId } = this.state;
|
2021-06-17 17:15:10 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
if (!items || items.length < 1) {
|
2021-06-17 17:15:10 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
if (isLoadingMessages) {
|
|
|
|
this.scrollToBottom(setFocus);
|
2021-06-17 17:15:10 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
if (
|
2022-03-03 20:23:10 +00:00
|
|
|
newestFullyVisibleMessageId &&
|
|
|
|
isNumber(oldestUnreadIndex) &&
|
|
|
|
items.findIndex(item => item === newestFullyVisibleMessageId) <
|
|
|
|
oldestUnreadIndex
|
2019-05-31 22:42:01 +00:00
|
|
|
) {
|
2022-03-03 20:23:10 +00:00
|
|
|
if (setFocus) {
|
|
|
|
const messageId = items[oldestUnreadIndex];
|
|
|
|
selectMessage(messageId, id);
|
|
|
|
} else {
|
|
|
|
this.scrollToItemIndex(oldestUnreadIndex);
|
2019-05-31 22:42:01 +00:00
|
|
|
}
|
2022-03-03 20:23:10 +00:00
|
|
|
} else if (haveNewest) {
|
|
|
|
this.scrollToBottom(setFocus);
|
|
|
|
} else {
|
|
|
|
const lastId = last(items);
|
|
|
|
if (lastId) {
|
|
|
|
loadNewestMessages(lastId, setFocus);
|
2019-09-03 20:06:17 +00:00
|
|
|
}
|
2019-05-31 22:42:01 +00:00
|
|
|
}
|
2022-03-03 20:23:10 +00:00
|
|
|
};
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
private isAtBottom(): boolean {
|
|
|
|
const containerEl = this.containerRef.current;
|
|
|
|
return Boolean(
|
|
|
|
containerEl && getScrollBottom(containerEl) <= AT_BOTTOM_THRESHOLD
|
|
|
|
);
|
|
|
|
}
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
/**
|
|
|
|
* 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 {
|
|
|
|
const containerEl = this.containerRef.current;
|
|
|
|
const messagesEl = this.messagesRef.current;
|
|
|
|
if (!containerEl || !messagesEl) {
|
2019-05-31 22:42:01 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
const {
|
|
|
|
haveNewest,
|
|
|
|
haveOldest,
|
|
|
|
isLoadingMessages,
|
|
|
|
items,
|
|
|
|
loadNewerMessages,
|
|
|
|
loadOlderMessages,
|
|
|
|
} = this.props;
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
this.intersectionObserver?.disconnect();
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
// Keys are message IDs. Values are intersection ratios.
|
|
|
|
const visibleMessages = new Map<string, number>();
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
const intersectionObserverCallback: IntersectionObserverCallback =
|
|
|
|
entries => {
|
|
|
|
entries.forEach(entry => {
|
|
|
|
const { intersectionRatio, target } = entry;
|
|
|
|
const {
|
|
|
|
dataset: { messageId },
|
|
|
|
} = target as HTMLElement;
|
|
|
|
if (!messageId) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
visibleMessages.set(messageId, intersectionRatio);
|
|
|
|
});
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
let oldestPartiallyVisibleMessageId: undefined | string;
|
|
|
|
let newestFullyVisibleMessageId: undefined | string;
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
for (const [messageId, intersectionRatio] of visibleMessages) {
|
|
|
|
if (intersectionRatio > 0 && !oldestPartiallyVisibleMessageId) {
|
|
|
|
oldestPartiallyVisibleMessageId = messageId;
|
|
|
|
}
|
|
|
|
if (intersectionRatio >= 1) {
|
|
|
|
newestFullyVisibleMessageId = messageId;
|
|
|
|
}
|
|
|
|
}
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
this.setState({
|
|
|
|
oldestPartiallyVisibleMessageId,
|
|
|
|
newestFullyVisibleMessageId,
|
|
|
|
});
|
2022-01-26 23:05:26 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
if (newestFullyVisibleMessageId) {
|
|
|
|
this.markNewestFullyVisibleMessageRead();
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
if (
|
|
|
|
!isLoadingMessages &&
|
|
|
|
!haveNewest &&
|
|
|
|
newestFullyVisibleMessageId === last(items)
|
|
|
|
) {
|
|
|
|
loadNewerMessages(newestFullyVisibleMessageId);
|
|
|
|
}
|
|
|
|
}
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
if (
|
|
|
|
!isLoadingMessages &&
|
|
|
|
!haveOldest &&
|
|
|
|
oldestPartiallyVisibleMessageId &&
|
|
|
|
oldestPartiallyVisibleMessageId === items[0]
|
|
|
|
) {
|
|
|
|
loadOlderMessages(oldestPartiallyVisibleMessageId);
|
|
|
|
}
|
2022-01-26 23:05:26 +00:00
|
|
|
};
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
this.intersectionObserver = new IntersectionObserver(
|
|
|
|
intersectionObserverCallback,
|
|
|
|
{
|
|
|
|
root: containerEl,
|
|
|
|
threshold: [0, 1],
|
2019-05-31 22:42:01 +00:00
|
|
|
}
|
2022-03-03 20:23:10 +00:00
|
|
|
);
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
for (const child of messagesEl.children) {
|
|
|
|
if ((child as HTMLElement).dataset.messageId) {
|
|
|
|
this.intersectionObserver.observe(child);
|
2019-05-31 22:42:01 +00:00
|
|
|
}
|
2022-03-03 20:23:10 +00:00
|
|
|
}
|
|
|
|
}
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
private markNewestFullyVisibleMessageRead = throttle(
|
|
|
|
(): void => {
|
|
|
|
const { markMessageRead } = this.props;
|
|
|
|
const { newestFullyVisibleMessageId } = this.state;
|
|
|
|
if (newestFullyVisibleMessageId) {
|
|
|
|
markMessageRead(newestFullyVisibleMessageId);
|
2021-05-25 18:34:34 +00:00
|
|
|
}
|
2019-05-31 22:42:01 +00:00
|
|
|
},
|
|
|
|
500,
|
2022-03-03 20:23:10 +00:00
|
|
|
{ leading: false }
|
2019-05-31 22:42:01 +00:00
|
|
|
);
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
public override componentDidMount(): void {
|
|
|
|
const containerEl = this.containerRef.current;
|
|
|
|
const messagesEl = this.messagesRef.current;
|
|
|
|
strictAssert(
|
|
|
|
containerEl && messagesEl,
|
|
|
|
'<Timeline> mounted without some refs'
|
2019-03-20 17:42:28 +00:00
|
|
|
);
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
this.updateIntersectionObserver();
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
window.registerForActive(this.markNewestFullyVisibleMessageRead);
|
2022-02-08 19:18:51 +00:00
|
|
|
|
|
|
|
this.delayedPeekTimeout = setTimeout(() => {
|
|
|
|
const { id, peekGroupCallForTheFirstTime } = this.props;
|
|
|
|
peekGroupCallForTheFirstTime(id);
|
|
|
|
}, 500);
|
2019-08-07 00:40:25 +00:00
|
|
|
}
|
|
|
|
|
2021-11-12 23:44:20 +00:00
|
|
|
public override componentWillUnmount(): void {
|
2022-03-08 19:11:11 +00:00
|
|
|
const { delayedPeekTimeout } = this;
|
2022-02-08 19:18:51 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
window.unregisterForActive(this.markNewestFullyVisibleMessageRead);
|
|
|
|
|
|
|
|
this.intersectionObserver?.disconnect();
|
2022-02-08 19:18:51 +00:00
|
|
|
|
2022-02-25 18:37:15 +00:00
|
|
|
clearTimeoutIfNecessary(delayedPeekTimeout);
|
2019-08-07 00:40:25 +00:00
|
|
|
}
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
public override getSnapshotBeforeUpdate(
|
|
|
|
prevProps: Readonly<PropsType>
|
|
|
|
): SnapshotType {
|
|
|
|
const containerEl = this.containerRef.current;
|
|
|
|
if (!containerEl) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const {
|
|
|
|
isLoadingMessages: wasLoadingMessages,
|
|
|
|
items: oldItems,
|
|
|
|
scrollToIndexCounter: oldScrollToIndexCounter,
|
|
|
|
typingContactId: oldTypingContactId,
|
|
|
|
} = prevProps;
|
2019-05-31 22:42:01 +00:00
|
|
|
const {
|
2021-04-30 22:59:37 +00:00
|
|
|
isIncomingMessageRequest,
|
2022-03-03 20:23:10 +00:00
|
|
|
isLoadingMessages,
|
|
|
|
items: newItems,
|
2022-03-08 20:05:05 +00:00
|
|
|
oldestUnreadIndex,
|
2019-05-31 22:42:01 +00:00
|
|
|
scrollToIndex,
|
2022-03-03 20:23:10 +00:00
|
|
|
scrollToIndexCounter: newScrollToIndexCounter,
|
2021-11-15 20:01:58 +00:00
|
|
|
typingContactId,
|
2019-05-31 22:42:01 +00:00
|
|
|
} = this.props;
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
const isDoingInitialLoad = isLoadingMessages && newItems.length === 0;
|
|
|
|
const wasDoingInitialLoad = wasLoadingMessages && oldItems.length === 0;
|
|
|
|
const justFinishedInitialLoad = wasDoingInitialLoad && !isDoingInitialLoad;
|
2021-11-03 02:00:54 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
if (isDoingInitialLoad) {
|
|
|
|
return null;
|
|
|
|
}
|
2019-08-23 19:56:49 +00:00
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
if (
|
2022-03-03 20:23:10 +00:00
|
|
|
isNumber(scrollToIndex) &&
|
|
|
|
(oldScrollToIndexCounter !== newScrollToIndexCounter ||
|
|
|
|
justFinishedInitialLoad)
|
2019-05-31 22:42:01 +00:00
|
|
|
) {
|
2022-03-03 20:23:10 +00:00
|
|
|
return { scrollToIndex };
|
2019-08-23 19:56:49 +00:00
|
|
|
}
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
if (justFinishedInitialLoad) {
|
2022-03-08 20:05:05 +00:00
|
|
|
if (isIncomingMessageRequest) {
|
|
|
|
return { scrollTop: 0 };
|
|
|
|
}
|
|
|
|
if (isNumber(oldestUnreadIndex)) {
|
|
|
|
return scrollToUnreadIndicator;
|
|
|
|
}
|
|
|
|
return { scrollBottom: 0 };
|
2021-11-02 23:42:35 +00:00
|
|
|
}
|
|
|
|
|
2019-08-23 19:56:49 +00:00
|
|
|
if (
|
2022-03-03 20:23:10 +00:00
|
|
|
Boolean(typingContactId) !== Boolean(oldTypingContactId) &&
|
|
|
|
this.isAtBottom()
|
2019-05-31 22:42:01 +00:00
|
|
|
) {
|
2022-03-03 20:23:10 +00:00
|
|
|
return { scrollBottom: 0 };
|
2019-08-23 19:56:49 +00:00
|
|
|
}
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
// This method assumes that item operations happen one at a time. For example, items
|
|
|
|
// are not added and removed in the same render pass.
|
|
|
|
if (oldItems.length === newItems.length) {
|
|
|
|
return null;
|
2019-08-23 19:56:49 +00:00
|
|
|
}
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
let scrollAnchor: 'top' | 'bottom';
|
|
|
|
if (this.isAtBottom()) {
|
|
|
|
const justLoadedAPage = wasLoadingMessages && !isLoadingMessages;
|
|
|
|
scrollAnchor = justLoadedAPage ? 'top' : 'bottom';
|
|
|
|
} else {
|
|
|
|
scrollAnchor = last(oldItems) !== last(newItems) ? 'top' : 'bottom';
|
2019-08-23 19:56:49 +00:00
|
|
|
}
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
return scrollAnchor === 'top'
|
|
|
|
? { scrollTop: containerEl.scrollTop }
|
|
|
|
: { scrollBottom: getScrollBottom(containerEl) };
|
2019-05-31 22:42:01 +00:00
|
|
|
}
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
public override componentDidUpdate(
|
|
|
|
prevProps: Readonly<PropsType>,
|
|
|
|
_prevState: Readonly<StateType>,
|
|
|
|
snapshot: Readonly<SnapshotType>
|
|
|
|
): void {
|
|
|
|
const { items: oldItems } = prevProps;
|
|
|
|
const { discardMessages, id, items: newItems } = this.props;
|
|
|
|
|
|
|
|
const containerEl = this.containerRef.current;
|
|
|
|
if (containerEl && snapshot) {
|
2022-03-08 20:05:05 +00:00
|
|
|
if (snapshot === scrollToUnreadIndicator) {
|
|
|
|
const lastSeenIndicatorEl = this.lastSeenIndicatorRef.current;
|
|
|
|
if (lastSeenIndicatorEl) {
|
|
|
|
lastSeenIndicatorEl.scrollIntoView();
|
|
|
|
} else {
|
|
|
|
scrollToBottom(containerEl);
|
|
|
|
assert(
|
|
|
|
false,
|
|
|
|
'<Timeline> expected a last seen indicator but it was not found'
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} else if ('scrollToIndex' in snapshot) {
|
2022-03-03 20:23:10 +00:00
|
|
|
this.scrollToItemIndex(snapshot.scrollToIndex);
|
|
|
|
} else if ('scrollTop' in snapshot) {
|
|
|
|
containerEl.scrollTop = snapshot.scrollTop;
|
|
|
|
} else {
|
|
|
|
setScrollBottom(containerEl, snapshot.scrollBottom);
|
|
|
|
}
|
2019-05-31 22:42:01 +00:00
|
|
|
}
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
if (oldItems.length !== newItems.length) {
|
|
|
|
this.updateIntersectionObserver();
|
|
|
|
|
|
|
|
// This condition is somewhat arbitrary.
|
|
|
|
const shouldDiscardOlderMessages: boolean =
|
|
|
|
this.isAtBottom() && newItems.length >= this.maxVisibleRows * 1.5;
|
|
|
|
if (shouldDiscardOlderMessages) {
|
|
|
|
discardMessages({
|
|
|
|
conversationId: id,
|
|
|
|
numberToKeepAtBottom: this.maxVisibleRows,
|
|
|
|
});
|
|
|
|
}
|
2019-05-31 22:42:01 +00:00
|
|
|
}
|
2022-03-03 20:23:10 +00:00
|
|
|
}
|
2019-05-31 22:42:01 +00:00
|
|
|
|
2022-01-26 23:05:26 +00:00
|
|
|
private handleBlur = (event: React.FocusEvent): void => {
|
2019-11-07 21:36:16 +00:00
|
|
|
const { clearSelectedMessage } = this.props;
|
|
|
|
|
|
|
|
const { currentTarget } = event;
|
|
|
|
|
|
|
|
// Thanks to https://gist.github.com/pstoica/4323d3e6e37e8a23dd59
|
|
|
|
setTimeout(() => {
|
2020-01-17 22:23:19 +00:00
|
|
|
// If focus moved to one of our portals, we do not clear the selected
|
|
|
|
// message so that focus stays inside the portal. We need to be careful
|
|
|
|
// to not create colliding keyboard shortcuts between selected messages
|
|
|
|
// and our portals!
|
|
|
|
const portals = Array.from(
|
|
|
|
document.querySelectorAll('body > div:not(.inbox)')
|
|
|
|
);
|
|
|
|
if (portals.some(el => el.contains(document.activeElement))) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2019-11-07 21:36:16 +00:00
|
|
|
if (!currentTarget.contains(document.activeElement)) {
|
|
|
|
clearSelectedMessage();
|
|
|
|
}
|
|
|
|
}, 0);
|
|
|
|
};
|
|
|
|
|
2022-01-26 23:05:26 +00:00
|
|
|
private handleKeyDown = (
|
|
|
|
event: React.KeyboardEvent<HTMLDivElement>
|
|
|
|
): void => {
|
2019-11-07 21:36:16 +00:00
|
|
|
const { selectMessage, selectedMessageId, items, id } = this.props;
|
2019-12-17 18:52:36 +00:00
|
|
|
const commandKey = get(window, 'platform') === 'darwin' && event.metaKey;
|
|
|
|
const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey;
|
|
|
|
const commandOrCtrl = commandKey || controlKey;
|
2019-11-07 21:36:16 +00:00
|
|
|
|
|
|
|
if (!items || items.length < 1) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (selectedMessageId && !commandOrCtrl && event.key === 'ArrowUp') {
|
|
|
|
const selectedMessageIndex = items.findIndex(
|
|
|
|
item => item === selectedMessageId
|
|
|
|
);
|
|
|
|
if (selectedMessageIndex < 0) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const targetIndex = selectedMessageIndex - 1;
|
|
|
|
if (targetIndex < 0) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const messageId = items[targetIndex];
|
|
|
|
selectMessage(messageId, id);
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (selectedMessageId && !commandOrCtrl && event.key === 'ArrowDown') {
|
|
|
|
const selectedMessageIndex = items.findIndex(
|
|
|
|
item => item === selectedMessageId
|
|
|
|
);
|
|
|
|
if (selectedMessageIndex < 0) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const targetIndex = selectedMessageIndex + 1;
|
|
|
|
if (targetIndex >= items.length) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const messageId = items[targetIndex];
|
|
|
|
selectMessage(messageId, id);
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (commandOrCtrl && event.key === 'ArrowUp') {
|
2022-03-03 20:23:10 +00:00
|
|
|
const firstMessageId = first(items);
|
|
|
|
if (firstMessageId) {
|
|
|
|
selectMessage(firstMessageId, id);
|
|
|
|
event.preventDefault();
|
|
|
|
event.stopPropagation();
|
|
|
|
}
|
2019-11-07 21:36:16 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (commandOrCtrl && event.key === 'ArrowDown') {
|
|
|
|
this.scrollDown(true);
|
|
|
|
event.preventDefault();
|
|
|
|
event.stopPropagation();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2021-11-12 23:44:20 +00:00
|
|
|
public override render(): JSX.Element | null {
|
2021-03-03 20:09:58 +00:00
|
|
|
const {
|
2021-06-01 23:30:25 +00:00
|
|
|
acknowledgeGroupMemberNameCollisions,
|
|
|
|
areWeAdmin,
|
2021-10-26 22:59:08 +00:00
|
|
|
clearInvitedUuidsForNewlyCreatedGroup,
|
2021-04-21 16:31:12 +00:00
|
|
|
closeContactSpoofingReview,
|
|
|
|
contactSpoofingReview,
|
2021-11-20 15:41:21 +00:00
|
|
|
getPreferredBadge,
|
2022-01-26 23:05:26 +00:00
|
|
|
getTimestampForMessage,
|
2022-03-03 20:23:10 +00:00
|
|
|
haveNewest,
|
2022-01-26 23:05:26 +00:00
|
|
|
haveOldest,
|
2021-03-03 20:09:58 +00:00
|
|
|
i18n,
|
|
|
|
id,
|
|
|
|
invitedContactsForNewlyCreatedGroup,
|
2022-03-03 20:23:10 +00:00
|
|
|
isConversationSelected,
|
2021-04-21 16:31:12 +00:00
|
|
|
isGroupV1AndDisabled,
|
2022-01-26 23:05:26 +00:00
|
|
|
isLoadingMessages,
|
2021-04-21 16:31:12 +00:00
|
|
|
items,
|
2022-03-03 20:23:10 +00:00
|
|
|
oldestUnreadIndex,
|
2021-04-21 16:31:12 +00:00
|
|
|
onBlock,
|
2021-05-27 20:17:05 +00:00
|
|
|
onBlockAndReportSpam,
|
2021-04-21 16:31:12 +00:00
|
|
|
onDelete,
|
|
|
|
onUnblock,
|
2021-06-01 23:30:25 +00:00
|
|
|
removeMember,
|
2022-03-03 20:23:10 +00:00
|
|
|
renderHeroRow,
|
|
|
|
renderItem,
|
|
|
|
renderTypingBubble,
|
2021-06-01 23:30:25 +00:00
|
|
|
reviewGroupMemberNameCollision,
|
2021-04-21 16:31:12 +00:00
|
|
|
reviewMessageRequestNameCollision,
|
2022-03-03 20:23:10 +00:00
|
|
|
showContactModal,
|
2021-11-20 15:41:21 +00:00
|
|
|
theme,
|
2022-03-08 20:05:05 +00:00
|
|
|
totalUnread,
|
2022-03-03 20:23:10 +00:00
|
|
|
typingContactId,
|
|
|
|
unblurAvatar,
|
|
|
|
unreadCount,
|
|
|
|
updateSharedGroups,
|
2021-03-03 20:09:58 +00:00
|
|
|
} = this.props;
|
2019-05-31 22:42:01 +00:00
|
|
|
const {
|
2022-01-26 23:05:26 +00:00
|
|
|
hasRecentlyScrolled,
|
2022-03-03 20:23:10 +00:00
|
|
|
newestFullyVisibleMessageId,
|
|
|
|
oldestPartiallyVisibleMessageId,
|
2021-10-12 23:59:08 +00:00
|
|
|
widthBreakpoint,
|
2019-05-31 22:42:01 +00:00
|
|
|
} = this.state;
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
// As a performance optimization, we don't need to render anything if this
|
|
|
|
// conversation isn't the active one.
|
|
|
|
if (!isConversationSelected) {
|
2019-08-20 19:34:52 +00:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
const areThereAnyMessages = items.length > 0;
|
|
|
|
const areAnyMessagesUnread = Boolean(unreadCount);
|
|
|
|
const areAnyMessagesBelowCurrentPosition =
|
|
|
|
!haveNewest ||
|
|
|
|
Boolean(
|
|
|
|
newestFullyVisibleMessageId &&
|
|
|
|
newestFullyVisibleMessageId !== last(items)
|
|
|
|
);
|
|
|
|
const areSomeMessagesBelowCurrentPosition =
|
|
|
|
!haveNewest ||
|
|
|
|
(newestFullyVisibleMessageId &&
|
|
|
|
!items
|
|
|
|
.slice(-SCROLL_DOWN_BUTTON_THRESHOLD)
|
|
|
|
.includes(newestFullyVisibleMessageId));
|
|
|
|
|
|
|
|
const areUnreadBelowCurrentPosition = Boolean(
|
|
|
|
areThereAnyMessages &&
|
|
|
|
areAnyMessagesUnread &&
|
|
|
|
areAnyMessagesBelowCurrentPosition
|
|
|
|
);
|
|
|
|
const shouldShowScrollDownButton = Boolean(
|
|
|
|
areThereAnyMessages &&
|
|
|
|
(areUnreadBelowCurrentPosition || areSomeMessagesBelowCurrentPosition)
|
|
|
|
);
|
|
|
|
|
|
|
|
const actionProps = getActions(this.props);
|
|
|
|
|
2022-01-26 23:05:26 +00:00
|
|
|
let floatingHeader: ReactNode;
|
2022-02-07 18:54:15 +00:00
|
|
|
// 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;
|
2022-03-03 20:23:10 +00:00
|
|
|
if (
|
|
|
|
oldestPartiallyVisibleMessageId &&
|
|
|
|
oldestPartiallyVisibleMessageTimestamp
|
|
|
|
) {
|
2022-01-26 23:05:26 +00:00
|
|
|
floatingHeader = (
|
|
|
|
<TimelineFloatingHeader
|
|
|
|
i18n={i18n}
|
|
|
|
isLoading={isLoadingMessages}
|
2022-02-07 18:54:15 +00:00
|
|
|
timestamp={oldestPartiallyVisibleMessageTimestamp}
|
2022-01-26 23:05:26 +00:00
|
|
|
visible={
|
|
|
|
(hasRecentlyScrolled || isLoadingMessages) &&
|
2022-02-07 18:54:15 +00:00
|
|
|
(!haveOldest || oldestPartiallyVisibleMessageId !== items[0])
|
2022-01-26 23:05:26 +00:00
|
|
|
}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
const messageNodes: Array<ReactChild> = [];
|
|
|
|
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
|
2022-03-08 14:32:42 +00:00
|
|
|
const previousItemIndex = itemIndex - 1;
|
|
|
|
const nextItemIndex = itemIndex + 1;
|
|
|
|
|
|
|
|
const previousMessageId: undefined | string = items[previousItemIndex];
|
|
|
|
const nextMessageId: undefined | string = items[nextItemIndex];
|
2022-03-03 20:23:10 +00:00
|
|
|
const messageId = items[itemIndex];
|
2021-03-10 20:36:58 +00:00
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
if (!messageId) {
|
|
|
|
assert(
|
|
|
|
false,
|
|
|
|
'<Timeline> iterated through items and got an empty message ID'
|
|
|
|
);
|
|
|
|
continue;
|
|
|
|
}
|
2021-03-10 20:36:58 +00:00
|
|
|
|
2022-03-08 14:32:42 +00:00
|
|
|
let unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
|
2022-03-03 20:23:10 +00:00
|
|
|
if (oldestUnreadIndex === itemIndex) {
|
2022-03-08 14:32:42 +00:00
|
|
|
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustAbove;
|
2022-03-03 20:23:10 +00:00
|
|
|
messageNodes.push(
|
2022-03-08 20:05:05 +00:00
|
|
|
<LastSeenIndicator
|
|
|
|
key="last seen indicator"
|
|
|
|
count={totalUnread}
|
|
|
|
i18n={i18n}
|
|
|
|
ref={this.lastSeenIndicatorRef}
|
|
|
|
/>
|
2022-03-03 20:23:10 +00:00
|
|
|
);
|
2022-03-08 14:32:42 +00:00
|
|
|
} else if (oldestUnreadIndex === nextItemIndex) {
|
|
|
|
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustBelow;
|
2022-03-03 20:23:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
messageNodes.push(
|
|
|
|
<div
|
|
|
|
key={messageId}
|
|
|
|
data-item-index={itemIndex}
|
|
|
|
data-message-id={messageId}
|
|
|
|
>
|
|
|
|
<ErrorBoundary i18n={i18n} showDebugLog={showDebugLog}>
|
|
|
|
{renderItem({
|
|
|
|
actionProps,
|
|
|
|
containerElementRef: this.containerRef,
|
|
|
|
containerWidthBreakpoint: widthBreakpoint,
|
|
|
|
conversationId: id,
|
|
|
|
isOldestTimelineItem: haveOldest && itemIndex === 0,
|
|
|
|
messageId,
|
|
|
|
nextMessageId,
|
|
|
|
previousMessageId,
|
2022-03-08 14:32:42 +00:00
|
|
|
unreadIndicatorPlacement,
|
2022-03-03 20:23:10 +00:00
|
|
|
})}
|
|
|
|
</ErrorBoundary>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
2021-03-10 20:36:58 +00:00
|
|
|
|
2021-09-20 19:19:55 +00:00
|
|
|
const warning = Timeline.getWarning(this.props, this.state);
|
2021-04-21 16:31:12 +00:00
|
|
|
let timelineWarning: ReactNode;
|
|
|
|
if (warning) {
|
2021-06-01 23:30:25 +00:00
|
|
|
let text: ReactChild;
|
|
|
|
let onClose: () => void;
|
|
|
|
switch (warning.type) {
|
|
|
|
case ContactSpoofingType.DirectConversationWithSameTitle:
|
|
|
|
text = (
|
|
|
|
<Intl
|
|
|
|
i18n={i18n}
|
|
|
|
id="ContactSpoofing__same-name"
|
|
|
|
components={{
|
|
|
|
link: (
|
|
|
|
<TimelineWarning.Link
|
|
|
|
onClick={() => {
|
|
|
|
reviewMessageRequestNameCollision({
|
|
|
|
safeConversationId: warning.safeConversation.id,
|
|
|
|
});
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{i18n('ContactSpoofing__same-name__link')}
|
|
|
|
</TimelineWarning.Link>
|
|
|
|
),
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
onClose = () => {
|
|
|
|
this.setState({
|
|
|
|
hasDismissedDirectContactSpoofingWarning: true,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
break;
|
|
|
|
case ContactSpoofingType.MultipleGroupMembersWithSameTitle: {
|
|
|
|
const { groupNameCollisions } = warning;
|
|
|
|
text = (
|
|
|
|
<Intl
|
|
|
|
i18n={i18n}
|
|
|
|
id="ContactSpoofing__same-name-in-group"
|
|
|
|
components={{
|
|
|
|
count: Object.values(groupNameCollisions)
|
|
|
|
.reduce(
|
|
|
|
(result, conversations) => result + conversations.length,
|
|
|
|
0
|
|
|
|
)
|
|
|
|
.toString(),
|
|
|
|
link: (
|
|
|
|
<TimelineWarning.Link
|
|
|
|
onClick={() => {
|
|
|
|
reviewGroupMemberNameCollision(id);
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{i18n('ContactSpoofing__same-name-in-group__link')}
|
|
|
|
</TimelineWarning.Link>
|
|
|
|
),
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
onClose = () => {
|
|
|
|
acknowledgeGroupMemberNameCollisions(groupNameCollisions);
|
|
|
|
};
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
throw missingCaseError(warning);
|
|
|
|
}
|
|
|
|
|
2021-04-21 16:31:12 +00:00
|
|
|
timelineWarning = (
|
2022-03-09 18:47:13 +00:00
|
|
|
<TimelineWarnings>
|
|
|
|
<TimelineWarning i18n={i18n} onClose={onClose}>
|
|
|
|
<TimelineWarning.IconContainer>
|
|
|
|
<TimelineWarning.GenericIcon />
|
|
|
|
</TimelineWarning.IconContainer>
|
|
|
|
<TimelineWarning.Text>{text}</TimelineWarning.Text>
|
|
|
|
</TimelineWarning>
|
|
|
|
</TimelineWarnings>
|
2021-04-21 16:31:12 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-06-01 23:30:25 +00:00
|
|
|
let contactSpoofingReviewDialog: ReactNode;
|
|
|
|
if (contactSpoofingReview) {
|
|
|
|
const commonProps = {
|
2021-11-30 10:07:24 +00:00
|
|
|
getPreferredBadge,
|
2021-06-01 23:30:25 +00:00
|
|
|
i18n,
|
|
|
|
onBlock,
|
|
|
|
onBlockAndReportSpam,
|
|
|
|
onClose: closeContactSpoofingReview,
|
|
|
|
onDelete,
|
|
|
|
onShowContactModal: showContactModal,
|
|
|
|
onUnblock,
|
|
|
|
removeMember,
|
2021-11-30 10:07:24 +00:00
|
|
|
theme,
|
2021-06-01 23:30:25 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
switch (contactSpoofingReview.type) {
|
|
|
|
case ContactSpoofingType.DirectConversationWithSameTitle:
|
|
|
|
contactSpoofingReviewDialog = (
|
|
|
|
<ContactSpoofingReviewDialog
|
|
|
|
{...commonProps}
|
|
|
|
type={ContactSpoofingType.DirectConversationWithSameTitle}
|
|
|
|
possiblyUnsafeConversation={
|
|
|
|
contactSpoofingReview.possiblyUnsafeConversation
|
|
|
|
}
|
|
|
|
safeConversation={contactSpoofingReview.safeConversation}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
case ContactSpoofingType.MultipleGroupMembersWithSameTitle:
|
|
|
|
contactSpoofingReviewDialog = (
|
|
|
|
<ContactSpoofingReviewDialog
|
|
|
|
{...commonProps}
|
|
|
|
type={ContactSpoofingType.MultipleGroupMembersWithSameTitle}
|
|
|
|
areWeAdmin={Boolean(areWeAdmin)}
|
|
|
|
collisionInfoByTitle={contactSpoofingReview.collisionInfoByTitle}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
throw missingCaseError(contactSpoofingReview);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-03-20 17:42:28 +00:00
|
|
|
return (
|
2021-03-03 20:09:58 +00:00
|
|
|
<>
|
2021-10-12 23:59:08 +00:00
|
|
|
<Measure
|
|
|
|
bounds
|
|
|
|
onResize={({ bounds }) => {
|
2022-03-03 20:23:10 +00:00
|
|
|
const { isNearBottom } = this.props;
|
|
|
|
|
|
|
|
strictAssert(bounds, 'We should be measuring the bounds');
|
|
|
|
|
2021-10-12 23:59:08 +00:00
|
|
|
this.setState({
|
2022-03-03 20:23:10 +00:00
|
|
|
widthBreakpoint: getWidthBreakpoint(bounds.width),
|
2021-10-12 23:59:08 +00:00
|
|
|
});
|
2022-03-03 20:23:10 +00:00
|
|
|
|
|
|
|
this.maxVisibleRows = Math.ceil(bounds.height / MIN_ROW_HEIGHT);
|
|
|
|
|
|
|
|
const containerEl = this.containerRef.current;
|
|
|
|
if (containerEl && isNearBottom) {
|
|
|
|
scrollToBottom(containerEl);
|
|
|
|
}
|
2021-10-12 23:59:08 +00:00
|
|
|
}}
|
2021-03-03 20:09:58 +00:00
|
|
|
>
|
2021-10-12 23:59:08 +00:00
|
|
|
{({ measureRef }) => (
|
|
|
|
<div
|
|
|
|
className={classNames(
|
|
|
|
'module-timeline',
|
|
|
|
isGroupV1AndDisabled ? 'module-timeline--disabled' : null,
|
|
|
|
`module-timeline--width-${widthBreakpoint}`
|
|
|
|
)}
|
|
|
|
role="presentation"
|
|
|
|
tabIndex={-1}
|
|
|
|
onBlur={this.handleBlur}
|
|
|
|
onKeyDown={this.handleKeyDown}
|
2022-03-03 20:23:10 +00:00
|
|
|
ref={measureRef}
|
2021-10-12 23:59:08 +00:00
|
|
|
>
|
|
|
|
{timelineWarning}
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
<div
|
|
|
|
className="module-timeline__messages__container"
|
|
|
|
onScroll={this.onScroll}
|
|
|
|
ref={this.containerRef}
|
|
|
|
>
|
2022-03-09 18:47:13 +00:00
|
|
|
{floatingHeader}
|
|
|
|
|
2022-03-03 20:23:10 +00:00
|
|
|
<div
|
2022-03-08 21:54:27 +00:00
|
|
|
className={classNames(
|
|
|
|
'module-timeline__messages',
|
|
|
|
haveNewest && 'module-timeline__messages--have-newest'
|
|
|
|
)}
|
2022-03-03 20:23:10 +00:00
|
|
|
ref={this.messagesRef}
|
|
|
|
>
|
2022-03-09 18:47:13 +00:00
|
|
|
{haveOldest &&
|
|
|
|
renderHeroRow(id, unblurAvatar, updateSharedGroups)}
|
2022-03-03 20:23:10 +00:00
|
|
|
|
|
|
|
{messageNodes}
|
|
|
|
|
|
|
|
{typingContactId && renderTypingBubble(id)}
|
|
|
|
</div>
|
|
|
|
</div>
|
2021-10-12 23:59:08 +00:00
|
|
|
|
|
|
|
{shouldShowScrollDownButton ? (
|
|
|
|
<ScrollDownButton
|
|
|
|
conversationId={id}
|
|
|
|
withNewMessages={areUnreadBelowCurrentPosition}
|
|
|
|
scrollDown={this.onClickScrollDownButton}
|
|
|
|
i18n={i18n}
|
|
|
|
/>
|
|
|
|
) : null}
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</Measure>
|
2021-03-03 20:09:58 +00:00
|
|
|
|
|
|
|
{Boolean(invitedContactsForNewlyCreatedGroup.length) && (
|
|
|
|
<NewlyCreatedGroupInvitedContactsDialog
|
|
|
|
contacts={invitedContactsForNewlyCreatedGroup}
|
2021-11-20 15:41:21 +00:00
|
|
|
getPreferredBadge={getPreferredBadge}
|
2019-05-31 22:42:01 +00:00
|
|
|
i18n={i18n}
|
2021-10-26 22:59:08 +00:00
|
|
|
onClose={clearInvitedUuidsForNewlyCreatedGroup}
|
2021-11-20 15:41:21 +00:00
|
|
|
theme={theme}
|
2019-05-31 22:42:01 +00:00
|
|
|
/>
|
2021-03-03 20:09:58 +00:00
|
|
|
)}
|
2021-04-21 16:31:12 +00:00
|
|
|
|
2021-06-01 23:30:25 +00:00
|
|
|
{contactSpoofingReviewDialog}
|
2021-03-03 20:09:58 +00:00
|
|
|
</>
|
2019-03-20 17:42:28 +00:00
|
|
|
);
|
|
|
|
}
|
2021-04-21 16:31:12 +00:00
|
|
|
|
2021-09-20 19:19:55 +00:00
|
|
|
private static getWarning(
|
|
|
|
{ warning }: PropsType,
|
|
|
|
state: StateType
|
|
|
|
): undefined | WarningType {
|
2021-06-01 23:30:25 +00:00
|
|
|
if (!warning) {
|
2021-04-21 16:31:12 +00:00
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
2021-06-01 23:30:25 +00:00
|
|
|
switch (warning.type) {
|
|
|
|
case ContactSpoofingType.DirectConversationWithSameTitle: {
|
2021-09-20 19:19:55 +00:00
|
|
|
const { hasDismissedDirectContactSpoofingWarning } = state;
|
2021-06-01 23:30:25 +00:00
|
|
|
return hasDismissedDirectContactSpoofingWarning ? undefined : warning;
|
|
|
|
}
|
|
|
|
case ContactSpoofingType.MultipleGroupMembersWithSameTitle:
|
|
|
|
return hasUnacknowledgedCollisions(
|
|
|
|
warning.acknowledgedGroupNameCollisions,
|
|
|
|
warning.groupNameCollisions
|
|
|
|
)
|
|
|
|
? warning
|
|
|
|
: undefined;
|
|
|
|
default:
|
|
|
|
throw missingCaseError(warning);
|
|
|
|
}
|
2021-04-21 16:31:12 +00:00
|
|
|
}
|
2019-03-20 17:42:28 +00:00
|
|
|
}
|
2022-03-03 20:23:10 +00:00
|
|
|
|
|
|
|
function showDebugLog() {
|
|
|
|
window.showDebugLog();
|
|
|
|
}
|