// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useEffect, useState } from 'react'; import { get, has } from 'lodash'; import { createPortal } from 'react-dom'; import type { AttachmentType } from '../types/Attachment'; import type { LinkPreviewSourceType } from '../types/LinkPreview'; import type { LinkPreviewForUIType } from '../types/message/LinkPreviews'; import type { LocalizerType, ThemeType } from '../types/Util'; import type { Props as StickerButtonProps } from './stickers/StickerButton'; import type { PropsType as SendStoryModalPropsType } from './SendStoryModal'; import type { StoryDistributionIdString } from '../types/StoryDistributionId'; import type { imageToBlurHash } from '../util/imageToBlurHash'; import type { PropsType as TextStoryCreatorPropsType } from './TextStoryCreator'; import type { PropsType as MediaEditorPropsType } from './MediaEditor'; import { TEXT_ATTACHMENT } from '../types/MIME'; import { isVideoAttachment } from '../types/Attachment'; import { SendStoryModal } from './SendStoryModal'; import { MediaEditor } from './MediaEditor'; import { TextStoryCreator } from './TextStoryCreator'; import type { DraftBodyRanges } from '../types/BodyRange'; import type { processAttachment } from '../util/processAttachment'; function usePortalElement(testid: string): HTMLDivElement | null { const [element, setElement] = useState(null); useEffect(() => { const div = document.createElement('div'); div.dataset.testid = testid; document.body.appendChild(div); setElement(div); return () => { document.body.removeChild(div); }; }, [testid]); return element; } export type PropsType = { debouncedMaybeGrabLinkPreview: ( message: string, source: LinkPreviewSourceType ) => unknown; file?: File; i18n: LocalizerType; isSending: boolean; linkPreview?: LinkPreviewForUIType; onClose: () => unknown; onSend: ( listIds: Array, conversationIds: Array, attachment: AttachmentType, bodyRanges: DraftBodyRanges | undefined ) => unknown; imageToBlurHash: typeof imageToBlurHash; processAttachment: typeof processAttachment; sendStoryModalOpenStateChanged: (isOpen: boolean) => unknown; theme: ThemeType; } & Pick & Pick< SendStoryModalPropsType, | 'candidateConversations' | 'distributionLists' | 'getPreferredBadge' | 'groupConversations' | 'groupStories' | 'hasFirstStoryPostExperience' | 'me' | 'ourConversationId' | 'onDeleteList' | 'onDistributionListCreated' | 'onHideMyStoriesFrom' | 'onRemoveMembers' | 'onRepliesNReactionsChanged' | 'onSelectedStoryList' | 'onViewersUpdated' | 'setMyStoriesToAllSignalConnections' | 'signalConnections' | 'toggleGroupsForStorySend' | 'mostRecentActiveStoryTimestampByGroupOrDistributionList' | 'toggleSignalConnectionsModal' | 'onMediaPlaybackStart' > & Pick< TextStoryCreatorPropsType, | 'onUseEmoji' | 'emojiSkinToneDefault' | 'onEmojiSkinToneDefaultChange' | 'recentEmojis' > & Pick< MediaEditorPropsType, | 'isFormattingEnabled' | 'onPickEmoji' | 'onTextTooLong' | 'platform' | 'sortedGroupMembers' >; export function StoryCreator({ candidateConversations, debouncedMaybeGrabLinkPreview, distributionLists, file, getPreferredBadge, groupConversations, groupStories, hasFirstStoryPostExperience, i18n, imageToBlurHash, installedPacks, isFormattingEnabled, isSending, linkPreview, me, mostRecentActiveStoryTimestampByGroupOrDistributionList, onClose, onDeleteList, onDistributionListCreated, onHideMyStoriesFrom, onMediaPlaybackStart, onPickEmoji, onRemoveMembers, onRepliesNReactionsChanged, onSelectedStoryList, onSend, onEmojiSkinToneDefaultChange, onTextTooLong, onUseEmoji, onViewersUpdated, ourConversationId, platform, processAttachment, recentEmojis, recentStickers, sendStoryModalOpenStateChanged, setMyStoriesToAllSignalConnections, signalConnections, emojiSkinToneDefault, sortedGroupMembers, theme, toggleGroupsForStorySend, toggleSignalConnectionsModal, }: PropsType): JSX.Element | null { const portalElement = usePortalElement('StoryCreatorPortal'); const [draftAttachment, setDraftAttachment] = useState< AttachmentType | undefined >(); const [isReadyToSend, setIsReadyToSend] = useState(false); const [attachmentUrl, setAttachmentUrl] = useState(); const [bodyRanges, setBodyRanges] = useState(); useEffect(() => { let url: string | undefined; let unmounted = false; async function loadAttachment(): Promise { if (!file || unmounted) { return; } const draft = await processAttachment(file, { // Screenshot is used in `getStoryBackground` generateScreenshot: true, flags: null, }); if (!draft || unmounted) { return; } let attachment: AttachmentType = draft; if (isVideoAttachment(draft)) { if ( 'screenshotData' in draft && draft.screenshotData && draft.screenshotContentType ) { url = URL.createObjectURL( new Blob([draft.screenshotData], { type: draft.screenshotContentType, }) ); attachment = { ...draft, screenshot: { contentType: draft.screenshotContentType, url, }, }; } setAttachmentUrl(undefined); setIsReadyToSend(true); } else if (draft && has(draft, 'data')) { url = URL.createObjectURL(new Blob([get(draft, 'data')])); setAttachmentUrl(url); // Needs editing in MediaEditor setIsReadyToSend(false); } setDraftAttachment(attachment); } void loadAttachment(); return () => { unmounted = true; if (url) { URL.revokeObjectURL(url); } }; }, [file, processAttachment]); useEffect(() => { if (draftAttachment === undefined) { sendStoryModalOpenStateChanged(false); setIsReadyToSend(false); } else { sendStoryModalOpenStateChanged(true); } }, [draftAttachment, sendStoryModalOpenStateChanged]); return portalElement != null ? createPortal( <> {draftAttachment && isReadyToSend && ( setIsReadyToSend(false)} onDeleteList={onDeleteList} onDistributionListCreated={onDistributionListCreated} onHideMyStoriesFrom={onHideMyStoriesFrom} onRemoveMembers={onRemoveMembers} onRepliesNReactionsChanged={onRepliesNReactionsChanged} onSelectedStoryList={onSelectedStoryList} onSend={(listIds, groupIds) => { onSend(listIds, groupIds, draftAttachment, bodyRanges); setDraftAttachment(undefined); }} onViewersUpdated={onViewersUpdated} onMediaPlaybackStart={onMediaPlaybackStart} setMyStoriesToAllSignalConnections={ setMyStoriesToAllSignalConnections } signalConnections={signalConnections} toggleGroupsForStorySend={toggleGroupsForStorySend} mostRecentActiveStoryTimestampByGroupOrDistributionList={ mostRecentActiveStoryTimestampByGroupOrDistributionList } theme={theme} toggleSignalConnectionsModal={toggleSignalConnectionsModal} /> )} {draftAttachment && attachmentUrl && ( { setDraftAttachment({ ...draftAttachment, contentType, data, size: data.byteLength, blurHash, caption, }); setBodyRanges(captionBodyRanges); setIsReadyToSend(true); }} onPickEmoji={onPickEmoji} onTextTooLong={onTextTooLong} ourConversationId={ourConversationId} platform={platform} recentStickers={recentStickers} emojiSkinToneDefault={emojiSkinToneDefault} sortedGroupMembers={sortedGroupMembers} draftText={null} draftBodyRanges={null} /> )} {!file && ( { setDraftAttachment({ contentType: TEXT_ATTACHMENT, textAttachment, size: textAttachment.text?.length || 0, }); setIsReadyToSend(true); }} onUseEmoji={onUseEmoji} onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} recentEmojis={recentEmojis} emojiSkinToneDefault={emojiSkinToneDefault} /> )} , portalElement ) : null; }