302 lines
9.3 KiB
TypeScript
302 lines
9.3 KiB
TypeScript
// Copyright 2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import React from 'react';
|
|
import formatFileSize from 'filesize';
|
|
import type { LocalizerType } from '../types/Util';
|
|
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
|
import type { StorySendStateType, StoryViewType } from '../types/Stories';
|
|
import { Avatar, AvatarSize } from './Avatar';
|
|
import { ContactName } from './conversation/ContactName';
|
|
import { ContextMenu } from './ContextMenu';
|
|
import { Intl } from './Intl';
|
|
import { Modal } from './Modal';
|
|
import { SendStatus } from '../messages/MessageSendState';
|
|
import { Theme } from '../util/theme';
|
|
import { formatDateTimeLong } from '../util/timestamp';
|
|
import { DurationInSeconds } from '../util/durations';
|
|
import type { SaveAttachmentActionCreatorType } from '../state/ducks/conversations';
|
|
import type { AttachmentType } from '../types/Attachment';
|
|
import { ThemeType } from '../types/Util';
|
|
import { Time } from './Time';
|
|
import { groupBy } from '../util/mapUtil';
|
|
import { format as formatRelativeTime } from '../util/expirationTimer';
|
|
|
|
export type PropsType = {
|
|
getPreferredBadge: PreferredBadgeSelectorType;
|
|
i18n: LocalizerType;
|
|
isInternalUser?: boolean;
|
|
onClose: () => unknown;
|
|
saveAttachment: SaveAttachmentActionCreatorType;
|
|
sender: StoryViewType['sender'];
|
|
sendState?: Array<StorySendStateType>;
|
|
attachment?: AttachmentType;
|
|
expirationTimestamp: number | undefined;
|
|
timestamp: number;
|
|
};
|
|
|
|
const contactSortCollator = new window.Intl.Collator();
|
|
|
|
function getSendStatusLabel(
|
|
sendStatus: SendStatus | undefined,
|
|
i18n: LocalizerType
|
|
): string {
|
|
if (sendStatus === SendStatus.Failed) {
|
|
return i18n('icu:MessageDetailsHeader--Failed');
|
|
}
|
|
|
|
if (sendStatus === SendStatus.Viewed) {
|
|
return i18n('icu:MessageDetailsHeader--Viewed');
|
|
}
|
|
|
|
if (sendStatus === SendStatus.Read) {
|
|
return i18n('icu:MessageDetailsHeader--Read');
|
|
}
|
|
|
|
if (sendStatus === SendStatus.Delivered) {
|
|
return i18n('icu:MessageDetailsHeader--Delivered');
|
|
}
|
|
|
|
if (sendStatus === SendStatus.Sent) {
|
|
return i18n('icu:MessageDetailsHeader--Sent');
|
|
}
|
|
|
|
if (sendStatus === SendStatus.Pending) {
|
|
return i18n('icu:MessageDetailsHeader--Pending');
|
|
}
|
|
|
|
return i18n('icu:from');
|
|
}
|
|
|
|
export function StoryDetailsModal({
|
|
attachment,
|
|
getPreferredBadge,
|
|
i18n,
|
|
isInternalUser,
|
|
onClose,
|
|
saveAttachment,
|
|
sender,
|
|
sendState,
|
|
timestamp,
|
|
expirationTimestamp,
|
|
}: PropsType): JSX.Element {
|
|
// the sender is included in the sendState data
|
|
// but we don't want to show the sender in the "Sent To" list
|
|
const actualRecipientsSendState = sendState?.filter(
|
|
s => s.recipient.id !== sender.id
|
|
);
|
|
|
|
const contactsBySendStatus = actualRecipientsSendState
|
|
? groupBy(actualRecipientsSendState, contact => contact.status)
|
|
: undefined;
|
|
|
|
let content: JSX.Element;
|
|
if (contactsBySendStatus) {
|
|
content = (
|
|
<div className="StoryDetailsModal__contact-container">
|
|
{[
|
|
SendStatus.Failed,
|
|
SendStatus.Viewed,
|
|
SendStatus.Read,
|
|
SendStatus.Delivered,
|
|
SendStatus.Sent,
|
|
SendStatus.Pending,
|
|
].map(sendStatus => {
|
|
const contacts = contactsBySendStatus.get(sendStatus);
|
|
|
|
if (!contacts) {
|
|
return null;
|
|
}
|
|
|
|
const sendStatusLabel = getSendStatusLabel(sendStatus, i18n);
|
|
|
|
const sortedContacts = [...contacts].sort((a, b) =>
|
|
contactSortCollator.compare(a.recipient.title, b.recipient.title)
|
|
);
|
|
|
|
return (
|
|
<div
|
|
key={sendStatusLabel}
|
|
className="StoryDetailsModal__contact-group"
|
|
>
|
|
<div className="StoryDetailsModal__contact-group__header">
|
|
{sendStatusLabel}
|
|
</div>
|
|
{sortedContacts.map(status => {
|
|
const contact = status.recipient;
|
|
|
|
return (
|
|
<div key={contact.id} className="StoryDetailsModal__contact">
|
|
<Avatar
|
|
acceptedMessageRequest={contact.acceptedMessageRequest}
|
|
avatarPath={contact.avatarPath}
|
|
badge={getPreferredBadge(contact.badges)}
|
|
color={contact.color}
|
|
conversationType="direct"
|
|
i18n={i18n}
|
|
isMe={contact.isMe}
|
|
phoneNumber={contact.phoneNumber}
|
|
profileName={contact.profileName}
|
|
sharedGroupNames={contact.sharedGroupNames}
|
|
size={AvatarSize.THIRTY_TWO}
|
|
theme={ThemeType.dark}
|
|
title={contact.title}
|
|
unblurredAvatarPath={contact.unblurredAvatarPath}
|
|
/>
|
|
<div className="StoryDetailsModal__contact__text">
|
|
<ContactName title={contact.title} />
|
|
</div>
|
|
{status.updatedAt && (
|
|
<Time
|
|
className="StoryDetailsModal__status-timestamp"
|
|
timestamp={status.updatedAt}
|
|
>
|
|
{formatDateTimeLong(i18n, status.updatedAt)}
|
|
</Time>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
} else {
|
|
content = (
|
|
<div className="StoryDetailsModal__contact-container">
|
|
<div className="StoryDetailsModal__contact-group">
|
|
<div className="StoryDetailsModal__contact-group__header">
|
|
{i18n('icu:sent')}
|
|
</div>
|
|
<div className="StoryDetailsModal__contact">
|
|
<Avatar
|
|
acceptedMessageRequest={sender.acceptedMessageRequest}
|
|
avatarPath={sender.avatarPath}
|
|
badge={getPreferredBadge(sender.badges)}
|
|
color={sender.color}
|
|
conversationType="direct"
|
|
i18n={i18n}
|
|
isMe={sender.isMe}
|
|
profileName={sender.profileName}
|
|
sharedGroupNames={sender.sharedGroupNames}
|
|
size={AvatarSize.THIRTY_TWO}
|
|
theme={ThemeType.dark}
|
|
title={sender.title}
|
|
/>
|
|
<div className="StoryDetailsModal__contact__text">
|
|
<div className="StoryDetailsModal__contact__name">
|
|
<ContactName title={sender.title} />
|
|
</div>
|
|
</div>
|
|
<Time
|
|
className="StoryDetailsModal__status-timestamp"
|
|
timestamp={timestamp}
|
|
>
|
|
{formatDateTimeLong(i18n, timestamp)}
|
|
</Time>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const timeRemaining = expirationTimestamp
|
|
? DurationInSeconds.fromMillis(expirationTimestamp - Date.now())
|
|
: undefined;
|
|
|
|
const menuOptions = [
|
|
{
|
|
icon: 'StoryDetailsModal__copy-icon',
|
|
label: i18n('icu:StoryDetailsModal__copy-timestamp'),
|
|
onClick: () => {
|
|
void window.navigator.clipboard.writeText(String(timestamp));
|
|
},
|
|
},
|
|
];
|
|
|
|
if (isInternalUser && attachment) {
|
|
menuOptions.push({
|
|
icon: 'StoryDetailsModal__download-icon',
|
|
label: i18n('icu:StoryDetailsModal__download-attachment'),
|
|
onClick: () => {
|
|
saveAttachment(attachment);
|
|
},
|
|
});
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
modalName="StoryDetailsModal"
|
|
hasXButton
|
|
i18n={i18n}
|
|
moduleClassName="StoryDetailsModal"
|
|
onClose={onClose}
|
|
useFocusTrap={false}
|
|
theme={Theme.Dark}
|
|
title={
|
|
<ContextMenu
|
|
i18n={i18n}
|
|
menuOptions={menuOptions}
|
|
moduleClassName="StoryDetailsModal__debugger"
|
|
popperOptions={{
|
|
placement: 'bottom',
|
|
strategy: 'absolute',
|
|
}}
|
|
theme={Theme.Dark}
|
|
>
|
|
<div>
|
|
<Intl
|
|
i18n={i18n}
|
|
id="icu:StoryDetailsModal__sent-time"
|
|
components={{
|
|
time: (
|
|
<Time
|
|
className="StoryDetailsModal__debugger__button__text"
|
|
timestamp={timestamp}
|
|
>
|
|
{formatDateTimeLong(i18n, timestamp)}
|
|
</Time>
|
|
),
|
|
}}
|
|
/>
|
|
</div>
|
|
{attachment && (
|
|
<div>
|
|
<Intl
|
|
i18n={i18n}
|
|
id="icu:StoryDetailsModal__file-size"
|
|
components={{
|
|
size: (
|
|
<span className="StoryDetailsModal__debugger__button__text">
|
|
{formatFileSize(attachment.size)}
|
|
</span>
|
|
),
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{timeRemaining && timeRemaining > 0 && (
|
|
<div>
|
|
<Intl
|
|
i18n={i18n}
|
|
id="icu:StoryDetailsModal__disappears-in"
|
|
components={{
|
|
countdown: (
|
|
<span className="StoryDetailsModal__debugger__button__text">
|
|
{formatRelativeTime(i18n, timeRemaining, {
|
|
largest: 2,
|
|
})}
|
|
</span>
|
|
),
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</ContextMenu>
|
|
}
|
|
>
|
|
{content}
|
|
</Modal>
|
|
);
|
|
}
|