// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; import React, { useCallback, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import classNames from 'classnames'; import lodash from 'lodash'; import type { DraftBodyRanges } from '../types/BodyRange.std.js'; import type { LocalizerType } from '../types/Util.std.js'; import type { ConversationType } from '../state/ducks/conversations.preload.js'; import type { InputApi } from './CompositionInput.dom.js'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges.preload.js'; import type { ReplyType, StorySendStateType } from '../types/Stories.std.js'; import { StoryViewTargetType } from '../types/Stories.std.js'; import { Avatar, AvatarSize } from './Avatar.dom.js'; import { CompositionInput } from './CompositionInput.dom.js'; import { ContactName } from './conversation/ContactName.dom.js'; import { Emojify } from './conversation/Emojify.dom.js'; import { Message, TextDirection } from './conversation/Message.dom.js'; import { MessageTimestamp } from './conversation/MessageTimestamp.dom.js'; import { Modal } from './Modal.dom.js'; import { ReactionPicker } from './conversation/ReactionPicker.dom.js'; import { Tabs } from './Tabs.dom.js'; import { Theme } from '../util/theme.std.js'; import { ThemeType } from '../types/Util.std.js'; import { WidthBreakpoint } from './_util.std.js'; import { getAvatarColor } from '../types/Colors.std.js'; import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled.std.js'; import { ConfirmationDialog } from './ConfirmationDialog.dom.js'; import type { EmojiSkinTone } from './fun/data/emojis.std.js'; import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.js'; import { FunEmojiPickerButton } from './fun/FunButton.dom.js'; import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.js'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard.dom.js'; import { AxoContextMenu } from '../axo/AxoContextMenu.dom.js'; import type { AxoMenuBuilder } from '../axo/AxoMenuBuilder.dom.js'; import { drop } from '../util/drop.std.js'; const { noop, orderBy } = lodash; // 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, clearTargetedMessage: shouldNeverBeCalled, containerWidthBreakpoint: WidthBreakpoint.Medium, doubleCheckMissingQuoteReference: shouldNeverBeCalled, isBlocked: false, isMessageRequestAccepted: true, isSelected: false, isSelectMode: false, isSMS: false, onToggleSelect: shouldNeverBeCalled, onReplyToMessage: shouldNeverBeCalled, kickOffAttachmentDownload: shouldNeverBeCalled, cancelAttachmentDownload: shouldNeverBeCalled, markAttachmentAsCorrupted: shouldNeverBeCalled, messageExpanded: shouldNeverBeCalled, openGiftBadge: shouldNeverBeCalled, openLink: shouldNeverBeCalled, previews: [], retryMessageSend: shouldNeverBeCalled, sendPollVote: shouldNeverBeCalled, endPoll: shouldNeverBeCalled, pushPanelForConversation: shouldNeverBeCalled, renderAudioAttachment: () =>
, saveAttachment: shouldNeverBeCalled, saveAttachments: shouldNeverBeCalled, scrollToQuotedMessage: shouldNeverBeCalled, showConversation: noop, showAttachmentDownloadStillInProgressToast: shouldNeverBeCalled, showExpiredIncomingTapToViewToast: shouldNeverBeCalled, showExpiredOutgoingTapToViewToast: shouldNeverBeCalled, showLightbox: shouldNeverBeCalled, showLightboxForViewOnceMedia: shouldNeverBeCalled, showMediaNoLongerAvailableToast: shouldNeverBeCalled, showTapToViewNotAvailableModal: shouldNeverBeCalled, startConversation: shouldNeverBeCalled, theme: ThemeType.dark, viewStory: shouldNeverBeCalled, }; export enum StoryViewsNRepliesTab { Replies = 'Replies', Views = 'Views', } export type PropsType = { authorTitle: string; canReply: boolean; deleteGroupStoryReply: (id: string) => void; deleteGroupStoryReplyForEveryone: (id: string) => void; getPreferredBadge: PreferredBadgeSelectorType; group: Pick | undefined; hasViewReceiptSetting: boolean; hasViewsCapability: boolean; i18n: LocalizerType; platform: string; isFormattingEnabled: boolean; isInternalUser?: boolean; onChangeViewTarget: (target: StoryViewTargetType) => unknown; onClose: () => unknown; onReact: (emoji: string) => unknown; onReply: ( message: string, bodyRanges: DraftBodyRanges, timestamp: number ) => unknown; onTextTooLong: () => unknown; onSelectEmoji: (emojiSelection: FunEmojiSelection) => unknown; ourConversationId: string | undefined; preferredReactionEmoji: ReadonlyArray; replies: ReadonlyArray; showContactModal: (contactId: string, conversationId?: string) => void; emojiSkinToneDefault: EmojiSkinTone | null; sortedGroupMembers?: ReadonlyArray; views: ReadonlyArray; viewTarget: StoryViewTargetType; }; export function StoryViewsNRepliesModal({ authorTitle, canReply, deleteGroupStoryReply, deleteGroupStoryReplyForEveryone, getPreferredBadge, group, hasViewReceiptSetting, hasViewsCapability, i18n, platform, isFormattingEnabled, isInternalUser, onChangeViewTarget, onClose, onReact, onReply, onTextTooLong, onSelectEmoji, ourConversationId, preferredReactionEmoji, replies, showContactModal, emojiSkinToneDefault, sortedGroupMembers, viewTarget, views, }: PropsType): JSX.Element | null { const [deleteReplyId, setDeleteReplyId] = useState( undefined ); const [deleteForEveryoneReplyId, setDeleteForEveryoneReplyId] = useState< string | undefined >(undefined); // These states aren't in redux; they are meant to last only as long as this dialog. const [revealedSpoilersById, setRevealedSpoilersById] = useState< Record | undefined> >({}); const [displayLimitById, setDisplayLimitById] = useState< Record >({}); const containerElementRef = useRef(null); const inputApiRef = useRef(); const shouldScrollToBottomRef = useRef(true); const bottomRef = useRef(null); const [messageBodyText, setMessageBodyText] = useState(''); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const currentTab = useMemo(() => { return viewTarget === StoryViewTargetType.Replies ? StoryViewsNRepliesTab.Replies : StoryViewsNRepliesTab.Views; }, [viewTarget]); const sortedViews = useMemo(() => { return orderBy(views, 'updatedAt', 'desc'); }, [views]); const onTabChange = (tab: string) => { onChangeViewTarget( tab === StoryViewsNRepliesTab.Replies ? StoryViewTargetType.Replies : StoryViewTargetType.Views ); }; const handleEmojiPickerOpenChange = useCallback((open: boolean) => { setEmojiPickerOpen(open); }, []); const handleSelectEmoji = useCallback((emojiSelection: FunEmojiSelection) => { if (inputApiRef.current) { inputApiRef.current.insertEmoji(emojiSelection); } }, []); let composerElement: JSX.Element | undefined; useLayoutEffect(() => { if ( currentTab === StoryViewsNRepliesTab.Replies && replies.length && shouldScrollToBottomRef.current ) { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); shouldScrollToBottomRef.current = false; } }, [currentTab, replies.length]); const tryClose = useRef<() => void | undefined>(); const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ i18n, name: 'StoryViewsNRepliesModal', tryClose, }); const onTryClose = useCallback(() => { confirmDiscardIf(emojiPickerOpen || messageBodyText.length > 0, onClose); }, [confirmDiscardIf, emojiPickerOpen, messageBodyText, onClose]); tryClose.current = onTryClose; if (group && group.left) { composerElement = (
{i18n('icu:StoryViewsNRepliesModal__not-a-member')}
); } else if (canReply) { composerElement = ( <> { if (!group) { onClose(); } onReact(emoji); }} preferredReactionEmoji={preferredReactionEmoji} theme={ThemeType.dark} />
{ setMessageBodyText(messageText); }} onSelectEmoji={onSelectEmoji} onSubmit={(...args) => { inputApiRef.current?.reset(); shouldScrollToBottomRef.current = true; onReply(...args); }} onTextTooLong={onTextTooLong} ourConversationId={ourConversationId} placeholder={ group ? i18n('icu:StoryViewer__reply-group') : i18n('icu:StoryViewer__reply-placeholder', { firstName: authorTitle, }) } platform={platform} quotedMessageId={null} sendCounter={0} emojiSkinToneDefault={emojiSkinToneDefault} sortedGroupMembers={sortedGroupMembers ?? null} theme={ThemeType.dark} conversationId={null} draftBodyRanges={null} draftEditMessage={null} large={null} shouldHidePopovers={null} linkPreviewResult={null} >
); } 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)) ); } if (replies.length) { repliesElement = (
{replies.map((reply, index) => { return ( setDeleteReplyId(reply.id)} deleteGroupStoryReplyForEveryone={() => setDeleteForEveryoneReplyId(reply.id) } displayLimit={displayLimitById[reply.id]} getPreferredBadge={getPreferredBadge} i18n={i18n} platform={platform} id={reply.id} isInternalUser={isInternalUser} isSpoilerExpanded={revealedSpoilersById[reply.id] || {}} messageExpanded={(messageId, displayLimit) => { const update = { ...displayLimitById, [messageId]: displayLimit, }; setDisplayLimitById(update); }} reply={reply} shouldCollapseAbove={shouldCollapse(reply, replies[index - 1])} shouldCollapseBelow={shouldCollapse(reply, replies[index + 1])} showContactModal={showContactModal} showSpoiler={(messageId, data) => { const update = { ...revealedSpoilersById, [messageId]: data, }; setRevealedSpoilersById(update); }} /> ); })}
); } else if (group) { repliesElement = (
{i18n('icu:StoryViewsNRepliesModal__no-replies')}
); } let viewsElement: JSX.Element | undefined; if (hasViewsCapability && !hasViewReceiptSetting) { viewsElement = (
{i18n('icu:StoryViewsNRepliesModal__read-receipts-off')}
); } else if (sortedViews.length) { viewsElement = (
{sortedViews.map(view => (
{view.updatedAt && ( )}
))}
); } else if (hasViewsCapability) { viewsElement = (
{i18n('icu:StoryViewsNRepliesModal__no-views')}
); } const tabsElement = viewsElement && repliesElement ? ( {({ selectedTab }) => ( <> {selectedTab === StoryViewsNRepliesTab.Views && viewsElement} {selectedTab === StoryViewsNRepliesTab.Replies && ( <> {repliesElement} {composerElement} )} )} ) : undefined; if (!tabsElement && !viewsElement && !repliesElement && !composerElement) { return null; } if (confirmDiscardModal) { return confirmDiscardModal; } return ( <>
{tabsElement || ( <> {viewsElement || repliesElement} {composerElement} )}
{deleteReplyId && ( deleteGroupStoryReply(deleteReplyId), style: 'negative', }, ]} title={i18n('icu:deleteWarning')} onClose={() => setDeleteReplyId(undefined)} onCancel={() => setDeleteReplyId(undefined)} /> )} {deleteForEveryoneReplyId && ( deleteGroupStoryReplyForEveryone(deleteForEveryoneReplyId), style: 'negative', }, ]} title={i18n('icu:deleteWarning')} onClose={() => setDeleteForEveryoneReplyId(undefined)} onCancel={() => setDeleteForEveryoneReplyId(undefined)} > {i18n('icu:deleteForEveryoneWarning')} )} ); } type ReplyOrReactionMessageProps = { containerElementRef: React.RefObject; deleteGroupStoryReply: (replyId: string) => void; deleteGroupStoryReplyForEveryone: (replyId: string) => void; displayLimit: number | undefined; getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; platform: string; id: string; isInternalUser?: boolean; isSpoilerExpanded: Record; onContextMenu?: (ev: React.MouseEvent) => void; reply: ReplyType; shouldCollapseAbove: boolean; shouldCollapseBelow: boolean; showContactModal: (contactId: string, conversationId?: string) => void; messageExpanded: (messageId: string, displayLimit: number) => void; showSpoiler: (messageId: string, data: Record) => void; }; function ReplyOrReactionMessage({ containerElementRef, deleteGroupStoryReply, deleteGroupStoryReplyForEveryone, displayLimit, getPreferredBadge, i18n, id, isInternalUser, isSpoilerExpanded, messageExpanded, platform, reply, shouldCollapseAbove, shouldCollapseBelow, showContactModal, showSpoiler, }: ReplyOrReactionMessageProps) { const handleDeleteReply = useCallback(() => { deleteGroupStoryReply(reply.id); }, [deleteGroupStoryReply, reply.id]); const handleDeleteReplyForEveryone = useCallback(() => { deleteGroupStoryReplyForEveryone(reply.id); }, [deleteGroupStoryReplyForEveryone, reply.id]); const handleCopyReplyTimestamp = useCallback(() => { drop(window.navigator.clipboard.writeText(String(reply.timestamp))); }, [reply.timestamp]); const renderMessageContextMenu = useCallback( (_renderer: AxoMenuBuilder.Renderer, children: ReactNode) => { return ( {children} {i18n('icu:StoryViewsNRepliesModal__delete-reply')} {!reply.deletedForEveryone && ( {i18n('icu:StoryViewsNRepliesModal__delete-reply-for-everyone')} )} {isInternalUser && ( {i18n('icu:StoryViewsNRepliesModal__copy-reply-timestamp')} )} ); }, [ i18n, reply, handleDeleteReply, handleDeleteReplyForEveryone, isInternalUser, handleCopyReplyTimestamp, ] ); if (reply.reactionEmoji && !reply.deletedForEveryone) { return renderMessageContextMenu( 'AxoContextMenu',
{reply.author.isMe ? i18n('icu:StoryViewsNRepliesModal__reacted--you') : i18n('icu:StoryViewsNRepliesModal__reacted--someone-else')}
); } return (
); }