From ae52a79bd0afaca7f1db03711417fda08fb10390 Mon Sep 17 00:00:00 2001 From: ramvignesh-b Date: Fri, 24 Apr 2026 06:34:08 +0530 Subject: [PATCH] feat: add a post-seal navigation flow to the reader page --- frontend/e2e/letter.spec.ts | 65 ++++++++---- frontend/src/pages/Editor.tsx | 117 ++++++++++------------ frontend/src/pages/Reader.tsx | 182 ++++++++++++++++++++++++++++------ 3 files changed, 244 insertions(+), 120 deletions(-) diff --git a/frontend/e2e/letter.spec.ts b/frontend/e2e/letter.spec.ts index b4734a5..cbcf956 100644 --- a/frontend/e2e/letter.spec.ts +++ b/frontend/e2e/letter.spec.ts @@ -76,7 +76,9 @@ test.describe("Letter Drafting (Real Backend)", () => { await expect(canvasInput).toHaveValue(/It should persist/i); }); - test("should seal a letter and show sharing link", async ({ page }) => { + test("should seal a letter and navigate to Reader, then share on demand", async ({ + page, + }) => { const timestamp = Date.now() + Math.random(); const email = `seal-${timestamp}@example.com`; const name = `Seal Author ${timestamp}`; @@ -94,36 +96,53 @@ test.describe("Letter Drafting (Real Backend)", () => { await canvasInput.focus(); await canvasInput.fill("This letter will be sealed and shared."); - // Click Seal + // Click Seal (open menu, then confirm) logger.info(">> [Seal] Clicking Seal..."); await page .getByRole("button", { name: /seal/i }) .filter({ visible: true }) - .click(); // Open menu + .click(); await page .getByRole("button", { name: /seal/i }) .filter({ visible: true }) - .click(); // Click confirm Seal + .click(); - // Verify "Sealed & Ready" modal - logger.info(">> [Seal] Verifying sharing modal..."); - await expect(page.getByText(/sealed & ready/i)).toBeVisible(); + // Should show sealed confirmation modal + logger.info(">> [Seal] Verifying sealed modal..."); + await expect(page.getByText(/your letter is sealed/i)).toBeVisible({ + timeout: 10000, + }); - // Verify sharing link contains a hash (the key) - const linkInput = page.locator("input[readOnly]"); + // Navigate to Reader via "View letter" + await page.getByRole("button", { name: /view letter/i }).click(); + + // Should be on Reader URL + await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 }); + + // Open the envelope to reveal the letter + await expect(page.getByText(/breaking the seal/i)).toBeHidden({ + timeout: 10000, + }); + await page.getByAltText("Seal").click(); + await page.waitForTimeout(1500); + await page.locator("#letter").click({ position: { x: 30, y: 15 } }); + await expect(page.locator("#letter")).toBeHidden({ timeout: 20000 }); + + // Share on demand + logger.info(">> [Seal] Clicking Share button in Reader..."); + await page.locator("#share-letter-btn").click(); + + // Verify share modal with a valid link + await expect(page.getByText(/share this letter/i)).toBeVisible(); + const linkInput = page.locator("#share-link-input"); const linkValue = await linkInput.inputValue(); - expect(linkValue).toContain("/read/"); expect(linkValue).toContain("#"); + logger.info(`>> [Seal] Sharing link: ${linkValue}`); - logger.info(`>> [Seal] Sharing link generated: ${linkValue}`); - - // Verify "Copy" button works await expect(page.getByRole("button", { name: /copy/i })).toBeVisible(); - - // Close modal await page.getByRole("button", { name: /close/i }).click(); - await expect(page.getByText(/sealed & ready/i)).toBeHidden(); + await expect(page.getByText(/share this letter/i)).toBeHidden(); }); test("should allow author to access sealed letter from drawer without sharing key", async ({ @@ -148,19 +167,21 @@ test.describe("Letter Drafting (Real Backend)", () => { await canvasInput.focus(); await canvasInput.fill(letterContent); - // Click Seal + // Click Seal (open menu, then confirm) await page .getByRole("button", { name: /seal/i }) .filter({ visible: true }) - .click(); // Open menu + .click(); await page .getByRole("button", { name: /seal/i }) .filter({ visible: true }) - .click(); // Click confirm Seal - await expect(page.getByText(/sealed & ready/i)).toBeVisible(); + .click(); - // Close modal - await page.getByRole("button", { name: /close/i }).click(); + // Sealed modal should appear — click "Keep it" to go to Drawer + await expect(page.getByText(/your letter is sealed/i)).toBeVisible({ + timeout: 10000, + }); + await page.getByRole("button", { name: /keep it/i }).click(); // Navigate to Drawer - use ID or precise label logger.info(">> [Drawer] Navigating to Drawer..."); diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 421864a..44e4583 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -8,7 +8,6 @@ import { StampIcon, TrayIcon, VaultIcon, - XCircleIcon, XIcon, } from "@phosphor-icons/react"; import { useEffect, useRef, useState } from "react"; @@ -26,7 +25,7 @@ import DateDisplay from "../components/ui/DateDisplay"; import { LogModal } from "../components/ui/LogModal"; import { Navbar } from "../components/ui/Navbar"; import { endpoints } from "../config/endpoints"; -import { PATHS } from "../config/routes"; +import { PATHS, ROUTES } from "../config/routes"; import { useKeyStore } from "../store/useKeyStore"; import { CryptoUtils } from "../utils/crypto"; import { formatRelativeDate } from "../utils/dateFormat"; @@ -60,7 +59,7 @@ export default function Editor() { }>({ status: "RESET", message: "", log: "" }); const [isInitialLoading, setIsInitialLoading] = useState(false); - const [shareLink, setShareLink] = useState(null); + const [sealedTargetId, setSealedTargetId] = useState(null); const [lastSaved, setLastSaved] = useState(null); const [status, setLetterStatus] = useState<"DRAFT" | "SEALED" | "VAULT">( "DRAFT", @@ -165,8 +164,6 @@ export default function Editor() { }); } - console.log(canvasData); - if (canvasRef.current) { await canvasRef.current.loadData(canvasData); } @@ -271,7 +268,6 @@ export default function Editor() { const formData = new FormData(); if (status === "VAULT") { const finalDate = vaultDate || unlockDate; - console.log(finalDate?.toISOString()); formData.append("type", "VAULT"); if (finalDate) { formData.append("unlock_at", finalDate.toISOString()); @@ -305,27 +301,59 @@ export default function Editor() { setLetterStatus(status); setLastSavedPulseTick((prev) => prev + 1); - if (status === "SEALED" && encrypted_letter.sharingKey) { - const link = `${window.location.origin}${PATHS.read( - targetId, - )}#${encrypted_letter.sharingKey}`; - setShareLink(link); - setShowSaveOverlay(false); - setTimeout(() => setSaveOverlay("idle"), OVERLAY_FADE_MS); - } else { - setSaveOverlay("saved"); - setShowSaveOverlay(true); + if (status === "SEALED") { + setSealedTargetId(targetId); } + setSaveOverlay("saved"); + setShowSaveOverlay(true); } catch (_error) { setSaveOverlay("error"); setShowSaveOverlay(true); } }; - const copyToClipboard = async () => { - if (!shareLink) return; - await navigator.clipboard.writeText(shareLink); - }; + function SealedModal() { + if (!sealedTargetId) return null; + return ( +
+
+ +

Your letter is sealed

+

+ It's encrypted and always safe in your drawer. +

+

+ When you're ready, +
+ you can{" "} + read{" "} + it, send{" "} + it to someone, or{" "} + burn it + to release +

+
+ + +
+
+
+ ); + } function ToolBar() { return ( @@ -547,53 +575,7 @@ export default function Editor() { )} - {shareLink && ( -
-
- -
-
- -
-
-

Sealed & Ready

-

- This letter is now encrypted. Share this secret link with - your recipient. -

-
- -
- - -
- -

- Zero-Knowledge: The key is in the link, not our servers. -

-
-
-
- )} - - {saveOverlay !== "idle" && !shareLink && ( + {saveOverlay !== "idle" && (
} + {sealedTargetId && }
diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index e1abc03..11471ed 100644 --- a/frontend/src/pages/Reader.tsx +++ b/frontend/src/pages/Reader.tsx @@ -1,4 +1,10 @@ -import { CampfireIcon, FlameIcon } from "@phosphor-icons/react"; +import { + CampfireIcon, + EyeSlashIcon, + FlameIcon, + PaperPlaneTiltIcon, + XCircleIcon, +} from "@phosphor-icons/react"; import { useEffect, useRef, useState } from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { api } from "../api/apiClient"; @@ -51,10 +57,92 @@ export default function Reader() { const [showBurnModal, setShowBurnModal] = useState(false); const [isBurning, setIsBurning] = useState(false); const [ignite, setIgnite] = useState(false); + const [encryptedDek, setEncryptedDek] = useState(null); + const [shareLink, setShareLink] = useState(null); const { masterKey } = useKeyStore(); - const isOwner = !!masterKey && !sharingKey; + const isAuthor = !!masterKey && !sharingKey; + + const handleShare = async () => { + if (!encryptedDek || !masterKey || !public_id) return; + const cryptoUtils = new CryptoUtils(); + const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey); + try { + await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" }); + } catch (err) { + console.error("Failed to update letter:", err); + } finally { + setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`); + } + }; + + const copyToClipboard = async () => { + if (!shareLink) return; + await navigator.clipboard.writeText(shareLink); + }; + + function ShareModal() { + return ( +
+
+ +
+
+ +

Send this letter

+

+ You've carried these words long enough. Send your letter now, + and let the{" "} + unsaid finally + find its home. +

+
+
+ The recipient will have the same viewing experience like you do + now. +
+
+
+ + +
+
+

+ {" "} + Zero-Knowledge Share: +

+

+ The key never leaves your or the recipient's browser. +

+
+
+
+
+ ); + } const burnLetter = async () => { console.log("Burning letter..."); @@ -95,27 +183,42 @@ export default function Reader() { const burnStyle = flameOn < 30 ? "" : `contrast(${flameOn / 30})`; return ( -
+
- + +

Are you ready to burn this letter?

-

- The ashes will be released into the winds. +

+ Some words are meant to be unsaid, but they don't have to linger + forever.
- Press and{" "} - hold the{" "} - flame to - proceed. + Let the echoes of your unsaid be finally released.

+
+ Press and{" "} + hold the{" "} + flame to proceed. +
-

- May your soul find solace like your{" "} - unsaid words did. +

+ May your soul find + solace, +
+ just like your unsaid{" "} + words did.

- - {isOwner && ( -
- -
- )}
)} + {shareLink && } {showBurnModal && } + {isAuthor && revealState !== "burned" && ( +
+ + +
+ )} +