1 Commits

Author SHA1 Message Date
me 47f8b0f377 fix: reader letter image decryption (#11)
CI / Generate Certificates (push) Successful in 1m31s
CI / Frontend CI (push) Successful in 1m8s
CI / Backend CI (push) Successful in 1m5s
CI / E2E Tests (push) Has been skipped
Co-authored-by: me <ramvignesh-b@github.com>
Reviewed-on: #11
2026-07-01 18:47:54 +00:00
10 changed files with 974 additions and 811 deletions
+3 -1
View File
@@ -64,7 +64,9 @@ test.describe("Letter Drafting (Real Backend)", () => {
await expect(page.getByTestId("opening-draft-overlay")).toBeHidden();
// Check recipient
await expect(page.getByTestId("recipient-input")).toHaveValue(recipientName);
await expect(page.getByTestId("recipient-input")).toHaveValue(
recipientName,
);
// Check canvas content
// We wait for the content to appear in the textarea.
+1 -1
View File
@@ -1,7 +1,7 @@
import { expect, type Page } from "@playwright/test";
import pino from "pino";
import { MailpitHelper } from "./mailpit";
import { handleWelcomeLetter } from "./envelope";
import { MailpitHelper } from "./mailpit";
const logger = pino({
transport: {
+4 -2
View File
@@ -1,4 +1,4 @@
import { type Page, expect } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
import pino from "pino";
const logger = pino({
@@ -22,7 +22,9 @@ export async function revealEnvelope(page: Page) {
await page.getByTestId("wax-seal").click();
// Click letter to reveal
await page.getByTestId("envelope-letter").click({ position: { x: 30, y: 15 } });
await page
.getByTestId("envelope-letter")
.click({ position: { x: 30, y: 15 } });
}
/**
+68 -68
View File
@@ -1,70 +1,70 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b & vite build",
"build:prod": "vite build --mode production",
"lint": "biome lint --write ./src",
"format": "biome format --write ./src",
"check": "biome check --write ./src",
"check-all": "biome check --write .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=43008"
},
"dependencies": {
"@fontsource-variable/fraunces": "^5.2.9",
"@fontsource-variable/josefin-slab": "^5.2.7",
"@fontsource-variable/jost": "^5.2.8",
"@fontsource-variable/playwrite-hr-lijeva": "^5.2.7",
"@fontsource/architects-daughter": "^5.2.7",
"@fontsource/kavivanar": "^5.2.8",
"@fontsource/knewave": "^5.2.7",
"@fontsource/redacted-script": "^5.2.8",
"@fontsource/space-mono": "^5.2.9",
"@hookform/resolvers": "^5.2.2",
"@phosphor-icons/react": "^2.1.10",
"@tailwindcss/vite": "^4.2.2",
"axios": "^1.15.0",
"daisyui": "^5.5.19",
"fabric": "^7.2.0",
"idb": "^8.0.3",
"lenis": "^1.3.23",
"motion": "^12.38.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.72.1",
"react-router-dom": "^7.14.0",
"tailwindcss": "^4.2.2",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
"devDependencies": {
"@biomejs/biome": "^2.4.11",
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.4",
"dotenv": "^17.4.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.0.2",
"msw": "^2.13.2",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"typescript": "~6.0.2",
"vite": "^8.0.4",
"vitest": "^4.1.4"
}
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b & vite build",
"build:prod": "vite build --mode production",
"lint": "biome lint --write ./src",
"format": "biome format --write ./src",
"check": "biome check --write ./src",
"check-all": "biome check --write .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=43008"
},
"dependencies": {
"@fontsource-variable/fraunces": "^5.2.9",
"@fontsource-variable/josefin-slab": "^5.2.7",
"@fontsource-variable/jost": "^5.2.8",
"@fontsource-variable/playwrite-hr-lijeva": "^5.2.7",
"@fontsource/architects-daughter": "^5.2.7",
"@fontsource/kavivanar": "^5.2.8",
"@fontsource/knewave": "^5.2.7",
"@fontsource/redacted-script": "^5.2.8",
"@fontsource/space-mono": "^5.2.9",
"@hookform/resolvers": "^5.2.2",
"@phosphor-icons/react": "^2.1.10",
"@tailwindcss/vite": "^4.2.2",
"axios": "^1.15.0",
"daisyui": "^5.5.19",
"fabric": "^7.2.0",
"idb": "^8.0.3",
"lenis": "^1.3.23",
"motion": "^12.38.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.72.1",
"react-router-dom": "^7.14.0",
"tailwindcss": "^4.2.2",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
"devDependencies": {
"@biomejs/biome": "^2.4.11",
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.4",
"dotenv": "^17.4.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.0.2",
"msw": "^2.13.2",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"typescript": "~6.0.2",
"vite": "^8.0.4",
"vitest": "^4.1.4"
}
}
+19 -1
View File
@@ -1 +1,19 @@
{"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": "",
"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"
}
+341 -339
View File
@@ -15,406 +15,408 @@ const DEFAULT_FONT_FAMILY = "Fraunces Variable";
const DEFAULT_FONT_COLOR = "#000";
export interface FabricObjectJSON {
type: string;
name?: string;
top: number;
left: number;
width: number;
height: number;
type: string;
name?: string;
top: number;
left: number;
width: number;
height: number;
[key: string]: unknown;
[key: string]: unknown;
}
export interface FabricImageJSON extends FabricObjectJSON {
type: "Image";
src: string;
_customRawFile?: File;
type: "Image";
src: string;
_customRawFile?: File;
}
export interface CanvasJSON {
objects: (FabricObjectJSON | FabricImageJSON)[];
canvasWidth?: number;
canvasHeight?: number;
objects: (FabricObjectJSON | FabricImageJSON)[];
canvasWidth?: number;
canvasHeight?: number;
}
export interface CanvasStyle {
fontFamily: string;
fontColor: string;
fontFamily: string;
fontColor: string;
}
export type CanvasTools = {
addImage: (url: string, file: File) => void;
getData: () => CanvasJSON;
getImages: () => { src: string; file: File }[];
loadData: (data: CanvasJSON) => Promise<void>;
getStyle: () => CanvasStyle;
addImage: (url: string, file: File) => void;
getData: () => CanvasJSON;
getImages: () => { src: string; file: File }[];
loadData: (data: CanvasJSON) => Promise<void>;
getStyle: () => CanvasStyle;
};
export interface FabricImageWithFile extends fabric.FabricImage {
_customRawFile: File;
_customRawFile: File;
}
// NOTE: We use the same canvasData to render on both mobile and desktop viewports.
// Instead of calculating the entire objects pad again, we apply a zoom multiplier (scale down or up)
// over the last saved canvas size.
const applyResponsiveViewport = (
canvas: fabric.Canvas,
wrapper: HTMLDivElement,
logicalWidth: number,
logicalHeight: number,
canvas: fabric.Canvas,
wrapper: HTMLDivElement,
logicalWidth: number,
logicalHeight: number,
) => {
const physicalWidth = wrapper.clientWidth || logicalWidth;
const zoomMultiplier = physicalWidth / logicalWidth;
const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier);
const physicalWidth = wrapper.clientWidth || logicalWidth;
const zoomMultiplier = physicalWidth / logicalWidth;
const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier);
canvas.setDimensions({
width: physicalWidth,
height: physicalHeight,
});
canvas.setDimensions({
width: physicalWidth,
height: physicalHeight,
});
wrapper.style.height = `${physicalHeight}px`;
canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]);
canvas.requestRenderAll();
wrapper.style.height = `${physicalHeight}px`;
canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]);
canvas.requestRenderAll();
};
// to find the maximum height of the content to dynamically resize the canvas
// would've been wayyy easier only if canvas supported fit-content like CSS property :)
const measureLogicalContentHeight = (
canvas: fabric.Canvas,
minimumHeight = DEFAULT_LOGICAL_HEIGHT,
canvas: fabric.Canvas,
minimumHeight = DEFAULT_LOGICAL_HEIGHT,
) => {
const maxBottom = canvas.getObjects().reduce((maxHeight, currObj) => {
const top = currObj.top;
const height = currObj.getScaledHeight();
return Math.max(maxHeight, top + height);
}, 0);
const maxBottom = canvas.getObjects().reduce((maxHeight, currObj) => {
const top = currObj.top;
const height = currObj.getScaledHeight();
return Math.max(maxHeight, top + height);
}, 0);
return Math.max(minimumHeight, maxBottom + PAD);
return Math.max(minimumHeight, maxBottom + PAD);
};
const DEFAULT_INIT_TEXT = "Take a deep breath...";
interface ComposeCanvasProps {
readOnly?: boolean;
initialData?: CanvasJSON | null;
style?: CanvasStyle;
ref?: React.Ref<CanvasTools>;
readOnly?: boolean;
initialData?: CanvasJSON | null;
style?: CanvasStyle;
ref?: React.Ref<CanvasTools>;
}
export function ComposeCanvas({
readOnly = false,
initialData = null,
style,
ref,
readOnly = false,
initialData = null,
style,
ref,
}: ComposeCanvasProps) {
// wrapper is the parent div box
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fabricRef = useRef<fabric.Canvas | null>(null);
// wrapper is the parent div box
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fabricRef = useRef<fabric.Canvas | null>(null);
const textboxRef = useRef<fabric.Textbox | null>(null);
const deferredDataRef = useRef<CanvasJSON | null>(null);
const logicalSizeRef = useRef({
width: BASE_WIDTH,
height: DEFAULT_LOGICAL_HEIGHT,
});
// re-calculates height based on content and applies the zoom transform
const syncViewport = useCallback(() => {
if (!(fabricRef.current && wrapperRef.current)) return;
textboxRef.current?.initDimensions();
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
logicalSizeRef.current.height = measureLogicalContentHeight(
fabricRef.current,
minHeight,
);
applyResponsiveViewport(
fabricRef.current,
wrapperRef.current,
logicalSizeRef.current.width,
logicalSizeRef.current.height,
);
fabricRef.current.requestRenderAll();
}, [initialData]);
// auto focus the cursor into the main textbox no matter the latest element added
const focusTextbox = useCallback(
(textbox: fabric.Textbox) => {
if (readOnly || !fabricRef.current) return;
fabricRef.current.setActiveObject(textbox);
textbox.enterEditing();
// move the cursor to the end of the text
const textLength = textbox.text?.length ?? 0;
textbox.selectionStart = textLength;
textbox.selectionEnd = textLength;
fabricRef.current.requestRenderAll();
},
[readOnly],
);
const loadContent = useCallback(
async (data: CanvasJSON | null) => {
const canvas = fabricRef.current;
const wrapper = wrapperRef.current;
if (!(canvas && wrapper)) return;
// clean the canvas everytime and set fresh
canvas.clear();
let textbox: fabric.Textbox | null = null;
// restore logical size from prev saved data if available (in case of existing letter)
logicalSizeRef.current = {
width: data?.canvasWidth ?? BASE_WIDTH,
height: data?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT,
};
if (data?.objects?.length) {
await canvas.loadFromJSON(data);
textbox = canvas.getObjects("Textbox")[0] as fabric.Textbox;
} else {
// Create a fresh letter if no data exists
textbox = new fabric.Textbox(DEFAULT_INIT_TEXT, {
name: "main-textbox",
originX: "left",
originY: "top",
left: PAD,
top: PAD,
width: BASE_WIDTH - PAD * 2,
fontSize: 18,
fontWeight: 500,
fontFamily: DEFAULT_FONT_FAMILY,
fill: DEFAULT_FONT_COLOR,
lineHeight: 1.5,
splitByGrapheme: false,
lockMovementX: true,
lockMovementY: true,
lockScalingX: true,
lockScalingY: true,
lockRotation: true,
hasControls: false,
hasBorders: false,
objectCaching: false,
noScaleCache: false,
});
canvas.add(textbox);
}
if (!textbox) return;
// readonly contraints applicable for post seal view
textbox.selectable = !readOnly;
textbox.evented = !readOnly;
textbox.editable = !readOnly;
textbox.hasBorders = false;
textboxRef.current = textbox;
// observe and auto-resize the canvas height whenever typed
textbox.on("changed", syncViewport);
// trapping the focus into the textbox wherever clicked on canvas (except images)
canvas.on("mouse:down", (e) => {
if (!e.target || e.target === textbox) {
focusTextbox(textbox);
}
});
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.
// otherwise it goes to the front
if (!readOnly) {
setTimeout(() => focusTextbox(textbox), 200);
}
},
[readOnly, syncViewport, focusTextbox],
);
useEffect(() => {
if (style && textboxRef.current) {
const textBox = textboxRef.current;
textBox.fontFamily = style.fontFamily || textBox.fontFamily;
textBox.fill = style.fontColor || textBox.fill;
syncViewport();
}
}, [style, syncViewport]);
useEffect(() => {
let isMounted = true;
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;
const width = await getInitialWidth();
// init the fabric instance
const canvas = new fabric.Canvas(canvasRef.current, {
width,
const textboxRef = useRef<fabric.Textbox | null>(null);
const deferredDataRef = useRef<CanvasJSON | null>(null);
const logicalSizeRef = useRef({
width: BASE_WIDTH,
height: DEFAULT_LOGICAL_HEIGHT,
selection: !readOnly,
preserveObjectStacking: true,
allowTouchScrolling: true,
enableRetinaScaling: true,
objectCaching: false,
});
});
// remove default fabric background to let our CSS show through
// TODO: provision custom bg (color in scope, but how does img fit?)
const wrapperEl = canvas.getElement().parentElement;
if (wrapperEl) wrapperEl.style.background = "transparent";
// re-calculates height based on content and applies the zoom transform
const syncViewport = useCallback(() => {
if (!(fabricRef.current && wrapperRef.current)) return;
textboxRef.current?.initDimensions();
fabricRef.current = canvas;
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
logicalSizeRef.current.height = measureLogicalContentHeight(
fabricRef.current,
minHeight,
);
await loadContent(initialData);
applyResponsiveViewport(
fabricRef.current,
wrapperRef.current,
logicalSizeRef.current.width,
logicalSizeRef.current.height,
);
// sometimes loadData() may be called before the canvas finished the init render
// so we retry that stashed render right after the init
if (deferredDataRef.current) {
await loadContent(deferredDataRef.current);
deferredDataRef.current = null;
}
fabricRef.current.requestRenderAll();
}, [initialData]);
// auto window resizing based width
lastWidth = wrapperRef.current.clientWidth;
resizeObserver = initResizeOberver();
};
// auto focus the cursor into the main textbox no matter the latest element added
const focusTextbox = useCallback(
(textbox: fabric.Textbox) => {
if (readOnly || !fabricRef.current) return;
initCanvas().then();
fabricRef.current.setActiveObject(textbox);
textbox.enterEditing();
return () => {
isMounted = false;
resizeObserver?.disconnect();
fabricRef.current?.dispose();
fabricRef.current = null;
textboxRef.current = null;
};
}, [initialData, loadContent, readOnly, syncViewport]);
// move the cursor to the end of the text
const textLength = textbox.text?.length ?? 0;
textbox.selectionStart = textLength;
textbox.selectionEnd = textLength;
// WHY?: fabric doesn't work like react with state and props based optimized re-renders.
// everytime we there's a change in the data, we should force the render,
// so we let the parent Editor component take control of this.
useImperativeHandle(ref, () => ({
addImage: (url: string, file: File) => {
if (!fabricRef.current) return;
fabricRef.current.requestRenderAll();
},
[readOnly],
);
fabric.FabricImage.fromURL(url).then((img) => {
img.scaleToWidth(Math.min(300, img.width));
img.set({
originX: "left",
originY: "top",
left: PAD,
top: PAD,
noScaleCache: false,
objectCaching: false,
// WHY?: after image object clean-up, its src becomes local blob://
// but browser won't let us parse this blob:// into file afterwards. so we hold a local copy
_customRawFile: file,
} as Partial<FabricImageWithFile>);
const loadContent = useCallback(
async (data: CanvasJSON | null) => {
const canvas = fabricRef.current;
const wrapper = wrapperRef.current;
if (!(canvas && wrapper)) return;
fabricRef.current?.add(img);
fabricRef.current?.setActiveObject(img);
// clean the canvas everytime and set fresh
canvas.clear();
let textbox: fabric.Textbox | null = null;
syncViewport();
// clean up memory
URL.revokeObjectURL(url);
});
},
// restore logical size from prev saved data if available (in case of existing letter)
logicalSizeRef.current = {
width: data?.canvasWidth ?? BASE_WIDTH,
height: data?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT,
};
getData: () => {
if (!fabricRef.current) return { objects: [] };
syncViewport();
if (data?.objects?.length) {
await canvas.loadFromJSON(data);
textbox = canvas.getObjects("Textbox")[0] as fabric.Textbox;
} else {
// Create a fresh letter if no data exists
textbox = new fabric.Textbox(DEFAULT_INIT_TEXT, {
name: "main-textbox",
originX: "left",
originY: "top",
left: PAD,
top: PAD,
width: BASE_WIDTH - PAD * 2,
fontSize: 18,
fontWeight: 500,
fontFamily: DEFAULT_FONT_FAMILY,
fill: DEFAULT_FONT_COLOR,
lineHeight: 1.5,
splitByGrapheme: false,
lockMovementX: true,
lockMovementY: true,
lockScalingX: true,
lockScalingY: true,
lockRotation: true,
hasControls: false,
hasBorders: false,
objectCaching: false,
noScaleCache: false,
});
canvas.add(textbox);
}
const json = fabricRef.current.toJSON() as CanvasJSON;
json.canvasWidth = logicalSizeRef.current.width;
json.canvasHeight = logicalSizeRef.current.height;
return json;
},
if (!textbox) return;
getImages: () => {
if (!fabricRef.current) return [];
const images = fabricRef.current.getObjects(
"Image",
) as FabricImageWithFile[];
return images.map((img) => ({
src: img.getSrc(),
file: img._customRawFile,
}));
},
// readonly contraints applicable for post seal view
textbox.selectable = !readOnly;
textbox.evented = !readOnly;
textbox.editable = !readOnly;
textbox.hasBorders = false;
loadData: async (data: CanvasJSON) => {
// if canvas isn't ready yet, stash the data and let the useEffect pick it up
if (!fabricRef.current) {
deferredDataRef.current = data;
return;
}
await loadContent(data);
},
textboxRef.current = textbox;
getStyle: () => {
const textBox = textboxRef.current;
// observe and auto-resize the canvas height whenever typed
textbox.on("changed", syncViewport);
return {
fontFamily: textBox?.fontFamily || DEFAULT_FONT_FAMILY,
fontColor: (textBox?.fill as string) || DEFAULT_FONT_COLOR,
};
},
}));
// trapping the focus into the textbox wherever clicked on canvas (except images)
canvas.on("mouse:down", (e) => {
if (!e.target || e.target === textbox) {
focusTextbox(textbox);
}
});
return (
<div
ref={wrapperRef}
className="relative bg-paper shadow-primary-content rounded-sm w-full outline-none overflow-hidden cursor-text"
>
<canvas
ref={canvasRef}
className="absolute top-0 left-0"
style={{ background: "transparent" }}
/>
</div>
);
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.
// otherwise it goes to the front
if (!readOnly) {
setTimeout(() => focusTextbox(textbox), 200);
}
},
[readOnly, syncViewport, focusTextbox],
);
useEffect(() => {
if (style && textboxRef.current) {
const textBox = textboxRef.current;
textBox.fontFamily = style.fontFamily || textBox.fontFamily;
textBox.fill = style.fontColor || textBox.fill;
syncViewport();
}
}, [style, syncViewport]);
useEffect(() => {
let isMounted = true;
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;
const width = await getInitialWidth();
// init the fabric instance
const canvas = new fabric.Canvas(canvasRef.current, {
width,
height: DEFAULT_LOGICAL_HEIGHT,
selection: !readOnly,
preserveObjectStacking: true,
allowTouchScrolling: true,
enableRetinaScaling: true,
objectCaching: false,
});
// remove default fabric background to let our CSS show through
// TODO: provision custom bg (color in scope, but how does img fit?)
const wrapperEl = canvas.getElement().parentElement;
if (wrapperEl) wrapperEl.style.background = "transparent";
fabricRef.current = canvas;
await loadContent(initialData);
// sometimes loadData() may be called before the canvas finished the init render
// so we retry that stashed render right after the init
if (deferredDataRef.current) {
await loadContent(deferredDataRef.current);
deferredDataRef.current = null;
}
if (!(isMounted && wrapperRef.current)) return;
// auto window resizing based width
lastWidth = wrapperRef.current.clientWidth;
resizeObserver = initResizeOberver();
};
initCanvas().then();
return () => {
isMounted = false;
resizeObserver?.disconnect();
fabricRef.current?.dispose();
fabricRef.current = null;
textboxRef.current = null;
};
}, [initialData, loadContent, readOnly, syncViewport]);
// WHY?: fabric doesn't work like react with state and props based optimized re-renders.
// everytime we there's a change in the data, we should force the render,
// so we let the parent Editor component take control of this.
useImperativeHandle(ref, () => ({
addImage: (url: string, file: File) => {
if (!fabricRef.current) return;
fabric.FabricImage.fromURL(url).then((img) => {
img.scaleToWidth(Math.min(300, img.width));
img.set({
originX: "left",
originY: "top",
left: PAD,
top: PAD,
noScaleCache: false,
objectCaching: false,
// WHY?: after image object clean-up, its src becomes local blob://
// but browser won't let us parse this blob:// into file afterwards. so we hold a local copy
_customRawFile: file,
} as Partial<FabricImageWithFile>);
fabricRef.current?.add(img);
fabricRef.current?.setActiveObject(img);
syncViewport();
// clean up memory
URL.revokeObjectURL(url);
});
},
getData: () => {
if (!fabricRef.current) return { objects: [] };
syncViewport();
const json = fabricRef.current.toJSON() as CanvasJSON;
json.canvasWidth = logicalSizeRef.current.width;
json.canvasHeight = logicalSizeRef.current.height;
return json;
},
getImages: () => {
if (!fabricRef.current) return [];
const images = fabricRef.current.getObjects(
"Image",
) as FabricImageWithFile[];
return images.map((img) => ({
src: img.getSrc(),
file: img._customRawFile,
}));
},
loadData: async (data: CanvasJSON) => {
// if canvas isn't ready yet, stash the data and let the useEffect pick it up
if (!fabricRef.current) {
deferredDataRef.current = data;
return;
}
await loadContent(data);
},
getStyle: () => {
const textBox = textboxRef.current;
return {
fontFamily: textBox?.fontFamily || DEFAULT_FONT_FAMILY,
fontColor: (textBox?.fill as string) || DEFAULT_FONT_COLOR,
};
},
}));
return (
<div
ref={wrapperRef}
className="relative bg-paper shadow-primary-content rounded-sm w-full outline-none overflow-hidden cursor-text"
>
<canvas
ref={canvasRef}
className="absolute top-0 left-0"
style={{ background: "transparent" }}
/>
</div>
);
}
ComposeCanvas.displayName = "ComposeCanvas";
@@ -68,7 +68,7 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
</div>
</div>
</Modal>
<div className="absolute bottom-0 md:right-5/11 z-1000 font-sans w-full">
<div className="absolute bottom-0 z-1000 font-sans w-screen">
<Saajan
position="top"
message={`Someone once said,\n"To send a letter is a good way to go somewhere without moving anything but your heart."\nThey were not wrong.`}
+375 -375
View File
@@ -1,10 +1,10 @@
import { InfoIcon } from "@phosphor-icons/react";
import { ReactLenis } from "lenis/react";
import {
motion,
useMotionValueEvent,
useScroll,
useTransform,
motion,
useMotionValueEvent,
useScroll,
useTransform,
} from "motion/react";
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -19,382 +19,382 @@ import "@fontsource/space-mono/index.css";
import "@fontsource/architects-daughter/index.css";
export default function Home() {
const sectionContainer1 = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: sectionContainer1,
});
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
const [flapOpen, setFlapOpen] = useState(false);
const [recipient, setRecipient] = useState("someone dear");
const [ignite, setIgnite] = useState(false);
const sectionContainer1 = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: sectionContainer1,
});
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
const [flapOpen, setFlapOpen] = useState(false);
const [recipient, setRecipient] = useState("someone dear");
const [ignite, setIgnite] = useState(false);
const navigate = useNavigate();
const navigate = useNavigate();
useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => {
if (latestScrollValue > 0.54) {
setFlapOpen(false);
} else {
setFlapOpen(true);
}
if (latestScrollValue <= 0.6) {
setIsEnvelopeFlipped(true);
} else {
setIsEnvelopeFlipped(false);
}
if (latestScrollValue > 0.68) {
setRecipient("future me");
} else {
setRecipient("someone dear");
}
if (latestScrollValue > 0.77) {
setIgnite(true);
} else {
setIgnite(false);
}
});
useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => {
if (latestScrollValue > 0.54) {
setFlapOpen(false);
} else {
setFlapOpen(true);
}
if (latestScrollValue <= 0.6) {
setIsEnvelopeFlipped(true);
} else {
setIsEnvelopeFlipped(false);
}
if (latestScrollValue > 0.68) {
setRecipient("future me");
} else {
setRecipient("someone dear");
}
if (latestScrollValue > 0.77) {
setIgnite(true);
} else {
setIgnite(false);
}
});
return (
<ReactLenis root options={{ lerp: 0.1, duration: 1.5, smoothWheel: true }}>
<section
ref={sectionContainer1}
className="relative w-full h-[850vh] bg-base-100 font-serif text-neutral-content/90"
>
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
{/* Intro */}
<motion.div
className="absolute flex flex-col items-center justify-center pointer-events-none"
style={{
opacity: useTransform(scrollYProgress, [0, 0.12, 1], [1, 0, 0]),
scale: useTransform(scrollYProgress, [0, 0.12], [1, 10]),
}}
>
<h1 className="text-neutral text-4xl md:text-6xl text-center px-6">
You've been carrying something
</h1>
<motion.h2 className="text-primary text-5xl md:text-7xl mt-4 italic font-display font-light">
unsaid
</motion.h2>
</motion.div>
return (
<ReactLenis root options={{ lerp: 0.01, duration: 5, smoothWheel: true }}>
<section
ref={sectionContainer1}
className="relative w-full h-[850vh] bg-base-100 font-serif text-neutral-content/90"
>
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
{/* Intro */}
<motion.div
className="absolute flex flex-col items-center justify-center pointer-events-none"
style={{
opacity: useTransform(scrollYProgress, [0, 0.12, 1], [1, 0, 0]),
scale: useTransform(scrollYProgress, [0, 0.12], [1, 10]),
}}
>
<h1 className="text-neutral text-4xl md:text-6xl text-center px-6">
You've been carrying something
</h1>
<motion.h2 className="text-primary text-5xl md:text-7xl mt-4 italic font-display font-light">
unsaid
</motion.h2>
</motion.div>
<motion.div
className="absolute text-center"
style={{
opacity: useTransform(scrollYProgress, [0, 0.15, 0.2], [0, 1, 0]),
y: useTransform(scrollYProgress, [0, 0.15, 0.2], [40, 0, -40]),
scale: useTransform(scrollYProgress, [0, 0.15, 0.2], [0.8, 1, 3]),
}}
>
<div className="mt-6 text-4xl md:text-6xl text-base-content/60 italic">
and that's okay...
</div>
</motion.div>
{/* pi. ku. */}
<motion.div
className="absolute text-center px-6"
style={{
opacity: useTransform(
scrollYProgress,
[0.18, 0.25, 0.3],
[0, 1, 0],
),
y: useTransform(scrollYProgress, [0.18, 0.25, 0.3], [20, 0, -20]),
}}
transition={{ delay: 4 }}
>
<Logo type="logo" scale={1.5} ul={true} />
<motion.div
className="font-serif italic font-extralight mt-6 text-4xl md:text-6xl text-neutral "
style={{
opacity: useTransform(
scrollYProgress,
[0.22, 0.25, 0.35, 0.4],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.25, 0.3, 0.35, 0.4],
[20, 0, 0, -20],
),
}}
>
is a{" "}
<span className="font-display text-primary font-extralight">
safe space
</span>
,<br />
<motion.span
className="opacity-0 text-2xl md:text-4xl font-hand tracking-widest italic text-neutral"
transition={{ delay: 5 }}
whileInView={{ opacity: 1 }}
viewport={{ once: false, amount: 0.3 }}
>
where you can
</motion.span>
</motion.div>
</motion.div>
<motion.div
className="absolute text-center"
style={{
opacity: useTransform(scrollYProgress, [0, 0.15, 0.2], [0, 1, 0]),
y: useTransform(scrollYProgress, [0, 0.15, 0.2], [40, 0, -40]),
scale: useTransform(scrollYProgress, [0, 0.15, 0.2], [0.8, 1, 3]),
}}
>
<div className="mt-6 text-4xl md:text-6xl text-base-content/60 italic">
and that's okay...
</div>
</motion.div>
{/* pi. ku. */}
<motion.div
className="absolute text-center px-6"
style={{
opacity: useTransform(
scrollYProgress,
[0.18, 0.25, 0.3],
[0, 1, 0],
),
y: useTransform(scrollYProgress, [0.18, 0.25, 0.3], [20, 0, -20]),
}}
transition={{ delay: 4 }}
>
<Logo type="logo" scale={1.5} ul={true} />
<motion.div
className="font-serif italic font-extralight mt-6 text-4xl md:text-6xl text-neutral "
style={{
opacity: useTransform(
scrollYProgress,
[0.22, 0.25, 0.35, 0.4],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.25, 0.3, 0.35, 0.4],
[20, 0, 0, -20],
),
}}
>
is a{" "}
<span className="font-display text-primary font-extralight">
safe space
</span>
,<br />
<motion.span
className="opacity-0 text-2xl md:text-4xl font-hand tracking-widest italic text-neutral"
transition={{ delay: 5 }}
whileInView={{ opacity: 1 }}
viewport={{ once: false, amount: 0.3 }}
>
where you can
</motion.span>
</motion.div>
</motion.div>
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
<motion.h2
style={{
opacity: useTransform(
scrollYProgress,
[0.3, 0.35, 0.4, 0.45],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.3, 0.35, 0.4, 0.45],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
pen down your unsaid words into&nbsp;
<span className="font-display text-primary font-extralight">
letters
</span>
.
</motion.h2>
{/* Seal */}
<motion.h2
style={{
opacity: useTransform(
scrollYProgress,
[0.45, 0.5, 0.55, 0.6],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.45, 0.5, 0.55, 0.6],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
seal it&nbsp;
<span className="text-success font-mono tracking-tighter font-extrabold">
secure
</span>
&nbsp; and&nbsp;
<span className="text-info font-mono tracking-tighter italic">
private
</span>
.
</motion.h2>
{/* Send / vault */}
<motion.h2
style={{
opacity: useTransform(
scrollYProgress,
[0.6, 0.63, 0.72, 0.75],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.6, 0.63, 0.72, 0.75],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
send it to&nbsp;
<motion.span
className="font-display text-accent"
style={{
color: useTransform(
scrollYProgress,
[0.67, 1],
["var(--color-accent)", "var(--color-neutral)"],
),
}}
>
someone dear
</motion.span>
<motion.span
style={{
opacity: useTransform(scrollYProgress, [0.66, 0.7], [0, 1]),
}}
>
<motion.span
className="font-display text-accent"
style={{
color: useTransform(
scrollYProgress,
[0.67, 1],
["var(--color-accent)", "var(--color-neutral)"],
),
}}
>
&nbsp; or&nbsp;
</motion.span>
<span className="font-display text-success">
yourself in the future
</span>
.
</motion.span>
</motion.h2>
{/* Burn */}
<motion.h2
style={{
opacity: useTransform(
scrollYProgress,
[0.75, 0.8, 0.85, 0.9],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.75, 0.8, 0.85, 0.9],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
and even <span className="font-display text-error">burn it</span>
&nbsp; to release the burden.
</motion.h2>
{/* Outro */}
<motion.h2
className={
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight text-neutral-content/50"
}
style={{
opacity: useTransform(scrollYProgress, [0.9, 1], [0, 1]),
y: useTransform(scrollYProgress, [0.9, 1], [80, 0]),
}}
>
You've been carrying it long enough.
</motion.h2>
{/* CTA */}
<motion.div
className={
"z-100 absolute -bottom-12 md:bottom-0 font-hand flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
}
style={{
opacity: useTransform(scrollYProgress, [0.98, 1], [0, 1]),
y: useTransform(scrollYProgress, [0.98, 1], [80, 0]),
display: useTransform(
scrollYProgress,
[0.96, 1],
["none", "flex"],
),
}}
>
<button
className={
"md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
}
type={"button"}
onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
>
<InfoIcon className={"text-primary"} />
Tell me More
</button>
<button
className={
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale-50 hover:grayscale-0 focus:grayscale-0 active:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
}
type={"button"}
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
>
I'm ready
</button>
</motion.div>
</div>
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
<motion.h2
style={{
opacity: useTransform(
scrollYProgress,
[0.3, 0.35, 0.4, 0.45],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.3, 0.35, 0.4, 0.45],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
pen down your unsaid words into&nbsp;
<span className="font-display text-primary font-extralight">
letters
</span>
.
</motion.h2>
{/* Seal */}
<motion.h2
style={{
opacity: useTransform(
scrollYProgress,
[0.45, 0.5, 0.55, 0.6],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.45, 0.5, 0.55, 0.6],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
seal it&nbsp;
<span className="text-success font-mono tracking-tighter font-extrabold">
secure
</span>
&nbsp; and&nbsp;
<span className="text-info font-mono tracking-tighter italic">
private
</span>
.
</motion.h2>
{/* Send / vault */}
<motion.h2
style={{
opacity: useTransform(
scrollYProgress,
[0.6, 0.63, 0.72, 0.75],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.6, 0.63, 0.72, 0.75],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
send it to&nbsp;
<motion.span
className="font-display text-accent"
style={{
color: useTransform(
scrollYProgress,
[0.67, 1],
["var(--color-accent)", "var(--color-neutral)"],
),
}}
>
someone dear
</motion.span>
<motion.span
style={{
opacity: useTransform(scrollYProgress, [0.66, 0.7], [0, 1]),
}}
>
<motion.span
className="font-display text-accent"
style={{
color: useTransform(
scrollYProgress,
[0.67, 1],
["var(--color-accent)", "var(--color-neutral)"],
),
}}
>
&nbsp; or&nbsp;
</motion.span>
<span className="font-display text-success">
yourself in the future
</span>
.
</motion.span>
</motion.h2>
{/* Burn */}
<motion.h2
style={{
opacity: useTransform(
scrollYProgress,
[0.75, 0.8, 0.85, 0.9],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.75, 0.8, 0.85, 0.9],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
and even <span className="font-display text-error">burn it</span>
&nbsp; to release the burden.
</motion.h2>
{/* Outro */}
<motion.h2
className={
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight text-neutral-content/50"
}
style={{
opacity: useTransform(scrollYProgress, [0.9, 1], [0, 1]),
y: useTransform(scrollYProgress, [0.9, 1], [80, 0]),
}}
>
You've been carrying it long enough.
</motion.h2>
{/* CTA */}
<motion.div
className={
"z-100 absolute -bottom-12 md:bottom-0 font-hand flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
}
style={{
opacity: useTransform(scrollYProgress, [0.98, 1], [0, 1]),
y: useTransform(scrollYProgress, [0.98, 1], [80, 0]),
display: useTransform(
scrollYProgress,
[0.96, 1],
["none", "flex"],
),
}}
>
<button
className={
"md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
}
type={"button"}
onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
>
<InfoIcon className={"text-primary"} />
Tell me More
</button>
<button
className={
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale-50 hover:grayscale-0 focus:grayscale-0 active:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
}
type={"button"}
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
>
I'm ready
</button>
</motion.div>
</div>
<div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
<motion.div
className={"z-21 absolute"}
style={{
opacity: useTransform(
scrollYProgress,
[0.3, 0.4, 0.5, 0.52],
[0, 1, 0.1, 0],
),
y: useTransform(
scrollYProgress,
[0.3, 0.45, 0.5],
[300, 0, 200],
),
scale: useTransform(
scrollYProgress,
[0.3, 0.4, 0.5],
[1, 1, 0.6],
),
}}
>
<div className="mockup-phone w-[75vw] border-primary">
<div className="mockup-phone-camera"></div>
<div className="mockup-phone-display">
<img alt="letter" src={letterSample} />
<div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
<motion.div
className={"z-21 absolute"}
style={{
opacity: useTransform(
scrollYProgress,
[0.3, 0.4, 0.5, 0.52],
[0, 1, 0.1, 0],
),
y: useTransform(
scrollYProgress,
[0.3, 0.45, 0.5],
[300, 0, 200],
),
scale: useTransform(
scrollYProgress,
[0.3, 0.4, 0.5],
[1, 1, 0.6],
),
}}
>
<div className="mockup-phone w-[75vw] border-primary">
<div className="mockup-phone-camera"></div>
<div className="mockup-phone-display">
<img alt="letter" src={letterSample} />
</div>
</div>
</motion.div>
{/* Envelope */}
<motion.div
className="absolute scale-50 md:scale-80 z-10"
style={{
opacity: useTransform(
scrollYProgress,
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
[0, 0.6, 1, 1, 0.3, 0],
),
y: useTransform(scrollYProgress, [0.45, 0.5, 1], [600, 200, 0]),
}}
>
<EnvelopeReveal
isInteractive={false}
ignite={ignite}
recipient={recipient}
date={formatDate(new Date().toISOString())}
onRevealComplete={() => { }}
isFlip={isEnvelopeFlipped}
openFlap={flapOpen}
/>
</motion.div>
{/* Saajan */}
<motion.div
className="fixed bottom-0 z-10 font-sans -mb-6 md:scale-100 md:mb-0"
style={{
opacity: useTransform(
scrollYProgress,
[0.98, 0.995, 1],
[0, 0.5, 1],
),
y: useTransform(scrollYProgress, [0.98, 1], [50, -10]),
}}
>
<Saajan
message={
"I think we forget things\nif there is nobody to tell them."
}
position={"top"}
/>
</motion.div>
{/* Orb */}
<motion.div
className="w-48 z-100 h-48 rounded-full blur-3xl opacity-20"
transition={{
backgroundColor: { ease: "easeIn", duration: 2 },
}}
style={{
backgroundColor: useTransform(
scrollYProgress,
[0.45, 0.5, 0.7, 0.75, 1],
[
"var(--color-primary)",
"var(--color-secondary)",
"var(--color-accent)",
"var(--color-success)",
"var(--color-error)",
],
),
scale: useTransform(scrollYProgress, [0, 1], [0.6, 2.5]),
}}
/>
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
</div>
</div>
</div>
</motion.div>
{/* Envelope */}
<motion.div
className="absolute scale-50 md:scale-80 z-10"
style={{
opacity: useTransform(
scrollYProgress,
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
[0, 0.6, 1, 1, 0.3, 0],
),
y: useTransform(scrollYProgress, [0.45, 0.5, 1], [600, 200, 0]),
}}
>
<EnvelopeReveal
isInteractive={false}
ignite={ignite}
recipient={recipient}
date={formatDate(new Date().toISOString())}
onRevealComplete={() => {}}
isFlip={isEnvelopeFlipped}
openFlap={flapOpen}
/>
</motion.div>
{/* Saajan */}
<motion.div
className="fixed bottom-0 z-10 font-sans -mb-6 md:scale-100 md:mb-0"
style={{
opacity: useTransform(
scrollYProgress,
[0.98, 0.995, 1],
[0, 0.5, 1],
),
y: useTransform(scrollYProgress, [0.98, 1], [50, -10]),
}}
>
<Saajan
message={
"I think we forget things\nif there is nobody to tell them."
}
position={"top"}
/>
</motion.div>
{/* Orb */}
<motion.div
className="w-48 z-100 h-48 rounded-full blur-3xl opacity-20"
transition={{
backgroundColor: { ease: "easeIn", duration: 2 },
}}
style={{
backgroundColor: useTransform(
scrollYProgress,
[0.45, 0.5, 0.7, 0.75, 1],
[
"var(--color-primary)",
"var(--color-secondary)",
"var(--color-accent)",
"var(--color-success)",
"var(--color-error)",
],
),
scale: useTransform(scrollYProgress, [0, 1], [0.6, 2.5]),
}}
/>
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
</div>
</div>
</section>
</ReactLenis>
);
</section>
</ReactLenis>
);
}
+119 -1
View File
@@ -8,6 +8,27 @@ import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto";
import Reader from "./Reader";
vi.mock("../components/reader/EnvelopeReveal", () => ({
EnvelopeReveal: ({
recipient,
onRevealComplete,
}: {
recipient?: string;
onRevealComplete: () => void;
}) => (
<div>
<div data-testid="envelope-recipient">{recipient}</div>
<button
type="button"
data-testid="reveal-button"
onClick={onRevealComplete}
>
Reveal
</button>
</div>
),
}));
const API_URL = import.meta.env.VITE_API_URL;
// Fabric.js needs to know when fonts are loaded
@@ -16,6 +37,13 @@ Object.defineProperty(document, "fonts", {
configurable: true,
});
class MockResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = MockResizeObserver;
describe("Reader Page", () => {
let masterKey: CryptoKey;
let utils: CryptoUtils;
@@ -47,7 +75,7 @@ describe("Reader Page", () => {
});
it("should load and decrypt the letter when a valid key is provided and display the envelope", async () => {
const mockPublicId = "test-uuid";
const mockPublicId = "41123-e2c3-f2115";
const letterContent = JSON.stringify({ objects: [] });
const metadata = { recipient: "Guest" };
// simulate guest
@@ -139,3 +167,93 @@ describe("Reader Page", () => {
);
});
});
describe("Reader Page - (write a letter) CTA", () => {
let masterKey: CryptoKey;
let utils: CryptoUtils;
beforeEach(async () => {
vi.clearAllMocks();
utils = new CryptoUtils();
await utils.initialize();
const bundle = await CryptoUtils.deriveKeyBundle("password", "salt");
masterKey = bundle.masterKey;
vi.stubGlobal("location", {
hash: "",
href: "http://localhost/",
});
});
it("should display CTA for guest and navigate to homepage", async () => {
const mockPublicId = "41123-e2c3-f2115";
const letterContent = JSON.stringify({ objects: [] });
const metadata = { recipient: "Guest" };
useKeyStore.setState({ masterKey: null });
const encryptedLetter = await utils.encryptLetter(letterContent, masterKey);
const encryptedMetadata = await utils.encryptMetadata(metadata, masterKey);
const sharingKey = encryptedLetter.sharingKey as string;
server.use(
http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => {
return HttpResponse.json({
encrypted_content: encryptedLetter.encrypted_content,
encrypted_metadata: encryptedMetadata.encrypted_content,
encrypted_dek: encryptedLetter.encrypted_dek,
images: [],
});
}),
);
render(
<MemoryRouter initialEntries={[`/read/${mockPublicId}#${sharingKey}`]}>
<Routes>
<Route path="/read/:public_id" element={<Reader />} />
<Route
path="/"
element={<div data-testid="home-page">Home Page</div>}
/>
</Routes>
</MemoryRouter>,
);
const revealBtn = await screen.findByTestId("reveal-button");
revealBtn.click();
const ctaBtn = await screen.findByTestId("reader-cta-btn");
ctaBtn.click();
expect(ctaBtn).toHaveTextContent(/write a letter/i);
expect(await screen.findByTestId("home-page")).toBeInTheDocument();
});
it("should not display the CTA for the author of the letter", async () => {
const mockPublicId = "41123-e2c3-f2115";
const letterContent = JSON.stringify({ objects: [] });
const metadata = { recipient: "Guest" };
useKeyStore.setState({ masterKey });
const encryptedLetter = await utils.encryptLetter(letterContent, masterKey);
const encryptedMetadata = await utils.encryptMetadata(metadata, masterKey);
server.use(
http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => {
return HttpResponse.json({
encrypted_content: encryptedLetter.encrypted_content,
encrypted_metadata: encryptedMetadata.encrypted_content,
encrypted_dek: encryptedLetter.encrypted_dek,
images: [],
});
}),
);
render(
<MemoryRouter initialEntries={[`/read/${mockPublicId}`]}>
<Routes>
<Route path="/read/:public_id" element={<Reader />} />
</Routes>
</MemoryRouter>,
);
const revealBtn = await screen.findByTestId("reveal-button");
revealBtn.click();
await screen.findByTestId("envelope-recipient");
expect(screen.queryByTestId("reader-cta-btn")).toBeNull();
});
});
+43 -22
View File
@@ -21,7 +21,7 @@ import { PostActionOverlay } from "../components/reader/PostActionOverlay";
import { ShareModal } from "../components/reader/ShareModal";
import { LogModal } from "../components/ui/LogModal";
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 { formatDate } from "../utils/dateFormat";
@@ -114,27 +114,29 @@ export default function Reader() {
images: LetterImageData[],
encrypted_dek: string,
cryptoUtils: CryptoUtils,
) => {
if (!images?.length) return;
): Promise<CanvasJSON> => {
if (!images?.length) return canvasData;
const isShared = !!sharingKey;
try {
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,
);
const { canvasDataWithDecryptedImages } =
await decryptCanvasImagesWithSharingKey(
canvasData,
images,
sharingKey,
cryptoUtils,
);
return canvasDataWithDecryptedImages;
}
const { canvasDataWithDecryptedImages } = 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,
);
return canvasDataWithDecryptedImages;
} catch (err) {
setLogTrace({
message:
@@ -142,6 +144,7 @@ export default function Reader() {
log: err instanceof Error ? err.message : "Unknown error",
type: "WARN",
});
return canvasData;
}
};
@@ -187,8 +190,13 @@ export default function Reader() {
);
const canvasData: CanvasJSON = JSON.parse(decryptedContent);
await decryptImages(canvasData, images, encrypted_dek, cryptoUtils);
setDecryptedCanvasData(canvasData);
const decryptedCanvasData = await decryptImages(
canvasData,
images,
encrypted_dek,
cryptoUtils,
);
setDecryptedCanvasData(decryptedCanvasData);
};
const processLetterData = async (data: LetterResponseData) => {
@@ -319,6 +327,19 @@ export default function Reader() {
</div>
)}
{revealState === "REVEALED" && !isAuthor && (
<button
data-testid="reader-cta-btn"
type="button"
className="btn btn-ghost btn-wide font-sans tracking-widest mx-auto cursor-pointer flex text-neutral hover:text-neutral-content focus:text-neutral-content"
onClick={() => {
navigate(ROUTES.HOME);
}}
>
write a letter
</button>
)}
{shareLink && (
<ShareModal shareLink={shareLink} setShareLink={setShareLink} />
)}
@@ -360,8 +381,8 @@ export default function Reader() {
</div>
)}
<footer className="mt-16 text-center z-10 opacity-10 pointer-events-none">
<p className="text-xs font-sans uppercase tracking-widester">
<footer className="mt-16 text-center z-10 text-neutral pointer-events-none">
<p className="text-xxs font-sans uppercase font-extrabold tracking-widester">
Read. Remember. Release.
</p>
</footer>