import { CampfireIcon, EyeSlashIcon, FlameIcon, PaperPlaneTiltIcon, XCircleIcon, } from "@phosphor-icons/react"; import { useEffect, useRef, useState } from "react"; import { type NavigateFunction, useLocation, useNavigate, useParams, } from "react-router-dom"; import { api } from "../api/apiClient"; import { type CanvasJSON, type CanvasTools, ComposeCanvas, } from "../components/editor/ComposeCanvas"; import Logo from "../components/Logo"; import { EnvelopeReveal } from "../components/reader/EnvelopeReveal"; import { LogModal } from "../components/ui/LogModal"; import { endpoints } from "../config/endpoints"; import { PATHS, ROUTES } from "../config/routes"; import { useKeyStore } from "../store/useKeyStore"; import { CryptoUtils } from "../utils/crypto"; import { formatDate } from "../utils/dateFormat"; import { decryptCanvasImages, decryptCanvasImagesWithSharingKey, } from "../utils/letterLogic"; interface LetterMetadata { recipient?: string; updated_at?: string; } export default function Reader() { const { public_id } = useParams(); const location = useLocation(); const navigate = useNavigate(); const sharingKey = location.hash.replace("#", ""); const navigateRef = useRef(navigate); const canvasRef = useRef(null); const [isDecrypting, setIsDecrypting] = useState(true); const [revealState, setRevealState] = useState< "sealed" | "revealed" | "burned" >("sealed"); const [error, setError] = useState<{ message: string; log: string; } | null>(null); const [warning, setWarning] = useState<{ message: string; log: string; } | null>(null); const [metadata, setMetadata] = useState(null); const [decryptedCanvasData, setDecryptedCanvasData] = useState(null); const [showBurnModal, setShowBurnModal] = useState(false); const [isBurning, setIsBurning] = useState(false); const [ignite, setIgnite] = useState(false); const [encryptedDek, setEncryptedDek] = useState(null); const [shareLink, setShareLink] = useState(null); const { masterKey } = useKeyStore(); const isAuthor = !!masterKey && !sharingKey; const handleShare = async () => { if (!(encryptedDek && masterKey && public_id)) return; const cryptoUtils = new CryptoUtils(); const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey); try { await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" }); } catch (_err) { } finally { setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`); } }; const copyToClipboard = async () => { if (!shareLink) return; await navigator.clipboard.writeText(shareLink); }; function ShareModal() { return (

Send this letter

You've carried these words long enough. Send your letter now, and let the{" "} unsaid finally find its home.

The recipient will have the same viewing experience like you do now.

{" "} Zero-Knowledge Share:

The key never leaves your or the recipient's browser.

); } const burnLetter = async () => { if (!public_id || isBurning) return; setIsBurning(true); try { await api.patch(`${endpoints.LETTERS}${public_id}/`, { status: "BURNED", }); } catch (_err) { } finally { setIsBurning(false); setShowBurnModal(false); setIgnite(true); setTimeout(() => { setRevealState("burned"); }, 13000); } }; function BurnModal() { const [flameOn, setFlameOn] = useState(0); const [rotate, setRotate] = useState(0); const [burnClicked, setBurnClicked] = useState(false); useEffect(() => { if (!burnClicked) return; if (flameOn === 100) { setRevealState("sealed"); burnLetter(); } const interval = setInterval(() => { setFlameOn((prev) => prev + 1); setRotate(Math.random() * 4 - 2); }, 100); return () => clearInterval(interval); }, [burnClicked, flameOn]); const burnStyle = flameOn < 30 ? "" : `contrast(${flameOn / 30})`; return (

Are you ready to burn this letter?

Some words are meant to be unsaid, but they don't have to linger forever.
Let the echoes of your unsaid be finally released.

Press and{" "} hold the{" "} flame to proceed.
); } useEffect(() => { if (!(sharingKey || masterKey)) { navigateRef.current("/login", { state: { redirectUrl: `/read/${public_id}` }, }); return; } const loadAndDecrypt = async () => { try { const response = await api.get(`${endpoints.LETTERS}${public_id}/`); const { encrypted_content, encrypted_metadata, encrypted_dek, images, updated_at, status, } = response.data; if (status === "BURNED") throw new Error("This letter has been burned."); if (encrypted_dek) setEncryptedDek(encrypted_dek); const cryptoUtils = new CryptoUtils(); const isShared = !!sharingKey; if (isShared && !encrypted_content) throw new Error("Content missing"); const isDecryptionKeyAvailable = encrypted_dek && masterKey; if (!(isShared || isDecryptionKeyAvailable)) throw new Error("Auth required: Decryption key is not available"); // Decrypt Metadata const decryptedMetadata = isShared ? await cryptoUtils.decryptMetadataWithSharingKey( encrypted_metadata, sharingKey, ) : await cryptoUtils.decryptMetadata( { encrypted_content: encrypted_metadata, encrypted_dek }, // biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true masterKey!, ); setMetadata({ ...(decryptedMetadata as LetterMetadata), updated_at, }); // Decrypt Content const decryptedContent = isShared ? await cryptoUtils.decryptLetterWithSharingKey( encrypted_content, sharingKey, ) : await cryptoUtils.decryptLetter( { encrypted_content, encrypted_dek }, // biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true masterKey!, ); const canvasData: CanvasJSON = JSON.parse(decryptedContent); try { // Decrypt Images if (images?.length > 0) { isShared ? await decryptCanvasImagesWithSharingKey( canvasData, images, sharingKey, cryptoUtils, ) : await decryptCanvasImages( canvasData, images, encrypted_dek, // biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true masterKey!, cryptoUtils, ); } } catch (err) { setWarning({ message: "Failed to decrypt elements. Images might not render in the letter as intended.", log: err instanceof Error ? err.message : "Unknown error", }); } setDecryptedCanvasData(canvasData); } catch (err) { setError({ message: `Failed to load letter :(`, log: err instanceof Error ? err.message : "Unknown error", }); } finally { setIsDecrypting(false); } }; loadAndDecrypt(); }, [public_id, sharingKey, masterKey]); useEffect(() => { if ( !isDecrypting && revealState === "revealed" && decryptedCanvasData && canvasRef.current ) { canvasRef.current.loadData(decryptedCanvasData); } }, [isDecrypting, revealState, decryptedCanvasData]); if (isDecrypting) { return (

Breaking the seal...

); } if (error) { return ( (window.location.href = "/")} message={error.message} log={error.log} status="ERROR" /> ); } return (
{revealState === "sealed" && ( setRevealState("revealed")} ignite={ignite} /> )}
{ignite && (

It is done

May your soul find solace,
just like your unsaid{" "} words did.

)} setWarning(null)} message={warning?.message || ""} log={warning?.log || ""} status="WARN" /> {revealState === "revealed" && (
{metadata?.recipient && (

For {metadata.recipient}

)}
)} {shareLink && } {showBurnModal && } {isAuthor && revealState !== "burned" && (
)}
); }