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 {logger} from '#/logger'
|
||||||
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
|
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
|
||||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||||
|
import {draftsStorage} from '#/state/drafts'
|
||||||
import {emitPostCreated} from '#/state/events'
|
import {emitPostCreated} from '#/state/events'
|
||||||
import {
|
import {
|
||||||
type ComposerImage,
|
type ComposerImage,
|
||||||
@@ -130,7 +131,6 @@ import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed'
|
|||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {Text as NewText} from '#/components/Typography'
|
import {Text as NewText} from '#/components/Typography'
|
||||||
import {account} from '#/storage'
|
|
||||||
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
||||||
import {DraftsView} from './DraftsDialog'
|
import {DraftsView} from './DraftsDialog'
|
||||||
import {PostLanguageSelect} from './select-language/PostLanguageSelect'
|
import {PostLanguageSelect} from './select-language/PostLanguageSelect'
|
||||||
@@ -302,21 +302,9 @@ export const ComposePost = ({
|
|||||||
|
|
||||||
// If a draftId is provided, load that specific draft
|
// If a draftId is provided, load that specific draft
|
||||||
if (initDraftId) {
|
if (initDraftId) {
|
||||||
try {
|
const draft = draftsStorage.getDraftSync(currentAccount.did, initDraftId)
|
||||||
const allDrafts = account.get([currentAccount.did, 'composerDrafts'])
|
|
||||||
if (!allDrafts) return null
|
|
||||||
|
|
||||||
const draft = allDrafts[initDraftId]
|
|
||||||
if (!draft) return null
|
if (!draft) return null
|
||||||
|
|
||||||
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', {
|
logger.info('Composer: loading draft by ID', {
|
||||||
draftId: initDraftId,
|
draftId: initDraftId,
|
||||||
textLength: draft.thread.posts[0]?.text.length || 0,
|
textLength: draft.thread.posts[0]?.text.length || 0,
|
||||||
@@ -335,13 +323,6 @@ export const ComposePost = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return parsed
|
return parsed
|
||||||
} catch (e) {
|
|
||||||
logger.error('Failed to load draft by ID', {
|
|
||||||
error: e,
|
|
||||||
draftId: initDraftId,
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -424,12 +405,11 @@ export const ComposePost = ({
|
|||||||
(selectedDraftId: string) => {
|
(selectedDraftId: string) => {
|
||||||
if (!currentAccount) return
|
if (!currentAccount) return
|
||||||
|
|
||||||
try {
|
const draft = draftsStorage.getDraftSync(
|
||||||
const allDrafts = account.get([currentAccount.did, 'composerDrafts'])
|
currentAccount.did,
|
||||||
if (!allDrafts) return
|
selectedDraftId,
|
||||||
|
)
|
||||||
const draft = allDrafts[selectedDraftId]
|
if (!draft) return
|
||||||
if (!draft || draft.version !== 1) return
|
|
||||||
|
|
||||||
// Deep clone and construct video URLs if needed
|
// Deep clone and construct video URLs if needed
|
||||||
const parsed = JSON.parse(JSON.stringify(draft))
|
const parsed = JSON.parse(JSON.stringify(draft))
|
||||||
@@ -463,12 +443,6 @@ export const ComposePost = ({
|
|||||||
setViewMode('compose')
|
setViewMode('compose')
|
||||||
|
|
||||||
logger.info('Loaded draft into composer', {draftId: selectedDraftId})
|
logger.info('Loaded draft into composer', {draftId: selectedDraftId})
|
||||||
} catch (e) {
|
|
||||||
logger.error('Failed to load draft', {
|
|
||||||
error: e,
|
|
||||||
draftId: selectedDraftId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[currentAccount, preferences?.postInteractionSettings],
|
[currentAccount, preferences?.postInteractionSettings],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,30 +3,17 @@ import {RichText} from '@atproto/api'
|
|||||||
|
|
||||||
import {type SelfLabel} from '#/lib/moderation'
|
import {type SelfLabel} from '#/lib/moderation'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {draftsStorage} from '#/state/drafts'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {account, type ComposerDraft} from '#/storage'
|
import {type ComposerDraft} from '#/storage'
|
||||||
import {type ComposerState} from './state/composer'
|
import {type ComposerState} from './state/composer'
|
||||||
|
|
||||||
const AUTOSAVE_DELAY_MS = 1000 // 1 second debounce
|
const AUTOSAVE_DELAY_MS = 1000 // 1 second debounce
|
||||||
const MAX_DRAFT_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
||||||
|
|
||||||
function generateDraftId(): string {
|
function generateDraftId(): string {
|
||||||
return `draft-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
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 = {
|
type SerializedImage = {
|
||||||
alt: string
|
alt: string
|
||||||
path: string
|
path: string
|
||||||
@@ -257,9 +244,6 @@ export function useComposerDraft(
|
|||||||
(state: ComposerState) => {
|
(state: ComposerState) => {
|
||||||
if (!accountDid) return
|
if (!accountDid) return
|
||||||
|
|
||||||
try {
|
|
||||||
const allDrafts = account.get([accountDid, 'composerDrafts']) ?? {}
|
|
||||||
|
|
||||||
if (hasContent(state)) {
|
if (hasContent(state)) {
|
||||||
const serialized = serializeDraft(state)
|
const serialized = serializeDraft(state)
|
||||||
logger.debug('Draft serialized successfully', {
|
logger.debug('Draft serialized successfully', {
|
||||||
@@ -268,34 +252,30 @@ export function useComposerDraft(
|
|||||||
hasVideo: !!serialized.thread.posts[0]?.embed?.video,
|
hasVideo: !!serialized.thread.posts[0]?.embed?.video,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update the draft
|
draftsStorage.saveDraft(accountDid, currentDraftId, serialized).then(
|
||||||
account.set([accountDid, 'composerDrafts'], {
|
() => {
|
||||||
...allDrafts,
|
|
||||||
[currentDraftId]: serialized,
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info('Composer draft saved', {
|
logger.info('Composer draft saved', {
|
||||||
draftId: currentDraftId,
|
draftId: currentDraftId,
|
||||||
textLength: state.thread.posts[0]?.richtext.text.length || 0,
|
textLength: state.thread.posts[0]?.richtext.text.length || 0,
|
||||||
})
|
})
|
||||||
} else {
|
},
|
||||||
// If no content, remove this draft
|
e => {
|
||||||
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', {
|
logger.error('Failed to save composer draft', {
|
||||||
error: e,
|
error: e,
|
||||||
message: e instanceof Error ? e.message : String(e),
|
message: e instanceof Error ? e.message : String(e),
|
||||||
stack: e instanceof Error ? e.stack : undefined,
|
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} 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],
|
[accountDid, currentDraftId, hasContent],
|
||||||
@@ -335,69 +315,46 @@ export function useComposerDraft(
|
|||||||
const loadDraft = useCallback((): Partial<ComposerState> | null => {
|
const loadDraft = useCallback((): Partial<ComposerState> | null => {
|
||||||
if (!accountDid || !draftId) return null
|
if (!accountDid || !draftId) return null
|
||||||
|
|
||||||
try {
|
// Note: This is synchronous for now since MMKV is sync underneath.
|
||||||
const allDrafts = account.get([accountDid, 'composerDrafts'])
|
// When migrating to a backend, this will need to become async.
|
||||||
if (!allDrafts) return null
|
// 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.
|
||||||
const draft = allDrafts[draftId]
|
let result: Partial<ComposerState> | null = null
|
||||||
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'])
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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', {
|
logger.info('Composer draft loaded', {
|
||||||
draftId,
|
draftId,
|
||||||
textLength: draft.thread.posts[0]?.text.length || 0,
|
textLength: draft.thread.posts[0]?.text.length || 0,
|
||||||
})
|
})
|
||||||
return deserializeDraft(draft)
|
result = deserializeDraft(draft)
|
||||||
} catch (e) {
|
|
||||||
logger.error('Failed to load composer draft', {error: e})
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
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])
|
}, [accountDid, draftId])
|
||||||
|
|
||||||
// Clear draft from storage
|
// Clear draft from storage
|
||||||
const clearDraft = useCallback(() => {
|
const clearDraft = useCallback(() => {
|
||||||
if (!accountDid) return
|
if (!accountDid) return
|
||||||
|
|
||||||
try {
|
draftsStorage.deleteDraft(accountDid, currentDraftId).then(
|
||||||
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'])
|
|
||||||
}
|
|
||||||
logger.debug('Composer draft cleared', {draftId: currentDraftId})
|
logger.debug('Composer draft cleared', {draftId: currentDraftId})
|
||||||
}
|
},
|
||||||
} catch (e) {
|
e => {
|
||||||
logger.error('Failed to clear composer draft', {error: e})
|
logger.error('Failed to clear composer draft', {error: e})
|
||||||
}
|
},
|
||||||
|
)
|
||||||
}, [accountDid, currentDraftId])
|
}, [accountDid, currentDraftId])
|
||||||
|
|
||||||
// Auto-save on state changes (only for new drafts, not when editing existing ones)
|
// 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 {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
|
// Re-export DraftItem for consumers
|
||||||
|
export type {DraftItem}
|
||||||
export type DraftItem = {
|
|
||||||
id: string
|
|
||||||
draft: ComposerDraft
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing the drafts list.
|
* Hook for managing the drafts list.
|
||||||
@@ -19,39 +15,18 @@ export function useDraftsList() {
|
|||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const accountDid = currentAccount?.did
|
const accountDid = currentAccount?.did
|
||||||
|
|
||||||
// State to force re-computation of drafts list after modifications
|
// State for drafts list
|
||||||
const [refreshKey, setRefreshKey] = useState(0)
|
const [drafts, setDrafts] = useState<DraftItem[]>([])
|
||||||
|
|
||||||
// Get all drafts, sorted by timestamp (newest first)
|
// Load drafts on mount and when accountDid changes
|
||||||
const drafts = useMemo((): DraftItem[] => {
|
useEffect(() => {
|
||||||
// refreshKey is used to trigger re-computation after deletions
|
if (!accountDid) {
|
||||||
const _refresh = refreshKey
|
setDrafts([])
|
||||||
if (!accountDid) return []
|
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
|
draftsStorage.getAllDrafts(accountDid).then(setDrafts)
|
||||||
items.sort((a, b) => b.draft.timestamp - a.draft.timestamp)
|
}, [accountDid])
|
||||||
|
|
||||||
return items
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Failed to get drafts list', {error: e})
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}, [accountDid, refreshKey])
|
|
||||||
|
|
||||||
const draftsCount = drafts.length
|
const draftsCount = drafts.length
|
||||||
|
|
||||||
@@ -60,48 +35,19 @@ export function useDraftsList() {
|
|||||||
(draftId: string) => {
|
(draftId: string) => {
|
||||||
if (!accountDid) return
|
if (!accountDid) return
|
||||||
|
|
||||||
try {
|
draftsStorage.deleteDraft(accountDid, draftId).then(() => {
|
||||||
const allDrafts = account.get([accountDid, 'composerDrafts'])
|
// Refresh the list after deletion
|
||||||
if (!allDrafts || !allDrafts[draftId]) return
|
draftsStorage.getAllDrafts(accountDid).then(setDrafts)
|
||||||
|
})
|
||||||
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})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[accountDid],
|
[accountDid],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get a specific draft by ID
|
// Get a specific draft by ID
|
||||||
const getDraft = useCallback(
|
const getDraft = useCallback(
|
||||||
(draftId: string): ComposerDraft | null => {
|
async (draftId: string): Promise<ComposerDraft | null> => {
|
||||||
if (!accountDid) return null
|
if (!accountDid) return null
|
||||||
|
return draftsStorage.getDraft(accountDid, draftId)
|
||||||
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],
|
[accountDid],
|
||||||
)
|
)
|
||||||
@@ -110,33 +56,19 @@ export function useDraftsList() {
|
|||||||
const cleanupOldDrafts = useCallback(() => {
|
const cleanupOldDrafts = useCallback(() => {
|
||||||
if (!accountDid) return
|
if (!accountDid) return
|
||||||
|
|
||||||
try {
|
draftsStorage.cleanupOldDrafts(accountDid).then(removedCount => {
|
||||||
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++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removedCount > 0) {
|
if (removedCount > 0) {
|
||||||
if (Object.keys(remainingDrafts).length > 0) {
|
// Refresh the list after cleanup
|
||||||
account.set([accountDid, 'composerDrafts'], remainingDrafts)
|
draftsStorage.getAllDrafts(accountDid).then(setDrafts)
|
||||||
} else {
|
|
||||||
account.remove([accountDid, 'composerDrafts'])
|
|
||||||
}
|
|
||||||
logger.debug('Cleaned up old drafts', {removedCount})
|
|
||||||
}
|
|
||||||
} 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])
|
}, [accountDid])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -145,5 +77,6 @@ export function useDraftsList() {
|
|||||||
deleteDraft,
|
deleteDraft,
|
||||||
getDraft,
|
getDraft,
|
||||||
cleanupOldDrafts,
|
cleanupOldDrafts,
|
||||||
|
refreshDrafts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user