import { useAtomValue } from "@effect/atom-react";import type { EnvironmentId } from "@t3tools/contracts";import { useEffect } from "react";import { Atom } from "effect/unstable/reactivity"; import type { DraftComposerImageAttachment } from "../lib/composerImages";import { appAtomRegistry } from "./atom-registry"; const COMPOSER_DRAFTS_SCHEMA_VERSION = 1;const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts";const COMPOSER_DRAFTS_FILE = "drafts.json";const PERSIST_DEBOUNCE_MS = 200; export interface ComposerDraft { readonly text: string; readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;} interface PersistedComposerDrafts { readonly schemaVersion: typeof COMPOSER_DRAFTS_SCHEMA_VERSION; readonly drafts: Record<string, ComposerDraft>;} const EMPTY_DRAFT: ComposerDraft = { text: "", attachments: [],}; export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).pipe( Atom.keepAlive, Atom.withLabel("mobile:composer-drafts"),); let loadPromise: Promise<void> | null = null;let persistTimer: ReturnType<typeof setTimeout> | null = null; function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { if (!draft) { return EMPTY_DRAFT; } return { text: draft.text, attachments: draft.attachments, };} function isEmptyDraft(draft: ComposerDraft): boolean { return draft.text.length === 0 && draft.attachments.length === 0;} async function getComposerDraftsFile() { const { Directory, File, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY); directory.create({ idempotent: true, intermediates: true }); return new File(directory, COMPOSER_DRAFTS_FILE);} async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDraft>> { try { const file = await getComposerDraftsFile(); if (!file.exists) { return {}; } const parsed = JSON.parse(await file.text()) as Partial<PersistedComposerDrafts>; if (parsed.schemaVersion !== COMPOSER_DRAFTS_SCHEMA_VERSION || !parsed.drafts) { return {}; } return Object.fromEntries( Object.entries(parsed.drafts).filter((entry): entry is [string, ComposerDraft] => { const draft = entry[1]; return ( typeof draft?.text === "string" && Array.isArray(draft.attachments) && !isEmptyDraft(draft) ); }), ); } catch { return {}; }} async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> { const file = await getComposerDraftsFile(); const nonEmptyDrafts = Object.fromEntries( Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), ); const document: PersistedComposerDrafts = { schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, }; if (!file.exists) { file.create({ intermediates: true, overwrite: true }); } file.write(JSON.stringify(document));} async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> { try { await writePersistedComposerDrafts(drafts); } catch { // Draft persistence is best-effort; in-memory drafts still keep working. }} function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): void { if (persistTimer !== null) { clearTimeout(persistTimer); } persistTimer = setTimeout(() => { persistTimer = null; void savePersistedComposerDrafts(drafts); }, PERSIST_DEBOUNCE_MS);} export function ensureComposerDraftsLoaded(): void { if (loadPromise !== null) { return; } loadPromise = loadPersistedComposerDrafts() .then((persistedDrafts) => { if (Object.keys(persistedDrafts).length === 0) { return; } const current = appAtomRegistry.get(composerDraftsAtom); appAtomRegistry.set(composerDraftsAtom, { ...persistedDrafts, ...current, }); }) .catch(() => { // Draft loading is best-effort; in-memory drafts still keep working. });} function updateComposerDrafts( update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>,): void { const next = update(appAtomRegistry.get(composerDraftsAtom)); appAtomRegistry.set(composerDraftsAtom, next); schedulePersistComposerDrafts(next);} export function setComposerDraftText(draftKey: string, value: string): void { updateComposerDrafts((current) => { const draft = { ...normalizeDraft(current[draftKey]), text: value, }; if (isEmptyDraft(draft)) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft, }; });} export function appendComposerDraftText(draftKey: string, value: string): void { updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); return { ...current, [draftKey]: { ...existing, text: `${existing.text}${value}`, }, }; });} export function appendComposerDraftAttachments( draftKey: string, attachments: ReadonlyArray<DraftComposerImageAttachment>,): void { if (attachments.length === 0) { return; } updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); return { ...current, [draftKey]: { ...existing, attachments: [...existing.attachments, ...attachments], }, }; });} export function replaceComposerDraftAttachments( draftKey: string, attachments: ReadonlyArray<DraftComposerImageAttachment>,): void { updateComposerDrafts((current) => { const draft = { ...normalizeDraft(current[draftKey]), attachments, }; if (isEmptyDraft(draft)) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft, }; });} export function removeComposerDraftAttachment(draftKey: string, imageId: string): void { updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); const draft = { ...existing, attachments: existing.attachments.filter((image) => image.id !== imageId), }; if (isEmptyDraft(draft)) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft, }; });} export function clearComposerDraft(draftKey: string): void { updateComposerDrafts((current) => { if (!current[draftKey]) { return current; } const next = { ...current }; delete next[draftKey]; return next; });} export function removeComposerDraftsForEnvironment( drafts: Record<string, ComposerDraft>, environmentId: EnvironmentId,): Record<string, ComposerDraft> { const environmentPrefix = `${environmentId}:`; return Object.fromEntries( Object.entries(drafts).filter(([draftKey]) => !draftKey.startsWith(environmentPrefix)), );} export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise<void> { ensureComposerDraftsLoaded(); if (loadPromise !== null) { await loadPromise; } const next = removeComposerDraftsForEnvironment( appAtomRegistry.get(composerDraftsAtom), environmentId, ); if (persistTimer !== null) { clearTimeout(persistTimer); persistTimer = null; } appAtomRegistry.set(composerDraftsAtom, next); await writePersistedComposerDrafts(next);} export function useComposerDraft(draftKey: string | null): ComposerDraft { const drafts = useAtomValue(composerDraftsAtom); useEffect(() => { ensureComposerDraftsLoaded(); }, []); return draftKey ? normalizeDraft(drafts[draftKey]) : EMPTY_DRAFT;}
import { useAtomValue } from "@effect/atom-react";
import type { EnvironmentId } from "@t3tools/contracts";
import { useEffect } from "react";
import { Atom } from "effect/unstable/reactivity";
import type { DraftComposerImageAttachment } from "../lib/composerImages";
import { appAtomRegistry } from "./atom-registry";
const COMPOSER_DRAFTS_SCHEMA_VERSION = 1;
const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts";
const COMPOSER_DRAFTS_FILE = "drafts.json";
const PERSIST_DEBOUNCE_MS = 200;
export interface ComposerDraft {
readonly text: string;
readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
}
interface PersistedComposerDrafts {
readonly schemaVersion: typeof COMPOSER_DRAFTS_SCHEMA_VERSION;
readonly drafts: Record<string, ComposerDraft>;
}
const EMPTY_DRAFT: ComposerDraft = {
text: "",
attachments: [],
};
export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).pipe(
Atom.keepAlive,
Atom.withLabel("mobile:composer-drafts"),
);
let loadPromise: Promise<void> | null = null;
let persistTimer: ReturnType<typeof setTimeout> | null = null;
function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {
if (!draft) {
return EMPTY_DRAFT;
}
return {
text: draft.text,
attachments: draft.attachments,
};
}
function isEmptyDraft(draft: ComposerDraft): boolean {
return draft.text.length === 0 && draft.attachments.length === 0;
}
async function getComposerDraftsFile() {
const { Directory, File, Paths } = await import("expo-file-system");
const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY);
directory.create({ idempotent: true, intermediates: true });
return new File(directory, COMPOSER_DRAFTS_FILE);
}
async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDraft>> {
try {
const file = await getComposerDraftsFile();
if (!file.exists) {
return {};
}
const parsed = JSON.parse(await file.text()) as Partial<PersistedComposerDrafts>;
if (parsed.schemaVersion !== COMPOSER_DRAFTS_SCHEMA_VERSION || !parsed.drafts) {
return {};
}
return Object.fromEntries(
Object.entries(parsed.drafts).filter((entry): entry is [string, ComposerDraft] => {
const draft = entry[1];
return (
typeof draft?.text === "string" &&
Array.isArray(draft.attachments) &&
!isEmptyDraft(draft)
);
}),
);
} catch {
return {};
}
}
async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> {
const file = await getComposerDraftsFile();
const nonEmptyDrafts = Object.fromEntries(
Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)),
);
const document: PersistedComposerDrafts = {
schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION,
drafts: nonEmptyDrafts,
};
if (!file.exists) {
file.create({ intermediates: true, overwrite: true });
}
file.write(JSON.stringify(document));
}
async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> {
try {
await writePersistedComposerDrafts(drafts);
} catch {
// Draft persistence is best-effort; in-memory drafts still keep working.
}
}
function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): void {
if (persistTimer !== null) {
clearTimeout(persistTimer);
}
persistTimer = setTimeout(() => {
persistTimer = null;
void savePersistedComposerDrafts(drafts);
}, PERSIST_DEBOUNCE_MS);
}
export function ensureComposerDraftsLoaded(): void {
if (loadPromise !== null) {
return;
}
loadPromise = loadPersistedComposerDrafts()
.then((persistedDrafts) => {
if (Object.keys(persistedDrafts).length === 0) {
return;
}
const current = appAtomRegistry.get(composerDraftsAtom);
appAtomRegistry.set(composerDraftsAtom, {
...persistedDrafts,
...current,
});
})
.catch(() => {
// Draft loading is best-effort; in-memory drafts still keep working.
});
}
function updateComposerDrafts(
update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>,
): void {
const next = update(appAtomRegistry.get(composerDraftsAtom));
appAtomRegistry.set(composerDraftsAtom, next);
schedulePersistComposerDrafts(next);
}
export function setComposerDraftText(draftKey: string, value: string): void {
updateComposerDrafts((current) => {
const draft = {
...normalizeDraft(current[draftKey]),
text: value,
};
if (isEmptyDraft(draft)) {
const next = { ...current };
delete next[draftKey];
return next;
}
return {
...current,
[draftKey]: draft,
};
});
}
export function appendComposerDraftText(draftKey: string, value: string): void {
updateComposerDrafts((current) => {
const existing = normalizeDraft(current[draftKey]);
return {
...current,
[draftKey]: {
...existing,
text: `${existing.text}${value}`,
},
};
});
}
export function appendComposerDraftAttachments(
draftKey: string,
attachments: ReadonlyArray<DraftComposerImageAttachment>,
): void {
if (attachments.length === 0) {
return;
}
updateComposerDrafts((current) => {
const existing = normalizeDraft(current[draftKey]);
return {
...current,
[draftKey]: {
...existing,
attachments: [...existing.attachments, ...attachments],
},
};
});
}
export function replaceComposerDraftAttachments(
draftKey: string,
attachments: ReadonlyArray<DraftComposerImageAttachment>,
): void {
updateComposerDrafts((current) => {
const draft = {
...normalizeDraft(current[draftKey]),
attachments,
};
if (isEmptyDraft(draft)) {
const next = { ...current };
delete next[draftKey];
return next;
}
return {
...current,
[draftKey]: draft,
};
});
}
export function removeComposerDraftAttachment(draftKey: string, imageId: string): void {
updateComposerDrafts((current) => {
const existing = normalizeDraft(current[draftKey]);
const draft = {
...existing,
attachments: existing.attachments.filter((image) => image.id !== imageId),
};
if (isEmptyDraft(draft)) {
const next = { ...current };
delete next[draftKey];
return next;
}
return {
...current,
[draftKey]: draft,
};
});
}
export function clearComposerDraft(draftKey: string): void {
updateComposerDrafts((current) => {
if (!current[draftKey]) {
return current;
}
const next = { ...current };
delete next[draftKey];
return next;
});
}
export function removeComposerDraftsForEnvironment(
drafts: Record<string, ComposerDraft>,
environmentId: EnvironmentId,
): Record<string, ComposerDraft> {
const environmentPrefix = `${environmentId}:`;
return Object.fromEntries(
Object.entries(drafts).filter(([draftKey]) => !draftKey.startsWith(environmentPrefix)),
);
}
export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise<void> {
ensureComposerDraftsLoaded();
if (loadPromise !== null) {
await loadPromise;
}
const next = removeComposerDraftsForEnvironment(
appAtomRegistry.get(composerDraftsAtom),
environmentId,
);
if (persistTimer !== null) {
clearTimeout(persistTimer);
persistTimer = null;
}
appAtomRegistry.set(composerDraftsAtom, next);
await writePersistedComposerDrafts(next);
}
export function useComposerDraft(draftKey: string | null): ComposerDraft {
const drafts = useAtomValue(composerDraftsAtom);
useEffect(() => {
ensureComposerDraftsLoaded();
}, []);
return draftKey ? normalizeDraft(drafts[draftKey]) : EMPTY_DRAFT;
}