diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx index 77b5061..367c947 100644 --- a/frontend/src/components/Logo.tsx +++ b/frontend/src/components/Logo.tsx @@ -3,63 +3,58 @@ import logo from "../assets/logo.svg"; import "@fontsource/knewave/400.css"; interface LogoProps { - scale?: number; - type?: "inline" | "mono" | "logo" | null; - ul?: boolean; + scale?: number; + type?: "inline" | "mono" | "logo" | null; + ul?: boolean; } export default function Logo({ - scale = 1, - type = null, - ul = false, + scale = 1, + type = null, + ul = false, }: LogoProps) { - if (type === "inline") { - return ( - - pi. ku - .  - - ); - } - - if (type === "mono") { - return ( - - pi. ku. - - ); - } - - if (type === "logo") { - return ( - Pi. Ku. logo - ); - } - + if (type === "inline") { return ( -
- Pi - -  Ku - -
+ + pi. ku + .  + ); + } + + if (type === "mono") { + return ( + + pi. ku. + + ); + } + + if (type === "logo") { + return ( + Pi. Ku. logo + ); + } + + return ( +
+ Pi + +  Ku + +
+ ); } diff --git a/frontend/src/components/drawer/WelcomeLetterOverlay.tsx b/frontend/src/components/drawer/WelcomeLetterOverlay.tsx index 23a6b8c..ba0c666 100644 --- a/frontend/src/components/drawer/WelcomeLetterOverlay.tsx +++ b/frontend/src/components/drawer/WelcomeLetterOverlay.tsx @@ -6,73 +6,73 @@ import { type CanvasTools, ComposeCanvas } from "../editor/ComposeCanvas"; import { EnvelopeReveal } from "../reader/EnvelopeReveal"; export interface WelcomeLetterOverlayProps { - onComplete: () => void; - userName: string; + onComplete: () => void; + userName: string; } export function WelcomeLetterOverlay({ - onComplete, - userName, + onComplete, + userName, }: WelcomeLetterOverlayProps) { - const [revealState, setRevealState] = useState<"SEALED" | "REVEALED">( - "SEALED", - ); - const canvasRef = useRef(null); + const [revealState, setRevealState] = useState<"SEALED" | "REVEALED">( + "SEALED", + ); + const canvasRef = useRef(null); - useEffect(() => { - if (revealState === "REVEALED" && canvasRef.current) { - const welcomeContent = getWelcomeLetterContent(userName); - canvasRef.current.loadData(welcomeContent); - } - }, [revealState, userName]); + useEffect(() => { + if (revealState === "REVEALED" && canvasRef.current) { + const welcomeContent = getWelcomeLetterContent(userName); + canvasRef.current.loadData(welcomeContent); + } + }, [revealState, userName]); - return ( -
-
+ return ( +
+
-
- - {revealState === "SEALED" && ( - - setRevealState("REVEALED")} - ignite={false} - /> - - )} - -
-
-
- -
+
+ + {revealState === "SEALED" && ( + + setRevealState("REVEALED")} + ignite={false} + /> + + )} + +
+
+
+ +
-
- -
-
-
+
+ +
- ); +
+
+ ); } diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index 47b6771..6849390 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -2,39 +2,39 @@ import { XCircleIcon } from "@phosphor-icons/react"; import type { ReactNode } from "react"; interface ModalProps { - isOpen: boolean; - onClose?: () => void; - children: ReactNode; - "data-testid"?: string; + isOpen: boolean; + onClose?: () => void; + children: ReactNode; + "data-testid"?: string; } export function Modal({ - isOpen, - onClose, - children, - "data-testid": testId, + isOpen, + onClose, + children, + "data-testid": testId, }: ModalProps) { - if (!isOpen) return null; + if (!isOpen) return null; - return ( -
-
- {onClose && ( - - )} - {children} -
-
- ); + return ( +
+
+ {onClose && ( + + )} + {children} +
+
+ ); } diff --git a/frontend/src/config/welcomeLetter.ts b/frontend/src/config/welcomeLetter.ts index e608088..ce47413 100644 --- a/frontend/src/config/welcomeLetter.ts +++ b/frontend/src/config/welcomeLetter.ts @@ -1,5 +1,5 @@ -import type { CanvasJSON } from "../components/editor/ComposeCanvas"; import trainImage from "../assets/screenshots/train.png"; +import type { CanvasJSON } from "../components/editor/ComposeCanvas"; export function getWelcomeLetterContent(userName: string): CanvasJSON { return { diff --git a/frontend/src/hooks/useLetters.tsx b/frontend/src/hooks/useLetters.tsx index 4597e75..0a23dbe 100644 --- a/frontend/src/hooks/useLetters.tsx +++ b/frontend/src/hooks/useLetters.tsx @@ -6,87 +6,87 @@ import { useKeyStore } from "../store/useKeyStore"; import { CryptoUtils } from "../utils/crypto"; export interface ProcessedLetter extends LetterResponseData { - metadata: LetterMetadata; + metadata: LetterMetadata; } async function decryptLettersMetadata( - letters: LetterResponseData[], - masterKey: CryptoKey, + letters: LetterResponseData[], + masterKey: CryptoKey, ): Promise { - const cryptoUtils = new CryptoUtils(); + const cryptoUtils = new CryptoUtils(); - return Promise.all( - letters.map(async (letter) => { - try { - const metadata = (await cryptoUtils.decryptMetadata( - { - encrypted_content: letter.encrypted_metadata, - encrypted_dek: letter.encrypted_dek, - }, - masterKey, - )) as LetterMetadata; + return Promise.all( + letters.map(async (letter) => { + try { + const metadata = (await cryptoUtils.decryptMetadata( + { + encrypted_content: letter.encrypted_metadata, + encrypted_dek: letter.encrypted_dek, + }, + masterKey, + )) as LetterMetadata; - return { ...letter, metadata }; - } catch { - return { - ...letter, - metadata: { recipient: "Encrypted Letter" }, - }; - } - }), - ); + return { ...letter, metadata }; + } catch { + return { + ...letter, + metadata: { recipient: "Encrypted Letter" }, + }; + } + }), + ); } export function useLetters() { - const [letters, setLetters] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [isAuthRequired, setIsAuthRequired] = useState(false); - const { masterKey } = useKeyStore(); + const [letters, setLetters] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [isAuthRequired, setIsAuthRequired] = useState(false); + const { masterKey } = useKeyStore(); - // to fetch the letters and decryypt the metadata on load - useEffect(() => { - if (!masterKey) { - setIsAuthRequired(true); - return; - } - setIsAuthRequired(false); - setError(null); - setLoading(true); - api - .get(endpoints.LETTERS) - .then((res) => decryptLettersMetadata(res.data, masterKey)) - .then((decrypted) => { - setLetters( - decrypted.sort( - (a, b) => - new Date(b.updated_at).getTime() - - new Date(a.updated_at).getTime(), - ), - ); - }) - .catch((err) => { - setError(err); - }) - .finally(() => setLoading(false)); - }, [masterKey]); - - const drawerItems = useMemo(() => { - return { - drafts: letters.filter((l) => l.status === "DRAFT"), - kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"), - vault: letters.filter((l) => l.type === "VAULT" && l.status === "SEALED"), - sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"), - }; - }, [letters]); - - if (error) { - throw error; + // to fetch the letters and decryypt the metadata on load + useEffect(() => { + if (!masterKey) { + setIsAuthRequired(true); + return; } + setIsAuthRequired(false); + setError(null); + setLoading(true); + api + .get(endpoints.LETTERS) + .then((res) => decryptLettersMetadata(res.data, masterKey)) + .then((decrypted) => { + setLetters( + decrypted.sort( + (a, b) => + new Date(b.updated_at).getTime() - + new Date(a.updated_at).getTime(), + ), + ); + }) + .catch((err) => { + setError(err); + }) + .finally(() => setLoading(false)); + }, [masterKey]); + const drawerItems = useMemo(() => { return { - ...drawerItems, - loading, - isAuthRequired, + drafts: letters.filter((l) => l.status === "DRAFT"), + kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"), + vault: letters.filter((l) => l.type === "VAULT" && l.status === "SEALED"), + sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"), }; + }, [letters]); + + if (error) { + throw error; + } + + return { + ...drawerItems, + loading, + isAuthRequired, + }; } diff --git a/frontend/src/pages/Activate.tsx b/frontend/src/pages/Activate.tsx index 9f3de48..ccdb492 100644 --- a/frontend/src/pages/Activate.tsx +++ b/frontend/src/pages/Activate.tsx @@ -7,99 +7,99 @@ import { endpoints, replacePathParams } from "../config/endpoints"; import { ROUTES } from "../config/routes"; export default function Activate() { - const { uidb64, token } = useParams(); - const [status, setStatus] = useState<"loading" | "success" | "error">( - "loading", - ); - const hasCalled = useRef(false); - const navigate = useNavigate(); + const { uidb64, token } = useParams(); + const [status, setStatus] = useState<"loading" | "success" | "error">( + "loading", + ); + const hasCalled = useRef(false); + const navigate = useNavigate(); - useEffect(() => { - if (!(uidb64 && token) || hasCalled.current) return; - hasCalled.current = true; + useEffect(() => { + if (!(uidb64 && token) || hasCalled.current) return; + hasCalled.current = true; - const activateAccount = async () => { - try { - const url = replacePathParams(endpoints.ACTIVATE, { - uidb64, - token, - }); - await publicApi.get(url); - setStatus("success"); - } catch { - setStatus("error"); - } - }; + const activateAccount = async () => { + try { + const url = replacePathParams(endpoints.ACTIVATE, { + uidb64, + token, + }); + await publicApi.get(url); + setStatus("success"); + } catch { + setStatus("error"); + } + }; - activateAccount(); - }, [uidb64, token]); + activateAccount(); + }, [uidb64, token]); - return ( -
- {status === "loading" && ( -
- -

Activating your account...

-
- )} - - {status === "success" && ( -
-
- -
-

- You're in. -

-

- Welcome to -
- Just one more step and you can start writing timeless letters. -

-
- -
- )} - - {status === "error" && ( -
-
- -
-

Activation Failed

-

- The link might be expired or already used. Please try registering - again. -

-
- -
- )} + return ( +
+ {status === "loading" && ( +
+ +

Activating your account...

- ); + )} + + {status === "success" && ( +
+
+ +
+

+ You're in. +

+

+ Welcome to +
+ Just one more step and you can start writing timeless letters. +

+
+ +
+ )} + + {status === "error" && ( +
+
+ +
+

Activation Failed

+

+ The link might be expired or already used. Please try registering + again. +

+
+ +
+ )} +
+ ); } diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 44e16bd..ca6dc3c 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -1,27 +1,27 @@ 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"; @@ -42,482 +42,486 @@ 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 [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 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 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); + } + }; + + 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()); + } }); - const [showSaveOverlay, setShowSaveOverlay] = useState(false); - const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>( - null, + }, [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 [recipient, setRecipient] = useState(""); - const [unlockDate, setUnlockDate] = useState(null); - const [placeholderIndex, setPlaceholderIndex] = useState(0); - const [canvasFontStyle, setCanvasFontStyle] = useState({ - fontColor: "", - fontFamily: "", + 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 getRequestData = async ( + targetId: string, + status: string, + vaultDate?: Date, + ): Promise => { + const cryptoUtils = new CryptoUtils(); + await cryptoUtils.initialize(); + + const canvasData = (await canvasRef.current?.getData()) || { objects: [] }; + const canvasImages = canvasRef.current?.getImages() || []; + + const { encryptedImageFiles, encryptedCanvasData } = + await encryptCanvasImages( + canvasData, + canvasImages, + // biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here + masterKey!, + cryptoUtils, + ); + + const encrypted_letter = await cryptoUtils.encryptLetter( + JSON.stringify(encryptedCanvasData), + // biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here + masterKey!, + ); + + const encrypted_metadata = await cryptoUtils.encryptMetadata( + { recipient, tags: [] }, + // biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here + 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); }); - const { masterKey } = useKeyStore(); + return formData; + }; - const canvasRef = useRef(null); - const fileInputRef = useRef(null); + 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(); - // 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 || ""); + try { + const formData = await getRequestData(targetId, status, vaultDate); + await api.put(`${endpoints.LETTERS}${targetId}/`, formData); - const decryptedJsonStr = await cryptoUtils.decryptLetter( - { - encrypted_content: letterData.encrypted_content, - encrypted_dek: letterData.encrypted_dek, - }, - masterKey, - ); - const canvasData = JSON.parse(decryptedJsonStr); + justSavedRef.current = true; + if (!public_id) { + letterIdRef.current = targetId; + navigate(PATHS.write(targetId), { replace: true }); + } - const { errors, isPartialFailure, canvasDataWithDecryptedImages } = - await decryptCanvasImages( - canvasData, - letterData.images ?? [], - letterData.encrypted_dek, - masterKey, - cryptoUtils, - true, - ); + setLastSaved(formatRelativeDate(new Date())); + setLetterStatus(status); + setLastSavedPulseTick((prev) => prev + 1); - if (isPartialFailure) { - setDecryptionStatus({ - status: "WARN", - message: "Failed to decrypt some elements. Please check the render.", - log: errors.toString(), - }); - } + if (status === "SEALED" || status === "VAULT") { + setSealedTargetId(targetId); + } + setSaveOverlay("SAVED"); + setShowSaveOverlay(true); + } catch { + setSaveOverlay("ERROR"); + setShowSaveOverlay(true); + } + }; - 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, - ); - - 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 getRequestData = async ( - targetId: string, - status: string, - vaultDate?: Date, - ): Promise => { - const cryptoUtils = new CryptoUtils(); - await cryptoUtils.initialize(); - - const canvasData = (await canvasRef.current?.getData()) || { objects: [] }; - const canvasImages = canvasRef.current?.getImages() || []; - - const { encryptedImageFiles, encryptedCanvasData } = - await encryptCanvasImages( - canvasData, - canvasImages, - // biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here - masterKey!, - cryptoUtils, - ); - - const encrypted_letter = await cryptoUtils.encryptLetter( - JSON.stringify(encryptedCanvasData), - // biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here - masterKey!, - ); - - const encrypted_metadata = await cryptoUtils.encryptMetadata( - { recipient, tags: [] }, - // biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here - 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); - }); - - 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} -
- -
- } + return ( + <> + +
+ + Last Save + +
+ {lastSaved} +
+ +
+ } + /> -
- - setDecryptionStatus({ status: "RESET", message: "", log: "" }) - } - isOpen={decryptionStatus.status !== "RESET"} +
+ + setDecryptionStatus({ status: "RESET", message: "", log: "" }) + } + isOpen={decryptionStatus.status !== "RESET"} + /> + + {isInitialLoading && ( +
+
+ +

+ Opening your draft... +

+
+
+ )} + + {saveOverlay !== "IDLE" && ( + + {saveOverlay === "SAVING" && ( +
+ + Securing your letter... +
+ )} - {isInitialLoading && ( -
-
- -

- Opening your draft... -

-
-
- )} + {saveOverlay === "SAVED" && ( +
+ + Your letter is saved! +
+ )} - {saveOverlay !== "IDLE" && ( - - {saveOverlay === "SAVING" && ( -
- - Securing your letter... -
- )} + {saveOverlay === "ERROR" && ( +
+ + Failed to save letter +
+ )} +
+ )} - {saveOverlay === "SAVED" && ( -
- - Your letter is saved! -
- )} + {confirmModal === "VAULT" && ( + + )} + {sealedTargetId && ( + + )} - {saveOverlay === "ERROR" && ( -
- - Failed to save letter -
- )} -
- )} +
+
+
+ + 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" + /> +
+ +
- {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"} + {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"} + /> + + ); } diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index 9026dec..87323a7 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,342 +26,343 @@ 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 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 { + // shouldn't obstruct share if api operation fails (since it's client side share) + } 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 { + // 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 (!(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 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 { - // shouldn't obstruct share if api operation fails (since it's client side share) - } finally { - setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`); - } + await decryptLetterData(data, cryptoUtils); + } catch (err) { + setLogTrace({ + message: `Failed to load letter ☹`, + log: err instanceof Error ? err.message : "Unknown error", + type: "ERROR", + }); + } }; - 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); - } - }; + loadAndDecrypt().then(() => setIsDecrypting(false)); + }, [public_id, sharingKey, masterKey]); - 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 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... -

-
-
-
- ); - } - - if (logTrace) { - return ( - { - if (logTrace.type === "ERROR") window.location.href = "/"; - setLogTrace(null); - }} - message={logTrace.message} - log={logTrace.log} - status={logTrace.type} - /> - ); + useEffect(() => { + if ( + !isDecrypting && + revealState === "REVEALED" && + decryptedCanvasData && + canvasRef.current + ) { + canvasRef.current.loadData(decryptedCanvasData); } + }, [isDecrypting, revealState, decryptedCanvasData]); + if (isDecrypting) { return ( -
-
-
+
+
+ +
+ +

- {revealState === "SEALED" && ( -

-
- setRevealState("REVEALED")} - ignite={ignite} - /> -
-
- )} + 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" && ( +
+
+ setRevealState("REVEALED")} + ignite={ignite} + /> +
+
+ )} +
+ + {ignite && } + + {revealState === "REVEALED" && ( +
+
+
+ +
+
+
- {ignite && } - - {revealState === "REVEALED" && ( -
-
-
- -
-
- -
- - {metadata?.recipient && ( -

- For {metadata.recipient} -

- )} -
-
+ {metadata?.recipient && ( +

+ For {metadata.recipient} +

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

- Read. Remember. Release. -

-
-
- ); + +
+ ); }