From 7e79c6ca8bc86cec24fce2a4571c8bcc504a0109 Mon Sep 17 00:00:00 2001 From: me Date: Fri, 8 May 2026 10:33:11 +0530 Subject: [PATCH] refactor: reduce complexity --- frontend/src/pages/Editor.tsx | 906 +++++++++++++++++----------------- frontend/src/pages/Reader.tsx | 633 ++++++++++++------------ 2 files changed, 772 insertions(+), 767 deletions(-) diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 912ac1e..44e16bd 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -1,32 +1,32 @@ import { - ClockIcon, - DownloadSimpleIcon, - SpinnerGapIcon, - XIcon, + ClockIcon, + DownloadSimpleIcon, + SpinnerGapIcon, + XIcon, } from "@phosphor-icons/react"; import { useEffect, useRef, useState } from "react"; import { - type NavigateFunction, - useNavigate, - useParams, + type NavigateFunction, + useNavigate, + useParams, } from "react-router-dom"; import { api } from "../api/apiClient"; +import type { LetterResponseData } from "../api/response"; import { - type CanvasStyle, - type CanvasTools, - ComposeCanvas, + type CanvasStyle, + type CanvasTools, + ComposeCanvas, } from "../components/editor/ComposeCanvas"; import { PostSealModal } from "../components/editor/PostSealModal"; import { - LetterHead, - ToolBar, - VaultConfirmModal, + 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"; @@ -42,480 +42,482 @@ const ERROR_VISIBLE_MS = 2400; const STOP_SAVE_DATE_PULSE_AFTER_MS = 10000; const toPlaceholderList = [ - "Someone dear...", - "Somewhere near...", - "Something to bear...", + "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 navigate = useNavigate(); + const navigateRef = useRef(navigate); + navigateRef.current = navigate; - const { public_id } = useParams(); - const letterIdRef = useRef(public_id ?? ""); - const justSavedRef = useRef(false); + 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 [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 [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()); - } + const [saveOverlay, setSaveOverlay] = useState("IDLE"); + const [logStatus, setLogStatus] = useState<{ + status: "WARN" | "ERROR" | "RESET"; + message: string; + }>({ + status: "RESET", + message: "", }); - }, [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, + const [showSaveOverlay, setShowSaveOverlay] = useState(false); + const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>( + null, ); - return () => { - clearTimeout(visibleTimer); - clearTimeout(unmountTimer); - }; - }, [saveOverlay]); + const [recipient, setRecipient] = useState(""); + const [unlockDate, setUnlockDate] = useState(null); + const [placeholderIndex, setPlaceholderIndex] = useState(0); + const [canvasFontStyle, setCanvasFontStyle] = useState({ + fontColor: "", + fontFamily: "", + }); - 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 { masterKey } = useKeyStore(); - const handleSave = async ( - status: "SEALED" | "DRAFT" | "VAULT", - vaultDate?: Date, - ): Promise => { - setSealBtnClicked(false); + const canvasRef = useRef(null); + const fileInputRef = useRef(null); - let targetId = public_id || letterIdRef.current; - if (!targetId) { - targetId = crypto.randomUUID(); - } + // to continuously rotate placeholder text of the recipient input + useEffect(() => { + const interval = setInterval(() => { + setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length); + }, 4000); - if (saveOverlay === "SAVING" || !masterKey) return; + return () => clearInterval(interval); + }, []); - setSaveOverlay("SAVING"); - setShowSaveOverlay(true); + // 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 decryptAndLoadLetter = async ( + letterData: LetterResponseData, + masterKey: CryptoKey, + ) => { + const cryptoUtils = new CryptoUtils(); + const metadata = await cryptoUtils.decryptMetadata( + { + encrypted_content: letterData.encrypted_metadata, + encrypted_dek: letterData.encrypted_dek, + }, + masterKey, + ); + setRecipient(metadata.recipient || ""); - const cryptoUtils = new CryptoUtils(); - await cryptoUtils.initialize(); + const decryptedJsonStr = await cryptoUtils.decryptLetter( + { + encrypted_content: letterData.encrypted_content, + encrypted_dek: letterData.encrypted_dek, + }, + masterKey, + ); + const canvasData = JSON.parse(decryptedJsonStr); - try { - const canvasData = (await canvasRef.current?.getData()) || { - objects: [], - }; - const canvasImages = canvasRef.current?.getImages() || []; + const { errors, isPartialFailure, canvasDataWithDecryptedImages } = + await decryptCanvasImages( + canvasData, + letterData.images ?? [], + letterData.encrypted_dek, + masterKey, + cryptoUtils, + true, + ); - const { encryptedImageFiles, encryptedCanvasData } = - await encryptCanvasImages( - canvasData, - canvasImages, - masterKey, - cryptoUtils, + 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); + } + }; + + const loadExistingLetter = async () => { + setIsInitialLoading(true); + 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 && masterKey) { + await decryptAndLoadLetter(letterData, masterKey); + } + } 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, ); - const encrypted_letter = await cryptoUtils.encryptLetter( - JSON.stringify(encryptedCanvasData), - masterKey, - ); + return () => { + clearTimeout(visibleTimer); + clearTimeout(unmountTimer); + }; + }, [saveOverlay]); - 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()); + 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.", + }); } - 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); - }); + const getRequestData = async ( + targetId: string, + status: string, + vaultDate?: Date, + ): Promise => { + const cryptoUtils = new CryptoUtils(); + await cryptoUtils.initialize(); - await api.put(`${endpoints.LETTERS}${targetId}/`, formData); - justSavedRef.current = true; + const canvasData = (await canvasRef.current?.getData()) || { objects: [] }; + const canvasImages = canvasRef.current?.getImages() || []; - if (!public_id) { - letterIdRef.current = targetId; - navigate(PATHS.write(targetId), { replace: true }); - } + const { encryptedImageFiles, encryptedCanvasData } = + await encryptCanvasImages( + canvasData, + canvasImages, + // biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here + masterKey!, + cryptoUtils, + ); - setLastSaved(formatRelativeDate(new Date())); - setLetterStatus(status); - setLastSavedPulseTick((prev) => prev + 1); + const encrypted_letter = await cryptoUtils.encryptLetter( + JSON.stringify(encryptedCanvasData), + // biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here + masterKey!, + ); - if (status === "SEALED" || status === "VAULT") { - setSealedTargetId(targetId); - } - setSaveOverlay("SAVED"); - setShowSaveOverlay(true); - } catch (_error) { - setSaveOverlay("ERROR"); - setShowSaveOverlay(true); - } - }; + const encrypted_metadata = await cryptoUtils.encryptMetadata( + { recipient, tags: [] }, + // biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here + masterKey!, + ); - return ( - <> - -
- - Last Save - -
- {lastSaved} -
- { + formData.append("image_files", blob, filename); + }); + + return formData; + }; + + const handleSave = async ( + status: "SEALED" | "DRAFT" | "VAULT", + vaultDate?: Date, + ): Promise => { + setSealBtnClicked(false); + // use the letter's id if an existing letter or create a new id + const targetId = public_id || letterIdRef.current || crypto.randomUUID(); + + if (saveOverlay === "SAVING" || !masterKey) return; + + setSaveOverlay("SAVING"); + setShowSaveOverlay(true); + + try { + const formData = await getRequestData(targetId, status, vaultDate); + 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 { + 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" && ( -
- + + setDecryptionStatus({ status: "RESET", message: "", log: "" }) + } + isOpen={decryptionStatus.status !== "RESET"} /> - Securing your letter... -
- )} - {saveOverlay === "SAVED" && ( -
- - Your letter is saved! -
- )} + {isInitialLoading && ( +
+
+ +

+ Opening your draft... +

+
+
+ )} - {saveOverlay === "ERROR" && ( -
- - Failed to save letter -
- )} -
- )} + {saveOverlay !== "IDLE" && ( + + {saveOverlay === "SAVING" && ( +
+ + Securing your letter... +
+ )} - {confirmModal === "VAULT" && ( - - )} - {sealedTargetId && ( - - )} + {saveOverlay === "SAVED" && ( +
+ + Your letter is saved! +
+ )} -
-
-
- - 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" - /> -
- -
+ {saveOverlay === "ERROR" && ( +
+ + Failed to save letter +
+ )} + + )} - {status === "DRAFT" ? ( - fileInputRef.current?.click()} - sealBtnClicked={sealBtnClicked} - setSealBtnClicked={setSealBtnClicked} - onSave={handleSave} - setConfirmModal={setConfirmModal} - onFontChange={setCanvasFontStyle} - latestFontStyle={canvasFontStyle} + {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" ? ( + fileInputRef.current?.click()} + sealBtnClicked={sealBtnClicked} + setSealBtnClicked={setSealBtnClicked} + onSave={handleSave} + setConfirmModal={setConfirmModal} + onFontChange={setCanvasFontStyle} + latestFontStyle={canvasFontStyle} + /> + ) : ( + + )} + + + + +
+
+ + setLogStatus({ + status: "RESET", + message: "", + }) + } + isOpen={logStatus.status !== "RESET"} /> - ) : ( - - )} - - - - - - - - setLogStatus({ - status: "RESET", - message: "", - }) - } - isOpen={logStatus.status !== "RESET"} - /> - - ); + + ); } diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index 1982032..9026dec 100644 --- a/frontend/src/pages/Reader.tsx +++ b/frontend/src/pages/Reader.tsx @@ -2,17 +2,17 @@ import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react"; import type { AxiosResponse } from "axios"; import { useEffect, useRef, useState } from "react"; import { - type NavigateFunction, - useLocation, - useNavigate, - useParams, + type NavigateFunction, + useLocation, + useNavigate, + useParams, } from "react-router-dom"; import { api } from "../api/apiClient"; import type { LetterImageData, LetterResponseData } from "../api/response"; import { - type CanvasJSON, - type CanvasTools, - ComposeCanvas, + type CanvasJSON, + type CanvasTools, + ComposeCanvas, } from "../components/editor/ComposeCanvas"; import Logo from "../components/Logo"; import { BurnModal } from "../components/reader/BurnModal"; @@ -26,339 +26,342 @@ import { useKeyStore } from "../store/useKeyStore"; import { CryptoUtils } from "../utils/crypto"; import { formatDate } from "../utils/dateFormat"; import { - decryptCanvasImages, - decryptCanvasImagesWithSharingKey, + decryptCanvasImages, + decryptCanvasImagesWithSharingKey, } from "../utils/letterLogic"; interface LetterMetadata { - recipient?: string; - updated_at?: string; + recipient?: string; + updated_at?: string; } const WAIT_FOR_BURN_MS = 18000; export default function Reader() { - const { public_id } = useParams(); - const location = useLocation(); - const navigate = useNavigate(); - const sharingKey = location.hash.replace("#", ""); + const { public_id } = useParams(); + const location = useLocation(); + const navigate = useNavigate(); + const sharingKey = location.hash.replace("#", ""); - const navigateRef = useRef(navigate); - const canvasRef = useRef(null); + const navigateRef = useRef(navigate); + const canvasRef = useRef(null); - const [isDecrypting, setIsDecrypting] = useState(true); - const [revealState, setRevealState] = useState< - "SEALED" | "REVEALED" | "BURNED" | "BURNING" - >("SEALED"); - const [logTrace, setLogTrace] = useState<{ - type: "WARN" | "ERROR"; - 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 [isDecrypting, setIsDecrypting] = useState(true); + const [revealState, setRevealState] = useState< + "SEALED" | "REVEALED" | "BURNED" | "BURNING" + >("SEALED"); + const [logTrace, setLogTrace] = useState<{ + type: "WARN" | "ERROR"; + 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 { 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 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"); - }, WAIT_FOR_BURN_MS); - } - }; - - useEffect(() => { - if (!(sharingKey || masterKey)) { - navigateRef.current("/login", { - state: { redirectUrl: `/read/${public_id}` }, - }); - return; - } - - const decryptImages = async ( - canvasData: CanvasJSON, - images: LetterImageData[], - encrypted_dek: string, - cryptoUtils: CryptoUtils, - ) => { - if (!images?.length) return; - const isShared = !!sharingKey; - try { - if (isShared) { - await decryptCanvasImagesWithSharingKey( - canvasData, - images, - sharingKey, - cryptoUtils, - ); - } else { - 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) { - setLogTrace({ - message: - "Failed to decrypt elements. Images might not render in the letter as intended.", - log: err instanceof Error ? err.message : "Unknown error", - type: "WARN", - }); - } - }; - - const decryptLetterData = async ( - data: LetterResponseData, - cryptoUtils: CryptoUtils, - ) => { - const isShared = !!sharingKey; - const { - encrypted_content, - encrypted_metadata, - encrypted_dek, - images, - updated_at, - } = data; - - // 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); - await decryptImages(canvasData, images, encrypted_dek, cryptoUtils); - setDecryptedCanvasData(canvasData); - }; - - const loadAndDecrypt = async () => { - try { - const response: AxiosResponse = await api.get( - `${endpoints.LETTERS}${public_id}/`, - ); - const data = response.data; - - if (data.status === "BURNED") - throw new Error("This letter has been burned."); - - if (data.encrypted_dek) setEncryptedDek(data.encrypted_dek); - - const isDecryptionKeyAvailable = data.encrypted_dek && masterKey; - if (!(!!sharingKey || isDecryptionKeyAvailable)) { - throw new Error("Auth required: Decryption key is not available"); - } + const isAuthor = !!masterKey && !sharingKey; + const handleShare = async () => { + if (!(encryptedDek && masterKey && public_id)) return; const cryptoUtils = new CryptoUtils(); - await decryptLetterData(data, cryptoUtils); - } catch (err) { - setLogTrace({ - message: `Failed to load letter ☹`, - log: err instanceof Error ? err.message : "Unknown error", - type: "ERROR", - }); - } + const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey); + try { + await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" }); + } catch { + // shouldn't obstruct share if api operation fails (since it's client side share) + } finally { + setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`); + } }; - loadAndDecrypt().then(() => setIsDecrypting(false)); - }, [public_id, sharingKey, masterKey]); + const burnLetter = async () => { + if (!public_id || isBurning) return; + setIsBurning(true); + try { + await api.patch(`${endpoints.LETTERS}${public_id}/`, { + status: "BURNED", + }); + } catch { + // should not obstruct burn if api operation fails + // WHY?: it disconnects the UX. if you want to burn the letter, you should be able to burn the letter + // TODO: maybe say something like: "the wind is strong today, let's try again"? or maybe something less stupid :3 + } finally { + setIsBurning(false); + setShowBurnModal(false); + setIgnite(true); + setTimeout(() => { + setRevealState("BURNED"); + }, WAIT_FOR_BURN_MS); + } + }; - useEffect(() => { - if ( - !isDecrypting && - revealState === "REVEALED" && - decryptedCanvasData && - canvasRef.current - ) { - canvasRef.current.loadData(decryptedCanvasData); - } - }, [isDecrypting, revealState, decryptedCanvasData]); + useEffect(() => { + if (!(sharingKey || masterKey)) { + navigateRef.current("/login", { + state: { redirectUrl: `/read/${public_id}` }, + }); + return; + } - if (isDecrypting) { - return ( -
-
-
- -
- -

- Breaking the seal... -

-
-
-
- ); - } - - if (logTrace) { - return ( - { - if (logTrace.type === "ERROR") window.location.href = "/"; - setLogTrace(null); - }} - message={logTrace.message} - log={logTrace.log} - status={logTrace.type} - /> - ); - } - - return ( -
-
-
- {revealState === "SEALED" && ( -
-
- { + if (!images?.length) return; + const isShared = !!sharingKey; + try { + if (isShared) { + await decryptCanvasImagesWithSharingKey( + canvasData, + images, + sharingKey, + cryptoUtils, + ); + } else { + 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, + ); } - onRevealComplete={() => setRevealState("REVEALED")} - ignite={ignite} - /> + } catch (err) { + setLogTrace({ + message: + "Failed to decrypt elements. Images might not render in the letter as intended.", + log: err instanceof Error ? err.message : "Unknown error", + type: "WARN", + }); + } + }; + + const decryptLetterData = async ( + data: LetterResponseData, + cryptoUtils: CryptoUtils, + ) => { + const isShared = !!sharingKey; + const { + encrypted_content, + encrypted_metadata, + encrypted_dek, + images, + updated_at, + } = data; + + // 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); + await decryptImages(canvasData, images, encrypted_dek, cryptoUtils); + setDecryptedCanvasData(canvasData); + }; + + const loadAndDecrypt = async () => { + try { + const response: AxiosResponse = await api.get( + `${endpoints.LETTERS}${public_id}/`, + ); + const data = response.data; + + if (data.status === "BURNED") + throw new Error("This letter has been burned."); + + if (data.encrypted_dek) setEncryptedDek(data.encrypted_dek); + + const isDecryptionKeyAvailable = data.encrypted_dek && masterKey; + if (!(!!sharingKey || isDecryptionKeyAvailable)) { + throw new Error("Auth required: Decryption key is not available"); + } + + const cryptoUtils = new CryptoUtils(); + await decryptLetterData(data, cryptoUtils); + } catch (err) { + setLogTrace({ + message: `Failed to load letter ☹`, + log: err instanceof Error ? err.message : "Unknown error", + type: "ERROR", + }); + } + }; + + loadAndDecrypt().then(() => setIsDecrypting(false)); + }, [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... +

+
+
-
- )} -
+ ); + } - {ignite && } + if (logTrace) { + return ( + { + if (logTrace.type === "ERROR") window.location.href = "/"; + setLogTrace(null); + }} + message={logTrace.message} + log={logTrace.log} + status={logTrace.type} + /> + ); + } - {revealState === "REVEALED" && ( -
-
-
- -
-
- + return ( +
+
+
+ {revealState === "SEALED" && ( +
+
+ setRevealState("REVEALED")} + ignite={ignite} + /> +
+
+ )}
- {metadata?.recipient && ( -

- For {metadata.recipient} -

+ {ignite && } + + {revealState === "REVEALED" && ( +
+
+
+ +
+
+ +
+ + {metadata?.recipient && ( +

+ For {metadata.recipient} +

+ )} +
+
)} -
-
- )} - {shareLink && ( - - )} - {showBurnModal && ( - - )} + {shareLink && ( + + )} + {showBurnModal && ( + + )} - {isAuthor && revealState !== "BURNED" && ( -
- - -
- )} + {isAuthor && revealState !== "BURNED" && ( +
+ + +
+ )} -
-

- Read. Remember. Release. -

-
-
- ); + +
+ ); }