feat/welcome-letter integration (#2)
CI / Generate Certificates (push) Successful in 42s
CI / Frontend CI (push) Successful in 1m8s
CI / Backend CI (push) Successful in 1m7s
CI / E2E Tests (push) Has been skipped

Co-authored-by: me <ramvignesh-b@github.com>
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-05-06 16:46:53 +00:00
parent 8449377b6d
commit 8d0ab979f5
13 changed files with 361 additions and 89 deletions
+1 -1
View File
@@ -208,7 +208,7 @@ test.describe("Letter Drafting (Real Backend)", () => {
// Verify it opens the Reader without a hash // Verify it opens the Reader without a hash
logger.info(">> [Drawer] Verifying Reader page..."); logger.info(">> [Drawer] Verifying Reader page...");
// Give it a bit more time for decryption // Give it a bit more time for decryption
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 }); // UUID without hash await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 });
// Reveal and check decrypted content in Reader // Reveal and check decrypted content in Reader
await expect(page.getByText(/breaking the seal/i)).toBeHidden({ await expect(page.getByText(/breaking the seal/i)).toBeHidden({
timeout: 10000, timeout: 10000,
+29 -1
View File
@@ -54,6 +54,34 @@ async function registerAndLogin(
await page.getByRole("button", { name: /sign in/i }).click(); await page.getByRole("button", { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/drawer/); await expect(page).toHaveURL(/\/drawer/);
await handleWelcomeLetter(page);
logger.info(`[Auth] Successfully authenticated ${email}`); logger.info(`[Auth] Successfully authenticated ${email}`);
} }
export const AuthHelper = { registerAndLogin };
/**
* Handles and dismisses the first welocme letter
*/
async function handleWelcomeLetter(page: Page) {
logger.info("[Auth] Handling Welcome Letter...");
// Click envelope to flip
const envelope = page.locator("#env-front");
await envelope.waitFor({ state: "visible", timeout: 10000 });
await envelope.click();
// Click seal to open flap
const seal = page.getByAltText("Seal");
await seal.waitFor({ state: "visible" });
await seal.click();
// Click letter to reveal
await page.locator("#letter").click({ position: { x: 30, y: 15 } });
// Click "I'll see you" button
const completeButton = page.getByRole("button", { name: /I'll see you/i });
await completeButton.waitFor({ state: "visible", timeout: 10000 });
await completeButton.click();
await expect(completeButton).toBeHidden();
}
export const AuthHelper = { registerAndLogin, handleWelcomeLetter };
+1 -1
View File
@@ -15,7 +15,7 @@ const baseUrl = getBaseUrl(
); );
export default defineConfig({ export default defineConfig({
timeout: 60000, timeout: 80000,
expect: { expect: {
timeout: 10000, timeout: 10000,
}, },
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

+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-10 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/noise.gif')]">
<Suspense fallback={<SplashScreen />}> <Suspense fallback={<SplashScreen />}>
<Routes> <Routes>
<Route path={ROUTES.HOME} element={<Home />} /> <Route path={ROUTES.HOME} element={<Home />} />
@@ -0,0 +1,77 @@
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { getWelcomeLetterContent } from "../../config/welcomeLetter";
import { formatDate } from "../../utils/dateFormat";
import { type CanvasTools, ComposeCanvas } from "../editor/ComposeCanvas";
import { EnvelopeReveal } from "../reader/EnvelopeReveal";
interface WelcomeLetterOverlayProps {
onComplete: () => void;
userName: string;
}
export function WelcomeLetterOverlay({
onComplete,
userName,
}: WelcomeLetterOverlayProps) {
const [revealState, setRevealState] = useState<"SEALED" | "REVEALED">(
"SEALED",
);
const canvasRef = useRef<CanvasTools>(null);
useEffect(() => {
if (revealState === "REVEALED" && canvasRef.current) {
const welcomeContent = getWelcomeLetterContent(userName);
canvasRef.current.loadData(welcomeContent);
}
}, [revealState, userName]);
return (
<div className="fixed inset-0 z-30 backdrop-blur-3xl flex flex-col items-center justify-center p-4 md:p-8 overflow-x-hidden">
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
<div className="w-full max-w-4xl z-10 flex flex-col items-center">
<AnimatePresence mode="wait">
{revealState === "SEALED" && (
<motion.div
key="envelope"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 0.8, opacity: 1 }}
exit={{
scale: 1,
opacity: 0,
transition: { duration: 0.5, ease: "easeOut" },
}}
transition={{ duration: 4, delay: 1 }}
>
<EnvelopeReveal
recipient={userName}
date={formatDate(new Date())}
onRevealComplete={() => setRevealState("REVEALED")}
ignite={false}
/>
</motion.div>
)}
</AnimatePresence>
<div
className={`w-full space-y-8 py-12 ${revealState === "REVEALED" ? "block" : "hidden"}`}
>
<div className="bg-paper shadow-warm rounded-sm overflow-hidden mx-auto max-w-180">
<div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" />
<ComposeCanvas ref={canvasRef} readOnly />
</div>
<div className="flex justify-center mt-12">
<button
type="button"
onClick={onComplete}
className="btn btn-accent opacity-80 px-12 shadow-lg"
>
I'll see you
</button>
</div>
</div>
</div>
</div>
);
}
@@ -2,6 +2,12 @@ import * as fabric from "fabric";
import type * as React from "react"; import type * as React from "react";
import { useCallback, useEffect, useImperativeHandle, useRef } from "react"; import { useCallback, useEffect, useImperativeHandle, useRef } from "react";
import "@fontsource/kavivanar/index.css";
import "@fontsource/space-mono/index.css";
import "@fontsource/cutive-mono/index.css";
import "@fontsource/architects-daughter/index.css";
import "@fontsource/redacted-script/index.css";
const PAD = 36; const PAD = 36;
const BASE_WIDTH = 680; const BASE_WIDTH = 680;
const DEFAULT_LOGICAL_HEIGHT = 900; const DEFAULT_LOGICAL_HEIGHT = 900;
@@ -184,9 +190,7 @@ export function ComposeCanvas({
fontFamily: DEFAULT_FONT_FAMILY, fontFamily: DEFAULT_FONT_FAMILY,
fill: DEFAULT_FONT_COLOR, fill: DEFAULT_FONT_COLOR,
lineHeight: 1.5, lineHeight: 1.5,
// NOTE: splitByGrapheme is required for word wrap and re-low splitByGrapheme: false,
// but fabric asks to disable this for clear font?? So we disable it for read view
splitByGrapheme: !readOnly,
lockMovementX: true, lockMovementX: true,
lockMovementY: true, lockMovementY: true,
lockScalingX: true, lockScalingX: true,
@@ -220,6 +224,16 @@ export function ComposeCanvas({
} }
}); });
for (const img of canvas.getObjects("Image")) {
img.set({
hasControls: !readOnly,
hasBorders: !readOnly,
});
}
// NOTE: fabric refreshes fonts once the textbox is rendered after initial focus
await document.fonts.ready;
textbox.set("dirty", true);
syncViewport(); syncViewport();
// Hack: Fabric needs a small initial delay to mount before it will accept focus. // Hack: Fabric needs a small initial delay to mount before it will accept focus.
@@ -34,8 +34,7 @@ export default function WelcomeModal({
className="inline text-primary" className="inline text-primary"
weight="fill" weight="fill"
/> />
<div className="divider my-0"></div> <span className="divider my-0 block"></span>
<br />
Everything you write here is sealed with your password,{" "} Everything you write here is sealed with your password,{" "}
<span className="font-display text-success">cryptographically</span> <span className="font-display text-success">cryptographically</span>
, before it leaves your hands. , before it leaves your hands.
@@ -44,11 +43,11 @@ export default function WelcomeModal({
<div className="alert alert-warning bg-paper/20 border-paper/20 flex items-start gap-3 text-left py-3"> <div className="alert alert-warning bg-paper/20 border-paper/20 flex items-start gap-3 text-left py-3">
<WarningIcon size={24} weight="fill" className="shrink-0 mt-0.5" /> <WarningIcon size={24} weight="fill" className="shrink-0 mt-0.5" />
<p className="text-sm font-medium text-primary-content"> <div className="text-sm font-medium text-primary-content">
If you ever happen to forget your password, your letters are lost If you ever happen to forget your password, your letters are lost
to time, forever. to time, forever.
<br /> <br />
<span className="font-bold mt-2"> <span className="font-bold mt-2 block">
I highly, highly recommend storing this password in your{" "} I highly, highly recommend storing this password in your{" "}
<a <a
href="https://www.privacyguides.org/en/passwords/" href="https://www.privacyguides.org/en/passwords/"
@@ -60,7 +59,7 @@ export default function WelcomeModal({
</a>{" "} </a>{" "}
or somewhere safe to remember it. or somewhere safe to remember it.
</span> </span>
</p> </div>
</div> </div>
<div className="modal-action w-full"> <div className="modal-action w-full">
+101
View File
@@ -0,0 +1,101 @@
import type { CanvasJSON } from "../components/editor/ComposeCanvas";
export function getWelcomeLetterContent(userName: string): CanvasJSON {
return {
objects: [
{
fontSize: 18,
fontWeight: 500,
fontFamily: "Kavivanar",
fontStyle: "normal",
lineHeight: 1.5,
text: `\nDear ${userName}, \n\nYou made it this far, which means something already brought you here. \nA name, maybe. A feeling you haven't been able to shake. Something you typed and deleted too many times to count.\n\nMost people carry it quietly. They tell themselves it doesn't matter anymore, or that too much time has passed, or that the other person wouldn't understand anyway. And maybe they're right. \n\nBut the thing is — the unsaid thing doesn't really care about any of that. \nIt just stays.\n\nSo here you are.\n\nYou don't have to know what you want to say yet. \nYou don't have to have it figured out — who it's for, or why it still matters, or what you're hoping will happen after. \n\nA lot of letters written here start without any of that. They find their way.\n\nTake your time. \nNo one's watching. \n\nWhen you're ready, write a letter.\n\nSometimes the wrong train takes you to the right station.\n- S.F.`,
charSpacing: 0,
textAlign: "left",
styles: [],
pathStartOffset: 0,
pathSide: "left",
pathAlign: "baseline",
underline: false,
overline: false,
linethrough: false,
textBackgroundColor: "",
direction: "ltr",
textDecorationThickness: 66.667,
minWidth: 20,
splitByGrapheme: false,
type: "Textbox",
version: "7.2.0",
originX: "left",
originY: "top",
left: 36,
top: 36,
width: 608,
height: 813.6,
fill: "#111e67",
stroke: null,
strokeWidth: 1,
strokeDashArray: null,
strokeLineCap: "butt",
strokeDashOffset: 0,
strokeLineJoin: "miter",
strokeUniform: false,
strokeMiterLimit: 4,
scaleX: 1,
scaleY: 1,
angle: 0,
flipX: false,
flipY: false,
opacity: 1,
shadow: null,
visible: true,
backgroundColor: "",
fillRule: "nonzero",
paintFirst: "fill",
globalCompositeOperation: "source-over",
skewX: 0,
skewY: 0,
},
{
cropX: 0,
cropY: 0,
type: "Image",
version: "7.2.0",
originX: "left",
originY: "top",
left: 298.4065,
top: 660.2853,
width: 512,
height: 400,
fill: "rgb(0,0,0)",
stroke: null,
strokeWidth: 0,
strokeDashArray: null,
strokeLineCap: "butt",
strokeDashOffset: 0,
strokeLineJoin: "miter",
strokeUniform: false,
strokeMiterLimit: 4,
scaleX: 0.4753,
scaleY: 0.4753,
angle: 355.5436,
flipX: false,
flipY: false,
opacity: 1,
shadow: null,
visible: true,
backgroundColor: "",
fillRule: "nonzero",
paintFirst: "fill",
globalCompositeOperation: "source-over",
skewX: 0,
skewY: 0,
src: "/screenshots/train.png",
crossOrigin: null,
filters: [],
},
],
canvasWidth: 680,
canvasHeight: 900,
};
}
+101 -61
View File
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { mockUser } from "../../test/fixtures/user.fixture"; import { mockUser } from "../../test/fixtures/user.fixture";
@@ -7,77 +7,117 @@ import { useAuthStore } from "../store/useAuthStore";
import Drawer from "./Drawer"; import Drawer from "./Drawer";
vi.mock("../hooks/useLetters"); vi.mock("../hooks/useLetters");
vi.mock("../components/drawer/WelcomeLetterOverlay", () => ({
WelcomeLetterOverlay: ({ onComplete }: any) => (
<div data-testid="welcome-letter-overlay">
<button
type="button"
data-testid="overlay-exit-button"
onClick={onComplete}
>
I'll see you
</button>
</div>
),
}));
describe("Drawer Page", () => { describe("Drawer Page", () => {
beforeEach(() => { beforeEach(() => {
// Setup authenticated state for the test // Setup authenticated state for the test
useAuthStore.setState({ useAuthStore.setState({
user: mockUser, user: mockUser,
accessToken: "fake-token", accessToken: "fake-token",
isInitializing: false, isInitializing: false,
});
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: false,
isAuthRequired: false,
});
}); });
it("renders the cabinet sections and empty state message", () => { vi.mocked(useLetters).mockReturnValue({
render( drafts: [],
<MemoryRouter> kept: [],
<Drawer /> sent: [],
</MemoryRouter>, vault: [],
); loading: false,
isAuthRequired: false,
});
});
expect(screen.getByText(/Drafts/i)).toBeInTheDocument(); it("renders the cabinet sections and empty state message", () => {
expect(screen.getAllByText(/Kept/i).length).toBeGreaterThanOrEqual(1); render(
expect(screen.getByText(/Vault/i)).toBeInTheDocument(); <MemoryRouter>
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument(); <Drawer />
</MemoryRouter>,
);
expect(screen.getByText(/Drafts/i)).toBeInTheDocument();
expect(screen.getAllByText(/Kept/i).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/Vault/i)).toBeInTheDocument();
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument();
});
it("renders the loading state", () => {
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: true,
isAuthRequired: false,
}); });
it("renders the loading state", () => { render(
vi.mocked(useLetters).mockReturnValue({ <MemoryRouter>
drafts: [], <Drawer />
kept: [], </MemoryRouter>,
sent: [], );
vault: [],
loading: true,
isAuthRequired: false,
});
render( expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument();
<MemoryRouter> });
<Drawer />
</MemoryRouter>,
);
expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument(); it("renders the authentication required modal when api requires auth", () => {
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: false,
isAuthRequired: true,
}); });
it("renders the authentication required modal when api requires auth", () => { render(
vi.mocked(useLetters).mockReturnValue({ <MemoryRouter>
drafts: [], <Drawer />
kept: [], </MemoryRouter>,
sent: [], );
vault: [],
loading: false,
isAuthRequired: true,
});
render( expect(screen.getByText(/You've been away a while./i)).toBeInTheDocument();
<MemoryRouter> expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
<Drawer /> });
</MemoryRouter>,
);
expect( it("renders the welcome letter when firstTime state is present", () => {
screen.getByText(/You've been away a while./i), render(
).toBeInTheDocument(); <MemoryRouter
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument(); initialEntries={[{ pathname: "/drawer", state: { firstTime: true } }]}
}); >
<Drawer />
</MemoryRouter>,
);
expect(screen.getByTestId("welcome-letter-overlay")).toBeInTheDocument();
});
it("renders the drawer content when the letter is closed", () => {
render(
<MemoryRouter
initialEntries={[{ pathname: "/drawer", state: { firstTime: true } }]}
>
<Drawer />
</MemoryRouter>,
);
const completeButton = screen.getByTestId("overlay-exit-button");
fireEvent.click(completeButton);
expect(
screen.queryByTestId("welcome-letter-overlay"),
).not.toBeInTheDocument();
});
}); });
+24 -7
View File
@@ -1,9 +1,10 @@
import { FeatherIcon } from "@phosphor-icons/react"; import { FeatherIcon } from "@phosphor-icons/react";
import { useState } from "react"; import { useState } from "react";
import { 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.tsx";
import { LetterItem } from "../components/drawer/LetterItem.tsx"; import { LetterItem } from "../components/drawer/LetterItem.tsx";
import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx"; import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx";
import { WelcomeLetterOverlay } from "../components/drawer/WelcomeLetterOverlay.tsx";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import Saajan from "../components/ui/Saajan.tsx"; import Saajan from "../components/ui/Saajan.tsx";
import { PATHS } from "../config/routes"; import { PATHS } from "../config/routes";
@@ -19,6 +20,10 @@ export default function Drawer() {
const [openSection, setOpenSection] = useState<string | null>(null); const [openSection, setOpenSection] = useState<string | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [showWelcomeLetter, setShowWelcomeLetter] = useState(
!!location.state?.firstTime,
);
const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters(); const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters();
if (!user) return null; if (!user) return null;
@@ -30,6 +35,16 @@ export default function Drawer() {
<div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors"> <div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors">
<div className="fixed inset-0 bg-vig pointer-events-none z-0" /> <div className="fixed inset-0 bg-vig pointer-events-none z-0" />
{showWelcomeLetter && (
<WelcomeLetterOverlay
userName={user.full_name}
onComplete={() => {
setShowWelcomeLetter(false);
navigate(location.pathname, { replace: true, state: {} });
}}
/>
)}
{isAuthRequired && <PasskeyModal />} {isAuthRequired && <PasskeyModal />}
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500"> <header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
<Logo /> <Logo />
@@ -166,12 +181,14 @@ export default function Drawer() {
<footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10"> <footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10">
For your unsaid. For your unsaid.
</footer> </footer>
<div className="absolute bottom-0 z-50 font-sans"> {!showWelcomeLetter && (
<Saajan <div className="absolute bottom-0 z-50 font-sans">
message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`} <Saajan
position="top" message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`}
/> position="top"
</div> />
</div>
)}
</div> </div>
); );
} }
+3 -7
View File
@@ -34,12 +34,6 @@ import { CryptoUtils } from "../utils/crypto";
import { formatRelativeDate } from "../utils/dateFormat"; import { formatRelativeDate } from "../utils/dateFormat";
import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic"; import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic";
import "@fontsource/kavivanar/index.css";
import "@fontsource/space-mono/index.css";
import "@fontsource/cutive-mono/index.css";
import "@fontsource/architects-daughter/index.css";
import "@fontsource/redacted-script/index.css";
type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR"; type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR";
const OVERLAY_FADE_MS = 250; const OVERLAY_FADE_MS = 250;
@@ -268,7 +262,9 @@ export default function Editor() {
await cryptoUtils.initialize(); await cryptoUtils.initialize();
try { try {
const canvasData = 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 } =
+2 -2
View File
@@ -7,7 +7,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import { z } from "zod"; import { z } from "zod";
import { api, publicApi } from "../api/apiClient"; import { api, publicApi } from "../api/apiClient";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import WelcomeModal from "../components/login/WelcomeModal.tsx"; import WelcomeModal from "../components/login/WelcomeModal";
import FormField from "../components/ui/FormField"; import FormField from "../components/ui/FormField";
import Saajan from "../components/ui/Saajan"; import Saajan from "../components/ui/Saajan";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
@@ -64,7 +64,7 @@ export default function Login() {
await setAuthStore(authData.access, userData, masterKey); await setAuthStore(authData.access, userData, masterKey);
navigate(nextRoute, { replace: true }); navigate(nextRoute, { replace: true, state: location.state });
} catch (err) { } catch (err) {
let message = let message =
"Sorry, we're experiencing technical issues.\nPlease try again later."; "Sorry, we're experiencing technical issues.\nPlease try again later.";