// 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 { Globals, useSpring, animated, to } from '@react-spring/web'; 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 { 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 } 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'; 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' >; hasActiveCall?: boolean; hasAllStoriesMuted: boolean; hasReadReceiptSetting: 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; shouldShowDetailsModal?: boolean; 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, hasReadReceiptSetting, i18n, loadStoryReplies, markStoryRead, numStories, onGoToConversation, onHideStory, onReactToStory, onReplyToStory, onSetSkinTone, onTextTooLong, onUseEmoji, preferredReactionEmoji, queueStoryDownload, recentEmojis, renderEmojiPicker, replyState, shouldShowDetailsModal, 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, sendState, timestamp } = story; const { acceptedMessageRequest, avatarPath, color, isMe, firstName, profileName, sharedGroupNames, title, } = story.sender; const conversationId = group?.id || story.sender.id; const [hasStoryViewsNRepliesModal, setHasStoryViewsNRepliesModal] = useState(false); const [hasStoryDetailsModal, setHasStoryDetailsModal] = useState( Boolean(shouldShowDetailsModal) ); const onClose = useCallback(() => { viewStory({ closeViewer: true, }); }, [viewStory]); const onEscape = useCallback(() => { if (hasStoryViewsNRepliesModal) { setHasStoryViewsNRepliesModal(false); } else { onClose(); } }, [hasStoryViewsNRepliesModal, 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 unmountRef = useRef(false); useEffect(() => { return () => { unmountRef.current = true; }; }, []); // Currently there's no way to globally skip animations but only allow select // ones. This component temporarily overrides the skipAnimation global and // then sets it back when it unmounts. // https://github.com/pmndrs/react-spring/issues/1982 useEffect(() => { const { skipAnimation } = Globals; Globals.assign({ skipAnimation: false, }); return () => { Globals.assign({ skipAnimation, }); }; }, []); const [styles, spring] = useSpring( () => ({ from: { width: 0 }, to: { width: 100 }, loop: true, onRest: { width: ({ value }) => { if (unmountRef.current) { log.info( 'stories.StoryViewer.spring.onRest: called after component unmounted' ); return; } if (value === 100) { viewStory({ storyId: story.messageId, storyViewMode, viewDirection: StoryViewDirectionType.Next, }); } }, }, }), [story.messageId, storyViewMode, viewStory] ); // 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) { spring.stop(); return; } spring.start({ config: { duration: storyDuration, }, from: { width: 0 }, to: { width: 100 }, }); return () => { spring.stop(); }; }, [currentIndex, spring, storyDuration]); const [pauseStory, setPauseStory] = useState(false); const shouldPauseViewing = hasActiveCall || hasConfirmHideStory || hasExpandedCaption || hasStoryDetailsModal || hasStoryViewsNRepliesModal || isShowingContextMenu || pauseStory || Boolean(reactionEmoji); useEffect(() => { if (shouldPauseViewing) { spring.pause(); } else { spring.resume(); } }, [shouldPauseViewing, spring]); useEffect(() => { markStoryRead(messageId); log.info('stories.markStoryRead', { messageId }); }, [markStoryRead, messageId]); const canFreelyNavigateStories = storyViewMode === StoryViewModeType.All || storyViewMode === StoryViewModeType.Hidden || 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 (hasStoryViewsNRepliesModal) { 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(); } }, [ hasStoryViewsNRepliesModal, 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: () => setHasStoryDetailsModal(true), }, { icon: 'StoryListItem__icon--delete', label: i18n('StoryListItem__delete'), onClick: () => setConfirmDeleteStory(story), }, ] : [ { icon: 'StoryListItem__icon--info', label: i18n('StoryListItem__info'), onClick: () => setHasStoryDetailsModal(true), }, { 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 ? ( `${width}%`), }} /> ) : (
)}
))}
{(canReply || isSent) && ( )}
{hasStoryDetailsModal && ( setHasStoryDetailsModal(false)} sender={story.sender} sendState={sendState} size={attachment?.size} timestamp={timestamp} expirationTimestamp={story.expirationTimestamp} /> )} {hasStoryViewsNRepliesModal && ( setHasStoryViewsNRepliesModal(false)} onReact={emoji => { onReactToStory(emoji, story); if (!isGroupStory) { setHasStoryViewsNRepliesModal(false); showToast(ToastType.StoryReact); } setReactionEmoji(emoji); }} onReply={(message, mentions, replyTimestamp) => { if (!isGroupStory) { setHasStoryViewsNRepliesModal(false); 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} /> )} {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')} )}
); };