// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useCallback, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import classNames from 'classnames'; import { usePopper } from 'react-popper'; import { noop } from 'lodash'; import type { AttachmentType } from '../types/Attachment'; import type { DraftBodyRangesType, LocalizerType } from '../types/Util'; import type { ConversationType } from '../state/ducks/conversations'; import type { EmojiPickDataType } from './emoji/EmojiPicker'; import type { InputApi } from './CompositionInput'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { RenderEmojiPickerProps } from './conversation/ReactionPicker'; import type { ReplyType, StorySendStateType } from '../types/Stories'; import { StoryViewTargetType } from '../types/Stories'; 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'; import { MessageTimestamp } from './conversation/MessageTimestamp'; import { Modal } from './Modal'; import { Quote } from './conversation/Quote'; import { ReactionPicker } from './conversation/ReactionPicker'; import { Tabs } from './Tabs'; import { Theme } from '../util/theme'; import { ThemeType } from '../types/Util'; import { WidthBreakpoint } from './_util'; import { getAvatarColor } from '../types/Colors'; import { getStoryReplyText } from '../util/getStoryReplyText'; import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled'; 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, markViewed: shouldNeverBeCalled, messageExpanded: shouldNeverBeCalled, // Called when clicking mention, but shouldn't do anything. openConversation: noop, openGiftBadge: shouldNeverBeCalled, openLink: shouldNeverBeCalled, previews: [], renderAudioAttachment: () =>
, scrollToQuotedMessage: shouldNeverBeCalled, showContactDetail: shouldNeverBeCalled, showContactModal: shouldNeverBeCalled, showExpiredIncomingTapToViewToast: shouldNeverBeCalled, showExpiredOutgoingTapToViewToast: shouldNeverBeCalled, showLightbox: shouldNeverBeCalled, showLightboxForViewOnceMedia: shouldNeverBeCalled, showMessageDetail: shouldNeverBeCalled, startConversation: shouldNeverBeCalled, theme: ThemeType.dark, viewStory: shouldNeverBeCalled, }; export enum StoryViewsNRepliesTab { Replies = 'Replies', Views = 'Views', } export type PropsType = { conversationTitle: string; authorTitle: string; canReply: boolean; getPreferredBadge: PreferredBadgeSelectorType; hasViewReceiptSetting: boolean; hasViewsCapability: boolean; i18n: LocalizerType; isInternalUser?: boolean; group: Pick | undefined; onClose: () => unknown; onReact: (emoji: string) => unknown; onReply: ( message: string, mentions: DraftBodyRangesType, timestamp: number ) => unknown; onSetSkinTone: (tone: number) => unknown; onTextTooLong: () => unknown; onUseEmoji: (_: EmojiPickDataType) => unknown; preferredReactionEmoji: Array; recentEmojis?: Array; renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; replies: ReadonlyArray; skinTone?: number; sortedGroupMembers?: Array; storyPreviewAttachment?: AttachmentType; views: Array; viewTarget: StoryViewTargetType; onChangeViewTarget: (target: StoryViewTargetType) => unknown; deleteGroupStoryReply: (id: string) => void; deleteGroupStoryReplyForEveryone: (id: string) => void; }; export function StoryViewsNRepliesModal({ conversationTitle, authorTitle, canReply, getPreferredBadge, hasViewReceiptSetting, hasViewsCapability, i18n, isInternalUser, group, onClose, onReact, onReply, onSetSkinTone, onTextTooLong, onUseEmoji, preferredReactionEmoji, recentEmojis, renderEmojiPicker, replies, skinTone, sortedGroupMembers, storyPreviewAttachment, views, viewTarget, onChangeViewTarget, deleteGroupStoryReply, deleteGroupStoryReplyForEveryone, }: PropsType): JSX.Element | null { const [deleteReplyId, setDeleteReplyId] = useState( undefined ); const [deleteForEveryoneReplyId, setDeleteForEveryoneReplyId] = useState< string | undefined >(undefined); const containerElementRef = useRef(null); const inputApiRef = useRef(); const shouldScrollToBottomRef = useRef(true); const bottomRef = useRef(null); const [messageBodyText, setMessageBodyText] = useState(''); const [showReactionPicker, setShowReactionPicker] = useState(false); const currentTab = useMemo(() => { return viewTarget === StoryViewTargetType.Replies ? StoryViewsNRepliesTab.Replies : StoryViewsNRepliesTab.Views; }, [viewTarget]); const onTabChange = (tab: string) => { onChangeViewTarget( tab === StoryViewsNRepliesTab.Replies ? StoryViewTargetType.Replies : StoryViewTargetType.Views ); }; const focusComposer = useCallback(() => { if (inputApiRef.current) { inputApiRef.current.focus(); } }, [inputApiRef]); const insertEmoji = useCallback( (e: EmojiPickDataType) => { if (inputApiRef.current) { inputApiRef.current.insertEmoji(e); onUseEmoji(e); } }, [inputApiRef, onUseEmoji] ); const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState( null ); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: 'top-start', strategy: 'fixed', }); 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]); if (group && group.left) { composerElement = (
{i18n('icu:StoryViewsNRepliesModal__not-a-member')}
); } else if (canReply) { composerElement = ( <> {!group && ( )}
{ setMessageBodyText(messageText); }} onPickEmoji={onUseEmoji} onSubmit={(...args) => { inputApiRef.current?.reset(); shouldScrollToBottomRef.current = true; onReply(...args); }} onTextTooLong={onTextTooLong} placeholder={ group ? i18n('StoryViewer__reply-group') : i18n('StoryViewer__reply') } sortedGroupMembers={sortedGroupMembers} theme={ThemeType.dark} >
); } 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) } getPreferredBadge={getPreferredBadge} shouldCollapseAbove={shouldCollapse(reply, replies[index - 1])} shouldCollapseBelow={shouldCollapse(reply, replies[index + 1])} containerElementRef={containerElementRef} /> ); })}
); } else if (group) { repliesElement = (
{i18n('StoryViewsNRepliesModal__no-replies')}
); } let viewsElement: JSX.Element | undefined; if (hasViewsCapability && !hasViewReceiptSetting) { viewsElement = (
{i18n('StoryViewsNRepliesModal__read-receipts-off')}
); } else if (views.length) { viewsElement = (
{views.map(view => (
{view.updatedAt && ( )}
))}
); } else if (hasViewsCapability) { viewsElement = (
{i18n('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; } return ( <>
{tabsElement || ( <> {viewsElement || repliesElement} {composerElement} )}
{deleteReplyId && ( deleteGroupStoryReply(deleteReplyId), style: 'negative', }, ]} title={i18n('deleteWarning')} onClose={() => setDeleteReplyId(undefined)} onCancel={() => setDeleteReplyId(undefined)} /> )} {deleteForEveryoneReplyId && ( deleteGroupStoryReplyForEveryone(deleteForEveryoneReplyId), style: 'negative', }, ]} title={i18n('deleteWarning')} onClose={() => setDeleteForEveryoneReplyId(undefined)} onCancel={() => setDeleteForEveryoneReplyId(undefined)} > {i18n('deleteForEveryoneWarning')} )} ); } type ReplyOrReactionMessageProps = { i18n: LocalizerType; isInternalUser?: boolean; reply: ReplyType; deleteGroupStoryReply: (replyId: string) => void; deleteGroupStoryReplyForEveryone: (replyId: string) => void; getPreferredBadge: PreferredBadgeSelectorType; shouldCollapseAbove: boolean; shouldCollapseBelow: boolean; containerElementRef: React.RefObject; onContextMenu?: (ev: React.MouseEvent) => void; }; function ReplyOrReactionMessage({ i18n, isInternalUser, reply, deleteGroupStoryReply, deleteGroupStoryReplyForEveryone, containerElementRef, getPreferredBadge, shouldCollapseAbove, shouldCollapseBelow, }: ReplyOrReactionMessageProps) { const renderContent = (onContextMenu?: (ev: React.MouseEvent) => void) => { if (reply.reactionEmoji && !reply.deletedForEveryone) { return (
{i18n('StoryViewsNRepliesModal__reacted')}
); } return ( ); }; 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: () => { window.navigator.clipboard.writeText(String(reply.timestamp)); }, }); } return reply.author.isMe && !reply.deletedForEveryone ? ( {({ openMenu, menuNode }) => ( <> {renderContent(openMenu)} {menuNode} )} ) : ( renderContent() ); }