refactor: lint formatting and fixes #6

Merged
me merged 10 commits from refactor/lint_fixes into main 2026-05-08 06:13:25 +00:00
37 changed files with 332 additions and 291 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+1 -19
View File
@@ -1,19 +1 @@
{ {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
"name": "Pi. Ku.",
"short_name": "Pi. Ku.",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#d4a24f",
"background_color": "#3b1d13",
"display": "standalone"
}
+1 -1
View File
@@ -31,7 +31,7 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<main className="relative min-h-screen min-w-screen flex items-center justify-center w-full bg-base-200 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-50 before:pointer-events-none before:bg-[url('assets/noise.gif')]"> <main className="relative min-h-screen min-w-screen flex items-center justify-center w-full bg-base-200 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-50 before:pointer-events-none before:bg-[url('assets/textures/noise.gif')]">
<Suspense fallback={<SplashScreen />}> <Suspense fallback={<SplashScreen />}>
<Routes> <Routes>
<Route <Route
+24
View File
@@ -0,0 +1,24 @@
export interface LetterResponseData {
public_id: string;
type: "KEPT" | "SENT" | "VAULT";
status: "DRAFT" | "SEALED" | "BURNED";
encrypted_content: string;
encrypted_metadata: string;
encrypted_dek: string;
unlock_at: string | null;
sealed_at: string | null;
created_at: string;
updated_at: string;
images: LetterImageData[];
}
export interface LetterImageData {
public_id: string;
file: string;
file_name: string;
}
export interface LetterMetadata {
recipient: string;
tags?: string[];
}

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 327 KiB

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Before

Width:  |  Height:  |  Size: 738 KiB

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+2 -6
View File
@@ -1,4 +1,5 @@
import { DotIcon } from "@phosphor-icons/react"; import { DotIcon } from "@phosphor-icons/react";
import logo from "../assets/logo.svg";
import "@fontsource/knewave/400.css"; import "@fontsource/knewave/400.css";
interface LogoProps { interface LogoProps {
@@ -31,12 +32,7 @@ export default function Logo({
if (type === "logo") { if (type === "logo") {
return ( return (
<img <img src={logo} alt="Pi. Ku. logo" className="mx-4" width={scale * 100} />
src="/logo.svg"
alt="Pi. Ku. logo"
className="mx-4"
width={scale * 100}
/>
); );
} }
+1 -1
View File
@@ -5,7 +5,7 @@ export default function SplashScreen() {
return ( return (
<div <div
data-testid="splash-screen" data-testid="splash-screen"
className="fixed w-screen h-screen inset-0 flex flex-col items-center justify-center z-9999 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')" className="fixed w-screen h-screen inset-0 flex flex-col items-center justify-center z-9999 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/textures/noise.gif')"
> >
<div className="flex flex-col items-center gap-6 animate-pulse"> <div className="flex flex-col items-center gap-6 animate-pulse">
<Logo /> <Logo />
@@ -66,7 +66,7 @@ export function WelcomeLetterOverlay({
type="button" type="button"
data-testid="dismiss-welcome-letter-btn" data-testid="dismiss-welcome-letter-btn"
onClick={onComplete} onClick={onComplete}
className="btn btn-accent opacity-80 px-12 shadow-lg" className="btn btn-base btn-xs btn-wide opacity-80 shadow-lg font-light tracking-wider"
> >
I'll see you I'll see you
</button> </button>
@@ -122,7 +122,7 @@ export function ComposeCanvas({
// re-calculates height based on content and applies the zoom transform // re-calculates height based on content and applies the zoom transform
const syncViewport = useCallback(() => { const syncViewport = useCallback(() => {
if (!(fabricRef.current && wrapperRef.current)) return; if (!(fabricRef.current && wrapperRef.current)) return;
textboxRef.current.initDimensions(); textboxRef.current?.initDimensions();
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT; const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
logicalSizeRef.current.height = measureLogicalContentHeight( logicalSizeRef.current.height = measureLogicalContentHeight(
@@ -260,17 +260,35 @@ export function ComposeCanvas({
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
let lastWidth = 0; let lastWidth = 0;
const getInitialWidth = async () => {
if (!wrapperRef.current) return BASE_WIDTH;
let width = wrapperRef.current.clientWidth;
if (width === 0) {
await new Promise((resolve) => requestAnimationFrame(resolve));
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
}
return width;
};
const initResizeOberver = () => {
if (!wrapperRef.current) return null;
const observer = new ResizeObserver(() => {
const nextWidth = wrapperRef.current?.clientWidth;
if (!nextWidth || nextWidth === lastWidth) return;
lastWidth = nextWidth;
syncViewport();
});
observer.observe(wrapperRef.current);
return observer;
};
const initCanvas = async () => { const initCanvas = async () => {
// HACK: actual font may change the text-width - small ux improvement // HACK: actual font may change the text-width - small ux improvement
await document.fonts.ready; await document.fonts.ready;
if (!(wrapperRef.current && canvasRef.current && isMounted)) return; if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
let width = wrapperRef.current.clientWidth; const width = await getInitialWidth();
if (width === 0) {
await new Promise((resolve) => requestAnimationFrame(resolve));
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
}
// init the fabric instance // init the fabric instance
const canvas = new fabric.Canvas(canvasRef.current, { const canvas = new fabric.Canvas(canvasRef.current, {
@@ -301,13 +319,7 @@ export function ComposeCanvas({
// auto window resizing based width // auto window resizing based width
lastWidth = wrapperRef.current.clientWidth; lastWidth = wrapperRef.current.clientWidth;
resizeObserver = new ResizeObserver(() => { resizeObserver = initResizeOberver();
const nextWidth = wrapperRef.current?.clientWidth;
if (!nextWidth || nextWidth === lastWidth) return;
lastWidth = nextWidth;
syncViewport();
});
resizeObserver.observe(wrapperRef.current!);
}; };
initCanvas().then(); initCanvas().then();
@@ -63,7 +63,11 @@ export function PostSealModal({
type="button" type="button"
data-testid="view-letter-btn" data-testid="view-letter-btn"
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={() => navigate(PATHS.read(sealedTargetId!))} onClick={() => {
if (sealedTargetId) {
navigate(PATHS.read(sealedTargetId));
}
}}
> >
View letter View letter
</button> </button>
+1 -1
View File
@@ -11,7 +11,7 @@ import {
XCircleIcon, XCircleIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { Modal } from "../ui/Modal"; import { Modal } from "../ui/Modal";
import type { CanvasStyle } from "./ComposeCanvas.tsx"; import type { CanvasStyle } from "./ComposeCanvas";
interface ToolBarProps { interface ToolBarProps {
onAddImage: () => void; onAddImage: () => void;
@@ -3,9 +3,9 @@ import {
ShieldCheckIcon, ShieldCheckIcon,
WarningIcon, WarningIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import Logo from "../Logo.tsx"; import Logo from "../Logo";
import { Modal } from "../ui/Modal"; import { Modal } from "../ui/Modal";
import Saajan from "../ui/Saajan.tsx"; import Saajan from "../ui/Saajan";
export default function WelcomeModal({ export default function WelcomeModal({
setShowWelcome, setShowWelcome,
@@ -53,7 +53,7 @@ export default function WelcomeModal({
href="https://www.privacyguides.org/en/passwords/" href="https://www.privacyguides.org/en/passwords/"
target="_blank" target="_blank"
className="link link-primary-content" className="link link-primary-content"
rel="noopener" rel="noopener noreferrer"
> >
password manager password manager
</a>{" "} </a>{" "}
+1 -1
View File
@@ -19,7 +19,7 @@ export function Modal({
return ( return (
<div <div
data-testid={testId} data-testid={testId}
className="modal modal-open modal-middle backdrop-blur-md before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')]" className="modal modal-open modal-middle backdrop-blur-md before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/textures/noise.gif')]"
> >
<div className="modal-box relative bg-base-100/60 flex flex-col items-center text-center gap-6"> <div className="modal-box relative bg-base-100/60 flex flex-col items-center text-center gap-6">
{onClose && ( {onClose && (
+4 -3
View File
@@ -1,3 +1,4 @@
import trainImage from "../assets/screenshots/train.png";
import type { CanvasJSON } from "../components/editor/ComposeCanvas"; import type { CanvasJSON } from "../components/editor/ComposeCanvas";
export function getWelcomeLetterContent(userName: string): CanvasJSON { export function getWelcomeLetterContent(userName: string): CanvasJSON {
@@ -30,7 +31,7 @@ export function getWelcomeLetterContent(userName: string): CanvasJSON {
originY: "top", originY: "top",
left: 36, left: 36,
top: 36, top: 36,
width: 608, width: 720,
height: 813.6, height: 813.6,
fill: "#111e67", fill: "#111e67",
stroke: null, stroke: null,
@@ -90,12 +91,12 @@ export function getWelcomeLetterContent(userName: string): CanvasJSON {
globalCompositeOperation: "source-over", globalCompositeOperation: "source-over",
skewX: 0, skewX: 0,
skewY: 0, skewY: 0,
src: "/screenshots/train.png", src: trainImage,
crossOrigin: null, crossOrigin: null,
filters: [], filters: [],
}, },
], ],
canvasWidth: 680, canvasWidth: 700,
canvasHeight: 900, canvasHeight: 900,
}; };
} }
+1 -1
View File
@@ -32,7 +32,7 @@ export const useAuth = () => {
const logout = async () => { const logout = async () => {
try { try {
await api.post(endpoints.LOGOUT); await api.post(endpoints.LOGOUT);
} catch (_error) { } catch {
} finally { } finally {
clearAuth(); clearAuth();
setMasterKey(null); setMasterKey(null);
+4 -20
View File
@@ -1,32 +1,16 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { api } from "../api/apiClient"; import { api } from "../api/apiClient";
import type { LetterMetadata, LetterResponseData } from "../api/response";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
import { useKeyStore } from "../store/useKeyStore"; import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto"; import { CryptoUtils } from "../utils/crypto";
export interface Letter { export interface ProcessedLetter extends LetterResponseData {
public_id: string;
type: "KEPT" | "VAULT" | "SENT";
status: "DRAFT" | "SEALED" | "BURNED";
updated_at: string;
sealed_at?: string;
unlock_at: string;
encrypted_metadata: string;
encrypted_content: string;
encrypted_dek: string;
}
export interface LetterMetadata {
recipient: string;
tags?: string[];
}
export interface ProcessedLetter extends Letter {
metadata: LetterMetadata; metadata: LetterMetadata;
} }
async function decryptLettersMetadata( async function decryptLettersMetadata(
letters: Letter[], letters: LetterResponseData[],
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<ProcessedLetter[]> { ): Promise<ProcessedLetter[]> {
const cryptoUtils = new CryptoUtils(); const cryptoUtils = new CryptoUtils();
@@ -43,7 +27,7 @@ async function decryptLettersMetadata(
)) as LetterMetadata; )) as LetterMetadata;
return { ...letter, metadata }; return { ...letter, metadata };
} catch (_err) { } catch {
return { return {
...letter, ...letter,
metadata: { recipient: "Encrypted Letter" }, metadata: { recipient: "Encrypted Letter" },
+1 -1
View File
@@ -7,7 +7,7 @@ import "@fontsource-variable/playwrite-hr-lijeva/wght.css";
import "@fontsource-variable/jost/wght.css"; import "@fontsource-variable/jost/wght.css";
import "@fontsource-variable/playfair-display/wght.css"; import "@fontsource-variable/playfair-display/wght.css";
import App from "./App.tsx"; import App from "./App";
const root = document.getElementById("root"); const root = document.getElementById("root");
if (root) { if (root) {
+12 -14
View File
@@ -25,7 +25,9 @@ import { ReactLenis } from "lenis/react";
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react"; import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import stamp from "../assets/envelope/stamp.png"; import stamp from "../assets/envelope/stamp.png";
import Logo from "../components/Logo.tsx"; import e2eDiag from "../assets/screenshots/e2e.svg";
import saajan from "../assets/sf.png";
import Logo from "../components/Logo";
import { Modal } from "../components/ui/Modal"; import { Modal } from "../components/ui/Modal";
import "@fontsource/kavivanar/index.css"; import "@fontsource/kavivanar/index.css";
@@ -35,7 +37,7 @@ import "@fontsource/architects-daughter/index.css";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
function HorizontalScroll({ children }: { children: React.ReactNode }) { function HorizontalScroll({ children }: { children: React.ReactNode }) {
const ref = useRef(null); const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: ref }); const { scrollYProgress } = useScroll({ target: ref });
const x = useTransform(scrollYProgress, [0, 1], ["0%", "-50%"]); const x = useTransform(scrollYProgress, [0, 1], ["0%", "-50%"]);
@@ -166,7 +168,7 @@ function PrivacySection() {
</div> </div>
</div> </div>
<div className="diff-item-2" role="img"> <div className="diff-item-2" role="img">
<div className="bg-neutral-content bg-[url('https://www.transparenttextures.com/patterns/random-grey-variations.png')] text-primary-content grid place-content-center text-sm md:gap-4"> <div className="bg-neutral-content bg-[url('assets/textures/random-grey-variations.png')] text-primary-content grid place-content-center text-sm md:gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-3xl md:text-6xl uppercase font-bold text-right tracking-widest mt-2 md:mt-8"> <h1 className="text-3xl md:text-6xl uppercase font-bold text-right tracking-widest mt-2 md:mt-8">
server see server see
@@ -257,7 +259,7 @@ function SpecsSection() {
</a>{" "} </a>{" "}
for the <span className="font-hand text-primary">keys</span>. for the <span className="font-hand text-primary">keys</span>.
</h2> </h2>
<p className="text-sm md:text-xl leading-relaxed"> <div className="text-sm md:text-xl leading-relaxed">
This means, both the{" "} This means, both the{" "}
<span className="font-display text-info">encryption</span> and{" "} <span className="font-display text-info">encryption</span> and{" "}
<span className="font-display text-info">decryption</span> runs on <span className="font-display text-info">decryption</span> runs on
@@ -288,8 +290,8 @@ function SpecsSection() {
<span className="italic">only you</span>&mdash;hold the very thing <span className="italic">only you</span>&mdash;hold the very thing
that opens that box,{" "} that opens that box,{" "}
<span className="font-mono text-accent">your password</span>. <span className="font-mono text-accent">your password</span>.
</p> </div>
<p className="text-xs md:text-lg text-right w-full flex items-center justify-end gap-4 leading-relaxed text-neutral-content/80"> <div className="text-xs md:text-lg text-right w-full flex items-center justify-end gap-4 leading-relaxed text-neutral-content/80">
<span> <span>
Nothing on the server is readable without your actual password. Nothing on the server is readable without your actual password.
<br /> <br />
@@ -304,14 +306,14 @@ function SpecsSection() {
(unless this happens) (unless this happens)
</a> </a>
</span> </span>
<div className="w-18 h-18 flex shrink-0 items-center justify-center bg-success/20 rounded-full p-0 "> <div className="w-18 h-18 flex shrink-0 items-center justify-end bg-success/20 rounded-full p-0 ">
<VaultIcon <VaultIcon
size={36} size={36}
weight="duotone" weight="duotone"
className="text-neutral-content" className="text-neutral-content"
/> />
</div> </div>
</p> </div>
<button <button
type={"button"} type={"button"}
@@ -326,11 +328,7 @@ function SpecsSection() {
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}> <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
<div className="w-full bg-paper rounded-md p-6"> <div className="w-full bg-paper rounded-md p-6">
<img <img src={e2eDiag} width={"100%"} alt="pi ku e2e diagram" />
src="/screenshots/e2e.svg"
width={"100%"}
alt="pi ku e2e diagram"
/>
</div> </div>
</Modal> </Modal>
@@ -833,7 +831,7 @@ function AttributionSection() {
<AnimatePresence> <AnimatePresence>
{hover.visible && ( {hover.visible && (
<motion.img <motion.img
src="/saajan.png" src={saajan}
alt="Saajan Fernandes from The Lunchbox, cutout" alt="Saajan Fernandes from The Lunchbox, cutout"
initial={{ opacity: 0, scale: 0.5 }} initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function Activate() {
}); });
await publicApi.get(url); await publicApi.get(url);
setStatus("success"); setStatus("success");
} catch (_err) { } catch {
setStatus("error"); setStatus("error");
} }
}; };
+6 -6
View File
@@ -7,19 +7,19 @@ import {
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useState } from "react"; import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { DrawerSection } from "../components/drawer/DrawerSection.tsx"; import { DrawerSection } from "../components/drawer/DrawerSection";
import { LetterItem } from "../components/drawer/LetterItem.tsx"; import { LetterItem } from "../components/drawer/LetterItem";
import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx"; import { PasskeyModal } from "../components/drawer/PasskeyModal";
import { WelcomeLetterOverlay } from "../components/drawer/WelcomeLetterOverlay.tsx"; import { WelcomeLetterOverlay } from "../components/drawer/WelcomeLetterOverlay";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import Saajan from "../components/ui/Saajan.tsx"; import Saajan from "../components/ui/Saajan";
import { PATHS } from "../config/routes"; import { PATHS } from "../config/routes";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { useLetters } from "../hooks/useLetters"; import { useLetters } from "../hooks/useLetters";
import { import {
formatRelativeDate, formatRelativeDate,
formatRelativeDateWithoutTime, formatRelativeDateWithoutTime,
} from "../utils/dateFormat.ts"; } from "../utils/dateFormat";
export default function Drawer() { export default function Drawer() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
+62 -56
View File
@@ -11,6 +11,7 @@ import {
useParams, useParams,
} from "react-router-dom"; } from "react-router-dom";
import { api } from "../api/apiClient"; import { api } from "../api/apiClient";
import type { LetterResponseData } from "../api/response";
import { import {
type CanvasStyle, type CanvasStyle,
type CanvasTools, type CanvasTools,
@@ -26,7 +27,6 @@ import DateDisplay from "../components/ui/DateDisplay";
import { LogModal } from "../components/ui/LogModal"; import { LogModal } from "../components/ui/LogModal";
import { Modal } from "../components/ui/Modal"; import { Modal } from "../components/ui/Modal";
import { Navbar } from "../components/ui/Navbar"; import { Navbar } from "../components/ui/Navbar";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
import { PATHS } from "../config/routes"; import { PATHS } from "../config/routes";
import { useKeyStore } from "../store/useKeyStore"; import { useKeyStore } from "../store/useKeyStore";
@@ -116,26 +116,11 @@ export default function Editor() {
justSavedRef.current = false; justSavedRef.current = false;
return; return;
} }
const loadExistingLetter = async () => { const decryptAndLoadLetter = async (
setIsInitialLoading(true); letterData: LetterResponseData,
masterKey: CryptoKey,
) => {
const cryptoUtils = new CryptoUtils(); 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( const metadata = await cryptoUtils.decryptMetadata(
{ {
encrypted_content: letterData.encrypted_metadata, encrypted_content: letterData.encrypted_metadata,
@@ -167,8 +152,7 @@ export default function Editor() {
if (isPartialFailure) { if (isPartialFailure) {
setDecryptionStatus({ setDecryptionStatus({
status: "WARN", status: "WARN",
message: message: "Failed to decrypt some elements. Please check the render.",
"Failed to decrypt some elements. Please check the render.",
log: errors.toString(), log: errors.toString(),
}); });
} }
@@ -176,11 +160,30 @@ export default function Editor() {
if (canvasRef.current) { if (canvasRef.current) {
await canvasRef.current.loadData(canvasDataWithDecryptedImages); await canvasRef.current.loadData(canvasDataWithDecryptedImages);
} }
} catch (_err) { };
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({ setDecryptionStatus({
status: "ERROR", status: "ERROR",
message: "Failed to decrypt letter. Please try again later.", message: "Failed to decrypt letter. Please try again later.",
log: _err instanceof Error ? _err.message : "Unknown error", log: err instanceof Error ? err.message : "Unknown error",
}); });
} finally { } finally {
setIsInitialLoading(false); setIsInitialLoading(false);
@@ -242,76 +245,79 @@ export default function Editor() {
} }
}; };
const handleSave = async ( const getRequestData = async (
status: "SEALED" | "DRAFT" | "VAULT", targetId: string,
status: string,
vaultDate?: Date, vaultDate?: Date,
): Promise<void> => { ): Promise<FormData> => {
setSealBtnClicked(false);
let targetId = public_id || letterIdRef.current;
if (!targetId) {
targetId = crypto.randomUUID();
}
if (saveOverlay === "SAVING" || !masterKey) return;
setSaveOverlay("SAVING");
setShowSaveOverlay(true);
const cryptoUtils = new CryptoUtils(); const cryptoUtils = new CryptoUtils();
await cryptoUtils.initialize(); await cryptoUtils.initialize();
try { const canvasData = (await canvasRef.current?.getData()) || { objects: [] };
const canvasData = (await canvasRef.current?.getData()) || {
objects: [],
};
const canvasImages = canvasRef.current?.getImages() || []; const canvasImages = canvasRef.current?.getImages() || [];
const { encryptedImageFiles, encryptedCanvasData } = const { encryptedImageFiles, encryptedCanvasData } =
await encryptCanvasImages( await encryptCanvasImages(
canvasData, canvasData,
canvasImages, canvasImages,
masterKey, // biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
masterKey!,
cryptoUtils, cryptoUtils,
); );
const encrypted_letter = await cryptoUtils.encryptLetter( const encrypted_letter = await cryptoUtils.encryptLetter(
JSON.stringify(encryptedCanvasData), JSON.stringify(encryptedCanvasData),
masterKey, // biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
masterKey!,
); );
const encrypted_metadata = await cryptoUtils.encryptMetadata( const encrypted_metadata = await cryptoUtils.encryptMetadata(
{ recipient, tags: [] }, { recipient, tags: [] },
masterKey, // biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
masterKey!,
); );
const formData = new FormData(); const formData = new FormData();
if (status === "VAULT") { if (status === "VAULT") {
const finalDate = vaultDate || unlockDate; const finalDate = vaultDate || unlockDate;
formData.append("type", "VAULT"); formData.append("type", "VAULT");
if (finalDate) { if (finalDate) formData.append("unlock_at", finalDate.toISOString());
formData.append("unlock_at", finalDate.toISOString());
}
formData.append("status", "SEALED"); formData.append("status", "SEALED");
} else { } else {
formData.append("type", "KEPT"); formData.append("type", "KEPT");
formData.append("status", status); formData.append("status", status);
} }
formData.append("public_id", targetId); formData.append("public_id", targetId);
formData.append("encrypted_content", encrypted_letter.encrypted_content); formData.append("encrypted_content", encrypted_letter.encrypted_content);
formData.append("encrypted_dek", encrypted_letter.encrypted_dek); formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
formData.append( formData.append("encrypted_metadata", encrypted_metadata.encrypted_content);
"encrypted_metadata",
encrypted_metadata.encrypted_content,
);
encryptedImageFiles.forEach((blob, filename) => { encryptedImageFiles.forEach((blob, filename) => {
formData.append("image_files", blob, filename); formData.append("image_files", blob, filename);
}); });
await api.put(`${endpoints.LETTERS}${targetId}/`, formData); return formData;
justSavedRef.current = true; };
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) { if (!public_id) {
letterIdRef.current = targetId; letterIdRef.current = targetId;
navigate(PATHS.write(targetId), { replace: true }); navigate(PATHS.write(targetId), { replace: true });
@@ -326,7 +332,7 @@ export default function Editor() {
} }
setSaveOverlay("SAVED"); setSaveOverlay("SAVED");
setShowSaveOverlay(true); setShowSaveOverlay(true);
} catch (_error) { } catch {
setSaveOverlay("ERROR"); setSaveOverlay("ERROR");
setShowSaveOverlay(true); setShowSaveOverlay(true);
} }
+5 -4
View File
@@ -8,11 +8,12 @@ import {
} from "motion/react"; } from "motion/react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import letterSample from "../assets/screenshots/letter.webp";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import { EnvelopeReveal } from "../components/reader/EnvelopeReveal"; import { EnvelopeReveal } from "../components/reader/EnvelopeReveal";
import Saajan from "../components/ui/Saajan.tsx"; import Saajan from "../components/ui/Saajan";
import { ROUTES } from "../config/routes.ts"; import { ROUTES } from "../config/routes";
import { formatDate } from "../utils/dateFormat.ts"; import { formatDate } from "../utils/dateFormat";
import "@fontsource/space-mono/index.css"; import "@fontsource/space-mono/index.css";
import "@fontsource/architects-daughter/index.css"; import "@fontsource/architects-daughter/index.css";
@@ -325,7 +326,7 @@ export default function Home() {
<div className="mockup-phone w-[75vw] border-primary"> <div className="mockup-phone w-[75vw] border-primary">
<div className="mockup-phone-camera"></div> <div className="mockup-phone-camera"></div>
<div className="mockup-phone-display"> <div className="mockup-phone-display">
<img alt="letter" src="/screenshots/letter.webp" /> <img alt="letter" src={letterSample} />
</div> </div>
</div> </div>
</motion.div> </motion.div>
+74 -48
View File
@@ -1,4 +1,5 @@
import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react"; import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
import type { AxiosResponse } from "axios";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
type NavigateFunction, type NavigateFunction,
@@ -7,6 +8,7 @@ import {
useParams, useParams,
} from "react-router-dom"; } from "react-router-dom";
import { api } from "../api/apiClient"; import { api } from "../api/apiClient";
import type { LetterImageData, LetterResponseData } from "../api/response";
import { import {
type CanvasJSON, type CanvasJSON,
type CanvasTools, type CanvasTools,
@@ -71,7 +73,8 @@ export default function Reader() {
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey); const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
try { try {
await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" }); await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" });
} catch (_err) { } catch {
// shouldn't obstruct share if api operation fails (since it's client side share)
} finally { } finally {
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`); setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
} }
@@ -84,7 +87,10 @@ export default function Reader() {
await api.patch(`${endpoints.LETTERS}${public_id}/`, { await api.patch(`${endpoints.LETTERS}${public_id}/`, {
status: "BURNED", status: "BURNED",
}); });
} catch (_err) { } 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 { } finally {
setIsBurning(false); setIsBurning(false);
setShowBurnModal(false); setShowBurnModal(false);
@@ -103,30 +109,54 @@ export default function Reader() {
return; return;
} }
const loadAndDecrypt = async () => { const decryptImages = async (
canvasData: CanvasJSON,
images: LetterImageData[],
encrypted_dek: string,
cryptoUtils: CryptoUtils,
) => {
if (!images?.length) return;
const isShared = !!sharingKey;
try { try {
const response = await api.get(`${endpoints.LETTERS}${public_id}/`); 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 { const {
encrypted_content, encrypted_content,
encrypted_metadata, encrypted_metadata,
encrypted_dek, encrypted_dek,
images, images,
updated_at, updated_at,
status, } = data;
} = response.data;
if (status === "BURNED")
throw new Error("This letter has been burned.");
if (encrypted_dek) setEncryptedDek(encrypted_dek);
const cryptoUtils = new CryptoUtils();
const isShared = !!sharingKey;
if (isShared && !encrypted_content) throw new Error("Content missing");
const isDecryptionKeyAvailable = encrypted_dek && masterKey;
if (!(isShared || isDecryptionKeyAvailable))
throw new Error("Auth required: Decryption key is not available");
// Decrypt Metadata // Decrypt Metadata
const decryptedMetadata = isShared const decryptedMetadata = isShared
@@ -157,35 +187,31 @@ export default function Reader() {
); );
const canvasData: CanvasJSON = JSON.parse(decryptedContent); const canvasData: CanvasJSON = JSON.parse(decryptedContent);
await decryptImages(canvasData, images, encrypted_dek, cryptoUtils);
try {
// Decrypt Images
if (images?.length > 0) {
isShared
? await decryptCanvasImagesWithSharingKey(
canvasData,
images,
sharingKey,
cryptoUtils,
)
: 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",
});
}
setDecryptedCanvasData(canvasData); setDecryptedCanvasData(canvasData);
};
const processLetterData = async (data: LetterResponseData) => {
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);
};
const loadAndDecryptLetter = async () => {
try {
const response: AxiosResponse<LetterResponseData> = await api.get(
`${endpoints.LETTERS}${public_id}/`,
);
await processLetterData(response.data);
} catch (err) { } catch (err) {
setLogTrace({ setLogTrace({
message: `Failed to load letter ☹`, message: `Failed to load letter ☹`,
@@ -195,7 +221,7 @@ export default function Reader() {
} }
}; };
loadAndDecrypt().then(() => setIsDecrypting(false)); loadAndDecryptLetter().then(() => setIsDecrypting(false));
}, [public_id, sharingKey, masterKey]); }, [public_id, sharingKey, masterKey]);
useEffect(() => { useEffect(() => {
+5 -3
View File
@@ -1,3 +1,5 @@
import type { LetterMetadata } from "../api/response";
export interface EncryptedLetter { export interface EncryptedLetter {
encrypted_content: string; encrypted_content: string;
encrypted_dek: string; encrypted_dek: string;
@@ -275,7 +277,7 @@ export class CryptoUtils {
} }
public async encryptMetadata( public async encryptMetadata(
metadata: Record<string, any>, metadata: LetterMetadata,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<EncryptedLetterMetadata> { ): Promise<EncryptedLetterMetadata> {
const { encryptedContent, encrypted_dek, sharingKey } = const { encryptedContent, encrypted_dek, sharingKey } =
@@ -290,7 +292,7 @@ export class CryptoUtils {
public async decryptMetadata( public async decryptMetadata(
encrypted_metadata: EncryptedLetter, encrypted_metadata: EncryptedLetter,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<Record<string, any>> { ): Promise<LetterMetadata> {
const bytes = await this.openEnvelope( const bytes = await this.openEnvelope(
encrypted_metadata.encrypted_content, encrypted_metadata.encrypted_content,
encrypted_metadata.encrypted_dek, encrypted_metadata.encrypted_dek,
@@ -303,7 +305,7 @@ export class CryptoUtils {
public async decryptMetadataWithSharingKey( public async decryptMetadataWithSharingKey(
encrypted_content: string, encrypted_content: string,
sharingKey: string, sharingKey: string,
): Promise<Record<string, any>> { ): Promise<LetterMetadata> {
const bytes = await this.openEnvelopeWithSharingKey( const bytes = await this.openEnvelopeWithSharingKey(
encrypted_content, encrypted_content,
sharingKey, sharingKey,
+5 -1
View File
@@ -221,7 +221,11 @@ describe("letterLogic image helpers", () => {
], ],
}; };
const remoteImages = [ const remoteImages = [
{ file_name: "photo.png.bin", file: "https://remote/photo.png.bin" }, {
public_id: "1234",
file_name: "photo.png.bin",
file: "https://remote/photo.png.bin",
},
]; ];
vi.mocked(api.get).mockResolvedValue({ data: new Blob(["encrypted"]) }); vi.mocked(api.get).mockResolvedValue({ data: new Blob(["encrypted"]) });
+2 -1
View File
@@ -1,4 +1,5 @@
import { api, apiServerUrl, publicApi } from "../api/apiClient"; import { api, apiServerUrl, publicApi } from "../api/apiClient";
import type { LetterImageData } from "../api/response";
import type { import type {
CanvasJSON, CanvasJSON,
FabricImageJSON, FabricImageJSON,
@@ -111,7 +112,7 @@ export async function decryptCanvasImages(
export async function decryptCanvasImagesWithSharingKey( export async function decryptCanvasImagesWithSharingKey(
canvasData: CanvasJSON, canvasData: CanvasJSON,
remoteImages: { file_name: string; file: string }[], remoteImages: LetterImageData[],
sharingKey: string, sharingKey: string,
cryptoUtils: CryptoUtils, cryptoUtils: CryptoUtils,
): Promise<DecryptionResult> { ): Promise<DecryptionResult> {