signal-desktop/ts/components/conversation/Timeline.tsx

1120 lines
34 KiB
TypeScript
Raw Normal View History

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
import { first, get, isNumber, last, pick, throttle } from 'lodash';
import classNames from 'classnames';
import type { ReactChild, ReactNode, RefObject } from 'react';
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';
import { ScrollDownButton } from './ScrollDownButton';
2021-11-20 15:41:21 +00:00
import type { AssertProps, LocalizerType, ThemeType } from '../../types/Util';
import type { ConversationType } from '../../state/ducks/conversations';
2021-11-20 15:41:21 +00:00
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
import { assert, strictAssert } from '../../util/assert';
2021-06-01 23:30:25 +00:00
import { missingCaseError } from '../../util/missingCaseError';
import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary';
import { WidthBreakpoint } from '../_util';
import type { PropsActions as MessageActionsType } from './Message';
import type { PropsActions as UnsupportedMessageActionsType } from './UnsupportedMessage';
import type { PropsActionsType as ChatSessionRefreshedNotificationActionsType } from './ChatSessionRefreshedNotification';
import { ErrorBoundary } from './ErrorBoundary';
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';
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions';
2022-01-26 23:05:26 +00:00
import { TimelineFloatingHeader } from './TimelineFloatingHeader';
import {
getWidthBreakpoint,
UnreadIndicatorPlacement,
} from '../../util/timelineUtil';
import {
getScrollBottom,
scrollToBottom,
setScrollBottom,
} from '../../util/scrollUtil';
import { LastSeenIndicator } from './LastSeenIndicator';
const AT_BOTTOM_THRESHOLD = 15;
const MIN_ROW_HEIGHT = 18;
const SCROLL_DOWN_BUTTON_THRESHOLD = 8;
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
export type PropsDataType = {
haveNewest: boolean;
haveOldest: boolean;
isLoadingMessages: boolean;
isNearBottom?: boolean;
items: ReadonlyArray<string>;
oldestUnreadIndex?: number;
scrollToIndex?: number;
scrollToIndexCounter: number;
totalUnread: number;
};
type PropsHousekeepingType = {
id: string;
2021-06-01 23:30:25 +00:00
areWeAdmin?: boolean;
isConversationSelected: boolean;
isGroupV1AndDisabled?: boolean;
isIncomingMessageRequest: boolean;
typingContactId?: string;
unreadCount?: number;
2019-11-07 21:36:16 +00:00
selectedMessageId?: string;
2021-03-03 20:09:58 +00:00
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
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
discardMessages: (
_: Readonly<{ conversationId: string; numberToKeepAtBottom: number }>
) => void;
getTimestampForMessage: (messageId: string) => undefined | number;
2021-11-20 15:41:21 +00:00
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
2021-11-20 15:41:21 +00:00
theme: ThemeType;
renderItem: (props: {
actionProps: PropsActionsType;
containerElementRef: RefObject<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint;
conversationId: string;
2022-01-26 23:05:26 +00:00
isOldestTimelineItem: boolean;
messageId: string;
nextMessageId: undefined | string;
previousMessageId: undefined | string;
unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
}) => JSX.Element;
2020-08-07 00:50:54 +00:00
renderHeroRow: (
id: string,
unblurAvatar: () => void,
2020-08-07 00:50:54 +00:00
updateSharedGroups: () => unknown
) => JSX.Element;
renderTypingBubble: (id: string) => JSX.Element;
};
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;
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;
learnMoreAboutDeliveryIssue: () => unknown;
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;
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;
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;
unblurAvatar: () => void;
2020-08-07 00:50:54 +00:00
updateSharedGroups: () => unknown;
} & MessageActionsType &
2021-08-11 16:23:21 +00:00
SafetyNumberActionsType &
UnsupportedMessageActionsType &
ChatSessionRefreshedNotificationActionsType;
export type PropsType = PropsDataType &
PropsHousekeepingType &
PropsActionsType;
type StateType = {
2021-06-01 23:30:25 +00:00
hasDismissedDirectContactSpoofingWarning: boolean;
hasRecentlyScrolled: boolean;
newestFullyVisibleMessageId?: string;
oldestPartiallyVisibleMessageId?: string;
widthBreakpoint: WidthBreakpoint;
};
const scrollToUnreadIndicator = Symbol('scrollToUnreadIndicator');
type SnapshotType =
| null
| typeof scrollToUnreadIndicator
| { 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.
(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',
'peekGroupCallForTheFirstTime',
2021-08-18 13:34:22 +00:00
'removeMember',
'selectMessage',
'clearSelectedMessage',
'unblurAvatar',
'updateSharedGroups',
'doubleCheckMissingQuoteReference',
'checkForAccount',
'reactToMessage',
'replyToMessage',
'retryDeleteForEveryone',
2021-08-18 13:34:22 +00:00
'retrySend',
'showForwardMessageModal',
'deleteMessage',
'deleteMessageForEveryone',
'showMessageDetail',
'openConversation',
'showContactDetail',
'showContactModal',
'kickOffAttachmentDownload',
'markAttachmentAsCorrupted',
'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;
}
);
export class Timeline extends React.Component<
PropsType,
StateType,
SnapshotType
> {
private readonly containerRef = React.createRef<HTMLDivElement>();
private readonly messagesRef = React.createRef<HTMLDivElement>();
private readonly lastSeenIndicatorRef = React.createRef<HTMLDivElement>();
private intersectionObserver?: IntersectionObserver;
// 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;
private delayedPeekTimeout?: NodeJS.Timeout;
override state: StateType = {
hasRecentlyScrolled: true,
hasDismissedDirectContactSpoofingWarning: false,
// This may be swiftly overridden.
widthBreakpoint: WidthBreakpoint.Wide,
};
private onScroll = (): void => {
const { id, setIsNearBottom } = this.props;
setIsNearBottom(id, this.isAtBottom());
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);
};
private scrollToItemIndex(itemIndex: number): void {
this.messagesRef.current
?.querySelector(`[data-item-index="${itemIndex}"]`)
?.scrollIntoViewIfNeeded();
}
private scrollToBottom = (setFocus?: boolean): void => {
const { selectMessage, id, items } = this.props;
if (setFocus && items && items.length > 0) {
const lastIndex = items.length - 1;
const lastMessageId = items[lastIndex];
selectMessage(lastMessageId, id);
} else {
const containerEl = this.containerRef.current;
if (containerEl) {
scrollToBottom(containerEl);
}
}
};
private onClickScrollDownButton = (): void => {
this.scrollDown(false);
2020-05-27 21:37:06 +00:00
};
private scrollDown = (setFocus?: boolean): void => {
const {
haveNewest,
id,
isLoadingMessages,
items,
loadNewestMessages,
oldestUnreadIndex,
selectMessage,
} = this.props;
const { newestFullyVisibleMessageId } = this.state;
if (!items || items.length < 1) {
return;
}
if (isLoadingMessages) {
this.scrollToBottom(setFocus);
return;
}
if (
newestFullyVisibleMessageId &&
isNumber(oldestUnreadIndex) &&
items.findIndex(item => item === newestFullyVisibleMessageId) <
oldestUnreadIndex
) {
if (setFocus) {
const messageId = items[oldestUnreadIndex];
selectMessage(messageId, id);
} else {
this.scrollToItemIndex(oldestUnreadIndex);
}
} else if (haveNewest) {
this.scrollToBottom(setFocus);
} else {
const lastId = last(items);
if (lastId) {
loadNewestMessages(lastId, setFocus);
}
}
};
private isAtBottom(): boolean {
const containerEl = this.containerRef.current;
return Boolean(
containerEl && getScrollBottom(containerEl) <= AT_BOTTOM_THRESHOLD
);
}
/**
* 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) {
return;
}
const {
haveNewest,
haveOldest,
isLoadingMessages,
items,
loadNewerMessages,
loadOlderMessages,
} = this.props;
this.intersectionObserver?.disconnect();
// Keys are message IDs. Values are intersection ratios.
const visibleMessages = new Map<string, number>();
const intersectionObserverCallback: IntersectionObserverCallback =
entries => {
entries.forEach(entry => {
const { intersectionRatio, target } = entry;
const {
dataset: { messageId },
} = target as HTMLElement;
if (!messageId) {
return;
}
visibleMessages.set(messageId, intersectionRatio);
});
let oldestPartiallyVisibleMessageId: undefined | string;
let newestFullyVisibleMessageId: undefined | string;
for (const [messageId, intersectionRatio] of visibleMessages) {
if (intersectionRatio > 0 && !oldestPartiallyVisibleMessageId) {
oldestPartiallyVisibleMessageId = messageId;
}
if (intersectionRatio >= 1) {
newestFullyVisibleMessageId = messageId;
}
}
this.setState({
oldestPartiallyVisibleMessageId,
newestFullyVisibleMessageId,
});
2022-01-26 23:05:26 +00:00
if (newestFullyVisibleMessageId) {
this.markNewestFullyVisibleMessageRead();
if (
!isLoadingMessages &&
!haveNewest &&
newestFullyVisibleMessageId === last(items)
) {
loadNewerMessages(newestFullyVisibleMessageId);
}
}
if (
!isLoadingMessages &&
!haveOldest &&
oldestPartiallyVisibleMessageId &&
oldestPartiallyVisibleMessageId === items[0]
) {
loadOlderMessages(oldestPartiallyVisibleMessageId);
}
2022-01-26 23:05:26 +00:00
};
this.intersectionObserver = new IntersectionObserver(
intersectionObserverCallback,
{
root: containerEl,
threshold: [0, 1],
}
);
for (const child of messagesEl.children) {
if ((child as HTMLElement).dataset.messageId) {
this.intersectionObserver.observe(child);
}
}
}
private markNewestFullyVisibleMessageRead = throttle(
(): void => {
const { markMessageRead } = this.props;
const { newestFullyVisibleMessageId } = this.state;
if (newestFullyVisibleMessageId) {
markMessageRead(newestFullyVisibleMessageId);
}
},
500,
{ leading: false }
);
public override componentDidMount(): void {
const containerEl = this.containerRef.current;
const messagesEl = this.messagesRef.current;
strictAssert(
containerEl && messagesEl,
'<Timeline> mounted without some refs'
);
this.updateIntersectionObserver();
window.registerForActive(this.markNewestFullyVisibleMessageRead);
this.delayedPeekTimeout = setTimeout(() => {
const { id, peekGroupCallForTheFirstTime } = this.props;
peekGroupCallForTheFirstTime(id);
}, 500);
2019-08-07 00:40:25 +00:00
}
public override componentWillUnmount(): void {
2022-03-08 19:11:11 +00:00
const { delayedPeekTimeout } = this;
window.unregisterForActive(this.markNewestFullyVisibleMessageRead);
this.intersectionObserver?.disconnect();
clearTimeoutIfNecessary(delayedPeekTimeout);
2019-08-07 00:40:25 +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;
const {
isIncomingMessageRequest,
isLoadingMessages,
items: newItems,
oldestUnreadIndex,
scrollToIndex,
scrollToIndexCounter: newScrollToIndexCounter,
typingContactId,
} = this.props;
const isDoingInitialLoad = isLoadingMessages && newItems.length === 0;
const wasDoingInitialLoad = wasLoadingMessages && oldItems.length === 0;
const justFinishedInitialLoad = wasDoingInitialLoad && !isDoingInitialLoad;
if (isDoingInitialLoad) {
return null;
}
if (
isNumber(scrollToIndex) &&
(oldScrollToIndexCounter !== newScrollToIndexCounter ||
justFinishedInitialLoad)
) {
return { scrollToIndex };
}
if (justFinishedInitialLoad) {
if (isIncomingMessageRequest) {
return { scrollTop: 0 };
}
if (isNumber(oldestUnreadIndex)) {
return scrollToUnreadIndicator;
}
return { scrollBottom: 0 };
}
if (
Boolean(typingContactId) !== Boolean(oldTypingContactId) &&
this.isAtBottom()
) {
return { scrollBottom: 0 };
}
// 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;
}
let scrollAnchor: 'top' | 'bottom';
if (this.isAtBottom()) {
const justLoadedAPage = wasLoadingMessages && !isLoadingMessages;
scrollAnchor = justLoadedAPage ? 'top' : 'bottom';
} else {
scrollAnchor = last(oldItems) !== last(newItems) ? 'top' : 'bottom';
}
return scrollAnchor === 'top'
? { scrollTop: containerEl.scrollTop }
: { scrollBottom: getScrollBottom(containerEl) };
}
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) {
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) {
this.scrollToItemIndex(snapshot.scrollToIndex);
} else if ('scrollTop' in snapshot) {
containerEl.scrollTop = snapshot.scrollTop;
} else {
setScrollBottom(containerEl, snapshot.scrollBottom);
}
}
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,
});
}
}
}
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;
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') {
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();
}
};
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,
haveNewest,
2022-01-26 23:05:26 +00:00
haveOldest,
2021-03-03 20:09:58 +00:00
i18n,
id,
invitedContactsForNewlyCreatedGroup,
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,
oldestUnreadIndex,
2021-04-21 16:31:12 +00:00
onBlock,
onBlockAndReportSpam,
2021-04-21 16:31:12 +00:00
onDelete,
onUnblock,
2021-06-01 23:30:25 +00:00
removeMember,
renderHeroRow,
renderItem,
renderTypingBubble,
2021-06-01 23:30:25 +00:00
reviewGroupMemberNameCollision,
2021-04-21 16:31:12 +00:00
reviewMessageRequestNameCollision,
showContactModal,
2021-11-20 15:41:21 +00:00
theme,
totalUnread,
typingContactId,
unblurAvatar,
unreadCount,
updateSharedGroups,
2021-03-03 20:09:58 +00:00
} = this.props;
const {
2022-01-26 23:05:26 +00:00
hasRecentlyScrolled,
newestFullyVisibleMessageId,
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) {
2019-08-20 19:34:52 +00:00
return null;
}
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;
// 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
) {
2022-01-26 23:05:26 +00:00
floatingHeader = (
<TimelineFloatingHeader
i18n={i18n}
isLoading={isLoadingMessages}
timestamp={oldestPartiallyVisibleMessageTimestamp}
2022-01-26 23:05:26 +00:00
visible={
(hasRecentlyScrolled || isLoadingMessages) &&
(!haveOldest || oldestPartiallyVisibleMessageId !== items[0])
2022-01-26 23:05:26 +00:00
}
/>
);
}
const messageNodes: Array<ReactChild> = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
const previousItemIndex = itemIndex - 1;
const nextItemIndex = itemIndex + 1;
const previousMessageId: undefined | string = items[previousItemIndex];
const nextMessageId: undefined | string = items[nextItemIndex];
const messageId = items[itemIndex];
if (!messageId) {
assert(
false,
'<Timeline> iterated through items and got an empty message ID'
);
continue;
}
let unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
if (oldestUnreadIndex === itemIndex) {
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustAbove;
messageNodes.push(
<LastSeenIndicator
key="last seen indicator"
count={totalUnread}
i18n={i18n}
ref={this.lastSeenIndicatorRef}
/>
);
} else if (oldestUnreadIndex === nextItemIndex) {
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustBelow;
}
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,
unreadIndicatorPlacement,
})}
</ErrorBoundary>
</div>
);
}
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 = (
<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);
}
}
return (
2021-03-03 20:09:58 +00:00
<>
<Measure
bounds
onResize={({ bounds }) => {
const { isNearBottom } = this.props;
strictAssert(bounds, 'We should be measuring the bounds');
this.setState({
widthBreakpoint: getWidthBreakpoint(bounds.width),
});
this.maxVisibleRows = Math.ceil(bounds.height / MIN_ROW_HEIGHT);
const containerEl = this.containerRef.current;
if (containerEl && isNearBottom) {
scrollToBottom(containerEl);
}
}}
2021-03-03 20:09:58 +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}
ref={measureRef}
>
{timelineWarning}
<div
className="module-timeline__messages__container"
onScroll={this.onScroll}
ref={this.containerRef}
>
{floatingHeader}
<div
className={classNames(
'module-timeline__messages',
haveNewest && 'module-timeline__messages--have-newest'
)}
ref={this.messagesRef}
>
{haveOldest &&
renderHeroRow(id, unblurAvatar, updateSharedGroups)}
{messageNodes}
{typingContactId && renderTypingBubble(id)}
</div>
</div>
{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}
i18n={i18n}
2021-10-26 22:59:08 +00:00
onClose={clearInvitedUuidsForNewlyCreatedGroup}
2021-11-20 15:41:21 +00:00
theme={theme}
/>
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
</>
);
}
2021-04-21 16:31:12 +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: {
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
}
}
function showDebugLog() {
window.showDebugLog();
}