// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import FocusTrap from 'focus-trap-react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useSpring, animated, to } from '@react-spring/web'; import type { BodyRangeType, LocalizerType } from '../types/Util'; 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 { StoryViewType } from './StoryListItem'; import { Avatar, AvatarSize } from './Avatar'; import { Intl } from './Intl'; import { MessageTimestamp } from './conversation/MessageTimestamp'; import { StoryImage } from './StoryImage'; import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal'; import { getAvatarColor } from '../types/Colors'; import { getStoryDuration } from '../util/getStoryDuration'; import { graphemeAwareSlice } from '../util/graphemeAwareSlice'; import { isDownloaded, isDownloading } from '../types/Attachment'; import { useEscapeHandling } from '../hooks/useEscapeHandling'; export type PropsType = { getPreferredBadge: PreferredBadgeSelectorType; group?: ConversationType; i18n: LocalizerType; markStoryRead: (mId: string) => unknown; onClose: () => unknown; onNextUserStories: () => unknown; onPrevUserStories: () => 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; replies?: number; renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; skinTone?: number; stories: Array; views?: number; }; const CAPTION_BUFFER = 20; const CAPTION_INITIAL_LENGTH = 200; const CAPTION_MAX_LENGTH = 700; export const StoryViewer = ({ getPreferredBadge, group, i18n, markStoryRead, onClose, onNextUserStories, onPrevUserStories, onReactToStory, onReplyToStory, onSetSkinTone, onTextTooLong, onUseEmoji, preferredReactionEmoji, queueStoryDownload, recentEmojis, renderEmojiPicker, replies, skinTone, stories, views, }: PropsType): JSX.Element => { const [currentStoryIndex, setCurrentStoryIndex] = useState(0); const [storyDuration, setStoryDuration] = useState(); const visibleStory = stories[currentStoryIndex]; const { attachment, messageId, timestamp } = visibleStory; const { acceptedMessageRequest, avatarPath, color, isMe, name, profileName, sharedGroupNames, title, } = visibleStory.sender; const [hasReplyModal, setHasReplyModal] = useState(false); const onEscape = useCallback(() => { if (hasReplyModal) { setHasReplyModal(false); } else { onClose(); } }, [hasReplyModal, 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]); // Either we show the next story in the current user's stories or we ask // for the next user's stories. const showNextStory = useCallback(() => { if (currentStoryIndex < stories.length - 1) { setCurrentStoryIndex(currentStoryIndex + 1); } else { setCurrentStoryIndex(0); onNextUserStories(); } }, [currentStoryIndex, onNextUserStories, stories.length]); // Either we show the previous story in the current user's stories or we ask // for the prior user's stories. const showPrevStory = useCallback(() => { if (currentStoryIndex === 0) { onPrevUserStories(); } else { setCurrentStoryIndex(currentStoryIndex - 1); } }, [currentStoryIndex, onPrevUserStories]); useEffect(() => { let shouldCancel = false; (async function hydrateStoryDuration() { if (!attachment) { return; } const duration = await getStoryDuration(attachment); if (shouldCancel) { return; } setStoryDuration(duration); })(); return () => { shouldCancel = true; }; }, [attachment]); const [styles, spring] = useSpring(() => ({ from: { width: 0 }, to: { width: 100 }, loop: true, })); // Adding "currentStoryIndex" to the dependency list here to explcitly signal // that this useEffect should run whenever the story changes. useEffect(() => { spring.start({ config: { duration: storyDuration, }, from: { width: 0 }, to: { width: 100 }, onRest: { width: ({ value }) => { if (value === 100) { showNextStory(); } }, }, }); return () => { spring.stop(); }; }, [currentStoryIndex, showNextStory, spring, storyDuration]); useEffect(() => { if (hasReplyModal) { spring.pause(); } else { spring.resume(); } }, [hasReplyModal, spring]); useEffect(() => { markStoryRead(messageId); }, [markStoryRead, messageId]); // Queue all undownloaded stories once we're viewing someone's stories const storiesToDownload = useMemo(() => { return stories .filter( story => !isDownloaded(story.attachment) && !isDownloading(story.attachment) ) .map(story => story.messageId); }, [stories]); useEffect(() => { storiesToDownload.forEach(id => queueStoryDownload(id)); }, [queueStoryDownload, storiesToDownload]); const navigateStories = useCallback( (ev: KeyboardEvent) => { if (ev.key === 'ArrowRight') { showNextStory(); ev.preventDefault(); ev.stopPropagation(); } else if (ev.key === 'ArrowLeft') { showPrevStory(); ev.preventDefault(); ev.stopPropagation(); } }, [showPrevStory, showNextStory] ); useEffect(() => { document.addEventListener('keydown', navigateStories); return () => { document.removeEventListener('keydown', navigateStories); }; }, [navigateStories]); return (
)}
)} {group && ( )}
{group ? i18n('Stories__from-to-group', { name: title, group: group.title, }) : title}
{stories.map((story, index) => (
{currentStoryIndex === index ? ( `${width}%`), }} /> ) : (
)}
))}
{isMe ? ( <> {views && (views === 1 ? ( {views}]} /> ) : ( {views}]} /> ))} {views && replies && ' '} {replies && (replies === 1 ? ( {replies}]} /> ) : ( {replies}]} /> ))} ) : ( )}
{hasReplyModal && ( setHasReplyModal(false)} onReact={emoji => { onReactToStory(emoji, visibleStory); }} onReply={(message, mentions, replyTimestamp) => { setHasReplyModal(false); onReplyToStory(message, mentions, replyTimestamp, visibleStory); }} onSetSkinTone={onSetSkinTone} onTextTooLong={onTextTooLong} onUseEmoji={onUseEmoji} preferredReactionEmoji={preferredReactionEmoji} recentEmojis={recentEmojis} renderEmojiPicker={renderEmojiPicker} replies={[]} skinTone={skinTone} storyPreviewAttachment={attachment} views={[]} /> )}
); };