| ... | ... | @@ -116,9 +116,6 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { |
| 116 | 116 | projectDraftThreadIdByProjectId: {}, |
| 117 | 117 | }; |
| 118 | 118 | |
| 119 | | let persistedDraftStateCache: PersistedComposerDraftStoreState = EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 120 | | let persistedDraftStorageChars: number | null = null; |
| 121 | | |
| 122 | 119 | const EMPTY_IMAGES: ComposerImageAttachment[] = []; |
| 123 | 120 | const EMPTY_IDS: string[] = []; |
| 124 | 121 | const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; |
| ... | ... | @@ -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 | 338 | if (threadId.length === 0) { |
| 342 | 339 | return []; |
| 343 | 340 | } |
| 344 | | return (persistedDraftStateCache.draftsByThreadId[threadId]?.attachments ?? []).map( |
| 345 | | (attachment) => attachment.id, |
| 346 | | ); |
| 341 | try { |
| 342 | const raw = localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY); |
| 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 { |
| 350 | | const [rawHeader, payload] = dataUrl.split(",", 2); |
| 352 | function hydreatePersistedComposerImageAttachment( |
| 353 | attachment: PersistedComposerImageAttachment, |
| 354 | ): File | null { |
| 355 | const [rawHeader, payload] = attachment.dataUrl.split(",", 2); |
| 351 | 356 | const header = rawHeader ?? ""; |
| 352 | 357 | if (!payload) { |
| 353 | 358 | return null; |
| ... | ... | @@ -359,15 +364,17 @@ function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File |
| 359 | 364 | const inferredMimeType = |
| 360 | 365 | header.startsWith("data:") && header.includes(";") |
| 361 | 366 | ? header.slice("data:".length, header.indexOf(";")) |
| 362 | | : mimeType; |
| 363 | | return new File([decodedText], name, { type: inferredMimeType || mimeType }); |
| 367 | : attachment.mimeType; |
| 368 | return new File([decodedText], attachment.name, { |
| 369 | type: inferredMimeType || attachment.mimeType, |
| 370 | }); |
| 364 | 371 | } |
| 365 | 372 | const binary = atob(payload); |
| 366 | 373 | const bytes = new Uint8Array(binary.length); |
| 367 | 374 | for (let index = 0; index < binary.length; index += 1) { |
| 368 | 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 | 378 | } catch { |
| 372 | 379 | return null; |
| 373 | 380 | } |
| ... | ... | @@ -377,10 +384,9 @@ function hydrateImagesFromPersisted( |
| 377 | 384 | attachments: PersistedComposerImageAttachment[], |
| 378 | 385 | ): ComposerImageAttachment[] { |
| 379 | 386 | return attachments.flatMap((attachment) => { |
| 380 | | const file = fileFromDataUrl(attachment.dataUrl, attachment.name, attachment.mimeType); |
| 381 | | if (!file) { |
| 382 | | return []; |
| 383 | | } |
| 387 | const file = hydreatePersistedComposerImageAttachment(attachment); |
| 388 | if (!file) return []; |
| 389 | |
| 384 | 390 | return [ |
| 385 | 391 | { |
| 386 | 392 | type: "image" as const, |
| ... | ... | @@ -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 | 418 | export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 459 | 419 | persist( |
| 460 | 420 | (set, get) => ({ |
| ... | ... | @@ -890,7 +850,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 890 | 850 | return { draftsByThreadId: nextDraftsByThreadId }; |
| 891 | 851 | }); |
| 892 | 852 | Promise.resolve().then(() => { |
| 893 | | const persistedIdSet = new Set(readPersistedAttachmentIdsFromCache(threadId)); |
| 853 | const persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadId)); |
| 894 | 854 | set((state) => { |
| 895 | 855 | const current = state.draftsByThreadId[threadId]; |
| 896 | 856 | if (!current) { |
| ... | ... | @@ -983,7 +943,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 983 | 943 | { |
| 984 | 944 | name: COMPOSER_DRAFT_STORAGE_KEY, |
| 985 | 945 | version: 1, |
| 986 | | storage: composerDraftStorage, |
| 946 | storage: createJSONStorage(() => localStorage), |
| 987 | 947 | partialize: (state) => { |
| 988 | 948 | const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; |
| 989 | 949 | for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { |
| ... | ... | @@ -1019,7 +979,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1019 | 979 | }, |
| 1020 | 980 | merge: (persistedState, currentState) => { |
| 1021 | 981 | const normalizedPersisted = normalizePersistedComposerDraftState(persistedState); |
| 1022 | | persistedDraftStateCache = normalizedPersisted; |
| 1023 | 982 | const draftsByThreadId = Object.fromEntries( |
| 1024 | 983 | Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [ |
| 1025 | 984 | threadId, |
| ... | ... | @@ -1040,7 +999,3 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1040 | 999 | export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState { |
| 1041 | 1000 | return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); |
| 1042 | 1001 | } |
| 1043 | | |
| 1044 | | export function readComposerDraftPersistStorageChars(): number | null { |
| 1045 | | return persistedDraftStorageChars; |
| 1046 | | } |