refactor: lint formatting and fixes (#6)
 --------- Co-authored-by: me <ramvignesh-b@github.com> Reviewed-on: #6
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 755 B |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,19 +1 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
{"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"}
|
||||
@@ -31,7 +31,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<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 />}>
|
||||
<Routes>
|
||||
<Route
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 5.7 KiB |
@@ -1,4 +1,5 @@
|
||||
import { DotIcon } from "@phosphor-icons/react";
|
||||
import logo from "../assets/logo.svg";
|
||||
import "@fontsource/knewave/400.css";
|
||||
|
||||
interface LogoProps {
|
||||
@@ -31,12 +32,7 @@ export default function Logo({
|
||||
|
||||
if (type === "logo") {
|
||||
return (
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Pi. Ku. logo"
|
||||
className="mx-4"
|
||||
width={scale * 100}
|
||||
/>
|
||||
<img src={logo} alt="Pi. Ku. logo" className="mx-4" width={scale * 100} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export default function SplashScreen() {
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
<Logo />
|
||||
|
||||
@@ -66,7 +66,7 @@ export function WelcomeLetterOverlay({
|
||||
type="button"
|
||||
data-testid="dismiss-welcome-letter-btn"
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -122,7 +122,7 @@ export function ComposeCanvas({
|
||||
// re-calculates height based on content and applies the zoom transform
|
||||
const syncViewport = useCallback(() => {
|
||||
if (!(fabricRef.current && wrapperRef.current)) return;
|
||||
textboxRef.current.initDimensions();
|
||||
textboxRef.current?.initDimensions();
|
||||
|
||||
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
|
||||
logicalSizeRef.current.height = measureLogicalContentHeight(
|
||||
@@ -260,17 +260,35 @@ export function ComposeCanvas({
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
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 () => {
|
||||
// HACK: actual font may change the text-width - small ux improvement
|
||||
await document.fonts.ready;
|
||||
|
||||
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
|
||||
|
||||
let width = wrapperRef.current.clientWidth;
|
||||
if (width === 0) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
|
||||
}
|
||||
const width = await getInitialWidth();
|
||||
|
||||
// init the fabric instance
|
||||
const canvas = new fabric.Canvas(canvasRef.current, {
|
||||
@@ -301,13 +319,7 @@ export function ComposeCanvas({
|
||||
|
||||
// auto window resizing based width
|
||||
lastWidth = wrapperRef.current.clientWidth;
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
const nextWidth = wrapperRef.current?.clientWidth;
|
||||
if (!nextWidth || nextWidth === lastWidth) return;
|
||||
lastWidth = nextWidth;
|
||||
syncViewport();
|
||||
});
|
||||
resizeObserver.observe(wrapperRef.current!);
|
||||
resizeObserver = initResizeOberver();
|
||||
};
|
||||
|
||||
initCanvas().then();
|
||||
|
||||
@@ -63,7 +63,11 @@ export function PostSealModal({
|
||||
type="button"
|
||||
data-testid="view-letter-btn"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => navigate(PATHS.read(sealedTargetId!))}
|
||||
onClick={() => {
|
||||
if (sealedTargetId) {
|
||||
navigate(PATHS.read(sealedTargetId));
|
||||
}
|
||||
}}
|
||||
>
|
||||
View letter
|
||||
</button>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
XCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import type { CanvasStyle } from "./ComposeCanvas.tsx";
|
||||
import type { CanvasStyle } from "./ComposeCanvas";
|
||||
|
||||
interface ToolBarProps {
|
||||
onAddImage: () => void;
|
||||
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
ShieldCheckIcon,
|
||||
WarningIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import Logo from "../Logo.tsx";
|
||||
import Logo from "../Logo";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import Saajan from "../ui/Saajan.tsx";
|
||||
import Saajan from "../ui/Saajan";
|
||||
|
||||
export default function WelcomeModal({
|
||||
setShowWelcome,
|
||||
@@ -53,7 +53,7 @@ export default function WelcomeModal({
|
||||
href="https://www.privacyguides.org/en/passwords/"
|
||||
target="_blank"
|
||||
className="link link-primary-content"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
password manager
|
||||
</a>{" "}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function Modal({
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
{onClose && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import trainImage from "../assets/screenshots/train.png";
|
||||
import type { CanvasJSON } from "../components/editor/ComposeCanvas";
|
||||
|
||||
export function getWelcomeLetterContent(userName: string): CanvasJSON {
|
||||
@@ -30,7 +31,7 @@ export function getWelcomeLetterContent(userName: string): CanvasJSON {
|
||||
originY: "top",
|
||||
left: 36,
|
||||
top: 36,
|
||||
width: 608,
|
||||
width: 720,
|
||||
height: 813.6,
|
||||
fill: "#111e67",
|
||||
stroke: null,
|
||||
@@ -90,12 +91,12 @@ export function getWelcomeLetterContent(userName: string): CanvasJSON {
|
||||
globalCompositeOperation: "source-over",
|
||||
skewX: 0,
|
||||
skewY: 0,
|
||||
src: "/screenshots/train.png",
|
||||
src: trainImage,
|
||||
crossOrigin: null,
|
||||
filters: [],
|
||||
},
|
||||
],
|
||||
canvasWidth: 680,
|
||||
canvasWidth: 700,
|
||||
canvasHeight: 900,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export const useAuth = () => {
|
||||
const logout = async () => {
|
||||
try {
|
||||
await api.post(endpoints.LOGOUT);
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
} finally {
|
||||
clearAuth();
|
||||
setMasterKey(null);
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "../api/apiClient";
|
||||
import type { LetterMetadata, LetterResponseData } from "../api/response";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
import { useKeyStore } from "../store/useKeyStore";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
|
||||
export interface Letter {
|
||||
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 {
|
||||
export interface ProcessedLetter extends LetterResponseData {
|
||||
metadata: LetterMetadata;
|
||||
}
|
||||
|
||||
async function decryptLettersMetadata(
|
||||
letters: Letter[],
|
||||
letters: LetterResponseData[],
|
||||
masterKey: CryptoKey,
|
||||
): Promise<ProcessedLetter[]> {
|
||||
const cryptoUtils = new CryptoUtils();
|
||||
@@ -43,7 +27,7 @@ async function decryptLettersMetadata(
|
||||
)) as LetterMetadata;
|
||||
|
||||
return { ...letter, metadata };
|
||||
} catch (_err) {
|
||||
} catch {
|
||||
return {
|
||||
...letter,
|
||||
metadata: { recipient: "Encrypted Letter" },
|
||||
|
||||
@@ -7,7 +7,7 @@ import "@fontsource-variable/playwrite-hr-lijeva/wght.css";
|
||||
import "@fontsource-variable/jost/wght.css";
|
||||
import "@fontsource-variable/playfair-display/wght.css";
|
||||
|
||||
import App from "./App.tsx";
|
||||
import App from "./App";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
|
||||
@@ -25,7 +25,9 @@ import { ReactLenis } from "lenis/react";
|
||||
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 "@fontsource/kavivanar/index.css";
|
||||
@@ -35,7 +37,7 @@ import "@fontsource/architects-daughter/index.css";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
function HorizontalScroll({ children }: { children: React.ReactNode }) {
|
||||
const ref = useRef(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({ target: ref });
|
||||
const x = useTransform(scrollYProgress, [0, 1], ["0%", "-50%"]);
|
||||
|
||||
@@ -166,7 +168,7 @@ function PrivacySection() {
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<h1 className="text-3xl md:text-6xl uppercase font-bold text-right tracking-widest mt-2 md:mt-8">
|
||||
server see
|
||||
@@ -257,7 +259,7 @@ function SpecsSection() {
|
||||
</a>{" "}
|
||||
for the <span className="font-hand text-primary">keys</span>.
|
||||
</h2>
|
||||
<p className="text-sm md:text-xl leading-relaxed">
|
||||
<div className="text-sm md:text-xl leading-relaxed">
|
||||
This means, both the{" "}
|
||||
<span className="font-display text-info">encryption</span> and{" "}
|
||||
<span className="font-display text-info">decryption</span> runs on
|
||||
@@ -288,8 +290,8 @@ function SpecsSection() {
|
||||
<span className="italic">only you</span>—hold the very thing
|
||||
that opens that box,{" "}
|
||||
<span className="font-mono text-accent">your password</span>.
|
||||
</p>
|
||||
<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>
|
||||
<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>
|
||||
Nothing on the server is readable without your actual password.
|
||||
<br />
|
||||
@@ -304,14 +306,14 @@ function SpecsSection() {
|
||||
(unless this happens)
|
||||
</a>
|
||||
</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
|
||||
size={36}
|
||||
weight="duotone"
|
||||
className="text-neutral-content"
|
||||
/>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type={"button"}
|
||||
@@ -326,11 +328,7 @@ function SpecsSection() {
|
||||
|
||||
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
|
||||
<div className="w-full bg-paper rounded-md p-6">
|
||||
<img
|
||||
src="/screenshots/e2e.svg"
|
||||
width={"100%"}
|
||||
alt="pi ku e2e diagram"
|
||||
/>
|
||||
<img src={e2eDiag} width={"100%"} alt="pi ku e2e diagram" />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -833,7 +831,7 @@ function AttributionSection() {
|
||||
<AnimatePresence>
|
||||
{hover.visible && (
|
||||
<motion.img
|
||||
src="/saajan.png"
|
||||
src={saajan}
|
||||
alt="Saajan Fernandes from The Lunchbox, cutout"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function Activate() {
|
||||
});
|
||||
await publicApi.get(url);
|
||||
setStatus("success");
|
||||
} catch (_err) {
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,19 +7,19 @@ import {
|
||||
} from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { DrawerSection } from "../components/drawer/DrawerSection.tsx";
|
||||
import { LetterItem } from "../components/drawer/LetterItem.tsx";
|
||||
import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx";
|
||||
import { WelcomeLetterOverlay } from "../components/drawer/WelcomeLetterOverlay.tsx";
|
||||
import { DrawerSection } from "../components/drawer/DrawerSection";
|
||||
import { LetterItem } from "../components/drawer/LetterItem";
|
||||
import { PasskeyModal } from "../components/drawer/PasskeyModal";
|
||||
import { WelcomeLetterOverlay } from "../components/drawer/WelcomeLetterOverlay";
|
||||
import Logo from "../components/Logo";
|
||||
import Saajan from "../components/ui/Saajan.tsx";
|
||||
import Saajan from "../components/ui/Saajan";
|
||||
import { PATHS } from "../config/routes";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { useLetters } from "../hooks/useLetters";
|
||||
import {
|
||||
formatRelativeDate,
|
||||
formatRelativeDateWithoutTime,
|
||||
} from "../utils/dateFormat.ts";
|
||||
} from "../utils/dateFormat";
|
||||
|
||||
export default function Drawer() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
import { api } from "../api/apiClient";
|
||||
import type { LetterResponseData } from "../api/response";
|
||||
import {
|
||||
type CanvasStyle,
|
||||
type CanvasTools,
|
||||
@@ -26,7 +27,6 @@ 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";
|
||||
@@ -116,26 +116,11 @@ export default function Editor() {
|
||||
justSavedRef.current = false;
|
||||
return;
|
||||
}
|
||||
const loadExistingLetter = async () => {
|
||||
setIsInitialLoading(true);
|
||||
const decryptAndLoadLetter = async (
|
||||
letterData: LetterResponseData,
|
||||
masterKey: CryptoKey,
|
||||
) => {
|
||||
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,
|
||||
@@ -167,8 +152,7 @@ export default function Editor() {
|
||||
if (isPartialFailure) {
|
||||
setDecryptionStatus({
|
||||
status: "WARN",
|
||||
message:
|
||||
"Failed to decrypt some elements. Please check the render.",
|
||||
message: "Failed to decrypt some elements. Please check the render.",
|
||||
log: errors.toString(),
|
||||
});
|
||||
}
|
||||
@@ -176,11 +160,30 @@ export default function Editor() {
|
||||
if (canvasRef.current) {
|
||||
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({
|
||||
status: "ERROR",
|
||||
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 {
|
||||
setIsInitialLoading(false);
|
||||
@@ -242,76 +245,79 @@ export default function Editor() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (
|
||||
status: "SEALED" | "DRAFT" | "VAULT",
|
||||
const getRequestData = async (
|
||||
targetId: string,
|
||||
status: string,
|
||||
vaultDate?: Date,
|
||||
): Promise<void> => {
|
||||
setSealBtnClicked(false);
|
||||
|
||||
let targetId = public_id || letterIdRef.current;
|
||||
if (!targetId) {
|
||||
targetId = crypto.randomUUID();
|
||||
}
|
||||
|
||||
if (saveOverlay === "SAVING" || !masterKey) return;
|
||||
|
||||
setSaveOverlay("SAVING");
|
||||
setShowSaveOverlay(true);
|
||||
|
||||
): Promise<FormData> => {
|
||||
const cryptoUtils = new CryptoUtils();
|
||||
await cryptoUtils.initialize();
|
||||
|
||||
try {
|
||||
const canvasData = (await canvasRef.current?.getData()) || {
|
||||
objects: [],
|
||||
};
|
||||
const canvasData = (await canvasRef.current?.getData()) || { objects: [] };
|
||||
const canvasImages = canvasRef.current?.getImages() || [];
|
||||
|
||||
const { encryptedImageFiles, encryptedCanvasData } =
|
||||
await encryptCanvasImages(
|
||||
canvasData,
|
||||
canvasImages,
|
||||
masterKey,
|
||||
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
|
||||
masterKey!,
|
||||
cryptoUtils,
|
||||
);
|
||||
|
||||
const encrypted_letter = await cryptoUtils.encryptLetter(
|
||||
JSON.stringify(encryptedCanvasData),
|
||||
masterKey,
|
||||
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
|
||||
masterKey!,
|
||||
);
|
||||
|
||||
const encrypted_metadata = await cryptoUtils.encryptMetadata(
|
||||
{ recipient, tags: [] },
|
||||
masterKey,
|
||||
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
|
||||
masterKey!,
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
if (status === "VAULT") {
|
||||
const finalDate = vaultDate || unlockDate;
|
||||
formData.append("type", "VAULT");
|
||||
if (finalDate) {
|
||||
formData.append("unlock_at", finalDate.toISOString());
|
||||
}
|
||||
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,
|
||||
);
|
||||
formData.append("encrypted_metadata", encrypted_metadata.encrypted_content);
|
||||
|
||||
encryptedImageFiles.forEach((blob, filename) => {
|
||||
formData.append("image_files", blob, filename);
|
||||
});
|
||||
|
||||
await api.put(`${endpoints.LETTERS}${targetId}/`, formData);
|
||||
justSavedRef.current = true;
|
||||
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 });
|
||||
@@ -326,7 +332,7 @@ export default function Editor() {
|
||||
}
|
||||
setSaveOverlay("SAVED");
|
||||
setShowSaveOverlay(true);
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
setSaveOverlay("ERROR");
|
||||
setShowSaveOverlay(true);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
} from "motion/react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import letterSample from "../assets/screenshots/letter.webp";
|
||||
import Logo from "../components/Logo";
|
||||
import { EnvelopeReveal } from "../components/reader/EnvelopeReveal";
|
||||
import Saajan from "../components/ui/Saajan.tsx";
|
||||
import { ROUTES } from "../config/routes.ts";
|
||||
import { formatDate } from "../utils/dateFormat.ts";
|
||||
import Saajan from "../components/ui/Saajan";
|
||||
import { ROUTES } from "../config/routes";
|
||||
import { formatDate } from "../utils/dateFormat";
|
||||
|
||||
import "@fontsource/space-mono/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-camera"></div>
|
||||
<div className="mockup-phone-display">
|
||||
<img alt="letter" src="/screenshots/letter.webp" />
|
||||
<img alt="letter" src={letterSample} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type NavigateFunction,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
import { api } from "../api/apiClient";
|
||||
import type { LetterImageData, LetterResponseData } from "../api/response";
|
||||
import {
|
||||
type CanvasJSON,
|
||||
type CanvasTools,
|
||||
@@ -71,7 +73,8 @@ export default function Reader() {
|
||||
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
|
||||
try {
|
||||
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 {
|
||||
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
|
||||
}
|
||||
@@ -84,7 +87,10 @@ export default function Reader() {
|
||||
await api.patch(`${endpoints.LETTERS}${public_id}/`, {
|
||||
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 {
|
||||
setIsBurning(false);
|
||||
setShowBurnModal(false);
|
||||
@@ -103,30 +109,54 @@ export default function Reader() {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadAndDecrypt = async () => {
|
||||
const decryptImages = async (
|
||||
canvasData: CanvasJSON,
|
||||
images: LetterImageData[],
|
||||
encrypted_dek: string,
|
||||
cryptoUtils: CryptoUtils,
|
||||
) => {
|
||||
if (!images?.length) return;
|
||||
const isShared = !!sharingKey;
|
||||
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 {
|
||||
encrypted_content,
|
||||
encrypted_metadata,
|
||||
encrypted_dek,
|
||||
images,
|
||||
updated_at,
|
||||
status,
|
||||
} = 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");
|
||||
} = data;
|
||||
|
||||
// Decrypt Metadata
|
||||
const decryptedMetadata = isShared
|
||||
@@ -157,35 +187,31 @@ export default function Reader() {
|
||||
);
|
||||
|
||||
const canvasData: CanvasJSON = JSON.parse(decryptedContent);
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
await decryptImages(canvasData, images, encrypted_dek, cryptoUtils);
|
||||
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) {
|
||||
setLogTrace({
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { LetterMetadata } from "../api/response";
|
||||
|
||||
export interface EncryptedLetter {
|
||||
encrypted_content: string;
|
||||
encrypted_dek: string;
|
||||
@@ -275,7 +277,7 @@ export class CryptoUtils {
|
||||
}
|
||||
|
||||
public async encryptMetadata(
|
||||
metadata: Record<string, any>,
|
||||
metadata: LetterMetadata,
|
||||
masterKey: CryptoKey,
|
||||
): Promise<EncryptedLetterMetadata> {
|
||||
const { encryptedContent, encrypted_dek, sharingKey } =
|
||||
@@ -290,7 +292,7 @@ export class CryptoUtils {
|
||||
public async decryptMetadata(
|
||||
encrypted_metadata: EncryptedLetter,
|
||||
masterKey: CryptoKey,
|
||||
): Promise<Record<string, any>> {
|
||||
): Promise<LetterMetadata> {
|
||||
const bytes = await this.openEnvelope(
|
||||
encrypted_metadata.encrypted_content,
|
||||
encrypted_metadata.encrypted_dek,
|
||||
@@ -303,7 +305,7 @@ export class CryptoUtils {
|
||||
public async decryptMetadataWithSharingKey(
|
||||
encrypted_content: string,
|
||||
sharingKey: string,
|
||||
): Promise<Record<string, any>> {
|
||||
): Promise<LetterMetadata> {
|
||||
const bytes = await this.openEnvelopeWithSharingKey(
|
||||
encrypted_content,
|
||||
sharingKey,
|
||||
|
||||
@@ -221,7 +221,11 @@ describe("letterLogic image helpers", () => {
|
||||
],
|
||||
};
|
||||
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"]) });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api, apiServerUrl, publicApi } from "../api/apiClient";
|
||||
import type { LetterImageData } from "../api/response";
|
||||
import type {
|
||||
CanvasJSON,
|
||||
FabricImageJSON,
|
||||
@@ -111,7 +112,7 @@ export async function decryptCanvasImages(
|
||||
|
||||
export async function decryptCanvasImagesWithSharingKey(
|
||||
canvasData: CanvasJSON,
|
||||
remoteImages: { file_name: string; file: string }[],
|
||||
remoteImages: LetterImageData[],
|
||||
sharingKey: string,
|
||||
cryptoUtils: CryptoUtils,
|
||||
): Promise<DecryptionResult> {
|
||||
|
||||