Switch to using MMKV instead of localstorage
This commit is contained in:
committed by
Samuel Newman
parent
2a01065a31
commit
649e1fe1f0
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user