refactor: format whitespaces

This commit is contained in:
me
2026-05-08 11:17:41 +05:30
parent ed79429735
commit d0947dc5af
8 changed files with 1083 additions and 1083 deletions
+48 -53
View File
@@ -3,63 +3,58 @@ import logo from "../assets/logo.svg";
import "@fontsource/knewave/400.css"; import "@fontsource/knewave/400.css";
interface LogoProps { interface LogoProps {
scale?: number; scale?: number;
type?: "inline" | "mono" | "logo" | null; type?: "inline" | "mono" | "logo" | null;
ul?: boolean; ul?: boolean;
} }
export default function Logo({ export default function Logo({
scale = 1, scale = 1,
type = null, type = null,
ul = false, ul = false,
}: LogoProps) { }: LogoProps) {
if (type === "inline") { if (type === "inline") {
return (
<span className={"text-accent font-display italic "}>
pi<span className="text-primary">.</span>&nbsp;ku
<span className="text-primary">.</span>&nbsp;
</span>
);
}
if (type === "mono") {
return (
<span className="font-display italic font-bold border-b-3 border-dashed border-stone-800/50">
pi. ku.
</span>
);
}
if (type === "logo") {
return (
<img
src={logo}
alt="Pi. Ku. logo"
className="mx-4"
width={scale * 100}
/>
);
}
return ( return (
<div <span className={"text-accent font-display italic "}>
role="img" pi<span className="text-primary">.</span>&nbsp;ku
aria-label="Pi. Ku. logo" <span className="text-primary">.</span>&nbsp;
className={`inline-flex items-baseline justify-center leading-none select-none ${ul ? "ul-wavy" : ""}`} </span>
style={{ fontFamily: "'Knewave', serif", scale }}
>
<span className="text-3xl font-light text-accent">Pi</span>
<DotIcon
weight="fill"
size={12}
className="text-primary translate-y-1 -mx-px"
/>
<span className="text-3xl font-light text-accent">&nbsp;Ku</span>
<DotIcon
weight="fill"
size={12}
className="text-primary translate-y-1 -mx-px"
/>
</div>
); );
}
if (type === "mono") {
return (
<span className="font-display italic font-bold border-b-3 border-dashed border-stone-800/50">
pi. ku.
</span>
);
}
if (type === "logo") {
return (
<img src={logo} alt="Pi. Ku. logo" className="mx-4" width={scale * 100} />
);
}
return (
<div
role="img"
aria-label="Pi. Ku. logo"
className={`inline-flex items-baseline justify-center leading-none select-none ${ul ? "ul-wavy" : ""}`}
style={{ fontFamily: "'Knewave', serif", scale }}
>
<span className="text-3xl font-light text-accent">Pi</span>
<DotIcon
weight="fill"
size={12}
className="text-primary translate-y-1 -mx-px"
/>
<span className="text-3xl font-light text-accent">&nbsp;Ku</span>
<DotIcon
weight="fill"
size={12}
className="text-primary translate-y-1 -mx-px"
/>
</div>
);
} }
@@ -6,73 +6,73 @@ import { type CanvasTools, ComposeCanvas } from "../editor/ComposeCanvas";
import { EnvelopeReveal } from "../reader/EnvelopeReveal"; import { EnvelopeReveal } from "../reader/EnvelopeReveal";
export interface WelcomeLetterOverlayProps { export interface WelcomeLetterOverlayProps {
onComplete: () => void; onComplete: () => void;
userName: string; userName: string;
} }
export function WelcomeLetterOverlay({ export function WelcomeLetterOverlay({
onComplete, onComplete,
userName, userName,
}: WelcomeLetterOverlayProps) { }: WelcomeLetterOverlayProps) {
const [revealState, setRevealState] = useState<"SEALED" | "REVEALED">( const [revealState, setRevealState] = useState<"SEALED" | "REVEALED">(
"SEALED", "SEALED",
); );
const canvasRef = useRef<CanvasTools>(null); const canvasRef = useRef<CanvasTools>(null);
useEffect(() => { useEffect(() => {
if (revealState === "REVEALED" && canvasRef.current) { if (revealState === "REVEALED" && canvasRef.current) {
const welcomeContent = getWelcomeLetterContent(userName); const welcomeContent = getWelcomeLetterContent(userName);
canvasRef.current.loadData(welcomeContent); canvasRef.current.loadData(welcomeContent);
} }
}, [revealState, userName]); }, [revealState, userName]);
return ( return (
<div className="fixed inset-0 z-30 backdrop-blur-3xl flex flex-col items-center justify-center p-4 md:p-8 overflow-x-hidden"> <div className="fixed inset-0 z-30 backdrop-blur-3xl flex flex-col items-center justify-center p-4 md:p-8 overflow-x-hidden">
<div className="fixed inset-0 bg-vig pointer-events-none z-0" /> <div className="fixed inset-0 bg-vig pointer-events-none z-0" />
<div className="w-full max-w-4xl z-10 flex flex-col items-center"> <div className="w-full max-w-4xl z-10 flex flex-col items-center">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{revealState === "SEALED" && ( {revealState === "SEALED" && (
<motion.div <motion.div
key="envelope" key="envelope"
initial={{ scale: 0.5, opacity: 0 }} initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 0.8, opacity: 1 }} animate={{ scale: 0.8, opacity: 1 }}
exit={{ exit={{
scale: 1, scale: 1,
opacity: 0, opacity: 0,
transition: { duration: 0.5, ease: "easeOut" }, transition: { duration: 0.5, ease: "easeOut" },
}} }}
transition={{ duration: 4, delay: 1 }} transition={{ duration: 4, delay: 1 }}
> >
<EnvelopeReveal <EnvelopeReveal
recipient={userName} recipient={userName}
date={formatDate(new Date())} date={formatDate(new Date())}
onRevealComplete={() => setRevealState("REVEALED")} onRevealComplete={() => setRevealState("REVEALED")}
ignite={false} ignite={false}
/> />
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
<div <div
className={`w-full space-y-8 py-12 ${revealState === "REVEALED" ? "block" : "hidden"}`} className={`w-full space-y-8 py-12 ${revealState === "REVEALED" ? "block" : "hidden"}`}
> >
<div className="bg-paper shadow-warm rounded-sm overflow-hidden mx-auto max-w-180"> <div className="bg-paper shadow-warm rounded-sm overflow-hidden mx-auto max-w-180">
<div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" /> <div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" />
<ComposeCanvas ref={canvasRef} readOnly /> <ComposeCanvas ref={canvasRef} readOnly />
</div> </div>
<div className="flex justify-center mt-12"> <div className="flex justify-center mt-12">
<button <button
type="button" type="button"
data-testid="dismiss-welcome-letter-btn" data-testid="dismiss-welcome-letter-btn"
onClick={onComplete} onClick={onComplete}
className="btn btn-base btn-xs btn-wide opacity-80 shadow-lg font-light tracking-wider" className="btn btn-base btn-xs btn-wide opacity-80 shadow-lg font-light tracking-wider"
> >
I'll see you I'll see you
</button> </button>
</div> </div>
</div>
</div>
</div> </div>
); </div>
</div>
);
} }
+30 -30
View File
@@ -2,39 +2,39 @@ import { XCircleIcon } from "@phosphor-icons/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
interface ModalProps { interface ModalProps {
isOpen: boolean; isOpen: boolean;
onClose?: () => void; onClose?: () => void;
children: ReactNode; children: ReactNode;
"data-testid"?: string; "data-testid"?: string;
} }
export function Modal({ export function Modal({
isOpen, isOpen,
onClose, onClose,
children, children,
"data-testid": testId, "data-testid": testId,
}: ModalProps) { }: ModalProps) {
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div <div
data-testid={testId} data-testid={testId}
className="modal modal-open modal-middle backdrop-blur-md before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/textures/noise.gif')]" className="modal modal-open modal-middle backdrop-blur-md before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/textures/noise.gif')]"
> >
<div className="modal-box relative bg-base-100/60 flex flex-col items-center text-center gap-6"> <div className="modal-box relative bg-base-100/60 flex flex-col items-center text-center gap-6">
{onClose && ( {onClose && (
<button <button
type="button" type="button"
data-testid="modal-close-btn" data-testid="modal-close-btn"
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 z-20" className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 z-20"
onClick={onClose} onClick={onClose}
aria-label="Close" aria-label="Close"
> >
<XCircleIcon size={18} weight="bold" /> <XCircleIcon size={18} weight="bold" />
</button> </button>
)} )}
{children} {children}
</div> </div>
</div> </div>
); );
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import type { CanvasJSON } from "../components/editor/ComposeCanvas";
import trainImage from "../assets/screenshots/train.png"; import trainImage from "../assets/screenshots/train.png";
import type { CanvasJSON } from "../components/editor/ComposeCanvas";
export function getWelcomeLetterContent(userName: string): CanvasJSON { export function getWelcomeLetterContent(userName: string): CanvasJSON {
return { return {
+69 -69
View File
@@ -6,87 +6,87 @@ import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto"; import { CryptoUtils } from "../utils/crypto";
export interface ProcessedLetter extends LetterResponseData { export interface ProcessedLetter extends LetterResponseData {
metadata: LetterMetadata; metadata: LetterMetadata;
} }
async function decryptLettersMetadata( async function decryptLettersMetadata(
letters: LetterResponseData[], letters: LetterResponseData[],
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<ProcessedLetter[]> { ): Promise<ProcessedLetter[]> {
const cryptoUtils = new CryptoUtils(); const cryptoUtils = new CryptoUtils();
return Promise.all( return Promise.all(
letters.map(async (letter) => { letters.map(async (letter) => {
try { try {
const metadata = (await cryptoUtils.decryptMetadata( const metadata = (await cryptoUtils.decryptMetadata(
{ {
encrypted_content: letter.encrypted_metadata, encrypted_content: letter.encrypted_metadata,
encrypted_dek: letter.encrypted_dek, encrypted_dek: letter.encrypted_dek,
}, },
masterKey, masterKey,
)) as LetterMetadata; )) as LetterMetadata;
return { ...letter, metadata }; return { ...letter, metadata };
} catch { } catch {
return { return {
...letter, ...letter,
metadata: { recipient: "Encrypted Letter" }, metadata: { recipient: "Encrypted Letter" },
}; };
} }
}), }),
); );
} }
export function useLetters() { export function useLetters() {
const [letters, setLetters] = useState<ProcessedLetter[]>([]); const [letters, setLetters] = useState<ProcessedLetter[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false); const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false);
const { masterKey } = useKeyStore(); const { masterKey } = useKeyStore();
// to fetch the letters and decryypt the metadata on load // to fetch the letters and decryypt the metadata on load
useEffect(() => { useEffect(() => {
if (!masterKey) { if (!masterKey) {
setIsAuthRequired(true); setIsAuthRequired(true);
return; 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;
} }
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 { return {
...drawerItems, drafts: letters.filter((l) => l.status === "DRAFT"),
loading, kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"),
isAuthRequired, 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,
};
} }
+90 -90
View File
@@ -7,99 +7,99 @@ import { endpoints, replacePathParams } from "../config/endpoints";
import { ROUTES } from "../config/routes"; import { ROUTES } from "../config/routes";
export default function Activate() { export default function Activate() {
const { uidb64, token } = useParams(); const { uidb64, token } = useParams();
const [status, setStatus] = useState<"loading" | "success" | "error">( const [status, setStatus] = useState<"loading" | "success" | "error">(
"loading", "loading",
); );
const hasCalled = useRef(false); const hasCalled = useRef(false);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (!(uidb64 && token) || hasCalled.current) return; if (!(uidb64 && token) || hasCalled.current) return;
hasCalled.current = true; hasCalled.current = true;
const activateAccount = async () => { const activateAccount = async () => {
try { try {
const url = replacePathParams(endpoints.ACTIVATE, { const url = replacePathParams(endpoints.ACTIVATE, {
uidb64, uidb64,
token, token,
}); });
await publicApi.get(url); await publicApi.get(url);
setStatus("success"); setStatus("success");
} catch { } catch {
setStatus("error"); setStatus("error");
} }
}; };
activateAccount(); activateAccount();
}, [uidb64, token]); }, [uidb64, token]);
return ( return (
<div className="glass-card w-full max-w-sm p-8 text-center fade-zoom"> <div className="glass-card w-full max-w-sm p-8 text-center fade-zoom">
{status === "loading" && ( {status === "loading" && (
<div className="flex flex-col items-center gap-4 py-8"> <div className="flex flex-col items-center gap-4 py-8">
<span className="loading loading-spinner loading-lg text-primary" /> <span className="loading loading-spinner loading-lg text-primary" />
<p className="text-sm opacity-70">Activating your account...</p> <p className="text-sm opacity-70">Activating your account...</p>
</div>
)}
{status === "success" && (
<div className="flex flex-col items-center gap-6 duration-500">
<div className="bg-success/10 p-4 rounded-full">
<CheckCircleIcon
size={64}
weight="duotone"
className="text-success"
/>
</div>
<h2
data-testid="activation-success-header"
className="font-display text-xl text-success"
>
You're in.
</h2>
<p className="opacity-70 leading-relaxed">
Welcome to <Logo scale={1} />
<br />
Just one more step and you can start writing timeless letters.
</p>
<div className="divider opacity-10 my-0"></div>
<button
type="button"
data-testid="start-writing-btn"
className="btn btn-primary w-full shadow-lg"
onClick={() =>
navigate(ROUTES.LOGIN, {
state: { firstTime: true },
replace: true,
})
}
>
I'm ready
</button>
</div>
)}
{status === "error" && (
<div className="flex flex-col items-center gap-6 animate-in fade-in zoom-in duration-500">
<div className="bg-error/10 p-4 rounded-full">
<XCircleIcon size={64} weight="duotone" className="text-error" />
</div>
<h2 className="font-display text-xl text-error">Activation Failed</h2>
<p className="opacity-70 leading-relaxed">
The link might be expired or already used. Please try registering
again.
</p>
<div className="divider opacity-10 my-0"></div>
<button
type="button"
className="btn btn-ghost w-full"
onClick={() => navigate(ROUTES.ONBOARD)}
>
Register Again
</button>
</div>
)}
</div> </div>
); )}
{status === "success" && (
<div className="flex flex-col items-center gap-6 duration-500">
<div className="bg-success/10 p-4 rounded-full">
<CheckCircleIcon
size={64}
weight="duotone"
className="text-success"
/>
</div>
<h2
data-testid="activation-success-header"
className="font-display text-xl text-success"
>
You're in.
</h2>
<p className="opacity-70 leading-relaxed">
Welcome to <Logo scale={1} />
<br />
Just one more step and you can start writing timeless letters.
</p>
<div className="divider opacity-10 my-0"></div>
<button
type="button"
data-testid="start-writing-btn"
className="btn btn-primary w-full shadow-lg"
onClick={() =>
navigate(ROUTES.LOGIN, {
state: { firstTime: true },
replace: true,
})
}
>
I'm ready
</button>
</div>
)}
{status === "error" && (
<div className="flex flex-col items-center gap-6 animate-in fade-in zoom-in duration-500">
<div className="bg-error/10 p-4 rounded-full">
<XCircleIcon size={64} weight="duotone" className="text-error" />
</div>
<h2 className="font-display text-xl text-error">Activation Failed</h2>
<p className="opacity-70 leading-relaxed">
The link might be expired or already used. Please try registering
again.
</p>
<div className="divider opacity-10 my-0"></div>
<button
type="button"
className="btn btn-ghost w-full"
onClick={() => navigate(ROUTES.ONBOARD)}
>
Register Again
</button>
</div>
)}
</div>
);
} }
+465 -461
View File
@@ -1,27 +1,27 @@
import { import {
ClockIcon, ClockIcon,
DownloadSimpleIcon, DownloadSimpleIcon,
SpinnerGapIcon, SpinnerGapIcon,
XIcon, XIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
type NavigateFunction, type NavigateFunction,
useNavigate, useNavigate,
useParams, useParams,
} from "react-router-dom"; } from "react-router-dom";
import { api } from "../api/apiClient"; import { api } from "../api/apiClient";
import type { LetterResponseData } from "../api/response"; import type { LetterResponseData } from "../api/response";
import { import {
type CanvasStyle, type CanvasStyle,
type CanvasTools, type CanvasTools,
ComposeCanvas, ComposeCanvas,
} from "../components/editor/ComposeCanvas"; } from "../components/editor/ComposeCanvas";
import { PostSealModal } from "../components/editor/PostSealModal"; import { PostSealModal } from "../components/editor/PostSealModal";
import { import {
LetterHead, LetterHead,
ToolBar, ToolBar,
VaultConfirmModal, VaultConfirmModal,
} from "../components/editor/ToolBar"; } from "../components/editor/ToolBar";
import DateDisplay from "../components/ui/DateDisplay"; import DateDisplay from "../components/ui/DateDisplay";
import { LogModal } from "../components/ui/LogModal"; import { LogModal } from "../components/ui/LogModal";
@@ -42,482 +42,486 @@ const ERROR_VISIBLE_MS = 2400;
const STOP_SAVE_DATE_PULSE_AFTER_MS = 10000; const STOP_SAVE_DATE_PULSE_AFTER_MS = 10000;
const toPlaceholderList = [ const toPlaceholderList = [
"Someone dear...", "Someone dear...",
"Somewhere near...", "Somewhere near...",
"Something to bear...", "Something to bear...",
]; ];
const MAX_FILE_SIZE = 10 * 1024 * 1024; const MAX_FILE_SIZE = 10 * 1024 * 1024;
export default function Editor() { export default function Editor() {
const navigate = useNavigate(); const navigate = useNavigate();
const navigateRef = useRef<NavigateFunction>(navigate); const navigateRef = useRef<NavigateFunction>(navigate);
navigateRef.current = navigate; navigateRef.current = navigate;
const { public_id } = useParams(); const { public_id } = useParams();
const letterIdRef = useRef<string>(public_id ?? ""); const letterIdRef = useRef<string>(public_id ?? "");
const justSavedRef = useRef<boolean>(false); const justSavedRef = useRef<boolean>(false);
const [decryptionStatus, setDecryptionStatus] = useState<{ const [decryptionStatus, setDecryptionStatus] = useState<{
status: "SUCCESS" | "WARN" | "ERROR" | "RESET"; status: "SUCCESS" | "WARN" | "ERROR" | "RESET";
message: string; message: string;
log: string; log: string;
}>({ status: "RESET", message: "", log: "" }); }>({ status: "RESET", message: "", log: "" });
const [isInitialLoading, setIsInitialLoading] = useState(false); const [isInitialLoading, setIsInitialLoading] = useState(false);
const [sealedTargetId, setSealedTargetId] = useState<string | null>(null); const [sealedTargetId, setSealedTargetId] = useState<string | null>(null);
const [lastSaved, setLastSaved] = useState<string | null>(null); const [lastSaved, setLastSaved] = useState<string | null>(null);
const [status, setLetterStatus] = useState<"DRAFT" | "SEALED" | "VAULT">( const [status, setLetterStatus] = useState<"DRAFT" | "SEALED" | "VAULT">(
"DRAFT", "DRAFT",
); );
const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false); const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false);
const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0); const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0);
const [sealBtnClicked, setSealBtnClicked] = useState<boolean>(false); const [sealBtnClicked, setSealBtnClicked] = useState<boolean>(false);
const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("IDLE"); const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("IDLE");
const [logStatus, setLogStatus] = useState<{ const [logStatus, setLogStatus] = useState<{
status: "WARN" | "ERROR" | "RESET"; status: "WARN" | "ERROR" | "RESET";
message: string; message: string;
}>({ }>({
status: "RESET", status: "RESET",
message: "", message: "",
});
const [showSaveOverlay, setShowSaveOverlay] = useState(false);
const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
null,
);
const [recipient, setRecipient] = useState("");
const [unlockDate, setUnlockDate] = useState<Date | null>(null);
const [placeholderIndex, setPlaceholderIndex] = useState(0);
const [canvasFontStyle, setCanvasFontStyle] = useState<CanvasStyle>({
fontColor: "",
fontFamily: "",
});
const { masterKey } = useKeyStore();
const canvasRef = useRef<CanvasTools>(null);
const fileInputRef = useRef<HTMLInputElement>(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); }, [public_id, masterKey]);
const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
null, // 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(""); return () => {
const [unlockDate, setUnlockDate] = useState<Date | null>(null); clearTimeout(visibleTimer);
const [placeholderIndex, setPlaceholderIndex] = useState(0); clearTimeout(unmountTimer);
const [canvasFontStyle, setCanvasFontStyle] = useState<CanvasStyle>({ };
fontColor: "", }, [saveOverlay]);
fontFamily: "",
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
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<FormData> => {
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<CanvasTools>(null); const handleSave = async (
const fileInputRef = useRef<HTMLInputElement>(null); status: "SEALED" | "DRAFT" | "VAULT",
vaultDate?: Date,
): Promise<void> => {
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 if (saveOverlay === "SAVING" || !masterKey) return;
useEffect(() => {
const interval = setInterval(() => {
setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length);
}, 4000);
return () => clearInterval(interval); setSaveOverlay("SAVING");
}, []); setShowSaveOverlay(true);
// to load existing letter when public_id param and masterKey is available try {
// NOTE: this has to trigger just once after each save const formData = await getRequestData(targetId, status, vaultDate);
useEffect(() => { await api.put(`${endpoints.LETTERS}${targetId}/`, formData);
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( justSavedRef.current = true;
{ if (!public_id) {
encrypted_content: letterData.encrypted_content, letterIdRef.current = targetId;
encrypted_dek: letterData.encrypted_dek, navigate(PATHS.write(targetId), { replace: true });
}, }
masterKey,
);
const canvasData = JSON.parse(decryptedJsonStr);
const { errors, isPartialFailure, canvasDataWithDecryptedImages } = setLastSaved(formatRelativeDate(new Date()));
await decryptCanvasImages( setLetterStatus(status);
canvasData, setLastSavedPulseTick((prev) => prev + 1);
letterData.images ?? [],
letterData.encrypted_dek,
masterKey,
cryptoUtils,
true,
);
if (isPartialFailure) { if (status === "SEALED" || status === "VAULT") {
setDecryptionStatus({ setSealedTargetId(targetId);
status: "WARN", }
message: "Failed to decrypt some elements. Please check the render.", setSaveOverlay("SAVED");
log: errors.toString(), setShowSaveOverlay(true);
}); } catch {
} setSaveOverlay("ERROR");
setShowSaveOverlay(true);
}
};
if (canvasRef.current) { return (
await canvasRef.current.loadData(canvasDataWithDecryptedImages); <>
} <Navbar
}; child={
<div
const loadExistingLetter = async () => { className={`flex items-center gap-2 ${
setIsInitialLoading(true); isSaveDatePulsing ? "animate-pulse" : ""
try { }`}
const res = await api.get(`${endpoints.LETTERS}${public_id}/`); >
const letterData = res.data; <div className="text-xxs text-neutral-content/30 flex-col justify-end leading-none text-right">
<span className="uppercase tracking-widest font-bold">
setLastSaved(formatRelativeDate(new Date(letterData.updated_at))); Last Save
setLetterStatus(letterData.status); </span>
<br />
if (letterData.status === "SEALED") { <span className="italic">{lastSaved}</span>
navigateRef.current(PATHS.read(public_id), { replace: true }); </div>
return; <ClockIcon
} size={16}
weight="bold"
if (letterData.encrypted_dek && masterKey) { className="text-neutral-content/30"
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<HTMLInputElement>) => {
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<FormData> => {
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<void> => {
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 (
<>
<Navbar
child={
<div
className={`flex items-center gap-2 ${isSaveDatePulsing ? "animate-pulse" : ""
}`}
>
<div className="text-xxs text-neutral-content/30 flex-col justify-end leading-none text-right">
<span className="uppercase tracking-widest font-bold">
Last Save
</span>
<br />
<span className="italic">{lastSaved}</span>
</div>
<ClockIcon
size={16}
weight="bold"
className="text-neutral-content/30"
/>
</div>
}
/> />
</div>
}
/>
<section className="flex-1 overflow-y-auto scrollbar-hide px-2 pt-32 pb-12 bg-base-300 relative"> <section className="flex-1 overflow-y-auto scrollbar-hide px-2 pt-32 pb-12 bg-base-300 relative">
<LogModal <LogModal
status={decryptionStatus.status} status={decryptionStatus.status}
message={decryptionStatus.message} message={decryptionStatus.message}
log={decryptionStatus.log} log={decryptionStatus.log}
onClose={() => onClose={() =>
setDecryptionStatus({ status: "RESET", message: "", log: "" }) setDecryptionStatus({ status: "RESET", message: "", log: "" })
} }
isOpen={decryptionStatus.status !== "RESET"} isOpen={decryptionStatus.status !== "RESET"}
/>
{isInitialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-base-300/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<SpinnerGapIcon
size={48}
weight="bold"
className="animate-spin text-primary"
/>
<p
data-testid="opening-draft-overlay"
className="text-xxs uppercase tracking-widester font-bold text-base-content/40"
>
Opening your draft...
</p>
</div>
</div>
)}
{saveOverlay !== "IDLE" && (
<Modal isOpen={showSaveOverlay}>
{saveOverlay === "SAVING" && (
<div
role="alert"
className={`alert text-center alert-neutral shadow-lg transition-all ease-in-out duration-2000 ${
showSaveOverlay
? "opacity-100 scale-100 translate-y-0"
: "opacity-0 scale-95 translate-y-1"
}`}
>
<SpinnerGapIcon
size={18}
weight="bold"
className="animate-spin"
/> />
<span className="font-bold">Securing your letter...</span>
</div>
)}
{isInitialLoading && ( {saveOverlay === "SAVED" && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-base-300/80 backdrop-blur-sm"> <div
<div className="flex flex-col items-center gap-4"> role="alert"
<SpinnerGapIcon data-testid="save-success-toast"
size={48} className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${
weight="bold" showSaveOverlay
className="animate-spin text-primary" ? "opacity-100 scale-100 translate-y-0"
/> : "opacity-0 scale-95 translate-y-1"
<p }`}
data-testid="opening-draft-overlay" >
className="text-xxs uppercase tracking-widester font-bold text-base-content/40" <DownloadSimpleIcon size={18} weight="bold" />
> <span className="font-bold">Your letter is saved!</span>
Opening your draft... </div>
</p> )}
</div>
</div>
)}
{saveOverlay !== "IDLE" && ( {saveOverlay === "ERROR" && (
<Modal isOpen={showSaveOverlay}> <div
{saveOverlay === "SAVING" && ( role="alert"
<div className={`alert alert-error shadow-lg transition-all duration-300 ${
role="alert" showSaveOverlay
className={`alert text-center alert-neutral shadow-lg transition-all ease-in-out duration-2000 ${showSaveOverlay ? "opacity-100 scale-100 translate-y-0"
? "opacity-100 scale-100 translate-y-0" : "opacity-0 scale-95 translate-y-1"
: "opacity-0 scale-95 translate-y-1" }`}
}`} >
> <XIcon size={18} weight="bold" />
<SpinnerGapIcon <span className="font-bold">Failed to save letter</span>
size={18} </div>
weight="bold" )}
className="animate-spin" </Modal>
/> )}
<span className="font-bold">Securing your letter...</span>
</div>
)}
{saveOverlay === "SAVED" && ( {confirmModal === "VAULT" && (
<div <VaultConfirmModal
role="alert" onSave={handleSave}
data-testid="save-success-toast" setConfirmModal={setConfirmModal}
className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${showSaveOverlay setUnlockDate={setUnlockDate}
? "opacity-100 scale-100 translate-y-0" />
: "opacity-0 scale-95 translate-y-1" )}
}`} {sealedTargetId && (
> <PostSealModal
<DownloadSimpleIcon size={18} weight="bold" /> sealedTargetId={sealedTargetId}
<span className="font-bold">Your letter is saved!</span> navigate={navigate}
</div> type={status === "VAULT" ? "VAULT" : "KEPT"}
)} />
)}
{saveOverlay === "ERROR" && ( <div className="max-w-180 mx-auto px-1 md:px-0">
<div <div className="flex justify-between items-end mb-16 border-b border-base-content/5 pb-8 px-0">
role="alert" <div className="flex flex-col gap-2 flex-1">
className={`alert alert-error shadow-lg transition-all duration-300 ${showSaveOverlay <label
? "opacity-100 scale-100 translate-y-0" htmlFor="recipient"
: "opacity-0 scale-95 translate-y-1" className="text-xxs uppercase tracking-widester text-secondary-content font-bold"
}`} >
> Recipient
<XIcon size={18} weight="bold" /> </label>
<span className="font-bold">Failed to save letter</span> <input
</div> id="recipient"
)} data-testid="recipient-input"
</Modal> type="text"
)} placeholder={toPlaceholderList[placeholderIndex]}
value={recipient}
disabled={status !== "DRAFT"}
onChange={(e) => 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"
/>
</div>
<DateDisplay />
</div>
{confirmModal === "VAULT" && ( {status === "DRAFT" ? (
<VaultConfirmModal <ToolBar
onSave={handleSave} onAddImage={() => fileInputRef.current?.click()}
setConfirmModal={setConfirmModal} sealBtnClicked={sealBtnClicked}
setUnlockDate={setUnlockDate} setSealBtnClicked={setSealBtnClicked}
/> onSave={handleSave}
)} setConfirmModal={setConfirmModal}
{sealedTargetId && ( onFontChange={setCanvasFontStyle}
<PostSealModal latestFontStyle={canvasFontStyle}
sealedTargetId={sealedTargetId}
navigate={navigate}
type={status === "VAULT" ? "VAULT" : "KEPT"}
/>
)}
<div className="max-w-180 mx-auto px-1 md:px-0">
<div className="flex justify-between items-end mb-16 border-b border-base-content/5 pb-8 px-0">
<div className="flex flex-col gap-2 flex-1">
<label
htmlFor="recipient"
className="text-xxs uppercase tracking-widester text-secondary-content font-bold"
>
Recipient
</label>
<input
id="recipient"
data-testid="recipient-input"
type="text"
placeholder={toPlaceholderList[placeholderIndex]}
value={recipient}
disabled={status !== "DRAFT"}
onChange={(e) => 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"
/>
</div>
<DateDisplay />
</div>
{status === "DRAFT" ? (
<ToolBar
onAddImage={() => fileInputRef.current?.click()}
sealBtnClicked={sealBtnClicked}
setSealBtnClicked={setSealBtnClicked}
onSave={handleSave}
setConfirmModal={setConfirmModal}
onFontChange={setCanvasFontStyle}
latestFontStyle={canvasFontStyle}
/>
) : (
<LetterHead />
)}
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept="image/*"
className="hidden"
/>
<ComposeCanvas
ref={canvasRef}
readOnly={status !== "DRAFT"}
style={canvasFontStyle}
/>
</div>
</section>
<LogModal
status={logStatus.status}
message={logStatus.message}
log={""}
onClose={() =>
setLogStatus({
status: "RESET",
message: "",
})
}
isOpen={logStatus.status !== "RESET"}
/> />
</> ) : (
); <LetterHead />
)}
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept="image/*"
className="hidden"
/>
<ComposeCanvas
ref={canvasRef}
readOnly={status !== "DRAFT"}
style={canvasFontStyle}
/>
</div>
</section>
<LogModal
status={logStatus.status}
message={logStatus.message}
log={""}
onClose={() =>
setLogStatus({
status: "RESET",
message: "",
})
}
isOpen={logStatus.status !== "RESET"}
/>
</>
);
} }
+320 -319
View File
@@ -2,17 +2,17 @@ import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
type NavigateFunction, type NavigateFunction,
useLocation, useLocation,
useNavigate, useNavigate,
useParams, useParams,
} from "react-router-dom"; } from "react-router-dom";
import { api } from "../api/apiClient"; import { api } from "../api/apiClient";
import type { LetterImageData, LetterResponseData } from "../api/response"; import type { LetterImageData, LetterResponseData } from "../api/response";
import { import {
type CanvasJSON, type CanvasJSON,
type CanvasTools, type CanvasTools,
ComposeCanvas, ComposeCanvas,
} from "../components/editor/ComposeCanvas"; } from "../components/editor/ComposeCanvas";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import { BurnModal } from "../components/reader/BurnModal"; import { BurnModal } from "../components/reader/BurnModal";
@@ -26,342 +26,343 @@ import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto"; import { CryptoUtils } from "../utils/crypto";
import { formatDate } from "../utils/dateFormat"; import { formatDate } from "../utils/dateFormat";
import { import {
decryptCanvasImages, decryptCanvasImages,
decryptCanvasImagesWithSharingKey, decryptCanvasImagesWithSharingKey,
} from "../utils/letterLogic"; } from "../utils/letterLogic";
interface LetterMetadata { interface LetterMetadata {
recipient?: string; recipient?: string;
updated_at?: string; updated_at?: string;
} }
const WAIT_FOR_BURN_MS = 18000; const WAIT_FOR_BURN_MS = 18000;
export default function Reader() { export default function Reader() {
const { public_id } = useParams(); const { public_id } = useParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const sharingKey = location.hash.replace("#", ""); const sharingKey = location.hash.replace("#", "");
const navigateRef = useRef<NavigateFunction>(navigate); const navigateRef = useRef<NavigateFunction>(navigate);
const canvasRef = useRef<CanvasTools>(null); const canvasRef = useRef<CanvasTools>(null);
const [isDecrypting, setIsDecrypting] = useState(true); const [isDecrypting, setIsDecrypting] = useState(true);
const [revealState, setRevealState] = useState< const [revealState, setRevealState] = useState<
"SEALED" | "REVEALED" | "BURNED" | "BURNING" "SEALED" | "REVEALED" | "BURNED" | "BURNING"
>("SEALED"); >("SEALED");
const [logTrace, setLogTrace] = useState<{ const [logTrace, setLogTrace] = useState<{
type: "WARN" | "ERROR"; type: "WARN" | "ERROR";
message: string; message: string;
log: string; log: string;
} | null>(null); } | null>(null);
const [metadata, setMetadata] = useState<LetterMetadata | null>(null); const [metadata, setMetadata] = useState<LetterMetadata | null>(null);
const [decryptedCanvasData, setDecryptedCanvasData] = const [decryptedCanvasData, setDecryptedCanvasData] =
useState<CanvasJSON | null>(null); useState<CanvasJSON | null>(null);
const [showBurnModal, setShowBurnModal] = useState(false); const [showBurnModal, setShowBurnModal] = useState(false);
const [isBurning, setIsBurning] = useState(false); const [isBurning, setIsBurning] = useState(false);
const [ignite, setIgnite] = useState(false); const [ignite, setIgnite] = useState(false);
const [encryptedDek, setEncryptedDek] = useState<string | null>(null); const [encryptedDek, setEncryptedDek] = useState<string | null>(null);
const [shareLink, setShareLink] = useState<string | null>(null); const [shareLink, setShareLink] = useState<string | null>(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<LetterResponseData> = 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 cryptoUtils = new CryptoUtils();
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey); await decryptLetterData(data, cryptoUtils);
try { } catch (err) {
await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" }); setLogTrace({
} catch { message: `Failed to load letter ☹`,
// shouldn't obstruct share if api operation fails (since it's client side share) log: err instanceof Error ? err.message : "Unknown error",
} finally { type: "ERROR",
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`); });
} }
}; };
const burnLetter = async () => { loadAndDecrypt().then(() => setIsDecrypting(false));
if (!public_id || isBurning) return; }, [public_id, sharingKey, masterKey]);
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(() => { useEffect(() => {
if (!(sharingKey || masterKey)) { if (
navigateRef.current("/login", { !isDecrypting &&
state: { redirectUrl: `/read/${public_id}` }, revealState === "REVEALED" &&
}); decryptedCanvasData &&
return; canvasRef.current
} ) {
canvasRef.current.loadData(decryptedCanvasData);
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<LetterResponseData> = 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 (
<div className="flex items-center h-screen w-screen justify-center bg-base-100 font-sans">
<div className="fixed inset-0 bg-vig pointer-events-none" />
<div className="text-center space-y-6 z-10">
<Logo />
<div className="flex flex-col items-center gap-2">
<span className="loading loading-ring loading-md text-primary/40"></span>
<p
data-testid="decryption-overlay"
className="text-xs uppercase tracking-widest text-base-content/20 animate-pulse"
>
Breaking the seal...
</p>
</div>
</div>
</div>
);
}
if (logTrace) {
return (
<LogModal
isOpen={!!logTrace}
onClose={() => {
if (logTrace.type === "ERROR") window.location.href = "/";
setLogTrace(null);
}}
message={logTrace.message}
log={logTrace.log}
status={logTrace.type}
/>
);
} }
}, [isDecrypting, revealState, decryptedCanvasData]);
if (isDecrypting) {
return ( return (
<section className="min-h-fit w-full bg-base-100 px-4 py-8 md:py-16 font-serif relative overflow-hidden"> <div className="flex items-center h-screen w-screen justify-center bg-base-100 font-sans">
<div className="fixed inset-0 bg-vig pointer-events-none z-0" /> <div className="fixed inset-0 bg-vig pointer-events-none" />
<div <div className="text-center space-y-6 z-10">
className={`transition-all delay-300 duration-1000 relative ${revealState === "REVEALED" <Logo />
? "opacity-0 w-0 h-0 overflow-hidden invisible" <div className="flex flex-col items-center gap-2">
: "opacity-100" <span className="loading loading-ring loading-md text-primary/40"></span>
}`} <p
data-testid="decryption-overlay"
className="text-xs uppercase tracking-widest text-base-content/20 animate-pulse"
> >
{revealState === "SEALED" && ( Breaking the seal...
<div className="h-[80vh] mx-auto flex-col items-center flex justify-center"> </p>
<div className="perspective-distant scale-80 duration-1000 transition-all animate-[pulse_2s_linear_1]"> </div>
<EnvelopeReveal </div>
recipient={metadata?.recipient || "Someone dear"} </div>
date={ );
metadata?.updated_at }
? formatDate(new Date(metadata.updated_at))
: undefined if (logTrace) {
} return (
onRevealComplete={() => setRevealState("REVEALED")} <LogModal
ignite={ignite} isOpen={!!logTrace}
/> onClose={() => {
</div> if (logTrace.type === "ERROR") window.location.href = "/";
</div> setLogTrace(null);
)} }}
message={logTrace.message}
log={logTrace.log}
status={logTrace.type}
/>
);
}
return (
<section className="min-h-fit w-full bg-base-100 px-4 py-8 md:py-16 font-serif relative overflow-hidden">
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
<div
className={`transition-all delay-300 duration-1000 relative ${
revealState === "REVEALED"
? "opacity-0 w-0 h-0 overflow-hidden invisible"
: "opacity-100"
}`}
>
{revealState === "SEALED" && (
<div className="h-[80vh] mx-auto flex-col items-center flex justify-center">
<div className="perspective-distant scale-80 duration-1000 transition-all animate-[pulse_2s_linear_1]">
<EnvelopeReveal
recipient={metadata?.recipient || "Someone dear"}
date={
metadata?.updated_at
? formatDate(new Date(metadata.updated_at))
: undefined
}
onRevealComplete={() => setRevealState("REVEALED")}
ignite={ignite}
/>
</div>
</div>
)}
</div>
{ignite && <PostActionOverlay revealState={revealState} />}
{revealState === "REVEALED" && (
<div className="max-w-180 m-8 mx-auto space-y-8 h-full relative inset-0 z-100">
<div className="relative group perspective-1000">
<div className="absolute inset-0 bg-primary/5 blur-3xl rounded-full scale-75 opacity-0 group-hover:opacity-100 transition-opacity duration-1000 pointer-events-none" />
<div className="bg-paper shadow-warm rounded-sm overflow-hidden animate-[opacity_1s_ease-in-out_1]">
<div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" />
<ComposeCanvas ref={canvasRef} readOnly />
</div> </div>
{ignite && <PostActionOverlay revealState={revealState} />} {metadata?.recipient && (
<p className="text-center sm:hidden text-xxs uppercase tracking-widester text-base-content/20 mt-8">
{revealState === "REVEALED" && ( For {metadata.recipient}
<div className="max-w-180 m-8 mx-auto space-y-8 h-full relative inset-0 z-100"> </p>
<div className="relative group perspective-1000">
<div className="absolute inset-0 bg-primary/5 blur-3xl rounded-full scale-75 opacity-0 group-hover:opacity-100 transition-opacity duration-1000 pointer-events-none" />
<div className="bg-paper shadow-warm rounded-sm overflow-hidden animate-[opacity_1s_ease-in-out_1]">
<div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" />
<ComposeCanvas ref={canvasRef} readOnly />
</div>
{metadata?.recipient && (
<p className="text-center sm:hidden text-xxs uppercase tracking-widester text-base-content/20 mt-8">
For {metadata.recipient}
</p>
)}
</div>
</div>
)} )}
</div>
</div>
)}
{shareLink && ( {shareLink && (
<ShareModal shareLink={shareLink} setShareLink={setShareLink} /> <ShareModal shareLink={shareLink} setShareLink={setShareLink} />
)} )}
{showBurnModal && ( {showBurnModal && (
<BurnModal <BurnModal
burnLetter={burnLetter} burnLetter={burnLetter}
isBurning={isBurning} isBurning={isBurning}
setShowBurnModal={setShowBurnModal} setShowBurnModal={setShowBurnModal}
setRevealState={setRevealState} setRevealState={setRevealState}
/> />
)} )}
{isAuthor && revealState !== "BURNED" && ( {isAuthor && revealState !== "BURNED" && (
<div className="flex justify-center gap-2 mt-8 z-10 relative"> <div className="flex justify-center gap-2 mt-8 z-10 relative">
<button <button
id="share-letter-btn" id="share-letter-btn"
data-testid="share-letter-btn" data-testid="share-letter-btn"
type="button" type="button"
className="btn btn-ghost btn-sm text-base-content/30 hover:text-base-content hover:bg-base-content/10 gap-1.5" className="btn btn-ghost btn-sm text-base-content/30 hover:text-base-content hover:bg-base-content/10 gap-1.5"
onClick={handleShare} onClick={handleShare}
> >
<PaperPlaneTiltIcon size={16} weight="duotone" /> <PaperPlaneTiltIcon size={16} weight="duotone" />
<span className="text-md uppercase font-sans tracking-widest"> <span className="text-md uppercase font-sans tracking-widest">
Send to someone Send to someone
</span> </span>
</button> </button>
<button <button
id="burn-letter-btn" id="burn-letter-btn"
data-testid="burn-letter-btn" data-testid="burn-letter-btn"
type="button" type="button"
className="btn btn-ghost btn-sm text-error/40 hover:text-error hover:bg-error/10 gap-1.5" className="btn btn-ghost btn-sm text-error/40 hover:text-error hover:bg-error/10 gap-1.5"
onClick={() => setShowBurnModal(true)} onClick={() => setShowBurnModal(true)}
> >
<FlameIcon size={16} weight="duotone" /> <FlameIcon size={16} weight="duotone" />
<span className="text-md uppercase font-sans tracking-widest"> <span className="text-md uppercase font-sans tracking-widest">
Burn the letter Burn the letter
</span> </span>
</button> </button>
</div> </div>
)} )}
<footer className="mt-16 text-center z-10 opacity-10 pointer-events-none"> <footer className="mt-16 text-center z-10 opacity-10 pointer-events-none">
<p className="text-xs font-sans uppercase tracking-widester"> <p className="text-xs font-sans uppercase tracking-widester">
Read. Remember. Release. Read. Remember. Release.
</p> </p>
</footer> </footer>
</section> </section>
); );
} }