// Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactChild, ReactNode } from 'react'; import React 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'; 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; theme: ThemeType; getPreferredBadge: PreferredBadgeSelectorType; } & Pick; export type PropsSmartActions = Pick; export type PropsReduxActions = Pick< MessagePropsType, | 'checkForAccount' | 'clearSelectedMessage' | 'doubleCheckMissingQuoteReference' | 'kickOffAttachmentDownload' | 'markAttachmentAsCorrupted' | 'openGiftBadge' | 'pushPanelForConversation' | 'saveAttachment' | 'showContactModal' | 'showConversation' | 'showExpiredIncomingTapToViewToast' | 'showExpiredOutgoingTapToViewToast' | 'showLightbox' | 'showLightboxForViewOnceMedia' | '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 class MessageDetail extends React.Component { private readonly focusRef = React.createRef(); private readonly messageContainerRef = React.createRef(); public override componentDidMount(): void { // When this component is created, it's initially not part of the DOM, and then it's // added off-screen and animated in. This ensures that the focus takes. setTimeout(() => { if (this.focusRef.current) { this.focusRef.current.focus(); } }); } public renderAvatar(contact: Contact): JSX.Element { const { getPreferredBadge, i18n, theme } = this.props; const { acceptedMessageRequest, avatarPath, badges, color, isMe, phoneNumber, profileName, sharedGroupNames, title, unblurredAvatarPath, } = contact; return ( ); } public renderContact(contact: Contact): JSX.Element { const { i18n, toggleSafetyNumberModal } = this.props; const errors = contact.errors || []; const errorComponent = contact.isOutgoingKeyError ? (
) : null; const unidentifiedDeliveryComponent = contact.isUnidentifiedDelivery ? (
) : null; return (
{this.renderAvatar(contact)}
{errors.map(error => (
{error.message}
))}
{errorComponent} {unidentifiedDeliveryComponent} {contact.statusTimestamp && ( )}
); } private renderContactGroup( sendStatus: undefined | SendStatus, contacts: undefined | ReadonlyArray ): ReactNode { const { i18n } = this.props; if (!contacts || !contacts.length) { return null; } const i18nKey = sendStatus === undefined ? 'from' : `MessageDetailsHeader--${sendStatus}`; const sortedContacts = [...contacts].sort((a, b) => contactSortCollator.compare(a.title, b.title) ); return (
{/* eslint-disable-next-line local-rules/valid-i18n-keys */} {i18n(i18nKey)}
{sortedContacts.map(contact => this.renderContact(contact))}
); } private 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 { contacts } = this.props; const contactsBySendStatus = groupBy(contacts, contact => contact.status); return (
{[ undefined, SendStatus.Failed, SendStatus.Viewed, SendStatus.Read, SendStatus.Delivered, SendStatus.Sent, SendStatus.Pending, ].map(sendStatus => this.renderContactGroup( sendStatus, contactsBySendStatus.get(sendStatus) ) )}
); } public override render(): JSX.Element { const { errors, message, receivedAt, sentAt, checkForAccount, clearSelectedMessage, contactNameColor, showLightboxForViewOnceMedia, doubleCheckMissingQuoteReference, getPreferredBadge, i18n, interactionMode, kickOffAttachmentDownload, markAttachmentAsCorrupted, openGiftBadge, pushPanelForConversation, renderAudioAttachment, saveAttachment, showContactModal, showConversation, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, showLightbox, startConversation, theme, viewStory, } = this.props; const timeRemaining = message.expirationTimestamp ? DurationInSeconds.fromMillis(message.expirationTimestamp - Date.now()) : undefined; return ( // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
{ log.warn('MessageDetail: scrollToQuotedMessage called!'); }} showContactModal={showContactModal} showExpiredIncomingTapToViewToast={ showExpiredIncomingTapToViewToast } showExpiredOutgoingTapToViewToast={ showExpiredOutgoingTapToViewToast } showLightbox={showLightbox} startConversation={startConversation} theme={theme} viewStory={viewStory} />
{(errors || []).map(error => ( ))} {receivedAt && message.direction === 'incoming' ? ( ) : null} {timeRemaining && timeRemaining > 0 && ( )}
{i18n('error')} {' '} {error.message}{' '}
{i18n('sent')} { void window.navigator.clipboard.writeText( String(sentAt) ); }, }, ]} > <> {' '} ({sentAt})
{i18n('received')} {' '} ({receivedAt})
{i18n('MessageDetail--disappears-in')} {formatRelativeTime(i18n, timeRemaining, { largest: 2, })}
{this.renderContacts()}
); } }