462 lines
14 KiB
TypeScript
462 lines
14 KiB
TypeScript
// 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<Error>;
|
|
};
|
|
|
|
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<Contact>;
|
|
|
|
contactNameColor?: ContactNameColorType;
|
|
errors: Array<Error>;
|
|
message: Omit<
|
|
MessagePropsDataType,
|
|
'renderingContext' | 'menu' | 'contextMenu' | 'showMenu'
|
|
>;
|
|
receivedAt: number;
|
|
sentAt: number;
|
|
|
|
i18n: LocalizerType;
|
|
platform: string;
|
|
theme: ThemeType;
|
|
getPreferredBadge: PreferredBadgeSelectorType;
|
|
} & Pick<MessagePropsType, 'getPreferredBadge' | 'interactionMode'>;
|
|
|
|
export type PropsSmartActions = Pick<MessagePropsType, 'renderAudioAttachment'>;
|
|
|
|
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<HTMLDivElement>(null);
|
|
|
|
function renderAvatar(contact: Contact): JSX.Element {
|
|
const {
|
|
acceptedMessageRequest,
|
|
avatarPath,
|
|
badges,
|
|
color,
|
|
isMe,
|
|
phoneNumber,
|
|
profileName,
|
|
sharedGroupNames,
|
|
title,
|
|
unblurredAvatarPath,
|
|
} = contact;
|
|
|
|
return (
|
|
<Avatar
|
|
acceptedMessageRequest={acceptedMessageRequest}
|
|
avatarPath={avatarPath}
|
|
badge={getPreferredBadge(badges)}
|
|
color={color}
|
|
conversationType="direct"
|
|
i18n={i18n}
|
|
isMe={isMe}
|
|
phoneNumber={phoneNumber}
|
|
profileName={profileName}
|
|
theme={theme}
|
|
title={title}
|
|
sharedGroupNames={sharedGroupNames}
|
|
size={AvatarSize.THIRTY_TWO}
|
|
unblurredAvatarPath={unblurredAvatarPath}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function renderContact(contact: Contact): JSX.Element {
|
|
const contactErrors = contact.errors || [];
|
|
|
|
const errorComponent = contact.isOutgoingKeyError ? (
|
|
<div className="module-message-detail__contact__error-buttons">
|
|
<button
|
|
type="button"
|
|
className="module-message-detail__contact__show-safety-number"
|
|
onClick={() => toggleSafetyNumberModal(contact.id)}
|
|
>
|
|
{i18n('icu:showSafetyNumber')}
|
|
</button>
|
|
</div>
|
|
) : null;
|
|
const unidentifiedDeliveryComponent = contact.isUnidentifiedDelivery ? (
|
|
<div className="module-message-detail__contact__unidentified-delivery-icon" />
|
|
) : null;
|
|
|
|
return (
|
|
<div key={contact.id} className="module-message-detail__contact">
|
|
{renderAvatar(contact)}
|
|
<div className="module-message-detail__contact__text">
|
|
<div className="module-message-detail__contact__name">
|
|
<ContactName title={contact.title} />
|
|
</div>
|
|
{contactErrors.map(contactError => (
|
|
<div
|
|
key={_keyForError(contactError)}
|
|
className="module-message-detail__contact__error"
|
|
>
|
|
{contactError.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{errorComponent}
|
|
{unidentifiedDeliveryComponent}
|
|
{contact.statusTimestamp && (
|
|
<Time
|
|
className="module-message-detail__status-timestamp"
|
|
timestamp={contact.statusTimestamp}
|
|
>
|
|
{formatDateTimeLong(i18n, contact.statusTimestamp)}
|
|
</Time>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<Contact>
|
|
): ReactNode {
|
|
if (!statusContacts || !statusContacts.length) {
|
|
return null;
|
|
}
|
|
|
|
const sortedContacts = [...statusContacts].sort((a, b) =>
|
|
contactSortCollator.compare(a.title, b.title)
|
|
);
|
|
|
|
const headerText = renderContactGroupHeaderText(sendStatus);
|
|
|
|
return (
|
|
<div key={headerText} className="module-message-detail__contact-group">
|
|
<div
|
|
className={classNames(
|
|
'module-message-detail__contact-group__header',
|
|
sendStatus &&
|
|
`module-message-detail__contact-group__header--${sendStatus}`
|
|
)}
|
|
>
|
|
{headerText}
|
|
</div>
|
|
{sortedContacts.map(contact => renderContact(contact))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="module-message-detail__contact-container">
|
|
{[
|
|
undefined,
|
|
SendStatus.Failed,
|
|
SendStatus.Viewed,
|
|
SendStatus.Read,
|
|
SendStatus.Delivered,
|
|
SendStatus.Sent,
|
|
SendStatus.Pending,
|
|
].map(sendStatus =>
|
|
renderContactGroup(sendStatus, contactsBySendStatus.get(sendStatus))
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const timeRemaining = message.expirationTimestamp
|
|
? DurationInSeconds.fromMillis(message.expirationTimestamp - Date.now())
|
|
: undefined;
|
|
|
|
return (
|
|
<div className="module-message-detail" ref={messageDetailRef}>
|
|
<PanelSection>
|
|
<div className="module-message-detail__message-container">
|
|
<Message
|
|
{...message}
|
|
renderingContext="conversation/MessageDetail"
|
|
checkForAccount={checkForAccount}
|
|
clearTargetedMessage={clearTargetedMessage}
|
|
contactNameColor={contactNameColor}
|
|
containerElementRef={messageDetailRef}
|
|
containerWidthBreakpoint={WidthBreakpoint.Wide}
|
|
renderMenu={undefined}
|
|
disableScroll
|
|
displayLimit={Number.MAX_SAFE_INTEGER}
|
|
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
|
|
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
|
|
getPreferredBadge={getPreferredBadge}
|
|
i18n={i18n}
|
|
interactionMode={interactionMode}
|
|
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
|
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
|
|
messageExpanded={messageExpanded}
|
|
openGiftBadge={openGiftBadge}
|
|
platform={platform}
|
|
pushPanelForConversation={pushPanelForConversation}
|
|
retryMessageSend={retryMessageSend}
|
|
renderAudioAttachment={renderAudioAttachment}
|
|
saveAttachment={saveAttachment}
|
|
shouldCollapseAbove={false}
|
|
shouldCollapseBelow={false}
|
|
shouldHideMetadata={false}
|
|
showConversation={showConversation}
|
|
showSpoiler={showSpoiler}
|
|
scrollToQuotedMessage={() => {
|
|
log.warn('MessageDetail: scrollToQuotedMessage called!');
|
|
}}
|
|
showContactModal={showContactModal}
|
|
showExpiredIncomingTapToViewToast={
|
|
showExpiredIncomingTapToViewToast
|
|
}
|
|
showExpiredOutgoingTapToViewToast={
|
|
showExpiredOutgoingTapToViewToast
|
|
}
|
|
showLightbox={showLightbox}
|
|
startConversation={startConversation}
|
|
theme={theme}
|
|
viewStory={viewStory}
|
|
onToggleSelect={noop}
|
|
onReplyToMessage={noop}
|
|
/>
|
|
</div>
|
|
<table className="module-message-detail__info">
|
|
<tbody>
|
|
{(errors || []).map(error => (
|
|
<tr key={_keyForError(error)}>
|
|
<td className="module-message-detail__label">
|
|
{i18n('icu:error')}
|
|
</td>
|
|
<td>
|
|
{' '}
|
|
<span className="error-message">{error.message}</span>{' '}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
<tr>
|
|
<td className="module-message-detail__label">
|
|
{i18n('icu:sent')}
|
|
</td>
|
|
<td>
|
|
<ContextMenu
|
|
i18n={i18n}
|
|
menuOptions={[
|
|
{
|
|
icon: 'StoryDetailsModal__copy-icon',
|
|
label: i18n('icu:StoryDetailsModal__copy-timestamp'),
|
|
onClick: () => {
|
|
void window.navigator.clipboard.writeText(
|
|
String(sentAt)
|
|
);
|
|
},
|
|
},
|
|
]}
|
|
>
|
|
<>
|
|
<Time timestamp={sentAt}>
|
|
{formatDateTimeLong(i18n, sentAt)}
|
|
</Time>{' '}
|
|
<span className="module-message-detail__unix-timestamp">
|
|
({sentAt})
|
|
</span>
|
|
</>
|
|
</ContextMenu>
|
|
</td>
|
|
</tr>
|
|
{receivedAt && message.direction === 'incoming' ? (
|
|
<tr>
|
|
<td className="module-message-detail__label">
|
|
{i18n('icu:received')}
|
|
</td>
|
|
<td>
|
|
<Time timestamp={receivedAt}>
|
|
{formatDateTimeLong(i18n, receivedAt)}
|
|
</Time>{' '}
|
|
<span className="module-message-detail__unix-timestamp">
|
|
({receivedAt})
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
{timeRemaining && timeRemaining > 0 && (
|
|
<tr>
|
|
<td className="module-message-detail__label">
|
|
{i18n('icu:MessageDetail--disappears-in')}
|
|
</td>
|
|
<td>
|
|
{formatRelativeTime(i18n, timeRemaining, {
|
|
largest: 2,
|
|
})}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</PanelSection>
|
|
{message.isEditedMessage && (
|
|
<PanelSection>
|
|
<PanelRow
|
|
icon={
|
|
<ConversationDetailsIcon
|
|
ariaLabel={i18n('icu:MessageDetail__view-edits')}
|
|
icon={IconType.edit}
|
|
/>
|
|
}
|
|
label={i18n('icu:MessageDetail__view-edits')}
|
|
onClick={() => {
|
|
showEditHistoryModal?.(message.id);
|
|
}}
|
|
/>
|
|
</PanelSection>
|
|
)}
|
|
|
|
<PanelSection>{renderContacts()}</PanelSection>
|
|
</div>
|
|
);
|
|
}
|