signal-desktop/ts/state/smart/Timeline.tsx

332 lines
10 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
2021-06-01 23:30:25 +00:00
import { isEmpty, mapValues, pick } from 'lodash';
import type { RefObject } from 'react';
import React from 'react';
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import type {
2021-08-11 16:23:21 +00:00
PropsActionsType as TimelineActionsType,
2021-06-01 23:30:25 +00:00
ContactSpoofingReviewPropType,
2021-04-21 16:31:12 +00:00
WarningType as TimelineWarningType,
2021-10-05 16:47:06 +00:00
PropsType as ComponentPropsType,
2021-04-21 16:31:12 +00:00
} from '../../components/conversation/Timeline';
import { Timeline } from '../../components/conversation/Timeline';
import type { StateType } from '../reducer';
import type { ConversationType } from '../ducks/conversations';
2021-11-20 15:41:21 +00:00
import { getIntl, getTheme } from '../selectors/user';
import {
2021-10-26 22:59:08 +00:00
getConversationByUuidSelector,
getConversationMessagesSelector,
getConversationSelector,
2021-04-21 16:31:12 +00:00
getConversationsByTitleSelector,
2021-03-03 20:09:58 +00:00
getInvitedContactsForNewlyCreatedGroup,
2022-01-26 23:05:26 +00:00
getMessageSelector,
2019-11-07 21:36:16 +00:00
getSelectedMessage,
} from '../selectors/conversations';
import { SmartTimelineItem } from './TimelineItem';
import { SmartContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
import type { PropsType as SmartContactSpoofingReviewDialogPropsType } from './ContactSpoofingReviewDialog';
import { SmartTypingBubble } from './TypingBubble';
2020-05-27 21:37:06 +00:00
import { SmartHeroRow } from './HeroRow';
import { renderAudioAttachment } from './renderAudioAttachment';
import { renderEmojiPicker } from './renderEmojiPicker';
import { renderReactionPicker } from './renderReactionPicker';
2021-06-01 23:30:25 +00:00
import { getOwn } from '../../util/getOwn';
import { assertDev } from '../../util/assert';
2021-06-01 23:30:25 +00:00
import { missingCaseError } from '../../util/missingCaseError';
import { getGroupMemberships } from '../../util/getGroupMemberships';
import {
dehydrateCollisionsWithConversations,
getCollisionsFromMemberships,
invertIdsByTitle,
} from '../../util/groupMemberNameCollisions';
import { ContactSpoofingType } from '../../util/contactSpoofing';
import type { UnreadIndicatorPlacement } from '../../util/timelineUtil';
import type { WidthBreakpoint } from '../../components/_util';
2021-11-20 15:41:21 +00:00
import { getPreferredBadgeSelector } from '../selectors/badges';
2022-10-03 23:43:44 +00:00
import { markViewed } from '../ducks/conversations';
2021-04-21 16:31:12 +00:00
type ExternalProps = {
id: string;
// Note: most action creators are not wired into redux; for now they
// are provided by ConversationView in setupTimeline().
};
2021-10-05 16:47:06 +00:00
export type TimelinePropsType = ExternalProps &
Pick<
ComponentPropsType,
| 'acknowledgeGroupMemberNameCollisions'
| 'contactSupport'
| 'blockGroupLinkRequests'
2021-10-05 16:47:06 +00:00
| 'deleteMessage'
| 'deleteMessageForEveryone'
| 'displayTapToViewMessage'
| 'downloadAttachment'
| 'downloadNewVersion'
| 'kickOffAttachmentDownload'
| 'learnMoreAboutDeliveryIssue'
| 'loadNewerMessages'
| 'loadNewestMessages'
| 'loadOlderMessages'
| 'markAttachmentAsCorrupted'
| 'markMessageRead'
| 'onBlock'
| 'onBlockAndReportSpam'
| 'onDelete'
| 'onUnblock'
| 'openConversation'
2022-05-11 20:59:58 +00:00
| 'openGiftBadge'
2021-10-05 16:47:06 +00:00
| 'openLink'
| 'reactToMessage'
| 'removeMember'
| 'replyToMessage'
| 'retryDeleteForEveryone'
2021-10-05 16:47:06 +00:00
| 'retrySend'
| 'scrollToQuotedMessage'
| 'showContactDetail'
| 'showContactModal'
| 'showExpiredIncomingTapToViewToast'
| 'showExpiredOutgoingTapToViewToast'
| 'showForwardMessageModal'
| 'showIdentity'
| 'showMessageDetail'
| 'showVisualAttachment'
| 'startConversation'
2021-10-05 16:47:06 +00:00
| 'unblurAvatar'
| 'updateSharedGroups'
>;
function renderItem({
actionProps,
containerElementRef,
containerWidthBreakpoint,
conversationId,
2022-01-26 23:05:26 +00:00
isOldestTimelineItem,
messageId,
nextMessageId,
previousMessageId,
unreadIndicatorPlacement,
}: {
actionProps: TimelineActionsType;
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 {
2019-11-07 21:36:16 +00:00
return (
2021-08-11 16:23:21 +00:00
<SmartTimelineItem
2019-11-07 21:36:16 +00:00
{...actionProps}
containerElementRef={containerElementRef}
containerWidthBreakpoint={containerWidthBreakpoint}
2019-11-07 21:36:16 +00:00
conversationId={conversationId}
2022-01-26 23:05:26 +00:00
isOldestTimelineItem={isOldestTimelineItem}
messageId={messageId}
previousMessageId={previousMessageId}
nextMessageId={nextMessageId}
renderEmojiPicker={renderEmojiPicker}
renderReactionPicker={renderReactionPicker}
renderAudioAttachment={renderAudioAttachment}
unreadIndicatorPlacement={unreadIndicatorPlacement}
/>
);
}
function renderContactSpoofingReviewDialog(
props: SmartContactSpoofingReviewDialogPropsType
): JSX.Element {
return <SmartContactSpoofingReviewDialog {...props} />;
}
2020-08-07 00:50:54 +00:00
function renderHeroRow(
id: string,
unblurAvatar: () => void,
2020-08-07 00:50:54 +00:00
updateSharedGroups: () => unknown
): JSX.Element {
return (
<SmartHeroRow
2020-08-07 00:50:54 +00:00
id={id}
unblurAvatar={unblurAvatar}
2020-08-07 00:50:54 +00:00
updateSharedGroups={updateSharedGroups}
/>
);
2020-05-27 21:37:06 +00:00
}
function renderTypingBubble(id: string): JSX.Element {
2021-08-11 16:23:21 +00:00
return <SmartTypingBubble id={id} />;
}
2021-04-21 16:31:12 +00:00
const getWarning = (
conversation: Readonly<ConversationType>,
state: Readonly<StateType>
): undefined | TimelineWarningType => {
2021-06-01 23:30:25 +00:00
switch (conversation.type) {
case 'direct':
if (!conversation.acceptedMessageRequest && !conversation.isBlocked) {
2021-11-11 22:43:05 +00:00
const getConversationsWithTitle =
getConversationsByTitleSelector(state);
2021-06-01 23:30:25 +00:00
const conversationsWithSameTitle = getConversationsWithTitle(
conversation.title
);
assertDev(
2021-06-01 23:30:25 +00:00
conversationsWithSameTitle.length,
'Expected at least 1 conversation with the same title (this one)'
);
const safeConversation = conversationsWithSameTitle.find(
otherConversation =>
otherConversation.acceptedMessageRequest &&
otherConversation.type === 'direct' &&
otherConversation.id !== conversation.id
);
if (safeConversation) {
return {
type: ContactSpoofingType.DirectConversationWithSameTitle,
safeConversation,
};
}
}
return undefined;
case 'group': {
if (conversation.left || conversation.groupVersion !== 2) {
return undefined;
}
2021-04-21 16:31:12 +00:00
2021-10-26 22:59:08 +00:00
const getConversationByUuid = getConversationByUuidSelector(state);
2021-06-01 23:30:25 +00:00
const { memberships } = getGroupMemberships(
conversation,
2021-10-26 22:59:08 +00:00
getConversationByUuid
2021-06-01 23:30:25 +00:00
);
const groupNameCollisions = getCollisionsFromMemberships(memberships);
const hasGroupMembersWithSameName = !isEmpty(groupNameCollisions);
if (hasGroupMembersWithSameName) {
return {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle,
acknowledgedGroupNameCollisions:
conversation.acknowledgedGroupNameCollisions,
2021-11-11 22:43:05 +00:00
groupNameCollisions:
dehydrateCollisionsWithConversations(groupNameCollisions),
2021-06-01 23:30:25 +00:00
};
}
return undefined;
}
default:
throw missingCaseError(conversation);
2021-06-01 23:30:25 +00:00
}
2021-04-21 16:31:12 +00:00
};
const getContactSpoofingReview = (
selectedConversationId: string,
state: Readonly<StateType>
2021-06-01 23:30:25 +00:00
): undefined | ContactSpoofingReviewPropType => {
2021-04-21 16:31:12 +00:00
const { contactSpoofingReview } = state.conversations;
if (!contactSpoofingReview) {
return undefined;
}
const conversationSelector = getConversationSelector(state);
2021-10-26 22:59:08 +00:00
const getConversationByUuid = getConversationByUuidSelector(state);
2021-06-01 23:30:25 +00:00
const currentConversation = conversationSelector(selectedConversationId);
switch (contactSpoofingReview.type) {
case ContactSpoofingType.DirectConversationWithSameTitle:
return {
type: ContactSpoofingType.DirectConversationWithSameTitle,
possiblyUnsafeConversation: currentConversation,
safeConversation: conversationSelector(
contactSpoofingReview.safeConversationId
),
};
case ContactSpoofingType.MultipleGroupMembersWithSameTitle: {
assertDev(
currentConversation.type === 'group',
'MultipleGroupMembersWithSameTitle: expects group conversation'
);
2021-06-01 23:30:25 +00:00
const { memberships } = getGroupMemberships(
currentConversation,
2021-10-26 22:59:08 +00:00
getConversationByUuid
2021-06-01 23:30:25 +00:00
);
const groupNameCollisions = getCollisionsFromMemberships(memberships);
const previouslyAcknowledgedTitlesById = invertIdsByTitle(
currentConversation.acknowledgedGroupNameCollisions
2021-06-01 23:30:25 +00:00
);
const collisionInfoByTitle = mapValues(
groupNameCollisions,
conversations =>
conversations.map(conversation => ({
conversation,
oldName: getOwn(previouslyAcknowledgedTitlesById, conversation.id),
}))
);
return {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle,
collisionInfoByTitle,
};
}
default:
throw missingCaseError(contactSpoofingReview);
}
2021-04-21 16:31:12 +00:00
};
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id, ...actions } = props;
const conversation = getConversationSelector(state)(id);
const conversationMessages = getConversationMessagesSelector(state)(id);
2019-11-07 21:36:16 +00:00
const selectedMessage = getSelectedMessage(state);
2022-01-26 23:05:26 +00:00
const messageSelector = getMessageSelector(state);
const getTimestampForMessage = (messageId: string): undefined | number =>
messageSelector(messageId)?.timestamp;
2022-01-26 23:05:26 +00:00
return {
id,
...pick(conversation, ['unreadCount', 'isGroupV1AndDisabled']),
isConversationSelected: state.conversations.selectedConversationId === id,
isIncomingMessageRequest: Boolean(
conversation.messageRequestsEnabled &&
!conversation.acceptedMessageRequest
),
isSomeoneTyping: Boolean(conversation.typingContactId),
...conversationMessages,
2021-11-11 22:43:05 +00:00
invitedContactsForNewlyCreatedGroup:
getInvitedContactsForNewlyCreatedGroup(state),
2019-11-07 21:36:16 +00:00
selectedMessageId: selectedMessage ? selectedMessage.id : undefined,
2021-04-21 16:31:12 +00:00
warning: getWarning(conversation, state),
contactSpoofingReview: getContactSpoofingReview(id, state),
2022-01-26 23:05:26 +00:00
getTimestampForMessage,
2021-11-20 15:41:21 +00:00
getPreferredBadge: getPreferredBadgeSelector(state),
i18n: getIntl(state),
2021-11-20 15:41:21 +00:00
theme: getTheme(state),
renderItem,
renderContactSpoofingReviewDialog,
2020-05-27 21:37:06 +00:00
renderHeroRow,
renderTypingBubble,
2022-10-03 23:43:44 +00:00
markViewed,
...actions,
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartTimeline = smart(Timeline);