diff --git a/src/storage/schema.ts b/src/storage/schema.ts index d1e4a8d88c..47942a9d48 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -66,4 +66,51 @@ export type Account = { * this device. */ birthdateLastUpdatedAt?: string + + /** + * Composer draft, saved when the user has unsent content in the post composer. + * Keyed by context (e.g., 'default', reply URI, etc.) + */ + composerDraft?: { + [context: string]: ComposerDraft + } +} + +export type ComposerDraft = { + version: 1 + timestamp: number + thread: { + posts: Array<{ + id: string + text: string + labels: string[] + embed: { + quoteUri?: string + linkUri?: string + images?: Array<{ + alt: string + path: string + width: number + height: number + mime: string + }> + gif?: { + id: string + media_formats: unknown + title: string + alt: string + } + video?: { + blobRef: unknown + width: number + height: number + mimeType: string + altText: string + } + } + }> + postgate: unknown + threadgate: unknown + } + activePostIndex: number } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 4d6caf5c13..7dea064992 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -130,6 +130,7 @@ import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed' import * as Prompt from '#/components/Prompt' import * as Toast from '#/components/Toast' import {Text as NewText} from '#/components/Typography' +import {account} from '#/storage' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import {PostLanguageSelect} from './select-language/PostLanguageSelect' import { @@ -238,12 +239,10 @@ export const ComposePost = ({ } // Check for draft before initializing composer - const draftKey = currentAccount - ? `composer-draft:${currentAccount.did}:${replyTo ? `reply:${replyTo.uri}` : 'default'}` - : null + const draftContext = replyTo ? `reply:${replyTo.uri}` : 'default' const loadInitialDraft = useCallback(() => { - if (!isWeb || !draftKey) return null + if (!currentAccount) return null const hasInitialContent = initText || @@ -255,25 +254,36 @@ export const ComposePost = ({ if (hasInitialContent) return null try { - const stored = localStorage.getItem(draftKey) - if (!stored) return null + const allDrafts = account.get([currentAccount.did, 'composerDraft']) + if (!allDrafts) return null - const parsed = JSON.parse(stored) - if (parsed.version !== 1) return null + const draft = allDrafts[draftContext] + if (!draft) return null + + if (draft.version !== 1) return null // Check age - const age = Date.now() - parsed.timestamp + const age = Date.now() - draft.timestamp if (age > 7 * 24 * 60 * 60 * 1000) { - localStorage.removeItem(draftKey) + // Remove old draft + const remainingDrafts = Object.fromEntries( + Object.entries(allDrafts).filter(([key]) => key !== draftContext), + ) + if (Object.keys(remainingDrafts).length > 0) { + account.set([currentAccount.did, 'composerDraft'], remainingDrafts) + } else { + account.remove([currentAccount.did, 'composerDraft']) + } return null } logger.info('Composer: loading initial draft', { - textLength: parsed.thread.posts[0]?.text.length || 0, + textLength: draft.thread.posts[0]?.text.length || 0, }) // Construct video URLs from blobRefs if we have videos in the draft - if (currentAccount && parsed.thread?.posts) { + const parsed = JSON.parse(JSON.stringify(draft)) // Deep clone + if (parsed.thread?.posts) { parsed.thread.posts = parsed.thread.posts.map((post: any) => { if (post.embed?.video?.blobRef?.ref?.$link) { const cid = post.embed.video.blobRef.ref.$link @@ -291,7 +301,7 @@ export const ComposePost = ({ } }, [ currentAccount, - draftKey, + draftContext, initText, initMention, initImageUris, diff --git a/src/view/com/composer/useComposerDraft.ts b/src/view/com/composer/useComposerDraft.ts index 473c157669..bc9e1c4aa8 100644 --- a/src/view/com/composer/useComposerDraft.ts +++ b/src/view/com/composer/useComposerDraft.ts @@ -3,12 +3,25 @@ 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 {account, type ComposerDraft} from '#/storage' import {type ComposerState} from './state/composer' -const DRAFT_KEY_PREFIX = 'composer-draft' const AUTOSAVE_DELAY_MS = 1000 // 1 second debounce +const MAX_DRAFT_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +function removeDraftContext( + allDrafts: Record, + contextToRemove: string, +): Record | null { + const result: Record = {} + for (const key of Object.keys(allDrafts)) { + if (key !== contextToRemove) { + result[key] = allDrafts[key] + } + } + return Object.keys(result).length > 0 ? result : null +} type SerializedImage = { alt: string @@ -19,44 +32,14 @@ type SerializedImage = { } type SerializedVideo = { - blobRef: any // BlobRef from @atproto/api (server reference) + blobRef: unknown // BlobRef from @atproto/api (server reference) width: number height: number mimeType: string altText: 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 - } - video?: SerializedVideo - } - }> - postgate: any - threadgate: any - } - activePostIndex: number -} - -function getDraftKey(accountDid: string, context: string = 'default'): string { - return `${DRAFT_KEY_PREFIX}:${accountDid}:${context}` -} +type SerializedDraft = ComposerDraft function serializeDraft(state: ComposerState): SerializedDraft { return { @@ -67,7 +50,7 @@ function serializeDraft(state: ComposerState): SerializedDraft { const media = post.embed.media let images: SerializedImage[] | undefined let gif: - | {id: string; media_formats: any; title: string; alt: string} + | {id: string; media_formats: unknown; title: string; alt: string} | undefined let video: SerializedVideo | undefined @@ -211,7 +194,7 @@ function deserializeDraft(data: SerializedDraft): Partial { id: post.id, richtext: rt, shortenedGraphemeLength: rt.graphemeLength, - labels: post.labels, + labels: post.labels as SelfLabel[], embed: { quote: post.embed.quoteUri ? {type: 'link' as const, uri: post.embed.quoteUri} @@ -223,8 +206,9 @@ function deserializeDraft(data: SerializedDraft): Partial { }, } }), - postgate: data.thread.postgate, - threadgate: data.thread.threadgate, + // These are already properly typed when saved, cast back to their types + postgate: data.thread.postgate as any, + threadgate: data.thread.threadgate as any, }, activePostIndex: data.activePostIndex, } @@ -235,11 +219,11 @@ export function useComposerDraft( context: string = 'default', ) { const {currentAccount} = useSession() - const saveTimeoutRef = useRef(undefined) + const saveTimeoutRef = useRef | undefined>( + undefined, + ) - const draftKey = currentAccount - ? getDraftKey(currentAccount.did, context) - : null + const accountDid = currentAccount?.did // Check if draft has any content worth saving const hasContent = useCallback((state: ComposerState) => { @@ -252,10 +236,10 @@ export function useComposerDraft( ) }, []) - // Save draft to localStorage (debounced) + // Save draft to storage (debounced) const saveDraft = useCallback( (state: ComposerState) => { - if (!isWeb || !draftKey) return + if (!accountDid) return // Clear any pending save if (saveTimeoutRef.current) { @@ -265,25 +249,36 @@ export function useComposerDraft( // Debounce the save saveTimeoutRef.current = setTimeout(() => { try { + const allDrafts = account.get([accountDid, 'composerDraft']) ?? {} + if (hasContent(state)) { const serialized = serializeDraft(state) logger.debug('Draft serialized successfully', { hasPosts: serialized.thread.posts.length > 0, hasVideo: !!serialized.thread.posts[0]?.embed?.video, }) - const jsonString = JSON.stringify(serialized) - logger.debug('Draft JSON stringified', { - size: jsonString.length, + + // Update the draft for this context + account.set([accountDid, 'composerDraft'], { + ...allDrafts, + [context]: serialized, }) - localStorage.setItem(draftKey, jsonString) + logger.info('Composer draft saved', { - key: draftKey, + context, 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}) + // If no content, remove this context's draft + if (allDrafts[context]) { + const remainingDrafts = removeDraftContext(allDrafts, context) + if (remainingDrafts) { + account.set([accountDid, 'composerDraft'], remainingDrafts) + } else { + account.remove([accountDid, 'composerDraft']) + } + logger.debug('Empty draft removed', {context}) + } } } catch (e) { logger.error('Failed to save composer draft', { @@ -294,63 +289,83 @@ export function useComposerDraft( } }, AUTOSAVE_DELAY_MS) }, - [draftKey, hasContent], + [accountDid, context, hasContent], ) - // Load draft from localStorage + // Load draft from storage const loadDraft = useCallback((): Partial | null => { - if (!isWeb || !draftKey) return null + if (!accountDid) return null try { - const stored = localStorage.getItem(draftKey) - if (!stored) return null + const allDrafts = account.get([accountDid, 'composerDraft']) + if (!allDrafts) return null - const parsed: SerializedDraft = JSON.parse(stored) + const draft = allDrafts[context] + if (!draft) return null // Check version compatibility - if (parsed.version !== 1) { + if (draft.version !== 1) { logger.warn('Incompatible draft version, discarding', { - version: parsed.version, + version: draft.version, }) - localStorage.removeItem(draftKey) + // Remove this context's draft + const remainingDrafts = removeDraftContext(allDrafts, context) + if (remainingDrafts) { + account.set([accountDid, 'composerDraft'], remainingDrafts) + } else { + account.remove([accountDid, 'composerDraft']) + } 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) { + // Check if draft is too old + const age = Date.now() - draft.timestamp + if (age > MAX_DRAFT_AGE_MS) { logger.debug('Draft too old, discarding', {age}) - localStorage.removeItem(draftKey) + // Remove this context's draft + const remainingDrafts = removeDraftContext(allDrafts, context) + if (remainingDrafts) { + account.set([accountDid, 'composerDraft'], remainingDrafts) + } else { + account.remove([accountDid, 'composerDraft']) + } return null } logger.info('Composer draft loaded', { - key: draftKey, - textLength: parsed.thread.posts[0]?.text.length || 0, + context, + textLength: draft.thread.posts[0]?.text.length || 0, }) - return deserializeDraft(parsed) + return deserializeDraft(draft) } catch (e) { logger.error('Failed to load composer draft', {error: e}) - // Remove corrupted draft + // Remove corrupted drafts try { - localStorage.removeItem(draftKey) + account.remove([accountDid, 'composerDraft']) } catch {} return null } - }, [draftKey]) + }, [accountDid, context]) - // Clear draft from localStorage + // Clear draft from storage const clearDraft = useCallback(() => { - if (!isWeb || !draftKey) return + if (!accountDid) return try { - localStorage.removeItem(draftKey) - logger.debug('Composer draft cleared', {key: draftKey}) + const allDrafts = account.get([accountDid, 'composerDraft']) + if (allDrafts && allDrafts[context]) { + const remainingDrafts = removeDraftContext(allDrafts, context) + if (remainingDrafts) { + account.set([accountDid, 'composerDraft'], remainingDrafts) + } else { + account.remove([accountDid, 'composerDraft']) + } + logger.debug('Composer draft cleared', {context}) + } } catch (e) { logger.error('Failed to clear composer draft', {error: e}) } - }, [draftKey]) + }, [accountDid, context]) // Auto-save on state changes useEffect(() => { @@ -367,13 +382,14 @@ export function useComposerDraft( }, []) const checkHasStoredDraft = useCallback(() => { - if (!isWeb || !draftKey) return false + if (!accountDid) return false try { - return localStorage.getItem(draftKey) !== null + const allDrafts = account.get([accountDid, 'composerDraft']) + return allDrafts ? !!allDrafts[context] : false } catch { return false } - }, [draftKey]) + }, [accountDid, context]) return { loadDraft,