diff --git a/NIP.md b/NIP.md index bd1b19c2..62e4b5ab 100644 --- a/NIP.md +++ b/NIP.md @@ -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 | --- diff --git a/package-lock.json b/package-lock.json index d8f70734..33c55798 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 957d2252..705906f5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 2e1b6143..11baa26c 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/ColorMomentContent.tsx b/src/components/ColorMomentContent.tsx index c84f945d..dd2059c4 100644 --- a/src/components/ColorMomentContent.tsx +++ b/src/components/ColorMomentContent.tsx @@ -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%' }; } diff --git a/src/components/FabButton.tsx b/src/components/FabButton.tsx new file mode 100644 index 00000000..772cd6b7 --- /dev/null +++ b/src/components/FabButton.tsx @@ -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(() => { + 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 ( + + ); +} diff --git a/src/components/FloatingComposeButton.tsx b/src/components/FloatingComposeButton.tsx index f56428ea..47059bd1 100644 --- a/src/components/FloatingComposeButton.tsx +++ b/src/components/FloatingComposeButton.tsx @@ -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(() => { - 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 ( <> - + icon={icon ?? } + /> {/* Kind 1: Compose modal */} {kind === 1 && ( diff --git a/src/components/TabButton.tsx b/src/components/TabButton.tsx index 9334a435..83584155 100644 --- a/src/components/TabButton.tsx +++ b/src/components/TabButton.tsx @@ -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, diff --git a/src/components/icons/MailboxIcon.tsx b/src/components/icons/MailboxIcon.tsx new file mode 100644 index 00000000..f096406e --- /dev/null +++ b/src/components/icons/MailboxIcon.tsx @@ -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>( + ({ className, strokeWidth = 2, ...props }, ref) => ( + + + + + ), +); + +MailboxIcon.displayName = 'MailboxIcon'; diff --git a/src/components/letter/ColorPaletteDisplay.tsx b/src/components/letter/ColorPaletteDisplay.tsx new file mode 100644 index 00000000..ad23de8e --- /dev/null +++ b/src/components/letter/ColorPaletteDisplay.tsx @@ -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 ( +
+ {colors.map((color, i) => ( +
+ ))} +
+ ); +} + +function VerticalLayout({ colors }: { colors: string[] }) { + return ( +
+ {colors.map((color, i) => ( +
+ ))} +
+ ); +} + +function GridLayout({ colors }: { colors: string[] }) { + const { cols, rows } = gridDimensions(colors.length); + return ( +
+ {colors.map((color, i) => ( +
+ ))} +
+ ); +} + +function StarLayout({ colors }: { colors: string[] }) { + const n = colors.length; + return ( +
+ {colors.map((color, i) => ( +
+ ))} +
+ ); +} + +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( + `` + ); + } + } + const svg = `${rects.join('')}`; + const dataUri = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; + + return ( +
+ ); +} + +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 ``; + }); + + const svg = `${polygons.join('')}`; + + return ( +
+ ); +} + +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 ( +
+ {layout === 'horizontal' && } + {layout === 'vertical' && } + {layout === 'grid' && } + {layout === 'star' && } + {layout === 'checkerboard' && } + {layout === 'diagonalStripes' && } + + {children && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/src/components/letter/ComposeLetterSheet.tsx b/src/components/letter/ComposeLetterSheet.tsx new file mode 100644 index 00000000..b7d45a0b --- /dev/null +++ b/src/components/letter/ComposeLetterSheet.tsx @@ -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 ( +
+ +
+

+ {content.body} +

+ {content.closing && ( +
+

{content.closing}

+
+ )} +
+
+ {content.stickers && content.stickers.length > 0 && ( + + )} +
+ ); +} + +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 ( +
+ + + + {displayName[0]?.toUpperCase() || '?'} + + + {displayName} + {onClear && ( + + )} +
+ ); +} + +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(null); + const bodyAreaRef = useRef(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(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(themeStationery); + const [frame, setFrame] = useState(() => prefs.frame ?? 'none'); + const [frameTint, setFrameTint] = useState(() => prefs.frameTint ?? false); + const [overlay, setOverlay] = useState('none'); + const [stickers, setStickers] = useState([]); + const { emojis: customEmojis } = useCustomEmojis(); + const [sealing, setSealing] = useState(false); + const [sendAnimationContent, setSendAnimationContent] = useState(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) => { + 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 + ? + : , + [sendAnimationContent, animLetterW], + ); + + return ( + <> + {/* Pre-render letter hidden so images/fonts are loaded before animation fires */} +
+ +
+ {sendAnimationContent && ( + + )} +
+ setOverlay(o as Overlay)} + renderToolbarButtons={(buttons: ReactNode, drawer: ReactNode) => ( +
+ {drawer} + + + {buttons} + +
+ )} + extraButtons={ + <> + {customEmojis.length > 0 && ( + setOverlay(overlay === 'sticker' ? 'none' : 'sticker')} + > + + + )} + setOverlay(overlay === 'draw' ? 'none' : 'draw')} + > + + + + } + extraDrawerContent={ + <> + {overlay === 'sticker' && } + {overlay === 'draw' && setOverlay('none')} />} + + } + beforeCard={ +
+
+ To + {!initialRecipient && !resolvedRecipient ? ( +
+ 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" + /> +
+ ) : ( + setResolvedRecipient(undefined)} + /> + )} +
+
+ } + bodyContent={({ lineHeightPx, stationeryTextColor: textColor, stationeryLineColor: lineColor, resolvedFontFamily: fontFamily }) => ( +