Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowUp, Clock, Mic, Square, X } from "lucide-react";
import { useStore, type Bot, type Group } from "@/state/store";
import { cn } from "@/lib/cn";
import { useDraft } from "@/lib/drafts";
import { MausAvatar } from "./Avatar";
import { normalizeState } from "@/lib/mascot";

Expand Down Expand Up @@ -36,7 +37,9 @@ export function Composer({
const busyName = group
? (members?.find((b) => b.id === group.busyBotId)?.name ?? "A bot")
: (bot?.name ?? "The bot");
const [text, setText] = useState("");
// per-thread draft: switching bots unmounts this component, so the text
// has to outlive it (see lib/drafts)
const [text, setText] = useDraft(group ? `group:${group.id}` : `bot:${bot?.id ?? ""}`);
const [recording, setRecording] = useState(false);
const [speechError, setSpeechError] = useState<string | null>(null);
const [caret, setCaret] = useState(0);
Expand Down
53 changes: 53 additions & 0 deletions src/lib/drafts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Unsent composer text, kept per thread. The Composer is keyed by bot/room
// id, so switching threads unmounts it and its local text state dies with
// it. Drafts live in localStorage, so coming back to a bot — in this
// session or after a restart — finds what you were typing still there.
import { useCallback, useState } from "react";

const KEY = "omb-drafts";

type Drafts = Record<string, string>;
type Store = Pick<Storage, "getItem" | "setItem"> | undefined;

// Storage is best-effort: a full quota, a locked-down origin, or a garbled
// value must never cost a keystroke — every failure reads as "no drafts".
function read(store: Store): Drafts {
try {
const raw = store?.getItem(KEY);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Drafts) : {};
} catch {
return {};
}
}

export function getDraft(store: Store, id: string): string {
const text = read(store)[id];
return typeof text === "string" ? text : "";
}

export function setDraft(store: Store, id: string, text: string): void {
const drafts = read(store);
// an emptied composer drops its entry rather than storing "" forever
if (text) drafts[id] = text;
else delete drafts[id];
try {
store?.setItem(KEY, JSON.stringify(drafts));
} catch {
/* quota / private mode — the draft just doesn't outlive the mount */
}
}

/** useState for the composer text, persisted under `id` (a bot or room). */
export function useDraft(id: string): [string, (next: string) => void] {
const store = typeof localStorage === "undefined" ? undefined : localStorage;
const [text, setText] = useState(() => getDraft(store, id));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const set = useCallback(
(next: string) => {
setText(next);
setDraft(store, id, next);
},
[store, id],
);
return [text, set];
}
Loading