refactor: reduce complexity

This commit is contained in:
me
2026-05-08 10:33:11 +05:30
parent 26cf95c78b
commit 7e79c6ca8b
2 changed files with 772 additions and 767 deletions
+454 -452
View File
@@ -1,32 +1,32 @@
import {
ClockIcon,
DownloadSimpleIcon,
SpinnerGapIcon,
XIcon,
ClockIcon,
DownloadSimpleIcon,
SpinnerGapIcon,
XIcon,
} from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
import {
type NavigateFunction,
useNavigate,
useParams,
type NavigateFunction,
useNavigate,
useParams,
} from "react-router-dom";
import { api } from "../api/apiClient";
import type { LetterResponseData } from "../api/response";
import {
type CanvasStyle,
type CanvasTools,
ComposeCanvas,
type CanvasStyle,
type CanvasTools,
ComposeCanvas,
} from "../components/editor/ComposeCanvas";
import { PostSealModal } from "../components/editor/PostSealModal";
import {
LetterHead,
ToolBar,
VaultConfirmModal,
LetterHead,
ToolBar,
VaultConfirmModal,
} from "../components/editor/ToolBar";
import DateDisplay from "../components/ui/DateDisplay";
import { LogModal } from "../components/ui/LogModal";
import { Modal } from "../components/ui/Modal";
import { Navbar } from "../components/ui/Navbar";
import { endpoints } from "../config/endpoints";
import { PATHS } from "../config/routes";
import { useKeyStore } from "../store/useKeyStore";
@@ -42,480 +42,482 @@ const ERROR_VISIBLE_MS = 2400;
const STOP_SAVE_DATE_PULSE_AFTER_MS = 10000;
const toPlaceholderList = [
"Someone dear...",
"Somewhere near...",
"Something to bear...",
"Someone dear...",
"Somewhere near...",
"Something to bear...",
];
const MAX_FILE_SIZE = 10 * 1024 * 1024;
export default function Editor() {
const navigate = useNavigate();
const navigateRef = useRef<NavigateFunction>(navigate);
navigateRef.current = navigate;
const navigate = useNavigate();
const navigateRef = useRef<NavigateFunction>(navigate);
navigateRef.current = navigate;
const { public_id } = useParams();
const letterIdRef = useRef<string>(public_id ?? "");
const justSavedRef = useRef<boolean>(false);
const { public_id } = useParams();
const letterIdRef = useRef<string>(public_id ?? "");
const justSavedRef = useRef<boolean>(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<string | null>(null);
const [lastSaved, setLastSaved] = useState<string | null>(null);
const [status, setLetterStatus] = useState<"DRAFT" | "SEALED" | "VAULT">(
"DRAFT",
);
const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false);
const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0);
const [sealBtnClicked, setSealBtnClicked] = useState<boolean>(false);
const [isInitialLoading, setIsInitialLoading] = useState(false);
const [sealedTargetId, setSealedTargetId] = useState<string | null>(null);
const [lastSaved, setLastSaved] = useState<string | null>(null);
const [status, setLetterStatus] = useState<"DRAFT" | "SEALED" | "VAULT">(
"DRAFT",
);
const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false);
const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0);
const [sealBtnClicked, setSealBtnClicked] = useState<boolean>(false);
const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("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<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 loadExistingLetter = async () => {
setIsInitialLoading(true);
const cryptoUtils = new CryptoUtils();
try {
const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
const letterData = res.data;
setLastSaved(formatRelativeDate(new Date(letterData.updated_at)));
setLetterStatus(letterData.status);
if (letterData.status === "SEALED") {
navigateRef.current(PATHS.read(public_id), { replace: true });
return;
}
if (!letterData.encrypted_dek) {
return;
}
const metadata = await cryptoUtils.decryptMetadata(
{
encrypted_content: letterData.encrypted_metadata,
encrypted_dek: letterData.encrypted_dek,
},
masterKey,
);
setRecipient(metadata.recipient || "");
const decryptedJsonStr = await cryptoUtils.decryptLetter(
{
encrypted_content: letterData.encrypted_content,
encrypted_dek: letterData.encrypted_dek,
},
masterKey,
);
const canvasData = JSON.parse(decryptedJsonStr);
const { errors, isPartialFailure, canvasDataWithDecryptedImages } =
await decryptCanvasImages(
canvasData,
letterData.images ?? [],
letterData.encrypted_dek,
masterKey,
cryptoUtils,
true,
);
if (isPartialFailure) {
setDecryptionStatus({
status: "WARN",
message:
"Failed to decrypt some elements. Please check the render.",
log: errors.toString(),
});
}
if (canvasRef.current) {
await canvasRef.current.loadData(canvasDataWithDecryptedImages);
}
} catch (_err) {
setDecryptionStatus({
status: "ERROR",
message: "Failed to decrypt letter. Please try again later.",
log: _err instanceof Error ? _err.message : "Unknown error",
});
} finally {
setIsInitialLoading(false);
}
};
loadExistingLetter().then((_) => {
if (canvasRef.current) {
setCanvasFontStyle(canvasRef.current.getStyle());
}
const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("IDLE");
const [logStatus, setLogStatus] = useState<{
status: "WARN" | "ERROR" | "RESET";
message: string;
}>({
status: "RESET",
message: "",
});
}, [public_id, masterKey]);
// to trigger short pulse animation for Last Saved AT element
useEffect(() => {
if (lastSavedPulseTick === 0) return;
setIsSaveDatePulsing(true);
const timer = setTimeout(() => {
setIsSaveDatePulsing(false);
}, STOP_SAVE_DATE_PULSE_AFTER_MS);
return () => clearTimeout(timer);
}, [lastSavedPulseTick]);
// to fade in and fade out the save status overlay after each save operation
// Note: otherwise the fade efect is abrupt due to component's immediate unmount
useEffect(() => {
if (saveOverlay === "IDLE" || saveOverlay === "SAVING") return;
const visibleTimer = setTimeout(
() => {
setShowSaveOverlay(false);
},
saveOverlay === "SAVED" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS,
);
const unmountTimer = setTimeout(
() => {
setSaveOverlay("IDLE");
},
(saveOverlay === "SAVED" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS) +
OVERLAY_FADE_MS,
const [showSaveOverlay, setShowSaveOverlay] = useState(false);
const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
null,
);
return () => {
clearTimeout(visibleTimer);
clearTimeout(unmountTimer);
};
}, [saveOverlay]);
const [recipient, setRecipient] = useState("");
const [unlockDate, setUnlockDate] = useState<Date | null>(null);
const [placeholderIndex, setPlaceholderIndex] = useState(0);
const [canvasFontStyle, setCanvasFontStyle] = useState<CanvasStyle>({
fontColor: "",
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 { masterKey } = useKeyStore();
const handleSave = async (
status: "SEALED" | "DRAFT" | "VAULT",
vaultDate?: Date,
): Promise<void> => {
setSealBtnClicked(false);
const canvasRef = useRef<CanvasTools>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
let targetId = public_id || letterIdRef.current;
if (!targetId) {
targetId = crypto.randomUUID();
}
// to continuously rotate placeholder text of the recipient input
useEffect(() => {
const interval = setInterval(() => {
setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length);
}, 4000);
if (saveOverlay === "SAVING" || !masterKey) return;
return () => clearInterval(interval);
}, []);
setSaveOverlay("SAVING");
setShowSaveOverlay(true);
// to load existing letter when public_id param and masterKey is available
// NOTE: this has to trigger just once after each save
useEffect(() => {
if (!(public_id && masterKey)) return;
if (justSavedRef.current) {
justSavedRef.current = false;
return;
}
const decryptAndLoadLetter = async (
letterData: LetterResponseData,
masterKey: CryptoKey,
) => {
const cryptoUtils = new CryptoUtils();
const metadata = await cryptoUtils.decryptMetadata(
{
encrypted_content: letterData.encrypted_metadata,
encrypted_dek: letterData.encrypted_dek,
},
masterKey,
);
setRecipient(metadata.recipient || "");
const cryptoUtils = new CryptoUtils();
await cryptoUtils.initialize();
const decryptedJsonStr = await cryptoUtils.decryptLetter(
{
encrypted_content: letterData.encrypted_content,
encrypted_dek: letterData.encrypted_dek,
},
masterKey,
);
const canvasData = JSON.parse(decryptedJsonStr);
try {
const canvasData = (await canvasRef.current?.getData()) || {
objects: [],
};
const canvasImages = canvasRef.current?.getImages() || [];
const { errors, isPartialFailure, canvasDataWithDecryptedImages } =
await decryptCanvasImages(
canvasData,
letterData.images ?? [],
letterData.encrypted_dek,
masterKey,
cryptoUtils,
true,
);
const { encryptedImageFiles, encryptedCanvasData } =
await encryptCanvasImages(
canvasData,
canvasImages,
masterKey,
cryptoUtils,
if (isPartialFailure) {
setDecryptionStatus({
status: "WARN",
message: "Failed to decrypt some elements. Please check the render.",
log: errors.toString(),
});
}
if (canvasRef.current) {
await canvasRef.current.loadData(canvasDataWithDecryptedImages);
}
};
const loadExistingLetter = async () => {
setIsInitialLoading(true);
try {
const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
const letterData = res.data;
setLastSaved(formatRelativeDate(new Date(letterData.updated_at)));
setLetterStatus(letterData.status);
if (letterData.status === "SEALED") {
navigateRef.current(PATHS.read(public_id), { replace: true });
return;
}
if (letterData.encrypted_dek && masterKey) {
await decryptAndLoadLetter(letterData, masterKey);
}
} catch (err) {
setDecryptionStatus({
status: "ERROR",
message: "Failed to decrypt letter. Please try again later.",
log: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setIsInitialLoading(false);
}
};
loadExistingLetter().then((_) => {
if (canvasRef.current) {
setCanvasFontStyle(canvasRef.current.getStyle());
}
});
}, [public_id, masterKey]);
// to trigger short pulse animation for Last Saved AT element
useEffect(() => {
if (lastSavedPulseTick === 0) return;
setIsSaveDatePulsing(true);
const timer = setTimeout(() => {
setIsSaveDatePulsing(false);
}, STOP_SAVE_DATE_PULSE_AFTER_MS);
return () => clearTimeout(timer);
}, [lastSavedPulseTick]);
// to fade in and fade out the save status overlay after each save operation
// Note: otherwise the fade efect is abrupt due to component's immediate unmount
useEffect(() => {
if (saveOverlay === "IDLE" || saveOverlay === "SAVING") return;
const visibleTimer = setTimeout(
() => {
setShowSaveOverlay(false);
},
saveOverlay === "SAVED" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS,
);
const unmountTimer = setTimeout(
() => {
setSaveOverlay("IDLE");
},
(saveOverlay === "SAVED" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS) +
OVERLAY_FADE_MS,
);
const encrypted_letter = await cryptoUtils.encryptLetter(
JSON.stringify(encryptedCanvasData),
masterKey,
);
return () => {
clearTimeout(visibleTimer);
clearTimeout(unmountTimer);
};
}, [saveOverlay]);
const encrypted_metadata = await cryptoUtils.encryptMetadata(
{ recipient, tags: [] },
masterKey,
);
const formData = new FormData();
if (status === "VAULT") {
const finalDate = vaultDate || unlockDate;
formData.append("type", "VAULT");
if (finalDate) {
formData.append("unlock_at", finalDate.toISOString());
const handleImageUpload = (e: React.ChangeEvent<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.",
});
}
formData.append("status", "SEALED");
} else {
formData.append("type", "KEPT");
formData.append("status", status);
}
formData.append("public_id", targetId);
formData.append("encrypted_content", encrypted_letter.encrypted_content);
formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
formData.append(
"encrypted_metadata",
encrypted_metadata.encrypted_content,
);
};
encryptedImageFiles.forEach((blob, filename) => {
formData.append("image_files", blob, filename);
});
const getRequestData = async (
targetId: string,
status: string,
vaultDate?: Date,
): Promise<FormData> => {
const cryptoUtils = new CryptoUtils();
await cryptoUtils.initialize();
await api.put(`${endpoints.LETTERS}${targetId}/`, formData);
justSavedRef.current = true;
const canvasData = (await canvasRef.current?.getData()) || { objects: [] };
const canvasImages = canvasRef.current?.getImages() || [];
if (!public_id) {
letterIdRef.current = targetId;
navigate(PATHS.write(targetId), { replace: true });
}
const { encryptedImageFiles, encryptedCanvasData } =
await encryptCanvasImages(
canvasData,
canvasImages,
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
masterKey!,
cryptoUtils,
);
setLastSaved(formatRelativeDate(new Date()));
setLetterStatus(status);
setLastSavedPulseTick((prev) => prev + 1);
const encrypted_letter = await cryptoUtils.encryptLetter(
JSON.stringify(encryptedCanvasData),
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
masterKey!,
);
if (status === "SEALED" || status === "VAULT") {
setSealedTargetId(targetId);
}
setSaveOverlay("SAVED");
setShowSaveOverlay(true);
} catch (_error) {
setSaveOverlay("ERROR");
setShowSaveOverlay(true);
}
};
const encrypted_metadata = await cryptoUtils.encryptMetadata(
{ recipient, tags: [] },
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
masterKey!,
);
return (
<>
<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"
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">
<LogModal
status={decryptionStatus.status}
message={decryptionStatus.message}
log={decryptionStatus.log}
onClose={() =>
setDecryptionStatus({ status: "RESET", message: "", log: "" })
}
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"
<section className="flex-1 overflow-y-auto scrollbar-hide px-2 pt-32 pb-12 bg-base-300 relative">
<LogModal
status={decryptionStatus.status}
message={decryptionStatus.message}
log={decryptionStatus.log}
onClose={() =>
setDecryptionStatus({ status: "RESET", message: "", log: "" })
}
isOpen={decryptionStatus.status !== "RESET"}
/>
<span className="font-bold">Securing your letter...</span>
</div>
)}
{saveOverlay === "SAVED" && (
<div
role="alert"
data-testid="save-success-toast"
className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${
showSaveOverlay
? "opacity-100 scale-100 translate-y-0"
: "opacity-0 scale-95 translate-y-1"
}`}
>
<DownloadSimpleIcon size={18} weight="bold" />
<span className="font-bold">Your letter is saved!</span>
</div>
)}
{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 === "ERROR" && (
<div
role="alert"
className={`alert alert-error shadow-lg transition-all duration-300 ${
showSaveOverlay
? "opacity-100 scale-100 translate-y-0"
: "opacity-0 scale-95 translate-y-1"
}`}
>
<XIcon size={18} weight="bold" />
<span className="font-bold">Failed to save letter</span>
</div>
)}
</Modal>
)}
{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>
)}
{confirmModal === "VAULT" && (
<VaultConfirmModal
onSave={handleSave}
setConfirmModal={setConfirmModal}
setUnlockDate={setUnlockDate}
/>
)}
{sealedTargetId && (
<PostSealModal
sealedTargetId={sealedTargetId}
navigate={navigate}
type={status === "VAULT" ? "VAULT" : "KEPT"}
/>
)}
{saveOverlay === "SAVED" && (
<div
role="alert"
data-testid="save-success-toast"
className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${showSaveOverlay
? "opacity-100 scale-100 translate-y-0"
: "opacity-0 scale-95 translate-y-1"
}`}
>
<DownloadSimpleIcon size={18} weight="bold" />
<span className="font-bold">Your letter is saved!</span>
</div>
)}
<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>
{saveOverlay === "ERROR" && (
<div
role="alert"
className={`alert alert-error shadow-lg transition-all duration-300 ${showSaveOverlay
? "opacity-100 scale-100 translate-y-0"
: "opacity-0 scale-95 translate-y-1"
}`}
>
<XIcon size={18} weight="bold" />
<span className="font-bold">Failed to save letter</span>
</div>
)}
</Modal>
)}
{status === "DRAFT" ? (
<ToolBar
onAddImage={() => fileInputRef.current?.click()}
sealBtnClicked={sealBtnClicked}
setSealBtnClicked={setSealBtnClicked}
onSave={handleSave}
setConfirmModal={setConfirmModal}
onFontChange={setCanvasFontStyle}
latestFontStyle={canvasFontStyle}
{confirmModal === "VAULT" && (
<VaultConfirmModal
onSave={handleSave}
setConfirmModal={setConfirmModal}
setUnlockDate={setUnlockDate}
/>
)}
{sealedTargetId && (
<PostSealModal
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"}
/>
</>
);
</>
);
}
+318 -315
View File
@@ -2,17 +2,17 @@ import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
import type { AxiosResponse } from "axios";
import { useEffect, useRef, useState } from "react";
import {
type NavigateFunction,
useLocation,
useNavigate,
useParams,
type NavigateFunction,
useLocation,
useNavigate,
useParams,
} from "react-router-dom";
import { api } from "../api/apiClient";
import type { LetterImageData, LetterResponseData } from "../api/response";
import {
type CanvasJSON,
type CanvasTools,
ComposeCanvas,
type CanvasJSON,
type CanvasTools,
ComposeCanvas,
} from "../components/editor/ComposeCanvas";
import Logo from "../components/Logo";
import { BurnModal } from "../components/reader/BurnModal";
@@ -26,339 +26,342 @@ import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto";
import { formatDate } from "../utils/dateFormat";
import {
decryptCanvasImages,
decryptCanvasImagesWithSharingKey,
decryptCanvasImages,
decryptCanvasImagesWithSharingKey,
} from "../utils/letterLogic";
interface LetterMetadata {
recipient?: string;
updated_at?: string;
recipient?: string;
updated_at?: string;
}
const WAIT_FOR_BURN_MS = 18000;
export default function Reader() {
const { public_id } = useParams();
const location = useLocation();
const navigate = useNavigate();
const sharingKey = location.hash.replace("#", "");
const { public_id } = useParams();
const location = useLocation();
const navigate = useNavigate();
const sharingKey = location.hash.replace("#", "");
const navigateRef = useRef<NavigateFunction>(navigate);
const canvasRef = useRef<CanvasTools>(null);
const navigateRef = useRef<NavigateFunction>(navigate);
const canvasRef = useRef<CanvasTools>(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<LetterMetadata | null>(null);
const [decryptedCanvasData, setDecryptedCanvasData] =
useState<CanvasJSON | null>(null);
const [showBurnModal, setShowBurnModal] = useState(false);
const [isBurning, setIsBurning] = useState(false);
const [ignite, setIgnite] = useState(false);
const [encryptedDek, setEncryptedDek] = useState<string | null>(null);
const [shareLink, setShareLink] = useState<string | null>(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<LetterMetadata | null>(null);
const [decryptedCanvasData, setDecryptedCanvasData] =
useState<CanvasJSON | null>(null);
const [showBurnModal, setShowBurnModal] = useState(false);
const [isBurning, setIsBurning] = useState(false);
const [ignite, setIgnite] = useState(false);
const [encryptedDek, setEncryptedDek] = useState<string | null>(null);
const [shareLink, setShareLink] = useState<string | null>(null);
const { masterKey } = useKeyStore();
const { masterKey } = useKeyStore();
const isAuthor = !!masterKey && !sharingKey;
const handleShare = async () => {
if (!(encryptedDek && masterKey && public_id)) return;
const cryptoUtils = new CryptoUtils();
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
try {
await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" });
} catch (_err) {
} finally {
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
}
};
const burnLetter = async () => {
if (!public_id || isBurning) return;
setIsBurning(true);
try {
await api.patch(`${endpoints.LETTERS}${public_id}/`, {
status: "BURNED",
});
} catch (_err) {
} finally {
setIsBurning(false);
setShowBurnModal(false);
setIgnite(true);
setTimeout(() => {
setRevealState("BURNED");
}, WAIT_FOR_BURN_MS);
}
};
useEffect(() => {
if (!(sharingKey || masterKey)) {
navigateRef.current("/login", {
state: { redirectUrl: `/read/${public_id}` },
});
return;
}
const decryptImages = async (
canvasData: CanvasJSON,
images: LetterImageData[],
encrypted_dek: string,
cryptoUtils: CryptoUtils,
) => {
if (!images?.length) return;
const isShared = !!sharingKey;
try {
if (isShared) {
await decryptCanvasImagesWithSharingKey(
canvasData,
images,
sharingKey,
cryptoUtils,
);
} else {
await decryptCanvasImages(
canvasData,
images,
encrypted_dek,
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
masterKey!,
cryptoUtils,
);
}
} catch (err) {
setLogTrace({
message:
"Failed to decrypt elements. Images might not render in the letter as intended.",
log: err instanceof Error ? err.message : "Unknown error",
type: "WARN",
});
}
};
const decryptLetterData = async (
data: LetterResponseData,
cryptoUtils: CryptoUtils,
) => {
const isShared = !!sharingKey;
const {
encrypted_content,
encrypted_metadata,
encrypted_dek,
images,
updated_at,
} = data;
// Decrypt Metadata
const decryptedMetadata = isShared
? await cryptoUtils.decryptMetadataWithSharingKey(
encrypted_metadata,
sharingKey,
)
: await cryptoUtils.decryptMetadata(
{ encrypted_content: encrypted_metadata, encrypted_dek },
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
masterKey!,
);
setMetadata({
...(decryptedMetadata as LetterMetadata),
updated_at,
});
// Decrypt Content
const decryptedContent = isShared
? await cryptoUtils.decryptLetterWithSharingKey(
encrypted_content,
sharingKey,
)
: await cryptoUtils.decryptLetter(
{ encrypted_content, encrypted_dek },
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
masterKey!,
);
const canvasData: CanvasJSON = JSON.parse(decryptedContent);
await decryptImages(canvasData, images, encrypted_dek, cryptoUtils);
setDecryptedCanvasData(canvasData);
};
const loadAndDecrypt = async () => {
try {
const response: AxiosResponse<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 isAuthor = !!masterKey && !sharingKey;
const handleShare = async () => {
if (!(encryptedDek && masterKey && public_id)) return;
const cryptoUtils = new CryptoUtils();
await decryptLetterData(data, cryptoUtils);
} catch (err) {
setLogTrace({
message: `Failed to load letter ☹`,
log: err instanceof Error ? err.message : "Unknown error",
type: "ERROR",
});
}
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
try {
await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" });
} catch {
// shouldn't obstruct share if api operation fails (since it's client side share)
} finally {
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
}
};
loadAndDecrypt().then(() => setIsDecrypting(false));
}, [public_id, sharingKey, masterKey]);
const burnLetter = async () => {
if (!public_id || isBurning) return;
setIsBurning(true);
try {
await api.patch(`${endpoints.LETTERS}${public_id}/`, {
status: "BURNED",
});
} catch {
// should not obstruct burn if api operation fails
// WHY?: it disconnects the UX. if you want to burn the letter, you should be able to burn the letter
// TODO: maybe say something like: "the wind is strong today, let's try again"? or maybe something less stupid :3
} finally {
setIsBurning(false);
setShowBurnModal(false);
setIgnite(true);
setTimeout(() => {
setRevealState("BURNED");
}, WAIT_FOR_BURN_MS);
}
};
useEffect(() => {
if (
!isDecrypting &&
revealState === "REVEALED" &&
decryptedCanvasData &&
canvasRef.current
) {
canvasRef.current.loadData(decryptedCanvasData);
}
}, [isDecrypting, revealState, decryptedCanvasData]);
useEffect(() => {
if (!(sharingKey || masterKey)) {
navigateRef.current("/login", {
state: { redirectUrl: `/read/${public_id}` },
});
return;
}
if (isDecrypting) {
return (
<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}
/>
);
}
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
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,
);
}
onRevealComplete={() => setRevealState("REVEALED")}
ignite={ignite}
/>
} catch (err) {
setLogTrace({
message:
"Failed to decrypt elements. Images might not render in the letter as intended.",
log: err instanceof Error ? err.message : "Unknown error",
type: "WARN",
});
}
};
const decryptLetterData = async (
data: LetterResponseData,
cryptoUtils: CryptoUtils,
) => {
const isShared = !!sharingKey;
const {
encrypted_content,
encrypted_metadata,
encrypted_dek,
images,
updated_at,
} = data;
// Decrypt Metadata
const decryptedMetadata = isShared
? await cryptoUtils.decryptMetadataWithSharingKey(
encrypted_metadata,
sharingKey,
)
: await cryptoUtils.decryptMetadata(
{ encrypted_content: encrypted_metadata, encrypted_dek },
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
masterKey!,
);
setMetadata({
...(decryptedMetadata as LetterMetadata),
updated_at,
});
// Decrypt Content
const decryptedContent = isShared
? await cryptoUtils.decryptLetterWithSharingKey(
encrypted_content,
sharingKey,
)
: await cryptoUtils.decryptLetter(
{ encrypted_content, encrypted_dek },
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
masterKey!,
);
const canvasData: CanvasJSON = JSON.parse(decryptedContent);
await decryptImages(canvasData, images, encrypted_dek, cryptoUtils);
setDecryptedCanvasData(canvasData);
};
const loadAndDecrypt = async () => {
try {
const response: AxiosResponse<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>
</div>
)}
</div>
);
}
{ignite && <PostActionOverlay revealState={revealState} />}
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}
/>
);
}
{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 />
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>
{metadata?.recipient && (
<p className="text-center sm:hidden text-xxs uppercase tracking-widester text-base-content/20 mt-8">
For {metadata.recipient}
</p>
{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>
{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 && (
<ShareModal shareLink={shareLink} setShareLink={setShareLink} />
)}
{showBurnModal && (
<BurnModal
burnLetter={burnLetter}
isBurning={isBurning}
setShowBurnModal={setShowBurnModal}
setRevealState={setRevealState}
/>
)}
{shareLink && (
<ShareModal shareLink={shareLink} setShareLink={setShareLink} />
)}
{showBurnModal && (
<BurnModal
burnLetter={burnLetter}
isBurning={isBurning}
setShowBurnModal={setShowBurnModal}
setRevealState={setRevealState}
/>
)}
{isAuthor && revealState !== "BURNED" && (
<div className="flex justify-center gap-2 mt-8 z-10 relative">
<button
id="share-letter-btn"
data-testid="share-letter-btn"
type="button"
className="btn btn-ghost btn-sm text-base-content/30 hover:text-base-content hover:bg-base-content/10 gap-1.5"
onClick={handleShare}
>
<PaperPlaneTiltIcon size={16} weight="duotone" />
<span className="text-md uppercase font-sans tracking-widest">
Send to someone
</span>
</button>
<button
id="burn-letter-btn"
data-testid="burn-letter-btn"
type="button"
className="btn btn-ghost btn-sm text-error/40 hover:text-error hover:bg-error/10 gap-1.5"
onClick={() => setShowBurnModal(true)}
>
<FlameIcon size={16} weight="duotone" />
<span className="text-md uppercase font-sans tracking-widest">
Burn the letter
</span>
</button>
</div>
)}
{isAuthor && revealState !== "BURNED" && (
<div className="flex justify-center gap-2 mt-8 z-10 relative">
<button
id="share-letter-btn"
data-testid="share-letter-btn"
type="button"
className="btn btn-ghost btn-sm text-base-content/30 hover:text-base-content hover:bg-base-content/10 gap-1.5"
onClick={handleShare}
>
<PaperPlaneTiltIcon size={16} weight="duotone" />
<span className="text-md uppercase font-sans tracking-widest">
Send to someone
</span>
</button>
<button
id="burn-letter-btn"
data-testid="burn-letter-btn"
type="button"
className="btn btn-ghost btn-sm text-error/40 hover:text-error hover:bg-error/10 gap-1.5"
onClick={() => setShowBurnModal(true)}
>
<FlameIcon size={16} weight="duotone" />
<span className="text-md uppercase font-sans tracking-widest">
Burn the letter
</span>
</button>
</div>
)}
<footer className="mt-16 text-center z-10 opacity-10 pointer-events-none">
<p className="text-xs font-sans uppercase tracking-widester">
Read. Remember. Release.
</p>
</footer>
</section>
);
<footer className="mt-16 text-center z-10 opacity-10 pointer-events-none">
<p className="text-xs font-sans uppercase tracking-widester">
Read. Remember. Release.
</p>
</footer>
</section>
);
}