Merge branch 'envelope-inbox' into 'main'

Add Letters inbox page with full letter composing from lief

See merge request soapbox-pub/ditto!117
This commit is contained in:
Chad Curtis
2026-03-27 03:59:48 +00:00
41 changed files with 5669 additions and 45 deletions
+1
View File
@@ -7,6 +7,7 @@
| 36767 | Theme Definition | Shareable, named custom UI theme |
| 16767 | Active Profile Theme | The user's currently active theme (one per user) |
| 16769 | Profile Tabs | The user's custom profile page tabs (one per user) |
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery |
---
+103
View File
@@ -19,6 +19,7 @@
"@emoji-mart/react": "^1.1.1",
"@fontsource-variable/comfortaa": "^5.2.8",
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/fredoka": "^5.2.10",
"@fontsource-variable/inter": "^5.2.6",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/lora": "^5.2.8",
@@ -28,15 +29,19 @@
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/playfair-display": "^5.2.8",
"@fontsource/bungee-shade": "^5.2.7",
"@fontsource/caveat": "^5.2.8",
"@fontsource/cherry-bomb-one": "^5.2.7",
"@fontsource/comic-neue": "^5.2.7",
"@fontsource/comic-relief": "^5.2.2",
"@fontsource/courier-prime": "^5.2.8",
"@fontsource/creepster": "^5.2.7",
"@fontsource/luckiest-guy": "^5.2.8",
"@fontsource/pacifico": "^5.2.7",
"@fontsource/permanent-marker": "^5.2.7",
"@fontsource/pirata-one": "^5.2.8",
"@fontsource/press-start-2p": "^5.2.7",
"@fontsource/silkscreen": "^5.2.8",
"@fontsource/special-elite": "^5.2.8",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@nostrify/nostrify": "^0.51.0",
@@ -80,6 +85,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
"embla-carousel-react": "^8.3.0",
"emoji-mart": "^5.6.0",
"fflate": "^0.8.2",
@@ -118,6 +124,7 @@
"@tailwindcss/typography": "^0.5.15",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/dompurify": "^3.0.5",
"@types/node": "^22.5.5",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.1",
@@ -800,6 +807,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/fredoka": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/@fontsource-variable/fredoka/-/fredoka-5.2.10.tgz",
"integrity": "sha512-W25a41EivYh8111C9x34ShRg+2kKGXnefg1Z9Zax5E0jOgOtGXn7UhoqVBvNLrJnWGv3hy8New0UiKDc/cYzPg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/inter": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.6.tgz",
@@ -879,6 +895,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/caveat": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.2.8.tgz",
"integrity": "sha512-9fUUfFE2IQFKbx+xOcaeQxxmh8iJguEb8z+j1PeueO4UUx+XfT4pRm/B04ZDvFA794/iRxY/IibmP8ZKtIf4rw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/cherry-bomb-one": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/cherry-bomb-one/-/cherry-bomb-one-5.2.7.tgz",
@@ -928,6 +953,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/pacifico": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/pacifico/-/pacifico-5.2.7.tgz",
"integrity": "sha512-L+YiZn3Lb8rVN15zcLlti62doVxQeFpS+dJmulKkFrUo3dZIAxZLHwXoKaaHm+VVcOTrT/mmKCZqGOIWVmFV2w==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/permanent-marker": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/permanent-marker/-/permanent-marker-5.2.7.tgz",
@@ -936,6 +970,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/pirata-one": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/pirata-one/-/pirata-one-5.2.8.tgz",
"integrity": "sha512-hl3Zqt6mgP+jstHnv2qYc2xQdYM1DSYrpSU9bpS1fYExoGFgJJNbXTPBQ9XivNk9RCKkibhou3iqoOZQOXM48w==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/press-start-2p": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/press-start-2p/-/press-start-2p-5.2.7.tgz",
@@ -952,6 +995,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/special-elite": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/special-elite/-/special-elite-5.2.8.tgz",
"integrity": "sha512-tJRYZ6dZK3wiGvMS5NMOg6fqctWimNUUrYtLfPGJYqidaFeI2QqFUE0rbn16DjqID0DrnZSHYfqz1jGdctd5ew==",
"license": "Apache-2.0",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@getalby/lightning-tools": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.2.0.tgz",
@@ -3170,6 +3222,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3183,6 +3236,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3196,6 +3250,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3209,6 +3264,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3222,6 +3278,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3235,6 +3292,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3248,6 +3306,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3261,6 +3320,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3274,6 +3334,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3287,6 +3348,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3300,6 +3362,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3313,6 +3376,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3326,6 +3390,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3339,6 +3404,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3352,6 +3418,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3365,6 +3432,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3378,6 +3446,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3391,6 +3460,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3404,6 +3474,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3417,6 +3488,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3430,6 +3502,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3443,6 +3516,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3456,6 +3530,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3469,6 +3544,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3482,6 +3558,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3924,6 +4001,16 @@
"@types/ms": "*"
}
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -4033,6 +4120,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -5529,6 +5623,15 @@
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.149",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.149.tgz",
+7
View File
@@ -26,6 +26,7 @@
"@emoji-mart/react": "^1.1.1",
"@fontsource-variable/comfortaa": "^5.2.8",
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/fredoka": "^5.2.10",
"@fontsource-variable/inter": "^5.2.6",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/lora": "^5.2.8",
@@ -35,15 +36,19 @@
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/playfair-display": "^5.2.8",
"@fontsource/bungee-shade": "^5.2.7",
"@fontsource/caveat": "^5.2.8",
"@fontsource/cherry-bomb-one": "^5.2.7",
"@fontsource/comic-neue": "^5.2.7",
"@fontsource/comic-relief": "^5.2.2",
"@fontsource/courier-prime": "^5.2.8",
"@fontsource/creepster": "^5.2.7",
"@fontsource/luckiest-guy": "^5.2.8",
"@fontsource/pacifico": "^5.2.7",
"@fontsource/permanent-marker": "^5.2.7",
"@fontsource/pirata-one": "^5.2.8",
"@fontsource/press-start-2p": "^5.2.7",
"@fontsource/silkscreen": "^5.2.8",
"@fontsource/special-elite": "^5.2.8",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@nostrify/nostrify": "^0.51.0",
@@ -87,6 +92,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
"embla-carousel-react": "^8.3.0",
"emoji-mart": "^5.6.0",
"fflate": "^0.8.2",
@@ -125,6 +131,7 @@
"@tailwindcss/typography": "^0.5.15",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/dompurify": "^3.0.5",
"@types/node": "^22.5.5",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.1",
+4
View File
@@ -55,6 +55,8 @@ import { ArchivePage } from "./pages/ArchivePage";
import { BlueskyPage } from "./pages/BlueskyPage";
import { ChangelogPage } from "./pages/ChangelogPage";
import { WikipediaPage } from "./pages/WikipediaPage";
import { LettersPage } from "./pages/LettersPage";
import { LetterPreferencesPage } from "./pages/LetterPreferencesPage";
const pollsDef = getExtraKindDef("polls")!;
const colorsDef = getExtraKindDef("colors")!;
@@ -211,6 +213,8 @@ export function AppRouter() {
<Route path="/archive" element={<ArchivePage />} />
<Route path="/bluesky" element={<BlueskyPage />} />
<Route path="/wikipedia" element={<WikipediaPage />} />
<Route path="/letters" element={<LettersPage />} />
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/safety" element={<CSAEPolicyPage />} />
+3 -7
View File
@@ -2,7 +2,7 @@ import { useMemo, useState, useEffect, useId } from 'react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/hooks/useTheme';
import { hexToHslString, hexToRgb, rgbToHsl, hslToRgb, getLuminance, getContrastRatio, parseHsl, formatHsl } from '@/lib/colorUtils';
import { hexToHslString, hexToRgb, rgbToHsl, hslToRgb, getLuminance, getContrastRatio, parseHsl, formatHsl, hexLuminance } from '@/lib/colorUtils';
import type { CoreThemeColors } from '@/themes';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -12,7 +12,7 @@ function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
function getColors(tags: string[][]): string[] {
export function getColors(tags: string[][]): string[] {
return tags
.filter(([n]) => n === 'c')
.map(([, v]) => v)
@@ -195,10 +195,6 @@ function DiagonalStripesLayout({ colors }: { colors: string[] }) {
// ─── Palette → theme mapping ─────────────────────────────
function hexLuminance(hex: string): number {
return getLuminance(...hexToRgb(hex));
}
function hexContrast(hex1: string, hex2: string): number {
return getContrastRatio(hexToRgb(hex1), hexToRgb(hex2));
}
@@ -242,7 +238,7 @@ function enforceContrast(hsl: string, bgHsl: string, targetRatio: number): strin
* 3. primary = most saturated remaining color; if contrast < 3:1 against
* background, adjust its lightness until it passes
*/
function paletteToTheme(colors: string[]): CoreThemeColors {
export function paletteToTheme(colors: string[]): CoreThemeColors {
if (colors.length === 0) {
return { background: '0 0% 10%', text: '0 0% 98%', primary: '258 70% 55%' };
}
+54
View File
@@ -0,0 +1,54 @@
import { useMemo } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { getAvatarShape, getEmojiMaskUrl } from '@/lib/avatarShape';
interface FabButtonProps {
onClick: () => void;
icon: React.ReactNode;
disabled?: boolean;
className?: string;
title?: string;
}
/**
* Reusable FAB that inherits the current user's avatar shape (emoji mask or
* circle fallback), matching the FloatingComposeButton style exactly.
*/
export function FabButton({ onClick, icon, disabled, className = '', title }: FabButtonProps) {
const { metadata } = useCurrentUser();
const avatarShape = getAvatarShape(metadata);
const shapeMaskStyle = useMemo<React.CSSProperties | undefined>(() => {
if (!avatarShape) return undefined;
const maskUrl = getEmojiMaskUrl(avatarShape);
if (!maskUrl) return undefined;
return {
WebkitMaskImage: `url(${maskUrl})`,
maskImage: `url(${maskUrl})`,
WebkitMaskSize: 'contain',
maskSize: 'contain' as string,
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat' as string,
WebkitMaskPosition: 'center',
maskPosition: 'center' as string,
};
}, [avatarShape]);
return (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none ${className}`}
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
>
<div
className={`absolute inset-0 bg-primary ${shapeMaskStyle ? '' : 'rounded-full'}`}
style={shapeMaskStyle}
/>
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
{icon}
</span>
</button>
);
}
+6 -37
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { Plus, Construction } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
@@ -9,7 +9,7 @@ import {
} from '@/components/ui/dialog';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { getAvatarShape, getEmojiMaskUrl } from '@/lib/avatarShape';
import { FabButton } from '@/components/FabButton';
@@ -25,30 +25,11 @@ interface FloatingComposeButtonProps {
}
export function FloatingComposeButton({ kind = 1, href, onFabClick, icon }: FloatingComposeButtonProps) {
const { user, metadata, isLoading } = useCurrentUser();
const { user, isLoading } = useCurrentUser();
const navigate = useNavigate();
const [composeOpen, setComposeOpen] = useState(false);
const [comingSoonOpen, setComingSoonOpen] = useState(false);
const avatarShape = getAvatarShape(metadata);
/** When the user has a custom emoji shape, use it as the FAB mask instead of a circle. */
const shapeMaskStyle = useMemo<React.CSSProperties | undefined>(() => {
if (!avatarShape) return undefined;
const maskUrl = getEmojiMaskUrl(avatarShape);
if (!maskUrl) return undefined;
return {
WebkitMaskImage: `url(${maskUrl})`,
maskImage: `url(${maskUrl})`,
WebkitMaskSize: 'contain',
maskSize: 'contain' as string,
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat' as string,
WebkitMaskPosition: 'center',
maskPosition: 'center' as string,
};
}, [avatarShape]);
// Hide until user metadata is resolved so the shape mask is immediately
// correct — avoids a brief flash of the default circle fallback.
if (!user || isLoading) {
@@ -69,22 +50,10 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon }: Floa
return (
<>
<button
<FabButton
onClick={handleClick}
className="relative size-16 transition-transform hover:scale-105 active:scale-95"
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
>
{/* FAB background: user's avatar shape (emoji mask) or circle (default) */}
<div
className={`absolute inset-0 bg-primary ${shapeMaskStyle ? '' : 'rounded-full'}`}
style={shapeMaskStyle}
/>
{/* Plus icon centered on the button */}
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
{icon ?? <Plus strokeWidth={4} size={16} />}
</span>
</button>
icon={icon ?? <Plus strokeWidth={4} size={16} />}
/>
{/* Kind 1: Compose modal */}
{kind === 1 && (
+1 -1
View File
@@ -61,7 +61,7 @@ export function TabButton({ label, active, onClick, disabled, className, childre
onMouseLeave={handleMouseLeave}
disabled={disabled}
className={cn(
'flex-1 py-1.5 text-center text-sm font-medium transition-colors relative px-4 whitespace-nowrap',
'flex-1 flex items-center justify-center py-1.5 text-sm font-medium transition-colors relative px-4 whitespace-nowrap',
active ? 'text-foreground' : 'text-muted-foreground',
disabled && 'opacity-50 cursor-not-allowed',
className,
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
/**
* Mailbox icon from lief — a rounded post-box with a flag.
* Rendered as a standard lucide-style SVG component.
*/
export const MailboxIcon = React.forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(
({ className, strokeWidth = 2, ...props }, ref) => (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...props}
>
<path d="M20 5.5A4 4 0 0 1 22 9v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a4 4 0 0 1 8 0v8a2 2 0 0 1-2 2M6 5h4" />
<path d="M14 9V5h2v1h-2" />
</svg>
),
);
MailboxIcon.displayName = 'MailboxIcon';
@@ -0,0 +1,201 @@
/**
* ColorPaletteDisplay
*
* Renders a color palette (array of hex strings) in one of 6 layouts,
* matching the approach used in mew and espy for kind 3367 color moments.
*/
import { hexLuminance } from '@/lib/colorUtils';
export type PaletteLayout =
| 'horizontal'
| 'vertical'
| 'grid'
| 'star'
| 'checkerboard'
| 'diagonalStripes';
interface ColorPaletteDisplayProps {
colors: string[];
layout?: PaletteLayout;
/** className applied to the outer wrapper — controls size/shape */
className?: string;
/** Overlay content (emoji, buttons, etc.) */
children?: React.ReactNode;
}
function gridDimensions(n: number): { cols: number; rows: number } {
if (n <= 3) return { cols: n, rows: 1 };
if (n === 4) return { cols: 2, rows: 2 };
return { cols: 3, rows: 2 };
}
function pieSliceClipPath(index: number, total: number): string {
const STEPS = 12;
const OVERLAP = 0.5;
const startAngle = (index / total) * 360 - 90 - OVERLAP;
const endAngle = ((index + 1) / total) * 360 - 90 + OVERLAP;
const points: string[] = ['50% 50%'];
for (let s = 0; s <= STEPS; s++) {
const angle = ((startAngle + (s / STEPS) * (endAngle - startAngle)) * Math.PI) / 180;
const x = 50 + 150 * Math.cos(angle);
const y = 50 + 150 * Math.sin(angle);
points.push(`${x.toFixed(2)}% ${y.toFixed(2)}%`);
}
return `polygon(${points.join(', ')})`;
}
function HorizontalLayout({ colors }: { colors: string[] }) {
return (
<div className="flex flex-col w-full h-full">
{colors.map((color, i) => (
<div key={i} className="flex-1" style={{ backgroundColor: color }} />
))}
</div>
);
}
function VerticalLayout({ colors }: { colors: string[] }) {
return (
<div className="flex w-full h-full">
{colors.map((color, i) => (
<div key={i} className="flex-1" style={{ backgroundColor: color }} />
))}
</div>
);
}
function GridLayout({ colors }: { colors: string[] }) {
const { cols, rows } = gridDimensions(colors.length);
return (
<div
className="w-full h-full"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
}}
>
{colors.map((color, i) => (
<div key={i} style={{ backgroundColor: color }} />
))}
</div>
);
}
function StarLayout({ colors }: { colors: string[] }) {
const n = colors.length;
return (
<div className="relative w-full h-full" style={{ backgroundColor: colors[0] }}>
{colors.map((color, i) => (
<div
key={i}
className="absolute inset-0"
style={{
backgroundColor: color,
clipPath: pieSliceClipPath(i, n),
}}
/>
))}
</div>
);
}
const HEX_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
function safeHex(color: string): string {
return HEX_RE.test(color) ? color : '#000000';
}
function CheckerboardLayout({ colors }: { colors: string[] }) {
const n = colors.length;
const rows = n * Math.max(2, 4 - n);
const cellSize = 20;
const cols = rows;
const svgSize = cellSize * cols;
const rects: string[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const color = safeHex(colors[(r + c) % n]);
rects.push(
`<rect x="${c * cellSize}" y="${r * cellSize}" width="${cellSize}" height="${cellSize}" fill="${color}"/>`
);
}
}
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='${svgSize}' height='${svgSize}'>${rects.join('')}</svg>`;
const dataUri = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
return (
<div
className="w-full h-full"
style={{
backgroundImage: dataUri,
backgroundSize: `${(100 / cols) * cellSize}% ${(100 / rows) * cellSize}%`,
backgroundRepeat: 'repeat',
imageRendering: 'pixelated',
}}
/>
);
}
function DiagonalStripesLayout({ colors }: { colors: string[] }) {
const n = colors.length;
const W = 400;
const H = 200;
const stripeW = (W + H) / n;
const polygons = colors.map((rawColor, i) => {
const color = safeHex(rawColor);
const x0 = i * stripeW;
const x1 = (i + 1) * stripeW;
const points = [
`${x0},0`,
`${x1},0`,
`${x1 - H},${H}`,
`${x0 - H},${H}`,
].join(' ');
return `<polygon points="${points}" fill="${color}"/>`;
});
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${W} ${H}' preserveAspectRatio='none' shape-rendering='geometricPrecision'>${polygons.join('')}</svg>`;
return (
<div
className="w-full h-full"
style={{
backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`,
backgroundSize: '100% 100%',
backgroundRepeat: 'no-repeat',
}}
/>
);
}
export function ColorPaletteDisplay({
colors,
layout = 'horizontal',
className = '',
children,
}: ColorPaletteDisplayProps) {
if (colors.length === 0) return null;
const avgLum = colors.reduce((s, c) => s + hexLuminance(c), 0) / colors.length;
const emojiColorClass = avgLum > 0.5 ? 'text-black/80' : 'text-white/90';
return (
<div className={`overflow-hidden ${className}`}>
{layout === 'horizontal' && <HorizontalLayout colors={colors} />}
{layout === 'vertical' && <VerticalLayout colors={colors} />}
{layout === 'grid' && <GridLayout colors={colors} />}
{layout === 'star' && <StarLayout colors={colors} />}
{layout === 'checkerboard' && <CheckerboardLayout colors={colors} />}
{layout === 'diagonalStripes' && <DiagonalStripesLayout colors={colors} />}
{children && (
<div className={`absolute inset-0 flex items-center justify-center pointer-events-none select-none ${emojiColorClass}`}>
{children}
</div>
)}
</div>
);
}
@@ -0,0 +1,493 @@
import { useState, useMemo, useRef, useCallback, useEffect, type ReactNode } from 'react';
import { ArrowLeft, Loader2, Pencil, Send, Sticker, X } from 'lucide-react';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { FabButton } from '@/components/FabButton';
import { nip19 } from 'nostr-tools';
import { useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useStationeryColors } from '@/hooks/useStationeryColors';
import { toast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { backgroundTextColor } from '@/lib/colorUtils';
import {
LETTER_KIND,
FONT_OPTIONS,
LINE_HEIGHT_RATIO,
DEFAULT_STATIONERY_COLOR,
resolveStationery,
type Stationery,
type FrameStyle,
type LetterContent,
type LetterSticker,
} from '@/lib/letterTypes';
import { useLetterPreferences } from '@/hooks/useLetterPreferences';
import { useThemeStationery } from '@/hooks/useThemeStationery';
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { LetterEditor } from './LetterEditor';
import { LetterStickers } from './LetterStickers';
import { StickerPicker } from './StickerPicker';
import { DrawingCanvas } from './DrawingCanvas';
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { StationeryBackground } from './StationeryBackground';
import { SendAnimation, useEnvelopeDimensions } from './SendAnimation';
/** Lightweight letter preview used inside the send animation */
function AnimationLetter({ content, width }: { content: LetterContent; width: number }) {
const { text: textColor, line: lineColor } = useStationeryColors(content.stationery);
const resolved = resolveStationery(content.stationery ?? { color: DEFAULT_STATIONERY_COLOR });
const fontFamily = resolved.fontFamily ?? FONT_OPTIONS[0].family;
const lh = Math.round(width * LINE_HEIGHT_RATIO);
return (
<div className="relative" style={{ containerType: 'inline-size', width }}>
<StationeryBackground
stationery={content.stationery}
frame={content.stationery?.frame}
frameTint={content.stationery?.frameTint}
className="rounded-2xl"
>
<div className="relative z-10 flex flex-col" style={{ aspectRatio: '5 / 4', padding: '5cqw' }}>
<p
className="whitespace-pre-wrap font-semibold tracking-wide overflow-hidden flex-1 min-h-0"
style={{
fontSize: '4.8cqw',
lineHeight: `${lh}px`,
letterSpacing: '0.06em',
paddingTop: '0.5cqw',
fontFamily,
color: textColor,
backgroundImage: `linear-gradient(to bottom, transparent ${lh - 3}px, ${lineColor} ${lh - 3}px)`,
backgroundSize: `100% ${lh}px`,
backgroundRepeat: 'repeat-y',
maxHeight: `${lh * 5}px`,
}}
>
{content.body}
</p>
{content.closing && (
<div className="flex flex-col items-end" style={{ paddingTop: '6cqw', gap: '3cqw', paddingRight: '4cqw', fontFamily }}>
<p style={{ fontSize: '4.8cqw', color: textColor }}>{content.closing}</p>
</div>
)}
</div>
</StationeryBackground>
{content.stickers && content.stickers.length > 0 && (
<LetterStickers stickers={content.stickers} />
)}
</div>
);
}
const BODY_MAX_LENGTH = 220;
/** Inline chip showing the selected recipient with avatar + name + optional clear. */
function SelectedRecipient({ pubkey, onClear }: { pubkey: string; onClear?: () => void }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey);
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-2xl bg-muted/60 min-w-0">
<Avatar className="size-6 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/15 text-[10px] font-bold text-primary">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<span className="text-base font-medium truncate">{displayName}</span>
{onClear && (
<button
onClick={onClear}
className="text-muted-foreground hover:text-foreground shrink-0 transition-colors p-0.5"
>
<X className="size-4" strokeWidth={3} />
</button>
)}
</div>
);
}
type Overlay = 'none' | 'font' | 'stationery' | 'frame' | 'sticker' | 'draw';
interface ComposeLetterSheetProps {
onClose: () => void;
toPubkey?: string;
}
export function ComposeLetterSheet({ onClose, toPubkey }: ComposeLetterSheetProps) {
const { user } = useCurrentUser();
const { mutateAsync: createEvent } = useNostrPublish();
const queryClient = useQueryClient();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const bodyAreaRef = useRef<HTMLDivElement>(null);
const initialRecipient = useMemo(() => {
if (!toPubkey) return undefined;
try {
if (toPubkey.startsWith('npub1')) {
const d = nip19.decode(toPubkey);
if (d.type === 'npub') return d.data;
}
if (/^[0-9a-f]{64}$/i.test(toPubkey)) return toPubkey;
} catch { /* ignore */ }
return undefined;
}, [toPubkey]);
const { prefs, isThemeDefault } = useLetterPreferences();
const themeStationery = useThemeStationery();
const [resolvedRecipient, setResolvedRecipient] = useState<string | undefined>(initialRecipient);
const [body, setBody] = useState('');
const [closing, setClosing] = useState(() => prefs.closing ?? 'Warmly,');
const [signature, setSignature] = useState(() => prefs.signature ?? '');
const [selectedFont, setSelectedFont] = useState(
() => FONT_OPTIONS.find((f) => f.value === prefs.font) ?? FONT_OPTIONS[0],
);
// Start from the live theme stationery immediately — don't wait for encrypted settings.
// If the user has saved a custom stationery preference, switch to it once prefs load.
const [stationery, setStationery] = useState<Stationery>(themeStationery);
const [frame, setFrame] = useState<FrameStyle>(() => prefs.frame ?? 'none');
const [frameTint, setFrameTint] = useState(() => prefs.frameTint ?? false);
const [overlay, setOverlay] = useState<Overlay>('none');
const [stickers, setStickers] = useState<LetterSticker[]>([]);
const { emojis: customEmojis } = useCustomEmojis();
const [sealing, setSealing] = useState(false);
const [sendAnimationContent, setSendAnimationContent] = useState<LetterContent | null>(null);
const envDims = useEnvelopeDimensions();
const animLetterW = envDims.letterW;
// Once encrypted settings load, apply saved stationery preference (if any).
// isThemeDefault is false only when the user has an explicit saved stationery.
const prefsLoadedRef = useRef(false);
useEffect(() => {
if (prefsLoadedRef.current) return;
if (!isThemeDefault && prefs.stationery) {
setStationery(prefs.stationery as Stationery);
prefsLoadedRef.current = true;
} else if (isThemeDefault) {
// Settings loaded and confirmed no override — stay with theme stationery.
// Keep the live theme stationery in sync if the theme changes.
prefsLoadedRef.current = true;
}
}, [isThemeDefault, prefs.stationery]);
// Keep stationery in sync with the live theme when using the theme default.
// If the user has explicitly chosen a stationery in this session, don't override it.
const userPickedStationery = useRef(false);
const handleSetStationery = useCallback((s: Stationery) => {
userPickedStationery.current = true;
setStationery(s);
}, []);
useEffect(() => {
if (!userPickedStationery.current && isThemeDefault) {
setStationery(themeStationery);
}
}, [themeStationery, isThemeDefault]);
const [textareaPadPx, setTextareaPadPx] = useState(0);
useEffect(() => {
const el = bodyAreaRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
setTextareaPadPx(Math.ceil(w * 0.005));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const canSend = !!resolvedRecipient && body.trim().length > 0 && !!user;
const handleAddSticker = useCallback((emoji: { shortcode: string; url: string }) => {
setStickers((prev) => [
...prev,
{
url: emoji.url,
shortcode: emoji.shortcode,
x: 20 + Math.random() * 60,
y: 20 + Math.random() * 60,
rotation: -15 + Math.random() * 30,
},
]);
setOverlay('none');
}, []);
const handleAddDrawing = useCallback((svg: string) => {
setStickers((prev) => [
...prev,
{
url: '',
shortcode: 'drawing',
svg,
x: 20 + Math.random() * 60,
y: 20 + Math.random() * 60,
rotation: -15 + Math.random() * 30,
},
]);
setOverlay('none');
}, []);
const handleUpdateSticker = useCallback((index: number, patch: Partial<LetterSticker>) => {
setStickers((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)));
}, []);
const handleRemoveSticker = useCallback((index: number) => {
setStickers((prev) => prev.filter((_, i) => i !== index));
}, []);
const buildLetterContent = useCallback((): LetterContent => {
const finalStationery: Stationery = {
...stationery,
...(selectedFont.family !== FONT_OPTIONS[0].family
? { fontFamily: selectedFont.family }
: {}),
...(frame && frame !== 'none' ? { frame } : {}),
...(frame && frame !== 'none' && frameTint ? { frameTint: true } : {}),
};
return {
body: body.trim(),
...(closing.trim() ? { closing: closing.trim() } : {}),
...(signature.trim() ? { signature: signature.trim() } : {}),
...(stickers.length > 0 ? { stickers } : {}),
stationery: finalStationery,
};
}, [body, closing, signature, stickers, stationery, selectedFont, frame, frameTint]);
const handleSend = async () => {
if (!canSend || !user || !resolvedRecipient) return;
if (!user.signer.nip44) {
toast({ title: "your signer doesn't support encryption yet", variant: 'destructive' });
return;
}
setSealing(true);
try {
const letterContent = buildLetterContent();
const encrypted = await user.signer.nip44.encrypt(
resolvedRecipient,
JSON.stringify(letterContent)
);
const tags: string[][] = [
['p', resolvedRecipient],
['alt', 'Encrypted letter'],
];
await createEvent({ kind: LETTER_KIND, content: encrypted, tags });
queryClient.invalidateQueries({ queryKey: ['letters-sent'] });
queryClient.invalidateQueries({ queryKey: ['letters-inbox'] });
setSendAnimationContent(letterContent);
} catch (err) {
console.error('Failed to send letter:', err);
setSealing(false);
toast({ title: "couldn't send that one", variant: 'destructive' });
}
};
const recipientAuthor = useAuthor(resolvedRecipient);
const recipientName = recipientAuthor.data?.metadata?.display_name
|| recipientAuthor.data?.metadata?.name
|| (resolvedRecipient ? genUserName(resolvedRecipient) : 'friend');
const resolvedSt = useMemo(() => resolveStationery(stationery ?? { color: DEFAULT_STATIONERY_COLOR }), [stationery]);
const bgColor = resolvedSt.color ?? DEFAULT_STATIONERY_COLOR;
const primaryColor = resolvedSt.primaryColor ?? '#7c52e0';
const textColor = resolvedSt.textColor ?? backgroundTextColor(bgColor);
// Memoize the animation letter element — only recompute when content or width changes.
// Uses sendAnimationContent directly (not a ref) so deps are exhaustive.
const animLetterElement = useMemo(
() => sendAnimationContent
? <AnimationLetter content={sendAnimationContent} width={animLetterW} />
: <AnimationLetter content={{ body: '' }} width={animLetterW} />,
[sendAnimationContent, animLetterW],
);
return (
<>
{/* Pre-render letter hidden so images/fonts are loaded before animation fires */}
<div aria-hidden className="absolute opacity-0 pointer-events-none" style={{ width: animLetterW, top: -9999 }}>
<AnimationLetter content={buildLetterContent()} width={animLetterW} />
</div>
{sendAnimationContent && (
<SendAnimation
letterElement={animLetterElement}
letterWidth={animLetterW}
recipientName={recipientName}
recipientPicture={recipientAuthor.data?.metadata?.picture}
bgColor={bgColor}
primaryColor={primaryColor}
textColor={textColor}
onComplete={onClose}
/>
)}
<div
ref={bodyAreaRef}
className="absolute inset-0 z-40 bg-background flex flex-col overflow-y-auto"
style={sendAnimationContent ? { visibility: 'hidden', pointerEvents: 'none' } : undefined}
>
<LetterEditor
state={{
selectedFont, setSelectedFont,
stationery, setStationery: handleSetStationery,
frame, setFrame,
frameTint, setFrameTint,
closing, setClosing,
signature, setSignature,
}}
overlay={overlay}
setOverlay={(o) => setOverlay(o as Overlay)}
renderToolbarButtons={(buttons: ReactNode, drawer: ReactNode) => (
<div className="sticky top-0 z-10">
{drawer}
<SubHeaderBar className="relative">
<button
onClick={onClose}
className="pl-3 pr-1 py-1.5 text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<ArrowLeft className="size-5" />
</button>
{buttons}
</SubHeaderBar>
</div>
)}
extraButtons={
<>
{customEmojis.length > 0 && (
<TabButton
label="Stickers"
active={overlay === 'sticker'}
onClick={() => setOverlay(overlay === 'sticker' ? 'none' : 'sticker')}
>
<Sticker className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
)}
<TabButton
label="Draw"
active={overlay === 'draw'}
onClick={() => setOverlay(overlay === 'draw' ? 'none' : 'draw')}
>
<Pencil className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
</>
}
extraDrawerContent={
<>
{overlay === 'sticker' && <StickerPicker onSelect={handleAddSticker} />}
{overlay === 'draw' && <DrawingCanvas onConfirm={handleAddDrawing} onCancel={() => setOverlay('none')} />}
</>
}
beforeCard={
<div className="max-w-xl mx-auto w-full px-5 pb-2 pt-4 max-sidebar:pt-[calc(20px+2.5rem)]">
<div className="flex items-center">
<span className="text-sm font-medium text-muted-foreground shrink-0 w-14">To</span>
{!initialRecipient && !resolvedRecipient ? (
<div className="flex-1">
<ProfileSearchDropdown
placeholder="search for a person..."
onSelect={(profile) => setResolvedRecipient(profile.pubkey)}
hideCountry
className="w-full"
inputClassName="rounded-2xl bg-muted/60 border-0 focus-visible:ring-2 focus-visible:ring-primary/20 text-base h-auto py-2"
/>
</div>
) : (
<SelectedRecipient
pubkey={resolvedRecipient ?? initialRecipient!}
onClear={initialRecipient ? undefined : () => setResolvedRecipient(undefined)}
/>
)}
</div>
</div>
}
bodyContent={({ lineHeightPx, stationeryTextColor: textColor, stationeryLineColor: lineColor, resolvedFontFamily: fontFamily }) => (
<textarea
ref={textareaRef}
value={body}
onFocus={() => setOverlay('none')}
onChange={(e) => {
const el = e.target;
const next = e.target.value;
if (next.length > BODY_MAX_LENGTH) {
el.value = body;
return;
}
if (next.length > body.length && el.scrollHeight > el.clientHeight) {
el.value = body;
return;
}
setBody(next);
}}
maxLength={BODY_MAX_LENGTH}
placeholder="dear friend..."
className="w-full flex-1 min-h-0 border-none shadow-none resize-none overflow-hidden focus:outline-none font-semibold tracking-wide"
style={{
paddingTop: '0.5cqw',
paddingBottom: 0,
fontSize: '4.8cqw',
lineHeight: lineHeightPx > 0 ? `${lineHeightPx}px` : '8.4cqw',
...(lineHeightPx > 0 ? { maxHeight: `${lineHeightPx * 5 + textareaPadPx}px` } : {}),
letterSpacing: '0.06em',
fontFamily,
color: textColor,
caretColor: textColor,
backgroundColor: 'transparent',
...(lineHeightPx > 0 ? {
backgroundImage: `linear-gradient(to bottom, transparent ${lineHeightPx - 3}px, ${lineColor} ${lineHeightPx - 3}px)`,
backgroundSize: `100% ${lineHeightPx}px`,
backgroundRepeat: 'repeat-y',
} : {}),
}}
/>
)}
cardOverlay={
<LetterStickers
stickers={stickers}
editable
onUpdate={handleUpdateSticker}
onRemove={handleRemoveSticker}
containerRef={bodyAreaRef}
/>
}
/>
{/* Send FAB — fixed bottom right, matches app FAB style */}
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
<FabButton
onClick={handleSend}
disabled={!canSend || sealing}
title="Send letter"
icon={sealing
? <Loader2 size={18} className="animate-spin" />
: <Send strokeWidth={3} size={18} />
}
/>
</div>
{/* Desktop FAB — sticky within column */}
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
<div className="flex justify-end pr-4">
<div className="pointer-events-auto">
<FabButton
onClick={handleSend}
disabled={!canSend || sealing}
title="Send letter"
icon={sealing
? <Loader2 size={18} className="animate-spin" />
: <Send strokeWidth={3} size={18} />
}
/>
</div>
</div>
</div>
</div>
</>
);
}
+163
View File
@@ -0,0 +1,163 @@
/**
* DrawingCanvas
*
* Freehand SVG drawing tool for creating hand-drawn stickers.
* Produces a tightly-cropped, point-simplified SVG string on confirm.
*/
import { useRef, useState, useCallback } from 'react';
import { Undo2, Check, X, Eraser } from 'lucide-react';
import { type Stroke, pointsToPath, strokesToSvg } from '@/lib/svgDrawing';
const CANVAS_SIZE = 300;
const COLORS = [
'#1a1a1a', '#e53e3e', '#dd6b20', '#d69e2e',
'#38a169', '#3182ce', '#805ad5', '#d53f8c', '#f7f7f7',
];
const BRUSH_SIZES = [
{ value: 3, label: 'S' },
{ value: 6, label: 'M' },
{ value: 10, label: 'L' },
{ value: 16, label: 'XL' },
];
function pointerToSvg(e: React.PointerEvent<SVGSVGElement>, svg: SVGSVGElement): [number, number] {
const rect = svg.getBoundingClientRect();
return [
((e.clientX - rect.left) / rect.width) * CANVAS_SIZE,
((e.clientY - rect.top) / rect.height) * CANVAS_SIZE,
];
}
interface DrawingCanvasProps {
onConfirm: (svg: string) => void;
onCancel: () => void;
}
export function DrawingCanvas({ onConfirm, onCancel }: DrawingCanvasProps) {
const svgRef = useRef<SVGSVGElement>(null);
const [strokes, setStrokes] = useState<Stroke[]>([]);
const [activeStroke, setActiveStroke] = useState<Stroke | null>(null);
const [color, setColor] = useState(COLORS[0]);
const [brushSize, setBrushSize] = useState(BRUSH_SIZES[1].value);
const handlePointerDown = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
if (!svgRef.current) return;
e.preventDefault();
(e.target as Element).setPointerCapture?.(e.pointerId);
setActiveStroke({ points: [pointerToSvg(e, svgRef.current)], color, width: brushSize });
}, [color, brushSize]);
const handlePointerMove = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
if (!activeStroke || !svgRef.current) return;
e.preventDefault();
const pt = pointerToSvg(e, svgRef.current);
setActiveStroke((prev) => prev ? { ...prev, points: [...prev.points, pt] } : prev);
}, [activeStroke]);
const handlePointerUp = useCallback(() => {
if (!activeStroke) return;
setStrokes((prev) => [...prev, activeStroke]);
setActiveStroke(null);
}, [activeStroke]);
const handleConfirm = useCallback(() => {
const svg = strokesToSvg(strokes);
if (svg) onConfirm(svg);
}, [strokes, onConfirm]);
const allStrokes = activeStroke ? [...strokes, activeStroke] : strokes;
const hasStrokes = strokes.length > 0;
const actionBtn = 'p-2.5 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-30 disabled:cursor-not-allowed';
return (
<div className="flex flex-col gap-3">
<div className="relative mx-auto w-full max-w-[300px]">
<div className="relative rounded-2xl overflow-hidden border-2 border-dashed border-border bg-white" style={{ aspectRatio: '1' }}>
<svg
ref={svgRef}
viewBox={`0 0 ${CANVAS_SIZE} ${CANVAS_SIZE}`}
className="absolute inset-0 w-full h-full cursor-crosshair"
style={{ touchAction: 'none' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<defs>
<pattern id="drawing-grid" width="30" height="30" patternUnits="userSpaceOnUse">
<circle cx="15" cy="15" r="0.5" fill="#d4d4d4" />
</pattern>
</defs>
<rect width={CANVAS_SIZE} height={CANVAS_SIZE} fill="url(#drawing-grid)" />
{allStrokes.map((s, i) => (
<path
key={i}
d={pointsToPath(s.points)}
fill="none"
stroke={s.color}
strokeWidth={s.width}
strokeLinecap="round"
strokeLinejoin="round"
opacity={s === activeStroke ? 0.8 : 1}
/>
))}
</svg>
</div>
</div>
{/* Colors */}
<div className="flex items-center justify-center gap-1.5">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`w-7 h-7 rounded-full transition-all border-2 ${color === c ? 'border-primary scale-110 shadow-md' : 'border-transparent hover:scale-105'}`}
style={{ backgroundColor: c }}
/>
))}
</div>
{/* Brush sizes */}
<div className="flex items-center justify-center gap-2">
{BRUSH_SIZES.map((b) => (
<button
key={b.value}
type="button"
onClick={() => setBrushSize(b.value)}
className={`flex items-center justify-center rounded-xl px-3 py-1.5 text-xs font-semibold transition-all ${brushSize === b.value ? 'bg-primary text-primary-foreground shadow-sm' : 'bg-muted text-muted-foreground hover:text-foreground'}`}
>
<span className="rounded-full inline-block mr-1.5" style={{ width: Math.max(4, b.value), height: Math.max(4, b.value), backgroundColor: brushSize === b.value ? 'currentColor' : color }} />
{b.label}
</button>
))}
</div>
{/* Actions */}
<div className="flex items-center justify-center gap-2">
<button type="button" onClick={onCancel} className={actionBtn} title="Cancel">
<X className="w-5 h-5" strokeWidth={2.5} />
</button>
<button type="button" onClick={() => { setStrokes([]); setActiveStroke(null); }} disabled={!hasStrokes} className={actionBtn} title="Clear all">
<Eraser className="w-5 h-5" strokeWidth={2.5} />
</button>
<button type="button" onClick={() => setStrokes((p) => p.slice(0, -1))} disabled={!hasStrokes} className={actionBtn} title="Undo">
<Undo2 className="w-5 h-5" strokeWidth={2.5} />
</button>
<button
type="button"
onClick={handleConfirm}
disabled={!hasStrokes}
className="px-5 py-2.5 rounded-xl bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-1.5"
>
<Check className="w-4 h-4" strokeWidth={3} />
done
</button>
</div>
</div>
);
}
+232
View File
@@ -0,0 +1,232 @@
/**
* EnvelopeCard — Wii Mail-inspired envelope tile for the letters grid.
*
* A sealed envelope — single rounded rectangle:
*
* ┌────────────────────┐
* │ │ ← closed flap = StationeryBackground
* │ (stationery) │
* │ ╲ ╱ │
* │──────────╲╱────────│ ← V crease line
* │ [seal] │
* │ ╱ ╲ │ ← V-fold body = opaque paper
* │ ╱ ╲ │
* └────────────────────┘
* name 3h
*
* The closed flap shows the real StationeryBackground (images, palettes, emoji).
* Below the V crease is the opaque paper body with corner folds and subtle shading.
* Avatar wax seal at the V vertex.
* Label: name left-aligned, shorthand time right-aligned.
*/
import { useMemo } from 'react';
import { Clock } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useDecryptLetter } from '@/hooks/useLetters';
import { genUserName } from '@/lib/genUserName';
import { resolveStationery, DEFAULT_STATIONERY_COLOR, type Letter } from '@/lib/letterTypes';
import { hexLuminance, darkenHex, lightenHex, blendHex } from '@/lib/colorUtils';
import { StationeryBackground } from './StationeryBackground';
interface EnvelopeCardProps {
letter: Letter;
mode: 'inbox' | 'sent';
index: number;
onClick: () => void;
}
// ---------------------------------------------------------------------------
// Color helpers
// ---------------------------------------------------------------------------
function deriveColors(bgHex: string, primaryHex: string) {
const body = blendHex(bgHex, primaryHex, 0.08);
const isDark = hexLuminance(body) < 0.45;
return {
body,
bodyLight: lightenHex(body, 0.06),
bodyDark: darkenHex(body, 0.06),
stroke: darkenHex(body, 0.20),
corner: darkenHex(body, 0.12),
shadow: darkenHex(body, 0.30),
text: isDark ? lightenHex(body, 0.55) : darkenHex(body, 0.55),
textMuted: isDark ? lightenHex(body, 0.40) : darkenHex(body, 0.40),
};
}
// ---------------------------------------------------------------------------
// Shorthand relative time — "3m", "2h", "5d", "3w", etc.
// ---------------------------------------------------------------------------
function shortTimeAgo(ts: number): string {
const secs = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (secs < 60) return 'now';
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d`;
const weeks = Math.floor(days / 7);
if (weeks < 5) return `${weeks}w`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo`;
return `${Math.floor(days / 365)}y`;
}
// ---------------------------------------------------------------------------
// Proportions — from SendAnimation's calcDims at W=200
// ---------------------------------------------------------------------------
const V_PCT = 65;
const FLAP_Y_PCT = 15;
export function EnvelopeCard({ letter, mode, index, onClick }: EnvelopeCardProps) {
const otherPubkey = mode === 'inbox' ? letter.sender : letter.recipient;
const author = useAuthor(otherPubkey);
const { data: decrypted } = useDecryptLetter(letter);
const displayName = author.data?.metadata?.name || genUserName(otherPubkey);
const avatar = author.data?.metadata?.picture;
const timeStr = shortTimeAgo(letter.timestamp);
const stationery = decrypted?.stationery;
const resolved = useMemo(() => resolveStationery(stationery ?? { color: DEFAULT_STATIONERY_COLOR }), [stationery]);
const bgColor = resolved.color;
const primaryColor = resolved.primaryColor ?? resolved.colors?.[0] ?? bgColor;
const C = useMemo(() => deriveColors(bgColor, primaryColor), [bgColor, primaryColor]);
const flapClip = `polygon(0% 0%, 100% 0%, 100% ${FLAP_Y_PCT}%, 50% ${V_PCT}%, 0% ${FLAP_Y_PCT}%)`;
return (
<button
onClick={onClick}
className="envelope-card group outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 rounded-xl w-full"
style={{ '--entrance-delay': `${index * 60}ms` } as React.CSSProperties}
title={`${mode === 'inbox' ? 'From' : 'To'} ${displayName}`}
>
{/* Envelope — single rounded rect, ~1.59:1 like SendAnimation body */}
<div
className="envelope-body relative w-full overflow-hidden rounded-xl"
style={{
aspectRatio: '200 / 136',
boxShadow: `0 4px 16px ${C.shadow}22, 0 2px 6px ${C.shadow}18`,
}}
>
{/* Layer 0: Body paper — subtle gradient for depth */}
<div
className="absolute inset-0"
style={{
background: `linear-gradient(170deg, ${C.bodyLight} 0%, ${C.body} 45%, ${C.bodyDark} 100%)`,
}}
/>
{/* Layer 1: Closed flap = StationeryBackground clipped to flap shape */}
<div
className="absolute inset-0"
style={{ clipPath: flapClip, zIndex: 1 }}
>
<StationeryBackground
stationery={stationery}
className="w-full h-full"
/>
</div>
{/* Layer 2: V-fold front pocket + crease lines + corner folds + shading */}
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
viewBox="0 0 200 136"
preserveAspectRatio="none"
style={{ zIndex: 2 }}
>
<defs>
<linearGradient id={`vfold-${letter.event.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={C.body} />
<stop offset="100%" stopColor={C.bodyDark} />
</linearGradient>
</defs>
{/* V-fold front pocket */}
<path
d={`M0,${FLAP_Y_PCT * 1.36} L92,${V_PCT * 1.36 - 10} Q100,${V_PCT * 1.36} 108,${V_PCT * 1.36 - 10} L200,${FLAP_Y_PCT * 1.36} L200,128 Q200,136 192,136 L8,136 Q0,136 0,128 Z`}
fill={`url(#vfold-${letter.event.id})`}
/>
{/* V crease line */}
<path
d={`M0,${FLAP_Y_PCT * 1.36} L92,${V_PCT * 1.36 - 10} Q100,${V_PCT * 1.36} 108,${V_PCT * 1.36 - 10} L200,${FLAP_Y_PCT * 1.36}`}
fill="none"
stroke={C.stroke}
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.3"
/>
{/* Corner fold diagonals */}
<path d="M2,134 L70,70" stroke={C.corner} strokeWidth="1.2" strokeLinecap="round" fill="none" opacity="0.4" />
<path d="M198,134 L130,70" stroke={C.corner} strokeWidth="1.2" strokeLinecap="round" fill="none" opacity="0.4" />
{/* Subtle bottom edge shadow */}
<rect x="0" y="132" width="200" height="4" rx="2" fill={C.shadow} opacity="0.06" />
</svg>
{/* Layer 4: Name + time inside the envelope bottom */}
<div
className="absolute left-0 right-0 bottom-0 flex flex-col items-start px-2.5 pb-1.5 pt-0.5"
style={{ zIndex: 3 }}
>
<span
className="flex items-center gap-0.5 text-[9px] font-medium leading-tight"
style={{ color: C.textMuted }}
>
<Clock className="w-2 h-2" />
{timeStr}
</span>
<span
className="text-[11px] font-semibold truncate leading-tight max-w-full"
style={{ color: C.text }}
>
{displayName}
</span>
</div>
{/* Layer 3: Avatar wax seal — positioned above V vertex */}
<div
className="absolute z-10"
style={{
left: '50%',
top: `${V_PCT - 8}%`,
transform: 'translate(-50%, -50%)',
}}
>
<div
className="rounded-full transition-transform duration-200 group-hover:scale-110 overflow-hidden"
style={{
width: 40,
height: 40,
boxShadow: `0 3px 10px ${C.shadow}77, 0 1px 3px ${C.shadow}44, inset 0 1.5px 3px rgba(255,255,255,0.3)`,
border: `3px solid ${darkenHex(primaryColor, 0.18)}`,
background: `
radial-gradient(ellipse at 35% 30%, rgba(255,255,255,0.22) 0%, transparent 50%),
radial-gradient(ellipse at 65% 70%, rgba(0,0,0,0.12) 0%, transparent 50%),
radial-gradient(circle at 50% 50%, ${primaryColor}, ${darkenHex(primaryColor, 0.18)})
`,
}}
>
{avatar ? (
<img src={avatar} alt="" className="w-full h-full object-cover" loading="lazy" />
) : (
<div className="w-full h-full flex items-center justify-center">
<img
src="/logo.svg"
alt=""
style={{ width: 22, height: 22, filter: 'brightness(0) invert(1) opacity(0.85)' }}
/>
</div>
)}
</div>
</div>
</div>
</button>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { FRAME_PRESETS, type FrameStyle } from '@/lib/letterTypes';
import { NoneFramePreview, EmojiFramePreview } from './FramePreviews';
import { Switch } from '@/components/ui/switch';
interface FramePickerProps {
frame: FrameStyle;
frameTint: boolean;
onFrameSelect: (frame: FrameStyle) => void;
onFrameTintChange: (tint: boolean) => void;
}
export function FramePicker({ frame, frameTint, onFrameSelect, onFrameTintChange }: FramePickerProps) {
const hasFrame = frame !== 'none';
return (
<div className="space-y-3">
<div className="grid grid-cols-5 gap-2">
{FRAME_PRESETS.map((fp) => {
const isSelected = frame === fp.id;
return (
<button
key={fp.id}
onClick={() => onFrameSelect(fp.id)}
title={fp.name}
className={`aspect-square rounded-2xl overflow-hidden transition-all hover:scale-105 active:scale-95 ${
isSelected ? 'scale-105 shadow-md' : 'opacity-70 hover:opacity-100'
}`}
>
{fp.id === 'none' ? (
<NoneFramePreview />
) : (
<EmojiFramePreview frameId={fp.id} />
)}
</button>
);
})}
</div>
{hasFrame && (
<div className="flex items-center justify-between px-1 pt-1">
<span className="text-sm text-muted-foreground font-medium">match stationery color</span>
<Switch checked={frameTint} onCheckedChange={onFrameTintChange} />
</div>
)}
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { FRAME_PRESETS, type FrameStyle } from '@/lib/letterTypes';
const BG = '#f5e6d3';
export function NoneFramePreview() {
return (
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" className="w-full h-full">
<rect x="10" y="10" width="44" height="44" fill={BG} rx="4" />
{[20,28,36,44].map(y => <line key={y} x1="14" y1={y} x2="50" y2={y} stroke="#c4a88240" strokeWidth="0.8" />)}
<line x1="22" y1="32" x2="42" y2="32" stroke="#c4a882" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export function EmojiFramePreview({ frameId }: { frameId: FrameStyle }) {
const preset = FRAME_PRESETS.find(f => f.id === frameId);
if (!preset?.emojis || !preset.bgColor) return null;
const positions = [
{x:5,y:6},{x:15,y:4},{x:25,y:7},{x:35,y:4},{x:45,y:6},{x:55,y:5},
{x:5,y:58},{x:15,y:60},{x:25,y:57},{x:35,y:60},{x:45,y:58},{x:55,y:59},
{x:4,y:18},{x:6,y:30},{x:4,y:42},{x:6,y:52},
{x:58,y:18},{x:60,y:30},{x:58,y:42},{x:60,y:52},
{x:8,y:10},{x:56,y:10},{x:8,y:54},{x:56,y:54},
];
return (
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" className="w-full h-full">
<rect x="0" y="0" width="64" height="64" fill={preset.bgColor} rx="8" />
{positions.map((p, i) => (
<text key={i} x={p.x} y={p.y} fontSize="8" textAnchor="middle" dominantBaseline="central">
{preset.emojis![i % preset.emojis!.length]}
</text>
))}
<rect x="10" y="10" width="44" height="44" fill={BG} rx="4" />
{[20,28,36,44].map(y => <line key={y} x1="14" y1={y} x2="50" y2={y} stroke="#c4a88240" strokeWidth="0.8" />)}
</svg>
);
}
+328
View File
@@ -0,0 +1,328 @@
import { useState, useRef, useEffect, useLayoutEffect } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Mail, MailOpen, Loader2, Lock, MoreHorizontal, Link2, Trash2, Braces } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useDecryptLetter } from '@/hooks/useLetters';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { FONT_OPTIONS, LETTER_KIND, LINE_HEIGHT_RATIO, type Letter } from '@/lib/letterTypes';
import { ensureLetterFonts } from '@/lib/letterUtils';
import { StationeryBackground } from './StationeryBackground';
import { useStationeryColors } from '@/hooks/useStationeryColors';
import { LetterStickers } from './LetterStickers';
import { formatDistanceToNow } from 'date-fns';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface LetterCardProps {
letter: Letter;
mode: 'inbox' | 'sent';
}
export function LetterCard({ letter, mode }: LetterCardProps) {
const [isOpen, setIsOpen] = useState(false);
const [hasOpened, setHasOpened] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const letterRef = useRef<HTMLDivElement>(null);
const [lineHeightPx, setLineHeightPx] = useState(0);
const otherPubkey = mode === 'inbox' ? letter.sender : letter.recipient;
const author = useAuthor(otherPubkey);
const { data: decrypted, isLoading: isDecrypting } = useDecryptLetter(letter);
const content = decrypted?.content;
const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const displayName = author.data?.metadata?.name || genUserName(otherPubkey);
const avatar = author.data?.metadata?.picture;
const npub = nip19.npubEncode(otherPubkey);
const noteId = nip19.noteEncode(letter.event.id);
const letterUrl = `${window.location.origin}/${noteId}`;
const isOwnLetter = user?.pubkey === letter.sender;
const handleCopyLink = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(letterUrl);
toast({ description: 'Link copied to clipboard.' });
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
const removeFn = (old: Letter[] | undefined) =>
old ? old.filter((l) => l.event.id !== letter.event.id) : [];
queryClient.setQueriesData<Letter[]>({ queryKey: ['letters-inbox'] }, removeFn);
queryClient.setQueriesData<Letter[]>({ queryKey: ['letters-sent'] }, removeFn);
publishEvent(
{
kind: 5,
content: '',
tags: [
['e', letter.event.id],
['k', String(LETTER_KIND)],
],
},
{
onError: () => {
queryClient.invalidateQueries({ queryKey: ['letters-inbox'] });
queryClient.invalidateQueries({ queryKey: ['letters-sent'] });
},
}
);
};
const effectiveStationery = decrypted?.stationery;
const effectiveFrame = effectiveStationery?.frame;
const effectiveFrameTint = effectiveStationery?.frameTint;
const timeAgo = formatDistanceToNow(new Date(letter.timestamp * 1000), { addSuffix: true });
const { text: textColor, faint: faintColor, line: lineColor } = useStationeryColors(effectiveStationery);
const rawFont = effectiveStationery?.fontFamily;
const letterFontFamily = rawFont
? (rawFont.includes(',') ? rawFont : `${rawFont}, ${FONT_OPTIONS[0].family}`)
: FONT_OPTIONS[0].family;
// Lazy-load the letter's font when decrypted content is available
useLayoutEffect(() => { ensureLetterFonts(letterFontFamily); }, [letterFontFamily]);
useEffect(() => {
const el = letterRef.current;
if (!el) return;
let raf: number;
const ro = new ResizeObserver(([entry]) => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
setLineHeightPx(Math.round(w * LINE_HEIGHT_RATIO));
});
});
ro.observe(el);
return () => { ro.disconnect(); cancelAnimationFrame(raf); };
}, [hasOpened]);
return (
<div>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this letter?</AlertDialogTitle>
<AlertDialogDescription>
This publishes a deletion request. It may not be removed from all relays.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Envelope card */}
<div className="relative">
<button
onClick={() => { setIsOpen(o => !o); setHasOpened(true); }}
className={`
w-full text-left group
rounded-3xl overflow-hidden shadow-sm transition-all duration-200
${isOpen
? 'shadow-md ring-1 ring-primary/20'
: 'hover:shadow-md hover:ring-1 hover:ring-primary/10'
}
`}
>
<StationeryBackground
stationery={effectiveStationery}
className="h-16 w-full relative"
>
<div className="absolute inset-x-0 bottom-0 h-4">
<svg viewBox="0 0 100 12" preserveAspectRatio="none" className="w-full h-full">
<path d="M0 12 L50 1 L100 12 Z" fill="hsl(var(--card))" />
</svg>
</div>
</StationeryBackground>
<div className="bg-card px-4 pb-5 pt-1">
<div className="flex items-center gap-3">
{avatar ? (
<img src={avatar} alt="" className="w-9 h-9 rounded-full object-cover ring-2 ring-background shrink-0" />
) : (
<div className="w-9 h-9 rounded-full bg-secondary flex items-center justify-center text-sm font-semibold text-secondary-foreground shrink-0">
{displayName.charAt(0).toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0 leading-none pt-1">
<div className="truncate leading-none">
<span className="text-sm font-normal text-muted-foreground">{mode === 'inbox' ? 'from ' : 'to '}</span>
<Link
to={`/${npub}`}
onClick={(e) => e.stopPropagation()}
className="text-lg font-semibold text-foreground hover:text-primary transition-colors"
>{displayName}</Link>
</div>
<span className="text-xs text-muted-foreground leading-none mt-0.5 block">{timeAgo}</span>
</div>
{isOpen ? (
<MailOpen className="w-8 h-8 text-primary shrink-0 translate-y-0.5" strokeWidth={2} />
) : (
<Mail className="w-8 h-8 text-muted-foreground shrink-0 translate-y-0.5" strokeWidth={2} />
)}
</div>
</div>
</button>
{/* Menu — top right of card, only when open */}
{isOpen && (
<div className="absolute top-2 right-2 z-20">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="p-1 rounded-full hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<MoreHorizontal className="w-5 h-5" strokeWidth={2.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleCopyLink}>
<Link2 className="w-4 h-4 mr-2" />
Copy link
</DropdownMenuItem>
{content && (
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(JSON.stringify(content, null, 2));
toast({ description: 'Decrypted JSON copied.' });
}}>
<Braces className="w-4 h-4 mr-2" />
Copy decrypted JSON
</DropdownMenuItem>
)}
{isOwnLetter && (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => { e.stopPropagation(); setConfirmDelete(true); }}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
{/* Letter — expands below the card */}
<div
style={{
overflow: 'hidden',
maxHeight: isOpen ? '800px' : '0',
transition: 'max-height 0.3s ease-in-out',
}}
aria-hidden={!isOpen}
>
<div style={{ padding: '8px 0 8px' }}>
{hasOpened && (
<div style={effectiveFrame && effectiveFrame !== 'none'
? { padding: '28px 28px 44px' }
: { padding: '0 0 0' }
}>
<div ref={letterRef} className="relative" style={{ containerType: 'inline-size' }}>
<StationeryBackground
stationery={effectiveStationery}
frame={effectiveFrame}
frameTint={effectiveFrameTint}
className="rounded-3xl shadow-inner shadow-black/5"
>
<div
className="relative z-10 flex flex-col"
style={{ aspectRatio: '5 / 4', padding: '5cqw' }}
>
{isDecrypting ? (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-2.5" style={{ color: faintColor }}>
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-xs">unsealing...</span>
</div>
</div>
) : content ? (
<>
<p
className="whitespace-pre-wrap font-semibold tracking-wide overflow-hidden flex-1 min-h-0"
style={{
fontSize: '4.8cqw',
lineHeight: lineHeightPx > 0 ? `${lineHeightPx}px` : '8.4cqw',
letterSpacing: '0.06em',
paddingTop: '0.5cqw',
fontFamily: letterFontFamily,
color: textColor,
...(lineHeightPx > 0 ? {
backgroundImage: `linear-gradient(to bottom, transparent ${lineHeightPx - 3}px, ${lineColor} ${lineHeightPx - 3}px)`,
backgroundSize: `100% ${lineHeightPx}px`,
backgroundRepeat: 'repeat-y',
maxHeight: `${lineHeightPx * 5}px`,
} : {}),
backgroundPosition: '0 0',
}}
>
{content.body}
</p>
{(content.closing || content.signature) && (
<div className="flex flex-col items-end" style={{ paddingTop: '6cqw', gap: '3cqw', paddingRight: '4cqw', fontFamily: letterFontFamily }}>
{content.closing && (
<p style={{ fontSize: '4.8cqw', color: textColor }}>{content.closing}</p>
)}
{content.signature && (
<p className="font-semibold" style={{ fontSize: '5cqw', color: textColor }}>{content.signature}</p>
)}
</div>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-2" style={{ color: faintColor }}>
<Lock className="w-5 h-5" />
<p className="text-xs italic">couldn't unseal this one</p>
</div>
</div>
)}
</div>
</StationeryBackground>
{content?.stickers && content.stickers.length > 0 && (
<LetterStickers stickers={content.stickers} />
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
+318
View File
@@ -0,0 +1,318 @@
/**
* LetterDetailSheet — Minimal modal showing just the letter card.
* Tap backdrop to dismiss.
*/
import { useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react';
import { Loader2, Lock } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useDecryptLetter } from '@/hooks/useLetters';
import { FONT_OPTIONS, LINE_HEIGHT_RATIO, COLOR_MOMENT_KIND, THEME_KIND, resolveStationery, colorMomentToStationery, themeToStationery, type Letter } from '@/lib/letterTypes';
import { hexLuminance, backgroundTextColor } from '@/lib/colorUtils';
import { ColorPaletteDisplay, type PaletteLayout } from './ColorPaletteDisplay';
import { ensureLetterFonts } from '@/lib/letterUtils';
import { StationeryBackground } from './StationeryBackground';
import { useStationeryColors } from '@/hooks/useStationeryColors';
import { LetterStickers } from './LetterStickers';
import { useTheme } from '@/hooks/useTheme';
import { toast } from '@/hooks/useToast';
import { paletteToTheme, getColors } from '@/components/ColorMomentContent';
import { parseThemeDefinition } from '@/lib/themeEvent';
import type { ThemeConfig } from '@/themes';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
// ---------------------------------------------------------------------------
// Attached gift — color moment or theme that can be applied on the spot
// ---------------------------------------------------------------------------
/** Renders an attached gift — present box overlapping a themed bubble. */
function LetterAttachment({ event }: { event: NostrEvent }) {
const { applyCustomTheme, theme, customTheme, setTheme } = useTheme();
const [applied, setApplied] = useState(false);
const prevRef = useRef<{ mode: typeof theme; config?: ThemeConfig }>();
const attachment = useMemo(() => {
if (event.kind === COLOR_MOMENT_KIND) {
const colors = getColors(event.tags);
if (colors.length < 2) return null;
const core = paletteToTheme(colors);
const stationery = colorMomentToStationery(event);
const resolved = resolveStationery(stationery);
return { type: 'color-moment' as const, label: 'Color Moment', colors, core, resolved };
}
if (event.kind === THEME_KIND) {
const parsed = parseThemeDefinition(event);
if (!parsed) return null;
const stationery = themeToStationery(event);
const resolved = resolveStationery(stationery);
return {
type: 'theme' as const,
label: parsed.title ?? 'Theme',
colors: [] as string[],
core: parsed.colors,
resolved,
themeConfig: { colors: parsed.colors, font: parsed.font, titleFont: parsed.titleFont, background: parsed.background, title: parsed.title } as ThemeConfig,
};
}
return null;
}, [event]);
const handleApply = useCallback(() => {
if (!attachment) return;
prevRef.current = { mode: theme, config: customTheme };
if (attachment.type === 'color-moment') {
applyCustomTheme(attachment.core);
} else {
applyCustomTheme(attachment.themeConfig!);
}
setApplied(true);
toast({
title: 'Theme applied',
description: `"${attachment.label}" is now your active theme.`,
action: (
<button
className="text-sm font-medium underline underline-offset-2"
onClick={() => {
const prev = prevRef.current;
if (!prev) return;
if (prev.mode === 'custom' && prev.config) applyCustomTheme(prev.config);
else setTheme(prev.mode);
setApplied(false);
}}
>
Undo
</button>
),
});
}, [attachment, theme, customTheme, applyCustomTheme, setTheme]);
if (!attachment) return null;
const { resolved } = attachment;
const bg = resolved.color;
const primary = `hsl(${attachment.core.primary})`;
// Use a visible border when the bg is very light to avoid white-on-white
const needsBorder = hexLuminance(bg) > 0.85;
const ribbonColor = attachment.type === 'color-moment'
? (attachment.colors[Math.floor(attachment.colors.length / 2)] ?? primary)
: primary;
// Derive readable text color from the scrimmed background
const textColor = backgroundTextColor(bg);
return (
<div className="relative max-w-[220px] mx-auto mt-16 pointer-events-none">
{/* Present box — overlaps the bubble top */}
<div className="flex justify-center relative z-10 mb-[-20px]">
<svg width="56" height="60" viewBox="0 0 56 60" fill="none" className="drop-shadow-sm">
{/* Box body */}
<rect x="4" y="26" width="48" height="32" rx="3" fill={bg} stroke={needsBorder ? '#0001' : 'none'} strokeWidth="1" />
<rect x="4" y="26" width="48" height="8" rx="3" fill="white" opacity="0.07" />
{/* Ribbon vertical */}
<rect x="24" y="26" width="8" height="32" fill={ribbonColor} opacity="0.8" />
{/* Ribbon horizontal */}
<rect x="4" y="38" width="48" height="6" fill={ribbonColor} opacity="0.8" />
{/* Lid */}
<rect x="2" y="18" width="52" height="10" rx="2.5" fill={bg} stroke={needsBorder ? '#0001' : ribbonColor} strokeWidth={needsBorder ? 1 : 0.5} strokeOpacity={needsBorder ? 1 : 0.2} />
<rect x="2" y="18" width="52" height="4" rx="2.5" fill="white" opacity="0.1" />
{/* Lid ribbon */}
<rect x="24" y="18" width="8" height="10" fill={ribbonColor} opacity="0.8" />
{/* Bow — left loop */}
<ellipse cx="20" cy="14" rx="8" ry="6" fill={ribbonColor} />
<ellipse cx="19.5" cy="12.5" rx="4.5" ry="3" fill="white" opacity="0.2" />
{/* Bow — right loop */}
<ellipse cx="36" cy="14" rx="8" ry="6" fill={ribbonColor} />
<ellipse cx="36.5" cy="12.5" rx="4.5" ry="3" fill="white" opacity="0.2" />
{/* Bow — knot */}
<ellipse cx="28" cy="16" rx="5" ry="4" fill={ribbonColor} />
<ellipse cx="28" cy="15" rx="2.5" ry="2" fill="white" opacity="0.15" />
</svg>
</div>
{/* Themed bubble — clickable */}
<div
onClick={handleApply}
role="button"
tabIndex={0}
title={applied ? 'Theme applied!' : `Tap to use "${attachment.label}" as your theme`}
className="relative rounded-2xl overflow-hidden pointer-events-auto cursor-pointer transition-transform duration-200 active:scale-95 hover:scale-[1.02]"
style={{
background: bg,
border: needsBorder ? '1px solid hsl(var(--border))' : `1px solid ${ribbonColor}22`,
}}
>
{/* Background: actual color moment pattern or theme image */}
{attachment.type === 'color-moment' && attachment.colors.length > 0 && (
<ColorPaletteDisplay
colors={attachment.colors}
layout={(resolved.layout as PaletteLayout) || 'horizontal'}
className="absolute inset-0"
/>
)}
{attachment.type === 'theme' && resolved.imageUrl && (
<div className="absolute inset-0">
<img
src={resolved.imageUrl}
alt=""
className="w-full h-full object-cover"
/>
</div>
)}
{/* Scrim for text readability */}
<div className="absolute inset-0" style={{ background: `${bg}bb` }} />
{/* Content */}
<div className="relative px-4 pt-7 pb-3.5 text-center">
<p className="text-xs font-semibold truncate" style={{ color: textColor }}>
{attachment.label}
</p>
<p className="text-[11px] mt-0.5" style={{ color: textColor, opacity: 0.6 }}>
{applied ? 'Applied as your theme' : 'Tap to use as theme'}
</p>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
interface LetterDetailSheetProps {
letter: Letter | null;
onClose: () => void;
}
export function LetterDetailSheet({ letter, onClose }: LetterDetailSheetProps) {
const letterRef = useRef<HTMLDivElement>(null);
const [lineHeightPx, setLineHeightPx] = useState(0);
const { data: decrypted, isLoading: isDecrypting } = useDecryptLetter(letter ?? undefined);
const content = decrypted?.content;
const effectiveStationery = decrypted?.stationery;
const effectiveFrame = effectiveStationery?.frame;
const effectiveFrameTint = effectiveStationery?.frameTint;
const { text: textColor, faint: faintColor, line: lineColor } = useStationeryColors(effectiveStationery);
const rawFont = effectiveStationery?.fontFamily;
const letterFontFamily = rawFont
? (rawFont.includes(',') ? rawFont : `${rawFont}, ${FONT_OPTIONS[0].family}`)
: FONT_OPTIONS[0].family;
// Lazy-load the letter's font when decrypted content is available
useLayoutEffect(() => { ensureLetterFonts(letterFontFamily); }, [letterFontFamily]);
// ResizeObserver for ruled line height — re-attaches when the dialog opens (letter changes)
useEffect(() => {
if (!letter) return;
// Small delay to let the Dialog portal mount and layout
const timer = setTimeout(() => {
const el = letterRef.current;
if (!el) return;
const w = el.getBoundingClientRect().width;
if (w > 0) setLineHeightPx(Math.round(w * LINE_HEIGHT_RATIO));
}, 50);
const el = letterRef.current;
if (!el) return () => clearTimeout(timer);
let raf: number;
const ro = new ResizeObserver(([entry]) => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
if (w > 0) setLineHeightPx(Math.round(w * LINE_HEIGHT_RATIO));
});
});
ro.observe(el);
return () => { clearTimeout(timer); ro.disconnect(); cancelAnimationFrame(raf); };
}, [letter]);
return (
<Dialog open={!!letter} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="p-0 gap-0 border-none bg-transparent shadow-none max-w-[calc(100vw-2rem)] sm:max-w-lg overflow-visible [&>button]:hidden">
<DialogTitle className="sr-only">Letter</DialogTitle>
<div style={effectiveFrame && effectiveFrame !== 'none'
? { padding: '28px 28px 44px' }
: { padding: '0' }
}>
<div ref={letterRef} className="relative" style={{ containerType: 'inline-size' }}>
<StationeryBackground
stationery={effectiveStationery}
frame={effectiveFrame}
frameTint={effectiveFrameTint}
className="rounded-3xl shadow-inner shadow-black/5"
>
<div
className="relative z-10 flex flex-col"
style={{ aspectRatio: '5 / 4', padding: '5cqw' }}
>
{isDecrypting ? (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-2.5" style={{ color: faintColor }}>
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-xs">unsealing...</span>
</div>
</div>
) : content ? (
<>
<p
className="whitespace-pre-wrap font-semibold tracking-wide overflow-hidden flex-1 min-h-0"
style={{
fontSize: '4.8cqw',
lineHeight: lineHeightPx > 0 ? `${lineHeightPx}px` : '8.4cqw',
letterSpacing: '0.06em',
paddingTop: '0.5cqw',
fontFamily: letterFontFamily,
color: textColor,
...(lineHeightPx > 0 ? {
backgroundImage: `linear-gradient(to bottom, transparent ${lineHeightPx - 3}px, ${lineColor} ${lineHeightPx - 3}px)`,
backgroundSize: `100% ${lineHeightPx}px`,
backgroundRepeat: 'repeat-y',
maxHeight: `${lineHeightPx * 5}px`,
} : {}),
backgroundPosition: '0 0',
}}
>
{content.body}
</p>
{(content.closing || content.signature) && (
<div className="flex flex-col items-end" style={{ paddingTop: '6cqw', gap: '3cqw', paddingRight: '4cqw', fontFamily: letterFontFamily }}>
{content.closing && (
<p style={{ fontSize: '4.8cqw', color: textColor }}>{content.closing}</p>
)}
{content.signature && (
<p className="font-semibold" style={{ fontSize: '5cqw', color: textColor }}>{content.signature}</p>
)}
</div>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-2" style={{ color: faintColor }}>
<Lock className="w-5 h-5" />
<p className="text-xs italic">couldn't unseal this one</p>
</div>
</div>
)}
</div>
</StationeryBackground>
{content?.stickers && content.stickers.length > 0 && (
<LetterStickers stickers={content.stickers} />
)}
</div>
{/* Attached gift — color moment or theme */}
{effectiveStationery?.event && (
<LetterAttachment event={effectiveStationery.event} />
)}
</div>
</DialogContent>
</Dialog>
);
}
+292
View File
@@ -0,0 +1,292 @@
/**
* LetterEditor — drawer and preview card for composing/previewing letters.
*
* Callers own the sticky header. LetterEditor exposes its toolbar buttons
* via the `renderToolbarButtons` render prop so callers can place them
* inline in their own header row alongside back buttons, titles, etc.
*/
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { Paintbrush } from 'lucide-react';
import { TabButton } from '@/components/TabButton';
import {
FONT_OPTIONS,
CLOSING_PRESETS,
LINE_HEIGHT_RATIO,
type Stationery,
type FrameStyle,
} from '@/lib/letterTypes';
import { StationeryBackground } from './StationeryBackground';
import { useStationeryColors } from '@/hooks/useStationeryColors';
import { StationeryPicker } from './StationeryPicker';
import { FramePicker } from './FramePicker';
import { resolveFont } from '@/lib/letterUtils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
// ---------------------------------------------------------------------------
// FrameIcon — shared SVG
// ---------------------------------------------------------------------------
export function FrameIcon({ className, strokeWidth = 2 }: { className?: string; strokeWidth?: number }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className}>
<rect x="2" y="3" width="20" height="18" rx="3" />
<rect x="5.5" y="6.5" width="13" height="11" rx="2" />
<path d="M4 5 C4.5 5.5 5 6 5.5 6.5" />
<path d="M20 5 C19.5 5.5 19 6 18.5 6.5" />
<path d="M4 19 C4.5 18.5 5 18 5.5 17.5" />
<path d="M20 19 C19.5 18.5 19 18 18.5 17.5" />
</svg>
);
}
// ---------------------------------------------------------------------------
// Overlay types — base set shared by all consumers
// ---------------------------------------------------------------------------
export type BaseOverlay = 'none' | 'font' | 'stationery' | 'frame';
// ---------------------------------------------------------------------------
// LetterEditor types
// ---------------------------------------------------------------------------
export interface LetterEditorFont {
value: string;
label: string;
family: string;
}
export interface LetterEditorState {
selectedFont: LetterEditorFont;
setSelectedFont: (f: LetterEditorFont) => void;
stationery: Stationery;
setStationery: (s: Stationery) => void;
frame: FrameStyle;
setFrame: (f: FrameStyle) => void;
frameTint: boolean;
setFrameTint: (v: boolean) => void;
closing: string;
setClosing: (v: string) => void;
signature: string;
setSignature: (v: string) => void;
}
interface LetterEditorProps {
state: LetterEditorState;
/**
* Render prop — receives the toolbar button nodes and the sliding drawer so
* the caller can compose them into their own sticky header+drawer region.
* The drawer is passed separately so callers that want the arc-then-drawer
* pattern can render it outside the SubHeaderBar.
*/
renderToolbarButtons: (buttons: ReactNode, drawer: ReactNode) => ReactNode;
/** Extra buttons appended after the base Aa/paintbrush/frame buttons. */
extraButtons?: ReactNode;
/** Extra drawer panels for overlays beyond 'font'/'stationery'/'frame'. */
extraDrawerContent?: ReactNode;
/** Current overlay — managed externally so callers can add custom overlays. */
overlay: string;
setOverlay: (o: string) => void;
/** Body content rendered inside the card above the outro. */
bodyContent?: (ctx: { lineHeightPx: number; stationeryTextColor: string; stationeryLineColor: string; resolvedFontFamily: string }) => ReactNode;
/** Content rendered on top of the card (e.g. stickers layer). */
cardOverlay?: ReactNode;
/** Content rendered between the drawer and the card (e.g. recipient row). */
beforeCard?: ReactNode;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function LetterEditor({
state,
renderToolbarButtons,
extraButtons,
extraDrawerContent,
overlay,
setOverlay,
bodyContent,
cardOverlay,
beforeCard,
}: LetterEditorProps) {
const {
selectedFont, setSelectedFont,
stationery, setStationery,
frame, setFrame,
frameTint, setFrameTint,
closing, setClosing,
signature, setSignature,
} = state;
const cardRef = useRef<HTMLDivElement>(null);
const [lineHeightPx, setLineHeightPx] = useState(0);
useEffect(() => {
const el = cardRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
setLineHeightPx(Math.round(w * LINE_HEIGHT_RATIO));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const { text: stationeryTextColor, line: stationeryLineColor, fontFamily: themeFont } = useStationeryColors(stationery);
const resolvedFontFamily = resolveFont(selectedFont.family, themeFont);
const isBaseOverlay = (o: string): o is BaseOverlay => ['none', 'font', 'stationery', 'frame'].includes(o);
const drawerOpen = overlay !== 'none';
// The toolbar buttons — passed to renderToolbarButtons so the caller
// can embed them in their own sticky header row.
const toolbarButtons = (
<>
<TabButton
label="Font"
active={overlay === 'font'}
onClick={() => setOverlay(overlay === 'font' ? 'none' : 'font')}
>
<span className="text-base font-bold">Aa</span>
</TabButton>
<TabButton
label="Stationery"
active={overlay === 'stationery'}
onClick={() => setOverlay(overlay === 'stationery' ? 'none' : 'stationery')}
>
<Paintbrush className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
<TabButton
label="Frame"
active={overlay === 'frame'}
onClick={() => setOverlay(overlay === 'frame' ? 'none' : 'frame')}
>
<FrameIcon className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
{extraButtons}
</>
);
const drawer = (
<div
style={{
overflow: 'hidden',
maxHeight: drawerOpen ? '400px' : '0',
transition: 'max-height 0.25s ease-in-out',
}}
>
<div className="max-w-xl mx-auto w-full px-4 pb-5 pt-3">
{overlay === 'font' && (
<div className="flex gap-2 flex-wrap">
{FONT_OPTIONS.map((font) => (
<button
key={font.value}
onClick={() => setSelectedFont(font)}
className={`px-4 py-2.5 rounded-2xl text-base font-medium transition-all ${
selectedFont.value === font.value
? 'bg-primary text-primary-foreground shadow-sm'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
style={{ fontFamily: font.family }}
>
{font.label}
</button>
))}
</div>
)}
{overlay === 'stationery' && (
<StationeryPicker selected={stationery} onSelect={setStationery} />
)}
{overlay === 'frame' && (
<FramePicker
frame={frame}
frameTint={frameTint}
onFrameSelect={setFrame}
onFrameTintChange={setFrameTint}
/>
)}
{!isBaseOverlay(overlay) && extraDrawerContent}
</div>
</div>
);
return (
<>
{/* Caller composes the toolbar buttons and drawer into their own sticky region */}
{renderToolbarButtons(toolbarButtons, drawer)}
{beforeCard}
<div
className="max-w-xl mx-auto w-full"
style={frame !== 'none' ? { padding: '28px 44px 44px' } : { padding: '0 16px 16px' }}
>
<div ref={cardRef} className="relative" style={{ containerType: 'inline-size' }}>
<StationeryBackground
stationery={stationery}
frame={frame}
frameTint={frameTint}
className="rounded-3xl shadow-[0_2px_12px_rgba(0,0,0,0.06)]"
>
<div className="relative z-10 flex flex-col" style={{ aspectRatio: '5 / 4', padding: '5cqw' }}>
{bodyContent?.({ lineHeightPx, stationeryTextColor, stationeryLineColor, resolvedFontFamily })}
<div className="flex flex-col items-end" style={{ paddingTop: '4cqw', gap: '3cqw', paddingRight: '4cqw' }}>
<Select value={closing || '__none__'} onValueChange={(v) => setClosing(v === '__none__' ? '' : v)}>
<SelectTrigger
className="w-auto h-auto focus:ring-0 focus:ring-offset-0 ring-0 ring-offset-0 outline-none rounded-2xl border-0 shadow-none flex-row-reverse gap-3 [&>span]:text-right"
style={{
fontSize: '4cqw',
padding: '2.5cqw 4cqw',
marginRight: '-4cqw',
color: closing ? stationeryTextColor : `${stationeryTextColor}44`,
backgroundColor: parseInt(stationeryTextColor.slice(stationeryTextColor.indexOf('(') + 1), 10) < 128
? 'rgba(0,0,0,0.07)'
: 'rgba(255,255,255,0.18)',
fontFamily: resolvedFontFamily,
}}
>
<SelectValue placeholder="Closing..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
<span className="text-muted-foreground italic">None</span>
</SelectItem>
{CLOSING_PRESETS.map((preset) => (
<SelectItem key={preset} value={preset}>
{preset}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="text"
value={signature}
onChange={(e) => setSignature(e.target.value)}
onFocus={() => setOverlay('none')}
maxLength={50}
placeholder="Your Name"
className="bg-transparent border-none font-semibold text-right focus:outline-none placeholder:opacity-60"
style={{
fontSize: '4.2cqw',
color: stationeryTextColor,
width: '60%',
fontFamily: resolvedFontFamily,
}}
/>
</div>
</div>
</StationeryBackground>
{cardOverlay}
</div>
</div>
</>
);
}
@@ -0,0 +1,204 @@
import { useState, useEffect, useRef, type ReactNode } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { ArrowLeft, Sparkles, RotateCcw } from 'lucide-react';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { useLetterPreferences } from '@/hooks/useLetterPreferences';
import { useThemeStationery } from '@/hooks/useThemeStationery';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import {
FONT_OPTIONS,
type Stationery,
type FrameStyle,
type SerializableStationery,
} from '@/lib/letterTypes';
import { LetterEditor, type BaseOverlay } from './LetterEditor';
/** Strip the non-serializable `event` field before persisting */
function toSerializable(s: Stationery): SerializableStationery {
const { event: _, ...rest } = s;
return rest;
}
export function LetterPreferencesSection() {
const { user } = useCurrentUser();
const navigate = useNavigate();
const { prefs, updatePrefs, resetStationery, isThemeDefault } = useLetterPreferences();
const themeStationery = useThemeStationery();
// Track whether any user-driven change has happened so we don't persist on mount
const mountedRef = useRef(false);
const [closing, setClosing] = useState(() => prefs.closing ?? 'Warmly,');
const [signature, setSignature] = useState(() => prefs.signature ?? '');
const [selectedFont, setSelectedFont] = useState(
() => FONT_OPTIONS.find((f) => f.value === prefs.font) ?? FONT_OPTIONS[0],
);
// When isThemeDefault, use the live theme stationery directly (not from prefs).
// When a custom stationery is saved, use that.
const [stationery, setStationery] = useState<Stationery>(
() => isThemeDefault ? themeStationery : (prefs.stationery as Stationery ?? themeStationery),
);
const [frame, setFrame] = useState<FrameStyle>(() => prefs.frame ?? 'none');
const [frameTint, setFrameTint] = useState(() => prefs.frameTint ?? false);
const [friendsOnlyInbox, setFriendsOnlyInbox] = useState(() => prefs.friendsOnlyInbox ?? false);
const [friendsOnlySearch, setFriendsOnlySearch] = useState(() => prefs.friendsOnlySearch ?? false);
const [overlay, setOverlay] = useState<BaseOverlay>('none');
// Keep preview in sync with the live theme when no custom stationery is saved
useEffect(() => {
if (isThemeDefault) {
setStationery(themeStationery);
}
}, [isThemeDefault, themeStationery]);
// Persist non-stationery prefs on change (skip mount).
// `updatePrefs` is intentionally omitted — its identity changes on every settings
// update (because it closes over `settings`), which would cause an infinite loop:
// effect → updatePrefs → settings change → new updatePrefs → effect.
// `user` is omitted because the guard (`!user`) short-circuits if absent and
// the component already renders a login prompt when user is null.
const updatePrefsRef = useRef(updatePrefs);
updatePrefsRef.current = updatePrefs;
useEffect(() => {
if (!mountedRef.current || !user) return;
updatePrefsRef.current({ font: selectedFont.value, frame, frameTint, closing, signature, friendsOnlyInbox, friendsOnlySearch });
}, [selectedFont, frame, frameTint, closing, signature, friendsOnlyInbox, friendsOnlySearch, user]);
// Mark as mounted
useEffect(() => { mountedRef.current = true; }, []);
// When the user picks stationery, persist it (not called on theme-sync updates
// because those go through setStationery directly, not this handler)
const handleSetStationery = (s: Stationery) => {
setStationery(s);
if (!user) return;
updatePrefs({ stationery: toSerializable(s) });
};
if (!user) {
return (
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
Log in to set letter preferences.
</div>
);
}
return (
<div className="pb-8">
<LetterEditor
state={{
selectedFont, setSelectedFont,
stationery, setStationery: handleSetStationery,
frame, setFrame,
frameTint, setFrameTint,
closing, setClosing,
signature, setSignature,
}}
overlay={overlay}
setOverlay={(o) => setOverlay(o as BaseOverlay)}
renderToolbarButtons={(buttons: ReactNode, drawer: ReactNode) => (
<div className="sticky top-0 z-50">
<div className="flex items-center gap-4 px-4 mt-4 mb-1">
<button
onClick={() => navigate('/letters')}
className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors"
>
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1 truncate">Letter Preferences</h1>
</div>
{drawer}
<SubHeaderBar className="relative">
{buttons}
</SubHeaderBar>
</div>
)}
beforeCard={
<div className="pt-4 max-w-xl mx-auto w-full px-5">
{isThemeDefault ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-2xl bg-primary/8 border border-primary/20 text-sm mb-3">
<Sparkles className="w-4 h-4 text-primary shrink-0" />
<span className="text-muted-foreground flex-1">
Using your{' '}
<Link to="/settings" className="text-primary font-medium hover:underline">
Ditto theme
</Link>
{' '}as stationery
</span>
</div>
) : (
<div className="flex items-center justify-between gap-2 px-3 py-2 rounded-2xl bg-muted/60 border border-border text-sm mb-3">
<span className="text-muted-foreground">Custom stationery saved</span>
<Button
variant="ghost"
size="sm"
onClick={resetStationery}
className="h-7 px-2 text-xs gap-1"
>
<RotateCcw className="w-3 h-3" />
Reset to theme
</Button>
</div>
)}
</div>
}
bodyContent={({ lineHeightPx, stationeryTextColor, stationeryLineColor, resolvedFontFamily }) => (
<div
className="flex-1 min-h-0"
style={{
...(lineHeightPx > 0 ? {
backgroundImage: `linear-gradient(to bottom, transparent ${lineHeightPx - 3}px, ${stationeryLineColor} ${lineHeightPx - 3}px)`,
backgroundSize: `100% ${lineHeightPx}px`,
backgroundRepeat: 'repeat-y',
} : {}),
}}
>
<p
className="font-semibold tracking-wide opacity-40 pointer-events-none select-none"
style={{
fontSize: '3.6cqw',
lineHeight: lineHeightPx > 0 ? `${lineHeightPx}px` : '8.4cqw',
letterSpacing: '0.04em',
fontFamily: resolvedFontFamily,
color: stationeryTextColor,
}}
>
Pick a font, stationery, and frame above. Choose a closing and sign your name below.
</p>
</div>
)}
/>
<div className="max-w-xl mx-auto w-full px-5 pt-4 space-y-8">
<p className="text-sm text-muted-foreground text-center">
These defaults apply when you start a new letter. You can always change them while composing.
</p>
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">inbox</h3>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Friends only</p>
<p className="text-xs text-muted-foreground">Only show letters from people you follow</p>
</div>
<Switch checked={friendsOnlyInbox} onCheckedChange={setFriendsOnlyInbox} />
</div>
</div>
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">compose</h3>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Friends only</p>
<p className="text-xs text-muted-foreground">Only suggest friends when choosing a recipient</p>
</div>
<Switch checked={friendsOnlySearch} onCheckedChange={setFriendsOnlySearch} />
</div>
</div>
</div>
</div>
);
}
+318
View File
@@ -0,0 +1,318 @@
/**
* LetterStickers
*
* Renders stickers positioned on top of a letter card. Two modes:
*
* editable=true — tap a sticker to select it, revealing controls
* editable=false — stickers are rendered at their saved positions (read-only).
*/
import { useRef, useCallback, useState, useEffect } from 'react';
import { X, RotateCw, Maximize2 } from 'lucide-react';
import type { LetterSticker } from '@/lib/letterTypes';
import { sanitizeSvg } from '@/lib/sanitizeSvg';
const MIN_SCALE = 0.5;
const MAX_SCALE = 4;
const BASE_SIZE_CQW = 14;
function isSafeUrl(url: string): boolean {
try {
return new URL(url).protocol === 'https:';
} catch {
return false;
}
}
function StickerMedia({ sticker, sizeCqw, className }: { sticker: LetterSticker; sizeCqw: string; className?: string }) {
if (sticker.svg) {
return (
<div
style={{ width: sizeCqw, height: sizeCqw }}
className={`sticker-svg-wrap ${className ?? ''}`}
dangerouslySetInnerHTML={{ __html: sanitizeSvg(sticker.svg) }}
/>
);
}
if (!isSafeUrl(sticker.url)) return null;
return (
<img
src={sticker.url}
alt={sticker.shortcode}
style={{ width: sizeCqw, height: sizeCqw }}
className={className}
draggable={false}
/>
);
}
function StaticSticker({ sticker }: { sticker: LetterSticker }) {
const s = sticker.scale ?? 1;
const sizeCqw = `${BASE_SIZE_CQW * s}cqw`;
return (
<div
className="absolute pointer-events-none select-none"
style={{
left: `${sticker.x}%`,
top: `${sticker.y}%`,
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
zIndex: 30,
}}
>
<StickerMedia sticker={sticker} sizeCqw={sizeCqw} className="object-contain drop-shadow-md" />
</div>
);
}
interface EditableStickerProps {
sticker: LetterSticker;
index: number;
selected: boolean;
onSelect: (index: number) => void;
onUpdate: (index: number, patch: Partial<LetterSticker>) => void;
onRemove: (index: number) => void;
containerRef: React.RefObject<HTMLDivElement | null>;
}
function EditableSticker({
sticker,
index,
selected,
onSelect,
onUpdate,
onRemove,
containerRef,
}: EditableStickerProps) {
const s = sticker.scale ?? 1;
const sizeCqw = `${BASE_SIZE_CQW * s}cqw`;
const dragging = useRef(false);
const hasMoved = useRef(false);
const [isDragging, setIsDragging] = useState(false);
const toPercent = useCallback((clientX: number, clientY: number) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return { x: sticker.x, y: sticker.y };
return {
x: Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)),
y: Math.max(0, Math.min(100, ((clientY - rect.top) / rect.height) * 100)),
};
}, [containerRef, sticker.x, sticker.y]);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
if (!selected) {
onSelect(index);
return;
}
dragging.current = true;
hasMoved.current = false;
setIsDragging(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [selected, index, onSelect]);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!dragging.current) return;
e.preventDefault();
hasMoved.current = true;
const { x, y } = toPercent(e.clientX, e.clientY);
onUpdate(index, { x, y });
}, [index, onUpdate, toPercent]);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (!dragging.current) return;
dragging.current = false;
setIsDragging(false);
try { (e.target as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* */ }
}, []);
const rotateRef = useRef<{ startAngle: number; startRotation: number } | null>(null);
const centerOfSticker = useCallback(() => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return { cx: 0, cy: 0 };
return {
cx: rect.left + (sticker.x / 100) * rect.width,
cy: rect.top + (sticker.y / 100) * rect.height,
};
}, [containerRef, sticker.x, sticker.y]);
const handleRotateDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
const { cx, cy } = centerOfSticker();
const startAngle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI);
rotateRef.current = { startAngle, startRotation: sticker.rotation };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [centerOfSticker, sticker.rotation]);
const handleRotateMove = useCallback((e: React.PointerEvent) => {
if (!rotateRef.current) return;
e.preventDefault();
const { cx, cy } = centerOfSticker();
const currentAngle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI);
const delta = currentAngle - rotateRef.current.startAngle;
let newRotation = rotateRef.current.startRotation + delta;
while (newRotation > 180) newRotation -= 360;
while (newRotation < -180) newRotation += 360;
onUpdate(index, { rotation: Math.round(newRotation) });
}, [centerOfSticker, index, onUpdate]);
const handleRotateUp = useCallback((e: React.PointerEvent) => {
rotateRef.current = null;
try { (e.target as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* */ }
}, []);
const resizeRef = useRef<{ startDist: number; startScale: number } | null>(null);
const handleResizeDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
const { cx, cy } = centerOfSticker();
const dist = Math.hypot(e.clientX - cx, e.clientY - cy);
resizeRef.current = { startDist: dist, startScale: s };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [centerOfSticker, s]);
const handleResizeMove = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
e.preventDefault();
const { cx, cy } = centerOfSticker();
const dist = Math.hypot(e.clientX - cx, e.clientY - cy);
const ratio = dist / resizeRef.current.startDist;
const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, resizeRef.current.startScale * ratio));
onUpdate(index, { scale: Math.round(newScale * 100) / 100 });
}, [centerOfSticker, index, onUpdate]);
const handleResizeUp = useCallback((e: React.PointerEvent) => {
resizeRef.current = null;
try { (e.target as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* */ }
}, []);
return (
<div
className={`absolute select-none ${isDragging ? 'cursor-grabbing' : selected ? 'cursor-grab' : 'cursor-pointer'}`}
style={{
left: `${sticker.x}%`,
top: `${sticker.y}%`,
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
zIndex: selected ? 50 : 30,
transition: isDragging ? 'none' : 'filter 0.15s ease-out',
touchAction: 'none',
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{selected && (
<div
className="absolute border-2 border-primary border-dashed rounded-lg pointer-events-none"
style={{ inset: -8 }}
/>
)}
<StickerMedia sticker={sticker} sizeCqw={sizeCqw} className="object-contain drop-shadow-lg" />
{selected && (
<>
<button
type="button"
onPointerDown={(e) => { e.stopPropagation(); e.preventDefault(); onRemove(index); }}
className="absolute -top-3 -right-3 w-6 h-6 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center shadow-md active:scale-90 transition-transform"
style={{ transform: `rotate(${-sticker.rotation}deg)` }}
>
<X className="w-3.5 h-3.5" strokeWidth={3} />
</button>
<div
className="absolute -bottom-3 -right-3 w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow-md cursor-alias active:scale-90 transition-transform"
style={{ transform: `rotate(${-sticker.rotation}deg)`, touchAction: 'none' }}
onPointerDown={handleRotateDown}
onPointerMove={handleRotateMove}
onPointerUp={handleRotateUp}
>
<RotateCw className="w-3.5 h-3.5" strokeWidth={2.5} />
</div>
<div
className="absolute -bottom-3 -left-3 w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow-md cursor-nwse-resize active:scale-90 transition-transform"
style={{ transform: `rotate(${-sticker.rotation}deg)`, touchAction: 'none' }}
onPointerDown={handleResizeDown}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeUp}
>
<Maximize2 className="w-3.5 h-3.5" strokeWidth={2.5} />
</div>
</>
)}
</div>
);
}
interface LetterStickersProps {
stickers: LetterSticker[];
editable?: boolean;
onUpdate?: (index: number, patch: Partial<LetterSticker>) => void;
onRemove?: (index: number) => void;
containerRef?: React.RefObject<HTMLDivElement | null>;
}
export function LetterStickers({
stickers,
editable = false,
onUpdate,
onRemove,
containerRef,
}: LetterStickersProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
useEffect(() => {
if (!editable || selectedIndex === null) return;
const container = containerRef?.current;
if (!container) return;
const handleDown = (e: PointerEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-sticker]')) return;
setSelectedIndex(null);
};
container.addEventListener('pointerdown', handleDown);
return () => container.removeEventListener('pointerdown', handleDown);
}, [editable, selectedIndex, containerRef]);
useEffect(() => {
if (selectedIndex !== null && selectedIndex >= stickers.length) {
setSelectedIndex(null);
}
}, [stickers.length, selectedIndex]);
if (stickers.length === 0) return null;
return (
<>
{stickers.map((sticker, i) =>
editable && onUpdate && onRemove && containerRef ? (
<div key={`${sticker.shortcode}-${i}`} data-sticker>
<EditableSticker
sticker={sticker}
index={i}
selected={selectedIndex === i}
onSelect={setSelectedIndex}
onUpdate={onUpdate}
onRemove={(idx) => {
onRemove(idx);
setSelectedIndex(null);
}}
containerRef={containerRef}
/>
</div>
) : (
<StaticSticker key={`${sticker.shortcode}-${i}`} sticker={sticker} />
),
)}
</>
);
}
+524
View File
@@ -0,0 +1,524 @@
/**
* SendAnimation
*
* Full-screen overlay: letter slides into envelope, flap closes, wax seal
* stamps with the Ditto logo, floats away, then shows "Sent a letter to <name>!"
*
* Envelope colors are derived from the letter's stationery (theme colors).
* The wax seal uses the stationery's primary color and the Ditto logo.
*/
import { useId, useRef, useEffect, useLayoutEffect, useCallback, useState, useMemo } from 'react';
import { hexToRgb, rgbToHex, darkenHex, blendHex } from '@/lib/colorUtils';
// ---------------------------------------------------------------------------
// Easing + animation driver
// ---------------------------------------------------------------------------
const ease = {
inOutCubic: (t: number) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
outQuart: (t: number) => 1 - Math.pow(1 - t, 4),
outQuint: (t: number) => 1 - Math.pow(1 - t, 5),
inQuad: (t: number) => t * t,
};
function animateVal(
ms: number, fn: (t: number) => void, done: () => void, e: (t: number) => number,
): () => void {
let id = 0;
const s = performance.now();
const tick = (now: number) => {
const raw = Math.min(1, (now - s) / ms);
fn(e(raw));
if (raw < 1) id = requestAnimationFrame(tick); else done();
};
id = requestAnimationFrame(tick);
return () => cancelAnimationFrame(id);
}
// ---------------------------------------------------------------------------
// Envelope dimensions — responsive, using vw-based sizing
// ---------------------------------------------------------------------------
export function useEnvelopeDimensions() {
const [dims, setDims] = useState(() => calcDims(window.innerWidth));
useEffect(() => {
const onResize = () => setDims(calcDims(window.innerWidth));
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return dims;
}
function calcDims(vw: number) {
const envW = Math.min(Math.round(vw * 0.85), 420);
const envH = Math.round(envW / 1.588);
const r = Math.round(envW * 0.041);
const s = envW / 54;
const flapY = Math.round(envH * 0.147);
const vY = Math.round(envH * 0.647);
const flapTriH = Math.round(vY * 1.08);
const letterW = Math.round(envW * 0.82);
const letterH = letterW / (5 / 4);
const strokeV = Math.round(s * 1.6 * 10) / 10;
const strokeCorner = Math.round(s * 1.4 * 10) / 10;
return { envW, envH, r, flapY, vY, flapTriH, letterW, letterH, strokeV, strokeCorner };
}
// ---------------------------------------------------------------------------
// Derive envelope palette from stationery background + primary colors
// ---------------------------------------------------------------------------
function mixHex(hex: string, lightAmount: number): string {
const [r, g, b] = hexToRgb(hex);
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * lightAmount));
return rgbToHex(mix(r), mix(g), mix(b));
}
function envelopeColors(bgHex: string, primaryHex: string) {
// Tint the envelope body slightly toward the primary color so it
// contrasts with the raw background even on matching themes.
const body = blendHex(bgHex, primaryHex, 0.08);
const inner = blendHex(bgHex, primaryHex, 0.18);
return {
body,
inner,
stroke: darkenHex(body, 0.20),
corner: darkenHex(body, 0.12),
// Seal: use primary color
sealBase: primaryHex,
sealDark: darkenHex(primaryHex, 0.12),
sealDarker: darkenHex(primaryHex, 0.22),
sealEdge: darkenHex(primaryHex, 0.18),
};
}
// ---------------------------------------------------------------------------
// Confetti particles for the confirmation screen
// ---------------------------------------------------------------------------
interface ConfettiParticle {
delay: number;
duration: number;
size: number;
startRotate: number;
color: string;
}
function generateParticles(count: number, primaryHex: string): ConfettiParticle[] {
const [r, g, b] = hexToRgb(primaryHex);
const colors = [
primaryHex,
mixHex(primaryHex, 0.25),
mixHex(primaryHex, 0.45),
darkenHex(primaryHex, 0.15),
rgbToHex(r, g, Math.min(255, b + 40)),
];
return Array.from({ length: count }, () => ({
delay: Math.random() * 1.8,
duration: 2.5 + Math.random() * 2,
size: 18 + Math.random() * 16,
startRotate: Math.random() * 360,
color: colors[Math.floor(Math.random() * colors.length)],
}));
}
function haptic(pattern: number | number[] = 30) {
try { navigator?.vibrate?.(pattern); } catch { /* unsupported */ }
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface SendAnimationProps {
/** Pre-rendered letter element to animate */
letterElement: React.ReactNode;
/** Width of the letter element in px */
letterWidth: number;
recipientName: string;
recipientPicture?: string;
/** Background hex color of the stationery (used for envelope) */
bgColor: string;
/** Primary hex color of the stationery (used for wax seal) */
primaryColor: string;
/** Text/foreground color of the stationery (used for V-fold crease lines) */
textColor: string;
onComplete: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function SendAnimation({
letterElement, letterWidth,
recipientName, recipientPicture,
bgColor, primaryColor, textColor,
onComplete,
}: SendAnimationProps) {
const d = useEnvelopeDimensions();
const splatId = useId();
const [t, setT] = useState(0);
const cancelRef = useRef<() => void>();
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const C = useMemo(() => envelopeColors(bgColor, primaryColor), [bgColor, primaryColor]);
const particles = useMemo(() => generateParticles(12, primaryColor), [primaryColor]);
const sealHapticFired = useRef(false);
const cleanup = useCallback(() => { cancelRef.current?.(); cancelRef.current = undefined; }, []);
useEffect(() => () => cleanup(), [cleanup]);
useLayoutEffect(() => {
cancelRef.current = animateVal(9000, setT, () => {
onCompleteRef.current();
}, ease.inOutCubic);
return cleanup;
}, [cleanup]);
const sub = (lo: number, hi: number) => Math.max(0, Math.min(1, (t - lo) / (hi - lo)));
const envAppear = ease.outQuint(sub(0.0, 0.06));
const slideIn = ease.outQuart(sub(0.04, 0.16));
const flapClose = ease.outQuart(sub(0.15, 0.21));
const sealP = ease.inOutCubic(sub(0.18, 0.38));
const flyP = ease.inQuad(sub(0.48, 0.58));
const confirmP = ease.outQuart(sub(0.58, 0.65));
const fadeOutP = ease.inQuad(sub(0.92, 1.00));
const letterTop = -d.letterH + slideIn * (d.letterH + d.flapY);
const flapDeg = flapClose * 180;
// Seal
const sealVisible = sealP > 0;
const sealDropY = (1 - sealP) * -120;
const impactT = Math.max(0, (sealP - 0.75) / 0.25);
const sealScaleX = impactT === 0 ? 1 : impactT < 0.4 ? 1 + impactT / 0.4 * 0.08 : 1.08 - (impactT - 0.4) / 0.6 * 0.08;
const sealScaleY = impactT === 0 ? 1 : impactT < 0.4 ? 1 - impactT / 0.4 * 0.06 : 0.94 + (impactT - 0.4) / 0.6 * 0.06;
const splatScale = impactT < 0.3 ? impactT / 0.3 : 1;
const sealShadowBlur = (1 - sealP) * 24 + 4;
const sealShadowY = (1 - sealP) * 30 + 2;
if (impactT > 0 && !sealHapticFired.current) {
sealHapticFired.current = true;
haptic([15, 30, 50]);
}
// Fly
const flyY = flyP * -250;
const flyOpacity = 1 - flyP;
const flyRotate = Math.sin(flyP * Math.PI) * 2;
const sealSize = Math.round(d.envW * 0.19);
const sealHalf = sealSize / 2;
const splatSize = Math.round(sealSize * 1.3);
const stageH = d.letterH + d.flapTriH + d.envH + 60;
return (
<div
className="absolute inset-0 z-50 bg-background flex items-center justify-center overflow-hidden"
style={{ opacity: 1 - fadeOutP }}
>
{/* Envelope animation */}
<div
className="absolute inset-0 flex items-center justify-center"
style={{ opacity: 1 - confirmP }}
>
<div className="relative" style={{ width: d.envW + 40, height: stageH, marginTop: -60 }}>
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: flyP > 0 ? `translateY(${flyY}px) rotate(${flyRotate}deg)` : undefined,
opacity: flyP > 0 ? flyOpacity : 1,
}}
>
<div
className="relative"
style={{
width: d.envW, height: d.envH,
marginTop: d.flapTriH,
opacity: envAppear,
transform: `scale(${0.94 + envAppear * 0.06})`,
}}
>
{/* Flap */}
<div
style={{
position: 'absolute', bottom: '100%', left: 0,
width: d.envW, height: d.flapTriH,
transformOrigin: 'bottom center',
transform: `rotateX(${flapDeg}deg)`,
transformStyle: 'preserve-3d',
zIndex: flapDeg > 90 ? 4 : -1,
}}
>
{/* Front face: inner lining color */}
<svg
width={d.envW} height={d.flapTriH}
viewBox={`0 0 ${d.envW} ${d.flapTriH}`}
className="absolute inset-0"
style={{ backfaceVisibility: 'hidden' }}
>
<path
d={`M${d.r * 0.65},${d.flapTriH} L${d.envW / 2 - d.r},${d.r} Q${d.envW / 2},0 ${d.envW / 2 + d.r},${d.r} L${d.envW - d.r * 0.65},${d.flapTriH} Z`}
fill={C.inner}
/>
</svg>
{/* Back face: body color */}
<svg
width={d.envW} height={d.flapTriH}
viewBox={`0 0 ${d.envW} ${d.flapTriH}`}
className="absolute inset-0"
style={{ backfaceVisibility: 'hidden', transform: 'rotateX(180deg)' }}
>
<path
d={`M${d.r},0 Q0,0 0,${d.r} L0,${d.flapY} L${d.envW / 2 - d.r},${d.flapTriH - d.r} Q${d.envW / 2},${d.flapTriH} ${d.envW / 2 + d.r},${d.flapTriH - d.r} L${d.envW},${d.flapY} L${d.envW},${d.r} Q${d.envW},0 ${d.envW - d.r},0 Z`}
fill={C.body}
/>
<path
d={`M0,${d.flapY} L${d.envW / 2 - d.r},${d.flapTriH - d.r} Q${d.envW / 2},${d.flapTriH} ${d.envW / 2 + d.r},${d.flapTriH - d.r} L${d.envW},${d.flapY}`}
stroke={textColor} strokeWidth={d.strokeV} strokeLinecap="round" strokeLinejoin="round" fill="none"
/>
</svg>
</div>
{/* Back wall */}
<div
className="absolute inset-0"
style={{
backgroundColor: C.body,
borderRadius: d.r,
boxShadow: '0 8px 40px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.10), 0 0 0 1px rgba(0,0,0,0.06)',
}}
/>
{/* Inner lining */}
<div
className="absolute overflow-hidden"
style={{
top: 0, left: 0, right: 0, height: d.vY,
borderRadius: `${d.r}px ${d.r}px 0 0`,
backgroundColor: C.inner,
zIndex: 0,
}}
/>
{/* Corner fold lines */}
<div className="absolute inset-0 overflow-hidden" style={{ borderRadius: d.r, zIndex: 1 }}>
<svg className="absolute inset-0" width={d.envW} height={d.envH} viewBox={`0 0 ${d.envW} ${d.envH}`}>
<path d={`M2,${d.envH - 2} L${d.envW * 0.35},${d.envH * 0.5}`} stroke={C.corner} strokeWidth={d.strokeCorner} strokeLinecap="round" fill="none" opacity="0.5" />
<path d={`M${d.envW - 2},${d.envH - 2} L${d.envW * 0.65},${d.envH * 0.5}`} stroke={C.corner} strokeWidth={d.strokeCorner} strokeLinecap="round" fill="none" opacity="0.5" />
</svg>
</div>
{/* Letter clip */}
<div
className="absolute overflow-hidden"
style={{
top: -(d.letterH + d.flapTriH), left: 0, right: 0, bottom: 0,
borderRadius: `0 0 ${d.r}px ${d.r}px`,
zIndex: 1,
}}
>
<div
className="absolute"
style={{
left: (d.envW - letterWidth) / 2,
width: letterWidth,
top: d.letterH + d.flapTriH + letterTop,
}}
>
{letterElement}
</div>
</div>
{/* Front V-fold pocket */}
<svg
className="absolute inset-0 pointer-events-none"
width={d.envW} height={d.envH}
viewBox={`0 0 ${d.envW} ${d.envH}`}
style={{ zIndex: 2 }}
>
<path
d={`M0,${d.flapY} L${d.envW / 2 - d.r},${d.vY - d.r} Q${d.envW / 2},${d.vY} ${d.envW / 2 + d.r},${d.vY - d.r} L${d.envW},${d.flapY} L${d.envW},${d.envH - d.r} Q${d.envW},${d.envH} ${d.envW - d.r},${d.envH} L${d.r},${d.envH} Q0,${d.envH} 0,${d.envH - d.r} Z`}
fill={C.body}
/>
{/* V crease lines in stationery text color */}
<path
d={`M0,${d.flapY} L${d.envW / 2 - d.r},${d.vY - d.r} Q${d.envW / 2},${d.vY} ${d.envW / 2 + d.r},${d.vY - d.r} L${d.envW},${d.flapY}`}
fill="none"
stroke={textColor}
strokeWidth={d.strokeV}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{/* Wax seal */}
{sealVisible && (
<div
className="absolute"
style={{
left: d.envW / 2 - sealHalf,
top: d.vY - sealHalf,
width: sealSize, height: sealSize,
zIndex: 5,
transform: `translateY(${sealDropY}px) scaleX(${sealScaleX}) scaleY(${sealScaleY})`,
transformOrigin: 'center center',
}}
>
{/* Splat blob on impact */}
{impactT > 0 && (
<svg
className="absolute"
width={splatSize} height={splatSize}
viewBox="0 0 84 84"
style={{
left: -(splatSize - sealSize) / 2,
top: -(splatSize - sealSize) / 2,
transform: `scale(${0.88 + splatScale * 0.12})`,
}}
>
<defs>
<radialGradient id={splatId}>
<stop offset="0%" stopColor={C.sealBase} />
<stop offset="50%" stopColor={C.sealDark} />
<stop offset="85%" stopColor={C.sealDarker} />
<stop offset="100%" stopColor={C.sealDarker} stopOpacity="0.6" />
</radialGradient>
</defs>
<path
d="M42 3 C50 2, 58 7, 64 13 C69 18, 76 24, 78 33 C80 41, 82 48, 77 56 C73 62, 66 70, 56 73 C48 76, 40 78, 32 74 C24 71, 14 66, 9 58 C5 50, 2 42, 4 34 C6 26, 12 18, 19 12 C26 6, 34 4, 42 3 Z"
fill={`url(#${splatId})`}
/>
<path
d="M42 3 C50 2, 58 7, 64 13 C69 18, 76 24, 78 33 C80 41, 82 48, 77 56 C73 62, 66 70, 56 73 C48 76, 40 78, 32 74 C24 71, 14 66, 9 58 C5 50, 2 42, 4 34 C6 26, 12 18, 19 12 C26 6, 34 4, 42 3 Z"
fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="1"
/>
</svg>
)}
{/* Seal disc */}
<div
className="absolute rounded-full"
style={{
inset: 2,
background: `
radial-gradient(ellipse at 35% 30%, rgba(255,255,255,0.22) 0%, transparent 50%),
radial-gradient(ellipse at 65% 70%, rgba(0,0,0,0.14) 0%, transparent 50%),
radial-gradient(circle at 50% 50%, ${C.sealBase} 0%, ${C.sealDark} 55%, ${C.sealDarker} 100%)
`,
boxShadow: `
0 ${sealShadowY}px ${sealShadowBlur}px rgba(0,0,0,0.28),
0 1px 3px rgba(0,0,0,0.14),
inset 0 1.5px 2px rgba(255,255,255,0.18),
inset 0 -1.5px 2px rgba(0,0,0,0.18)
`,
border: `2px solid ${C.sealEdge}`,
}}
/>
{/* Ditto logo */}
<div
className="absolute inset-0 flex items-center justify-center"
style={{ zIndex: 1 }}
>
<img
src="/logo.svg"
alt=""
style={{
width: sealSize * 0.58,
height: sealSize * 0.58,
filter: 'brightness(0) invert(1) opacity(0.85)',
}}
/>
</div>
</div>
)}
{/* Contact shadow */}
<div
className="absolute left-4 right-4"
style={{
bottom: -4, height: 8,
background: 'radial-gradient(ellipse at center, rgba(0,0,0,0.06) 0%, transparent 70%)',
zIndex: -1, borderRadius: '50%',
}}
/>
</div>
</div>
</div>
</div>
{/* Confirmation */}
{confirmP > 0 && (
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
style={{ opacity: confirmP }}
>
<div className="relative text-center space-y-5 px-6" style={{ transform: `translateY(${(1 - confirmP) * 12}px)` }}>
<div className="relative mx-auto" style={{ width: 96, height: 96 }}>
{/* Confetti burst */}
{particles.map((p, i) => {
const angle = (i / particles.length) * 360 + p.startRotate * 0.3;
const rad = (angle * Math.PI) / 180;
const dist = 60 + p.size * 3;
const tx = Math.cos(rad) * dist;
const ty = Math.sin(rad) * dist;
return (
<svg
key={i}
className="absolute pointer-events-none"
width={p.size} height={p.size}
viewBox="0 0 24 24"
fill="none"
style={{
left: '50%', top: '50%',
marginLeft: -p.size / 2, marginTop: -p.size / 2,
opacity: 0,
animation: `letter-send-burst ${p.duration}s ease-out ${p.delay * 0.4}s both`,
'--burst-tx': `${tx}px`,
'--burst-ty': `${ty}px`,
'--burst-rot': `${p.startRotate}deg`,
} as React.CSSProperties}
>
<circle cx="12" cy="12" r="5" fill={p.color} opacity={0.8} />
</svg>
);
})}
{/* Avatar */}
<div
className="w-24 h-24 rounded-full border-4 flex items-center justify-center overflow-hidden"
style={{
backgroundColor: bgColor,
borderColor: C.stroke,
animation: 'letter-send-avatar-in 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) both',
}}
>
{recipientPicture ? (
<img src={recipientPicture} alt="" className="w-full h-full object-cover" />
) : (
<img src="/logo.svg" alt="" style={{ width: 44, height: 44, opacity: 0.5 }} />
)}
</div>
</div>
<p
className="text-2xl font-bold text-foreground"
style={{ opacity: 0, animation: 'letter-send-fade-up 0.5s ease-out 0.3s forwards' }}
>
Sent a letter to {recipientName}!
</p>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,328 @@
/**
* StationeryBackground
*
* Renders a letter's stationery based on its source type:
*
* preset — flat color + faint tiled emoji backsplash
* color-moment — ColorPaletteDisplay with the actual layout
* theme — flat background color + optional image
*
* Frame styles overlay on top.
*/
import { useMemo } from 'react';
import {
FRAME_PRESETS,
DEFAULT_STATIONERY_COLOR,
type Stationery,
type ResolvedStationery,
type FrameStyle,
resolveStationery,
} from '@/lib/letterTypes';
import { ColorPaletteDisplay, type PaletteLayout } from './ColorPaletteDisplay';
import { hexLuminance, darkenHex } from '@/lib/colorUtils';
export type { PaletteLayout } from './ColorPaletteDisplay';
const DEFAULT_STATIONERY: Stationery = { color: DEFAULT_STATIONERY_COLOR };
function frameTintColor(resolved: ResolvedStationery): string {
if (resolved.primaryColor) return resolved.primaryColor;
if (resolved.colors && resolved.colors.length >= 1) return resolved.colors[0];
return resolved.color ?? '#3a7a3a';
}
interface EmojiFrameProps {
tint: string | null;
thickness: number;
emojis: string[];
defaultBg: string;
}
function EmojiFrame({ tint, thickness, emojis, defaultBg }: EmojiFrameProps) {
const t = thickness;
const bgColor = tint ? darkenHex(tint, 0.45) : defaultBg;
const flowers = useMemo(() => {
const gap = 48;
const row1 = 8;
const row2 = t - 4;
const items: { emoji: string; left: string; top: string; size: number; rot: number }[] = [];
let ei = 0;
const next = () => emojis[ei++ % emojis.length];
const place = (left: string, top: string) => {
items.push({ emoji: next(), left, top, size: 40, rot: 0 });
};
for (const d of [row1, row2]) {
for (let x = 8; x < 800; x += gap) place(`${x}px`, `${d}px`);
for (let x = 8; x < 800; x += gap) place(`${x}px`, `calc(100% - ${d}px)`);
}
for (const d of [row1, row2]) {
for (let y = t + gap; y < 800; y += gap) place(`${d}px`, `${y}px`);
for (let y = t + gap; y < 800; y += gap) place(`calc(100% - ${d}px)`, `${y}px`);
}
return items;
}, [emojis, t]);
return (
<div
className="absolute pointer-events-none select-none overflow-hidden"
aria-hidden
style={{
inset: -t,
borderRadius: '2rem',
zIndex: -1,
backgroundColor: bgColor,
}}
>
{flowers.map((f, i) => (
<span
key={i}
style={{
position: 'absolute',
left: f.left,
top: f.top,
fontSize: `${f.size}px`,
transform: `rotate(${f.rot}deg) translate(-50%, -50%)`,
lineHeight: 1,
}}
>
{f.emoji}
</span>
))}
{tint && (
<div
style={{
position: 'absolute',
inset: 0,
backgroundColor: tint,
mixBlendMode: 'color',
opacity: 0.6,
}}
/>
)}
</div>
);
}
function sanitizeEmoji(raw: string): string {
return [...raw].filter((ch) => /\p{Emoji_Presentation}|\p{Extended_Pictographic}/u.test(ch)).join('').slice(0, 8);
}
function EmojiBacksplash({ emoji }: { emoji: string }) {
const safe = sanitizeEmoji(emoji);
if (!safe) return null;
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='64' height='64'><text x='12' y='44' font-size='32' opacity='0.10'>${safe}</text></svg>`;
return (
<div
className="absolute inset-0 pointer-events-none overflow-hidden select-none"
aria-hidden
style={{
backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`,
backgroundSize: '64px 64px',
backgroundRepeat: 'repeat',
}}
/>
);
}
function EmojiEmblem({ emoji }: { emoji: string }) {
return (
<div
className="absolute inset-0 flex items-center justify-center pointer-events-none select-none"
aria-hidden
>
<span style={{ fontSize: '4rem', lineHeight: 1, opacity: 0.10, transform: 'scale(3.5)', display: 'inline-block' }}>{emoji}</span>
</div>
);
}
interface StationeryBackgroundProps {
stationery?: Stationery;
frame?: FrameStyle;
frameTint?: boolean;
className?: string;
children?: React.ReactNode;
}
function FrameRenderer({ frame, tint, thickness }: { frame: FrameStyle; tint: string | null; thickness: number }) {
const preset = FRAME_PRESETS.find(f => f.id === frame);
if (!preset?.emojis || !preset.bgColor) return null;
return <EmojiFrame key={frame} tint={tint} thickness={thickness} emojis={preset.emojis} defaultBg={preset.bgColor} />;
}
export function StationeryBackground({
stationery,
frame,
frameTint = false,
className = '',
children,
}: StationeryBackgroundProps) {
const s = resolveStationery(stationery ?? DEFAULT_STATIONERY);
const hasColors = s.colors && s.colors.length >= 2;
const tint = frameTint ? frameTintColor(s) : null;
const frameThickness = 28;
const containerStyle: React.CSSProperties = hasColors
? {}
: { backgroundColor: s.color };
const radiusClasses = className.match(/rounded-\S+/g)?.join(' ') ?? '';
const hasFrame = frame && frame !== 'none';
return (
<div className={hasFrame ? `isolate relative ${className}` : `relative ${className}`} style={hasFrame ? { overflow: 'visible' } : { overflow: 'hidden', ...containerStyle }}>
{hasFrame && <FrameRenderer frame={frame} tint={tint} thickness={frameThickness} />}
<div
className={hasFrame ? `relative overflow-hidden ${radiusClasses}` : ''}
style={hasFrame ? containerStyle : {}}
>
{hasColors && (
<ColorPaletteDisplay
colors={s.colors!}
layout={(s.layout as PaletteLayout) ?? 'horizontal'}
className="absolute inset-0 w-full h-full"
/>
)}
{hasColors && s.emoji && (
s.emojiMode === 'emblem'
? <EmojiEmblem emoji={s.emoji} />
: <EmojiBacksplash emoji={s.emoji} />
)}
{!hasColors && s.imageUrl && (
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${s.imageUrl})`,
backgroundSize: s.imageMode === 'tile' ? 'auto' : 'cover',
backgroundPosition: 'center',
backgroundRepeat: s.imageMode === 'tile' ? 'repeat' : 'no-repeat',
opacity: 0.5,
}}
/>
)}
{!hasColors && s.emoji && (
s.emojiMode === 'emblem'
? <EmojiEmblem emoji={s.emoji} />
: <EmojiBacksplash emoji={s.emoji} />
)}
{children}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// StationeryPreview — picker grid swatch
// ---------------------------------------------------------------------------
interface StationeryPreviewProps {
stationery: Stationery;
selected?: boolean;
className?: string;
}
function ThemeMockup({ stationery }: { stationery: Stationery }) {
const s = resolveStationery(stationery);
const bg = s.color;
const text = s.textColor ?? '#333333';
const primary = s.primaryColor ?? '#4CAF50';
const isDark = hexLuminance(bg) < 0.3;
const cardBg = isDark ? `${bg}dd` : '#ffffffcc';
return (
<div className="absolute inset-0 p-1.5 flex flex-col gap-1" style={{ backgroundColor: bg }}>
{s.imageUrl && (
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${s.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
opacity: 0.35,
}}
/>
)}
<div className="relative z-10 h-2 rounded-sm" style={{ backgroundColor: cardBg }} />
<div
className="relative z-10 flex-1 rounded-sm p-1 flex flex-col justify-between overflow-hidden"
style={{ backgroundColor: cardBg }}
>
<div className="space-y-0.5">
<div className="h-1 w-3/4 rounded-full opacity-60" style={{ backgroundColor: text }} />
<div className="h-1 w-1/2 rounded-full opacity-40" style={{ backgroundColor: text }} />
</div>
<div className="h-2 w-8 rounded-sm" style={{ backgroundColor: primary }} />
</div>
<div
className="absolute right-0 top-0 bottom-0 w-3 z-10"
style={{ backgroundColor: primary, opacity: 0.6 }}
/>
</div>
);
}
export function StationeryPreview({
stationery,
selected,
className = '',
}: StationeryPreviewProps) {
const s = resolveStationery(stationery);
const ringClass = selected
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
: '';
if (s.colors && s.colors.length >= 2) {
return (
<div className={`relative overflow-hidden rounded-2xl ${ringClass} ${className}`}>
<ColorPaletteDisplay
colors={s.colors}
layout={(s.layout as PaletteLayout) ?? 'horizontal'}
className="w-full h-full"
>
{s.emoji && (
<span className="text-xl drop-shadow select-none">{s.emoji}</span>
)}
</ColorPaletteDisplay>
</div>
);
}
if (s.textColor || s.primaryColor) {
return (
<div className={`relative overflow-hidden rounded-2xl ${ringClass} ${className}`}>
<ThemeMockup stationery={stationery} />
</div>
);
}
return (
<div
className={`relative overflow-hidden rounded-2xl ${ringClass} ${className}`}
style={{ backgroundColor: s.color }}
>
{s.imageUrl && (
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${s.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
)}
{s.emoji && !s.imageUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none">
<span className="text-3xl opacity-70">{s.emoji}</span>
</div>
)}
</div>
);
}
+427
View File
@@ -0,0 +1,427 @@
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { Switch } from '@/components/ui/switch';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { NostrEvent } from '@nostrify/nostrify';
import {
STATIONERY_PRESETS,
COLOR_MOMENT_KIND,
THEME_KIND,
type Stationery,
colorMomentToStationery,
themeToStationery,
presetToStationery,
resolveStationery,
} from '@/lib/letterTypes';
import { useColorMomentsPage, useThemesPage } from '@/hooks/useStationery';
import { useFollowList } from '@/hooks/useFollowActions';
import { StationeryPreview } from './StationeryBackground';
const PAGE_SIZE = 24;
const PRESET_ENTRIES = Object.entries(STATIONERY_PRESETS);
// ---------------------------------------------------------------------------
// Paginated color moments grid with infinite scroll
// ---------------------------------------------------------------------------
function ColorMomentsGrid({
selectedStationery,
onSelect,
authors,
}: {
selectedStationery?: Stationery;
onSelect: (s: Stationery) => void;
authors?: string[];
}) {
const [pages, setPages] = useState<NostrEvent[][]>([]);
const [until, setUntil] = useState<number | undefined>(undefined);
const [hasMore, setHasMore] = useState(true);
const lastUntilRef = useRef<number | 'init' | undefined>('init');
const { data: page, isLoading } = useColorMomentsPage(PAGE_SIZE, until, authors);
useEffect(() => {
if (!page || isLoading) return;
if (lastUntilRef.current === until) return;
lastUntilRef.current = until;
if (page.length > 0) {
setPages((prev) => [...prev, page]);
if (page.length < PAGE_SIZE) setHasMore(false);
} else {
setHasMore(false);
}
}, [page, isLoading, until]);
const allItems = pages.flat();
const initialized = pages.length > 0 || (!isLoading && lastUntilRef.current !== 'init');
const loadMore = useCallback(() => {
if (!hasMore || isLoading || allItems.length === 0) return;
setUntil(allItems[allItems.length - 1].created_at - 1);
}, [hasMore, isLoading, allItems]);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelCallback = useCallback(
(node: HTMLDivElement | null) => {
observerRef.current?.disconnect();
if (!node) return;
observerRef.current = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) loadMore(); },
{ threshold: 0.1 }
);
observerRef.current.observe(node);
},
[loadMore]
);
if (!initialized && isLoading) {
return (
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="aspect-square rounded-2xl" />)}
</div>
);
}
if (initialized && allItems.length === 0) {
return <p className="py-6 text-center text-sm text-muted-foreground">none found try presets</p>;
}
return (
<div className="overflow-y-auto rounded-xl" style={{ height: 160 }}>
<div className="grid grid-cols-5 gap-2">
{allItems.map((event) => {
const stationery = colorMomentToStationery(event);
const isSelected = selectedStationery?.event?.id === event.id;
const name = event.tags.find(([n]) => n === 'name')?.[1];
return (
<button
key={event.id}
onClick={() => onSelect(stationery)}
title={name}
className="relative aspect-square rounded-2xl overflow-hidden transition-all hover:scale-105 active:scale-95"
>
<StationeryPreview
stationery={stationery}
selected={isSelected}
className="w-full h-full"
/>
</button>
);
})}
{hasMore && (
<div ref={sentinelCallback} className="col-span-5 h-2">
{isLoading && Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-2xl" />
))}
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Paginated themes grid with infinite scroll
// ---------------------------------------------------------------------------
function ThemesGrid({
selectedStationery,
onSelect,
authors,
}: {
selectedStationery?: Stationery;
onSelect: (s: Stationery) => void;
authors?: string[];
}) {
const [pages, setPages] = useState<NostrEvent[][]>([]);
const [until, setUntil] = useState<number | undefined>(undefined);
const [hasMore, setHasMore] = useState(true);
const lastUntilRef = useRef<number | 'init' | undefined>('init');
const { data: page, isLoading } = useThemesPage(PAGE_SIZE, until, authors);
useEffect(() => {
if (!page || isLoading) return;
if (lastUntilRef.current === until) return;
lastUntilRef.current = until;
if (page.length > 0) {
setPages((prev) => [...prev, page]);
if (page.length < PAGE_SIZE) setHasMore(false);
} else {
setHasMore(false);
}
}, [page, isLoading, until]);
const allItems = pages.flat();
const initialized = pages.length > 0 || (!isLoading && lastUntilRef.current !== 'init');
const loadMore = useCallback(() => {
if (!hasMore || isLoading || allItems.length === 0) return;
setUntil(allItems[allItems.length - 1].created_at - 1);
}, [hasMore, isLoading, allItems]);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelCallback = useCallback(
(node: HTMLDivElement | null) => {
observerRef.current?.disconnect();
if (!node) return;
observerRef.current = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) loadMore(); },
{ threshold: 0.1 }
);
observerRef.current.observe(node);
},
[loadMore]
);
if (!initialized && isLoading) {
return (
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="aspect-square rounded-2xl" />)}
</div>
);
}
if (initialized && allItems.length === 0) {
return <p className="py-6 text-center text-sm text-muted-foreground">none found try presets</p>;
}
return (
<div className="overflow-y-auto rounded-xl" style={{ height: 160 }}>
<div className="grid grid-cols-5 gap-2">
{allItems.map((event) => {
const stationery = themeToStationery(event);
const isSelected = selectedStationery?.event?.id === event.id;
const title = event.tags.find(([n]) => n === 'title')?.[1];
return (
<button
key={event.id}
onClick={() => onSelect(stationery)}
title={title}
className="relative aspect-square rounded-2xl overflow-hidden transition-all hover:scale-105 active:scale-95"
>
<StationeryPreview
stationery={stationery}
selected={isSelected}
className="w-full h-full"
/>
</button>
);
})}
{hasMore && (
<div ref={sentinelCallback} className="col-span-5 h-2">
{isLoading && Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-2xl" />
))}
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main StationeryPicker
// ---------------------------------------------------------------------------
interface StationeryPickerProps {
selected?: Stationery;
onSelect: (stationery: Stationery) => void;
}
type Tab = 'presets' | 'colors' | 'themes';
export function StationeryPicker({ selected, onSelect }: StationeryPickerProps) {
const [tab, setTab] = useState<Tab>('presets');
const [scope, setScope] = useState<'everyone' | 'friends' | 'mine'>('everyone');
const [infoOpen, setInfoOpen] = useState(false);
const { user } = useCurrentUser();
const followListData = useFollowList();
const followPubkeyArray = followListData.data?.pubkeys;
const followList = useMemo(() => new Set(followPubkeyArray ?? []), [followPubkeyArray]);
const scopedAuthors = useMemo(() => {
if (scope === 'mine') return user ? [user.pubkey] : undefined;
if (scope === 'friends') return followList.size > 0 ? Array.from(followList) : undefined;
return undefined;
}, [scope, user, followList]);
const resolved = selected ? resolveStationery(selected) : undefined;
const emojiMode = selected?.emojiMode ?? 'tile';
const hasEmoji = !!resolved?.emoji;
const isColorMoment = selected?.event?.kind === COLOR_MOMENT_KIND;
const isTheme = selected?.event?.kind === THEME_KIND;
const isSingleColor = isColorMoment && selected?.colors !== undefined && selected.colors.length === 0;
const toggleSingleColor = () => {
if (!selected || !isColorMoment) return;
if (isSingleColor) {
const { colors: _, ...rest } = selected;
onSelect(rest as Stationery);
} else {
onSelect({ ...selected, colors: [] });
}
};
const toggleEmojiMode = () => {
if (!selected) return;
onSelect({ ...selected, emojiMode: emojiMode === 'tile' ? 'emblem' : 'tile' });
};
const tabs: { id: Tab; label: string }[] = [
{ id: 'presets', label: 'presets' },
{ id: 'colors', label: 'moments' },
{ id: 'themes', label: 'themes' },
];
const isMomentsTab = tab === 'colors';
const isThemesTab = tab === 'themes';
const showInfoButton = isMomentsTab || isThemesTab;
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => { setTab(t.id); setScope('everyone'); }}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all border ${
tab === t.id
? 'bg-foreground text-background border-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground border-secondary'
}`}
>
{t.label}
</button>
))}
{showInfoButton && (
<button
onClick={() => setInfoOpen(true)}
className="ml-auto opacity-70 hover:opacity-100 transition-opacity text-xs text-muted-foreground font-medium px-2 py-1 rounded-full bg-secondary"
>
{isMomentsTab ? 'about' : 'about'}
</button>
)}
</div>
{showInfoButton && (
<div className="flex gap-1">
{(['everyone', 'friends', ...(user ? ['mine' as const] : [])] as const).map((s) => (
<button
key={s}
onClick={() => setScope(s)}
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-all border ${
scope === s
? 'bg-foreground text-background border-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground border-secondary'
}`}
>
{s}
</button>
))}
</div>
)}
<div>
{tab === 'presets' && (
<div className="rounded-xl">
<div className="grid grid-cols-5 gap-2">
{PRESET_ENTRIES.map(([key, preset]) => {
const stationery: Stationery = { color: preset.color, emoji: preset.emoji };
return (
<button
key={key}
onClick={() => onSelect(presetToStationery(key) ?? stationery)}
title={preset.name}
className="relative aspect-square rounded-2xl overflow-hidden transition-all hover:scale-105 active:scale-95"
>
<StationeryPreview
stationery={stationery}
selected={selected?.color === preset.color && !selected?.event}
className="w-full h-full"
/>
</button>
);
})}
</div>
</div>
)}
{tab === 'colors' && <ColorMomentsGrid key={scope} selectedStationery={selected} onSelect={onSelect} authors={scopedAuthors} />}
{tab === 'themes' && <ThemesGrid key={scope} selectedStationery={selected} onSelect={onSelect} authors={scopedAuthors} />}
</div>
{((hasEmoji && !isTheme) || isColorMoment) && (
<div className="flex items-center gap-4 px-1 pt-1">
{hasEmoji && !isTheme && (
<label className="flex items-center gap-1.5">
<Switch checked={emojiMode === 'emblem'} onCheckedChange={toggleEmojiMode} />
<span className="text-sm text-muted-foreground font-medium">emblem</span>
</label>
)}
{isColorMoment && (
<label className="flex items-center gap-1.5">
<Switch checked={isSingleColor} onCheckedChange={toggleSingleColor} />
<span className="text-sm text-muted-foreground font-medium">flat</span>
</label>
)}
</div>
)}
<Dialog open={infoOpen} onOpenChange={setInfoOpen}>
<DialogContent className="max-w-xs rounded-2xl">
{isMomentsTab && (
<>
<DialogHeader>
<DialogTitle>Color moments</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
Color moments are beautiful color combinations created and shared by the community. Each one gives your letter a unique palette and mood.
</p>
<p>
<Link
to="/colors"
onClick={() => setInfoOpen(false)}
className="text-foreground font-medium underline underline-offset-2 hover:no-underline"
>
Discover and create color moments
</Link>
</p>
</div>
</>
)}
{isThemesTab && (
<>
<DialogHeader>
<DialogTitle>Ditto themes</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
Ditto themes are UI themes shared by the community. Letters borrows their colors and fonts to style your letter.
</p>
<p>
<Link
to="/themes"
onClick={() => setInfoOpen(false)}
className="text-foreground font-medium underline underline-offset-2 hover:no-underline"
>
Browse and create themes
</Link>
</p>
</div>
</>
)}
</DialogContent>
</Dialog>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Sticker, Info } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
interface StickerPickerProps {
onSelect: (emoji: CustomEmoji) => void;
}
export function StickerPicker({ onSelect }: StickerPickerProps) {
const { emojis, isLoading } = useCustomEmojis();
const [infoOpen, setInfoOpen] = useState(false);
return (
<div className="space-y-2">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-muted-foreground px-1">my stickers</span>
<button
onClick={() => setInfoOpen(true)}
className="ml-auto opacity-60 hover:opacity-100 transition-opacity"
aria-label="About stickers"
>
<Info className="w-5 h-5" strokeWidth={2.5} />
</button>
</div>
{isLoading && (
<div className="grid grid-cols-4 gap-1.5">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-xl" />
))}
</div>
)}
{!isLoading && emojis.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
<Sticker className="size-8 opacity-40" />
<p className="text-sm">no sticker packs yet</p>
<p className="text-xs opacity-70">add emoji packs to your profile to use stickers</p>
</div>
)}
{!isLoading && emojis.length > 0 && (
<ScrollArea className="h-[200px]">
<div className="grid grid-cols-4 gap-1.5 p-1">
{emojis.map((emoji) => (
<button
key={emoji.shortcode}
type="button"
title={emoji.shortcode}
onClick={() => onSelect(emoji)}
className="aspect-square rounded-xl overflow-hidden hover:bg-muted/80 transition-all p-1.5 group active:scale-90"
>
<img
src={emoji.url}
alt={emoji.shortcode}
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
loading="lazy"
/>
</button>
))}
</div>
</ScrollArea>
)}
<Dialog open={infoOpen} onOpenChange={setInfoOpen}>
<DialogContent className="max-w-xs rounded-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sticker className="w-5 h-5 shrink-0" />
Stickers
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
Stickers are custom emoji packs (NIP-30) published on Nostr. They get placed on top of your letter as decorations.
</p>
<p>
Add emoji packs to your profile to make them available as stickers.
</p>
<p>
<Link
to="/emojis"
onClick={() => setInfoOpen(false)}
className="text-foreground font-medium underline underline-offset-2 hover:no-underline"
>
Browse emoji packs
</Link>
</p>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+3
View File
@@ -8,6 +8,7 @@ import { useCurrentUser } from './useCurrentUser';
import type { Theme, FeedSettings, ContentWarningPolicy, SavedFeed } from '@/contexts/AppContext';
import type { ThemeConfig } from '@/themes';
import type { ContentFilter } from './useContentFilters';
import type { LetterPreferences } from '@/lib/letterTypes';
import { EncryptedSettingsSchema } from '@/lib/schemas';
/**
@@ -75,6 +76,8 @@ export interface EncryptedSettings {
sentryDsn?: string;
/** Saved feed tabs created from the search page. */
savedFeeds?: SavedFeed[];
/** Letter preferences (stationery, font, frame, closing, signature, inbox filters) */
letterPreferences?: LetterPreferences;
}
/**
+36
View File
@@ -0,0 +1,36 @@
import { useCallback } from 'react';
import { useEncryptedSettings } from './useEncryptedSettings';
import type { LetterPreferences } from '@/lib/letterTypes';
/**
* Persists per-user letter preferences in the encrypted settings event (NIP-78 kind 30078).
*
* `isThemeDefault` is true when no stationery has been explicitly saved — callers
* should use `useThemeStationery()` as the live preview source in that case.
*/
export function useLetterPreferences() {
const { settings, updateSettings } = useEncryptedSettings();
// Raw saved prefs — stationery may be undefined if never set
const prefs: LetterPreferences = settings?.letterPreferences ?? {};
/** True when no stationery has been explicitly saved — use the active Ditto theme. */
const isThemeDefault = !prefs.stationery;
const updatePrefs = useCallback(
(patch: Partial<LetterPreferences>) => {
const current: LetterPreferences = settings?.letterPreferences ?? {};
updateSettings.mutate({ letterPreferences: { ...current, ...patch } });
},
[settings, updateSettings],
);
/** Remove the saved stationery, reverting to the active Ditto theme. */
const resetStationery = useCallback(() => {
const current: LetterPreferences = settings?.letterPreferences ?? {};
const { stationery: _removed, ...rest } = current;
updateSettings.mutate({ letterPreferences: rest });
}, [settings, updateSettings]);
return { prefs, updatePrefs, resetStationery, isThemeDefault };
}
+209
View File
@@ -0,0 +1,209 @@
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import {
LETTER_KIND,
type Letter,
type LetterContent,
type Stationery,
} from '@/lib/letterTypes';
const PAGE_SIZE = 50;
const EMPTY_DELETED = new Set<string>();
/** Parse a letter event into a Letter object (without decrypting).
* All presentation data (stationery, frame, font) is inside the
* encrypted content and populated later by useDecryptLetter. */
function parseLetterEvent(event: NostrEvent): Letter | null {
if (event.kind !== LETTER_KIND) return null;
const recipient = event.tags.find(([name]) => name === 'p')?.[1];
if (!recipient) return null;
return {
event,
recipient,
sender: event.pubkey,
decrypted: false,
timestamp: event.created_at,
};
}
/** Collect event IDs targeted by the user's kind 5 deletion requests. */
function getDeletedIds(deletionEvents: NostrEvent[]): Set<string> {
const ids = new Set<string>();
for (const event of deletionEvents) {
for (const [name, value] of event.tags) {
if (name === 'e' && value) ids.add(value);
}
}
return ids;
}
/** Fetch inbox letters (letters sent to the current user) with cursor-based pagination.
* When `friendPubkeys` is provided, only letters from those pubkeys are returned. */
export function useInbox(friendPubkeys?: string[]) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
// Fetch all deletion IDs once (not paginated — deletion events are small)
const deletionsQuery = useQuery({
queryKey: ['letters-deletions', user?.pubkey],
queryFn: async () => {
if (!user) return new Set<string>();
const deletions = await nostr.query([{ kinds: [5], authors: [user.pubkey], '#k': [String(LETTER_KIND)], limit: 500 }]);
return getDeletedIds(deletions);
},
enabled: !!user,
});
const deletedIds = deletionsQuery.data ?? EMPTY_DELETED;
const infiniteQuery = useInfiniteQuery({
queryKey: ['letters-inbox', user?.pubkey, friendPubkeys ?? null],
queryFn: async ({ pageParam }: { pageParam: number | undefined }) => {
if (!user) return [];
const filter: NostrFilter = {
kinds: [LETTER_KIND],
'#p': [user.pubkey],
limit: PAGE_SIZE,
};
if (pageParam) filter.until = pageParam;
if (friendPubkeys) {
if (friendPubkeys.length === 0) return [];
filter.authors = friendPubkeys;
}
const events = await nostr.query([filter]);
return events
.map(parseLetterEvent)
.filter((l): l is Letter => l !== null)
.sort((a, b) => b.timestamp - a.timestamp);
},
initialPageParam: undefined as number | undefined,
getNextPageParam: (lastPage) => {
if (lastPage.length < PAGE_SIZE) return undefined;
const oldest = lastPage[lastPage.length - 1];
return oldest ? oldest.timestamp : undefined;
},
enabled: !!user,
});
// Flatten pages and filter out deleted letters
const data = useMemo(() => {
if (!infiniteQuery.data) return undefined;
return infiniteQuery.data.pages
.flat()
.filter((l) => !deletedIds.has(l.event.id));
}, [infiniteQuery.data, deletedIds]);
return {
data,
isLoading: infiniteQuery.isLoading,
fetchNextPage: infiniteQuery.fetchNextPage,
hasNextPage: infiniteQuery.hasNextPage,
isFetchingNextPage: infiniteQuery.isFetchingNextPage,
};
}
/** Fetch sent letters (letters authored by the current user) with cursor-based pagination. */
export function useSentLetters() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
// Reuse the same deletion query (keyed by pubkey, shared across inbox/sent)
const deletionsQuery = useQuery({
queryKey: ['letters-deletions', user?.pubkey],
queryFn: async () => {
if (!user) return new Set<string>();
const deletions = await nostr.query([{ kinds: [5], authors: [user.pubkey], '#k': [String(LETTER_KIND)], limit: 500 }]);
return getDeletedIds(deletions);
},
enabled: !!user,
});
const deletedIds = deletionsQuery.data ?? EMPTY_DELETED;
const infiniteQuery = useInfiniteQuery({
queryKey: ['letters-sent', user?.pubkey],
queryFn: async ({ pageParam }: { pageParam: number | undefined }) => {
if (!user) return [];
const filter: NostrFilter = {
kinds: [LETTER_KIND],
authors: [user.pubkey],
limit: PAGE_SIZE,
};
if (pageParam) filter.until = pageParam;
const events = await nostr.query([filter]);
return events
.map(parseLetterEvent)
.filter((l): l is Letter => l !== null)
.sort((a, b) => b.timestamp - a.timestamp);
},
initialPageParam: undefined as number | undefined,
getNextPageParam: (lastPage) => {
if (lastPage.length < PAGE_SIZE) return undefined;
const oldest = lastPage[lastPage.length - 1];
return oldest ? oldest.timestamp : undefined;
},
enabled: !!user,
});
const data = useMemo(() => {
if (!infiniteQuery.data) return undefined;
return infiniteQuery.data.pages
.flat()
.filter((l) => !deletedIds.has(l.event.id));
}, [infiniteQuery.data, deletedIds]);
return {
data,
isLoading: infiniteQuery.isLoading,
fetchNextPage: infiniteQuery.fetchNextPage,
hasNextPage: infiniteQuery.hasNextPage,
isFetchingNextPage: infiniteQuery.isFetchingNextPage,
};
}
/** Result of decrypting a letter — includes extracted presentation data */
export interface DecryptedLetter {
content: LetterContent;
stationery?: Stationery;
}
/** Decrypt a letter's content using NIP-44 and extract presentation fields */
export function useDecryptLetter(letter: Letter | undefined) {
const { user } = useCurrentUser();
return useQuery({
queryKey: ['letter-decrypt', letter?.event.id, user?.pubkey],
queryFn: async (): Promise<DecryptedLetter | null> => {
if (!user || !letter) return null;
if (!user.signer.nip44) {
throw new Error('NIP-44 encryption not supported by your signer');
}
const otherPubkey = letter.sender === user.pubkey
? letter.recipient
: letter.sender;
try {
const decrypted = await user.signer.nip44.decrypt(otherPubkey, letter.event.content);
const parsed = JSON.parse(decrypted) as LetterContent;
if (!parsed.body) return null;
return {
content: parsed,
stationery: parsed.stationery,
};
} catch {
return null;
}
},
enabled: !!user && !!letter && !!letter.event.content,
retry: false,
});
}
+66
View File
@@ -0,0 +1,66 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { COLOR_MOMENT_KIND, THEME_KIND } from '@/lib/letterTypes';
/** Validate a color moment event. Returns the event if valid, null otherwise. */
function validateColorMoment(event: NostrEvent): NostrEvent | null {
if (event.kind !== COLOR_MOMENT_KIND) return null;
const colorTags = event.tags.filter(([name]) => name === 'c');
if (colorTags.length < 3 || colorTags.length > 6) return null;
const hexColorRegex = /^#[0-9A-Fa-f]{6}$/;
if (!colorTags.every(([, color]) => hexColorRegex.test(color))) return null;
return event;
}
/** Validate a theme event. Returns the event if valid, null otherwise. */
function validateTheme(event: NostrEvent): NostrEvent | null {
if (event.kind !== THEME_KIND) return null;
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
const title = event.tags.find(([name]) => name === 'title')?.[1];
if (!dTag || !title) return null;
return event;
}
/** Fetch a page of color moments for stationery infinite scroll */
export function useColorMomentsPage(limit = 24, until?: number, authors?: string[]) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['color-moments-page', limit, until, authors ?? null],
queryFn: async () => {
const filter = {
kinds: [COLOR_MOMENT_KIND],
limit,
...(until ? { until } : {}),
...(authors && authors.length > 0 ? { authors } : {}),
};
const events = await nostr.query([filter]);
// Deduplicate by event id
const seen = new Map<string, NostrEvent>();
for (const e of events) seen.set(e.id, e);
return Array.from(seen.values())
.filter((e): e is NostrEvent => validateColorMoment(e) !== null)
.sort((a, b) => b.created_at - a.created_at);
},
});
}
/** Fetch a page of themes for stationery infinite scroll */
export function useThemesPage(limit = 24, until?: number, authors?: string[]) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['themes-page', limit, until, authors ?? null],
queryFn: async () => {
const filter = {
kinds: [THEME_KIND],
limit,
...(until ? { until } : {}),
...(authors && authors.length > 0 ? { authors } : {}),
};
const events = await nostr.query([filter]);
return events
.filter((e): e is NostrEvent => validateTheme(e) !== null)
.sort((a, b) => b.created_at - a.created_at);
},
});
}
+66
View File
@@ -0,0 +1,66 @@
import { useMemo } from 'react';
import { type Stationery, resolveStationery, DEFAULT_STATIONERY_COLOR } from '@/lib/letterTypes';
import {
paletteTextColor,
paletteTextColorFaint,
paletteLineColor,
backgroundTextColor,
backgroundTextColorFaint,
backgroundLineColor,
} from '@/lib/colorUtils';
/** Default stationery when none is provided */
const DEFAULT_STATIONERY: Stationery = { color: DEFAULT_STATIONERY_COLOR };
/**
* useStationeryColors
*
* Returns the correct text colors to render over a given stationery:
*
* - Has textColor: use it directly (theme)
* - Has colors[]: WCAG avg-luminance of palette (color moment)
* - Otherwise: WCAG luminance of the single background color (preset)
*
* Returns { text, faint, line } as CSS color strings for use in `style={{ color }}`.
*/
export function useStationeryColors(stationery?: Stationery): {
text: string;
faint: string;
line: string;
fontFamily?: string;
} {
const raw = stationery ?? DEFAULT_STATIONERY;
return useMemo(() => {
const s = resolveStationery(raw);
// Explicit text color (theme)
if (s.textColor) {
const line = backgroundLineColor(s.color);
return {
text: s.textColor,
faint: s.textColor + '4d',
line,
fontFamily: s.fontFamily,
};
}
// Color moment: avg WCAG luminance of palette
if (s.colors && s.colors.length > 0) {
return {
text: paletteTextColor(s.colors),
faint: paletteTextColorFaint(s.colors),
line: paletteLineColor(s.colors),
fontFamily: s.fontFamily,
};
}
// Preset or fallback: single background color
return {
text: backgroundTextColor(s.color),
faint: backgroundTextColorFaint(s.color),
line: backgroundLineColor(s.color),
fontFamily: s.fontFamily,
};
}, [raw]);
}
+61
View File
@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { useTheme } from '@/hooks/useTheme';
import { useAppContext } from '@/hooks/useAppContext';
import { builtinThemes, resolveTheme, resolveThemeConfig } from '@/themes';
import { hslStringToHex } from '@/lib/colorUtils';
import type { Stationery } from '@/lib/letterTypes';
/**
* Converts the user's currently active Ditto theme into a letter Stationery.
*
* The mapping:
* - Stationery.color ← theme background color (hex)
* - Stationery.textColor ← theme text/foreground color (hex)
* - Stationery.primaryColor ← theme primary color (hex)
* - Stationery.imageUrl ← theme background image URL (if any)
* - Stationery.imageMode ← theme background mode (cover/tile)
* - Stationery.fontFamily ← theme body font-family (if any)
*
* This lets letters inherit the user's full Ditto theme — colors, fonts,
* and background image — as their default stationery, so letters look
* native to the user's chosen aesthetic.
*/
export function useThemeStationery(): Stationery {
const { theme, customTheme, themes } = useTheme();
const { config } = useAppContext();
return useMemo(() => {
const resolved = resolveTheme(theme);
let bgHex: string;
let textHex: string;
let primaryHex: string;
let fontFamily: string | undefined;
let imageUrl: string | undefined;
let imageMode: 'cover' | 'tile' | undefined;
if (resolved === 'custom' && customTheme) {
const { colors, font, background } = customTheme;
bgHex = hslStringToHex(colors.background);
textHex = hslStringToHex(colors.text);
primaryHex = hslStringToHex(colors.primary);
fontFamily = font?.family ?? undefined;
imageUrl = background?.url ?? undefined;
imageMode = background?.mode ?? undefined;
} else {
const colors = resolveThemeConfig(resolved as 'light' | 'dark', themes ?? config.themes).colors
?? builtinThemes[resolved as 'light' | 'dark'];
bgHex = hslStringToHex(colors.background);
textHex = hslStringToHex(colors.text);
primaryHex = hslStringToHex(colors.primary);
}
return {
color: bgHex,
textColor: textHex,
primaryColor: primaryHex,
...(fontFamily ? { fontFamily } : {}),
...(imageUrl ? { imageUrl, imageMode: imageMode ?? 'cover' } : {}),
};
}, [theme, customTheme, themes, config.themes]);
}
+94
View File
@@ -248,6 +248,100 @@
50%, 90% { opacity: 0.2; }
}
/* ── Wii Mail envelope grid ─────────────────────────────────────────────────── */
/* Staggered entrance: envelopes pop in one by one */
@keyframes envelope-entrance {
0% { opacity: 0; transform: scale(0.6) translateY(20px) rotate(-4deg); }
60% { opacity: 1; transform: scale(1.05) translateY(-2px) rotate(1deg); }
80% { transform: scale(0.97) translateY(1px) rotate(-0.5deg); }
100% { opacity: 1; transform: scale(1) translateY(0) rotate(0deg); }
}
.envelope-card {
animation: envelope-entrance 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
animation-delay: var(--entrance-delay, 0ms);
transform-origin: center bottom;
cursor: pointer;
}
/* Hover wobble — playful Wii-style oscillation */
@keyframes envelope-wobble {
0% { transform: rotate(0deg); }
15% { transform: rotate(-5deg); }
30% { transform: rotate(4deg); }
45% { transform: rotate(-3deg); }
60% { transform: rotate(2deg); }
75% { transform: rotate(-1deg); }
100% { transform: rotate(0deg); }
}
.envelope-body {
transition: transform 0.15s ease;
}
.envelope-card:hover .envelope-body,
.envelope-card:focus-visible .envelope-body {
animation: envelope-wobble 0.5s ease-in-out;
}
.envelope-card:active .envelope-body {
transform: scale(0.92);
animation: none;
}
/* Envelope skeleton shimmer */
@keyframes envelope-skeleton-shimmer {
0% { opacity: 0.4; }
50% { opacity: 0.7; }
100% { opacity: 0.4; }
}
.envelope-skeleton {
animation: envelope-skeleton-shimmer 1.5s ease-in-out infinite;
}
/* Reduced motion: disable envelope animations */
@media (prefers-reduced-motion: reduce) {
.envelope-card {
animation: none;
opacity: 1;
}
.envelope-card:hover .envelope-body,
.envelope-card:focus-visible .envelope-body {
animation: none;
}
}
/* Drawn SVG stickers — ensure inline SVGs fill their container */
[data-sticker] svg,
.sticker-svg-wrap svg {
width: 100%;
height: 100%;
display: block;
}
/* Letter send animation keyframes */
@keyframes letter-send-fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes letter-send-avatar-in {
0% { transform: scale(0); opacity: 0; }
50% { transform: scale(1.15); opacity: 1; }
70% { transform: scale(0.92); }
85% { transform: scale(1.04); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes letter-send-burst {
0% { transform: translate(0, 0) rotate(0deg) scale(0); opacity: 0; }
15% { opacity: 1; transform: translate(0, 0) rotate(0deg) scale(1); }
60% { opacity: 0.8; transform: translate(var(--burst-tx), var(--burst-ty)) rotate(var(--burst-rot)) scale(1); }
100% { opacity: 0; transform: translate(var(--burst-tx), var(--burst-ty)) rotate(var(--burst-rot)) scale(0.4); }
}
/* Reduced motion: disable all vanish animations */
@media (prefers-reduced-motion: reduce) {
.vanish-stripes {
+85
View File
@@ -206,3 +206,88 @@ export function tokensToCoreColors(tokens: ThemeTokens): CoreThemeColors {
primary: tokens.primary,
};
}
// ─── Hex color manipulation ───────────────────────────────────────────
/** Darken a hex color by a factor (0 = no change, 1 = black). */
export function darkenHex(hex: string, amount: number): string {
const [r, g, b] = hexToRgb(hex);
const dark = (c: number) => Math.max(0, Math.round(c * (1 - amount)));
return rgbToHex(dark(r), dark(g), dark(b));
}
/** Lighten a hex color by a factor (0 = no change, 1 = white). */
export function lightenHex(hex: string, amount: number): string {
const [r, g, b] = hexToRgb(hex);
const light = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount));
return rgbToHex(light(r), light(g), light(b));
}
/** Blend two hex colors by a factor (0 = hex1, 1 = hex2). */
export function blendHex(hex1: string, hex2: string, amount: number): string {
const [r1, g1, b1] = hexToRgb(hex1);
const [r2, g2, b2] = hexToRgb(hex2);
return rgbToHex(
Math.round(r1 + (r2 - r1) * amount),
Math.round(g1 + (g2 - g1) * amount),
Math.round(b1 + (b2 - b1) * amount),
);
}
// ─── Letter stationery color utilities ────────────────────────────────
/** WCAG 2.1 relative luminance of a hex color (0 = black, 1 = white). */
export function hexLuminance(hex: string): number {
if (!hex) return 0.5;
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const toLinear = (c: number) =>
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
/**
* Derive a readable text color for a given palette of hex colors.
* avgLum > 0.5 → dark text; avgLum ≤ 0.5 → light text
*/
export function paletteTextColor(colors: string[]): string {
if (colors.length === 0) return 'rgba(0,0,0,0.75)';
const avg = colors.reduce((sum, c) => sum + hexLuminance(c), 0) / colors.length;
return avg > 0.5 ? 'rgba(0,0,0,0.75)' : 'rgba(255,255,255,0.90)';
}
/** Derive a readable text color for a single background hex color. */
export function backgroundTextColor(bgHex: string): string {
return hexLuminance(bgHex) > 0.5
? 'rgba(0,0,0,0.75)'
: 'rgba(255,255,255,0.90)';
}
/** Faint text color for secondary elements (palette version). */
export function paletteTextColorFaint(colors: string[]): string {
if (colors.length === 0) return 'rgba(0,0,0,0.30)';
const avg = colors.reduce((sum, c) => sum + hexLuminance(c), 0) / colors.length;
return avg > 0.5 ? 'rgba(0,0,0,0.30)' : 'rgba(255,255,255,0.35)';
}
/** Faint text color for secondary elements (single background version). */
export function backgroundTextColorFaint(bgHex: string): string {
return hexLuminance(bgHex) > 0.5
? 'rgba(0,0,0,0.30)'
: 'rgba(255,255,255,0.35)';
}
/** Ruled-line color for letter stationery (palette version). */
export function paletteLineColor(colors: string[]): string {
if (colors.length === 0) return 'rgba(0,0,0,0.08)';
const avg = colors.reduce((sum, c) => sum + hexLuminance(c), 0) / colors.length;
return avg > 0.5 ? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.15)';
}
/** Ruled-line color for letter stationery (single background version). */
export function backgroundLineColor(bgHex: string): string {
return hexLuminance(bgHex) > 0.5
? 'rgba(0,0,0,0.08)'
: 'rgba(255,255,255,0.15)';
}
+40
View File
@@ -170,6 +170,46 @@ export const bundledFonts: BundledFont[] = [
load: () => import('@fontsource/press-start-2p/400.css').then(() => {}),
cdnUrl: 'https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff2',
},
{
family: 'Fredoka',
cssFamily: 'Fredoka Variable',
category: 'display',
variable: true,
load: () => import('@fontsource-variable/fredoka').then(() => {}),
cdnUrl: 'https://cdn.jsdelivr.net/fontsource/fonts/fredoka:vf@latest/latin-wght-normal.woff2',
},
{
family: 'Caveat',
cssFamily: 'Caveat',
category: 'handwriting',
variable: false,
load: () => import('@fontsource/caveat/400.css').then(() => {}),
cdnUrl: 'https://cdn.jsdelivr.net/fontsource/fonts/caveat@latest/latin-400-normal.woff2',
},
{
family: 'Pacifico',
cssFamily: 'Pacifico',
category: 'handwriting',
variable: false,
load: () => import('@fontsource/pacifico/400.css').then(() => {}),
cdnUrl: 'https://cdn.jsdelivr.net/fontsource/fonts/pacifico@latest/latin-400-normal.woff2',
},
{
family: 'Pirata One',
cssFamily: 'Pirata One',
category: 'display',
variable: false,
load: () => import('@fontsource/pirata-one/400.css').then(() => {}),
cdnUrl: 'https://cdn.jsdelivr.net/fontsource/fonts/pirata-one@latest/latin-400-normal.woff2',
},
{
family: 'Special Elite',
cssFamily: 'Special Elite',
category: 'display',
variable: false,
load: () => import('@fontsource/special-elite/400.css').then(() => {}),
cdnUrl: 'https://cdn.jsdelivr.net/fontsource/fonts/special-elite@latest/latin-400-normal.woff2',
},
{
family: 'Nunito',
cssFamily: 'Nunito Variable',
+293
View File
@@ -0,0 +1,293 @@
import type { NostrEvent } from '@nostrify/nostrify';
export const LETTER_KIND = 8211;
export const COLOR_MOMENT_KIND = 3367;
export const THEME_KIND = 36767;
/** Default stationery background color (parchment). */
export const DEFAULT_STATIONERY_COLOR = '#F5E6D3';
/** Ratio of ruled-line height to card width. Used across letter rendering components. */
export const LINE_HEIGHT_RATIO = 0.084;
/** A sticker placed on top of the letter card */
export interface LetterSticker {
/** Image URL of the sticker (empty string for drawn stickers) */
url: string;
/** NIP-30 shortcode (without colons). "drawing" for hand-drawn SVG stickers. */
shortcode: string;
/** X position as percentage (0100) from left edge of the card */
x: number;
/** Y position as percentage (0100) from top edge of the card */
y: number;
/** Rotation in degrees (-180 to 180) */
rotation: number;
/** Scale multiplier (default 1). Range 0.54. */
scale?: number;
/** Raw SVG markup for hand-drawn stickers. When present, rendered inline instead of url. */
svg?: string;
}
export interface LetterContent {
body: string;
closing?: string;
signature?: string;
/** Stickers placed on the letter card — stored in encrypted content for privacy */
stickers?: LetterSticker[];
/** Visual stationery — all rendering attributes plus optional source event */
stationery?: Stationery;
}
/**
* Visual stationery for a letter — the wire format.
*
* User-chosen fields live directly on this object. Event-derived fields
* (colors, layout, imageUrl, textColor, etc.) are read from the source
* `event` at render time via `resolveStationery()`. Old letters that
* predate this change may carry those fields as flat fallbacks.
*/
export interface Stationery {
/** Background color (hex). Always present. */
color: string;
/** Emoji character for backsplash or emblem. */
emoji?: string;
/** Emoji display mode: 'tile' (faint repeating pattern) or 'emblem' (single large centered glyph). */
emojiMode?: 'tile' | 'emblem';
/** Palette colors override. Empty array = "flat" mode (suppress palette). */
colors?: string[];
/** CSS font-family string (e.g. "Caveat, cursive"). Set from the sender's font choice. */
fontFamily?: string;
/** Frame style ID. */
frame?: FrameStyle;
/** When true, color-shift the frame emojis to match the stationery palette. */
frameTint?: boolean;
/** Source Nostr event (kind 36767 theme or kind 3367 color moment). */
event?: NostrEvent;
// --- Legacy flat fallbacks (from old letters, not set by new code) ---
/** @deprecated Read from event tags instead. */
textColor?: string;
/** @deprecated Read from event tags instead. */
primaryColor?: string;
/** @deprecated Read from event tags instead. */
layout?: string;
/** @deprecated Read from event tags instead. */
imageUrl?: string;
/** @deprecated Read from event tags instead. */
imageMode?: 'cover' | 'tile';
}
/** All rendering attributes for a stationery, fully resolved from event + fallbacks. */
export interface ResolvedStationery {
color: string;
textColor?: string;
primaryColor?: string;
emoji?: string;
emojiMode: 'tile' | 'emblem';
colors?: string[];
layout?: string;
imageUrl?: string;
imageMode: 'cover' | 'tile';
fontFamily?: string;
frame?: FrameStyle;
frameTint?: boolean;
event?: NostrEvent;
}
/** Resolve a Stationery into full rendering attributes by reading event tags. */
export function resolveStationery(s: Stationery): ResolvedStationery {
const base: ResolvedStationery = {
color: s.color,
emoji: s.emoji,
emojiMode: s.emojiMode ?? 'tile',
fontFamily: s.fontFamily,
frame: s.frame,
frameTint: s.frameTint,
imageMode: 'cover',
event: s.event,
};
const event = s.event;
if (event?.kind === COLOR_MOMENT_KIND) {
// Colors override: empty array = flat mode, undefined = read from event
if (s.colors !== undefined) {
base.colors = s.colors.length > 0 ? s.colors : undefined;
} else {
const hexRe = /^#[0-9A-Fa-f]{6}$/;
const eventColors = event.tags.filter(([n]) => n === 'c').map(([, c]) => c).filter((c) => hexRe.test(c));
if (eventColors.length >= 2) base.colors = eventColors;
}
base.layout = s.layout ?? event.tags.find(([n]) => n === 'layout')?.[1];
if (!base.emoji) {
const raw = event.content?.trim();
if (raw && [...raw].length <= 2 && /\p{Emoji}/u.test(raw)) base.emoji = raw;
}
return base;
}
if (event?.kind === THEME_KIND) {
const colorTags = event.tags.filter(([n]) => n === 'c');
for (const [, hex, marker] of colorTags) {
if (marker === 'text') base.textColor = hex;
if (marker === 'primary') base.primaryColor = hex;
}
const bgTag = event.tags.find(([n]) => n === 'bg');
if (bgTag) {
for (const slot of bgTag.slice(1)) {
if (slot.startsWith('url ')) base.imageUrl = slot.slice(4);
else if (slot === 'mode tile') base.imageMode = 'tile';
else if (slot === 'mode cover') base.imageMode = 'cover';
}
}
if (!base.imageUrl) {
base.imageUrl = event.tags.find(([n]) => n === 'image')?.[1];
}
return base;
}
// No event or unknown kind — use legacy flat fallbacks (old letters, presets)
base.textColor = s.textColor;
base.primaryColor = s.primaryColor;
base.colors = s.colors;
base.layout = s.layout;
base.imageUrl = s.imageUrl;
base.imageMode = s.imageMode ?? 'cover';
return base;
}
/**
* Frame style presets — combinable with any stationery.
* Each frame uses the same emoji-scatter system with different emoji sets
* and default background colors.
*/
export type FrameStyle =
| 'none'
| 'flowers'
| 'autumn'
| 'ocean'
| 'celestial'
| 'hearts'
| 'garden'
| 'winter'
| 'fruit'
| 'sparkle';
export interface FramePreset {
id: FrameStyle;
name: string;
/** Emoji set for the border scatter */
emojis?: string[];
/** Default background color (before tint) */
bgColor?: string;
}
export const FRAME_PRESETS: FramePreset[] = [
{ id: 'none', name: 'None' },
{ id: 'flowers', name: 'Flowers', emojis: ['🌸', '🌺', '🌼', '🌷', '🌻', '🌹'], bgColor: '#3a7a3a' },
{ id: 'autumn', name: 'Autumn', emojis: ['🍂', '🍁', '🍃', '🌾', '🍄', '🌰'], bgColor: '#8b5e3c' },
{ id: 'ocean', name: 'Ocean', emojis: ['🐚', '🌊', '🐠', '🐙', '🦀', '🐬'], bgColor: '#1a5276' },
{ id: 'celestial', name: 'Celestial', emojis: ['🪐', '🌙', '⭐', '🌕', '☄️', '🔭'], bgColor: '#1a1a3e' },
{ id: 'hearts', name: 'Hearts', emojis: ['❤️', '💕', '💗', '💖', '💝', '💘'], bgColor: '#8b2252' },
{ id: 'garden', name: 'Garden', emojis: ['🦋', '🐝', '🌿', '🌱', '🐞', '🍀'], bgColor: '#2d5a27' },
{ id: 'winter', name: 'Winter', emojis: ['❄️', '⛄', '🌨️', '🏔️', '🎿', '🧣'], bgColor: '#4a6d8c' },
{ id: 'fruit', name: 'Fruit', emojis: ['🍊', '🍋', '🍓', '🍑', '🍒', '🫐'], bgColor: '#6b4226' },
{ id: 'sparkle', name: 'Sparkle', emojis: ['✨', '💎', '🔮', '🪩', '⚡', '🌈'], bgColor: '#4a2d6b' },
];
export interface Letter {
event: NostrEvent;
recipient: string;
sender: string;
decrypted: boolean;
timestamp: number;
}
/**
* Built-in stationery presets — flat single color + optional emoji backsplash.
* No gradients.
*/
export const STATIONERY_PRESETS: Record<string, { name: string; color: string; emoji?: string }> = {
parchment: { name: 'Parchment', color: DEFAULT_STATIONERY_COLOR, emoji: undefined },
meadow: { name: 'Meadow', color: '#C8E6C9', emoji: '🌿' },
twilight: { name: 'Twilight', color: '#E1BEE7', emoji: '🌙' },
ocean: { name: 'Ocean', color: '#B3E5FC', emoji: '🌊' },
blossom: { name: 'Blossom', color: '#FCE4EC', emoji: '🌸' },
forest: { name: 'Forest', color: '#DCEDC8', emoji: '🌲' },
butter: { name: 'Butter', color: '#FFF9C4', emoji: '🌻' },
peach: { name: 'Peach', color: '#FFE0CC', emoji: '🍑' },
mint: { name: 'Mint', color: '#E0F2E9', emoji: '🍃' },
lavender: { name: 'Lavender', color: '#EDE7F6', emoji: '💜' },
};
export const CLOSING_PRESETS = [
'With Love,',
'Warmly,',
'Yours Truly,',
'XO,',
'Until Next Time,',
'Thinking of You,',
'Forever Yours,',
'With Gratitude,',
];
export const FONT_OPTIONS = [
{ value: 'fredoka', label: 'Fredoka', family: 'Fredoka Variable, Fredoka, sans-serif' },
{ value: 'nunito', label: 'Nunito', family: 'Nunito Variable, Nunito, sans-serif' },
{ value: 'playfair', label: 'Playfair', family: 'Playfair Display Variable, Playfair Display, serif' },
{ value: 'caveat', label: 'Caveat', family: 'Caveat, cursive' },
{ value: 'pacifico', label: 'Pacifico', family: 'Pacifico, cursive' },
{ value: 'pirata', label: 'Pirata', family: 'Pirata One, cursive' },
{ value: 'marker', label: 'Marker', family: 'Permanent Marker, cursive' },
{ value: 'typewriter', label: 'Typewriter', family: 'Special Elite, cursive' },
{ value: 'creepster', label: 'Creepster', family: 'Creepster, cursive' },
{ value: 'pixel', label: 'Pixel', family: 'Silkscreen, monospace' },
{ value: 'mono', label: 'Mono', family: 'ui-monospace, monospace' },
];
/**
* Serializable stationery for localStorage persistence (no raw NostrEvent).
*/
export type SerializableStationery = Omit<Stationery, 'event'>;
/**
* User's default letter preferences — persisted per-pubkey in settings.
*/
export interface LetterPreferences {
/** Font value key from FONT_OPTIONS (e.g. 'caveat') */
font?: string;
/** Default stationery (without raw event) */
stationery?: SerializableStationery;
/** Default frame style */
frame?: FrameStyle;
/** Whether frame should match stationery color */
frameTint?: boolean;
/** Default closing line (e.g. 'Warmly,') */
closing?: string;
/** Default signature (e.g. 'Chad') */
signature?: string;
/** Only show letters from friends in the inbox */
friendsOnlyInbox?: boolean;
/** Only show friends in search suggestions */
friendsOnlySearch?: boolean;
}
/** Build a Stationery from a preset key */
export function presetToStationery(key: string): Stationery | undefined {
const preset = STATIONERY_PRESETS[key];
if (!preset) return undefined;
return { color: preset.color, emoji: preset.emoji };
}
/** Build a Stationery from a kind 3367 color moment event. */
export function colorMomentToStationery(event: NostrEvent): Stationery {
const color = event.tags.find(([n]) => n === 'c')?.[1] ?? DEFAULT_STATIONERY_COLOR;
return { color, event };
}
/** Build a Stationery from a kind 36767 theme event. */
export function themeToStationery(event: NostrEvent): Stationery {
const bg = event.tags.filter(([n]) => n === 'c').find(([, , marker]) => marker === 'background');
return { color: bg?.[1] ?? DEFAULT_STATIONERY_COLOR, event };
}
+32
View File
@@ -0,0 +1,32 @@
import { FONT_OPTIONS } from '@/lib/letterTypes';
import { loadBundledFont } from '@/lib/fonts';
/**
* Resolve the effective font-family string for a letter.
*
* If the user has selected a non-default font, that takes priority.
* Otherwise the stationery's themeFont is used.
* Falls back to the default font if neither is set.
*/
export function resolveFont(
selectedFamily: string,
themeFont: string | undefined,
): string {
const defaultFamily = FONT_OPTIONS[0].family;
const rawFont = selectedFamily !== defaultFamily ? selectedFamily : themeFont;
if (!rawFont) return defaultFamily;
return rawFont.includes(',') ? rawFont : `${rawFont}, ${defaultFamily}`;
}
/**
* Ensure all bundled fonts referenced in a CSS font-family string are loaded.
* Parses the comma-separated list, strips quotes/whitespace, and calls
* loadBundledFont for each segment. No-ops for already-loaded or unknown fonts.
*/
export function ensureLetterFonts(cssFontFamily: string | undefined): void {
if (!cssFontFamily) return;
const families = cssFontFamily.split(',').map((s) => s.trim().replace(/^["']|["']$/g, ''));
for (const family of families) {
if (family) loadBundledFont(family);
}
}
+98
View File
@@ -0,0 +1,98 @@
/**
* SVG sanitization — prevents XSS from untrusted SVG markup.
*
* The drawing canvas (svgDrawing.ts) produces SVGs containing only <svg> and
* <path> elements with a small set of presentation attributes. A malicious
* sender could craft a letter whose sticker `svg` field contains arbitrary
* HTML/JS (e.g. `<svg onload="…">`, `<foreignObject>`, `<script>`).
*
* This module uses DOMPurify configured for SVG-only output with an additional
* strict allowlist of elements and attributes that matches what the drawing
* canvas actually generates.
*/
import DOMPurify from 'dompurify';
/**
* Elements the drawing canvas can produce, plus structural SVG elements
* that are safe and useful for rendering simple vector graphics.
*/
const ALLOWED_TAGS = [
'svg',
'path',
'circle',
'ellipse',
'rect',
'line',
'polyline',
'polygon',
'g',
];
/**
* Attributes the drawing canvas uses, plus standard safe SVG presentation
* attributes. No event handlers, no `href`/`xlink:href`, no `style`.
*/
const ALLOWED_ATTRS = [
// Structural
'xmlns',
'viewBox',
'width',
'height',
// Path data
'd',
// Presentation
'fill',
'stroke',
'stroke-width',
'stroke-linecap',
'stroke-linejoin',
'stroke-dasharray',
'stroke-dashoffset',
'stroke-opacity',
'fill-opacity',
'opacity',
'transform',
// Geometry attributes for basic shapes
'cx',
'cy',
'r',
'rx',
'ry',
'x',
'y',
'x1',
'y1',
'x2',
'y2',
'points',
];
/** Maximum SVG string length (256 KB). Anything larger is likely malicious or degenerate. */
const MAX_SVG_LENGTH = 256 * 1024;
/**
* Sanitize an SVG string so it is safe to inject via `dangerouslySetInnerHTML`.
*
* Returns a clean SVG string with only allowlisted elements and attributes.
* Any scripts, event handlers, foreignObject, use/image elements, data URIs,
* and other dangerous constructs are stripped.
*
* SVGs exceeding MAX_SVG_LENGTH are rejected outright to prevent denial-of-service
* via oversized payloads that would force DOMPurify to parse megabytes of markup.
*/
export function sanitizeSvg(dirty: string): string {
if (dirty.length > MAX_SVG_LENGTH) return '';
return DOMPurify.sanitize(dirty, {
USE_PROFILES: { svg: true, svgFilters: false },
ALLOWED_TAGS,
ALLOWED_ATTR: ALLOWED_ATTRS,
// Strip <use>, <image>, <foreignObject>, <script>, <style>, etc.
FORBID_TAGS: ['script', 'style', 'foreignObject', 'use', 'image', 'a', 'iframe', 'embed', 'object'],
// Strip all event handler attributes (on*)
FORBID_ATTR: ['onload', 'onerror', 'onclick', 'onmouseover', 'onmouseout', 'onfocus', 'onblur', 'style', 'href', 'xlink:href'],
// Return string, not DOM node
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
});
}
+9
View File
@@ -15,6 +15,7 @@ import {
Earth,
Film,
HelpCircle,
MessageSquare,
MessageSquareMore,
Mic,
@@ -37,6 +38,7 @@ import { ChestIcon } from "@/components/icons/ChestIcon";
import { PlanetIcon } from "@/components/icons/PlanetIcon";
import { WikipediaIcon } from "@/components/icons/WikipediaIcon";
import { BlueskyIcon } from "@/components/icons/BlueskyIcon";
import { MailboxIcon } from "@/components/icons/MailboxIcon";
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -130,6 +132,13 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
requiresAuth: true,
},
{ id: "settings", label: "Settings", path: "/settings", icon: Settings },
{
id: "letters",
label: "Letters",
path: "/letters",
icon: MailboxIcon,
requiresAuth: true,
},
{
id: "ai-chat",
label: "AI Chat",
+132
View File
@@ -0,0 +1,132 @@
/**
* SVG drawing utilities — pure functions for converting freehand strokes
* into compact SVG markup.
*/
export interface Stroke {
points: [number, number][];
color: string;
width: number;
}
// ---------------------------------------------------------------------------
// Point simplification (Ramer-Douglas-Peucker)
// ---------------------------------------------------------------------------
/** Perpendicular distance from point p to line segment a-b */
function perpendicularDist(p: [number, number], a: [number, number], b: [number, number]): number {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
/** Ramer-Douglas-Peucker simplification. Epsilon ~1-2 works well for a 300-unit viewBox. */
export function simplifyPoints(points: [number, number][], epsilon: number): [number, number][] {
if (points.length <= 2) return points;
let maxDist = 0;
let maxIdx = 0;
const last = points.length - 1;
for (let i = 1; i < last; i++) {
const d = perpendicularDist(points[i], points[0], points[last]);
if (d > maxDist) { maxDist = d; maxIdx = i; }
}
if (maxDist <= epsilon) return [points[0], points[last]];
const left = simplifyPoints(points.slice(0, maxIdx + 1), epsilon);
const right = simplifyPoints(points.slice(maxIdx), epsilon);
return [...left.slice(0, -1), ...right];
}
// ---------------------------------------------------------------------------
// Path generation
// ---------------------------------------------------------------------------
/** Round a number to 1 decimal place to reduce SVG string size */
function r(n: number): number {
return Math.round(n * 10) / 10;
}
/** Build an SVG path d-attribute with quadratic bezier smoothing */
export function pointsToPath(points: [number, number][]): string {
if (points.length === 0) return '';
if (points.length === 1) {
return `M${r(points[0][0])},${r(points[0][1])}l0,0`;
}
if (points.length === 2) {
return `M${r(points[0][0])},${r(points[0][1])}L${r(points[1][0])},${r(points[1][1])}`;
}
let d = `M${r(points[0][0])},${r(points[0][1])}`;
for (let i = 1; i < points.length - 1; i++) {
const [cx, cy] = points[i];
const [nx, ny] = points[i + 1];
d += `Q${r(cx)},${r(cy)},${r((cx + nx) / 2)},${r((cy + ny) / 2)}`;
}
const last = points[points.length - 1];
d += `L${r(last[0])},${r(last[1])}`;
return d;
}
// ---------------------------------------------------------------------------
// Bounding box
// ---------------------------------------------------------------------------
export function computeBBox(strokes: Stroke[]): { x: number; y: number; w: number; h: number } | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const stroke of strokes) {
const hw = stroke.width / 2;
for (const [px, py] of stroke.points) {
if (px - hw < minX) minX = px - hw;
if (py - hw < minY) minY = py - hw;
if (px + hw > maxX) maxX = px + hw;
if (py + hw > maxY) maxY = py + hw;
}
}
if (!isFinite(minX)) return null;
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
// ---------------------------------------------------------------------------
// SVG serialization
// ---------------------------------------------------------------------------
const PADDING = 8;
const SIMPLIFY_EPSILON = 1.5;
/** Matches hex colors (#rgb, #rrggbb, #rrggbbaa) and common CSS named colors. */
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
/** Sanitize a color value for safe SVG attribute interpolation. Returns a fallback for invalid values. */
function safeColor(color: string): string {
return HEX_COLOR_RE.test(color) ? color : '#000000';
}
/** Clamp stroke width to a safe finite number. */
function safeWidth(width: number): number {
const n = Number(width);
return Number.isFinite(n) && n > 0 && n <= 100 ? n : 4;
}
/** Generate a tightly-cropped SVG string from strokes. Points are simplified to reduce size. */
export function strokesToSvg(strokes: Stroke[]): string | null {
if (strokes.length === 0) return null;
const bbox = computeBBox(strokes);
if (!bbox || bbox.w < 1 || bbox.h < 1) return null;
const vx = r(Math.max(0, bbox.x - PADDING));
const vy = r(Math.max(0, bbox.y - PADDING));
const vw = r(bbox.w + PADDING * 2);
const vh = r(bbox.h + PADDING * 2);
const paths = strokes.map((s) => {
const simplified = simplifyPoints(s.points, SIMPLIFY_EPSILON);
return `<path d="${pointsToPath(simplified)}" fill="none" stroke="${safeColor(s.color)}" stroke-width="${safeWidth(s.width)}" stroke-linecap="round" stroke-linejoin="round"/>`;
});
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vx} ${vy} ${vw} ${vh}">${paths.join('')}</svg>`;
}
+15
View File
@@ -0,0 +1,15 @@
import { useSeoMeta } from '@unhead/react';
import { LetterPreferencesSection } from '@/components/letter/LetterPreferencesSection';
export function LetterPreferencesPage() {
useSeoMeta({
title: 'Letter Preferences',
description: 'Customize your default letter stationery, font, and inbox settings',
});
return (
<main className="min-h-screen pb-16 sidebar:pb-0">
<LetterPreferencesSection />
</main>
);
}
+203
View File
@@ -0,0 +1,203 @@
import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate } from 'react-router-dom';
import { PenLine, Settings, Loader2 } from 'lucide-react';
import { MailboxIcon } from '@/components/icons/MailboxIcon';
import { Button } from '@/components/ui/button';
import { FabButton } from '@/components/FabButton';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useInbox, useSentLetters } from '@/hooks/useLetters';
import { useLetterPreferences } from '@/hooks/useLetterPreferences';
import { useFollowList } from '@/hooks/useFollowActions';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { LoginArea } from '@/components/auth/LoginArea';
import { PageHeader } from '@/components/PageHeader';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { EnvelopeCard } from '@/components/letter/EnvelopeCard';
import { LetterDetailSheet } from '@/components/letter/LetterDetailSheet';
import { ComposeLetterSheet } from '@/components/letter/ComposeLetterSheet';
import type { Letter } from '@/lib/letterTypes';
type Tab = 'inbox' | 'sent';
/** Skeleton envelope matching the grid tile shape. */
function EnvelopeSkeleton({ index }: { index: number }) {
return (
<div
className="envelope-skeleton flex flex-col items-center gap-2"
style={{ animationDelay: `${index * 150}ms` }}
>
<div className="w-full rounded-lg bg-muted/60" style={{ aspectRatio: '4 / 3' }} />
<div className="flex flex-col items-center gap-1 w-full">
<div className="h-3 w-14 rounded-full bg-muted/50" />
<div className="h-2 w-10 rounded-full bg-muted/30" />
</div>
</div>
);
}
export function LettersPage() {
const { user } = useCurrentUser();
const navigate = useNavigate();
const [tab, setTab] = useState<Tab>('inbox');
const [composing, setComposing] = useState(false);
const [selectedLetter, setSelectedLetter] = useState<Letter | null>(null);
useLayoutOptions({ showFAB: false, hasSubHeader: !!user, noOverscroll: composing });
const { prefs } = useLetterPreferences();
const followListData = useFollowList();
const followedPubkeys = followListData.data?.pubkeys;
// If friendsOnlyInbox is enabled, only show letters from followed users
const inboxFilter = prefs.friendsOnlyInbox && followedPubkeys
? followedPubkeys
: undefined;
const inboxQuery = useInbox(inboxFilter);
const sentQuery = useSentLetters();
const inbox = inboxQuery.data;
const inboxLoading = inboxQuery.isLoading;
const sent = sentQuery.data;
const sentLoading = sentQuery.isLoading;
const activeQuery = tab === 'inbox' ? inboxQuery : sentQuery;
useSeoMeta({ title: 'Letters', description: 'Your private encrypted letters' });
if (!user) {
return (
<main className="min-h-screen pb-16 sidebar:pb-0">
<PageHeader title="Letters" icon={<MailboxIcon className="size-5" />} backTo="/" />
<div className="flex flex-col items-center justify-center py-24 gap-6 px-6 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center">
<MailboxIcon className="w-10 h-10 text-primary" />
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold">Your personal inbox</h2>
<p className="text-muted-foreground text-sm max-w-xs">
Send and receive beautiful encrypted letters with stationery, frames, and stickers.
</p>
</div>
<LoginArea />
</div>
</main>
);
}
const activeLetters = tab === 'inbox' ? inbox : sent;
const isLoading = tab === 'inbox' ? inboxLoading : sentLoading;
return (
<main
className={composing ? 'relative h-screen overflow-hidden' : 'relative min-h-screen pb-16 sidebar:pb-0'}
style={composing ? { touchAction: 'none' } : undefined}
>
{composing && (
<ComposeLetterSheet
onClose={() => setComposing(false)}
/>
)}
<PageHeader title="Letters" icon={<MailboxIcon className="size-5" />} backTo="/" alwaysShowBack>
<button
onClick={() => navigate('/settings/letters')}
className="p-2 rounded-full text-muted-foreground hover:text-foreground transition-colors"
title="Letter preferences"
>
<Settings className="w-4 h-4" />
</button>
</PageHeader>
{/* Tabs */}
<SubHeaderBar>
<TabButton label="Inbox" active={tab === 'inbox'} onClick={() => setTab('inbox')} />
<TabButton label="Sent" active={tab === 'sent'} onClick={() => setTab('sent')} />
</SubHeaderBar>
<div style={{ height: ARC_OVERHANG_PX }} />
{/* Envelope grid */}
<div className="px-4 py-3">
{isLoading && (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 sidebar:grid-cols-3">
{Array.from({ length: 9 }).map((_, i) => (
<EnvelopeSkeleton key={i} index={i} />
))}
</div>
)}
{!isLoading && activeLetters && activeLetters.length === 0 && (
<div className="py-16 text-center space-y-3">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto">
<MailboxIcon className="w-8 h-8 text-muted-foreground opacity-50" />
</div>
<p className="text-muted-foreground text-sm">
{tab === 'inbox'
? prefs.friendsOnlyInbox
? 'no letters from friends yet'
: 'no letters yet'
: 'no sent letters yet'
}
</p>
{tab === 'inbox' && (
<p className="text-xs text-muted-foreground opacity-70">
ask a friend to send you a letter
</p>
)}
</div>
)}
{!isLoading && activeLetters && activeLetters.length > 0 && (
<>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 sidebar:grid-cols-3">
{activeLetters.map((letter, i) => (
<EnvelopeCard
key={letter.event.id}
letter={letter}
mode={tab}
index={i}
onClick={() => setSelectedLetter(letter)}
/>
))}
</div>
{activeQuery.hasNextPage && (
<div className="flex justify-center pt-6 pb-2">
<Button
variant="ghost"
size="sm"
onClick={() => activeQuery.fetchNextPage()}
disabled={activeQuery.isFetchingNextPage}
className="gap-2"
>
{activeQuery.isFetchingNextPage && <Loader2 className="size-4 animate-spin" />}
Load more
</Button>
</div>
)}
</>
)}
</div>
{/* Letter detail drawer */}
<LetterDetailSheet
letter={selectedLetter}
onClose={() => setSelectedLetter(null)}
/>
{/* Compose FAB */}
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
<FabButton onClick={() => setComposing(true)} icon={<PenLine size={18} strokeWidth={3} />} title="Write a letter" />
</div>
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
<div className="flex justify-end pr-4">
<div className="pointer-events-auto">
<FabButton onClick={() => setComposing(true)} icon={<PenLine size={18} strokeWidth={3} />} title="Write a letter" />
</div>
</div>
</div>
</main>
);
}
+7
View File
@@ -57,6 +57,13 @@ const settingsSections: SettingsSection[] = [
path: '/settings/notifications',
requiresAuth: true,
},
{
id: 'letters',
label: 'Letters',
description: 'Default stationery, font, frame, and inbox preferences for encrypted letters',
path: '/settings/letters',
requiresAuth: true,
},
{
id: 'advanced',
label: 'Advanced',