| ... | @@ -116,9 +116,6 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { | ... | @@ -116,9 +116,6 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { |
| 116 | projectDraftThreadIdByProjectId: {}, | 116 | projectDraftThreadIdByProjectId: {}, |
| 117 | }; | 117 | }; |
| 118 | | 118 | |
| 119 | let persistedDraftStateCache: PersistedComposerDraftStoreState = EMPTY_PERSISTED_DRAFT_STORE_STATE; | | |
| 120 | let persistedDraftStorageChars: number | null = null; | | |
| 121 | | | |
| 122 | const EMPTY_IMAGES: ComposerImageAttachment[] = []; | 119 | const EMPTY_IMAGES: ComposerImageAttachment[] = []; |
| 123 | const EMPTY_IDS: string[] = []; | 120 | const EMPTY_IDS: string[] = []; |
| 124 | const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; | 121 | const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; |
| ... | @@ -337,17 +334,25 @@ function parsePersistedDraftStateRaw(raw: string | null): PersistedComposerDraft | ... | @@ -337,17 +334,25 @@ function parsePersistedDraftStateRaw(raw: string | null): PersistedComposerDraft |
| 337 | } | 334 | } |
| 338 | } | 335 | } |
| 339 | | 336 | |
| 340 | function readPersistedAttachmentIdsFromCache(threadId: ThreadId): string[] { | 337 | function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { |
| 341 | if (threadId.length === 0) { | 338 | if (threadId.length === 0) { |
| 342 | return []; | 339 | return []; |
| 343 | } | 340 | } |
| 344 | return (persistedDraftStateCache.draftsByThreadId[threadId]?.attachments ?? []).map( | 341 | try { |
| 345 | (attachment) => attachment.id, | 342 | const raw = localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY); |
| 346 | ); | 343 | const persisted = parsePersistedDraftStateRaw(raw); |
| | 344 | return (persisted.draftsByThreadId[threadId]?.attachments ?? []).map( |
| | 345 | (attachment) => attachment.id, |
| | 346 | ); |
| | 347 | } catch { |
| | 348 | return []; |
| | 349 | } |
| 347 | } | 350 | } |
| 348 | | 351 | |
| 349 | function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null { | 352 | function hydreatePersistedComposerImageAttachment( |
| 350 | const [rawHeader, payload] = dataUrl.split(",", 2); | 353 | attachment: PersistedComposerImageAttachment, |
| | 354 | ): File | null { |
| | 355 | const [rawHeader, payload] = attachment.dataUrl.split(",", 2); |
| 351 | const header = rawHeader ?? ""; | 356 | const header = rawHeader ?? ""; |
| 352 | if (!payload) { | 357 | if (!payload) { |
| 353 | return null; | 358 | return null; |
| ... | @@ -359,15 +364,17 @@ function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | ... | @@ -359,15 +364,17 @@ function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File |
| 359 | const inferredMimeType = | 364 | const inferredMimeType = |
| 360 | header.startsWith("data:") && header.includes(";") | 365 | header.startsWith("data:") && header.includes(";") |
| 361 | ? header.slice("data:".length, header.indexOf(";")) | 366 | ? header.slice("data:".length, header.indexOf(";")) |
| 362 | : mimeType; | 367 | : attachment.mimeType; |
| 363 | return new File([decodedText], name, { type: inferredMimeType || mimeType }); | 368 | return new File([decodedText], attachment.name, { |
| | 369 | type: inferredMimeType || attachment.mimeType, |
| | 370 | }); |
| 364 | } | 371 | } |
| 365 | const binary = atob(payload); | 372 | const binary = atob(payload); |
| 366 | const bytes = new Uint8Array(binary.length); | 373 | const bytes = new Uint8Array(binary.length); |
| 367 | for (let index = 0; index < binary.length; index += 1) { | 374 | for (let index = 0; index < binary.length; index += 1) { |
| 368 | bytes[index] = binary.charCodeAt(index); | 375 | bytes[index] = binary.charCodeAt(index); |
| 369 | } | 376 | } |
| 370 | return new File([bytes], name, { type: mimeType }); | 377 | return new File([bytes], attachment.name, { type: attachment.mimeType }); |
| 371 | } catch { | 378 | } catch { |
| 372 | return null; | 379 | return null; |
| 373 | } | 380 | } |
| ... | @@ -377,10 +384,9 @@ function hydrateImagesFromPersisted( | ... | @@ -377,10 +384,9 @@ function hydrateImagesFromPersisted( |
| 377 | attachments: PersistedComposerImageAttachment[], | 384 | attachments: PersistedComposerImageAttachment[], |
| 378 | ): ComposerImageAttachment[] { | 385 | ): ComposerImageAttachment[] { |
| 379 | return attachments.flatMap((attachment) => { | 386 | return attachments.flatMap((attachment) => { |
| 380 | const file = fileFromDataUrl(attachment.dataUrl, attachment.name, attachment.mimeType); | 387 | const file = hydreatePersistedComposerImageAttachment(attachment); |
| 381 | if (!file) { | 388 | if (!file) return []; |
| 382 | return []; | 389 | |
| 383 | } | | |
| 384 | return [ | 390 | return [ |
| 385 | { | 391 | { |
| 386 | type: "image" as const, | 392 | type: "image" as const, |
| ... | @@ -409,52 +415,6 @@ function toHydratedThreadDraft( | ... | @@ -409,52 +415,6 @@ function toHydratedThreadDraft( |
| 409 | }; | 415 | }; |
| 410 | } | 416 | } |
| 411 | | 417 | |
| 412 | const composerDraftStorage = createJSONStorage(() => ({ | | |
| 413 | getItem: (name: string): string | null => { | | |
| 414 | if (typeof window === "undefined") { | | |
| 415 | persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE; | | |
| 416 | persistedDraftStorageChars = null; | | |
| 417 | return null; | | |
| 418 | } | | |
| 419 | try { | | |
| 420 | const raw = window.localStorage.getItem(name); | | |
| 421 | persistedDraftStateCache = parsePersistedDraftStateRaw(raw); | | |
| 422 | persistedDraftStorageChars = raw?.length ?? 0; | | |
| 423 | return raw; | | |
| 424 | } catch { | | |
| 425 | persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE; | | |
| 426 | persistedDraftStorageChars = null; | | |
| 427 | return null; | | |
| 428 | } | | |
| 429 | }, | | |
| 430 | setItem: (name: string, value: string): void => { | | |
| 431 | if (typeof window === "undefined") { | | |
| 432 | return; | | |
| 433 | } | | |
| 434 | try { | | |
| 435 | window.localStorage.setItem(name, value); | | |
| 436 | persistedDraftStateCache = parsePersistedDraftStateRaw(value); | | |
| 437 | persistedDraftStorageChars = value.length; | | |
| 438 | } catch { | | |
| 439 | // Quota/storage errors are expected for large drafts. Keep last successful snapshot. | | |
| 440 | } | | |
| 441 | }, | | |
| 442 | removeItem: (name: string): void => { | | |
| 443 | if (typeof window === "undefined") { | | |
| 444 | persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE; | | |
| 445 | persistedDraftStorageChars = null; | | |
| 446 | return; | | |
| 447 | } | | |
| 448 | try { | | |
| 449 | window.localStorage.removeItem(name); | | |
| 450 | } catch { | | |
| 451 | // Ignore remove failures; clear in-memory cache regardless. | | |
| 452 | } | | |
| 453 | persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE; | | |
| 454 | persistedDraftStorageChars = 0; | | |
| 455 | }, | | |
| 456 | })); | | |
| 457 | | | |
| 458 | export const useComposerDraftStore = create<ComposerDraftStoreState>()( | 418 | export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 459 | persist( | 419 | persist( |
| 460 | (set, get) => ({ | 420 | (set, get) => ({ |
| ... | @@ -890,7 +850,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -890,7 +850,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 890 | return { draftsByThreadId: nextDraftsByThreadId }; | 850 | return { draftsByThreadId: nextDraftsByThreadId }; |
| 891 | }); | 851 | }); |
| 892 | Promise.resolve().then(() => { | 852 | Promise.resolve().then(() => { |
| 893 | const persistedIdSet = new Set(readPersistedAttachmentIdsFromCache(threadId)); | 853 | const persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadId)); |
| 894 | set((state) => { | 854 | set((state) => { |
| 895 | const current = state.draftsByThreadId[threadId]; | 855 | const current = state.draftsByThreadId[threadId]; |
| 896 | if (!current) { | 856 | if (!current) { |
| ... | @@ -983,7 +943,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -983,7 +943,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 983 | { | 943 | { |
| 984 | name: COMPOSER_DRAFT_STORAGE_KEY, | 944 | name: COMPOSER_DRAFT_STORAGE_KEY, |
| 985 | version: 1, | 945 | version: 1, |
| 986 | storage: composerDraftStorage, | 946 | storage: createJSONStorage(() => localStorage), |
| 987 | partialize: (state) => { | 947 | partialize: (state) => { |
| 988 | const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; | 948 | const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; |
| 989 | for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { | 949 | for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { |
| ... | @@ -1019,7 +979,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -1019,7 +979,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1019 | }, | 979 | }, |
| 1020 | merge: (persistedState, currentState) => { | 980 | merge: (persistedState, currentState) => { |
| 1021 | const normalizedPersisted = normalizePersistedComposerDraftState(persistedState); | 981 | const normalizedPersisted = normalizePersistedComposerDraftState(persistedState); |
| 1022 | persistedDraftStateCache = normalizedPersisted; | | |
| 1023 | const draftsByThreadId = Object.fromEntries( | 982 | const draftsByThreadId = Object.fromEntries( |
| 1024 | Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [ | 983 | Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [ |
| 1025 | threadId, | 984 | threadId, |
| ... | @@ -1040,7 +999,3 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -1040,7 +999,3 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1040 | export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState { | 999 | export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState { |
| 1041 | return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); | 1000 | return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); |
| 1042 | } | 1001 | } |
| 1043 | | | |
| 1044 | export function readComposerDraftPersistStorageChars(): number | null { | | |
| 1045 | return persistedDraftStorageChars; | | |
| 1046 | } | | |