// 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 { BodyRangeType, LocalizerType } from '../types/Util'; import type { ContextMenuOptionType } from './ContextMenu'; import type { ConversationType } 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 { ShowToastActionCreatorType } 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 { Emojify } from './conversation/Emojify'; import { Intl } from './Intl'; import { MessageTimestamp } from './conversation/MessageTimestamp'; import { SendStatus } from '../messages/MessageSendState'; import { StoryDetailsModal } from './StoryDetailsModal'; import { StoryDistributionListName } from './StoryDistributionListName'; import { StoryImage } from './StoryImage'; import { StoryViewDirectionType, StoryViewModeType, StoryViewTargetType, } from '../types/Stories'; import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal'; import { Theme } from '../util/theme'; import { ToastType } from '../state/ducks/toast'; import { getAvatarColor } from '../types/Colors'; import { getStoryBackground } from '../util/getStoryBackground'; import { getStoryDuration } from '../util/getStoryDuration'; import { graphemeAwareSlice } from '../util/graphemeAwareSlice'; import { isVideoAttachment } from '../types/Attachment'; import { useEscapeHandling } from '../hooks/useEscapeHandling'; import { strictAssert } from '../util/assert'; export type PropsType = { currentIndex: number; 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; hasAllStoriesMuted: boolean; hasViewReceiptSetting: boolean; i18n: LocalizerType; 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, mentions: Array, timestamp: number, story: StoryViewType ) => unknown; onUseEmoji: (_: EmojiPickDataType) => unknown; preferredReactionEmoji: Array; queueStoryDownload: (storyId: string) => unknown; recentEmojis?: Array; renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; replyState?: ReplyStateType; viewTarget?: StoryViewTargetType; showToast: ShowToastActionCreatorType; skinTone?: number; story: StoryViewType; storyViewMode: StoryViewModeType; toggleHasAllStoriesMuted: () => unknown; viewStory: ViewStoryActionCreatorType; }; 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 const StoryViewer = ({ currentIndex, deleteStoryForEveryone, distributionList, getPreferredBadge, group, hasActiveCall, hasAllStoriesMuted, hasViewReceiptSetting, i18n, loadStoryReplies, markStoryRead, numStories, onGoToConversation, onHideStory, onReactToStory, onReplyToStory, onSetSkinTone, onTextTooLong, onUseEmoji, preferredReactionEmoji, queueStoryDownload, recentEmojis, renderEmojiPicker, replyState, viewTarget, showToast, skinTone, story, storyViewMode, toggleHasAllStoriesMuted, viewStory, }: 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, 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 [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 caption = useMemo(() => { if (!attachment?.caption) { return; } return graphemeAwareSlice( attachment.caption, hasExpandedCaption ? CAPTION_MAX_LENGTH : CAPTION_INITIAL_LENGTH, CAPTION_BUFFER ); }, [attachment?.caption, hasExpandedCaption]); // Reset expansion if messageId changes useEffect(() => { setHasExpandedCaption(false); }, [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; (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([{ width: '0%' }, { width: '100%' }], { id: 'story-progress-bar', duration: storyDuration, easing: 'linear', fill: 'forwards', }); animationRef.current = animation; function onFinish() { onFinishRef.current?.(); } animation.addEventListener('finish', onFinish); return () => { animation.removeEventListener('finish', onFinish); animation.cancel(); }; }, [story.messageId, storyDuration]); const [pauseStory, setPauseStory] = useState(false); const shouldPauseViewing = hasActiveCall || hasConfirmHideStory || hasExpandedCaption || currentViewTarget != null || isShowingContextMenu || pauseStory || Boolean(reactionEmoji); useEffect(() => { if (shouldPauseViewing) { animationRef.current?.pause(); } else { animationRef.current?.play(); } }, [shouldPauseViewing]); 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 === 'ArrowRight') { viewStory({ storyId: story.messageId, storyViewMode, viewDirection: StoryViewDirectionType.Next, }); ev.preventDefault(); ev.stopPropagation(); } else if (canNavigateLeft && ev.key === 'ArrowLeft') { 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 lastMouseMove: number | undefined; function updateLastMouseMove() { lastMouseMove = Date.now(); } function checkMouseIdle() { requestAnimationFrame(() => { if (lastMouseMove && Date.now() - lastMouseMove > MOUSE_IDLE_TIME) { setArrowToShow(Arrow.None); } else { checkMouseIdle(); } }); } checkMouseIdle(); document.addEventListener('mousemove', updateLastMouseMove); return () => { lastMouseMove = 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 canMuteStory = isVideoAttachment(attachment); const isStoryMuted = hasAllStoriesMuted || !canMuteStory; let muteClassName: string; let muteAriaLabel: string; if (canMuteStory) { muteAriaLabel = hasAllStoriesMuted ? i18n('StoryViewer__unmute') : i18n('StoryViewer__mute'); muteClassName = hasAllStoriesMuted ? 'StoryViewer__unmute' : 'StoryViewer__mute'; } else { muteAriaLabel = i18n('Stories__toast--hasNoSound'); muteClassName = 'StoryViewer__soundless'; } const isSent = Boolean(sendState); const contextMenuOptions: ReadonlyArray> = isSent ? [ { icon: 'StoryListItem__icon--info', label: i18n('StoryListItem__info'), onClick: () => setCurrentViewTarget(StoryViewTargetType.Details), }, { icon: 'StoryListItem__icon--delete', label: i18n('StoryListItem__delete'), onClick: () => setConfirmDeleteStory(story), }, ] : [ { icon: 'StoryListItem__icon--info', label: i18n('StoryListItem__info'), onClick: () => setCurrentViewTarget(StoryViewTargetType.Details), }, { icon: 'StoryListItem__icon--hide', label: isHidden ? i18n('StoryListItem__unhide') : i18n('StoryListItem__hide'), onClick: () => { if (isHidden) { onHideStory(conversationId); } else { setHasConfirmHideStory(true); } }, }, { icon: 'StoryListItem__icon--chat', label: i18n('StoryListItem__go-to-chat'), onClick: () => { onGoToConversation(conversationId); }, }, ]; return (
{canNavigateLeft && (
{canNavigateRight && ( )}
)}
{group && ( )}
{(group && i18n('Stories__from-to-group', { name: isMe ? i18n('you') : title, group: group.title, })) || (isMe ? i18n('you') : title)}
{distributionList && (
)}
{Array.from(Array(numStories), (_, index) => (
{currentIndex === index ? (
) : (
)}
))}
{(canReply || isSent) && ( )}
{currentViewTarget === StoryViewTargetType.Details && ( setCurrentViewTarget(null)} sender={story.sender} sendState={sendState} size={attachment?.size} timestamp={timestamp} expirationTimestamp={story.expirationTimestamp} /> )} {(currentViewTarget === StoryViewTargetType.Replies || currentViewTarget === StoryViewTargetType.Views) && ( setCurrentViewTarget(null)} onReact={emoji => { onReactToStory(emoji, story); if (!isGroupStory) { setCurrentViewTarget(null); showToast(ToastType.StoryReact); } setReactionEmoji(emoji); }} onReply={(message, mentions, replyTimestamp) => { if (!isGroupStory) { setCurrentViewTarget(null); showToast(ToastType.StoryReply); } onReplyToStory(message, mentions, replyTimestamp, story); }} onSetSkinTone={onSetSkinTone} onTextTooLong={onTextTooLong} onUseEmoji={onUseEmoji} preferredReactionEmoji={preferredReactionEmoji} recentEmojis={recentEmojis} renderEmojiPicker={renderEmojiPicker} replies={replies} skinTone={skinTone} sortedGroupMembers={group?.sortedGroupMembers} storyPreviewAttachment={attachment} views={views} viewTarget={currentViewTarget} onChangeViewTarget={setCurrentViewTarget} /> )} {hasConfirmHideStory && ( { onHideStory(conversationId); onClose(); }, style: 'affirmative', text: i18n('StoryListItem__hide-modal--confirm'), }, ]} i18n={i18n} onClose={() => { setHasConfirmHideStory(false); }} > {i18n('StoryListItem__hide-modal--body', [String(firstName)])} )} {confirmDeleteStory && ( deleteStoryForEveryone(confirmDeleteStory), style: 'negative', }, ]} i18n={i18n} onClose={() => setConfirmDeleteStory(undefined)} > {i18n('MyStories__delete')} )}
); };