// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import FocusTrap from 'focus-trap-react'; import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import classNames from 'classnames'; import type { DraftBodyRanges } from '../types/BodyRange'; import type { LocalizerType } from '../types/Util'; import type { ContextMenuOptionType } from './ContextMenu'; import type { ConversationType, SaveAttachmentActionCreatorType, } from '../state/ducks/conversations'; import type { EmojiPickDataType } from './emoji/EmojiPicker'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { RenderEmojiPickerProps } from './conversation/ReactionPicker'; import type { ReplyStateType, StoryViewType } from '../types/Stories'; import type { ShowToastAction } from '../state/ducks/toast'; import type { ViewStoryActionCreatorType } from '../state/ducks/stories'; import * as log from '../logging/log'; import { AnimatedEmojiGalore } from './AnimatedEmojiGalore'; import { Avatar, AvatarSize } from './Avatar'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenu } from './ContextMenu'; import { Intl } from './Intl'; import { MessageTimestamp } from './conversation/MessageTimestamp'; import { SendStatus } from '../messages/MessageSendState'; import { Spinner } from './Spinner'; import { StoryDetailsModal } from './StoryDetailsModal'; import { StoryDistributionListName } from './StoryDistributionListName'; import { StoryImage } from './StoryImage'; import { ResolvedSendStatus, StoryViewDirectionType, StoryViewModeType, StoryViewTargetType, } from '../types/Stories'; import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal'; import { Theme } from '../util/theme'; import { ToastType } from '../types/Toast'; import { getAvatarColor } from '../types/Colors'; import { getStoryBackground } from '../util/getStoryBackground'; import { getStoryDuration } from '../util/getStoryDuration'; import { isVideoAttachment } from '../types/Attachment'; import { graphemeAndLinkAwareSlice } from '../util/graphemeAndLinkAwareSlice'; import { useEscapeHandling } from '../hooks/useEscapeHandling'; import { useRetryStorySend } from '../hooks/useRetryStorySend'; import { resolveStorySendStatus } from '../util/resolveStorySendStatus'; import { strictAssert } from '../util/assert'; import { MessageBody } from './conversation/MessageBody'; import { RenderLocation } from './conversation/MessageTextRenderer'; import { arrow } from '../util/keyboard'; function renderStrong(parts: Array) { return {parts}; } export type PropsType = { currentIndex: number; deleteGroupStoryReply: (id: string) => void; deleteGroupStoryReplyForEveryone: (id: string) => void; deleteStoryForEveryone: (story: StoryViewType) => unknown; distributionList?: { id: string; name: string }; getPreferredBadge: PreferredBadgeSelectorType; group?: Pick< ConversationType, | 'acceptedMessageRequest' | 'avatarPath' | 'color' | 'id' | 'name' | 'profileName' | 'sharedGroupNames' | 'sortedGroupMembers' | 'title' | 'left' >; hasActiveCall?: boolean; hasAllStoriesUnmuted: boolean; hasViewReceiptSetting: boolean; i18n: LocalizerType; isFormattingEnabled: boolean; isFormattingFlagEnabled: boolean; isFormattingSpoilersFlagEnabled: boolean; isInternalUser?: boolean; isSignalConversation?: boolean; isWindowActive: boolean; loadStoryReplies: (conversationId: string, messageId: string) => unknown; markStoryRead: (mId: string) => unknown; numStories: number; onGoToConversation: (conversationId: string) => unknown; onHideStory: (conversationId: string) => unknown; onSetSkinTone: (tone: number) => unknown; onTextTooLong: () => unknown; onReactToStory: (emoji: string, story: StoryViewType) => unknown; onReplyToStory: ( message: string, bodyRanges: DraftBodyRanges, timestamp: number, story: StoryViewType ) => unknown; onUseEmoji: (_: EmojiPickDataType) => unknown; onMediaPlaybackStart: () => void; platform: string; preferredReactionEmoji: ReadonlyArray; queueStoryDownload: (storyId: string) => unknown; recentEmojis?: ReadonlyArray; renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; replyState?: ReplyStateType; retryMessageSend: (messageId: string) => unknown; saveAttachment: SaveAttachmentActionCreatorType; setHasAllStoriesUnmuted: (isUnmuted: boolean) => unknown; showContactModal: (contactId: string, conversationId?: string) => void; showToast: ShowToastAction; skinTone?: number; story: StoryViewType; storyViewMode: StoryViewModeType; viewStory: ViewStoryActionCreatorType; viewTarget?: StoryViewTargetType; }; const CAPTION_BUFFER = 20; const CAPTION_INITIAL_LENGTH = 200; const CAPTION_MAX_LENGTH = 700; const MOUSE_IDLE_TIME = 3000; enum Arrow { None, Left, Right, } export function StoryViewer({ currentIndex, deleteGroupStoryReply, deleteGroupStoryReplyForEveryone, deleteStoryForEveryone, distributionList, getPreferredBadge, group, hasActiveCall, hasAllStoriesUnmuted, hasViewReceiptSetting, i18n, isFormattingEnabled, isFormattingFlagEnabled, isFormattingSpoilersFlagEnabled, isInternalUser, isSignalConversation, isWindowActive, loadStoryReplies, markStoryRead, numStories, onGoToConversation, onHideStory, onReactToStory, onReplyToStory, onSetSkinTone, onTextTooLong, onUseEmoji, onMediaPlaybackStart, platform, preferredReactionEmoji, queueStoryDownload, recentEmojis, renderEmojiPicker, replyState, retryMessageSend, saveAttachment, setHasAllStoriesUnmuted, showContactModal, showToast, skinTone, story, storyViewMode, viewStory, viewTarget, }: PropsType): JSX.Element { const [isShowingContextMenu, setIsShowingContextMenu] = useState(false); const [storyDuration, setStoryDuration] = useState(); const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false); const [reactionEmoji, setReactionEmoji] = useState(); const [confirmDeleteStory, setConfirmDeleteStory] = useState< StoryViewType | undefined >(); const { attachment, bodyRanges, canReply, isHidden, messageId, messageIdForLogging, sendState, timestamp, } = story; const { acceptedMessageRequest, avatarPath, color, isMe, firstName, profileName, sharedGroupNames, title, } = story.sender; const conversationId = group?.id || story.sender.id; const sendStatus = sendState ? resolveStorySendStatus(sendState) : undefined; const { renderAlert, setWasManuallyRetried, wasManuallyRetried } = useRetryStorySend(i18n, sendStatus); const [currentViewTarget, setCurrentViewTarget] = useState( viewTarget ?? null ); useEffect(() => { setCurrentViewTarget(viewTarget ?? null); }, [viewTarget]); const onClose = useCallback(() => { viewStory({ closeViewer: true, }); }, [viewStory]); const onEscape = useCallback(() => { if (currentViewTarget != null) { setCurrentViewTarget(null); } else { onClose(); } }, [currentViewTarget, onClose]); useEscapeHandling(onEscape); // Caption related hooks const [hasExpandedCaption, setHasExpandedCaption] = useState(false); const [isSpoilerExpanded, setIsSpoilerExpanded] = useState< Record >({}); const caption = useMemo(() => { if (!attachment?.caption) { return; } return graphemeAndLinkAwareSlice( attachment.caption, hasExpandedCaption ? CAPTION_MAX_LENGTH : CAPTION_INITIAL_LENGTH, CAPTION_BUFFER ); }, [attachment?.caption, hasExpandedCaption]); // Reset expansion if messageId changes useEffect(() => { setHasExpandedCaption(false); setIsSpoilerExpanded({}); }, [messageId]); // messageId is set as a dependency so that we can reset the story duration // when a new story is selected in case the same story (and same attachment) // are sequentially posted. useEffect(() => { let shouldCancel = false; void (async function hydrateStoryDuration() { if (!attachment) { return; } const duration = await getStoryDuration(attachment); if (shouldCancel) { return; } log.info('stories.setStoryDuration', { contentType: attachment.textAttachment ? 'text' : attachment.contentType, duration, }); setStoryDuration(duration); })(); return () => { shouldCancel = true; }; }, [attachment, messageId]); const progressBarRef = useRef(null); const animationRef = useRef(null); // Putting this in a ref allows us to call it from the useEffect below without // triggering the effect to re-run every time these values change. const onFinishRef = useRef<(() => void) | null>(null); useEffect(() => { onFinishRef.current = () => { viewStory({ storyId: story.messageId, storyViewMode, viewDirection: StoryViewDirectionType.Next, }); }; }, [story.messageId, storyViewMode, viewStory]); // This guarantees that we'll have a valid ref to the animation when we need it strictAssert(currentIndex != null, "StoryViewer: currentIndex can't be null"); // We need to be careful about this effect refreshing, it should only run // every time a story changes or its duration changes. useEffect(() => { if (!storyDuration) { return; } strictAssert( progressBarRef.current != null, "progressBarRef can't be null" ); const target = progressBarRef.current; const animation = target.animate( [{ transform: 'translateX(-100%)' }, { transform: 'translateX(0%)' }], { id: 'story-progress-bar', duration: storyDuration, easing: 'linear', fill: 'forwards', } ); animationRef.current = animation; function onFinish() { onFinishRef.current?.(); } animation.addEventListener('finish', onFinish); // Reset the stuff that pauses a story when you switch story views setConfirmDeleteStory(undefined); setHasConfirmHideStory(false); setHasExpandedCaption(false); setIsSpoilerExpanded({}); setIsShowingContextMenu(false); setPauseStory(false); return () => { animation.removeEventListener('finish', onFinish); animation.cancel(); }; }, [story.messageId, storyDuration]); const [pauseStory, setPauseStory] = useState(false); useEffect(() => { if (!isWindowActive) { setPauseStory(true); } }, [isWindowActive]); const alertElement = renderAlert(); const shouldPauseViewing = Boolean(alertElement) || Boolean(confirmDeleteStory) || currentViewTarget != null || hasActiveCall || hasConfirmHideStory || hasExpandedCaption || isShowingContextMenu || pauseStory || Boolean(reactionEmoji); useEffect(() => { if (shouldPauseViewing) { animationRef.current?.pause(); } else { animationRef.current?.play(); } }, [shouldPauseViewing, story.messageId, storyDuration]); useEffect(() => { markStoryRead(messageId); log.info('stories.markStoryRead', { message: messageIdForLogging }); }, [markStoryRead, messageId, messageIdForLogging]); const canFreelyNavigateStories = storyViewMode === StoryViewModeType.All || storyViewMode === StoryViewModeType.Hidden || storyViewMode === StoryViewModeType.MyStories || storyViewMode === StoryViewModeType.Unread; const canNavigateLeft = (storyViewMode === StoryViewModeType.User && currentIndex > 0) || canFreelyNavigateStories; const canNavigateRight = (storyViewMode === StoryViewModeType.User && currentIndex < numStories - 1) || canFreelyNavigateStories; const navigateStories = useCallback( (ev: KeyboardEvent) => { // the replies modal can consume arrow keys // we don't want to navigate while someone is typing a reply if (currentViewTarget != null) { return; } if (canNavigateRight && ev.key === arrow('end')) { viewStory({ storyId: story.messageId, storyViewMode, viewDirection: StoryViewDirectionType.Next, }); ev.preventDefault(); ev.stopPropagation(); } else if (canNavigateLeft && ev.key === arrow('start')) { viewStory({ storyId: story.messageId, storyViewMode, viewDirection: StoryViewDirectionType.Previous, }); ev.preventDefault(); ev.stopPropagation(); } }, [ currentViewTarget, canNavigateLeft, canNavigateRight, story.messageId, storyViewMode, viewStory, ] ); useEffect(() => { document.addEventListener('keydown', navigateStories); return () => { document.removeEventListener('keydown', navigateStories); }; }, [navigateStories]); const groupId = group?.id; const isGroupStory = Boolean(groupId); useEffect(() => { if (!groupId) { return; } loadStoryReplies(groupId, messageId); }, [groupId, loadStoryReplies, messageId]); const [arrowToShow, setArrowToShow] = useState(Arrow.None); useEffect(() => { if (arrowToShow === Arrow.None) { return; } let mouseMoveExpiration: number | undefined; let timer: NodeJS.Timeout | undefined; function updateLastMouseMove() { mouseMoveExpiration = Date.now() + MOUSE_IDLE_TIME; if (timer === undefined) { checkMouseIdle(); } } function checkMouseIdle() { timer = undefined; if (mouseMoveExpiration === undefined) { return; } const remaining = mouseMoveExpiration - Date.now(); if (remaining <= 0) { setArrowToShow(Arrow.None); return; } timer = setTimeout(checkMouseIdle, remaining); } document.addEventListener('mousemove', updateLastMouseMove); return () => { if (timer !== undefined) { clearTimeout(timer); } mouseMoveExpiration = undefined; timer = undefined; document.removeEventListener('mousemove', updateLastMouseMove); }; }, [arrowToShow]); const replies = replyState && replyState.messageId === messageId ? replyState.replies : []; const views = sendState ? sendState.filter(({ status }) => status === SendStatus.Viewed) : []; const replyCount = replies.length; const viewCount = views.length; const hasAudio = isVideoAttachment(attachment); const isStoryMuted = !hasAllStoriesUnmuted || !hasAudio; let muteClassName: string; let muteAriaLabel: string; if (hasAudio) { muteAriaLabel = hasAllStoriesUnmuted ? i18n('icu:StoryViewer__mute') : i18n('icu:StoryViewer__unmute'); muteClassName = hasAllStoriesUnmuted ? 'StoryViewer__mute' : 'StoryViewer__unmute'; } else { muteAriaLabel = i18n('icu:Stories__toast--hasNoSound'); muteClassName = 'StoryViewer__soundless'; } const isSent = Boolean(sendState); let contextMenuOptions: | ReadonlyArray> | undefined; if (isSent) { contextMenuOptions = [ { icon: 'StoryListItem__icon--info', label: i18n('icu:StoryListItem__info'), onClick: () => setCurrentViewTarget(StoryViewTargetType.Details), }, { icon: 'StoryListItem__icon--delete', label: i18n('icu:StoryListItem__delete'), onClick: () => setConfirmDeleteStory(story), }, ]; } else if (!isSignalConversation) { contextMenuOptions = [ { icon: 'StoryListItem__icon--info', label: i18n('icu:StoryListItem__info'), onClick: () => setCurrentViewTarget(StoryViewTargetType.Details), }, { icon: 'StoryListItem__icon--hide', label: isHidden ? i18n('icu:StoryListItem__unhide') : i18n('icu:StoryListItem__hide'), onClick: () => { if (isHidden) { onHideStory(conversationId); } else { setHasConfirmHideStory(true); } }, }, { icon: 'StoryListItem__icon--chat', label: i18n('icu:StoryListItem__go-to-chat'), onClick: () => { onGoToConversation(conversationId); }, }, ]; } function doRetryMessageSend() { if (wasManuallyRetried) { return; } if ( sendStatus !== ResolvedSendStatus.Failed && sendStatus !== ResolvedSendStatus.PartiallySent ) { return; } setWasManuallyRetried(true); retryMessageSend(messageId); } return (
{alertElement}
{canNavigateLeft && (
{canNavigateRight && ( )}
)}
{group && ( )}
{(group && i18n('icu:Stories__from-to-group', { name: isMe ? i18n('icu:you') : title, group: group.title, })) || (isMe ? i18n('icu:you') : title)}
{distributionList && (
)}
{Array.from(Array(numStories), (_, index) => (
{currentIndex === index ? (
) : (
)}
))}
{sendStatus === ResolvedSendStatus.Failed && !wasManuallyRetried && ( )} {sendStatus === ResolvedSendStatus.PartiallySent && !wasManuallyRetried && ( )} {sendStatus === ResolvedSendStatus.Sending && (
{i18n('icu:StoryViewer__sending')}
)} {(canReply || (isSent && sendStatus === ResolvedSendStatus.Sent)) && ( )}
{currentViewTarget === StoryViewTargetType.Details && ( setCurrentViewTarget(null)} saveAttachment={saveAttachment} sender={story.sender} sendState={sendState} timestamp={timestamp} expirationTimestamp={story.expirationTimestamp} /> )} {(currentViewTarget === StoryViewTargetType.Replies || currentViewTarget === StoryViewTargetType.Views) && ( setCurrentViewTarget(null)} onReact={emoji => { onReactToStory(emoji, story); if (!isGroupStory) { setCurrentViewTarget(null); showToast({ toastType: ToastType.StoryReact }); } setReactionEmoji(emoji); }} onReply={(message, replyBodyRanges, replyTimestamp) => { if (!isGroupStory) { setCurrentViewTarget(null); showToast({ toastType: ToastType.StoryReply }); } onReplyToStory(message, replyBodyRanges, replyTimestamp, story); }} onSetSkinTone={onSetSkinTone} onTextTooLong={onTextTooLong} onUseEmoji={onUseEmoji} preferredReactionEmoji={preferredReactionEmoji} recentEmojis={recentEmojis} renderEmojiPicker={renderEmojiPicker} replies={replies} showContactModal={showContactModal} skinTone={skinTone} sortedGroupMembers={group?.sortedGroupMembers} views={views} viewTarget={currentViewTarget} onChangeViewTarget={setCurrentViewTarget} deleteGroupStoryReply={deleteGroupStoryReply} deleteGroupStoryReplyForEveryone={deleteGroupStoryReplyForEveryone} /> )} {hasConfirmHideStory && ( { onHideStory(conversationId); onClose(); }, style: 'affirmative', text: i18n('icu:StoryListItem__hide-modal--confirm'), }, ]} i18n={i18n} onClose={() => { setHasConfirmHideStory(false); }} > {i18n('icu:StoryListItem__hide-modal--body', { name: String(firstName), })} )} {confirmDeleteStory && ( deleteStoryForEveryone(confirmDeleteStory), style: 'negative', }, ]} i18n={i18n} onClose={() => setConfirmDeleteStory(undefined)} > {i18n('icu:MyStories__delete')} )}
); }