Draft storage abstraction layer

This commit is contained in:
Alex Benzer
2025-12-22 13:24:37 -08:00
committed by Samuel Newman
parent 22e0edbdaa
commit bb9d7f48a7
5 changed files with 403 additions and 285 deletions
+1
View File
@@ -0,0 +1 @@
export {type DraftItem, draftsStorage} from './storage'
+253
View File
@@ -0,0 +1,253 @@
import {logger} from '#/logger'
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
}
/**
* Abstraction layer for draft storage operations.
*
* Currently backed by MMKV (local storage), but designed with an async API
* to make future migration to a backend key-value store easier.
*
* 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 = {
/**
* Get a single draft by ID
*/
async getDraft(did: string, draftId: string): Promise<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', {error: e, draftId})
return null
}
},
/**
* Get all drafts for an account, sorted by timestamp (newest first)
*/
async getAllDrafts(did: string): Promise<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', {error: e})
return []
}
},
/**
* Save a draft (create or update)
*/
async saveDraft(
did: string,
draftId: string,
draft: ComposerDraft,
): Promise<void> {
try {
const allDrafts = account.get([did, 'composerDrafts']) ?? {}
account.set([did, 'composerDrafts'], {
...allDrafts,
[draftId]: draft,
})
logger.debug('Draft saved', {draftId})
} catch (e) {
logger.error('Failed to save draft', {error: e, draftId})
throw e
}
},
/**
* Delete a single draft
*/
async deleteDraft(did: string, draftId: string): Promise<void> {
try {
const allDrafts = account.get([did, 'composerDrafts'])
if (!allDrafts || !allDrafts[draftId]) return
const remainingDrafts: Record<string, ComposerDraft> = {}
for (const [id, draft] of Object.entries(allDrafts)) {
if (id !== draftId) {
remainingDrafts[id] = draft
}
}
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<boolean> {
const draft = await this.getDraft(did, draftId)
return draft !== null
},
/**
* Get count of valid drafts
*/
async getDraftsCount(did: string): Promise<number> {
const drafts = await this.getAllDrafts(did)
return drafts.length
},
/**
* Clean up old/invalid drafts
*/
async cleanupOldDrafts(did: string): Promise<number> {
try {
const allDrafts = account.get([did, 'composerDrafts'])
if (!allDrafts) return 0
const now = Date.now()
const remainingDrafts: Record<string, ComposerDraft> = {}
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([did, 'composerDrafts'], remainingDrafts)
} else {
account.remove([did, 'composerDrafts'])
}
logger.debug('Cleaned up old drafts', {removedCount})
}
return removedCount
} catch (e) {
logger.error('Failed to cleanup old drafts', {error: e})
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 []
}
},
}
+53 -79
View File
@@ -78,6 +78,7 @@ import {colors} from '#/lib/styles'
import {logger} from '#/logger'
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
import {useDialogStateControlContext} from '#/state/dialogs'
import {draftsStorage} from '#/state/drafts'
import {emitPostCreated} from '#/state/events'
import {
type ComposerImage,
@@ -130,7 +131,6 @@ 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 {DraftsView} from './DraftsDialog'
import {PostLanguageSelect} from './select-language/PostLanguageSelect'
@@ -302,46 +302,27 @@ export const ComposePost = ({
// 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 = draftsStorage.getDraftSync(currentAccount.did, initDraftId)
if (!draft) return null
const draft = allDrafts[initDraftId]
if (!draft) return null
logger.info('Composer: loading draft by ID', {
draftId: initDraftId,
textLength: draft.thread.posts[0]?.text.length || 0,
})
if (draft.version !== 1) return null
// 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
})
// 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
}
return parsed
}
return null
@@ -424,51 +405,44 @@ export const ComposePost = ({
(selectedDraftId: string) => {
if (!currentAccount) return
try {
const allDrafts = account.get([currentAccount.did, 'composerDrafts'])
if (!allDrafts) return
const draft = draftsStorage.getDraftSync(
currentAccount.did,
selectedDraftId,
)
if (!draft) 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,
// 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})
},
[currentAccount, preferences?.postInteractionSettings],
)
+63 -106
View File
@@ -3,30 +3,17 @@ import {RichText} from '@atproto/api'
import {type SelfLabel} from '#/lib/moderation'
import {logger} from '#/logger'
import {draftsStorage} from '#/state/drafts'
import {useSession} from '#/state/session'
import {account, type ComposerDraft} from '#/storage'
import {type ComposerDraft} from '#/storage'
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 generateDraftId(): string {
return `draft-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
function removeDraft(
allDrafts: Record<string, ComposerDraft>,
draftIdToRemove: string,
): Record<string, ComposerDraft> | null {
const result: Record<string, ComposerDraft> = {}
for (const key of Object.keys(allDrafts)) {
if (key !== draftIdToRemove) {
result[key] = allDrafts[key]
}
}
return Object.keys(result).length > 0 ? result : null
}
type SerializedImage = {
alt: string
path: string
@@ -257,45 +244,38 @@ export function useComposerDraft(
(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,
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],
@@ -335,69 +315,46 @@ export function useComposerDraft(
const loadDraft = useCallback((): Partial<ComposerState> | null => {
if (!accountDid || !draftId) return null
try {
const allDrafts = account.get([accountDid, 'composerDrafts'])
if (!allDrafts) 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<ComposerState> | null = null
const draft = allDrafts[draftId]
if (!draft) return null
// Check version compatibility
if (draft.version !== 1) {
logger.warn('Incompatible draft version, discarding', {
version: draft.version,
})
const remainingDrafts = removeDraft(allDrafts, draftId)
if (remainingDrafts) {
account.set([accountDid, 'composerDrafts'], remainingDrafts)
} else {
account.remove([accountDid, 'composerDrafts'])
// 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)
}
return null
}
},
e => {
logger.error('Failed to load composer draft', {error: e})
},
)
// 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})
const remainingDrafts = removeDraft(allDrafts, draftId)
if (remainingDrafts) {
account.set([accountDid, 'composerDrafts'], remainingDrafts)
} else {
account.remove([accountDid, 'composerDrafts'])
}
return null
}
logger.info('Composer draft loaded', {
draftId,
textLength: draft.thread.posts[0]?.text.length || 0,
})
return deserializeDraft(draft)
} catch (e) {
logger.error('Failed to load composer draft', {error: e})
return null
}
// 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
try {
const allDrafts = account.get([accountDid, 'composerDrafts'])
if (allDrafts && allDrafts[currentDraftId]) {
const remainingDrafts = removeDraft(allDrafts, currentDraftId)
if (remainingDrafts) {
account.set([accountDid, 'composerDrafts'], remainingDrafts)
} else {
account.remove([accountDid, 'composerDrafts'])
}
draftsStorage.deleteDraft(accountDid, currentDraftId).then(
() => {
logger.debug('Composer draft cleared', {draftId: currentDraftId})
}
} catch (e) {
logger.error('Failed to clear composer draft', {error: e})
}
},
e => {
logger.error('Failed to clear composer draft', {error: e})
},
)
}, [accountDid, currentDraftId])
// Auto-save on state changes (only for new drafts, not when editing existing ones)
+33 -100
View File
@@ -1,15 +1,11 @@
import {useCallback, useMemo, useState} from 'react'
import {useCallback, useEffect, useState} from 'react'
import {logger} from '#/logger'
import {type DraftItem, draftsStorage} from '#/state/drafts'
import {useSession} from '#/state/session'
import {account, type ComposerDraft} from '#/storage'
import {type ComposerDraft} from '#/storage'
const MAX_DRAFT_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export type DraftItem = {
id: string
draft: ComposerDraft
}
// Re-export DraftItem for consumers
export type {DraftItem}
/**
* Hook for managing the drafts list.
@@ -19,39 +15,18 @@ 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)
// State for drafts list
const [drafts, setDrafts] = useState<DraftItem[]>([])
// 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 []
// Load drafts on mount and when accountDid changes
useEffect(() => {
if (!accountDid) {
setDrafts([])
return
}
}, [accountDid, refreshKey])
draftsStorage.getAllDrafts(accountDid).then(setDrafts)
}, [accountDid])
const draftsCount = drafts.length
@@ -60,48 +35,19 @@ export function useDraftsList() {
(draftId: string) => {
if (!accountDid) return
try {
const allDrafts = account.get([accountDid, 'composerDrafts'])
if (!allDrafts || !allDrafts[draftId]) return
const remainingDrafts: Record<string, ComposerDraft> = {}
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})
}
draftsStorage.deleteDraft(accountDid, draftId).then(() => {
// Refresh the list after deletion
draftsStorage.getAllDrafts(accountDid).then(setDrafts)
})
},
[accountDid],
)
// Get a specific draft by ID
const getDraft = useCallback(
(draftId: string): ComposerDraft | null => {
async (draftId: string): Promise<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
}
return draftsStorage.getDraft(accountDid, draftId)
},
[accountDid],
)
@@ -110,33 +56,19 @@ export function useDraftsList() {
const cleanupOldDrafts = useCallback(() => {
if (!accountDid) return
try {
const allDrafts = account.get([accountDid, 'composerDrafts'])
if (!allDrafts) return
const now = Date.now()
const remainingDrafts: Record<string, ComposerDraft> = {}
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++
}
}
draftsStorage.cleanupOldDrafts(accountDid).then(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})
// Refresh the list after cleanup
draftsStorage.getAllDrafts(accountDid).then(setDrafts)
}
} catch (e) {
logger.error('Failed to cleanup old drafts', {error: e})
}
})
}, [accountDid])
// Refresh the drafts list (useful after saving a new draft)
const refreshDrafts = useCallback(() => {
if (!accountDid) return
draftsStorage.getAllDrafts(accountDid).then(setDrafts)
}, [accountDid])
return {
@@ -145,5 +77,6 @@ export function useDraftsList() {
deleteDraft,
getDraft,
cleanupOldDrafts,
refreshDrafts,
}
}