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
|
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:open': {}
|
||||||
'composer:gif:select': {}
|
'composer:gif:select': {}
|
||||||
'composerPrompt:press': {}
|
'composerPrompt:press': {}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export {serializeDraft} from './serialization'
|
||||||
export {type DraftItem, draftsStorage} from './storage'
|
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
|
* Currently backed by MMKV (local storage), but uses an async API
|
||||||
* to make future migration to a backend key-value store easier.
|
* to support future migration to a server-side KV store.
|
||||||
*
|
*
|
||||||
* All operations are scoped to a specific account (DID).
|
* 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 = {
|
export const draftsStorage = {
|
||||||
/**
|
/**
|
||||||
@@ -32,7 +28,6 @@ export const draftsStorage = {
|
|||||||
const draft = allDrafts[draftId]
|
const draft = allDrafts[draftId]
|
||||||
if (!draft) return null
|
if (!draft) return null
|
||||||
|
|
||||||
// Validate version and age
|
|
||||||
if (draft.version !== 1) {
|
if (draft.version !== 1) {
|
||||||
logger.warn('Incompatible draft version', {
|
logger.warn('Incompatible draft version', {
|
||||||
draftId,
|
draftId,
|
||||||
@@ -42,7 +37,6 @@ export const draftsStorage = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Date.now() - draft.timestamp > MAX_DRAFT_AGE_MS) {
|
if (Date.now() - draft.timestamp > MAX_DRAFT_AGE_MS) {
|
||||||
logger.debug('Draft too old', {draftId})
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,16 +59,12 @@ export const draftsStorage = {
|
|||||||
const items: DraftItem[] = []
|
const items: DraftItem[] = []
|
||||||
|
|
||||||
for (const [id, draft] of Object.entries(allDrafts)) {
|
for (const [id, draft] of Object.entries(allDrafts)) {
|
||||||
// Skip invalid or too old drafts
|
|
||||||
if (draft.version !== 1) continue
|
if (draft.version !== 1) continue
|
||||||
if (now - draft.timestamp > MAX_DRAFT_AGE_MS) continue
|
if (now - draft.timestamp > MAX_DRAFT_AGE_MS) continue
|
||||||
|
|
||||||
items.push({id, draft})
|
items.push({id, draft})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by timestamp, newest first
|
|
||||||
items.sort((a, b) => b.draft.timestamp - a.draft.timestamp)
|
items.sort((a, b) => b.draft.timestamp - a.draft.timestamp)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to get drafts list', {error: e})
|
logger.error('Failed to get drafts list', {error: e})
|
||||||
@@ -96,10 +86,8 @@ export const draftsStorage = {
|
|||||||
...allDrafts,
|
...allDrafts,
|
||||||
[draftId]: draft,
|
[draftId]: draft,
|
||||||
})
|
})
|
||||||
logger.debug('Draft saved', {draftId})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to save draft', {error: e, draftId})
|
logger.error('Failed to save draft', {error: e, draftId})
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -111,44 +99,20 @@ export const draftsStorage = {
|
|||||||
const allDrafts = account.get([did, 'composerDrafts'])
|
const allDrafts = account.get([did, 'composerDrafts'])
|
||||||
if (!allDrafts || !allDrafts[draftId]) return
|
if (!allDrafts || !allDrafts[draftId]) return
|
||||||
|
|
||||||
const remainingDrafts: Record<string, ComposerDraft> = {}
|
const {[draftId]: _deleted, ...remainingDrafts} = allDrafts
|
||||||
for (const [id, draft] of Object.entries(allDrafts)) {
|
|
||||||
if (id !== draftId) {
|
|
||||||
remainingDrafts[id] = draft
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(remainingDrafts).length > 0) {
|
if (Object.keys(remainingDrafts).length > 0) {
|
||||||
account.set([did, 'composerDrafts'], remainingDrafts)
|
account.set([did, 'composerDrafts'], remainingDrafts)
|
||||||
} else {
|
} else {
|
||||||
account.remove([did, 'composerDrafts'])
|
account.remove([did, 'composerDrafts'])
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('Draft deleted', {draftId})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to delete draft', {error: e, draftId})
|
logger.error('Failed to delete draft', {error: e, draftId})
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a draft exists
|
* Clean up old/invalid drafts, returns count of removed drafts
|
||||||
*/
|
|
||||||
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> {
|
async cleanupOldDrafts(did: string): Promise<number> {
|
||||||
try {
|
try {
|
||||||
@@ -173,7 +137,6 @@ export const draftsStorage = {
|
|||||||
} else {
|
} else {
|
||||||
account.remove([did, 'composerDrafts'])
|
account.remove([did, 'composerDrafts'])
|
||||||
}
|
}
|
||||||
logger.debug('Cleaned up old drafts', {removedCount})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return removedCount
|
return removedCount
|
||||||
@@ -182,72 +145,4 @@ export const draftsStorage = {
|
|||||||
return 0
|
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 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 {type ComposerDraft} 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'
|
||||||
@@ -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 = {
|
type CancelRef = {
|
||||||
onPressCancel: () => void
|
onPressCancel: () => void
|
||||||
}
|
}
|
||||||
@@ -287,54 +309,14 @@ export const ComposePost = ({
|
|||||||
setReplyToLanguages([])
|
setReplyToLanguages([])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load draft by ID if provided
|
// Check if we should load a draft (no other initial content provided)
|
||||||
const loadInitialDraft = useCallback(() => {
|
const shouldLoadDraft =
|
||||||
if (!currentAccount) return null
|
initDraftId &&
|
||||||
|
!initText &&
|
||||||
const hasInitialContent =
|
!initMention &&
|
||||||
initText ||
|
!initImageUris?.length &&
|
||||||
initMention ||
|
!initQuote &&
|
||||||
initImageUris?.length ||
|
!initVideoUri
|
||||||
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,
|
|
||||||
])
|
|
||||||
|
|
||||||
const [composerState, composerDispatch] = useReducer(
|
const [composerState, composerDispatch] = useReducer(
|
||||||
composerReducer,
|
composerReducer,
|
||||||
@@ -344,7 +326,7 @@ export const ComposePost = ({
|
|||||||
initText,
|
initText,
|
||||||
initMention,
|
initMention,
|
||||||
initInteractionSettings: preferences?.postInteractionSettings,
|
initInteractionSettings: preferences?.postInteractionSettings,
|
||||||
initDraft: loadInitialDraft(),
|
initDraft: undefined, // Draft loaded async below
|
||||||
},
|
},
|
||||||
createComposerState,
|
createComposerState,
|
||||||
)
|
)
|
||||||
@@ -355,10 +337,7 @@ export const ComposePost = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Draft persistence - only for top-level posts (not replies)
|
// Draft persistence - only for top-level posts (not replies)
|
||||||
const {clearDraft, saveImmediate} = useComposerDraft(
|
const {clearDraft, saveDraft} = useComposerDraft(currentDraftId)
|
||||||
composerState,
|
|
||||||
currentDraftId,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Track if we're editing an existing draft (either from initial load or from list selection)
|
// Track if we're editing an existing draft (either from initial load or from list selection)
|
||||||
const [isEditingExistingDraft, setIsEditingExistingDraft] =
|
const [isEditingExistingDraft, setIsEditingExistingDraft] =
|
||||||
@@ -366,25 +345,81 @@ export const ComposePost = ({
|
|||||||
|
|
||||||
// Snapshot of the draft content when loaded, for detecting changes
|
// Snapshot of the draft content when loaded, for detecting changes
|
||||||
const [loadedDraftSnapshot, setLoadedDraftSnapshot] = useState<string | null>(
|
const [loadedDraftSnapshot, setLoadedDraftSnapshot] = useState<string | null>(
|
||||||
() => {
|
null,
|
||||||
// If loading from initDraftId, capture initial snapshot
|
|
||||||
if (initDraftId && composerState.thread.posts.length > 0) {
|
|
||||||
return serializeStateForComparison(composerState)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Track the timestamp of the loaded draft (for analytics)
|
// Track the timestamp of the loaded draft (for analytics)
|
||||||
const [loadedDraftTimestamp, setLoadedDraftTimestamp] = useState<
|
const [loadedDraftTimestamp, setLoadedDraftTimestamp] = useState<
|
||||||
number | null
|
number | null
|
||||||
>(() => {
|
>(null)
|
||||||
if (initDraftId && currentAccount) {
|
|
||||||
const draft = draftsStorage.getDraftSync(currentAccount.did, initDraftId)
|
// Loading state for initial draft
|
||||||
return draft?.timestamp ?? null
|
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
|
// Drafts list for the drafts dialog
|
||||||
const {draftsCount} = useDraftsList()
|
const {draftsCount} = useDraftsList()
|
||||||
@@ -429,35 +464,23 @@ export const ComposePost = ({
|
|||||||
|
|
||||||
// Handler for selecting a draft from the list
|
// Handler for selecting a draft from the list
|
||||||
const onSelectDraft = useCallback(
|
const onSelectDraft = useCallback(
|
||||||
(selectedDraftId: string) => {
|
async (selectedDraftId: string) => {
|
||||||
if (!currentAccount) return
|
if (!currentAccount) return
|
||||||
|
|
||||||
const draft = draftsStorage.getDraftSync(
|
const draft = await draftsStorage.getDraft(
|
||||||
currentAccount.did,
|
currentAccount.did,
|
||||||
selectedDraftId,
|
selectedDraftId,
|
||||||
)
|
)
|
||||||
if (!draft) return
|
if (!draft) return
|
||||||
|
|
||||||
// Deep clone and construct video URLs if needed
|
const prepared = prepareDraftForLoading(draft, currentAccount.did)
|
||||||
const parsed = JSON.parse(JSON.stringify(draft))
|
|
||||||
if (parsed.thread?.posts) {
|
|
||||||
parsed.thread.posts = parsed.thread.posts.map((post: any) => {
|
|
||||||
if (post.embed?.video?.blobRef?.ref?.$link) {
|
|
||||||
const cid = post.embed.video.blobRef.ref.$link
|
|
||||||
post.embed.video.uri = `https://video.bsky.app/watch/${encodeURIComponent(currentAccount.did)}/${cid}/playlist.m3u8`
|
|
||||||
}
|
|
||||||
return post
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to ComposerState
|
|
||||||
const newState = createComposerState({
|
const newState = createComposerState({
|
||||||
initText: undefined,
|
initText: undefined,
|
||||||
initMention: undefined,
|
initMention: undefined,
|
||||||
initImageUris: undefined,
|
initImageUris: undefined,
|
||||||
initQuoteUri: undefined,
|
initQuoteUri: undefined,
|
||||||
initInteractionSettings: preferences?.postInteractionSettings,
|
initInteractionSettings: preferences?.postInteractionSettings,
|
||||||
initDraft: parsed,
|
initDraft: prepared,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update draft tracking state
|
// Update draft tracking state
|
||||||
@@ -472,7 +495,7 @@ export const ComposePost = ({
|
|||||||
|
|
||||||
// Log draft:load event
|
// Log draft:load event
|
||||||
const metadata = getDraftMetadata(newState)
|
const metadata = getDraftMetadata(newState)
|
||||||
logEvent('draft:load', {
|
logger.metric('draft:load', {
|
||||||
draftAgeMs: Date.now() - draft.timestamp,
|
draftAgeMs: Date.now() - draft.timestamp,
|
||||||
hasText: metadata.hasText,
|
hasText: metadata.hasText,
|
||||||
hasImages: metadata.hasImages,
|
hasImages: metadata.hasImages,
|
||||||
@@ -497,7 +520,7 @@ export const ComposePost = ({
|
|||||||
} else {
|
} else {
|
||||||
// No content or no changes - just show drafts
|
// No content or no changes - just show drafts
|
||||||
setViewMode('drafts')
|
setViewMode('drafts')
|
||||||
logEvent('draft:listOpen', {
|
logger.metric('draft:listOpen', {
|
||||||
draftCount: draftsCount ?? 0,
|
draftCount: draftsCount ?? 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -512,18 +535,18 @@ export const ComposePost = ({
|
|||||||
|
|
||||||
// Handler for "Save" in the save-before-drafts prompt
|
// Handler for "Save" in the save-before-drafts prompt
|
||||||
const onSaveBeforeDrafts = useCallback(() => {
|
const onSaveBeforeDrafts = useCallback(() => {
|
||||||
saveImmediate(composerState)
|
saveDraft(composerState)
|
||||||
const metadata = getDraftMetadata(composerState)
|
const metadata = getDraftMetadata(composerState)
|
||||||
logEvent('draft:save', {
|
logger.metric('draft:save', {
|
||||||
isNewDraft: !isEditingExistingDraft,
|
isNewDraft: !isEditingExistingDraft,
|
||||||
...metadata,
|
...metadata,
|
||||||
})
|
})
|
||||||
setViewMode('drafts')
|
setViewMode('drafts')
|
||||||
logEvent('draft:listOpen', {
|
logger.metric('draft:listOpen', {
|
||||||
draftCount: (draftsCount ?? 0) + (isEditingExistingDraft ? 0 : 1),
|
draftCount: (draftsCount ?? 0) + (isEditingExistingDraft ? 0 : 1),
|
||||||
})
|
})
|
||||||
}, [
|
}, [
|
||||||
saveImmediate,
|
saveDraft,
|
||||||
composerState,
|
composerState,
|
||||||
getDraftMetadata,
|
getDraftMetadata,
|
||||||
isEditingExistingDraft,
|
isEditingExistingDraft,
|
||||||
@@ -533,23 +556,23 @@ export const ComposePost = ({
|
|||||||
// Handler for "Don't save" in the save-before-drafts prompt
|
// Handler for "Don't save" in the save-before-drafts prompt
|
||||||
const onDiscardBeforeDrafts = useCallback(() => {
|
const onDiscardBeforeDrafts = useCallback(() => {
|
||||||
const metadata = getDraftMetadata(composerState)
|
const metadata = getDraftMetadata(composerState)
|
||||||
logEvent('draft:discard', {
|
logger.metric('draft:discard', {
|
||||||
logContext: 'BeforeDraftsList',
|
logContext: 'BeforeDraftsList',
|
||||||
hadContent: hasContent,
|
hadContent: hasContent,
|
||||||
textLength: metadata.textLength,
|
textLength: metadata.textLength,
|
||||||
})
|
})
|
||||||
clearDraft()
|
clearDraft()
|
||||||
setViewMode('drafts')
|
setViewMode('drafts')
|
||||||
logEvent('draft:listOpen', {
|
logger.metric('draft:listOpen', {
|
||||||
draftCount: draftsCount ?? 0,
|
draftCount: draftsCount ?? 0,
|
||||||
})
|
})
|
||||||
}, [clearDraft, getDraftMetadata, composerState, hasContent, draftsCount])
|
}, [clearDraft, getDraftMetadata, composerState, hasContent, draftsCount])
|
||||||
|
|
||||||
// Handler for saving draft and closing with toast
|
// Handler for saving draft and closing with toast
|
||||||
const onSaveDraftAndClose = useCallback(() => {
|
const onSaveDraftAndClose = useCallback(() => {
|
||||||
saveImmediate(composerState)
|
saveDraft(composerState)
|
||||||
const metadata = getDraftMetadata(composerState)
|
const metadata = getDraftMetadata(composerState)
|
||||||
logEvent('draft:save', {
|
logger.metric('draft:save', {
|
||||||
isNewDraft: !isEditingExistingDraft,
|
isNewDraft: !isEditingExistingDraft,
|
||||||
...metadata,
|
...metadata,
|
||||||
})
|
})
|
||||||
@@ -558,7 +581,7 @@ export const ComposePost = ({
|
|||||||
Toast.show(_(msg`Saved to drafts`))
|
Toast.show(_(msg`Saved to drafts`))
|
||||||
}, [
|
}, [
|
||||||
_,
|
_,
|
||||||
saveImmediate,
|
saveDraft,
|
||||||
composerState,
|
composerState,
|
||||||
getDraftMetadata,
|
getDraftMetadata,
|
||||||
isEditingExistingDraft,
|
isEditingExistingDraft,
|
||||||
@@ -569,7 +592,7 @@ export const ComposePost = ({
|
|||||||
// Handler for "Don't save" when closing composer with unsaved content
|
// Handler for "Don't save" when closing composer with unsaved content
|
||||||
const onDiscardAndClose = useCallback(() => {
|
const onDiscardAndClose = useCallback(() => {
|
||||||
const metadata = getDraftMetadata(composerState)
|
const metadata = getDraftMetadata(composerState)
|
||||||
logEvent('draft:discard', {
|
logger.metric('draft:discard', {
|
||||||
logContext: 'ComposerClose',
|
logContext: 'ComposerClose',
|
||||||
hadContent: hasContent,
|
hadContent: hasContent,
|
||||||
textLength: metadata.textLength,
|
textLength: metadata.textLength,
|
||||||
@@ -588,22 +611,22 @@ export const ComposePost = ({
|
|||||||
|
|
||||||
// Handler for "Update draft" in the update-before-drafts prompt
|
// Handler for "Update draft" in the update-before-drafts prompt
|
||||||
const onUpdateBeforeDrafts = useCallback(() => {
|
const onUpdateBeforeDrafts = useCallback(() => {
|
||||||
saveImmediate(composerState)
|
saveDraft(composerState)
|
||||||
const metadata = getDraftMetadata(composerState)
|
const metadata = getDraftMetadata(composerState)
|
||||||
logEvent('draft:save', {
|
logger.metric('draft:save', {
|
||||||
isNewDraft: false,
|
isNewDraft: false,
|
||||||
...metadata,
|
...metadata,
|
||||||
})
|
})
|
||||||
setViewMode('drafts')
|
setViewMode('drafts')
|
||||||
logEvent('draft:listOpen', {
|
logger.metric('draft:listOpen', {
|
||||||
draftCount: draftsCount ?? 0,
|
draftCount: draftsCount ?? 0,
|
||||||
})
|
})
|
||||||
}, [saveImmediate, composerState, getDraftMetadata, draftsCount])
|
}, [saveDraft, composerState, getDraftMetadata, draftsCount])
|
||||||
|
|
||||||
// Handler for "Don't update" in the update-before-drafts prompt
|
// Handler for "Don't update" in the update-before-drafts prompt
|
||||||
const onSkipUpdateBeforeDrafts = useCallback(() => {
|
const onSkipUpdateBeforeDrafts = useCallback(() => {
|
||||||
setViewMode('drafts')
|
setViewMode('drafts')
|
||||||
logEvent('draft:listOpen', {
|
logger.metric('draft:listOpen', {
|
||||||
draftCount: draftsCount ?? 0,
|
draftCount: draftsCount ?? 0,
|
||||||
})
|
})
|
||||||
}, [draftsCount])
|
}, [draftsCount])
|
||||||
@@ -962,7 +985,7 @@ export const ComposePost = ({
|
|||||||
}
|
}
|
||||||
// Log draft:post event if we posted from an existing draft
|
// Log draft:post event if we posted from an existing draft
|
||||||
if (isEditingExistingDraft && loadedDraftTimestamp) {
|
if (isEditingExistingDraft && loadedDraftTimestamp) {
|
||||||
logEvent('draft:post', {
|
logger.metric('draft:post', {
|
||||||
draftAgeMs: Date.now() - loadedDraftTimestamp,
|
draftAgeMs: Date.now() - loadedDraftTimestamp,
|
||||||
wasEdited: hasUnsavedChanges,
|
wasEdited: hasUnsavedChanges,
|
||||||
})
|
})
|
||||||
@@ -1147,7 +1170,11 @@ export const ComposePost = ({
|
|||||||
style={[a.flex_1, viewStyles]}
|
style={[a.flex_1, viewStyles]}
|
||||||
aria-modal
|
aria-modal
|
||||||
accessibilityViewIsModal>
|
accessibilityViewIsModal>
|
||||||
{viewMode === 'drafts' ? (
|
{isLoadingDraft ? (
|
||||||
|
<View style={[a.flex_1, a.justify_center, a.align_center]}>
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
</View>
|
||||||
|
) : viewMode === 'drafts' ? (
|
||||||
<DraftsView
|
<DraftsView
|
||||||
onSelectDraft={onSelectDraft}
|
onSelectDraft={onSelectDraft}
|
||||||
onBack={() => setViewMode('compose')}
|
onBack={() => setViewMode('compose')}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {msg, plural, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logger} from '#/logger'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
@@ -132,7 +132,7 @@ function DraftListItem({
|
|||||||
}, [item.id, onSelect])
|
}, [item.id, onSelect])
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
logEvent('draft:delete', {
|
logger.metric('draft:delete', {
|
||||||
logContext: 'DraftsList',
|
logContext: 'DraftsList',
|
||||||
draftAgeMs: Date.now() - item.draft.timestamp,
|
draftAgeMs: Date.now() - item.draft.timestamp,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,385 +1,59 @@
|
|||||||
import {useCallback, useEffect, useMemo, useRef} from 'react'
|
import {useCallback, useMemo} from 'react'
|
||||||
import {RichText} from '@atproto/api'
|
|
||||||
|
|
||||||
import {type SelfLabel} from '#/lib/moderation'
|
import {draftsStorage, serializeDraft} from '#/state/drafts'
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {draftsStorage} from '#/state/drafts'
|
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
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
|
|
||||||
|
|
||||||
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)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
* 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 for an existing draft. If not provided, a new ID is generated.
|
||||||
* @param draftId Optional draft ID to load an existing draft. If not provided, a new ID is generated.
|
|
||||||
*/
|
*/
|
||||||
export function useComposerDraft(
|
export function useComposerDraft(draftId?: string) {
|
||||||
composerState: ComposerState,
|
|
||||||
draftId?: string,
|
|
||||||
) {
|
|
||||||
const {currentAccount} = useSession()
|
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
|
const accountDid = currentAccount?.did
|
||||||
|
|
||||||
// Check if draft has any content worth saving
|
const currentDraftId = useMemo(() => draftId || generateDraftId(), [draftId])
|
||||||
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,
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 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(
|
const saveDraft = useCallback(
|
||||||
(state: ComposerState) => {
|
(state: ComposerState) => {
|
||||||
if (!accountDid) return
|
if (!accountDid) return
|
||||||
|
|
||||||
// Clear any pending save
|
const hasContent = state.thread.posts.some(
|
||||||
if (saveTimeoutRef.current) {
|
post =>
|
||||||
clearTimeout(saveTimeoutRef.current)
|
post.richtext.text.trim().length > 0 ||
|
||||||
}
|
post.embed.quote ||
|
||||||
|
post.embed.link ||
|
||||||
|
post.embed.media,
|
||||||
|
)
|
||||||
|
|
||||||
// Debounce the save
|
if (hasContent) {
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
draftsStorage.saveDraft(
|
||||||
performSave(state)
|
accountDid,
|
||||||
}, AUTOSAVE_DELAY_MS)
|
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(() => {
|
const clearDraft = useCallback(() => {
|
||||||
if (!accountDid) return
|
if (!accountDid) return
|
||||||
|
draftsStorage.deleteDraft(accountDid, currentDraftId)
|
||||||
draftsStorage.deleteDraft(accountDid, currentDraftId).then(
|
|
||||||
() => {
|
|
||||||
logger.debug('Composer draft cleared', {draftId: currentDraftId})
|
|
||||||
},
|
|
||||||
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)
|
|
||||||
// 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 {
|
return {
|
||||||
draftId: currentDraftId,
|
draftId: currentDraftId,
|
||||||
loadDraft,
|
saveDraft,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
saveImmediate,
|
|
||||||
isExistingDraft: !!draftId,
|
isExistingDraft: !!draftId,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,80 +2,56 @@ import {useCallback, useEffect, useState} from 'react'
|
|||||||
|
|
||||||
import {type DraftItem, draftsStorage} from '#/state/drafts'
|
import {type DraftItem, draftsStorage} from '#/state/drafts'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {type ComposerDraft} from '#/storage'
|
|
||||||
|
|
||||||
// Re-export DraftItem for consumers
|
|
||||||
export type {DraftItem}
|
export type {DraftItem}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing the drafts list.
|
* 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() {
|
export function useDraftsList() {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const accountDid = currentAccount?.did
|
const accountDid = currentAccount?.did
|
||||||
|
|
||||||
// State for drafts list
|
|
||||||
const [drafts, setDrafts] = useState<DraftItem[]>([])
|
const [drafts, setDrafts] = useState<DraftItem[]>([])
|
||||||
|
|
||||||
// Load drafts on mount and when accountDid changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accountDid) {
|
if (!accountDid) {
|
||||||
setDrafts([])
|
setDrafts([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
draftsStorage.getAllDrafts(accountDid).then(setDrafts)
|
draftsStorage.getAllDrafts(accountDid).then(setDrafts)
|
||||||
}, [accountDid])
|
}, [accountDid])
|
||||||
|
|
||||||
const draftsCount = drafts.length
|
|
||||||
|
|
||||||
// Delete a specific draft
|
|
||||||
const deleteDraft = useCallback(
|
const deleteDraft = useCallback(
|
||||||
(draftId: string) => {
|
async (draftId: string) => {
|
||||||
if (!accountDid) return
|
if (!accountDid) return
|
||||||
|
await draftsStorage.deleteDraft(accountDid, draftId)
|
||||||
draftsStorage.deleteDraft(accountDid, draftId).then(() => {
|
const updated = await draftsStorage.getAllDrafts(accountDid)
|
||||||
// Refresh the list after deletion
|
setDrafts(updated)
|
||||||
draftsStorage.getAllDrafts(accountDid).then(setDrafts)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
[accountDid],
|
[accountDid],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get a specific draft by ID
|
const cleanupOldDrafts = useCallback(async () => {
|
||||||
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(() => {
|
|
||||||
if (!accountDid) return
|
if (!accountDid) return
|
||||||
|
const removedCount = await draftsStorage.cleanupOldDrafts(accountDid)
|
||||||
draftsStorage.cleanupOldDrafts(accountDid).then(removedCount => {
|
if (removedCount > 0) {
|
||||||
if (removedCount > 0) {
|
const updated = await draftsStorage.getAllDrafts(accountDid)
|
||||||
// Refresh the list after cleanup
|
setDrafts(updated)
|
||||||
draftsStorage.getAllDrafts(accountDid).then(setDrafts)
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [accountDid])
|
}, [accountDid])
|
||||||
|
|
||||||
// Refresh the drafts list (useful after saving a new draft)
|
const refreshDrafts = useCallback(async () => {
|
||||||
const refreshDrafts = useCallback(() => {
|
|
||||||
if (!accountDid) return
|
if (!accountDid) return
|
||||||
|
const updated = await draftsStorage.getAllDrafts(accountDid)
|
||||||
draftsStorage.getAllDrafts(accountDid).then(setDrafts)
|
setDrafts(updated)
|
||||||
}, [accountDid])
|
}, [accountDid])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
drafts,
|
drafts,
|
||||||
draftsCount,
|
draftsCount: drafts.length,
|
||||||
deleteDraft,
|
deleteDraft,
|
||||||
getDraft,
|
|
||||||
cleanupOldDrafts,
|
cleanupOldDrafts,
|
||||||
refreshDrafts,
|
refreshDrafts,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user