// Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactChild, ReactNode } from 'react'; import React, { useRef } from 'react'; import classNames from 'classnames'; import { noop } from 'lodash'; import { Avatar, AvatarSize } from '../Avatar'; import { ContactName } from './ContactName'; import { ContextMenu } from '../ContextMenu'; import { Time } from '../Time'; import type { Props as MessagePropsType, PropsData as MessagePropsDataType, } from './Message'; import { Message } from './Message'; import type { LocalizerType, ThemeType } from '../../types/Util'; import type { ConversationType } from '../../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../../state/selectors/badges'; import { groupBy } from '../../util/mapUtil'; import type { ContactNameColorType } from '../../types/Colors'; import { SendStatus } from '../../messages/MessageSendState'; import { WidthBreakpoint } from '../_util'; import * as log from '../../logging/log'; import { formatDateTimeLong } from '../../util/timestamp'; import { DurationInSeconds } from '../../util/durations'; import { format as formatRelativeTime } from '../../util/expirationTimer'; import { missingCaseError } from '../../util/missingCaseError'; import { PanelRow } from './conversation-details/PanelRow'; import { PanelSection } from './conversation-details/PanelSection'; import { ConversationDetailsIcon, IconType, } from './conversation-details/ConversationDetailsIcon'; export type Contact = Pick< ConversationType, | 'acceptedMessageRequest' | 'avatarPath' | 'badges' | 'color' | 'id' | 'isMe' | 'phoneNumber' | 'profileName' | 'sharedGroupNames' | 'title' | 'unblurredAvatarPath' > & { status?: SendStatus; statusTimestamp?: number; isOutgoingKeyError: boolean; isUnidentifiedDelivery: boolean; errors?: Array; }; export type PropsData = { // An undefined status means they were the sender and it's an incoming message. If // `undefined` is a status, there should be no other items in the array; if there are // any defined statuses, `undefined` shouldn't be present. contacts: ReadonlyArray; contactNameColor?: ContactNameColorType; errors: Array; message: Omit< MessagePropsDataType, 'renderingContext' | 'menu' | 'contextMenu' | 'showMenu' >; receivedAt: number; sentAt: number; i18n: LocalizerType; platform: string; theme: ThemeType; getPreferredBadge: PreferredBadgeSelectorType; } & Pick; export type PropsSmartActions = Pick; export type PropsReduxActions = Pick< MessagePropsType, | 'checkForAccount' | 'clearTargetedMessage' | 'doubleCheckMissingQuoteReference' | 'kickOffAttachmentDownload' | 'markAttachmentAsCorrupted' | 'messageExpanded' | 'openGiftBadge' | 'pushPanelForConversation' | 'retryMessageSend' | 'saveAttachment' | 'showContactModal' | 'showConversation' | 'showEditHistoryModal' | 'showExpiredIncomingTapToViewToast' | 'showExpiredOutgoingTapToViewToast' | 'showLightbox' | 'showLightboxForViewOnceMedia' | 'showSpoiler' | 'startConversation' | 'viewStory' > & { toggleSafetyNumberModal: (contactId: string) => void; }; export type Props = PropsData & PropsSmartActions & PropsReduxActions; const contactSortCollator = new Intl.Collator(); const _keyForError = (error: Error): string => { return `${error.name}-${error.message}`; }; export function MessageDetail({ contacts, errors, message, receivedAt, sentAt, checkForAccount, clearTargetedMessage, contactNameColor, doubleCheckMissingQuoteReference, getPreferredBadge, i18n, interactionMode, kickOffAttachmentDownload, markAttachmentAsCorrupted, messageExpanded, openGiftBadge, platform, pushPanelForConversation, retryMessageSend, renderAudioAttachment, saveAttachment, showContactModal, showConversation, showEditHistoryModal, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, showLightbox, showLightboxForViewOnceMedia, showSpoiler, startConversation, theme, toggleSafetyNumberModal, viewStory, }: Props): JSX.Element { const messageDetailRef = useRef(null); function renderAvatar(contact: Contact): JSX.Element { const { acceptedMessageRequest, avatarPath, badges, color, isMe, phoneNumber, profileName, sharedGroupNames, title, unblurredAvatarPath, } = contact; return ( ); } function renderContact(contact: Contact): JSX.Element { const contactErrors = contact.errors || []; const errorComponent = contact.isOutgoingKeyError ? (
) : null; const unidentifiedDeliveryComponent = contact.isUnidentifiedDelivery ? (
) : null; return (
{renderAvatar(contact)}
{contactErrors.map(contactError => (
{contactError.message}
))}
{errorComponent} {unidentifiedDeliveryComponent} {contact.statusTimestamp && ( )}
); } function renderContactGroupHeaderText( sendStatus: undefined | SendStatus ): string { if (sendStatus === undefined) { return i18n('icu:from'); } switch (sendStatus) { case SendStatus.Failed: return i18n('icu:MessageDetailsHeader--Failed'); case SendStatus.Pending: return i18n('icu:MessageDetailsHeader--Pending'); case SendStatus.Sent: return i18n('icu:MessageDetailsHeader--Sent'); case SendStatus.Delivered: return i18n('icu:MessageDetailsHeader--Delivered'); case SendStatus.Read: return i18n('icu:MessageDetailsHeader--Read'); case SendStatus.Viewed: return i18n('icu:MessageDetailsHeader--Viewed'); default: throw missingCaseError(sendStatus); } } function renderContactGroup( sendStatus: undefined | SendStatus, statusContacts: undefined | ReadonlyArray ): ReactNode { if (!statusContacts || !statusContacts.length) { return null; } const sortedContacts = [...statusContacts].sort((a, b) => contactSortCollator.compare(a.title, b.title) ); const headerText = renderContactGroupHeaderText(sendStatus); return (
{headerText}
{sortedContacts.map(contact => renderContact(contact))}
); } function renderContacts(): ReactChild { // This assumes that the list either contains one sender (a status of `undefined`) or // 1+ contacts with `SendStatus`es, but it doesn't check that assumption. const contactsBySendStatus = groupBy(contacts, contact => contact.status); return (
{[ undefined, SendStatus.Failed, SendStatus.Viewed, SendStatus.Read, SendStatus.Delivered, SendStatus.Sent, SendStatus.Pending, ].map(sendStatus => renderContactGroup(sendStatus, contactsBySendStatus.get(sendStatus)) )}
); } const timeRemaining = message.expirationTimestamp ? DurationInSeconds.fromMillis(message.expirationTimestamp - Date.now()) : undefined; return (
{ log.warn('MessageDetail: scrollToQuotedMessage called!'); }} showContactModal={showContactModal} showExpiredIncomingTapToViewToast={ showExpiredIncomingTapToViewToast } showExpiredOutgoingTapToViewToast={ showExpiredOutgoingTapToViewToast } showLightbox={showLightbox} startConversation={startConversation} theme={theme} viewStory={viewStory} onToggleSelect={noop} onReplyToMessage={noop} />
{(errors || []).map(error => ( ))} {receivedAt && message.direction === 'incoming' ? ( ) : null} {timeRemaining && timeRemaining > 0 && ( )}
{i18n('icu:error')} {' '} {error.message}{' '}
{i18n('icu:sent')} { void window.navigator.clipboard.writeText( String(sentAt) ); }, }, ]} > <> {' '} ({sentAt})
{i18n('icu:received')} {' '} ({receivedAt})
{i18n('icu:MessageDetail--disappears-in')} {formatRelativeTime(i18n, timeRemaining, { largest: 2, })}
{message.isEditedMessage && ( } label={i18n('icu:MessageDetail__view-edits')} onClick={() => { showEditHistoryModal?.(message.id); }} /> )} {renderContacts()}
); }