Refactor to simplify code for Drafts + add client events
This commit is contained in:
committed by
Samuel Newman
parent
9df81cecd7
commit
a877fede5b
@@ -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 +1,2 @@
|
||||
export {serializeDraft} from './serialization'
|
||||
export {type DraftItem, draftsStorage} from './storage'
|
||||
|
||||
@@ -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
@@ -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
@@ -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')}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user