diff --git a/frontend/e2e/letter.spec.ts b/frontend/e2e/letter.spec.ts index bfc216a..3b90fce 100644 --- a/frontend/e2e/letter.spec.ts +++ b/frontend/e2e/letter.spec.ts @@ -208,7 +208,7 @@ test.describe("Letter Drafting (Real Backend)", () => { // Verify it opens the Reader without a hash logger.info(">> [Drawer] Verifying Reader page..."); // 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 await expect(page.getByText(/breaking the seal/i)).toBeHidden({ timeout: 10000, diff --git a/frontend/e2e/utils/auth.ts b/frontend/e2e/utils/auth.ts index a9d373e..e0b255e 100644 --- a/frontend/e2e/utils/auth.ts +++ b/frontend/e2e/utils/auth.ts @@ -54,6 +54,34 @@ async function registerAndLogin( await page.getByRole("button", { name: /sign in/i }).click(); await expect(page).toHaveURL(/\/drawer/); + await handleWelcomeLetter(page); 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 }; diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 13007d3..8a81b4f 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -15,7 +15,7 @@ const baseUrl = getBaseUrl( ); export default defineConfig({ - timeout: 60000, + timeout: 80000, expect: { timeout: 10000, }, diff --git a/frontend/public/screenshots/train.png b/frontend/public/screenshots/train.png new file mode 100644 index 0000000..9e60582 Binary files /dev/null and b/frontend/public/screenshots/train.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c54c4e9..4db6ed3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,7 +31,7 @@ export default function App() { return ( -
+
}> } /> diff --git a/frontend/src/components/drawer/WelcomeLetterOverlay.tsx b/frontend/src/components/drawer/WelcomeLetterOverlay.tsx new file mode 100644 index 0000000..5b59420 --- /dev/null +++ b/frontend/src/components/drawer/WelcomeLetterOverlay.tsx @@ -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(null); + + useEffect(() => { + if (revealState === "REVEALED" && canvasRef.current) { + const welcomeContent = getWelcomeLetterContent(userName); + canvasRef.current.loadData(welcomeContent); + } + }, [revealState, userName]); + + return ( +
+
+ +
+ + {revealState === "SEALED" && ( + + setRevealState("REVEALED")} + ignite={false} + /> + + )} + +
+
+
+ +
+ +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/editor/ComposeCanvas.tsx b/frontend/src/components/editor/ComposeCanvas.tsx index 5a4a0b3..6c97b1e 100644 --- a/frontend/src/components/editor/ComposeCanvas.tsx +++ b/frontend/src/components/editor/ComposeCanvas.tsx @@ -2,6 +2,12 @@ import * as fabric from "fabric"; import type * as React 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 BASE_WIDTH = 680; const DEFAULT_LOGICAL_HEIGHT = 900; @@ -184,9 +190,7 @@ export function ComposeCanvas({ fontFamily: DEFAULT_FONT_FAMILY, fill: DEFAULT_FONT_COLOR, lineHeight: 1.5, - // NOTE: splitByGrapheme is required for word wrap and re-low - // but fabric asks to disable this for clear font?? So we disable it for read view - splitByGrapheme: !readOnly, + splitByGrapheme: false, lockMovementX: true, lockMovementY: 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(); // Hack: Fabric needs a small initial delay to mount before it will accept focus. diff --git a/frontend/src/components/login/WelcomeModal.tsx b/frontend/src/components/login/WelcomeModal.tsx index b07b1ce..71be50c 100644 --- a/frontend/src/components/login/WelcomeModal.tsx +++ b/frontend/src/components/login/WelcomeModal.tsx @@ -34,8 +34,7 @@ export default function WelcomeModal({ className="inline text-primary" weight="fill" /> -
-
+ Everything you write here is sealed with your password,{" "} cryptographically , before it leaves your hands. @@ -44,11 +43,11 @@ export default function WelcomeModal({
-

+

If you ever happen to forget your password, your letters are lost to time, forever.
- + I highly, highly recommend storing this password in your{" "} {" "} or somewhere safe to remember it. -

+
diff --git a/frontend/src/config/welcomeLetter.ts b/frontend/src/config/welcomeLetter.ts new file mode 100644 index 0000000..f35ed58 --- /dev/null +++ b/frontend/src/config/welcomeLetter.ts @@ -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, + }; +} diff --git a/frontend/src/pages/Drawer.test.tsx b/frontend/src/pages/Drawer.test.tsx index 05feade..206ec94 100644 --- a/frontend/src/pages/Drawer.test.tsx +++ b/frontend/src/pages/Drawer.test.tsx @@ -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 { beforeEach, describe, expect, it, vi } from "vitest"; import { mockUser } from "../../test/fixtures/user.fixture"; @@ -7,77 +7,117 @@ import { useAuthStore } from "../store/useAuthStore"; import Drawer from "./Drawer"; vi.mock("../hooks/useLetters"); +vi.mock("../components/drawer/WelcomeLetterOverlay", () => ({ + WelcomeLetterOverlay: ({ onComplete }: any) => ( +
+ +
+ ), +})); describe("Drawer Page", () => { - beforeEach(() => { - // Setup authenticated state for the test - useAuthStore.setState({ - user: mockUser, - accessToken: "fake-token", - isInitializing: false, - }); - - vi.mocked(useLetters).mockReturnValue({ - drafts: [], - kept: [], - sent: [], - vault: [], - loading: false, - isAuthRequired: false, - }); + beforeEach(() => { + // Setup authenticated state for the test + useAuthStore.setState({ + user: mockUser, + accessToken: "fake-token", + isInitializing: false, }); - it("renders the cabinet sections and empty state message", () => { - render( - - - , - ); + vi.mocked(useLetters).mockReturnValue({ + drafts: [], + kept: [], + sent: [], + vault: [], + loading: false, + isAuthRequired: false, + }); + }); - 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 cabinet sections and empty state message", () => { + render( + + + , + ); + + 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", () => { - vi.mocked(useLetters).mockReturnValue({ - drafts: [], - kept: [], - sent: [], - vault: [], - loading: true, - isAuthRequired: false, - }); + render( + + + , + ); - render( - - - , - ); + expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument(); + }); - 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", () => { - vi.mocked(useLetters).mockReturnValue({ - drafts: [], - kept: [], - sent: [], - vault: [], - loading: false, - isAuthRequired: true, - }); + render( + + + , + ); - render( - - - , - ); + expect(screen.getByText(/You've been away a while./i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument(); + }); - expect( - screen.getByText(/You've been away a while./i), - ).toBeInTheDocument(); - expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument(); - }); + it("renders the welcome letter when firstTime state is present", () => { + render( + + + , + ); + + expect(screen.getByTestId("welcome-letter-overlay")).toBeInTheDocument(); + }); + + it("renders the drawer content when the letter is closed", () => { + render( + + + , + ); + + const completeButton = screen.getByTestId("overlay-exit-button"); + fireEvent.click(completeButton); + + expect( + screen.queryByTestId("welcome-letter-overlay"), + ).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/pages/Drawer.tsx b/frontend/src/pages/Drawer.tsx index 5fe14f4..b21f941 100644 --- a/frontend/src/pages/Drawer.tsx +++ b/frontend/src/pages/Drawer.tsx @@ -1,9 +1,10 @@ import { FeatherIcon } from "@phosphor-icons/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 { LetterItem } from "../components/drawer/LetterItem.tsx"; import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx"; +import { WelcomeLetterOverlay } from "../components/drawer/WelcomeLetterOverlay.tsx"; import Logo from "../components/Logo"; import Saajan from "../components/ui/Saajan.tsx"; import { PATHS } from "../config/routes"; @@ -19,6 +20,10 @@ export default function Drawer() { const [openSection, setOpenSection] = useState(null); const navigate = useNavigate(); + const location = useLocation(); + const [showWelcomeLetter, setShowWelcomeLetter] = useState( + !!location.state?.firstTime, + ); const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters(); if (!user) return null; @@ -30,6 +35,16 @@ export default function Drawer() {
+ {showWelcomeLetter && ( + { + setShowWelcomeLetter(false); + navigate(location.pathname, { replace: true, state: {} }); + }} + /> + )} + {isAuthRequired && }
@@ -166,12 +181,14 @@ export default function Drawer() {
For your unsaid.
-
- -
+ {!showWelcomeLetter && ( +
+ +
+ )}
); } diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 57a29b8..ce96e05 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -34,12 +34,6 @@ import { CryptoUtils } from "../utils/crypto"; import { formatRelativeDate } from "../utils/dateFormat"; 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"; const OVERLAY_FADE_MS = 250; @@ -268,7 +262,9 @@ export default function Editor() { await cryptoUtils.initialize(); try { - const canvasData = canvasRef.current?.getData() || { objects: [] }; + const canvasData = (await canvasRef.current?.getData()) || { + objects: [], + }; const canvasImages = canvasRef.current?.getImages() || []; const { encryptedImageFiles, encryptedCanvasData } = diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index c55de2d..ee16836 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -7,7 +7,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { z } from "zod"; import { api, publicApi } from "../api/apiClient"; 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 Saajan from "../components/ui/Saajan"; import { endpoints } from "../config/endpoints"; @@ -64,7 +64,7 @@ export default function Login() { await setAuthStore(authData.access, userData, masterKey); - navigate(nextRoute, { replace: true }); + navigate(nextRoute, { replace: true, state: location.state }); } catch (err) { let message = "Sorry, we're experiencing technical issues.\nPlease try again later.";