From 99f60bfcc2bcc4a0532e784d09e0e57b52b1ea1d Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Sat, 6 Dec 2025 08:57:40 -0800 Subject: [PATCH] Save composer's contents to localstorage --- src/view/com/composer/Composer.tsx | 59 ++++- src/view/com/composer/state/composer.ts | 71 +++++ src/view/com/composer/useComposerDraft.ts | 301 ++++++++++++++++++++++ 3 files changed, 427 insertions(+), 4 deletions(-) create mode 100644 src/view/com/composer/useComposerDraft.ts diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 2b2eaed64e..728fe2ab00 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -154,6 +154,7 @@ import { type VideoState, } from './state/video' import {type TextInputRef} from './text-input/TextInput.types' +import {useComposerDraft} from './useComposerDraft' import {getVideoMetadata} from './videos/pickVideo' import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop' @@ -236,6 +237,48 @@ export const ComposePost = ({ setReplyToLanguages([]) } + // Check for draft before initializing composer + const draftKey = currentAccount + ? `composer-draft:${currentAccount.did}:${replyTo ? `reply:${replyTo.uri}` : 'default'}` + : null + + const loadInitialDraft = useCallback(() => { + if (!isWeb || !draftKey) return null + + const hasInitialContent = + initText || + initMention || + initImageUris?.length || + initQuote || + initVideoUri + + if (hasInitialContent) return null + + try { + const stored = localStorage.getItem(draftKey) + if (!stored) return null + + const parsed = JSON.parse(stored) + if (parsed.version !== 1) return null + + // Check age + const age = Date.now() - parsed.timestamp + if (age > 7 * 24 * 60 * 60 * 1000) { + localStorage.removeItem(draftKey) + return null + } + + logger.info('Composer: loading initial draft', { + textLength: parsed.thread.posts[0]?.text.length || 0, + }) + + return parsed + } catch (e) { + logger.error('Failed to load initial draft', {error: e}) + return null + } + }, [draftKey, initText, initMention, initImageUris, initQuote, initVideoUri]) + const [composerState, composerDispatch] = useReducer( composerReducer, { @@ -244,10 +287,17 @@ export const ComposePost = ({ initText, initMention, initInteractionSettings: preferences?.postInteractionSettings, + initDraft: loadInitialDraft(), }, createComposerState, ) + // Draft persistence + const {clearDraft} = useComposerDraft( + composerState, + replyTo ? `reply:${replyTo.uri}` : 'default', + ) + const thread = composerState.thread const activePost = thread.posts[composerState.activePostIndex] const nextPost: PostDraft | undefined = @@ -322,9 +372,10 @@ export const ComposePost = ({ const [publishOnUpload, setPublishOnUpload] = useState(false) const onClose = useCallback(() => { + clearDraft() closeComposer() clearThumbnailCache(queryClient) - }, [closeComposer, queryClient]) + }, [clearDraft, closeComposer, queryClient]) const insets = useSafeAreaInsets() const viewStyles = useMemo( @@ -1409,7 +1460,7 @@ function ComposerFooter({ if (assets.length) { if (type === 'image') { - const images: ComposerImage[] = [] + const composerImages: ComposerImage[] = [] await Promise.all( assets.map(async image => { @@ -1419,7 +1470,7 @@ function ComposerFooter({ height: image.height, mime: image.mimeType!, }) - images.push(composerImage) + composerImages.push(composerImage) }), ).catch(e => { logger.error(`createComposerImage failed`, { @@ -1427,7 +1478,7 @@ function ComposerFooter({ }) }) - onImageAdd(images) + onImageAdd(composerImages) } else if (type === 'video') { onSelectVideo(post.id, assets[0]) } else if (type === 'gif') { diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index c673f21341..2fdbe8647e 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -488,6 +488,7 @@ export function createComposerState({ initImageUris, initQuoteUri, initInteractionSettings, + initDraft, }: { initText: string | undefined initMention: string | undefined @@ -496,7 +497,77 @@ export function createComposerState({ initInteractionSettings: | BskyPreferences['postInteractionSettings'] | undefined + initDraft?: any }): ComposerState { + // If we have a draft, use it instead of init values + if (initDraft?.thread?.posts?.[0]) { + return { + activePostIndex: initDraft.activePostIndex || 0, + mutableNeedsFocusActive: false, + thread: { + posts: initDraft.thread.posts.map((post: any) => { + let media: ImagesMedia | GifMedia | undefined + + if (post.embed?.images?.length) { + media = { + type: 'images', + images: post.embed.images.map((img: any) => ({ + alt: img.alt, + source: { + id: `restored-${Date.now()}-${Math.random()}`, + path: img.path, + width: img.width, + height: img.height, + mime: img.mime, + }, + })), + } + } else if (post.embed?.gif) { + media = { + type: 'gif', + gif: post.embed.gif, + alt: post.embed.gif.alt || '', + } + } + + return { + id: nanoid(), + richtext: new RichText({text: post.text || ''}), + shortenedGraphemeLength: getShortenedLength( + new RichText({text: post.text || ''}), + ), + labels: post.labels || [], + embed: { + quote: post.embed?.quoteUri + ? {type: 'link', uri: post.embed.quoteUri} + : undefined, + media, + link: post.embed?.linkUri + ? {type: 'link', uri: post.embed.linkUri} + : undefined, + }, + } + }), + postgate: + initDraft.thread.postgate || + createPostgateRecord({ + post: '', + embeddingRules: + initInteractionSettings?.postgateEmbeddingRules || [], + }), + threadgate: + initDraft.thread.threadgate || + threadgateRecordToAllowUISetting({ + $type: 'app.bsky.feed.threadgate', + post: '', + createdAt: new Date().toString(), + allow: initInteractionSettings?.threadgateAllowRules, + }), + }, + } + } + + // Otherwise use normal initialization let media: ImagesMedia | undefined if (initImageUris?.length) { media = { diff --git a/src/view/com/composer/useComposerDraft.ts b/src/view/com/composer/useComposerDraft.ts new file mode 100644 index 0000000000..9450e68e04 --- /dev/null +++ b/src/view/com/composer/useComposerDraft.ts @@ -0,0 +1,301 @@ +import {useCallback, useEffect, useRef} from 'react' +import {RichText} from '@atproto/api' + +import {type SelfLabel} from '#/lib/moderation' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {useSession} from '#/state/session' +import {type ComposerState} from './state/composer' + +const DRAFT_KEY_PREFIX = 'composer-draft' +const AUTOSAVE_DELAY_MS = 1000 // 1 second debounce + +type SerializedImage = { + alt: string + path: string + width: number + height: number + mime: string +} + +type SerializedDraft = { + version: 1 + timestamp: number + thread: { + posts: Array<{ + id: string + text: string + labels: SelfLabel[] + embed: { + quoteUri?: string + linkUri?: string + // Media + images?: SerializedImage[] + gif?: { + id: string + media_formats: any + title: string + alt: string + } + // Note: Videos are complex with compression/upload state + // For now we skip videos in drafts + } + }> + postgate: any + threadgate: any + } + activePostIndex: number +} + +function getDraftKey(accountDid: string, context: string = 'default'): string { + return `${DRAFT_KEY_PREFIX}:${accountDid}:${context}` +} + +function serializeDraft(state: ComposerState): SerializedDraft { + return { + version: 1, + timestamp: Date.now(), + thread: { + posts: state.thread.posts.map(post => { + const media = post.embed.media + let images: SerializedImage[] | undefined + let gif: + | {id: string; media_formats: any; title: string; alt: string} + | undefined + + if (media?.type === 'images') { + // Serialize images with their local paths + // Note: These may not be available if the app was closed and cache was cleared + images = media.images.map(img => ({ + alt: img.alt, + path: img.source.path, + width: img.source.width, + height: img.source.height, + mime: img.source.mime, + })) + } else if (media?.type === 'gif') { + // GIFs are already references, easy to serialize + gif = { + id: media.gif.id, + media_formats: media.gif.media_formats, + title: media.gif.title, + alt: media.alt, + } + } + // Videos are skipped for now due to complexity + + return { + id: post.id, + text: post.richtext.text, + labels: post.labels, + embed: { + quoteUri: post.embed.quote?.uri, + linkUri: post.embed.link?.uri, + images, + gif, + }, + } + }), + postgate: state.thread.postgate, + threadgate: state.thread.threadgate, + }, + activePostIndex: state.activePostIndex, + } +} + +function deserializeDraft(data: SerializedDraft): Partial { + return { + thread: { + posts: data.thread.posts.map(post => { + let media: + | {type: 'images'; images: any[]} + | {type: 'gif'; gif: any; alt: string} + | undefined + + // Reconstruct images if available + if (post.embed.images && post.embed.images.length > 0) { + media = { + type: 'images', + images: post.embed.images.map(img => ({ + alt: img.alt, + source: { + id: `restored-${Date.now()}-${Math.random()}`, // Generate new ID + path: img.path, + width: img.width, + height: img.height, + mime: img.mime, + }, + // No transformations in restored drafts + })), + } + } else if (post.embed.gif) { + // Reconstruct GIF + media = { + type: 'gif', + gif: post.embed.gif, + alt: post.embed.gif.alt, + } + } + + return { + id: post.id, + richtext: new RichText({text: post.text}), + shortenedGraphemeLength: post.text.length, // Will be recalculated + labels: post.labels, + embed: { + quote: post.embed.quoteUri + ? {type: 'link' as const, uri: post.embed.quoteUri} + : undefined, + link: post.embed.linkUri + ? {type: 'link' as const, uri: post.embed.linkUri} + : undefined, + media, + }, + } + }), + postgate: data.thread.postgate, + threadgate: data.thread.threadgate, + }, + activePostIndex: data.activePostIndex, + } +} + +export function useComposerDraft( + composerState: ComposerState, + context: string = 'default', +) { + const {currentAccount} = useSession() + const saveTimeoutRef = useRef(undefined) + + const draftKey = currentAccount + ? getDraftKey(currentAccount.did, context) + : null + + // Check if draft has any content worth saving + const hasContent = useCallback((state: ComposerState) => { + return state.thread.posts.some( + post => + post.richtext.text.trim().length > 0 || + post.embed.quote || + post.embed.link || + post.embed.media, + ) + }, []) + + // Save draft to localStorage (debounced) + const saveDraft = useCallback( + (state: ComposerState) => { + if (!isWeb || !draftKey) return + + // Clear any pending save + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current) + } + + // Debounce the save + saveTimeoutRef.current = setTimeout(() => { + try { + if (hasContent(state)) { + const serialized = serializeDraft(state) + localStorage.setItem(draftKey, JSON.stringify(serialized)) + logger.info('Composer draft saved', { + key: draftKey, + textLength: state.thread.posts[0]?.richtext.text.length || 0, + }) + } else { + // If no content, remove any existing draft + localStorage.removeItem(draftKey) + logger.debug('Empty draft removed', {key: draftKey}) + } + } catch (e) { + logger.error('Failed to save composer draft', {error: e}) + } + }, AUTOSAVE_DELAY_MS) + }, + [draftKey, hasContent], + ) + + // Load draft from localStorage + const loadDraft = useCallback((): Partial | null => { + if (!isWeb || !draftKey) return null + + try { + const stored = localStorage.getItem(draftKey) + if (!stored) return null + + const parsed: SerializedDraft = JSON.parse(stored) + + // Check version compatibility + if (parsed.version !== 1) { + logger.warn('Incompatible draft version, discarding', { + version: parsed.version, + }) + localStorage.removeItem(draftKey) + return null + } + + // Check if draft is too old (e.g., more than 7 days) + const age = Date.now() - parsed.timestamp + const MAX_AGE = 7 * 24 * 60 * 60 * 1000 // 7 days + if (age > MAX_AGE) { + logger.debug('Draft too old, discarding', {age}) + localStorage.removeItem(draftKey) + return null + } + + logger.info('Composer draft loaded', { + key: draftKey, + textLength: parsed.thread.posts[0]?.text.length || 0, + }) + return deserializeDraft(parsed) + } catch (e) { + logger.error('Failed to load composer draft', {error: e}) + // Remove corrupted draft + try { + localStorage.removeItem(draftKey) + } catch {} + return null + } + }, [draftKey]) + + // Clear draft from localStorage + const clearDraft = useCallback(() => { + if (!isWeb || !draftKey) return + + try { + localStorage.removeItem(draftKey) + logger.debug('Composer draft cleared', {key: draftKey}) + } catch (e) { + logger.error('Failed to clear composer draft', {error: e}) + } + }, [draftKey]) + + // Auto-save on state changes + useEffect(() => { + saveDraft(composerState) + }, [composerState, saveDraft]) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current) + } + } + }, []) + + const checkHasStoredDraft = useCallback(() => { + if (!isWeb || !draftKey) return false + try { + return localStorage.getItem(draftKey) !== null + } catch { + return false + } + }, [draftKey]) + + return { + loadDraft, + clearDraft, + hasStoredDraft: checkHasStoredDraft, + } +}