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,
|
type VideoState,
|
||||||
} from './state/video'
|
} from './state/video'
|
||||||
import {type TextInputRef} from './text-input/TextInput.types'
|
import {type TextInputRef} from './text-input/TextInput.types'
|
||||||
|
import {useComposerDraft} from './useComposerDraft'
|
||||||
import {getVideoMetadata} from './videos/pickVideo'
|
import {getVideoMetadata} from './videos/pickVideo'
|
||||||
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
||||||
|
|
||||||
@@ -236,6 +237,48 @@ export const ComposePost = ({
|
|||||||
setReplyToLanguages([])
|
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(
|
const [composerState, composerDispatch] = useReducer(
|
||||||
composerReducer,
|
composerReducer,
|
||||||
{
|
{
|
||||||
@@ -244,10 +287,17 @@ export const ComposePost = ({
|
|||||||
initText,
|
initText,
|
||||||
initMention,
|
initMention,
|
||||||
initInteractionSettings: preferences?.postInteractionSettings,
|
initInteractionSettings: preferences?.postInteractionSettings,
|
||||||
|
initDraft: loadInitialDraft(),
|
||||||
},
|
},
|
||||||
createComposerState,
|
createComposerState,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Draft persistence
|
||||||
|
const {clearDraft} = useComposerDraft(
|
||||||
|
composerState,
|
||||||
|
replyTo ? `reply:${replyTo.uri}` : 'default',
|
||||||
|
)
|
||||||
|
|
||||||
const thread = composerState.thread
|
const thread = composerState.thread
|
||||||
const activePost = thread.posts[composerState.activePostIndex]
|
const activePost = thread.posts[composerState.activePostIndex]
|
||||||
const nextPost: PostDraft | undefined =
|
const nextPost: PostDraft | undefined =
|
||||||
@@ -322,9 +372,10 @@ export const ComposePost = ({
|
|||||||
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||||
|
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
|
clearDraft()
|
||||||
closeComposer()
|
closeComposer()
|
||||||
clearThumbnailCache(queryClient)
|
clearThumbnailCache(queryClient)
|
||||||
}, [closeComposer, queryClient])
|
}, [clearDraft, closeComposer, queryClient])
|
||||||
|
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const viewStyles = useMemo(
|
const viewStyles = useMemo(
|
||||||
@@ -1409,7 +1460,7 @@ function ComposerFooter({
|
|||||||
|
|
||||||
if (assets.length) {
|
if (assets.length) {
|
||||||
if (type === 'image') {
|
if (type === 'image') {
|
||||||
const images: ComposerImage[] = []
|
const composerImages: ComposerImage[] = []
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
assets.map(async image => {
|
assets.map(async image => {
|
||||||
@@ -1419,7 +1470,7 @@ function ComposerFooter({
|
|||||||
height: image.height,
|
height: image.height,
|
||||||
mime: image.mimeType!,
|
mime: image.mimeType!,
|
||||||
})
|
})
|
||||||
images.push(composerImage)
|
composerImages.push(composerImage)
|
||||||
}),
|
}),
|
||||||
).catch(e => {
|
).catch(e => {
|
||||||
logger.error(`createComposerImage failed`, {
|
logger.error(`createComposerImage failed`, {
|
||||||
@@ -1427,7 +1478,7 @@ function ComposerFooter({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onImageAdd(images)
|
onImageAdd(composerImages)
|
||||||
} else if (type === 'video') {
|
} else if (type === 'video') {
|
||||||
onSelectVideo(post.id, assets[0])
|
onSelectVideo(post.id, assets[0])
|
||||||
} else if (type === 'gif') {
|
} else if (type === 'gif') {
|
||||||
|
|||||||
@@ -488,6 +488,7 @@ export function createComposerState({
|
|||||||
initImageUris,
|
initImageUris,
|
||||||
initQuoteUri,
|
initQuoteUri,
|
||||||
initInteractionSettings,
|
initInteractionSettings,
|
||||||
|
initDraft,
|
||||||
}: {
|
}: {
|
||||||
initText: string | undefined
|
initText: string | undefined
|
||||||
initMention: string | undefined
|
initMention: string | undefined
|
||||||
@@ -496,7 +497,77 @@ export function createComposerState({
|
|||||||
initInteractionSettings:
|
initInteractionSettings:
|
||||||
| BskyPreferences['postInteractionSettings']
|
| BskyPreferences['postInteractionSettings']
|
||||||
| undefined
|
| undefined
|
||||||
|
initDraft?: any
|
||||||
}): ComposerState {
|
}): 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
|
let media: ImagesMedia | undefined
|
||||||
if (initImageUris?.length) {
|
if (initImageUris?.length) {
|
||||||
media = {
|
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