Switch to using MMKV instead of localstorage

This commit is contained in:
Alex Benzer
2025-12-07 17:34:22 -08:00
committed by Samuel Newman
parent 2a01065a31
commit 649e1fe1f0
3 changed files with 166 additions and 93 deletions
+47
View File
@@ -66,4 +66,51 @@ export type Account = {
* this device. * this device.
*/ */
birthdateLastUpdatedAt?: string birthdateLastUpdatedAt?: string
/**
* Composer draft, saved when the user has unsent content in the post composer.
* Keyed by context (e.g., 'default', reply URI, etc.)
*/
composerDraft?: {
[context: string]: ComposerDraft
}
}
export type ComposerDraft = {
version: 1
timestamp: number
thread: {
posts: Array<{
id: string
text: string
labels: string[]
embed: {
quoteUri?: string
linkUri?: string
images?: Array<{
alt: string
path: string
width: number
height: number
mime: string
}>
gif?: {
id: string
media_formats: unknown
title: string
alt: string
}
video?: {
blobRef: unknown
width: number
height: number
mimeType: string
altText: string
}
}
}>
postgate: unknown
threadgate: unknown
}
activePostIndex: number
} }
+23 -13
View File
@@ -130,6 +130,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 {account} from '#/storage'
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
import {PostLanguageSelect} from './select-language/PostLanguageSelect' import {PostLanguageSelect} from './select-language/PostLanguageSelect'
import { import {
@@ -238,12 +239,10 @@ export const ComposePost = ({
} }
// Check for draft before initializing composer // Check for draft before initializing composer
const draftKey = currentAccount const draftContext = replyTo ? `reply:${replyTo.uri}` : 'default'
? `composer-draft:${currentAccount.did}:${replyTo ? `reply:${replyTo.uri}` : 'default'}`
: null
const loadInitialDraft = useCallback(() => { const loadInitialDraft = useCallback(() => {
if (!isWeb || !draftKey) return null if (!currentAccount) return null
const hasInitialContent = const hasInitialContent =
initText || initText ||
@@ -255,25 +254,36 @@ export const ComposePost = ({
if (hasInitialContent) return null if (hasInitialContent) return null
try { try {
const stored = localStorage.getItem(draftKey) const allDrafts = account.get([currentAccount.did, 'composerDraft'])
if (!stored) return null if (!allDrafts) return null
const parsed = JSON.parse(stored) const draft = allDrafts[draftContext]
if (parsed.version !== 1) return null if (!draft) return null
if (draft.version !== 1) return null
// Check age // Check age
const age = Date.now() - parsed.timestamp const age = Date.now() - draft.timestamp
if (age > 7 * 24 * 60 * 60 * 1000) { if (age > 7 * 24 * 60 * 60 * 1000) {
localStorage.removeItem(draftKey) // Remove old draft
const remainingDrafts = Object.fromEntries(
Object.entries(allDrafts).filter(([key]) => key !== draftContext),
)
if (Object.keys(remainingDrafts).length > 0) {
account.set([currentAccount.did, 'composerDraft'], remainingDrafts)
} else {
account.remove([currentAccount.did, 'composerDraft'])
}
return null return null
} }
logger.info('Composer: loading initial draft', { logger.info('Composer: loading initial draft', {
textLength: parsed.thread.posts[0]?.text.length || 0, textLength: draft.thread.posts[0]?.text.length || 0,
}) })
// Construct video URLs from blobRefs if we have videos in the draft // Construct video URLs from blobRefs if we have videos in the draft
if (currentAccount && parsed.thread?.posts) { const parsed = JSON.parse(JSON.stringify(draft)) // Deep clone
if (parsed.thread?.posts) {
parsed.thread.posts = parsed.thread.posts.map((post: any) => { parsed.thread.posts = parsed.thread.posts.map((post: any) => {
if (post.embed?.video?.blobRef?.ref?.$link) { if (post.embed?.video?.blobRef?.ref?.$link) {
const cid = post.embed.video.blobRef.ref.$link const cid = post.embed.video.blobRef.ref.$link
@@ -291,7 +301,7 @@ export const ComposePost = ({
} }
}, [ }, [
currentAccount, currentAccount,
draftKey, draftContext,
initText, initText,
initMention, initMention,
initImageUris, initImageUris,
+96 -80
View File
@@ -3,12 +3,25 @@ import {RichText} from '@atproto/api'
import {type SelfLabel} from '#/lib/moderation' import {type SelfLabel} from '#/lib/moderation'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {account, type ComposerDraft} from '#/storage'
import {type ComposerState} from './state/composer' import {type ComposerState} from './state/composer'
const DRAFT_KEY_PREFIX = 'composer-draft'
const AUTOSAVE_DELAY_MS = 1000 // 1 second debounce const AUTOSAVE_DELAY_MS = 1000 // 1 second debounce
const MAX_DRAFT_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
function removeDraftContext(
allDrafts: Record<string, ComposerDraft>,
contextToRemove: string,
): Record<string, ComposerDraft> | null {
const result: Record<string, ComposerDraft> = {}
for (const key of Object.keys(allDrafts)) {
if (key !== contextToRemove) {
result[key] = allDrafts[key]
}
}
return Object.keys(result).length > 0 ? result : null
}
type SerializedImage = { type SerializedImage = {
alt: string alt: string
@@ -19,44 +32,14 @@ type SerializedImage = {
} }
type SerializedVideo = { type SerializedVideo = {
blobRef: any // BlobRef from @atproto/api (server reference) blobRef: unknown // BlobRef from @atproto/api (server reference)
width: number width: number
height: number height: number
mimeType: string mimeType: string
altText: string altText: string
} }
type SerializedDraft = { type SerializedDraft = ComposerDraft
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
}
video?: SerializedVideo
}
}>
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 { function serializeDraft(state: ComposerState): SerializedDraft {
return { return {
@@ -67,7 +50,7 @@ function serializeDraft(state: ComposerState): SerializedDraft {
const media = post.embed.media const media = post.embed.media
let images: SerializedImage[] | undefined let images: SerializedImage[] | undefined
let gif: let gif:
| {id: string; media_formats: any; title: string; alt: string} | {id: string; media_formats: unknown; title: string; alt: string}
| undefined | undefined
let video: SerializedVideo | undefined let video: SerializedVideo | undefined
@@ -211,7 +194,7 @@ function deserializeDraft(data: SerializedDraft): Partial<ComposerState> {
id: post.id, id: post.id,
richtext: rt, richtext: rt,
shortenedGraphemeLength: rt.graphemeLength, shortenedGraphemeLength: rt.graphemeLength,
labels: post.labels, labels: post.labels as SelfLabel[],
embed: { embed: {
quote: post.embed.quoteUri quote: post.embed.quoteUri
? {type: 'link' as const, uri: post.embed.quoteUri} ? {type: 'link' as const, uri: post.embed.quoteUri}
@@ -223,8 +206,9 @@ function deserializeDraft(data: SerializedDraft): Partial<ComposerState> {
}, },
} }
}), }),
postgate: data.thread.postgate, // These are already properly typed when saved, cast back to their types
threadgate: data.thread.threadgate, postgate: data.thread.postgate as any,
threadgate: data.thread.threadgate as any,
}, },
activePostIndex: data.activePostIndex, activePostIndex: data.activePostIndex,
} }
@@ -235,11 +219,11 @@ export function useComposerDraft(
context: string = 'default', context: string = 'default',
) { ) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined) const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
)
const draftKey = currentAccount const accountDid = currentAccount?.did
? getDraftKey(currentAccount.did, context)
: null
// Check if draft has any content worth saving // Check if draft has any content worth saving
const hasContent = useCallback((state: ComposerState) => { const hasContent = useCallback((state: ComposerState) => {
@@ -252,10 +236,10 @@ export function useComposerDraft(
) )
}, []) }, [])
// Save draft to localStorage (debounced) // Save draft to storage (debounced)
const saveDraft = useCallback( const saveDraft = useCallback(
(state: ComposerState) => { (state: ComposerState) => {
if (!isWeb || !draftKey) return if (!accountDid) return
// Clear any pending save // Clear any pending save
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) {
@@ -265,25 +249,36 @@ export function useComposerDraft(
// Debounce the save // Debounce the save
saveTimeoutRef.current = setTimeout(() => { saveTimeoutRef.current = setTimeout(() => {
try { try {
const allDrafts = account.get([accountDid, 'composerDraft']) ?? {}
if (hasContent(state)) { if (hasContent(state)) {
const serialized = serializeDraft(state) const serialized = serializeDraft(state)
logger.debug('Draft serialized successfully', { logger.debug('Draft serialized successfully', {
hasPosts: serialized.thread.posts.length > 0, hasPosts: serialized.thread.posts.length > 0,
hasVideo: !!serialized.thread.posts[0]?.embed?.video, hasVideo: !!serialized.thread.posts[0]?.embed?.video,
}) })
const jsonString = JSON.stringify(serialized)
logger.debug('Draft JSON stringified', { // Update the draft for this context
size: jsonString.length, account.set([accountDid, 'composerDraft'], {
...allDrafts,
[context]: serialized,
}) })
localStorage.setItem(draftKey, jsonString)
logger.info('Composer draft saved', { logger.info('Composer draft saved', {
key: draftKey, context,
textLength: state.thread.posts[0]?.richtext.text.length || 0, textLength: state.thread.posts[0]?.richtext.text.length || 0,
}) })
} else { } else {
// If no content, remove any existing draft // If no content, remove this context's draft
localStorage.removeItem(draftKey) if (allDrafts[context]) {
logger.debug('Empty draft removed', {key: draftKey}) const remainingDrafts = removeDraftContext(allDrafts, context)
if (remainingDrafts) {
account.set([accountDid, 'composerDraft'], remainingDrafts)
} else {
account.remove([accountDid, 'composerDraft'])
}
logger.debug('Empty draft removed', {context})
}
} }
} catch (e) { } catch (e) {
logger.error('Failed to save composer draft', { logger.error('Failed to save composer draft', {
@@ -294,63 +289,83 @@ export function useComposerDraft(
} }
}, AUTOSAVE_DELAY_MS) }, AUTOSAVE_DELAY_MS)
}, },
[draftKey, hasContent], [accountDid, context, hasContent],
) )
// Load draft from localStorage // Load draft from storage
const loadDraft = useCallback((): Partial<ComposerState> | null => { const loadDraft = useCallback((): Partial<ComposerState> | null => {
if (!isWeb || !draftKey) return null if (!accountDid) return null
try { try {
const stored = localStorage.getItem(draftKey) const allDrafts = account.get([accountDid, 'composerDraft'])
if (!stored) return null if (!allDrafts) return null
const parsed: SerializedDraft = JSON.parse(stored) const draft = allDrafts[context]
if (!draft) return null
// Check version compatibility // Check version compatibility
if (parsed.version !== 1) { if (draft.version !== 1) {
logger.warn('Incompatible draft version, discarding', { logger.warn('Incompatible draft version, discarding', {
version: parsed.version, version: draft.version,
}) })
localStorage.removeItem(draftKey) // Remove this context's draft
const remainingDrafts = removeDraftContext(allDrafts, context)
if (remainingDrafts) {
account.set([accountDid, 'composerDraft'], remainingDrafts)
} else {
account.remove([accountDid, 'composerDraft'])
}
return null return null
} }
// Check if draft is too old (e.g., more than 7 days) // Check if draft is too old
const age = Date.now() - parsed.timestamp const age = Date.now() - draft.timestamp
const MAX_AGE = 7 * 24 * 60 * 60 * 1000 // 7 days if (age > MAX_DRAFT_AGE_MS) {
if (age > MAX_AGE) {
logger.debug('Draft too old, discarding', {age}) logger.debug('Draft too old, discarding', {age})
localStorage.removeItem(draftKey) // Remove this context's draft
const remainingDrafts = removeDraftContext(allDrafts, context)
if (remainingDrafts) {
account.set([accountDid, 'composerDraft'], remainingDrafts)
} else {
account.remove([accountDid, 'composerDraft'])
}
return null return null
} }
logger.info('Composer draft loaded', { logger.info('Composer draft loaded', {
key: draftKey, context,
textLength: parsed.thread.posts[0]?.text.length || 0, textLength: draft.thread.posts[0]?.text.length || 0,
}) })
return deserializeDraft(parsed) return deserializeDraft(draft)
} catch (e) { } catch (e) {
logger.error('Failed to load composer draft', {error: e}) logger.error('Failed to load composer draft', {error: e})
// Remove corrupted draft // Remove corrupted drafts
try { try {
localStorage.removeItem(draftKey) account.remove([accountDid, 'composerDraft'])
} catch {} } catch {}
return null return null
} }
}, [draftKey]) }, [accountDid, context])
// Clear draft from localStorage // Clear draft from storage
const clearDraft = useCallback(() => { const clearDraft = useCallback(() => {
if (!isWeb || !draftKey) return if (!accountDid) return
try { try {
localStorage.removeItem(draftKey) const allDrafts = account.get([accountDid, 'composerDraft'])
logger.debug('Composer draft cleared', {key: draftKey}) if (allDrafts && allDrafts[context]) {
const remainingDrafts = removeDraftContext(allDrafts, context)
if (remainingDrafts) {
account.set([accountDid, 'composerDraft'], remainingDrafts)
} else {
account.remove([accountDid, 'composerDraft'])
}
logger.debug('Composer draft cleared', {context})
}
} catch (e) { } catch (e) {
logger.error('Failed to clear composer draft', {error: e}) logger.error('Failed to clear composer draft', {error: e})
} }
}, [draftKey]) }, [accountDid, context])
// Auto-save on state changes // Auto-save on state changes
useEffect(() => { useEffect(() => {
@@ -367,13 +382,14 @@ export function useComposerDraft(
}, []) }, [])
const checkHasStoredDraft = useCallback(() => { const checkHasStoredDraft = useCallback(() => {
if (!isWeb || !draftKey) return false if (!accountDid) return false
try { try {
return localStorage.getItem(draftKey) !== null const allDrafts = account.get([accountDid, 'composerDraft'])
return allDrafts ? !!allDrafts[context] : false
} catch { } catch {
return false return false
} }
}, [draftKey]) }, [accountDid, context])
return { return {
loadDraft, loadDraft,