import { ClockIcon, DownloadSimpleIcon, SpinnerGapIcon, XIcon, } from "@phosphor-icons/react"; import { useEffect, useRef, useState } from "react"; import { type NavigateFunction, useNavigate, useParams, } from "react-router-dom"; import { api } from "../api/apiClient"; import { type CanvasStyle, type CanvasTools, ComposeCanvas, } from "../components/editor/ComposeCanvas"; import { PostSealModal } from "../components/editor/PostSealModal"; import { LetterHead, ToolBar, VaultConfirmModal, } from "../components/editor/ToolBar"; import DateDisplay from "../components/ui/DateDisplay"; import { LogModal } from "../components/ui/LogModal"; import { Modal } from "../components/ui/Modal"; import { Navbar } from "../components/ui/Navbar"; import { endpoints } from "../config/endpoints"; import { PATHS } from "../config/routes"; import { useKeyStore } from "../store/useKeyStore"; import { CryptoUtils } from "../utils/crypto"; import { formatRelativeDate } from "../utils/dateFormat"; import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic"; type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR"; const OVERLAY_FADE_MS = 250; const SAVED_VISIBLE_MS = 1400; const ERROR_VISIBLE_MS = 2400; const STOP_SAVE_DATE_PULSE_AFTER_MS = 10000; const toPlaceholderList = [ "Someone dear...", "Somewhere near...", "Something to bear...", ]; const MAX_FILE_SIZE = 10 * 1024 * 1024; export default function Editor() { const navigate = useNavigate(); const navigateRef = useRef(navigate); navigateRef.current = navigate; const { public_id } = useParams(); const letterIdRef = useRef(public_id ?? ""); const justSavedRef = useRef(false); const [decryptionStatus, setDecryptionStatus] = useState<{ status: "SUCCESS" | "WARN" | "ERROR" | "RESET"; message: string; log: string; }>({ status: "RESET", message: "", log: "" }); const [isInitialLoading, setIsInitialLoading] = useState(false); const [sealedTargetId, setSealedTargetId] = useState(null); const [lastSaved, setLastSaved] = useState(null); const [status, setLetterStatus] = useState<"DRAFT" | "SEALED" | "VAULT">( "DRAFT", ); const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false); const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0); const [sealBtnClicked, setSealBtnClicked] = useState(false); const [saveOverlay, setSaveOverlay] = useState("IDLE"); const [logStatus, setLogStatus] = useState<{ status: "WARN" | "ERROR" | "RESET"; message: string; }>({ status: "RESET", message: "", }); const [showSaveOverlay, setShowSaveOverlay] = useState(false); const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>( null, ); const [recipient, setRecipient] = useState(""); const [unlockDate, setUnlockDate] = useState(null); const [placeholderIndex, setPlaceholderIndex] = useState(0); const [canvasFontStyle, setCanvasFontStyle] = useState({ fontColor: "", fontFamily: "", }); const { masterKey } = useKeyStore(); const canvasRef = useRef(null); const fileInputRef = useRef(null); // to continuously rotate placeholder text of the recipient input useEffect(() => { const interval = setInterval(() => { setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length); }, 4000); return () => clearInterval(interval); }, []); // to load existing letter when public_id param and masterKey is available // NOTE: this has to trigger just once after each save useEffect(() => { if (!(public_id && masterKey)) return; if (justSavedRef.current) { justSavedRef.current = false; return; } const loadExistingLetter = async () => { setIsInitialLoading(true); const cryptoUtils = new CryptoUtils(); try { const res = await api.get(`${endpoints.LETTERS}${public_id}/`); const letterData = res.data; setLastSaved(formatRelativeDate(new Date(letterData.updated_at))); setLetterStatus(letterData.status); if (letterData.status === "SEALED") { navigateRef.current(PATHS.read(public_id), { replace: true }); return; } if (!letterData.encrypted_dek) { return; } const metadata = await cryptoUtils.decryptMetadata( { encrypted_content: letterData.encrypted_metadata, encrypted_dek: letterData.encrypted_dek, }, masterKey, ); setRecipient(metadata.recipient || ""); const decryptedJsonStr = await cryptoUtils.decryptLetter( { encrypted_content: letterData.encrypted_content, encrypted_dek: letterData.encrypted_dek, }, masterKey, ); const canvasData = JSON.parse(decryptedJsonStr); const { errors, isPartialFailure, canvasDataWithDecryptedImages } = await decryptCanvasImages( canvasData, letterData.images ?? [], letterData.encrypted_dek, masterKey, cryptoUtils, true, ); if (isPartialFailure) { setDecryptionStatus({ status: "WARN", message: "Failed to decrypt some elements. Please check the render.", log: errors.toString(), }); } if (canvasRef.current) { await canvasRef.current.loadData(canvasDataWithDecryptedImages); } } catch (_err) { setDecryptionStatus({ status: "ERROR", message: "Failed to decrypt letter. Please try again later.", log: _err instanceof Error ? _err.message : "Unknown error", }); } finally { setIsInitialLoading(false); } }; loadExistingLetter().then((_) => { if (canvasRef.current) { setCanvasFontStyle(canvasRef.current.getStyle()); } }); }, [public_id, masterKey]); // to trigger short pulse animation for Last Saved AT element useEffect(() => { if (lastSavedPulseTick === 0) return; setIsSaveDatePulsing(true); const timer = setTimeout(() => { setIsSaveDatePulsing(false); }, STOP_SAVE_DATE_PULSE_AFTER_MS); return () => clearTimeout(timer); }, [lastSavedPulseTick]); // to fade in and fade out the save status overlay after each save operation // Note: otherwise the fade efect is abrupt due to component's immediate unmount useEffect(() => { if (saveOverlay === "IDLE" || saveOverlay === "SAVING") return; const visibleTimer = setTimeout( () => { setShowSaveOverlay(false); }, saveOverlay === "SAVED" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS, ); const unmountTimer = setTimeout( () => { setSaveOverlay("IDLE"); }, (saveOverlay === "SAVED" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS) + OVERLAY_FADE_MS, ); return () => { clearTimeout(visibleTimer); clearTimeout(unmountTimer); }; }, [saveOverlay]); const handleImageUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file && file.size < MAX_FILE_SIZE) { const url = URL.createObjectURL(file); canvasRef.current?.addImage(url, file); } else { setLogStatus({ status: "WARN", message: "Please upload images with size less than 10MB.", }); } }; const handleSave = async ( status: "SEALED" | "DRAFT" | "VAULT", vaultDate?: Date, ): Promise => { setSealBtnClicked(false); let targetId = public_id || letterIdRef.current; if (!targetId) { targetId = crypto.randomUUID(); } if (saveOverlay === "SAVING" || !masterKey) return; setSaveOverlay("SAVING"); setShowSaveOverlay(true); const cryptoUtils = new CryptoUtils(); await cryptoUtils.initialize(); try { const canvasData = canvasRef.current?.getData() || { objects: [] }; const canvasImages = canvasRef.current?.getImages() || []; const { encryptedImageFiles, encryptedCanvasData } = await encryptCanvasImages( canvasData, canvasImages, masterKey, cryptoUtils, ); const encrypted_letter = await cryptoUtils.encryptLetter( JSON.stringify(encryptedCanvasData), masterKey, ); const encrypted_metadata = await cryptoUtils.encryptMetadata( { recipient, tags: [] }, masterKey, ); const formData = new FormData(); if (status === "VAULT") { const finalDate = vaultDate || unlockDate; formData.append("type", "VAULT"); if (finalDate) { formData.append("unlock_at", finalDate.toISOString()); } formData.append("status", "SEALED"); } else { formData.append("type", "KEPT"); formData.append("status", status); } formData.append("public_id", targetId); formData.append("encrypted_content", encrypted_letter.encrypted_content); formData.append("encrypted_dek", encrypted_letter.encrypted_dek); formData.append( "encrypted_metadata", encrypted_metadata.encrypted_content, ); encryptedImageFiles.forEach((blob, filename) => { formData.append("image_files", blob, filename); }); await api.put(`${endpoints.LETTERS}${targetId}/`, formData); justSavedRef.current = true; if (!public_id) { letterIdRef.current = targetId; navigate(PATHS.write(targetId), { replace: true }); } setLastSaved(formatRelativeDate(new Date())); setLetterStatus(status); setLastSavedPulseTick((prev) => prev + 1); if (status === "SEALED" || status === "VAULT") { setSealedTargetId(targetId); } setSaveOverlay("SAVED"); setShowSaveOverlay(true); } catch (_error) { setSaveOverlay("ERROR"); setShowSaveOverlay(true); } }; return ( <>
Last Save
{lastSaved}
} />
setDecryptionStatus({ status: "RESET", message: "", log: "" }) } isOpen={decryptionStatus.status !== "RESET"} /> {isInitialLoading && (

Opening your draft...

)} {saveOverlay !== "IDLE" && ( {saveOverlay === "SAVING" && (
Securing your letter...
)} {saveOverlay === "SAVED" && (
Your letter is saved!
)} {saveOverlay === "ERROR" && (
Failed to save letter
)}
)} {confirmModal === "VAULT" && ( )} {sealedTargetId && ( )}
setRecipient(e.target.value)} className="bg-transparent border-none outline-none text-2xl md:text-3xl lg:text-4xl font-serif text-base-content placeholder:text-base-content/10 w-full disabled:opacity-50" />
{status === "DRAFT" ? ( { setCanvasFontStyle({ fontFamily: style.fontFamily, fontColor: style.fontColor, }); if (canvasRef?.current?.setStyle) canvasRef.current.setStyle({ fontFamily: style.fontFamily, fontColor: style.fontColor, }); }} latestFontStyle={canvasFontStyle} /> ) : ( )}
setLogStatus({ status: "RESET", message: "", }) } isOpen={logStatus.status !== "RESET"} /> ); }