refactor: reduce complexity
This commit is contained in:
+454
-452
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user