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.
*/
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 Toast from '#/components/Toast'
import {Text as NewText} from '#/components/Typography'
import {account} from '#/storage'
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
import {PostLanguageSelect} from './select-language/PostLanguageSelect'
import {
@@ -238,12 +239,10 @@ export const ComposePost = ({
}
// Check for draft before initializing composer
const draftKey = currentAccount
? `composer-draft:${currentAccount.did}:${replyTo ? `reply:${replyTo.uri}` : 'default'}`
: null
const draftContext = replyTo ? `reply:${replyTo.uri}` : 'default'
const loadInitialDraft = useCallback(() => {
if (!isWeb || !draftKey) return null
if (!currentAccount) return null
const hasInitialContent =
initText ||
@@ -255,25 +254,36 @@ export const ComposePost = ({
if (hasInitialContent) return null
try {
const stored = localStorage.getItem(draftKey)
if (!stored) return null
const allDrafts = account.get([currentAccount.did, 'composerDraft'])
if (!allDrafts) return null
const parsed = JSON.parse(stored)
if (parsed.version !== 1) return null
const draft = allDrafts[draftContext]
if (!draft) return null
if (draft.version !== 1) return null
// Check age
const age = Date.now() - parsed.timestamp
const age = Date.now() - draft.timestamp
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
}
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
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) => {
if (post.embed?.video?.blobRef?.ref?.$link) {
const cid = post.embed.video.blobRef.ref.$link
@@ -291,7 +301,7 @@ export const ComposePost = ({
}
}, [
currentAccount,
draftKey,
draftContext,
initText,
initMention,
initImageUris,
+96 -80
View File
@@ -3,12 +3,25 @@ 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 {account, type ComposerDraft} from '#/storage'
import {type ComposerState} from './state/composer'
const DRAFT_KEY_PREFIX = 'composer-draft'
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 = {
alt: string
@@ -19,44 +32,14 @@ type SerializedImage = {
}
type SerializedVideo = {
blobRef: any // BlobRef from @atproto/api (server reference)
blobRef: unknown // BlobRef from @atproto/api (server reference)
width: number
height: number
mimeType: string
altText: 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
}
video?: SerializedVideo
}
}>
postgate: any
threadgate: any
}
activePostIndex: number
}
function getDraftKey(accountDid: string, context: string = 'default'): string {
return `${DRAFT_KEY_PREFIX}:${accountDid}:${context}`
}
type SerializedDraft = ComposerDraft
function serializeDraft(state: ComposerState): SerializedDraft {
return {
@@ -67,7 +50,7 @@ function serializeDraft(state: ComposerState): SerializedDraft {
const media = post.embed.media
let images: SerializedImage[] | undefined
let gif:
| {id: string; media_formats: any; title: string; alt: string}
| {id: string; media_formats: unknown; title: string; alt: string}
| undefined
let video: SerializedVideo | undefined
@@ -211,7 +194,7 @@ function deserializeDraft(data: SerializedDraft): Partial<ComposerState> {
id: post.id,
richtext: rt,
shortenedGraphemeLength: rt.graphemeLength,
labels: post.labels,
labels: post.labels as SelfLabel[],
embed: {
quote: 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,
threadgate: data.thread.threadgate,
// 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,
}
@@ -235,11 +219,11 @@ export function useComposerDraft(
context: string = 'default',
) {
const {currentAccount} = useSession()
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
)
const draftKey = currentAccount
? getDraftKey(currentAccount.did, context)
: null
const accountDid = currentAccount?.did
// Check if draft has any content worth saving
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(
(state: ComposerState) => {
if (!isWeb || !draftKey) return
if (!accountDid) return
// Clear any pending save
if (saveTimeoutRef.current) {
@@ -265,25 +249,36 @@ export function useComposerDraft(
// Debounce the save
saveTimeoutRef.current = setTimeout(() => {
try {
const allDrafts = account.get([accountDid, 'composerDraft']) ?? {}
if (hasContent(state)) {
const serialized = serializeDraft(state)
logger.debug('Draft serialized successfully', {
hasPosts: serialized.thread.posts.length > 0,
hasVideo: !!serialized.thread.posts[0]?.embed?.video,
})
const jsonString = JSON.stringify(serialized)
logger.debug('Draft JSON stringified', {
size: jsonString.length,
// Update the draft for this context
account.set([accountDid, 'composerDraft'], {
...allDrafts,
[context]: serialized,
})
localStorage.setItem(draftKey, jsonString)
logger.info('Composer draft saved', {
key: draftKey,
context,
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})
// If no content, remove this context's draft
if (allDrafts[context]) {
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) {
logger.error('Failed to save composer draft', {
@@ -294,63 +289,83 @@ export function useComposerDraft(
}
}, AUTOSAVE_DELAY_MS)
},
[draftKey, hasContent],
[accountDid, context, hasContent],
)
// Load draft from localStorage
// Load draft from storage
const loadDraft = useCallback((): Partial<ComposerState> | null => {
if (!isWeb || !draftKey) return null
if (!accountDid) return null
try {
const stored = localStorage.getItem(draftKey)
if (!stored) return null
const allDrafts = account.get([accountDid, 'composerDraft'])
if (!allDrafts) return null
const parsed: SerializedDraft = JSON.parse(stored)
const draft = allDrafts[context]
if (!draft) return null
// Check version compatibility
if (parsed.version !== 1) {
if (draft.version !== 1) {
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
}
// 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) {
// Check if draft is too old
const age = Date.now() - draft.timestamp
if (age > MAX_DRAFT_AGE_MS) {
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
}
logger.info('Composer draft loaded', {
key: draftKey,
textLength: parsed.thread.posts[0]?.text.length || 0,
context,
textLength: draft.thread.posts[0]?.text.length || 0,
})
return deserializeDraft(parsed)
return deserializeDraft(draft)
} catch (e) {
logger.error('Failed to load composer draft', {error: e})
// Remove corrupted draft
// Remove corrupted drafts
try {
localStorage.removeItem(draftKey)
account.remove([accountDid, 'composerDraft'])
} catch {}
return null
}
}, [draftKey])
}, [accountDid, context])
// Clear draft from localStorage
// Clear draft from storage
const clearDraft = useCallback(() => {
if (!isWeb || !draftKey) return
if (!accountDid) return
try {
localStorage.removeItem(draftKey)
logger.debug('Composer draft cleared', {key: draftKey})
const allDrafts = account.get([accountDid, 'composerDraft'])
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) {
logger.error('Failed to clear composer draft', {error: e})
}
}, [draftKey])
}, [accountDid, context])
// Auto-save on state changes
useEffect(() => {
@@ -367,13 +382,14 @@ export function useComposerDraft(
}, [])
const checkHasStoredDraft = useCallback(() => {
if (!isWeb || !draftKey) return false
if (!accountDid) return false
try {
return localStorage.getItem(draftKey) !== null
const allDrafts = account.get([accountDid, 'composerDraft'])
return allDrafts ? !!allDrafts[context] : false
} catch {
return false
}
}, [draftKey])
}, [accountDid, context])
return {
loadDraft,