From 865fabce984e43239ff78a43e4a1fa11a323fcd9 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Thu, 26 Mar 2026 16:11:29 -0500 Subject: [PATCH 01/23] Add Letters inbox page with full letter composing from lief - Port letter protocol (kind 8211, NIP-44 encrypted) from lief - LettersPage at /letters with inbox and sent tabs - ComposeLetterSheet with full stationery, font, frame, sticker, drawing support - LetterCard with expand-to-read animation and deletion - LetterPreferencesSection stored in encrypted settings (NIP-78) - /settings/letters route for letter preferences - Letters added to sidebar nav - All letter lib utilities: letterTypes, letterUtils, colorUtils extensions, sanitizeSvg, svgDrawing - StationeryBackground, StationeryPicker, FramePicker, StickerPicker, DrawingCanvas all ported --- NIP.md | 1 + package-lock.json | 51 +++ package.json | 2 + src/AppRouter.tsx | 4 + src/components/letter/ColorPaletteDisplay.tsx | 207 +++++++++ src/components/letter/ComposeLetterSheet.tsx | 374 +++++++++++++++ src/components/letter/DrawingCanvas.tsx | 163 +++++++ src/components/letter/FramePicker.tsx | 47 ++ src/components/letter/FramePreviews.tsx | 39 ++ src/components/letter/LetterCard.tsx | 324 +++++++++++++ src/components/letter/LetterEditor.tsx | 313 +++++++++++++ .../letter/LetterPreferencesSection.tsx | 141 ++++++ .../letter/LetterRecipientInput.tsx | 168 +++++++ src/components/letter/LetterStickers.tsx | 318 +++++++++++++ .../letter/StationeryBackground.tsx | 339 ++++++++++++++ src/components/letter/StationeryPicker.tsx | 431 ++++++++++++++++++ src/components/letter/StickerPicker.tsx | 88 ++++ src/hooks/useEncryptedSettings.ts | 3 + src/hooks/useLetterPreferences.ts | 26 ++ src/hooks/useLetters.ts | 148 ++++++ src/hooks/useStationery.ts | 74 +++ src/hooks/useStationeryColors.ts | 66 +++ src/lib/colorUtils.ts | 58 +++ src/lib/letterTypes.ts | 287 ++++++++++++ src/lib/letterUtils.ts | 18 + src/lib/sanitizeSvg.ts | 91 ++++ src/lib/sidebarItems.tsx | 8 + src/lib/svgDrawing.ts | 132 ++++++ src/pages/LetterPreferencesPage.tsx | 22 + src/pages/LettersPage.tsx | 170 +++++++ src/pages/SettingsPage.tsx | 7 + 31 files changed, 4120 insertions(+) create mode 100644 src/components/letter/ColorPaletteDisplay.tsx create mode 100644 src/components/letter/ComposeLetterSheet.tsx create mode 100644 src/components/letter/DrawingCanvas.tsx create mode 100644 src/components/letter/FramePicker.tsx create mode 100644 src/components/letter/FramePreviews.tsx create mode 100644 src/components/letter/LetterCard.tsx create mode 100644 src/components/letter/LetterEditor.tsx create mode 100644 src/components/letter/LetterPreferencesSection.tsx create mode 100644 src/components/letter/LetterRecipientInput.tsx create mode 100644 src/components/letter/LetterStickers.tsx create mode 100644 src/components/letter/StationeryBackground.tsx create mode 100644 src/components/letter/StationeryPicker.tsx create mode 100644 src/components/letter/StickerPicker.tsx create mode 100644 src/hooks/useLetterPreferences.ts create mode 100644 src/hooks/useLetters.ts create mode 100644 src/hooks/useStationery.ts create mode 100644 src/hooks/useStationeryColors.ts create mode 100644 src/lib/letterTypes.ts create mode 100644 src/lib/letterUtils.ts create mode 100644 src/lib/sanitizeSvg.ts create mode 100644 src/lib/svgDrawing.ts create mode 100644 src/pages/LetterPreferencesPage.tsx create mode 100644 src/pages/LettersPage.tsx 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 57abf7f1..5f41beb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "@radix-ui/react-tooltip": "^1.1.4", "@sentry/react": "^10.42.0", "@tanstack/react-query": "^5.56.2", + "@types/dompurify": "^3.0.5", "@unhead/addons": "^2.0.10", "@unhead/react": "^2.0.10", "blurhash": "^2.0.5", @@ -80,6 +81,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", @@ -3170,6 +3172,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3183,6 +3186,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3196,6 +3200,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3209,6 +3214,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3222,6 +3228,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3235,6 +3242,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3248,6 +3256,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3261,6 +3270,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3274,6 +3284,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3287,6 +3298,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3300,6 +3312,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3313,6 +3326,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3326,6 +3340,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3339,6 +3354,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3352,6 +3368,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3365,6 +3382,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3378,6 +3396,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3391,6 +3410,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3404,6 +3424,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3417,6 +3438,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3430,6 +3452,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3443,6 +3466,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3456,6 +3480,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3469,6 +3494,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3482,6 +3508,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3924,6 +3951,15 @@ "@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==", + "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 +4069,12 @@ "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==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5529,6 +5571,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 58a3dd9f..e21ae34c 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "@radix-ui/react-tooltip": "^1.1.4", "@sentry/react": "^10.42.0", "@tanstack/react-query": "^5.56.2", + "@types/dompurify": "^3.0.5", "@unhead/addons": "^2.0.10", "@unhead/react": "^2.0.10", "blurhash": "^2.0.5", @@ -88,6 +89,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", diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 5fb5de26..79c828fc 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -54,6 +54,8 @@ import { WorldPage } from "./pages/WorldPage"; import { ArchivePage } from "./pages/ArchivePage"; import { BlueskyPage } from "./pages/BlueskyPage"; import { WikipediaPage } from "./pages/WikipediaPage"; +import { LettersPage } from "./pages/LettersPage"; +import { LetterPreferencesPage } from "./pages/LetterPreferencesPage"; const pollsDef = getExtraKindDef("polls")!; const colorsDef = getExtraKindDef("colors")!; @@ -210,6 +212,8 @@ export function AppRouter() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/letter/ColorPaletteDisplay.tsx b/src/components/letter/ColorPaletteDisplay.tsx new file mode 100644 index 00000000..21abadbf --- /dev/null +++ b/src/components/letter/ColorPaletteDisplay.tsx @@ -0,0 +1,207 @@ +/** + * 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. + */ + +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 ( +
+ ); +} + +function hexLum(hex: string): number { + const h = hex.replace('#', ''); + const r = parseInt(h.slice(0, 2), 16) / 255; + const g = parseInt(h.slice(2, 4), 16) / 255; + const b = parseInt(h.slice(4, 6), 16) / 255; + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +export function ColorPaletteDisplay({ + colors, + layout = 'horizontal', + className = '', + children, +}: ColorPaletteDisplayProps) { + if (colors.length === 0) return null; + + const avgLum = colors.reduce((s, c) => s + hexLum(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..4f6885c9 --- /dev/null +++ b/src/components/letter/ComposeLetterSheet.tsx @@ -0,0 +1,374 @@ +import { useState, useMemo, useRef, useCallback, useEffect, type CSSProperties } from 'react'; +import { ArrowLeft, Pencil, Sticker } from 'lucide-react'; +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 { toast } from '@/hooks/useToast'; +import { genUserName } from '@/lib/genUserName'; +import { + LETTER_KIND, + FONT_OPTIONS, + type Stationery, + type FrameStyle, + type LetterContent, + type LetterSticker, + presetToStationery, +} from '@/lib/letterTypes'; +import { useLetterPreferences } from '@/hooks/useLetterPreferences'; +import { useCustomEmojis } from '@/hooks/useCustomEmojis'; +import { LetterEditor } from './LetterEditor'; +import { LetterStickers } from './LetterStickers'; +import { StickerPicker } from './StickerPicker'; +import { DrawingCanvas } from './DrawingCanvas'; +import { LetterRecipientInput } from './LetterRecipientInput'; + +const BODY_MAX_LENGTH = 220; + +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 } = useLetterPreferences(); + + 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], + ); + const [stationery, setStationery] = useState( + () => (prefs.stationery as Stationery) ?? presetToStationery('parchment') ?? { color: '#F5E6D3' }, + ); + 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 [flyingAway, setFlyingAway] = useState(false); + const [sent, setSent] = useState(false); + + const recipientAuthor = useAuthor(resolvedRecipient); + const recipientName = recipientAuthor.data?.metadata?.display_name + || recipientAuthor.data?.metadata?.name + || (resolvedRecipient ? genUserName(resolvedRecipient) : 'friend'); + + 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'] }); + + setFlyingAway(true); + await new Promise((r) => setTimeout(r, 420)); + setSent(true); + setTimeout(onClose, 1800); + } catch (err) { + console.error('Failed to send letter:', err); + setSealing(false); + setFlyingAway(false); + toast({ title: "couldn't send that one", variant: 'destructive' }); + } + }; + + if (sent) { + return ( +
+
+
✉️
+

letter sent!

+

your letter is on its way to {recipientName}

+
+
+ ); + } + + return ( +
+ setOverlay(o as Overlay)} + headerLeft={ + + } + extraButtons={ + <> + {customEmojis.length > 0 && ( + + )} + + + } + extraDrawerContent={ + <> + {overlay === 'sticker' && } + {overlay === 'draw' && setOverlay('none')} />} + + } + beforeCard={ +
+
+ To + {!initialRecipient ? ( +
+ setResolvedRecipient(pubkey)} + initialNpub={resolvedRecipient ? nip19.npubEncode(resolvedRecipient) : undefined} + friendsOnly={prefs.friendsOnlySearch} + /> +
+ ) : ( + + {nip19.npubEncode(initialRecipient).slice(0, 16)}... + + )} +
+
+ } + bodyContent={({ lineHeightPx, stationeryTextColor: textColor, stationeryLineColor: lineColor, resolvedFontFamily: fontFamily }) => ( +