Save composer's contents to localstorage

This commit is contained in:
Alex Benzer
2025-12-06 08:57:40 -08:00
committed by Samuel Newman
parent 43ebb80a5b
commit 99f60bfcc2
3 changed files with 427 additions and 4 deletions
+55 -4
View File
@@ -154,6 +154,7 @@ import {
type VideoState,
} from './state/video'
import {type TextInputRef} from './text-input/TextInput.types'
import {useComposerDraft} from './useComposerDraft'
import {getVideoMetadata} from './videos/pickVideo'
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
@@ -236,6 +237,48 @@ export const ComposePost = ({
setReplyToLanguages([])
}
// Check for draft before initializing composer
const draftKey = currentAccount
? `composer-draft:${currentAccount.did}:${replyTo ? `reply:${replyTo.uri}` : 'default'}`
: null
const loadInitialDraft = useCallback(() => {
if (!isWeb || !draftKey) return null
const hasInitialContent =
initText ||
initMention ||
initImageUris?.length ||
initQuote ||
initVideoUri
if (hasInitialContent) return null
try {
const stored = localStorage.getItem(draftKey)
if (!stored) return null
const parsed = JSON.parse(stored)
if (parsed.version !== 1) return null
// Check age
const age = Date.now() - parsed.timestamp
if (age > 7 * 24 * 60 * 60 * 1000) {
localStorage.removeItem(draftKey)
return null
}
logger.info('Composer: loading initial draft', {
textLength: parsed.thread.posts[0]?.text.length || 0,
})
return parsed
} catch (e) {
logger.error('Failed to load initial draft', {error: e})
return null
}
}, [draftKey, initText, initMention, initImageUris, initQuote, initVideoUri])
const [composerState, composerDispatch] = useReducer(
composerReducer,
{
@@ -244,10 +287,17 @@ export const ComposePost = ({
initText,
initMention,
initInteractionSettings: preferences?.postInteractionSettings,
initDraft: loadInitialDraft(),
},
createComposerState,
)
// Draft persistence
const {clearDraft} = useComposerDraft(
composerState,
replyTo ? `reply:${replyTo.uri}` : 'default',
)
const thread = composerState.thread
const activePost = thread.posts[composerState.activePostIndex]
const nextPost: PostDraft | undefined =
@@ -322,9 +372,10 @@ export const ComposePost = ({
const [publishOnUpload, setPublishOnUpload] = useState(false)
const onClose = useCallback(() => {
clearDraft()
closeComposer()
clearThumbnailCache(queryClient)
}, [closeComposer, queryClient])
}, [clearDraft, closeComposer, queryClient])
const insets = useSafeAreaInsets()
const viewStyles = useMemo(
@@ -1409,7 +1460,7 @@ function ComposerFooter({
if (assets.length) {
if (type === 'image') {
const images: ComposerImage[] = []
const composerImages: ComposerImage[] = []
await Promise.all(
assets.map(async image => {
@@ -1419,7 +1470,7 @@ function ComposerFooter({
height: image.height,
mime: image.mimeType!,
})
images.push(composerImage)
composerImages.push(composerImage)
}),
).catch(e => {
logger.error(`createComposerImage failed`, {
@@ -1427,7 +1478,7 @@ function ComposerFooter({
})
})
onImageAdd(images)
onImageAdd(composerImages)
} else if (type === 'video') {
onSelectVideo(post.id, assets[0])
} else if (type === 'gif') {
+71
View File
@@ -488,6 +488,7 @@ export function createComposerState({
initImageUris,
initQuoteUri,
initInteractionSettings,
initDraft,
}: {
initText: string | undefined
initMention: string | undefined
@@ -496,7 +497,77 @@ export function createComposerState({
initInteractionSettings:
| BskyPreferences['postInteractionSettings']
| undefined
initDraft?: any
}): ComposerState {
// If we have a draft, use it instead of init values
if (initDraft?.thread?.posts?.[0]) {
return {
activePostIndex: initDraft.activePostIndex || 0,
mutableNeedsFocusActive: false,
thread: {
posts: initDraft.thread.posts.map((post: any) => {
let media: ImagesMedia | GifMedia | undefined
if (post.embed?.images?.length) {
media = {
type: 'images',
images: post.embed.images.map((img: any) => ({
alt: img.alt,
source: {
id: `restored-${Date.now()}-${Math.random()}`,
path: img.path,
width: img.width,
height: img.height,
mime: img.mime,
},
})),
}
} else if (post.embed?.gif) {
media = {
type: 'gif',
gif: post.embed.gif,
alt: post.embed.gif.alt || '',
}
}
return {
id: nanoid(),
richtext: new RichText({text: post.text || ''}),
shortenedGraphemeLength: getShortenedLength(
new RichText({text: post.text || ''}),
),
labels: post.labels || [],
embed: {
quote: post.embed?.quoteUri
? {type: 'link', uri: post.embed.quoteUri}
: undefined,
media,
link: post.embed?.linkUri
? {type: 'link', uri: post.embed.linkUri}
: undefined,
},
}
}),
postgate:
initDraft.thread.postgate ||
createPostgateRecord({
post: '',
embeddingRules:
initInteractionSettings?.postgateEmbeddingRules || [],
}),
threadgate:
initDraft.thread.threadgate ||
threadgateRecordToAllowUISetting({
$type: 'app.bsky.feed.threadgate',
post: '',
createdAt: new Date().toString(),
allow: initInteractionSettings?.threadgateAllowRules,
}),
},
}
}
// Otherwise use normal initialization
let media: ImagesMedia | undefined
if (initImageUris?.length) {
media = {
+301
View File
@@ -0,0 +1,301 @@
import {useCallback, useEffect, useRef} from 'react'
import {RichText} from '@atproto/api'
import {type SelfLabel} from '#/lib/moderation'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useSession} from '#/state/session'
import {type ComposerState} from './state/composer'
const DRAFT_KEY_PREFIX = 'composer-draft'
const AUTOSAVE_DELAY_MS = 1000 // 1 second debounce
type SerializedImage = {
alt: string
path: string
width: number
height: number
mime: string
}
type SerializedDraft = {
version: 1
timestamp: number
thread: {
posts: Array<{
id: string
text: string
labels: SelfLabel[]
embed: {
quoteUri?: string
linkUri?: string
// Media
images?: SerializedImage[]
gif?: {
id: string
media_formats: any
title: string
alt: string
}
// Note: Videos are complex with compression/upload state
// For now we skip videos in drafts
}
}>
postgate: any
threadgate: any
}
activePostIndex: number
}
function getDraftKey(accountDid: string, context: string = 'default'): string {
return `${DRAFT_KEY_PREFIX}:${accountDid}:${context}`
}
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: any; title: string; alt: string}
| 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,
}
}
// Videos are skipped for now due to complexity
return {
id: post.id,
text: post.richtext.text,
labels: post.labels,
embed: {
quoteUri: post.embed.quote?.uri,
linkUri: post.embed.link?.uri,
images,
gif,
},
}
}),
postgate: state.thread.postgate,
threadgate: state.thread.threadgate,
},
activePostIndex: state.activePostIndex,
}
}
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}
| 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,
}
}
return {
id: post.id,
richtext: new RichText({text: post.text}),
shortenedGraphemeLength: post.text.length, // Will be recalculated
labels: post.labels,
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,
},
}
}),
postgate: data.thread.postgate,
threadgate: data.thread.threadgate,
},
activePostIndex: data.activePostIndex,
}
}
export function useComposerDraft(
composerState: ComposerState,
context: string = 'default',
) {
const {currentAccount} = useSession()
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
const draftKey = currentAccount
? getDraftKey(currentAccount.did, context)
: null
// 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,
)
}, [])
// Save draft to localStorage (debounced)
const saveDraft = useCallback(
(state: ComposerState) => {
if (!isWeb || !draftKey) return
// Clear any pending save
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
// Debounce the save
saveTimeoutRef.current = setTimeout(() => {
try {
if (hasContent(state)) {
const serialized = serializeDraft(state)
localStorage.setItem(draftKey, JSON.stringify(serialized))
logger.info('Composer draft saved', {
key: draftKey,
textLength: state.thread.posts[0]?.richtext.text.length || 0,
})
} else {
// If no content, remove any existing draft
localStorage.removeItem(draftKey)
logger.debug('Empty draft removed', {key: draftKey})
}
} catch (e) {
logger.error('Failed to save composer draft', {error: e})
}
}, AUTOSAVE_DELAY_MS)
},
[draftKey, hasContent],
)
// Load draft from localStorage
const loadDraft = useCallback((): Partial<ComposerState> | null => {
if (!isWeb || !draftKey) return null
try {
const stored = localStorage.getItem(draftKey)
if (!stored) return null
const parsed: SerializedDraft = JSON.parse(stored)
// Check version compatibility
if (parsed.version !== 1) {
logger.warn('Incompatible draft version, discarding', {
version: parsed.version,
})
localStorage.removeItem(draftKey)
return null
}
// Check if draft is too old (e.g., more than 7 days)
const age = Date.now() - parsed.timestamp
const MAX_AGE = 7 * 24 * 60 * 60 * 1000 // 7 days
if (age > MAX_AGE) {
logger.debug('Draft too old, discarding', {age})
localStorage.removeItem(draftKey)
return null
}
logger.info('Composer draft loaded', {
key: draftKey,
textLength: parsed.thread.posts[0]?.text.length || 0,
})
return deserializeDraft(parsed)
} catch (e) {
logger.error('Failed to load composer draft', {error: e})
// Remove corrupted draft
try {
localStorage.removeItem(draftKey)
} catch {}
return null
}
}, [draftKey])
// Clear draft from localStorage
const clearDraft = useCallback(() => {
if (!isWeb || !draftKey) return
try {
localStorage.removeItem(draftKey)
logger.debug('Composer draft cleared', {key: draftKey})
} catch (e) {
logger.error('Failed to clear composer draft', {error: e})
}
}, [draftKey])
// Auto-save on state changes
useEffect(() => {
saveDraft(composerState)
}, [composerState, saveDraft])
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
}
}, [])
const checkHasStoredDraft = useCallback(() => {
if (!isWeb || !draftKey) return false
try {
return localStorage.getItem(draftKey) !== null
} catch {
return false
}
}, [draftKey])
return {
loadDraft,
clearDraft,
hasStoredDraft: checkHasStoredDraft,
}
}