diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 45372a1e91..db4d3f2f42 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -130,7 +130,11 @@ import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' -import {draftToComposerPosts, extractLocalRefs} from './drafts/state/api' +import { + draftToComposerPosts, + extractLocalRefs, + type RestoredVideo, +} from './drafts/state/api' import { loadDraft, useCleanupPublishedDraftMutation, @@ -329,6 +333,92 @@ export const ComposePost = ({ [composerDispatch], ) + const restoreVideo = React.useCallback( + async (postId: string, videoInfo: RestoredVideo) => { + try { + logger.debug('restoring video from draft', { + postId, + videoUri: videoInfo.uri, + altText: videoInfo.altText, + }) + + let asset: ImagePickerAsset + + if (IS_WEB) { + // Web: Convert blob URL to a File, then get video metadata (returns data URL) + const response = await fetch(videoInfo.uri) + const blob = await response.blob() + const file = new File([blob], 'restored-video', { + type: videoInfo.mimeType, + }) + asset = await getVideoMetadata(file) + } else { + // Native: Use file URI directly with minimal asset properties + // The native compressVideo only needs uri and mimeType + asset = { + uri: videoInfo.uri, + mimeType: videoInfo.mimeType, + width: 0, + height: 0, + } + } + + // Start video processing using existing flow + const abortController = new AbortController() + composerDispatch({ + type: 'update_post', + postId, + postAction: { + type: 'embed_add_video', + asset, + abortController, + }, + }) + + // Restore alt text immediately + if (videoInfo.altText) { + composerDispatch({ + type: 'update_post', + postId, + postAction: { + type: 'embed_update_video', + videoAction: { + type: 'update_alt_text', + altText: videoInfo.altText, + signal: abortController.signal, + }, + }, + }) + } + + // Start video compression and upload + processVideo( + asset, + videoAction => { + composerDispatch({ + type: 'update_post', + postId, + postAction: { + type: 'embed_update_video', + videoAction, + }, + }) + }, + agent, + currentDid, + abortController.signal, + _, + ) + } catch (e) { + logger.error('Failed to restore video from draft', { + postId, + error: e, + }) + } + }, + [_, agent, currentDid, composerDispatch], + ) + const handleSelectDraft = React.useCallback( async (draftSummary: DraftSummary) => { logger.debug('loading draft for editing', { @@ -347,8 +437,11 @@ export const ComposePost = ({ originalLocalRefCount: originalLocalRefs.size, }) - // Convert server draft to composer posts - const posts = draftToComposerPosts(draftSummary.draft, loadedMedia) + // Convert server draft to composer posts (videos returned separately) + const {posts, restoredVideos} = draftToComposerPosts( + draftSummary.draft, + loadedMedia, + ) // Dispatch restore action (this also sets draftId in state) composerDispatch({ @@ -360,8 +453,15 @@ export const ComposePost = ({ loadedMedia, originalLocalRefs, }) + + // Initiate video processing for any restored videos + // This is async but we don't await - videos process in the background + for (const [postIndex, videoInfo] of restoredVideos) { + const postId = posts[postIndex].id + restoreVideo(postId, videoInfo) + } }, - [composerDispatch], + [composerDispatch, restoreVideo], ) const [publishOnUpload, setPublishOnUpload] = useState(false) diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index d96f96971e..49fd9f1a74 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -17,6 +17,31 @@ import {type DraftPostDisplay, type DraftSummary} from './schema' const TENOR_HOSTNAME = 'media.tenor.com' +/** + * Video data from a draft that needs to be restored by re-processing. + * Contains the local file URI, alt text, and mime type to restore. + */ +export type RestoredVideo = { + uri: string + altText: string + mimeType: string + localRefPath: string +} + +/** + * Parse mime type from video localRefPath. + * Format: `video:${mimeType}:${nanoid()}` (new) or `video:${nanoid()}` (legacy) + */ +function parseVideoMimeType(localRefPath: string): string { + const parts = localRefPath.split(':') + // New format: video:video/mp4:abc123 -> parts[1] is mime type + // Legacy format: video:abc123 -> no mime type, default to video/mp4 + if (parts.length >= 3 && parts[1].includes('/')) { + return parts[1] + } + return 'video/mp4' // Default for legacy drafts +} + /** * Convert ComposerState to server Draft format for saving. * Returns both the draft and a map of localRef paths to their source paths. @@ -165,6 +190,7 @@ function serializeImages( /** * Serialize video to server format with localRef path. + * The localRef path encodes the mime type: `video:${mimeType}:${nanoid()}` */ function serializeVideo( videoState: VideoState, @@ -175,7 +201,9 @@ function serializeVideo( return undefined } - const localRefPath = `video:${nanoid()}` + // Encode mime type in the path for restoration + const mimeType = videoState.video.mimeType || 'video/mp4' + const localRefPath = `video:${mimeType}:${nanoid()}` localRefPaths.set(localRefPath, videoState.video.uri) return { @@ -345,13 +373,19 @@ function parseGifFromUrl( /** * Convert server Draft back to composer-compatible format for restoration. - * Returns partial state that can be merged with initial composer state. + * Returns posts and a map of videos that need to be restored by re-processing. + * + * Videos cannot be restored synchronously like images because they need to go through + * the compression and upload pipeline. The caller should handle the restoredVideos + * by initiating video processing for each entry. */ export function draftToComposerPosts( draft: AppBskyDraftDefs.Draft, loadedMedia: Map, -): PostDraft[] { - return draft.posts.map((post, index) => { +): {posts: PostDraft[]; restoredVideos: Map} { + const restoredVideos = new Map() + + const posts = draft.posts.map((post, index) => { const richtext = new RichText({text: post.text || ''}) richtext.detectFacetsWithoutResolution() @@ -428,6 +462,27 @@ export function draftToComposerPosts( } } + // Collect video for restoration (processed async by caller) + if (post.embedVideos && post.embedVideos.length > 0) { + const vid = post.embedVideos[0] + const videoUri = loadedMedia.get(vid.localRef.path) + if (videoUri) { + const mimeType = parseVideoMimeType(vid.localRef.path) + logger.debug('found video to restore', { + localRefPath: vid.localRef.path, + videoUri, + altText: vid.alt, + mimeType, + }) + restoredVideos.set(index, { + uri: videoUri, + altText: vid.alt || '', + mimeType, + localRefPath: vid.localRef.path, + }) + } + } + // Restore quote embed if (post.embedRecords && post.embedRecords.length > 0) { const record = post.embedRecords[0] @@ -461,6 +516,8 @@ export function draftToComposerPosts( embed, } as PostDraft }) + + return {posts, restoredVideos} } /**