import { create } from "@bufbuild/protobuf"; import { Button, Field, Input, Textarea } from "@usememos/ui"; import { LockKeyholeIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useBlocker } from "react-router-dom"; import { VaultPanel } from "@/components/Encryption/VaultPanel"; import { lockEncryption, useVaultSession } from "@/contexts/E2EEContext"; import { useCreateMemo, useUpdateMemo } from "@/hooks/useMemoQueries"; import { decodeMemo, decryptMemo, type EncryptedMemo, encodeRecord, encryptMemo } from "@/lib/e2ee/crypto"; import { registerEncryptedDraft } from "@/lib/e2ee/dirty"; import { showErrorToast } from "@/lib/toast"; import { cn } from "@/lib/utils"; import { type Memo, MemoSchema, Visibility } from "@/types/proto/api/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; interface Props { className?: string; onFocusModeExit?: () => void; memo?: Memo; editing?: boolean; onConfirm?: (name: string) => void; onCancel?: () => void; onSavingChange?: (saving: boolean) => void; } /** The restricted text mode of MemoEditor never mounts Markdown plugins or draft persistence. */ export function EncryptedMemoEditor(props: Props) { const vault = useVaultSession(); const content = !vault.key ? ( ) : ( ); return (
{content}
); } /** Shows the plaintext title and offers vault unlock without exposing the encrypted body. */ function LockedText({ memo, onCancel }: Props) { const t = useTranslate(); const [open, setOpen] = useState(!memo); return (
{memo && ( )} {open && } {onCancel && ( )}
); } /** Warns before navigation or tab closure can discard an unsaved encrypted draft. */ function UnsavedGuard() { const t = useTranslate(); const blocker = useBlocker(true); useEffect(() => registerEncryptedDraft(), []); useEffect(() => { /** Requests the browser confirmation before an unsaved draft is unloaded. */ const leave = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; }; window.addEventListener("beforeunload", leave); return () => window.removeEventListener("beforeunload", leave); }, []); useEffect(() => { if (blocker.state === "blocked") { if (window.confirm(t("e2ee.unsaved"))) blocker.proceed(); else blocker.reset(); } }, [blocker, t]); return null; } /** Keeps decrypted text in component memory and saves ciphertext with revision checks. */ function UnlockedText({ memo, editing = false, onConfirm, onCancel, onSavingChange }: Props) { const t = useTranslate(); const { session, key, vaultId } = useVaultSession(); const [initialMemo] = useState(memo); const source = editing ? initialMemo : memo; const [content, setContent] = useState(""); const [saved, setSaved] = useState(""); const [title, setTitle] = useState(source?.encryptedTitle ?? ""); const [savedTitle, setSavedTitle] = useState(source?.encryptedTitle ?? ""); const [ready, setReady] = useState(!source); const [failed, setFailed] = useState(false); const [saving, setSaving] = useState(false); const baseline = useRef(undefined); const alive = useRef(true); const createMemo = useCreateMemo(); const updateMemo = useUpdateMemo(memo?.name); const dirty = editing && (content !== saved || title !== savedTitle); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); useEffect(() => { let active = true; const ticket = session.ticket(); if (!source || !key) return; setReady(false); setFailed(false); /** Applies plaintext only while this memo and its unlocked vault session are still current. */ const decrypt = async () => { const encrypted = decodeMemo(source.encryptedContent); if (encrypted.vaultId !== vaultId) throw new Error("Vault mismatch"); const text = await decryptMemo(key, encrypted); if (active && session.current(ticket)) { baseline.current = encrypted; setTitle(source.encryptedTitle); setSavedTitle(source.encryptedTitle); setContent(text); setSaved(text); setReady(true); } }; decrypt().catch(() => { if (active && session.current(ticket)) setFailed(true); }); return () => { active = false; }; }, [source, key, vaultId, session]); /** Encrypts the current draft and ignores results belonging to a locked or replaced session. */ const save = async () => { if (!key || !vaultId || !ready || saving || !content.trim()) return; const ticket = session.ticket(); setSaving(true); onSavingChange?.(true); try { const envelope = await encryptMemo(key, vaultId, content, baseline.current?.objectId); if (!session.current(ticket) || !alive.current) return; const encryptedContent = encodeRecord(envelope); const result = memo ? await updateMemo.mutateAsync({ update: { name: memo.name, encryptedContent, encryptedTitle: title }, updateMask: ["encrypted_content", "encrypted_title", "update_time"], expectedEncryptionRevision: baseline.current?.revisionId, }) : await createMemo.mutateAsync({ memo: create(MemoSchema, { content: "", encryptedContent, encryptedTitle: title, visibility: Visibility.PRIVATE }), }); if (session.current(ticket) && alive.current) { baseline.current = envelope; setSaved(memo ? content : ""); setSavedTitle(memo ? title : ""); if (!memo) { setContent(""); setTitle(""); baseline.current = undefined; } onConfirm?.(result.name); } } catch { if (session.current(ticket) && alive.current) showErrorToast(t("e2ee.failed")); } finally { if (alive.current) setSaving(false); onSavingChange?.(false); } }; return (
{dirty && } {failed ? (

{t("e2ee.failed")}

) : !ready ? (

{t("editor.validation.loading-content")}

) : editing ? ( <>

{t("e2ee.limitations")}

{t("e2ee.memo-title")} setTitle(event.target.value)} /> {t("e2ee.title-notice")}