signal-desktop/ts/components/StoryViewsNRepliesModal.tsx

593 lines
19 KiB
TypeScript
Raw Normal View History

2022-03-04 21:14:52 +00:00
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
2022-10-11 17:59:02 +00:00
import React, {
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
2022-03-04 21:14:52 +00:00
import classNames from 'classnames';
2022-11-10 04:59:36 +00:00
import { noop } from 'lodash';
import type { DraftBodyRangesType, LocalizerType } from '../types/Util';
2022-08-23 18:02:51 +00:00
import type { ConversationType } from '../state/ducks/conversations';
2022-03-04 21:14:52 +00:00
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { InputApi } from './CompositionInput';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
2022-07-01 00:52:03 +00:00
import type { ReplyType, StorySendStateType } from '../types/Stories';
2022-10-11 17:59:02 +00:00
import { StoryViewTargetType } from '../types/Stories';
2022-03-04 21:14:52 +00:00
import { Avatar, AvatarSize } from './Avatar';
import { CompositionInput } from './CompositionInput';
import { ContactName } from './conversation/ContactName';
import { EmojiButton } from './emoji/EmojiButton';
import { Emojify } from './conversation/Emojify';
import { Message, TextDirection } from './conversation/Message';
2022-03-04 21:14:52 +00:00
import { MessageTimestamp } from './conversation/MessageTimestamp';
import { Modal } from './Modal';
import { ReactionPicker } from './conversation/ReactionPicker';
import { Tabs } from './Tabs';
2022-04-15 00:08:46 +00:00
import { Theme } from '../util/theme';
2022-03-04 21:14:52 +00:00
import { ThemeType } from '../types/Util';
import { WidthBreakpoint } from './_util';
2022-03-04 21:14:52 +00:00
import { getAvatarColor } from '../types/Colors';
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
2022-11-04 13:22:07 +00:00
import { ContextMenu } from './ContextMenu';
import { ConfirmationDialog } from './ConfirmationDialog';
// Menu is disabled so these actions are inaccessible. We also don't support
// link previews, tap to view messages, attachments, or gifts. Just regular
// text messages and reactions.
const MESSAGE_DEFAULT_PROPS = {
canDeleteForEveryone: false,
checkForAccount: shouldNeverBeCalled,
clearSelectedMessage: shouldNeverBeCalled,
containerWidthBreakpoint: WidthBreakpoint.Medium,
doubleCheckMissingQuoteReference: shouldNeverBeCalled,
isBlocked: false,
isMessageRequestAccepted: true,
kickOffAttachmentDownload: shouldNeverBeCalled,
markAttachmentAsCorrupted: shouldNeverBeCalled,
messageExpanded: shouldNeverBeCalled,
openGiftBadge: shouldNeverBeCalled,
openLink: shouldNeverBeCalled,
previews: [],
pushPanelForConversation: shouldNeverBeCalled,
renderAudioAttachment: () => <div />,
2022-12-14 18:12:04 +00:00
saveAttachment: shouldNeverBeCalled,
scrollToQuotedMessage: shouldNeverBeCalled,
showContactModal: shouldNeverBeCalled,
showConversation: noop,
showExpiredIncomingTapToViewToast: shouldNeverBeCalled,
showExpiredOutgoingTapToViewToast: shouldNeverBeCalled,
2022-12-10 02:02:22 +00:00
showLightbox: shouldNeverBeCalled,
showLightboxForViewOnceMedia: shouldNeverBeCalled,
startConversation: shouldNeverBeCalled,
theme: ThemeType.dark,
viewStory: shouldNeverBeCalled,
};
2022-03-04 21:14:52 +00:00
2022-10-11 17:59:02 +00:00
export enum StoryViewsNRepliesTab {
2022-03-04 21:14:52 +00:00
Replies = 'Replies',
Views = 'Views',
}
export type PropsType = {
authorTitle: string;
2022-07-25 18:55:44 +00:00
canReply: boolean;
2022-03-04 21:14:52 +00:00
getPreferredBadge: PreferredBadgeSelectorType;
2022-10-25 22:18:42 +00:00
hasViewReceiptSetting: boolean;
2022-09-21 19:19:16 +00:00
hasViewsCapability: boolean;
2022-03-04 21:14:52 +00:00
i18n: LocalizerType;
2022-11-22 22:33:15 +00:00
isInternalUser?: boolean;
group: Pick<ConversationType, 'left'> | undefined;
2022-03-04 21:14:52 +00:00
onClose: () => unknown;
onReact: (emoji: string) => unknown;
onReply: (
message: string,
2022-11-10 04:59:36 +00:00
mentions: DraftBodyRangesType,
2022-03-04 21:14:52 +00:00
timestamp: number
) => unknown;
onSetSkinTone: (tone: number) => unknown;
onTextTooLong: () => unknown;
onUseEmoji: (_: EmojiPickDataType) => unknown;
preferredReactionEmoji: ReadonlyArray<string>;
recentEmojis?: ReadonlyArray<string>;
2022-03-04 21:14:52 +00:00
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
2022-11-02 23:48:38 +00:00
replies: ReadonlyArray<ReplyType>;
2022-03-04 21:14:52 +00:00
skinTone?: number;
sortedGroupMembers?: ReadonlyArray<ConversationType>;
views: ReadonlyArray<StorySendStateType>;
2022-10-11 17:59:02 +00:00
viewTarget: StoryViewTargetType;
onChangeViewTarget: (target: StoryViewTargetType) => unknown;
2022-11-04 13:22:07 +00:00
deleteGroupStoryReply: (id: string) => void;
deleteGroupStoryReplyForEveryone: (id: string) => void;
2022-03-04 21:14:52 +00:00
};
2022-11-18 00:45:19 +00:00
export function StoryViewsNRepliesModal({
2022-03-04 21:14:52 +00:00
authorTitle,
2022-07-25 18:55:44 +00:00
canReply,
2022-03-04 21:14:52 +00:00
getPreferredBadge,
2022-10-25 22:18:42 +00:00
hasViewReceiptSetting,
2022-09-21 19:19:16 +00:00
hasViewsCapability,
2022-03-04 21:14:52 +00:00
i18n,
2022-11-22 22:33:15 +00:00
isInternalUser,
group,
2022-03-04 21:14:52 +00:00
onClose,
onReact,
onReply,
onSetSkinTone,
onTextTooLong,
onUseEmoji,
preferredReactionEmoji,
recentEmojis,
renderEmojiPicker,
replies,
skinTone,
2022-08-23 18:02:51 +00:00
sortedGroupMembers,
2022-03-04 21:14:52 +00:00
views,
2022-10-11 17:59:02 +00:00
viewTarget,
onChangeViewTarget,
2022-11-04 13:22:07 +00:00
deleteGroupStoryReply,
deleteGroupStoryReplyForEveryone,
2022-11-18 00:45:19 +00:00
}: PropsType): JSX.Element | null {
2022-11-04 13:22:07 +00:00
const [deleteReplyId, setDeleteReplyId] = useState<string | undefined>(
undefined
);
const [deleteForEveryoneReplyId, setDeleteForEveryoneReplyId] = useState<
string | undefined
>(undefined);
const containerElementRef = useRef<HTMLDivElement | null>(null);
2022-04-23 03:16:13 +00:00
const inputApiRef = useRef<InputApi | undefined>();
2022-10-11 17:59:02 +00:00
const shouldScrollToBottomRef = useRef(true);
const bottomRef = useRef<HTMLDivElement>(null);
2022-03-04 21:14:52 +00:00
const [messageBodyText, setMessageBodyText] = useState('');
2022-10-11 17:59:02 +00:00
const currentTab = useMemo<StoryViewsNRepliesTab>(() => {
return viewTarget === StoryViewTargetType.Replies
? StoryViewsNRepliesTab.Replies
: StoryViewsNRepliesTab.Views;
}, [viewTarget]);
const onTabChange = (tab: string) => {
onChangeViewTarget(
tab === StoryViewsNRepliesTab.Replies
? StoryViewTargetType.Replies
: StoryViewTargetType.Views
);
};
2022-03-04 21:14:52 +00:00
const focusComposer = useCallback(() => {
if (inputApiRef.current) {
inputApiRef.current.focus();
}
}, [inputApiRef]);
const insertEmoji = useCallback(
(e: EmojiPickDataType) => {
2022-09-27 20:24:21 +00:00
if (inputApiRef.current) {
inputApiRef.current.insertEmoji(e);
onUseEmoji(e);
}
2022-03-04 21:14:52 +00:00
},
2022-09-27 20:24:21 +00:00
[inputApiRef, onUseEmoji]
2022-03-04 21:14:52 +00:00
);
let composerElement: JSX.Element | undefined;
2022-10-11 17:59:02 +00:00
useLayoutEffect(() => {
if (
currentTab === StoryViewsNRepliesTab.Replies &&
replies.length &&
shouldScrollToBottomRef.current
) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
shouldScrollToBottomRef.current = false;
2022-04-23 03:16:13 +00:00
}
2022-10-11 17:59:02 +00:00
}, [currentTab, replies.length]);
2022-04-23 03:16:13 +00:00
if (group && group.left) {
composerElement = (
<div className="StoryViewsNRepliesModal__not-a-member">
{i18n('icu:StoryViewsNRepliesModal__not-a-member')}
</div>
);
} else if (canReply) {
2022-03-04 21:14:52 +00:00
composerElement = (
<>
2023-01-27 17:34:15 +00:00
<ReactionPicker
i18n={i18n}
onPick={emoji => {
if (!group) {
onClose();
}
onReact(emoji);
}}
onSetSkinTone={onSetSkinTone}
preferredReactionEmoji={preferredReactionEmoji}
renderEmojiPicker={renderEmojiPicker}
/>
<div className="StoryViewsNRepliesModal__compose-container">
<div className="StoryViewsNRepliesModal__composer">
<CompositionInput
draftText={messageBodyText}
getPreferredBadge={getPreferredBadge}
2022-03-04 21:14:52 +00:00
i18n={i18n}
inputApi={inputApiRef}
moduleClassName="StoryViewsNRepliesModal__input"
onEditorStateChange={(_conversationId, messageText) => {
setMessageBodyText(messageText);
2022-03-04 21:14:52 +00:00
}}
2022-09-27 20:24:21 +00:00
onPickEmoji={onUseEmoji}
onSubmit={(...args) => {
inputApiRef.current?.reset();
shouldScrollToBottomRef.current = true;
onReply(...args);
2022-03-04 21:14:52 +00:00
}}
onTextTooLong={onTextTooLong}
placeholder={
group
? i18n('StoryViewer__reply-group')
2023-01-27 17:34:15 +00:00
: i18n('icu:StoryViewer__reply-placeholder', {
firstName: authorTitle,
})
}
2022-08-23 18:02:51 +00:00
sortedGroupMembers={sortedGroupMembers}
theme={ThemeType.dark}
>
<EmojiButton
className="StoryViewsNRepliesModal__emoji-button"
i18n={i18n}
onPickEmoji={insertEmoji}
onClose={focusComposer}
recentEmojis={recentEmojis}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
/>
</CompositionInput>
2022-03-04 21:14:52 +00:00
</div>
</div>
</>
2022-03-04 21:14:52 +00:00
);
}
2022-04-23 03:16:13 +00:00
let repliesElement: JSX.Element | undefined;
function shouldCollapse(reply: ReplyType, otherReply?: ReplyType) {
// deleted reactions get rendered the same as deleted replies
return (
reply.conversationId === otherReply?.conversationId &&
(!otherReply?.reactionEmoji || Boolean(otherReply.deletedForEveryone))
);
}
2022-04-23 03:16:13 +00:00
if (replies.length) {
repliesElement = (
<div
className="StoryViewsNRepliesModal__replies"
ref={containerElementRef}
>
2022-11-04 13:22:07 +00:00
{replies.map((reply, index) => {
return (
<ReplyOrReactionMessage
2022-11-04 13:22:07 +00:00
key={reply.id}
id={reply.id}
2022-11-04 13:22:07 +00:00
i18n={i18n}
2022-11-22 22:33:15 +00:00
isInternalUser={isInternalUser}
2022-11-04 13:22:07 +00:00
reply={reply}
deleteGroupStoryReply={() => setDeleteReplyId(reply.id)}
deleteGroupStoryReplyForEveryone={() =>
setDeleteForEveryoneReplyId(reply.id)
}
getPreferredBadge={getPreferredBadge}
shouldCollapseAbove={shouldCollapse(reply, replies[index - 1])}
shouldCollapseBelow={shouldCollapse(reply, replies[index + 1])}
containerElementRef={containerElementRef}
2022-11-04 13:22:07 +00:00
/>
);
})}
2022-10-11 17:59:02 +00:00
<div ref={bottomRef} />
2022-04-23 03:16:13 +00:00
</div>
);
} else if (group) {
2022-04-23 03:16:13 +00:00
repliesElement = (
<div className="StoryViewsNRepliesModal__replies--none">
{i18n('StoryViewsNRepliesModal__no-replies')}
</div>
);
}
2022-03-04 21:14:52 +00:00
let viewsElement: JSX.Element | undefined;
2022-10-25 22:18:42 +00:00
if (hasViewsCapability && !hasViewReceiptSetting) {
viewsElement = (
<div className="StoryViewsNRepliesModal__read-receipts-off">
{i18n('StoryViewsNRepliesModal__read-receipts-off')}
</div>
);
} else if (views.length) {
viewsElement = (
<div className="StoryViewsNRepliesModal__views">
{views.map(view => (
<div
className="StoryViewsNRepliesModal__view"
key={view.recipient.id}
>
<div>
<Avatar
acceptedMessageRequest={view.recipient.acceptedMessageRequest}
avatarPath={view.recipient.avatarPath}
badge={undefined}
color={getAvatarColor(view.recipient.color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(view.recipient.isMe)}
profileName={view.recipient.profileName}
sharedGroupNames={view.recipient.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}
title={view.recipient.title}
/>
<span className="StoryViewsNRepliesModal__view--name">
<ContactName title={view.recipient.title} />
</span>
</div>
{view.updatedAt && (
<MessageTimestamp
i18n={i18n}
module="StoryViewsNRepliesModal__view--timestamp"
timestamp={view.updatedAt}
/>
)}
2022-03-04 21:14:52 +00:00
</div>
))}
</div>
);
2022-09-21 19:19:16 +00:00
} else if (hasViewsCapability) {
viewsElement = (
<div className="StoryViewsNRepliesModal__replies--none">
{i18n('StoryViewsNRepliesModal__no-views')}
</div>
);
}
2022-03-04 21:14:52 +00:00
const tabsElement =
2022-09-21 19:19:16 +00:00
viewsElement && repliesElement ? (
2022-03-04 21:14:52 +00:00
<Tabs
2022-10-11 17:59:02 +00:00
selectedTab={currentTab}
onTabChange={onTabChange}
2022-03-04 21:14:52 +00:00
moduleClassName="StoryViewsNRepliesModal__tabs"
tabs={[
{
2022-10-11 17:59:02 +00:00
id: StoryViewsNRepliesTab.Views,
2022-03-04 21:14:52 +00:00
label: i18n('StoryViewsNRepliesModal__tab--views'),
},
{
2022-10-11 17:59:02 +00:00
id: StoryViewsNRepliesTab.Replies,
2022-03-04 21:14:52 +00:00
label: i18n('StoryViewsNRepliesModal__tab--replies'),
},
]}
>
{({ selectedTab }) => (
<>
2022-10-11 17:59:02 +00:00
{selectedTab === StoryViewsNRepliesTab.Views && viewsElement}
{selectedTab === StoryViewsNRepliesTab.Replies && (
2022-03-04 21:14:52 +00:00
<>
{repliesElement}
{composerElement}
</>
)}
</>
)}
</Tabs>
) : undefined;
2022-07-25 18:55:44 +00:00
if (!tabsElement && !viewsElement && !repliesElement && !composerElement) {
return null;
}
2022-03-04 21:14:52 +00:00
return (
2022-11-04 13:22:07 +00:00
<>
<Modal
modalName="StoryViewsNRepliesModal"
i18n={i18n}
moduleClassName="StoryViewsNRepliesModal"
onClose={onClose}
useFocusTrap={Boolean(composerElement)}
theme={Theme.Dark}
2022-04-23 03:16:13 +00:00
>
2022-11-04 13:22:07 +00:00
<div
className={classNames({
'StoryViewsNRepliesModal--group': Boolean(group),
})}
>
{tabsElement || (
<>
{viewsElement || repliesElement}
{composerElement}
</>
)}
</div>
</Modal>
{deleteReplyId && (
<ConfirmationDialog
i18n={i18n}
theme={Theme.Dark}
dialogName="confirmDialog"
actions={[
{
text: i18n('delete'),
action: () => deleteGroupStoryReply(deleteReplyId),
style: 'negative',
},
]}
title={i18n('deleteWarning')}
onClose={() => setDeleteReplyId(undefined)}
onCancel={() => setDeleteReplyId(undefined)}
/>
)}
{deleteForEveryoneReplyId && (
<ConfirmationDialog
i18n={i18n}
theme={Theme.Dark}
dialogName="confirmDialog"
actions={[
{
text: i18n('delete'),
action: () =>
deleteGroupStoryReplyForEveryone(deleteForEveryoneReplyId),
style: 'negative',
},
]}
title={i18n('deleteWarning')}
onClose={() => setDeleteForEveryoneReplyId(undefined)}
onCancel={() => setDeleteForEveryoneReplyId(undefined)}
>
{i18n('deleteForEveryoneWarning')}
</ConfirmationDialog>
)}
</>
);
2022-11-18 00:45:19 +00:00
}
2022-11-04 13:22:07 +00:00
type ReplyOrReactionMessageProps = {
2022-11-04 13:22:07 +00:00
i18n: LocalizerType;
id: string;
2022-11-22 22:33:15 +00:00
isInternalUser?: boolean;
2022-11-04 13:22:07 +00:00
reply: ReplyType;
deleteGroupStoryReply: (replyId: string) => void;
deleteGroupStoryReplyForEveryone: (replyId: string) => void;
getPreferredBadge: PreferredBadgeSelectorType;
shouldCollapseAbove: boolean;
shouldCollapseBelow: boolean;
containerElementRef: React.RefObject<HTMLElement>;
onContextMenu?: (ev: React.MouseEvent) => void;
2022-11-04 13:22:07 +00:00
};
2022-11-18 00:45:19 +00:00
function ReplyOrReactionMessage({
2022-11-04 13:22:07 +00:00
i18n,
id,
2022-11-22 22:33:15 +00:00
isInternalUser,
2022-11-04 13:22:07 +00:00
reply,
deleteGroupStoryReply,
deleteGroupStoryReplyForEveryone,
containerElementRef,
2022-11-04 13:22:07 +00:00
getPreferredBadge,
shouldCollapseAbove,
shouldCollapseBelow,
2022-11-18 00:45:19 +00:00
}: ReplyOrReactionMessageProps) {
const renderContent = (onContextMenu?: (ev: React.MouseEvent) => void) => {
if (reply.reactionEmoji && !reply.deletedForEveryone) {
return (
<div
className="StoryViewsNRepliesModal__reaction"
onContextMenu={onContextMenu}
data-id={id}
>
<div className="StoryViewsNRepliesModal__reaction--container">
<Avatar
acceptedMessageRequest={reply.author.acceptedMessageRequest}
avatarPath={reply.author.avatarPath}
badge={getPreferredBadge(reply.author.badges)}
color={getAvatarColor(reply.author.color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(reply.author.isMe)}
profileName={reply.author.profileName}
sharedGroupNames={reply.author.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}
theme={ThemeType.dark}
title={reply.author.title}
/>
<div className="StoryViewsNRepliesModal__reaction--body">
<div className="StoryViewsNRepliesModal__reply--title">
<ContactName
contactNameColor={reply.contactNameColor}
title={reply.author.isMe ? i18n('you') : reply.author.title}
/>
</div>
{i18n('StoryViewsNRepliesModal__reacted')}
<MessageTimestamp
i18n={i18n}
isRelativeTime
module="StoryViewsNRepliesModal__reply--timestamp"
timestamp={reply.timestamp}
/>
</div>
</div>
<Emojify text={reply.reactionEmoji} />
</div>
);
}
return (
<div className="StoryViewsNRepliesModal__reply" data-id={id}>
<Message
{...MESSAGE_DEFAULT_PROPS}
author={reply.author}
bodyRanges={reply.bodyRanges}
contactNameColor={reply.contactNameColor}
containerElementRef={containerElementRef}
conversationColor="ultramarine"
conversationId={reply.conversationId}
conversationTitle={reply.author.title}
conversationType="group"
direction="incoming"
deletedForEveryone={reply.deletedForEveryone}
renderMenu={undefined}
onContextMenu={onContextMenu}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
id={reply.id}
interactionMode="mouse"
readStatus={reply.readStatus}
renderingContext="StoryViewsNRepliesModal"
shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow}
shouldHideMetadata={false}
text={reply.body}
textDirection={TextDirection.Default}
timestamp={reply.timestamp}
/>
</div>
);
};
2022-11-04 13:22:07 +00:00
2022-11-22 22:33:15 +00:00
const menuOptions = [
{
icon: 'module-message__context--icon module-message__context__delete-message',
label: i18n('icu:StoryViewsNRepliesModal__delete-reply'),
onClick: () => deleteGroupStoryReply(reply.id),
},
{
icon: 'module-message__context--icon module-message__context__delete-message-for-everyone',
label: i18n('icu:StoryViewsNRepliesModal__delete-reply-for-everyone'),
onClick: () => deleteGroupStoryReplyForEveryone(reply.id),
},
];
if (isInternalUser) {
menuOptions.push({
icon: 'module-message__context--icon module-message__context__copy-timestamp',
label: i18n('icu:StoryViewsNRepliesModal__copy-reply-timestamp'),
onClick: () => {
void window.navigator.clipboard.writeText(String(reply.timestamp));
2022-11-22 22:33:15 +00:00
},
});
}
return reply.author.isMe && !reply.deletedForEveryone ? (
2022-11-22 22:33:15 +00:00
<ContextMenu i18n={i18n} key={reply.id} menuOptions={menuOptions}>
2022-11-04 13:22:07 +00:00
{({ openMenu, menuNode }) => (
<>
{renderContent(openMenu)}
2022-11-04 13:22:07 +00:00
{menuNode}
</>
)}
</ContextMenu>
) : (
renderContent()
2022-03-04 21:14:52 +00:00
);
2022-11-18 00:45:19 +00:00
}