// Copyright 2018-2022 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 { 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'; export type Contact = Pick< ConversationType, | 'acceptedMessageRequest' | 'avatarPath' | 'badges' | 'color' | 'id' | 'isMe' | 'name' | '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; receivedAt: number; sentAt: number; showSafetyNumber: (contactId: string) => void; i18n: LocalizerType; theme: ThemeType; getPreferredBadge: PreferredBadgeSelectorType; } & Pick; export type PropsBackboneActions = Pick< MessagePropsType, | 'displayTapToViewMessage' | 'kickOffAttachmentDownload' | 'markAttachmentAsCorrupted' | 'markViewed' | 'openConversation' | 'openGiftBadge' | 'openLink' | 'reactToMessage' | 'renderAudioAttachment' | 'renderEmojiPicker' | 'renderReactionPicker' | 'replyToMessage' | 'retryDeleteForEveryone' | 'retrySend' | 'showContactDetail' | 'showContactModal' | 'showExpiredIncomingTapToViewToast' | 'showExpiredOutgoingTapToViewToast' | 'showForwardMessageModal' | 'showVisualAttachment' | 'startConversation' >; export type PropsReduxActions = Pick< MessagePropsType, | 'clearSelectedMessage' | 'doubleCheckMissingQuoteReference' | 'checkForAccount' >; export type ExternalProps = PropsData & PropsBackboneActions; export type Props = PropsData & PropsBackboneActions & 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, name, phoneNumber, profileName, sharedGroupNames, title, unblurredAvatarPath, } = contact; return ( ); } public renderContact(contact: Contact): JSX.Element { const { i18n, showSafetyNumber } = 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 (
{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, displayTapToViewMessage, doubleCheckMissingQuoteReference, getPreferredBadge, i18n, interactionMode, kickOffAttachmentDownload, markAttachmentAsCorrupted, markViewed, openConversation, openGiftBadge, openLink, reactToMessage, renderAudioAttachment, renderEmojiPicker, renderReactionPicker, replyToMessage, retryDeleteForEveryone, retrySend, showContactDetail, showContactModal, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, showForwardMessageModal, showVisualAttachment, startConversation, theme, } = this.props; return ( // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
log.warn('MessageDetail: deleteMessage called!') } deleteMessageForEveryone={() => log.warn('MessageDetail: deleteMessageForEveryone called!') } disableMenu disableScroll displayLimit={Number.MAX_SAFE_INTEGER} displayTapToViewMessage={displayTapToViewMessage} downloadAttachment={() => log.warn('MessageDetail: deleteMessageForEveryone called!') } doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference} getPreferredBadge={getPreferredBadge} i18n={i18n} interactionMode={interactionMode} kickOffAttachmentDownload={kickOffAttachmentDownload} markAttachmentAsCorrupted={markAttachmentAsCorrupted} markViewed={markViewed} messageExpanded={noop} openConversation={openConversation} openGiftBadge={openGiftBadge} openLink={openLink} reactToMessage={reactToMessage} renderAudioAttachment={renderAudioAttachment} renderEmojiPicker={renderEmojiPicker} renderReactionPicker={renderReactionPicker} replyToMessage={replyToMessage} retryDeleteForEveryone={retryDeleteForEveryone} retrySend={retrySend} shouldCollapseAbove={false} shouldCollapseBelow={false} shouldHideMetadata={false} showForwardMessageModal={showForwardMessageModal} scrollToQuotedMessage={() => { log.warn('MessageDetail: scrollToQuotedMessage called!'); }} showContactDetail={showContactDetail} showContactModal={showContactModal} showExpiredIncomingTapToViewToast={ showExpiredIncomingTapToViewToast } showExpiredOutgoingTapToViewToast={ showExpiredOutgoingTapToViewToast } showMessageDetail={() => { log.warn('MessageDetail: deleteMessageForEveryone called!'); }} showVisualAttachment={showVisualAttachment} startConversation={startConversation} theme={theme} />
{(errors || []).map(error => ( ))} {receivedAt && message.direction === 'incoming' ? ( ) : null}
{i18n('error')} {' '} {error.message}{' '}
{i18n('sent')} {' '} ({sentAt})
{i18n('received')} {' '} ({receivedAt})
{this.renderContacts()}
); } }