Refactor to simplify code for Drafts + add client events

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