243 lines · 6.5 KB · TypeScript
RawDownload
import { useAtomValue } from "@effect/atom-react";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 loadStarted = false;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 savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> {  try {    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));  } 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 (loadStarted) {    return;  }  loadStarted = true;  void loadPersistedComposerDrafts().then((persistedDrafts) => {    if (Object.keys(persistedDrafts).length === 0) {      return;    }    const current = appAtomRegistry.get(composerDraftsAtom);    appAtomRegistry.set(composerDraftsAtom, {      ...persistedDrafts,      ...current,    });  });} 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 useComposerDraft(draftKey: string | null): ComposerDraft {  const drafts = useAtomValue(composerDraftsAtom);  useEffect(() => {    ensureComposerDraftsLoaded();  }, []);  return draftKey ? normalizeDraft(drafts[draftKey]) : EMPTY_DRAFT;}