diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index aa89e15602..e7c74777bd 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -201,6 +201,19 @@ export type MetricEvents = { userDid: string } + // Fired when the composer is opened + 'composer:open': { + logContext: + | 'Fab' + | 'PostReply' + | 'QuotePost' + | 'ProfileFeed' + | 'Deeplink' + | 'Other' + isReply: boolean + hasQuote: boolean + hasDraft: boolean + } 'composer:gif:open': {} 'composer:gif:select': {} 'composerPrompt:press': {} diff --git a/src/state/drafts/index.ts b/src/state/drafts/index.ts index 74e0585941..032290a963 100644 --- a/src/state/drafts/index.ts +++ b/src/state/drafts/index.ts @@ -1 +1,2 @@ +export {serializeDraft} from './serialization' export {type DraftItem, draftsStorage} from './storage' diff --git a/src/state/drafts/serialization.ts b/src/state/drafts/serialization.ts new file mode 100644 index 0000000000..d1155b227d --- /dev/null +++ b/src/state/drafts/serialization.ts @@ -0,0 +1,78 @@ +import {type ComposerState} from '#/view/com/composer/state/composer' +import {type ComposerDraft} from '#/storage' + +type SerializedImage = { + alt: string + path: string + width: number + height: number + mime: string +} + +type SerializedVideo = { + blobRef: unknown + width: number + height: number + mimeType: string + altText: string +} + +export function serializeDraft(state: ComposerState): ComposerDraft { + 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: unknown; title: string; alt: string} + | undefined + let video: SerializedVideo | undefined + + if (media?.type === 'images') { + 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') { + gif = { + id: media.gif.id, + media_formats: media.gif.media_formats, + title: media.gif.title, + alt: media.alt, + } + } else if (media?.type === 'video') { + if (media.video.status === 'done') { + video = { + blobRef: media.video.pendingPublish.blobRef, + width: media.video.asset.width, + height: media.video.asset.height, + mimeType: media.video.asset.mimeType || 'video/mp4', + altText: media.video.altText, + } + } + } + + return { + id: post.id, + text: post.richtext.text, + labels: post.labels, + embed: { + quoteUri: post.embed.quote?.uri, + linkUri: post.embed.link?.uri, + images, + gif, + video, + }, + } + }), + postgate: state.thread.postgate, + threadgate: state.thread.threadgate, + }, + activePostIndex: state.activePostIndex, + } +} diff --git a/src/state/drafts/storage.ts b/src/state/drafts/storage.ts index fa3a35f436..e76c99754c 100644 --- a/src/state/drafts/storage.ts +++ b/src/state/drafts/storage.ts @@ -9,16 +9,12 @@ export type DraftItem = { } /** - * Abstraction layer for draft storage operations. + * Storage layer for composer drafts. * - * Currently backed by MMKV (local storage), but designed with an async API - * to make future migration to a backend key-value store easier. + * Currently backed by MMKV (local storage), but uses an async API + * to support future migration to a server-side KV store. * * All operations are scoped to a specific account (DID). - * - * Note: Sync methods (*Sync) are provided for cases where async isn't possible - * (e.g., React initial state). When migrating to a backend, these will need - * to be refactored to use async patterns (loading states, suspense, etc.). */ export const draftsStorage = { /** @@ -32,7 +28,6 @@ export const draftsStorage = { const draft = allDrafts[draftId] if (!draft) return null - // Validate version and age if (draft.version !== 1) { logger.warn('Incompatible draft version', { draftId, @@ -42,7 +37,6 @@ export const draftsStorage = { } if (Date.now() - draft.timestamp > MAX_DRAFT_AGE_MS) { - logger.debug('Draft too old', {draftId}) return null } @@ -65,16 +59,12 @@ export const draftsStorage = { const items: DraftItem[] = [] for (const [id, draft] of Object.entries(allDrafts)) { - // Skip invalid or too old drafts if (draft.version !== 1) continue if (now - draft.timestamp > MAX_DRAFT_AGE_MS) continue - items.push({id, draft}) } - // Sort by timestamp, newest first items.sort((a, b) => b.draft.timestamp - a.draft.timestamp) - return items } catch (e) { logger.error('Failed to get drafts list', {error: e}) @@ -96,10 +86,8 @@ export const draftsStorage = { ...allDrafts, [draftId]: draft, }) - logger.debug('Draft saved', {draftId}) } catch (e) { logger.error('Failed to save draft', {error: e, draftId}) - throw e } }, @@ -111,44 +99,20 @@ export const draftsStorage = { const allDrafts = account.get([did, 'composerDrafts']) if (!allDrafts || !allDrafts[draftId]) return - const remainingDrafts: Record = {} - for (const [id, draft] of Object.entries(allDrafts)) { - if (id !== draftId) { - remainingDrafts[id] = draft - } - } + const {[draftId]: _deleted, ...remainingDrafts} = allDrafts if (Object.keys(remainingDrafts).length > 0) { account.set([did, 'composerDrafts'], remainingDrafts) } else { account.remove([did, 'composerDrafts']) } - - logger.debug('Draft deleted', {draftId}) } catch (e) { logger.error('Failed to delete draft', {error: e, draftId}) - throw e } }, /** - * Check if a draft exists - */ - async hasDraft(did: string, draftId: string): Promise { - const draft = await this.getDraft(did, draftId) - return draft !== null - }, - - /** - * Get count of valid drafts - */ - async getDraftsCount(did: string): Promise { - const drafts = await this.getAllDrafts(did) - return drafts.length - }, - - /** - * Clean up old/invalid drafts + * Clean up old/invalid drafts, returns count of removed drafts */ async cleanupOldDrafts(did: string): Promise { try { @@ -173,7 +137,6 @@ export const draftsStorage = { } else { account.remove([did, 'composerDrafts']) } - logger.debug('Cleaned up old drafts', {removedCount}) } return removedCount @@ -182,72 +145,4 @@ export const draftsStorage = { return 0 } }, - - // ============================================ - // Synchronous methods (for use in contexts where async isn't possible) - // These will need refactoring when migrating to a backend store - // ============================================ - - /** - * Get a single draft by ID (synchronous) - * Use this only when async isn't possible (e.g., React initial state) - */ - getDraftSync(did: string, draftId: string): ComposerDraft | null { - try { - const allDrafts = account.get([did, 'composerDrafts']) - if (!allDrafts) return null - - const draft = allDrafts[draftId] - if (!draft) return null - - // Validate version and age - if (draft.version !== 1) { - logger.warn('Incompatible draft version', { - draftId, - version: draft.version, - }) - return null - } - - if (Date.now() - draft.timestamp > MAX_DRAFT_AGE_MS) { - logger.debug('Draft too old', {draftId}) - return null - } - - return draft - } catch (e) { - logger.error('Failed to get draft (sync)', {error: e, draftId}) - return null - } - }, - - /** - * Get all drafts for an account (synchronous) - * Use this only when async isn't possible - */ - getAllDraftsSync(did: string): DraftItem[] { - try { - const allDrafts = account.get([did, 'composerDrafts']) - if (!allDrafts) return [] - - const now = Date.now() - const items: DraftItem[] = [] - - for (const [id, draft] of Object.entries(allDrafts)) { - // Skip invalid or too old drafts - if (draft.version !== 1) continue - if (now - draft.timestamp > MAX_DRAFT_AGE_MS) continue - - items.push({id, draft}) - } - - // Sort by timestamp, newest first - items.sort((a, b) => b.draft.timestamp - a.draft.timestamp) - - return items - } catch (e) { - logger.error('Failed to get drafts list (sync)', {error: e}) - return [] - } - }, } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 6ffb10eacf..bdf73d29e1 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -131,6 +131,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 {type ComposerDraft} from '#/storage' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import {DraftsView} from './DraftsDialog' import {PostLanguageSelect} from './select-language/PostLanguageSelect' @@ -204,6 +205,27 @@ function serializeStateForComparison(state: ComposerState): string { }) } +/** + * Prepares a draft from storage for loading into the composer. + * Constructs video URLs from blobRefs since we only store the ref, not the URL. + */ +function prepareDraftForLoading( + draft: ComposerDraft, + did: string, +): ComposerDraft { + const prepared = JSON.parse(JSON.stringify(draft)) // Deep clone + if (prepared.thread?.posts) { + prepared.thread.posts = prepared.thread.posts.map((post: any) => { + if (post.embed?.video?.blobRef?.ref?.$link) { + const cid = post.embed.video.blobRef.ref.$link + post.embed.video.uri = `https://video.bsky.app/watch/${encodeURIComponent(did)}/${cid}/playlist.m3u8` + } + return post + }) + } + return prepared +} + type CancelRef = { onPressCancel: () => void } @@ -287,54 +309,14 @@ export const ComposePost = ({ setReplyToLanguages([]) } - // Load draft by ID if provided - const loadInitialDraft = useCallback(() => { - if (!currentAccount) return null - - const hasInitialContent = - initText || - initMention || - initImageUris?.length || - initQuote || - initVideoUri - - if (hasInitialContent) return null - - // If a draftId is provided, load that specific draft - if (initDraftId) { - const draft = draftsStorage.getDraftSync(currentAccount.did, initDraftId) - if (!draft) return null - - logger.info('Composer: loading draft by ID', { - draftId: initDraftId, - textLength: draft.thread.posts[0]?.text.length || 0, - }) - - // Construct video URLs from blobRefs if we have videos in the draft - 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 - post.embed.video.uri = `https://video.bsky.app/watch/${encodeURIComponent(currentAccount.did)}/${cid}/playlist.m3u8` - } - return post - }) - } - - return parsed - } - - return null - }, [ - currentAccount, - initDraftId, - initText, - initMention, - initImageUris, - initQuote, - initVideoUri, - ]) + // Check if we should load a draft (no other initial content provided) + const shouldLoadDraft = + initDraftId && + !initText && + !initMention && + !initImageUris?.length && + !initQuote && + !initVideoUri const [composerState, composerDispatch] = useReducer( composerReducer, @@ -344,7 +326,7 @@ export const ComposePost = ({ initText, initMention, initInteractionSettings: preferences?.postInteractionSettings, - initDraft: loadInitialDraft(), + initDraft: undefined, // Draft loaded async below }, createComposerState, ) @@ -355,10 +337,7 @@ export const ComposePost = ({ ) // Draft persistence - only for top-level posts (not replies) - const {clearDraft, saveImmediate} = useComposerDraft( - composerState, - currentDraftId, - ) + const {clearDraft, saveDraft} = useComposerDraft(currentDraftId) // Track if we're editing an existing draft (either from initial load or from list selection) const [isEditingExistingDraft, setIsEditingExistingDraft] = @@ -366,25 +345,81 @@ export const ComposePost = ({ // Snapshot of the draft content when loaded, for detecting changes const [loadedDraftSnapshot, setLoadedDraftSnapshot] = useState( - () => { - // If loading from initDraftId, capture initial snapshot - if (initDraftId && composerState.thread.posts.length > 0) { - return serializeStateForComparison(composerState) - } - return null - }, + null, ) // Track the timestamp of the loaded draft (for analytics) const [loadedDraftTimestamp, setLoadedDraftTimestamp] = useState< number | null - >(() => { - if (initDraftId && currentAccount) { - const draft = draftsStorage.getDraftSync(currentAccount.did, initDraftId) - return draft?.timestamp ?? null + >(null) + + // Loading state for initial draft + const [isLoadingDraft, setIsLoadingDraft] = useState(!!shouldLoadDraft) + + // Load initial draft asynchronously + useEffect(() => { + if (!shouldLoadDraft || !currentAccount) { + setIsLoadingDraft(false) + return } - return null - }) + + let cancelled = false + + async function loadDraft() { + const draft = await draftsStorage.getDraft( + currentAccount!.did, + initDraftId!, + ) + if (cancelled || !draft) { + setIsLoadingDraft(false) + return + } + + const prepared = prepareDraftForLoading(draft, currentAccount!.did) + const newState = createComposerState({ + initText: undefined, + initMention: undefined, + initImageUris: undefined, + initQuoteUri: undefined, + initInteractionSettings: preferences?.postInteractionSettings, + initDraft: prepared, + }) + + composerDispatch({type: 'load_draft', draft: newState}) + setLoadedDraftSnapshot(serializeStateForComparison(newState)) + setLoadedDraftTimestamp(draft.timestamp) + setIsLoadingDraft(false) + + logger.info('Composer: loaded draft', { + draftId: initDraftId, + textLength: draft.thread.posts[0]?.text.length || 0, + }) + } + + loadDraft() + return () => { + cancelled = true + } + }, [ + shouldLoadDraft, + currentAccount, + initDraftId, + preferences?.postInteractionSettings, + ]) + + // Log composer:open event on mount + const onceRef = useRef(false) + useEffect(() => { + if (onceRef.current) return + onceRef.current = true + const logContext = replyTo ? 'PostReply' : initQuote ? 'QuotePost' : 'Fab' + logger.metric('composer:open', { + logContext, + isReply: !!replyTo, + hasQuote: !!initQuote, + hasDraft: !!initDraftId, + }) + }, [replyTo, initQuote, initDraftId]) // Drafts list for the drafts dialog const {draftsCount} = useDraftsList() @@ -429,35 +464,23 @@ export const ComposePost = ({ // Handler for selecting a draft from the list const onSelectDraft = useCallback( - (selectedDraftId: string) => { + async (selectedDraftId: string) => { if (!currentAccount) return - const draft = draftsStorage.getDraftSync( + const draft = await draftsStorage.getDraft( currentAccount.did, selectedDraftId, ) if (!draft) return - // Deep clone and construct video URLs if needed - const parsed = JSON.parse(JSON.stringify(draft)) - 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 - post.embed.video.uri = `https://video.bsky.app/watch/${encodeURIComponent(currentAccount.did)}/${cid}/playlist.m3u8` - } - return post - }) - } - - // Convert to ComposerState + const prepared = prepareDraftForLoading(draft, currentAccount.did) const newState = createComposerState({ initText: undefined, initMention: undefined, initImageUris: undefined, initQuoteUri: undefined, initInteractionSettings: preferences?.postInteractionSettings, - initDraft: parsed, + initDraft: prepared, }) // Update draft tracking state @@ -472,7 +495,7 @@ export const ComposePost = ({ // Log draft:load event const metadata = getDraftMetadata(newState) - logEvent('draft:load', { + logger.metric('draft:load', { draftAgeMs: Date.now() - draft.timestamp, hasText: metadata.hasText, hasImages: metadata.hasImages, @@ -497,7 +520,7 @@ export const ComposePost = ({ } else { // No content or no changes - just show drafts setViewMode('drafts') - logEvent('draft:listOpen', { + logger.metric('draft:listOpen', { draftCount: draftsCount ?? 0, }) } @@ -512,18 +535,18 @@ export const ComposePost = ({ // Handler for "Save" in the save-before-drafts prompt const onSaveBeforeDrafts = useCallback(() => { - saveImmediate(composerState) + saveDraft(composerState) const metadata = getDraftMetadata(composerState) - logEvent('draft:save', { + logger.metric('draft:save', { isNewDraft: !isEditingExistingDraft, ...metadata, }) setViewMode('drafts') - logEvent('draft:listOpen', { + logger.metric('draft:listOpen', { draftCount: (draftsCount ?? 0) + (isEditingExistingDraft ? 0 : 1), }) }, [ - saveImmediate, + saveDraft, composerState, getDraftMetadata, isEditingExistingDraft, @@ -533,23 +556,23 @@ export const ComposePost = ({ // Handler for "Don't save" in the save-before-drafts prompt const onDiscardBeforeDrafts = useCallback(() => { const metadata = getDraftMetadata(composerState) - logEvent('draft:discard', { + logger.metric('draft:discard', { logContext: 'BeforeDraftsList', hadContent: hasContent, textLength: metadata.textLength, }) clearDraft() setViewMode('drafts') - logEvent('draft:listOpen', { + logger.metric('draft:listOpen', { draftCount: draftsCount ?? 0, }) }, [clearDraft, getDraftMetadata, composerState, hasContent, draftsCount]) // Handler for saving draft and closing with toast const onSaveDraftAndClose = useCallback(() => { - saveImmediate(composerState) + saveDraft(composerState) const metadata = getDraftMetadata(composerState) - logEvent('draft:save', { + logger.metric('draft:save', { isNewDraft: !isEditingExistingDraft, ...metadata, }) @@ -558,7 +581,7 @@ export const ComposePost = ({ Toast.show(_(msg`Saved to drafts`)) }, [ _, - saveImmediate, + saveDraft, composerState, getDraftMetadata, isEditingExistingDraft, @@ -569,7 +592,7 @@ export const ComposePost = ({ // Handler for "Don't save" when closing composer with unsaved content const onDiscardAndClose = useCallback(() => { const metadata = getDraftMetadata(composerState) - logEvent('draft:discard', { + logger.metric('draft:discard', { logContext: 'ComposerClose', hadContent: hasContent, textLength: metadata.textLength, @@ -588,22 +611,22 @@ export const ComposePost = ({ // Handler for "Update draft" in the update-before-drafts prompt const onUpdateBeforeDrafts = useCallback(() => { - saveImmediate(composerState) + saveDraft(composerState) const metadata = getDraftMetadata(composerState) - logEvent('draft:save', { + logger.metric('draft:save', { isNewDraft: false, ...metadata, }) setViewMode('drafts') - logEvent('draft:listOpen', { + logger.metric('draft:listOpen', { draftCount: draftsCount ?? 0, }) - }, [saveImmediate, composerState, getDraftMetadata, draftsCount]) + }, [saveDraft, composerState, getDraftMetadata, draftsCount]) // Handler for "Don't update" in the update-before-drafts prompt const onSkipUpdateBeforeDrafts = useCallback(() => { setViewMode('drafts') - logEvent('draft:listOpen', { + logger.metric('draft:listOpen', { draftCount: draftsCount ?? 0, }) }, [draftsCount]) @@ -962,7 +985,7 @@ export const ComposePost = ({ } // Log draft:post event if we posted from an existing draft if (isEditingExistingDraft && loadedDraftTimestamp) { - logEvent('draft:post', { + logger.metric('draft:post', { draftAgeMs: Date.now() - loadedDraftTimestamp, wasEdited: hasUnsavedChanges, }) @@ -1147,7 +1170,11 @@ export const ComposePost = ({ style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal> - {viewMode === 'drafts' ? ( + {isLoadingDraft ? ( + + + + ) : viewMode === 'drafts' ? ( setViewMode('compose')} diff --git a/src/view/com/composer/DraftsDialog.tsx b/src/view/com/composer/DraftsDialog.tsx index 322c0c259d..1a9cfb2f09 100644 --- a/src/view/com/composer/DraftsDialog.tsx +++ b/src/view/com/composer/DraftsDialog.tsx @@ -4,7 +4,7 @@ import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' -import {logEvent} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import {isNative} from '#/platform/detection' import {useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' @@ -132,7 +132,7 @@ function DraftListItem({ }, [item.id, onSelect]) const handleDelete = useCallback(() => { - logEvent('draft:delete', { + logger.metric('draft:delete', { logContext: 'DraftsList', draftAgeMs: Date.now() - item.draft.timestamp, }) diff --git a/src/view/com/composer/useComposerDraft.ts b/src/view/com/composer/useComposerDraft.ts index 208ae64d04..ab91d45a05 100644 --- a/src/view/com/composer/useComposerDraft.ts +++ b/src/view/com/composer/useComposerDraft.ts @@ -1,385 +1,59 @@ -import {useCallback, useEffect, useMemo, useRef} from 'react' -import {RichText} from '@atproto/api' +import {useCallback, useMemo} from 'react' -import {type SelfLabel} from '#/lib/moderation' -import {logger} from '#/logger' -import {draftsStorage} from '#/state/drafts' +import {draftsStorage, serializeDraft} from '#/state/drafts' import {useSession} from '#/state/session' -import {type ComposerDraft} from '#/storage' import {type ComposerState} from './state/composer' -const AUTOSAVE_DELAY_MS = 1000 // 1 second debounce - function generateDraftId(): string { return `draft-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` } -type SerializedImage = { - alt: string - path: string - width: number - height: number - mime: string -} - -type SerializedVideo = { - blobRef: unknown // BlobRef from @atproto/api (server reference) - width: number - height: number - mimeType: string - altText: string -} - -type SerializedDraft = ComposerDraft - -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: unknown; title: string; alt: string} - | undefined - - let video: SerializedVideo | 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, - } - } else if (media?.type === 'video') { - logger.debug('Draft: Video found in post', { - status: media.video.status, - hasAsset: !!media.video.asset, - hasPendingPublish: !!(media.video as any).pendingPublish, - }) - if (media.video.status === 'done') { - // Only serialize videos that are fully uploaded - // Don't save the asset.uri - it's local data and can be huge - // The blobRef is all we need since the video is on the server - video = { - blobRef: media.video.pendingPublish.blobRef, - width: media.video.asset.width, - height: media.video.asset.height, - mimeType: media.video.asset.mimeType || 'video/mp4', - altText: media.video.altText, - } - logger.debug('Draft: Serialized video', { - hasBlobRef: !!video.blobRef, - dimensions: `${video.width}x${video.height}`, - }) - } else { - logger.debug('Draft: Skipping video (not done)', { - status: media.video.status, - }) - } - } - // Videos in other states (compressing, uploading, processing) are skipped - - return { - id: post.id, - text: post.richtext.text, - labels: post.labels, - embed: { - quoteUri: post.embed.quote?.uri, - linkUri: post.embed.link?.uri, - images, - gif, - video, - }, - } - }), - postgate: state.thread.postgate, - threadgate: state.thread.threadgate, - }, - activePostIndex: state.activePostIndex, - } -} - -export 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} - | {type: 'video'; video: any} - | 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, - } - } else if (post.embed.video) { - // Reconstruct video (already uploaded to server) - logger.debug('Draft: Restoring video from draft', { - hasVideo: !!post.embed.video, - hasBlobRef: !!post.embed.video.blobRef, - }) - const abortController = new AbortController() - abortController.abort() // Already uploaded, can't resume - media = { - type: 'video', - video: { - status: 'done', - progress: 100, - abortController, - asset: { - uri: '', // Placeholder - video is on server, we have the blobRef - width: post.embed.video.width, - height: post.embed.video.height, - mimeType: post.embed.video.mimeType, - }, - video: { - uri: '', // Placeholder - not needed for posting - mimeType: post.embed.video.mimeType, - size: 0, - }, - pendingPublish: { - blobRef: post.embed.video.blobRef, - }, - altText: post.embed.video.altText, - captions: [], - }, - } - } - - const rt = new RichText({text: post.text}) - return { - id: post.id, - richtext: rt, - shortenedGraphemeLength: rt.graphemeLength, - labels: post.labels as SelfLabel[], - 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, - }, - } - }), - // 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, - } -} - /** * Hook for managing a single draft in the composer. - * Auto-saves the draft as the user types, and provides methods to load/clear. + * Provides methods to save and clear drafts. * - * @param composerState The current composer state to auto-save - * @param draftId Optional draft ID to load an existing draft. If not provided, a new ID is generated. + * @param draftId Optional draft ID for an existing draft. If not provided, a new ID is generated. */ -export function useComposerDraft( - composerState: ComposerState, - draftId?: string, -) { +export function useComposerDraft(draftId?: string) { const {currentAccount} = useSession() - const saveTimeoutRef = useRef | undefined>( - undefined, - ) - - // Generate or use provided draft ID - const currentDraftId = useMemo(() => draftId || generateDraftId(), [draftId]) - const accountDid = currentAccount?.did - // 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, - ) - }, []) + const currentDraftId = useMemo(() => draftId || generateDraftId(), [draftId]) - // Core save logic (used by both debounced and immediate save) - const performSave = useCallback( - (state: ComposerState) => { - if (!accountDid) return - - if (hasContent(state)) { - const serialized = serializeDraft(state) - logger.debug('Draft serialized successfully', { - draftId: currentDraftId, - hasPosts: serialized.thread.posts.length > 0, - hasVideo: !!serialized.thread.posts[0]?.embed?.video, - }) - - draftsStorage.saveDraft(accountDid, currentDraftId, serialized).then( - () => { - logger.info('Composer draft saved', { - draftId: currentDraftId, - textLength: state.thread.posts[0]?.richtext.text.length || 0, - }) - }, - e => { - logger.error('Failed to save composer draft', { - error: e, - message: e instanceof Error ? e.message : String(e), - }) - }, - ) - } else { - // If no content, remove this draft - draftsStorage.deleteDraft(accountDid, currentDraftId).then( - () => { - logger.debug('Empty draft removed', {draftId: currentDraftId}) - }, - e => { - logger.error('Failed to remove empty draft', {error: e}) - }, - ) - } - }, - [accountDid, currentDraftId, hasContent], - ) - - // Save draft to storage (debounced) const saveDraft = useCallback( (state: ComposerState) => { if (!accountDid) return - // Clear any pending save - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current) - } + const hasContent = state.thread.posts.some( + post => + post.richtext.text.trim().length > 0 || + post.embed.quote || + post.embed.link || + post.embed.media, + ) - // Debounce the save - saveTimeoutRef.current = setTimeout(() => { - performSave(state) - }, AUTOSAVE_DELAY_MS) + if (hasContent) { + draftsStorage.saveDraft( + accountDid, + currentDraftId, + serializeDraft(state), + ) + } else { + draftsStorage.deleteDraft(accountDid, currentDraftId) + } }, - [accountDid, performSave], + [accountDid, currentDraftId], ) - // Save draft immediately (bypasses debounce) - const saveImmediate = useCallback( - (state: ComposerState) => { - // Clear any pending debounced save - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current) - } - performSave(state) - }, - [performSave], - ) - - // Load draft from storage - const loadDraft = useCallback((): Partial | null => { - if (!accountDid || !draftId) return null - - // Note: This is synchronous for now since MMKV is sync underneath. - // When migrating to a backend, this will need to become async. - // For now, we call the async function but don't await it in this sync context. - // The actual loading happens in Composer.tsx via a useEffect. - let result: Partial | null = null - - // We need a sync return here for the current implementation - // This is a known limitation that will need refactoring when migrating to backend - draftsStorage.getDraft(accountDid, draftId).then( - draft => { - if (draft) { - logger.info('Composer draft loaded', { - draftId, - textLength: draft.thread.posts[0]?.text.length || 0, - }) - result = deserializeDraft(draft) - } - }, - e => { - logger.error('Failed to load composer draft', {error: e}) - }, - ) - - // Since MMKV is actually sync, the promise resolves immediately - // This works for now but should be refactored for async backends - return result - }, [accountDid, draftId]) - - // Clear draft from storage const clearDraft = useCallback(() => { if (!accountDid) return - - draftsStorage.deleteDraft(accountDid, currentDraftId).then( - () => { - logger.debug('Composer draft cleared', {draftId: currentDraftId}) - }, - e => { - logger.error('Failed to clear composer draft', {error: e}) - }, - ) + draftsStorage.deleteDraft(accountDid, currentDraftId) }, [accountDid, currentDraftId]) - // Auto-save on state changes (only for new drafts, not when editing existing ones) - // For existing drafts, we only save on explicit user action (Update button) - const isExisting = !!draftId - useEffect(() => { - if (!isExisting) { - saveDraft(composerState) - } - }, [composerState, saveDraft, isExisting]) - - // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current) - } - } - }, []) - return { draftId: currentDraftId, - loadDraft, + saveDraft, clearDraft, - saveImmediate, isExistingDraft: !!draftId, } } diff --git a/src/view/com/composer/useDraftsList.ts b/src/view/com/composer/useDraftsList.ts index 7264c5ca93..5df3c67ac6 100644 --- a/src/view/com/composer/useDraftsList.ts +++ b/src/view/com/composer/useDraftsList.ts @@ -2,80 +2,56 @@ import {useCallback, useEffect, useState} from 'react' import {type DraftItem, draftsStorage} from '#/state/drafts' import {useSession} from '#/state/session' -import {type ComposerDraft} from '#/storage' -// Re-export DraftItem for consumers export type {DraftItem} /** * Hook for managing the drafts list. - * Provides access to all saved drafts and methods to delete them. + * Provides access to all saved drafts and methods to manage them. */ export function useDraftsList() { const {currentAccount} = useSession() const accountDid = currentAccount?.did - // State for drafts list const [drafts, setDrafts] = useState([]) - // Load drafts on mount and when accountDid changes useEffect(() => { if (!accountDid) { setDrafts([]) return } - draftsStorage.getAllDrafts(accountDid).then(setDrafts) }, [accountDid]) - const draftsCount = drafts.length - - // Delete a specific draft const deleteDraft = useCallback( - (draftId: string) => { + async (draftId: string) => { if (!accountDid) return - - draftsStorage.deleteDraft(accountDid, draftId).then(() => { - // Refresh the list after deletion - draftsStorage.getAllDrafts(accountDid).then(setDrafts) - }) + await draftsStorage.deleteDraft(accountDid, draftId) + const updated = await draftsStorage.getAllDrafts(accountDid) + setDrafts(updated) }, [accountDid], ) - // Get a specific draft by ID - const getDraft = useCallback( - async (draftId: string): Promise => { - if (!accountDid) return null - return draftsStorage.getDraft(accountDid, draftId) - }, - [accountDid], - ) - - // Clean up old drafts - const cleanupOldDrafts = useCallback(() => { + const cleanupOldDrafts = useCallback(async () => { if (!accountDid) return - - draftsStorage.cleanupOldDrafts(accountDid).then(removedCount => { - if (removedCount > 0) { - // Refresh the list after cleanup - draftsStorage.getAllDrafts(accountDid).then(setDrafts) - } - }) + const removedCount = await draftsStorage.cleanupOldDrafts(accountDid) + if (removedCount > 0) { + const updated = await draftsStorage.getAllDrafts(accountDid) + setDrafts(updated) + } }, [accountDid]) - // Refresh the drafts list (useful after saving a new draft) - const refreshDrafts = useCallback(() => { + const refreshDrafts = useCallback(async () => { if (!accountDid) return - - draftsStorage.getAllDrafts(accountDid).then(setDrafts) + const updated = await draftsStorage.getAllDrafts(accountDid) + setDrafts(updated) }, [accountDid]) return { drafts, - draftsCount, + draftsCount: drafts.length, deleteDraft, - getDraft, cleanupOldDrafts, refreshDrafts, }