Draft storage abstraction layer
This commit is contained in:
committed by
Samuel Newman
parent
22e0edbdaa
commit
bb9d7f48a7
@@ -0,0 +1 @@
|
||||
export {type DraftItem, draftsStorage} from './storage'
|
||||
@@ -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 []
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user