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

1596 lines
46 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
2022-01-26 23:05:26 +00:00
import { debounce, get, isEqual, isNumber, pick } from 'lodash';
import classNames from 'classnames';
import type { CSSProperties, ReactChild, ReactNode, RefObject } from 'react';
import React from 'react';
2021-08-18 13:34:22 +00:00
import { createSelector } from 'reselect';
import type { Grid } from 'react-virtualized';
import {
AutoSizer,
CellMeasurer,
CellMeasurerCache,
List,
} from 'react-virtualized';
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';
2021-04-21 16:31:12 +00:00
import { assert } from '../../util/assert';
2021-06-01 23:30:25 +00:00
import { missingCaseError } from '../../util/missingCaseError';
import { createRefMerger } from '../../util/refMerger';
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 {
fromItemIndexToRow,
fromRowToItemIndex,
getEphemeralRows,
getHeroRow,
getLastSeenIndicatorRow,
getRowCount,
getTypingBubbleRow,
getWidthBreakpoint,
} from '../../util/timelineUtil';
const AT_BOTTOM_THRESHOLD = 15;
const NEAR_BOTTOM_THRESHOLD = 15;
const AT_TOP_THRESHOLD = 10;
const LOAD_MORE_THRESHOLD = 30;
const SCROLL_DOWN_BUTTON_THRESHOLD = 8;
export const LOAD_COUNTDOWN = 1;
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>;
loadCountdownStart?: number;
messageHeightChangeBaton?: unknown;
messageHeightChangeIndex?: number;
oldestUnreadIndex?: number;
resetCounter: number;
scrollToIndex?: number;
scrollToIndexCounter: number;
totalUnread: number;
};
type PropsHousekeepingType = {
id: string;
2021-06-01 23:30:25 +00:00
areWeAdmin?: 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
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;
onHeightChange: (messageId: string) => unknown;
previousMessageId: undefined | string;
}) => JSX.Element;
renderLastSeenIndicator: (id: string) => JSX.Element;
2020-08-07 00:50:54 +00:00
renderHeroRow: (
id: string,
resizeHeroRow: () => unknown,
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;
clearChangedMessages: (conversationId: string, baton: unknown) => unknown;
2021-10-26 22:59:08 +00:00
clearInvitedUuidsForNewlyCreatedGroup: () => void;
2021-04-21 16:31:12 +00:00
closeContactSpoofingReview: () => void;
setLoadCountdownStart: (
conversationId: string,
loadCountdownStart?: number
) => unknown;
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;
2021-10-05 16:47:06 +00:00
} & Omit<MessageActionsType, 'onHeightChange'> &
2021-08-11 16:23:21 +00:00
SafetyNumberActionsType &
UnsupportedMessageActionsType &
ChatSessionRefreshedNotificationActionsType;
export type PropsType = PropsDataType &
PropsHousekeepingType &
PropsActionsType;
// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5
type RowRendererParamsType = {
index: number;
isScrolling: boolean;
isVisible: boolean;
key: string;
2020-09-14 19:51:27 +00:00
parent: Record<string, unknown>;
style: CSSProperties;
};
type OnScrollParamsType = {
scrollTop: number;
clientHeight: number;
scrollHeight: number;
clientWidth: number;
scrollWidth?: number;
scrollLeft?: number;
scrollToColumn?: number;
_hasScrolledToColumnTarget?: boolean;
scrollToRow?: number;
_hasScrolledToRowTarget?: boolean;
};
2022-01-26 23:05:26 +00:00
type VisibleRowType = {
id: string;
offsetTop: number;
row: number;
};
type StateType = {
atBottom: boolean;
atTop: boolean;
2022-01-26 23:05:26 +00:00
hasRecentlyScrolled: boolean;
oneTimeScrollRow?: number;
2022-01-26 23:05:26 +00:00
visibleRows?: {
newestFullyVisible?: VisibleRowType;
oldestPartiallyVisibleMessageId?: string;
2022-01-26 23:05:26 +00:00
oldestFullyVisible?: VisibleRowType;
};
widthBreakpoint: WidthBreakpoint;
prevPropScrollToIndex?: number;
prevPropScrollToIndexCounter?: number;
propScrollToIndex?: number;
shouldShowScrollDownButton: boolean;
areUnreadBelowCurrentPosition: boolean;
2021-04-21 16:31:12 +00:00
2021-06-01 23:30:25 +00:00
hasDismissedDirectContactSpoofingWarning: boolean;
2021-04-21 16:31:12 +00:00
lastMeasuredWarningHeight: 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',
'clearChangedMessages',
2021-10-26 22:59:08 +00:00
'clearInvitedUuidsForNewlyCreatedGroup',
2021-08-18 13:34:22 +00:00
'closeContactSpoofingReview',
'setLoadCountdownStart',
'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',
'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.PureComponent<PropsType, StateType> {
2022-01-26 23:05:26 +00:00
private cellSizeCache = new CellMeasurerCache({
defaultHeight: 64,
fixedWidth: true,
});
2020-09-14 19:51:27 +00:00
2022-01-26 23:05:26 +00:00
private mostRecentWidth = 0;
2020-09-14 19:51:27 +00:00
2022-01-26 23:05:26 +00:00
private mostRecentHeight = 0;
2020-09-14 19:51:27 +00:00
2022-01-26 23:05:26 +00:00
private offsetFromBottom: number | undefined = 0;
2020-09-14 19:51:27 +00:00
2022-01-26 23:05:26 +00:00
private resizeFlag = false;
2020-09-14 19:51:27 +00:00
private readonly containerRef = React.createRef<HTMLDivElement>();
private readonly listRef = React.createRef<List>();
2020-09-14 19:51:27 +00:00
2022-01-26 23:05:26 +00:00
private loadCountdownTimeout: NodeJS.Timeout | null = null;
2020-09-14 19:51:27 +00:00
2022-01-26 23:05:26 +00:00
private hasRecentlyScrolledTimeout?: NodeJS.Timeout;
private delayedPeekTimeout?: NodeJS.Timeout;
private containerRefMerger = createRefMerger();
constructor(props: PropsType) {
super(props);
const { scrollToIndex, isIncomingMessageRequest } = this.props;
const oneTimeScrollRow = isIncomingMessageRequest
? undefined
: getLastSeenIndicatorRow(props);
// We only stick to the bottom if this is not an incoming message request.
const atBottom = !isIncomingMessageRequest;
this.state = {
atBottom,
atTop: false,
2022-01-26 23:05:26 +00:00
hasRecentlyScrolled: true,
oneTimeScrollRow,
propScrollToIndex: scrollToIndex,
prevPropScrollToIndex: scrollToIndex,
shouldShowScrollDownButton: false,
areUnreadBelowCurrentPosition: false,
2021-06-01 23:30:25 +00:00
hasDismissedDirectContactSpoofingWarning: false,
2021-04-21 16:31:12 +00:00
lastMeasuredWarningHeight: 0,
// This may be swiftly overridden.
widthBreakpoint: WidthBreakpoint.Wide,
};
}
public static getDerivedStateFromProps(
props: PropsType,
state: StateType
): StateType {
if (
isNumber(props.scrollToIndex) &&
(props.scrollToIndex !== state.prevPropScrollToIndex ||
props.scrollToIndexCounter !== state.prevPropScrollToIndexCounter)
) {
return {
...state,
propScrollToIndex: props.scrollToIndex,
prevPropScrollToIndex: props.scrollToIndex,
prevPropScrollToIndexCounter: props.scrollToIndexCounter,
};
}
return state;
}
2022-01-26 23:05:26 +00:00
private getList = (): List | null => {
if (!this.listRef) {
2020-09-14 19:51:27 +00:00
return null;
}
const { current } = this.listRef;
return current;
};
2022-01-26 23:05:26 +00:00
private getGrid = (): Grid | undefined => {
const list = this.getList();
if (!list) {
return;
}
return list.Grid;
};
2022-01-26 23:05:26 +00:00
private getScrollContainer = (): HTMLDivElement | undefined => {
2020-09-14 19:51:27 +00:00
// We're using an internal variable (_scrollingContainer)) here,
// so cannot rely on the public type.
2020-09-14 19:51:27 +00:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const grid: any = this.getGrid();
if (!grid) {
return;
}
return grid._scrollingContainer as HTMLDivElement;
};
2022-01-26 23:05:26 +00:00
private recomputeRowHeights = (row?: number): void => {
const list = this.getList();
if (!list) {
return;
}
list.recomputeRowHeights(row);
};
2022-01-26 23:05:26 +00:00
private onHeightOnlyChange = (): void => {
const grid = this.getGrid();
const scrollContainer = this.getScrollContainer();
if (!grid || !scrollContainer) {
return;
}
if (!isNumber(this.offsetFromBottom)) {
return;
}
const { clientHeight, scrollHeight, scrollTop } = scrollContainer;
const newOffsetFromBottom = Math.max(
0,
scrollHeight - clientHeight - scrollTop
);
const delta = newOffsetFromBottom - this.offsetFromBottom;
2020-09-14 19:51:27 +00:00
// TODO: DESKTOP-687
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(grid as any).scrollToPosition({
scrollTop: scrollContainer.scrollTop + delta,
});
};
2022-01-26 23:05:26 +00:00
private resize = (row?: number): void => {
this.offsetFromBottom = undefined;
this.resizeFlag = false;
if (isNumber(row) && row > 0) {
2021-12-17 01:14:21 +00:00
// This is a private interface we want to use.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.cellSizeCache as any).clearPlus(row, 0);
} else {
this.cellSizeCache.clearAll();
}
this.recomputeRowHeights(row || 0);
};
2022-01-26 23:05:26 +00:00
private resizeHeroRow = (): void => {
2020-05-27 21:37:06 +00:00
this.resize(0);
};
2022-01-26 23:05:26 +00:00
private resizeMessage = (messageId: string): void => {
const { items } = this.props;
if (!items || !items.length) {
return;
}
const index = items.findIndex(item => item === messageId);
if (index < 0) {
return;
}
const row = fromItemIndexToRow(index, this.props);
this.resize(row);
};
2022-01-26 23:05:26 +00:00
private onScroll = (data: OnScrollParamsType): void => {
// Ignore scroll events generated as react-virtualized recursively scrolls and
// re-measures to get us where we want to go.
if (
isNumber(data.scrollToRow) &&
data.scrollToRow >= 0 &&
!data._hasScrolledToRowTarget
) {
return;
}
// Sometimes react-virtualized ends up with some incorrect math - we've scrolled below
// what should be possible. In this case, we leave everything the same and ask
// react-virtualized to try again. Without this, we'll set atBottom to true and
// pop the user back down to the bottom.
const { clientHeight, scrollHeight, scrollTop } = data;
if (scrollTop + clientHeight > scrollHeight) {
return;
}
2022-01-26 23:05:26 +00:00
this.setState({ hasRecentlyScrolled: true });
if (this.hasRecentlyScrolledTimeout) {
clearTimeout(this.hasRecentlyScrolledTimeout);
}
this.hasRecentlyScrolledTimeout = setTimeout(() => {
this.setState({ hasRecentlyScrolled: false });
2022-01-28 18:31:20 +00:00
}, 3000);
2022-01-26 23:05:26 +00:00
this.updateScrollMetrics(data);
this.updateWithVisibleRows();
};
2022-01-26 23:05:26 +00:00
private onRowsRendered = (): void => {
// React Virtualized doesn't respect `scrollToIndex` in some cases, likely
// because it hasn't rendered that row yet.
const { oneTimeScrollRow } = this.state;
if (isNumber(oneTimeScrollRow)) {
this.getList()?.scrollToRow(oneTimeScrollRow);
}
};
private updateScrollMetrics = debounce(
(data: OnScrollParamsType) => {
const { clientHeight, clientWidth, scrollHeight, scrollTop } = data;
if (clientHeight <= 0 || scrollHeight <= 0) {
return;
}
const {
haveNewest,
haveOldest,
id,
isIncomingMessageRequest,
setIsNearBottom,
setLoadCountdownStart,
} = this.props;
if (
this.mostRecentHeight &&
clientHeight !== this.mostRecentHeight &&
this.mostRecentWidth &&
clientWidth === this.mostRecentWidth
) {
this.onHeightOnlyChange();
}
// If we've scrolled, we want to reset these
const oneTimeScrollRow = undefined;
const propScrollToIndex = undefined;
this.offsetFromBottom = Math.max(
0,
scrollHeight - clientHeight - scrollTop
);
// If there's an active message request, we won't stick to the bottom of the
// conversation as new messages come in.
const atBottom = isIncomingMessageRequest
? false
: haveNewest && this.offsetFromBottom <= AT_BOTTOM_THRESHOLD;
const isNearBottom =
haveNewest && this.offsetFromBottom <= NEAR_BOTTOM_THRESHOLD;
const atTop = scrollTop <= AT_TOP_THRESHOLD;
const loadCountdownStart = atTop && !haveOldest ? Date.now() : undefined;
if (this.loadCountdownTimeout) {
clearTimeout(this.loadCountdownTimeout);
this.loadCountdownTimeout = null;
}
if (isNumber(loadCountdownStart)) {
this.loadCountdownTimeout = setTimeout(
this.loadOlderMessages,
LOAD_COUNTDOWN
);
}
2020-09-14 19:51:27 +00:00
// Variable collision
// eslint-disable-next-line react/destructuring-assignment
if (loadCountdownStart !== this.props.loadCountdownStart) {
setLoadCountdownStart(id, loadCountdownStart);
}
2020-09-14 19:51:27 +00:00
// Variable collision
// eslint-disable-next-line react/destructuring-assignment
if (isNearBottom !== this.props.isNearBottom) {
setIsNearBottom(id, isNearBottom);
}
this.setState({
atBottom,
atTop,
oneTimeScrollRow,
propScrollToIndex,
});
},
50,
{ maxWait: 50 }
);
2022-01-26 23:05:26 +00:00
private updateVisibleRows = (): void => {
const scrollContainer = this.getScrollContainer();
if (!scrollContainer) {
return;
}
if (scrollContainer.clientHeight === 0) {
return;
}
const innerScrollContainer = scrollContainer.children[0];
if (!innerScrollContainer) {
return;
}
2022-01-26 23:05:26 +00:00
let newestFullyVisible: undefined | VisibleRowType;
let oldestPartiallyVisibleMessageId: undefined | string;
2022-01-26 23:05:26 +00:00
let oldestFullyVisible: undefined | VisibleRowType;
const { children } = innerScrollContainer;
2022-01-26 23:05:26 +00:00
const visibleTop = scrollContainer.scrollTop;
const visibleBottom = visibleTop + scrollContainer.clientHeight;
for (let i = children.length - 1; i >= 0; i -= 1) {
const child = children[i] as HTMLDivElement;
const { id, offsetTop, offsetHeight } = child;
if (!id) {
continue;
}
const bottom = offsetTop + offsetHeight;
if (bottom - AT_BOTTOM_THRESHOLD <= visibleBottom) {
const row = parseInt(child.getAttribute('data-row') || '-1', 10);
2022-01-26 23:05:26 +00:00
newestFullyVisible = { offsetTop, row, id };
break;
}
}
const max = children.length;
for (let i = 0; i < max; i += 1) {
const child = children[i] as HTMLDivElement;
2022-01-26 23:05:26 +00:00
const { id, offsetTop, offsetHeight } = child;
if (!id) {
continue;
}
2022-01-26 23:05:26 +00:00
const bottom = offsetTop + offsetHeight;
if (bottom >= visibleTop && !oldestPartiallyVisibleMessageId) {
oldestPartiallyVisibleMessageId = id;
2022-01-26 23:05:26 +00:00
}
2022-01-26 23:05:26 +00:00
if (offsetTop + AT_TOP_THRESHOLD >= visibleTop) {
oldestFullyVisible = {
offsetTop,
row: parseInt(child.getAttribute('data-row') || '-1', 10),
id,
};
break;
}
}
2022-01-26 23:05:26 +00:00
this.setState(oldState => {
const visibleRows = {
newestFullyVisible,
oldestPartiallyVisibleMessageId,
2022-01-26 23:05:26 +00:00
oldestFullyVisible,
};
// This avoids a render loop.
return isEqual(oldState.visibleRows, visibleRows)
? null
: { visibleRows };
});
};
2022-01-26 23:05:26 +00:00
private updateWithVisibleRows = debounce(
() => {
const {
unreadCount,
haveNewest,
haveOldest,
isLoadingMessages,
items,
loadNewerMessages,
markMessageRead,
} = this.props;
if (!items || items.length < 1) {
return;
}
this.updateVisibleRows();
2022-01-26 23:05:26 +00:00
const { visibleRows } = this.state;
if (!visibleRows) {
return;
}
2022-01-26 23:05:26 +00:00
const { newestFullyVisible, oldestFullyVisible } = visibleRows;
if (!newestFullyVisible) {
return;
}
2022-01-26 23:05:26 +00:00
markMessageRead(newestFullyVisible.id);
const newestRow = getRowCount(this.props) - 1;
const oldestRow = fromItemIndexToRow(0, this.props);
// Loading newer messages (that go below current messages) is pain-free and quick
// we'll just kick these off immediately.
if (
!isLoadingMessages &&
!haveNewest &&
2022-01-26 23:05:26 +00:00
newestFullyVisible.row > newestRow - LOAD_MORE_THRESHOLD
) {
const lastId = items[items.length - 1];
loadNewerMessages(lastId);
}
// Loading older messages is more destructive, as they requires a recalculation of
// all locations of things below. So we need to be careful with these loads.
// Generally we hid this behind a countdown spinner at the top of the window, but
// this is a special-case for the situation where the window is so large and that
// all the messages are visible.
2022-01-26 23:05:26 +00:00
const oldestVisible = Boolean(
oldestFullyVisible && oldestRow === oldestFullyVisible.row
);
const newestVisible = newestRow === newestFullyVisible.row;
if (oldestVisible && newestVisible && !haveOldest) {
this.loadOlderMessages();
}
const lastIndex = items.length - 1;
const lastItemRow = fromItemIndexToRow(lastIndex, this.props);
const areUnreadBelowCurrentPosition = Boolean(
isNumber(unreadCount) &&
unreadCount > 0 &&
2022-01-26 23:05:26 +00:00
(!haveNewest || newestFullyVisible.row < lastItemRow)
);
const shouldShowScrollDownButton = Boolean(
!haveNewest ||
areUnreadBelowCurrentPosition ||
2022-01-26 23:05:26 +00:00
newestFullyVisible.row < newestRow - SCROLL_DOWN_BUTTON_THRESHOLD
);
this.setState({
shouldShowScrollDownButton,
areUnreadBelowCurrentPosition,
});
},
500,
{ maxWait: 500 }
);
2022-01-26 23:05:26 +00:00
private loadOlderMessages = (): void => {
2021-11-11 22:43:05 +00:00
const { haveOldest, isLoadingMessages, items, loadOlderMessages } =
this.props;
if (this.loadCountdownTimeout) {
clearTimeout(this.loadCountdownTimeout);
this.loadCountdownTimeout = null;
}
if (isLoadingMessages || haveOldest || !items || items.length < 1) {
return;
}
const oldestId = items[0];
loadOlderMessages(oldestId);
};
2022-01-26 23:05:26 +00:00
private rowRenderer = ({
index: rowIndex,
key,
parent,
style,
}: Readonly<RowRendererParamsType>): JSX.Element => {
const {
id,
i18n,
haveOldest,
items,
renderItem,
2020-05-27 21:37:06 +00:00
renderHeroRow,
renderLastSeenIndicator,
renderTypingBubble,
unblurAvatar,
2020-08-07 00:50:54 +00:00
updateSharedGroups,
} = this.props;
const { lastMeasuredWarningHeight, widthBreakpoint } = this.state;
const commonProps = {
'data-row': rowIndex,
style: {
...style,
width: `${this.mostRecentWidth}px`,
},
role: 'row',
2019-08-15 14:59:56 +00:00
};
let rowContents: ReactChild;
switch (rowIndex) {
case getHeroRow(this.props):
rowContents = (
<div {...commonProps}>
{Timeline.getWarning(this.props, this.state) ? (
<div style={{ height: lastMeasuredWarningHeight }} />
) : null}
{renderHeroRow(
id,
this.resizeHeroRow,
unblurAvatar,
updateSharedGroups
)}
</div>
);
break;
case getLastSeenIndicatorRow(this.props):
rowContents = <div {...commonProps}>{renderLastSeenIndicator(id)}</div>;
break;
case getTypingBubbleRow(this.props):
rowContents = (
<div {...commonProps} className="module-timeline__message-container">
{renderTypingBubble(id)}
</div>
);
break;
default:
{
const itemIndex = fromRowToItemIndex(rowIndex, this.props);
if (typeof itemIndex !== 'number') {
throw new Error(
`Attempted to render item with undefined index - row ${rowIndex}`
);
}
const previousMessageId: undefined | string = items[itemIndex - 1];
const messageId = items[itemIndex];
const nextMessageId: undefined | string = items[itemIndex + 1];
const actionProps = getActions(this.props);
rowContents = (
<div
{...commonProps}
id={messageId}
className="module-timeline__message-container"
>
<ErrorBoundary
i18n={i18n}
showDebugLog={() => window.showDebugLog()}
>
{renderItem({
actionProps,
containerElementRef: this.containerRef,
containerWidthBreakpoint: widthBreakpoint,
conversationId: id,
isOldestTimelineItem: haveOldest && itemIndex === 0,
messageId,
nextMessageId,
onHeightChange: this.resizeMessage,
previousMessageId,
})}
</ErrorBoundary>
</div>
);
}
break;
}
return (
<CellMeasurer
cache={this.cellSizeCache}
columnIndex={0}
2021-09-28 18:32:54 +00:00
key={key}
parent={parent}
rowIndex={rowIndex}
width={this.mostRecentWidth}
>
{rowContents}
</CellMeasurer>
);
};
2022-01-26 23:05:26 +00:00
private scrollToBottom = (setFocus?: boolean): void => {
2019-11-07 21:36:16 +00:00
const { selectMessage, id, items } = this.props;
if (setFocus && items && items.length > 0) {
const lastIndex = items.length - 1;
const lastMessageId = items[lastIndex];
selectMessage(lastMessageId, id);
}
const oneTimeScrollRow =
items && items.length > 0 ? items.length - 1 : undefined;
this.setState({
propScrollToIndex: undefined,
oneTimeScrollRow,
});
};
2022-01-26 23:05:26 +00:00
private onClickScrollDownButton = (): void => {
2019-11-07 21:36:16 +00:00
this.scrollDown(false);
};
2022-01-26 23:05:26 +00:00
private scrollDown = (setFocus?: boolean): void => {
const {
haveNewest,
2019-11-07 21:36:16 +00:00
id,
isLoadingMessages,
items,
loadNewestMessages,
2019-11-07 21:36:16 +00:00
oldestUnreadIndex,
selectMessage,
} = this.props;
2019-11-07 21:36:16 +00:00
if (!items || items.length < 1) {
return;
}
const lastId = items[items.length - 1];
const lastSeenIndicatorRow = getLastSeenIndicatorRow(this.props);
2022-01-26 23:05:26 +00:00
const { visibleRows } = this.state;
if (!visibleRows) {
if (haveNewest) {
2019-11-07 21:36:16 +00:00
this.scrollToBottom(setFocus);
} else if (!isLoadingMessages) {
2019-11-07 21:36:16 +00:00
loadNewestMessages(lastId, setFocus);
}
return;
}
2022-01-26 23:05:26 +00:00
const { newestFullyVisible } = visibleRows;
if (
2022-01-26 23:05:26 +00:00
newestFullyVisible &&
isNumber(lastSeenIndicatorRow) &&
2022-01-26 23:05:26 +00:00
newestFullyVisible.row < lastSeenIndicatorRow
) {
2019-11-07 21:36:16 +00:00
if (setFocus && isNumber(oldestUnreadIndex)) {
const messageId = items[oldestUnreadIndex];
selectMessage(messageId, id);
}
this.setState({
oneTimeScrollRow: lastSeenIndicatorRow,
});
} else if (haveNewest) {
2019-11-07 21:36:16 +00:00
this.scrollToBottom(setFocus);
} else if (!isLoadingMessages) {
2019-11-07 21:36:16 +00:00
loadNewestMessages(lastId, setFocus);
}
};
public override componentDidMount(): void {
2019-08-07 00:40:25 +00:00
this.updateWithVisibleRows();
window.registerForActive(this.updateWithVisibleRows);
this.delayedPeekTimeout = setTimeout(() => {
const { id, peekGroupCallForTheFirstTime } = this.props;
peekGroupCallForTheFirstTime(id);
}, 500);
2019-08-07 00:40:25 +00:00
}
public override componentWillUnmount(): void {
const { delayedPeekTimeout } = this;
window.unregisterForActive(this.updateWithVisibleRows);
if (delayedPeekTimeout) {
clearTimeout(delayedPeekTimeout);
}
2019-08-07 00:40:25 +00:00
}
public override componentDidUpdate(
2021-04-21 16:31:12 +00:00
prevProps: Readonly<PropsType>,
prevState: Readonly<StateType>
): void {
const {
clearChangedMessages,
haveOldest,
id,
isIncomingMessageRequest,
items,
messageHeightChangeIndex,
messageHeightChangeBaton,
oldestUnreadIndex,
resetCounter,
scrollToIndex,
typingContactId,
} = this.props;
// We recompute the hero row's height if:
//
// 1. We just started showing it (the user has scrolled up to see the hero row)
// 2. Warnings were shown (they add padding to the hero for the floating warning)
const hadOldest = prevProps.haveOldest;
const hadWarning = Boolean(Timeline.getWarning(prevProps, prevState));
const haveWarning = Boolean(Timeline.getWarning(this.props, this.state));
const shouldRecomputeRowHeights =
(!hadOldest && haveOldest) || hadWarning !== haveWarning;
if (shouldRecomputeRowHeights) {
this.resizeHeroRow();
}
// There are a number of situations which can necessitate that we forget about row
// heights previously calculated. We reset the minimum number of rows to minimize
// unexpected changes to the scroll position. Those changes happen because
// react-virtualized doesn't know what to expect (variable row heights) when it
// renders, so it does have a fixed row it's attempting to scroll to, and you ask it
// to render a given point it space, it will do pretty random things.
if (
!prevProps.items ||
prevProps.items.length === 0 ||
resetCounter !== prevProps.resetCounter
) {
2019-11-07 21:36:16 +00:00
if (prevProps.items && prevProps.items.length > 0) {
this.resize();
}
// We want to come in at the top of the conversation if it's a message request
const oneTimeScrollRow = isIncomingMessageRequest
? undefined
: getLastSeenIndicatorRow(this.props);
const atBottom = !isIncomingMessageRequest;
2020-09-14 19:51:27 +00:00
// TODO: DESKTOP-688
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
oneTimeScrollRow,
atBottom,
propScrollToIndex: scrollToIndex,
prevPropScrollToIndex: scrollToIndex,
});
return;
}
let resizeStartRow: number | undefined;
if (isNumber(messageHeightChangeIndex)) {
resizeStartRow = fromItemIndexToRow(messageHeightChangeIndex, this.props);
clearChangedMessages(id, messageHeightChangeBaton);
}
if (
items !== prevProps.items ||
oldestUnreadIndex !== prevProps.oldestUnreadIndex ||
Boolean(typingContactId) !== Boolean(prevProps.typingContactId)
) {
2020-09-14 19:51:27 +00:00
const { atTop } = this.state;
// This clause handles prepended messages when user scrolls up. New
// messages are added to `items`, but we want to keep the scroll position
// at the first previously visible message even though the row numbers
// have now changed.
2020-09-14 19:51:27 +00:00
if (atTop) {
const oldFirstIndex = 0;
const oldFirstId = prevProps.items[oldFirstIndex];
2019-08-15 14:59:56 +00:00
const newFirstIndex = items.findIndex(item => item === oldFirstId);
if (newFirstIndex < 0) {
this.resize();
return;
}
const newRow = fromItemIndexToRow(newFirstIndex, this.props);
2022-01-26 23:05:26 +00:00
if (newRow > 0) {
2019-08-15 14:59:56 +00:00
// We're loading more new messages at the top; we want to stay at the top
this.resize();
2020-09-14 19:51:27 +00:00
// TODO: DESKTOP-688
// eslint-disable-next-line react/no-did-update-set-state
2019-08-15 14:59:56 +00:00
this.setState({ oneTimeScrollRow: newRow });
return;
}
2019-08-15 14:59:56 +00:00
}
// Compare current rows against previous rows to identify the number of
// consecutive rows (from start of the list) the are the same in both
// lists.
const rowsIterator = getEphemeralRows(this.props);
const prevRowsIterator = getEphemeralRows(prevProps);
2019-08-15 14:59:56 +00:00
let firstChangedRow = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
const row = rowsIterator.next();
if (row.done) {
break;
}
2019-08-15 14:59:56 +00:00
const prevRow = prevRowsIterator.next();
if (prevRow.done) {
break;
}
2019-08-15 14:59:56 +00:00
if (prevRow.value !== row.value) {
break;
}
firstChangedRow += 1;
}
// If either:
//
// - Row count has changed after props update
// - There are some different rows (and the loop above was interrupted)
//
// Recompute heights of all rows starting from the first changed row or
// the last row in the previous row list.
if (!rowsIterator.next().done || !prevRowsIterator.next().done) {
resizeStartRow = Math.min(
resizeStartRow ?? firstChangedRow,
firstChangedRow
);
}
}
if (this.resizeFlag) {
this.resize();
return;
}
if (resizeStartRow !== undefined) {
this.resize(resizeStartRow);
}
this.updateWithVisibleRows();
}
2022-01-26 23:05:26 +00:00
private getScrollTarget = (): number | undefined => {
const { oneTimeScrollRow, atBottom, propScrollToIndex } = this.state;
const rowCount = getRowCount(this.props);
const targetMessageRow = isNumber(propScrollToIndex)
? fromItemIndexToRow(propScrollToIndex, this.props)
: undefined;
const scrollToBottom = atBottom ? rowCount - 1 : undefined;
if (isNumber(targetMessageRow)) {
return targetMessageRow;
}
if (isNumber(oneTimeScrollRow)) {
return oneTimeScrollRow;
}
return scrollToBottom;
};
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') {
this.setState({ oneTimeScrollRow: 0 });
const firstMessageId = items[0];
selectMessage(firstMessageId, id);
event.preventDefault();
event.stopPropagation();
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,
haveOldest,
2021-03-03 20:09:58 +00:00
i18n,
id,
invitedContactsForNewlyCreatedGroup,
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,
onBlock,
onBlockAndReportSpam,
2021-04-21 16:31:12 +00:00
onDelete,
onUnblock,
showContactModal,
2021-06-01 23:30:25 +00:00
removeMember,
reviewGroupMemberNameCollision,
2021-04-21 16:31:12 +00:00
reviewMessageRequestNameCollision,
2021-11-20 15:41:21 +00:00
theme,
2021-03-03 20:09:58 +00:00
} = this.props;
const {
shouldShowScrollDownButton,
areUnreadBelowCurrentPosition,
2022-01-26 23:05:26 +00:00
hasRecentlyScrolled,
lastMeasuredWarningHeight,
visibleRows,
widthBreakpoint,
} = this.state;
const rowCount = getRowCount(this.props);
const scrollToIndex = this.getScrollTarget();
2019-08-20 19:34:52 +00:00
if (!items || rowCount === 0) {
return null;
}
2022-01-26 23:05:26 +00:00
let floatingHeader: ReactNode;
const oldestPartiallyVisibleMessageId =
visibleRows?.oldestPartiallyVisibleMessageId;
// 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 (oldestPartiallyVisibleMessageTimestamp) {
2022-01-26 23:05:26 +00:00
floatingHeader = (
<TimelineFloatingHeader
i18n={i18n}
isLoading={isLoadingMessages}
style={
lastMeasuredWarningHeight
? { marginTop: lastMeasuredWarningHeight }
: undefined
}
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 autoSizer = (
<AutoSizer>
{({ height, width }) => {
if (this.mostRecentWidth && this.mostRecentWidth !== width) {
this.resizeFlag = true;
setTimeout(this.resize, 0);
} else if (
this.mostRecentHeight &&
this.mostRecentHeight !== height
) {
setTimeout(this.onHeightOnlyChange, 0);
}
this.mostRecentWidth = width;
this.mostRecentHeight = height;
return (
<List
deferredMeasurementCache={this.cellSizeCache}
height={height}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScroll={this.onScroll as any}
overscanRowCount={10}
2022-01-26 23:05:26 +00:00
onRowsRendered={this.onRowsRendered}
ref={this.listRef}
rowCount={rowCount}
rowHeight={this.cellSizeCache.rowHeight}
rowRenderer={this.rowRenderer}
scrollToAlignment="start"
scrollToIndex={scrollToIndex}
tabIndex={-1}
width={width}
style={{
// `overlay` is [a nonstandard value][0] so it's not supported. See [this
// issue][1].
//
// [0]: https://developer.mozilla.org/en-US/docs/Web/CSS/overflow#values
// [1]: https://github.com/frenic/csstype/issues/62#issuecomment-937238313
//
// eslint-disable-next-line @typescript-eslint/no-explicit-any
overflowY: 'overlay' as any,
}}
/>
);
}}
</AutoSizer>
);
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 = (
<Measure
bounds
onResize={({ bounds }) => {
if (!bounds) {
assert(false, 'We should be measuring the bounds');
return;
}
this.setState({ lastMeasuredWarningHeight: bounds.height });
}}
>
{({ measureRef }) => (
<TimelineWarnings ref={measureRef}>
2021-06-01 23:30:25 +00:00
<TimelineWarning i18n={i18n} onClose={onClose}>
2021-04-21 16:31:12 +00:00
<TimelineWarning.IconContainer>
<TimelineWarning.GenericIcon />
</TimelineWarning.IconContainer>
2021-06-01 23:30:25 +00:00
<TimelineWarning.Text>{text}</TimelineWarning.Text>
2021-04-21 16:31:12 +00:00
</TimelineWarning>
</TimelineWarnings>
)}
</Measure>
);
}
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 }) => {
this.setState({
widthBreakpoint: getWidthBreakpoint(bounds?.width || 0),
});
}}
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={this.containerRefMerger(measureRef)}
>
{timelineWarning}
2022-01-26 23:05:26 +00:00
{floatingHeader}
{autoSizer}
{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
}
}