Save composer's contents to localstorage
This commit is contained in:
committed by
Samuel Newman
parent
43ebb80a5b
commit
99f60bfcc2
@@ -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') {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user