diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx index 8449847770..ed4c2bb89c 100644 --- a/src/state/shell/composer/index.tsx +++ b/src/state/shell/composer/index.tsx @@ -44,6 +44,8 @@ export interface ComposerOpts { imageUris?: {uri: string; width: number; height: number; altText?: string}[] videoUri?: {uri: string; width: number; height: number} openGallery?: boolean + /** ID of a saved draft to load */ + draftId?: string } type StateContext = ComposerOpts | undefined diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 47942a9d48..13f4f19be8 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -70,10 +70,19 @@ export type Account = { /** * Composer draft, saved when the user has unsent content in the post composer. * Keyed by context (e.g., 'default', reply URI, etc.) + * @deprecated Use composerDrafts instead for multi-draft support */ composerDraft?: { [context: string]: ComposerDraft } + + /** + * Composer drafts library, allowing users to save multiple drafts. + * Keyed by unique draft ID (UUID). + */ + composerDrafts?: { + [draftId: string]: ComposerDraft + } } export type ComposerDraft = { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 7dea064992..756fa04a67 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -132,6 +132,7 @@ import * as Toast from '#/components/Toast' import {Text as NewText} from '#/components/Typography' import {account} from '#/storage' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' +import {DraftsView} from './DraftsDialog' import {PostLanguageSelect} from './select-language/PostLanguageSelect' import { type AssetType, @@ -141,6 +142,7 @@ import { import { type ComposerAction, composerReducer, + type ComposerState, createComposerState, type EmbedDraft, MAX_IMAGES, @@ -156,9 +158,52 @@ import { } from './state/video' import {type TextInputRef} from './text-input/TextInput.types' import {useComposerDraft} from './useComposerDraft' +import {useDraftsList} from './useDraftsList' import {getVideoMetadata} from './videos/pickVideo' import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop' +/** + * Serializes the relevant parts of composer state for comparison. + * Used to detect if a loaded draft has been modified. + */ +function serializeStateForComparison(state: ComposerState): string { + return JSON.stringify({ + posts: state.thread.posts.map(post => ({ + text: post.richtext.text, + labels: post.labels, + hasQuote: !!post.embed.quote, + quoteUri: post.embed.quote?.uri, + hasLink: !!post.embed.link, + linkUri: post.embed.link?.uri, + mediaType: post.embed.media?.type, + // For images, compare paths and alts + images: + post.embed.media?.type === 'images' + ? post.embed.media.images.map(img => ({ + path: img.source.path, + alt: img.alt, + })) + : undefined, + // For gif, compare id and alt + gif: + post.embed.media?.type === 'gif' + ? {id: post.embed.media.gif.id, alt: post.embed.media.alt} + : undefined, + // For video, compare blobRef (if done) + video: + post.embed.media?.type === 'video' && + post.embed.media.video.status === 'done' + ? { + blobRef: (post.embed.media.video as any).pendingPublish?.blobRef, + alt: post.embed.media.video.altText, + } + : undefined, + })), + postgate: state.thread.postgate, + threadgate: state.thread.threadgate, + }) +} + type CancelRef = { onPressCancel: () => void } @@ -175,6 +220,7 @@ export const ComposePost = ({ imageUris: initImageUris, videoUri: initVideoUri, openGallery, + draftId: initDraftId, cancelRef, }: Props & { cancelRef?: React.RefObject @@ -190,6 +236,8 @@ export const ComposePost = ({ const setLangPrefs = useLanguagePrefsApi() const textInput = useRef(null) const discardPromptControl = Prompt.usePromptControl() + const saveBeforeDraftsPromptControl = Prompt.usePromptControl() + const updateBeforeDraftsPromptControl = Prompt.usePromptControl() const {closeAllDialogs} = useDialogStateControlContext() const {closeAllModals} = useModalControls() const {data: preferences} = usePreferencesQuery() @@ -199,6 +247,7 @@ export const ComposePost = ({ const [isPublishing, setIsPublishing] = useState(false) const [publishingStage, setPublishingStage] = useState('') const [error, setError] = useState('') + const [viewMode, setViewMode] = useState<'compose' | 'drafts'>('compose') /** * A temporary local reference to a language suggestion that the user has @@ -238,9 +287,7 @@ export const ComposePost = ({ setReplyToLanguages([]) } - // Check for draft before initializing composer - const draftContext = replyTo ? `reply:${replyTo.uri}` : 'default' - + // Load draft by ID if provided const loadInitialDraft = useCallback(() => { if (!currentAccount) return null @@ -253,55 +300,54 @@ export const ComposePost = ({ if (hasInitialContent) return null - try { - const allDrafts = account.get([currentAccount.did, 'composerDraft']) - if (!allDrafts) return null + // If a draftId is provided, load that specific draft + if (initDraftId) { + try { + const allDrafts = account.get([currentAccount.did, 'composerDrafts']) + if (!allDrafts) return null - const draft = allDrafts[draftContext] - if (!draft) return null + const draft = allDrafts[initDraftId] + if (!draft) return null - if (draft.version !== 1) return null + if (draft.version !== 1) return null - // Check age - const age = Date.now() - draft.timestamp - if (age > 7 * 24 * 60 * 60 * 1000) { - // 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']) + // Check age + const age = Date.now() - draft.timestamp + if (age > 7 * 24 * 60 * 60 * 1000) { + 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 + } catch (e) { + logger.error('Failed to load draft by ID', { + error: e, + draftId: initDraftId, + }) return null } - - logger.info('Composer: loading initial draft', { - 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 - // Use the video.bsky.app CDN with HLS playlist - post.embed.video.uri = `https://video.bsky.app/watch/${encodeURIComponent(currentAccount.did)}/${cid}/playlist.m3u8` - } - return post - }) - } - - return parsed - } catch (e) { - logger.error('Failed to load initial draft', {error: e}) - return null } + + return null }, [ currentAccount, - draftContext, + initDraftId, initText, initMention, initImageUris, @@ -322,13 +368,156 @@ export const ComposePost = ({ createComposerState, ) - // Draft persistence - const {clearDraft} = useComposerDraft( - composerState, - replyTo ? `reply:${replyTo.uri}` : 'default', + // Track current draft ID - can be updated when user selects a draft from the list + const [currentDraftId, setCurrentDraftId] = useState( + !replyTo ? initDraftId : undefined, ) + // Draft persistence - only for top-level posts (not replies) + const {clearDraft, saveImmediate} = useComposerDraft( + composerState, + currentDraftId, + ) + + // Track if we're editing an existing draft (either from initial load or from list selection) + const [isEditingExistingDraft, setIsEditingExistingDraft] = + useState(!!initDraftId) + + // 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 + }, + ) + + // Drafts list for the drafts dialog + const {draftsCount} = useDraftsList() + const thread = composerState.thread + + // Check if composer has content + const hasContent = useMemo(() => { + return thread.posts.some( + post => + post.richtext.text.trim().length > 0 || + post.embed.quote || + post.embed.link || + post.embed.media, + ) + }, [thread.posts]) + + // Check if an existing draft has been modified since loading + const hasUnsavedChanges = useMemo(() => { + if (!isEditingExistingDraft || !loadedDraftSnapshot) { + return false + } + const currentSnapshot = serializeStateForComparison(composerState) + return currentSnapshot !== loadedDraftSnapshot + }, [isEditingExistingDraft, loadedDraftSnapshot, composerState]) + + // Handler for selecting a draft from the list + const onSelectDraft = useCallback( + (selectedDraftId: string) => { + if (!currentAccount) return + + try { + const allDrafts = account.get([currentAccount.did, 'composerDrafts']) + if (!allDrafts) return + + const draft = allDrafts[selectedDraftId] + if (!draft || draft.version !== 1) 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 newState = createComposerState({ + initText: undefined, + initMention: undefined, + initImageUris: undefined, + initQuoteUri: undefined, + initInteractionSettings: preferences?.postInteractionSettings, + initDraft: parsed, + }) + + // Update draft tracking state + setCurrentDraftId(selectedDraftId) + setIsEditingExistingDraft(true) + setLoadedDraftSnapshot(serializeStateForComparison(newState)) + + // Load the draft and switch back to compose mode + composerDispatch({type: 'load_draft', draft: newState}) + setViewMode('compose') + + logger.info('Loaded draft into composer', {draftId: selectedDraftId}) + } catch (e) { + logger.error('Failed to load draft', { + error: e, + draftId: selectedDraftId, + }) + } + }, + [currentAccount, preferences?.postInteractionSettings], + ) + + // Handler for opening drafts view + const onPressDrafts = useCallback(() => { + if (hasContent && !isEditingExistingDraft) { + // New unsaved content - prompt to save first + saveBeforeDraftsPromptControl.open() + } else if (isEditingExistingDraft && hasUnsavedChanges) { + // Editing existing draft with changes - prompt to update first + updateBeforeDraftsPromptControl.open() + } else { + // No content or no changes - just show drafts + setViewMode('drafts') + } + }, [ + hasContent, + isEditingExistingDraft, + hasUnsavedChanges, + saveBeforeDraftsPromptControl, + updateBeforeDraftsPromptControl, + ]) + + // Handler for "Save" in the save-before-drafts prompt + const onSaveBeforeDrafts = useCallback(() => { + saveImmediate(composerState) + setViewMode('drafts') + }, [saveImmediate, composerState]) + + // Handler for "Don't save" in the save-before-drafts prompt + const onDiscardBeforeDrafts = useCallback(() => { + clearDraft() + setViewMode('drafts') + }, [clearDraft]) + + // Handler for when a draft is deleted from the drafts list + const onDraftDeleted = useCallback( + (deletedDraftId: string) => { + // If the deleted draft is the one we're currently editing, reset to new draft state + if (currentDraftId === deletedDraftId) { + setCurrentDraftId(undefined) + setIsEditingExistingDraft(false) + setLoadedDraftSnapshot(null) + } + }, + [currentDraftId], + ) + const activePost = thread.posts[composerState.activePostIndex] const nextPost: PostDraft | undefined = thread.posts[composerState.activePostIndex + 1] @@ -402,10 +591,10 @@ export const ComposePost = ({ const [publishOnUpload, setPublishOnUpload] = useState(false) const onClose = useCallback(() => { - clearDraft() + // Don't clear draft - it's auto-saved and will be kept closeComposer() clearThumbnailCache(queryClient) - }, [clearDraft, closeComposer, queryClient]) + }, [closeComposer, queryClient]) const insets = useSafeAreaInsets() const viewStyles = useMemo( @@ -427,7 +616,23 @@ export const ComposePost = ({ const onPressCancel = useCallback(() => { if (textInput.current?.maybeClosePopup()) { return - } else if ( + } + + // For existing drafts, only prompt if there are unsaved changes + if (isEditingExistingDraft) { + if (hasUnsavedChanges) { + closeAllDialogs() + Keyboard.dismiss() + discardPromptControl.open() + } else { + // No changes made, just close without prompt + onClose() + } + return + } + + // For new content, prompt if there's any content + if ( thread.posts.some( post => post.shortenedGraphemeLength > 0 || @@ -441,7 +646,14 @@ export const ComposePost = ({ } else { onClose() } - }, [thread, closeAllDialogs, discardPromptControl, onClose]) + }, [ + thread, + closeAllDialogs, + discardPromptControl, + onClose, + isEditingExistingDraft, + hasUnsavedChanges, + ]) useImperativeHandle(cancelRef, () => ({onPressCancel})) @@ -820,74 +1032,175 @@ export const ComposePost = ({ style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal> - 1} - publishingStage={publishingStage} - topBarAnimatedStyle={topBarAnimatedStyle} - onCancel={onPressCancel} - onPublish={onPressPublish}> - {missingAltError && } - setError('')} - clearVideo={ - erroredVideoPostId - ? () => clearVideo(erroredVideoPostId) - : () => {} - } + {viewMode === 'drafts' ? ( + setViewMode('compose')} + onDeleteDraft={onDraftDeleted} /> - - - - {replyTo ? : undefined} - {thread.posts.map((post, index) => ( - - 1} - isReply={index > 0 || !!replyTo} - isActive={post.id === activePost.id} - canRemovePost={thread.posts.length > 1} - canRemoveQuote={index > 0 || !initQuote} - onSelectVideo={selectVideo} - onClearVideo={clearVideo} - onPublish={onComposerPostPublish} - onError={setError} + ) : ( + <> + 1} + publishingStage={publishingStage} + topBarAnimatedStyle={topBarAnimatedStyle} + onCancel={onPressCancel} + onPublish={onPressPublish} + onPressDrafts={onPressDrafts} + draftsCount={draftsCount}> + {missingAltError && } + setError('')} + clearVideo={ + erroredVideoPostId + ? () => clearVideo(erroredVideoPostId) + : () => {} + } /> - {isWebFooterSticky && post.id === activePost.id && ( - {footer} - )} - - ))} - - {!isWebFooterSticky && footer} + + + + {replyTo ? : undefined} + {thread.posts.map((post, index) => ( + + 1} + isReply={index > 0 || !!replyTo} + isActive={post.id === activePost.id} + canRemovePost={thread.posts.length > 1} + canRemoveQuote={index > 0 || !initQuote} + onSelectVideo={selectVideo} + onClearVideo={clearVideo} + onPublish={onComposerPostPublish} + onError={setError} + /> + {isWebFooterSticky && post.id === activePost.id && ( + {footer} + )} + + ))} + + {!isWebFooterSticky && footer} + + )} - + {isEditingExistingDraft ? ( + + + Update draft? + + + Update draft and save it to post at a later time. + + + { + // Explicitly save the changes before closing + saveImmediate(composerState) + closeComposer() + clearThumbnailCache(queryClient) + }} + /> + { + // Just close without saving - preserve the original draft + closeComposer() + clearThumbnailCache(queryClient) + }} + /> + + + + ) : ( + + + Save to drafts? + + + Save to drafts to edit and post at a later time. + + + + { + clearDraft() + closeComposer() + clearThumbnailCache(queryClient) + }} + /> + + + + )} + + + + Save to drafts? + + + Save to drafts to edit and post at a later time. + + + + + + + + + + + Update draft? + + + Update draft and save it to post at a later time. + + + { + saveImmediate(composerState) + setViewMode('drafts') + }} + /> + { + setViewMode('drafts') + }} + /> + + + ) @@ -1106,6 +1419,8 @@ function ComposerTopBar({ publishingStage, onCancel, onPublish, + onPressDrafts, + draftsCount, topBarAnimatedStyle, children, }: { @@ -1117,11 +1432,14 @@ function ComposerTopBar({ isThread: boolean onCancel: () => void onPublish: () => void + onPressDrafts?: () => void + draftsCount?: number topBarAnimatedStyle: StyleProp children?: React.ReactNode }) { const pal = usePalette('default') const {_} = useLingui() + const showDraftsButton = !isReply && draftsCount !== undefined return ( + {showDraftsButton && ( + + )} {isPublishing ? ( <> {publishingStage} diff --git a/src/view/com/composer/DraftsDialog.tsx b/src/view/com/composer/DraftsDialog.tsx new file mode 100644 index 0000000000..47a4413d19 --- /dev/null +++ b/src/view/com/composer/DraftsDialog.tsx @@ -0,0 +1,250 @@ +import React, {useCallback} from 'react' +import {ScrollView, View} from 'react-native' +import {msg, plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' +import {useProfileQuery} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {DotGrid_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import * as Menu from '#/components/Menu' +import {Text} from '#/components/Typography' +import {type ComposerDraft} from '#/storage' +import {type DraftItem, useDraftsList} from './useDraftsList' + +/** + * Inline drafts view - renders directly in the composer + */ +export function DraftsView({ + onSelectDraft, + onBack, + onDeleteDraft, +}: { + onSelectDraft: (draftId: string) => void + onBack: () => void + onDeleteDraft?: (draftId: string) => void +}) { + const {_} = useLingui() + const t = useTheme() + const {drafts, deleteDraft} = useDraftsList() + + const handleDeleteDraft = useCallback( + (draftId: string) => { + deleteDraft(draftId) + onDeleteDraft?.(draftId) + }, + [deleteDraft, onDeleteDraft], + ) + + return ( + + + + + + + + + Drafts + + + {drafts.length === 0 ? ( + + + No drafts yet + + + + When you close the composer, your post will be saved here. + + + + ) : ( + + {drafts.map((item, index) => ( + + + {index < drafts.length - 1 && ( + + )} + + ))} + + )} + + + ) +} + +function DraftListItem({ + item, + onSelect, + onDelete, +}: { + item: DraftItem + onSelect: (draftId: string) => void + onDelete: (draftId: string) => void +}) { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) + const getTimeAgo = useGetTimeAgo() + + const handleSelect = useCallback(() => { + onSelect(item.id) + }, [item.id, onSelect]) + + const handleDelete = useCallback(() => { + onDelete(item.id) + }, [item.id, onDelete]) + + const previewText = getPreviewText(item.draft, _) + const timeAgo = getTimeAgo(item.draft.timestamp, Date.now()) + + return ( + + )} + + + + + + Delete + + + + + + )} + + ) +} + +function getPreviewText( + draft: ComposerDraft, + _: ReturnType['_'], +): string { + // Get text from first post + const firstPost = draft.thread.posts[0] + if (!firstPost) return '' + + const text = firstPost.text.trim() + if (text) { + // Truncate to ~100 chars + if (text.length > 100) { + return text.substring(0, 100) + '...' + } + return text + } + + // If no text, describe the media + if (firstPost.embed.images && firstPost.embed.images.length > 0) { + const count = firstPost.embed.images.length + return _( + plural(count, { + one: '1 image', + other: `${count} images`, + }), + ) + } + + if (firstPost.embed.gif) { + return _(msg`GIF`) + } + + if (firstPost.embed.video) { + return _(msg`Video`) + } + + if (firstPost.embed.quoteUri) { + return _(msg`Quote post`) + } + + if (firstPost.embed.linkUri) { + return _(msg`Link`) + } + + return '' +} diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 12c71ea509..e0721efe51 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -122,6 +122,10 @@ export type ComposerAction = type: 'focus_post' postId: string } + | { + type: 'load_draft' + draft: ComposerState + } export const MAX_IMAGES = 4 @@ -229,6 +233,9 @@ export function composerReducer( activePostIndex: nextActivePostIndex, } } + case 'load_draft': { + return action.draft + } } } diff --git a/src/view/com/composer/useComposerDraft.ts b/src/view/com/composer/useComposerDraft.ts index bc9e1c4aa8..263c15c448 100644 --- a/src/view/com/composer/useComposerDraft.ts +++ b/src/view/com/composer/useComposerDraft.ts @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef} from 'react' +import {useCallback, useEffect, useMemo, useRef} from 'react' import {RichText} from '@atproto/api' import {type SelfLabel} from '#/lib/moderation' @@ -10,13 +10,17 @@ import {type ComposerState} from './state/composer' const AUTOSAVE_DELAY_MS = 1000 // 1 second debounce const MAX_DRAFT_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days -function removeDraftContext( +function generateDraftId(): string { + return `draft-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` +} + +function removeDraft( allDrafts: Record, - contextToRemove: string, + draftIdToRemove: string, ): Record | null { const result: Record = {} for (const key of Object.keys(allDrafts)) { - if (key !== contextToRemove) { + if (key !== draftIdToRemove) { result[key] = allDrafts[key] } } @@ -122,7 +126,9 @@ function serializeDraft(state: ComposerState): SerializedDraft { } } -function deserializeDraft(data: SerializedDraft): Partial { +export function deserializeDraft( + data: SerializedDraft, +): Partial { return { thread: { posts: data.thread.posts.map(post => { @@ -214,15 +220,25 @@ function deserializeDraft(data: SerializedDraft): Partial { } } +/** + * Hook for managing a single draft in the composer. + * Auto-saves the draft as the user types, and provides methods to load/clear. + * + * @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. + */ export function useComposerDraft( composerState: ComposerState, - context: string = 'default', + 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 @@ -236,6 +252,55 @@ export function useComposerDraft( ) }, []) + // Core save logic (used by both debounced and immediate save) + const performSave = useCallback( + (state: ComposerState) => { + if (!accountDid) return + + try { + const allDrafts = account.get([accountDid, 'composerDrafts']) ?? {} + + 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, + }) + + // Update the draft + account.set([accountDid, 'composerDrafts'], { + ...allDrafts, + [currentDraftId]: serialized, + }) + + logger.info('Composer draft saved', { + draftId: currentDraftId, + textLength: state.thread.posts[0]?.richtext.text.length || 0, + }) + } else { + // If no content, remove this draft + if (allDrafts[currentDraftId]) { + const remainingDrafts = removeDraft(allDrafts, currentDraftId) + if (remainingDrafts) { + account.set([accountDid, 'composerDrafts'], remainingDrafts) + } else { + account.remove([accountDid, 'composerDrafts']) + } + logger.debug('Empty draft removed', {draftId: currentDraftId}) + } + } + } catch (e) { + logger.error('Failed to save composer draft', { + error: e, + message: e instanceof Error ? e.message : String(e), + stack: e instanceof Error ? e.stack : undefined, + }) + } + }, + [accountDid, currentDraftId, hasContent], + ) + // Save draft to storage (debounced) const saveDraft = useCallback( (state: ComposerState) => { @@ -248,59 +313,33 @@ 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, - }) - - // Update the draft for this context - account.set([accountDid, 'composerDraft'], { - ...allDrafts, - [context]: serialized, - }) - - logger.info('Composer draft saved', { - context, - textLength: state.thread.posts[0]?.richtext.text.length || 0, - }) - } else { - // 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', { - error: e, - message: e instanceof Error ? e.message : String(e), - stack: e instanceof Error ? e.stack : undefined, - }) - } + performSave(state) }, AUTOSAVE_DELAY_MS) }, - [accountDid, context, hasContent], + [accountDid, performSave], + ) + + // 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) return null + if (!accountDid || !draftId) return null try { - const allDrafts = account.get([accountDid, 'composerDraft']) + const allDrafts = account.get([accountDid, 'composerDrafts']) if (!allDrafts) return null - const draft = allDrafts[context] + const draft = allDrafts[draftId] if (!draft) return null // Check version compatibility @@ -308,12 +347,11 @@ export function useComposerDraft( logger.warn('Incompatible draft version, discarding', { version: draft.version, }) - // Remove this context's draft - const remainingDrafts = removeDraftContext(allDrafts, context) + const remainingDrafts = removeDraft(allDrafts, draftId) if (remainingDrafts) { - account.set([accountDid, 'composerDraft'], remainingDrafts) + account.set([accountDid, 'composerDrafts'], remainingDrafts) } else { - account.remove([accountDid, 'composerDraft']) + account.remove([accountDid, 'composerDrafts']) } return null } @@ -322,55 +360,54 @@ export function useComposerDraft( const age = Date.now() - draft.timestamp if (age > MAX_DRAFT_AGE_MS) { logger.debug('Draft too old, discarding', {age}) - // Remove this context's draft - const remainingDrafts = removeDraftContext(allDrafts, context) + const remainingDrafts = removeDraft(allDrafts, draftId) if (remainingDrafts) { - account.set([accountDid, 'composerDraft'], remainingDrafts) + account.set([accountDid, 'composerDrafts'], remainingDrafts) } else { - account.remove([accountDid, 'composerDraft']) + account.remove([accountDid, 'composerDrafts']) } return null } logger.info('Composer draft loaded', { - context, + draftId, textLength: draft.thread.posts[0]?.text.length || 0, }) return deserializeDraft(draft) } catch (e) { logger.error('Failed to load composer draft', {error: e}) - // Remove corrupted drafts - try { - account.remove([accountDid, 'composerDraft']) - } catch {} return null } - }, [accountDid, context]) + }, [accountDid, draftId]) // Clear draft from storage const clearDraft = useCallback(() => { if (!accountDid) return try { - const allDrafts = account.get([accountDid, 'composerDraft']) - if (allDrafts && allDrafts[context]) { - const remainingDrafts = removeDraftContext(allDrafts, context) + const allDrafts = account.get([accountDid, 'composerDrafts']) + if (allDrafts && allDrafts[currentDraftId]) { + const remainingDrafts = removeDraft(allDrafts, currentDraftId) if (remainingDrafts) { - account.set([accountDid, 'composerDraft'], remainingDrafts) + account.set([accountDid, 'composerDrafts'], remainingDrafts) } else { - account.remove([accountDid, 'composerDraft']) + account.remove([accountDid, 'composerDrafts']) } - logger.debug('Composer draft cleared', {context}) + logger.debug('Composer draft cleared', {draftId: currentDraftId}) } } catch (e) { logger.error('Failed to clear composer draft', {error: e}) } - }, [accountDid, context]) + }, [accountDid, currentDraftId]) - // Auto-save on state changes + // 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(() => { - saveDraft(composerState) - }, [composerState, saveDraft]) + if (!isExisting) { + saveDraft(composerState) + } + }, [composerState, saveDraft, isExisting]) // Cleanup timeout on unmount useEffect(() => { @@ -381,19 +418,11 @@ export function useComposerDraft( } }, []) - const checkHasStoredDraft = useCallback(() => { - if (!accountDid) return false - try { - const allDrafts = account.get([accountDid, 'composerDraft']) - return allDrafts ? !!allDrafts[context] : false - } catch { - return false - } - }, [accountDid, context]) - return { + draftId: currentDraftId, loadDraft, clearDraft, - hasStoredDraft: checkHasStoredDraft, + saveImmediate, + isExistingDraft: !!draftId, } } diff --git a/src/view/com/composer/useDraftsList.ts b/src/view/com/composer/useDraftsList.ts new file mode 100644 index 0000000000..8cdc233cda --- /dev/null +++ b/src/view/com/composer/useDraftsList.ts @@ -0,0 +1,149 @@ +import {useCallback, useMemo, useState} from 'react' + +import {logger} from '#/logger' +import {useSession} from '#/state/session' +import {account, type ComposerDraft} from '#/storage' + +const MAX_DRAFT_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +export type DraftItem = { + id: string + draft: ComposerDraft +} + +/** + * Hook for managing the drafts list. + * Provides access to all saved drafts and methods to delete them. + */ +export function useDraftsList() { + const {currentAccount} = useSession() + const accountDid = currentAccount?.did + + // State to force re-computation of drafts list after modifications + const [refreshKey, setRefreshKey] = useState(0) + + // Get all drafts, sorted by timestamp (newest first) + const drafts = useMemo((): DraftItem[] => { + // refreshKey is used to trigger re-computation after deletions + const _refresh = refreshKey + if (!accountDid) return [] + + try { + const allDrafts = account.get([accountDid, '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', {error: e}) + return [] + } + }, [accountDid, refreshKey]) + + const draftsCount = drafts.length + + // Delete a specific draft + const deleteDraft = useCallback( + (draftId: string) => { + if (!accountDid) return + + try { + const allDrafts = account.get([accountDid, 'composerDrafts']) + if (!allDrafts || !allDrafts[draftId]) return + + const remainingDrafts: Record = {} + for (const [id, draft] of Object.entries(allDrafts)) { + if (id !== draftId) { + remainingDrafts[id] = draft + } + } + + if (Object.keys(remainingDrafts).length > 0) { + account.set([accountDid, 'composerDrafts'], remainingDrafts) + } else { + account.remove([accountDid, 'composerDrafts']) + } + + // Trigger re-render to update the list + setRefreshKey(k => k + 1) + + logger.debug('Draft deleted', {draftId}) + } catch (e) { + logger.error('Failed to delete draft', {error: e, draftId}) + } + }, + [accountDid], + ) + + // Get a specific draft by ID + const getDraft = useCallback( + (draftId: string): ComposerDraft | null => { + if (!accountDid) return null + + try { + const allDrafts = account.get([accountDid, 'composerDrafts']) + if (!allDrafts) return null + + return allDrafts[draftId] ?? null + } catch (e) { + logger.error('Failed to get draft', {error: e, draftId}) + return null + } + }, + [accountDid], + ) + + // Clean up old drafts + const cleanupOldDrafts = useCallback(() => { + if (!accountDid) return + + try { + const allDrafts = account.get([accountDid, 'composerDrafts']) + if (!allDrafts) return + + const now = Date.now() + const remainingDrafts: Record = {} + let removedCount = 0 + + for (const [id, draft] of Object.entries(allDrafts)) { + if (draft.version === 1 && now - draft.timestamp <= MAX_DRAFT_AGE_MS) { + remainingDrafts[id] = draft + } else { + removedCount++ + } + } + + if (removedCount > 0) { + if (Object.keys(remainingDrafts).length > 0) { + account.set([accountDid, 'composerDrafts'], remainingDrafts) + } else { + account.remove([accountDid, 'composerDrafts']) + } + logger.debug('Cleaned up old drafts', {removedCount}) + } + } catch (e) { + logger.error('Failed to cleanup old drafts', {error: e}) + } + }, [accountDid]) + + return { + drafts, + draftsCount, + deleteDraft, + getDraft, + cleanupOldDrafts, + } +}