From 292fe289f73ffe66c71eef6367751d5c27bfc4c7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 19:47:21 -0500 Subject: [PATCH 001/104] Add sidebar nav items to autocomplete search results Allow users to search for sidebar navigation pages (e.g. Notifications, Settings, Bookmarks) in both the desktop and mobile search autocomplete. Results appear as instant local matches above profile/identifier results. --- src/components/MobileSearchSheet.tsx | 66 ++++++++++++++++-- src/components/ProfileSearchDropdown.tsx | 86 +++++++++++++++++++++--- src/lib/sidebarItems.tsx | 28 ++++++++ 3 files changed, 168 insertions(+), 12 deletions(-) diff --git a/src/components/MobileSearchSheet.tsx b/src/components/MobileSearchSheet.tsx index 912efe7b..cb9fddec 100644 --- a/src/components/MobileSearchSheet.tsx +++ b/src/components/MobileSearchSheet.tsx @@ -20,6 +20,8 @@ import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; import { useWikipediaSearch, type WikipediaSearchResult } from '@/hooks/useWikipediaSearch'; import { useArchiveSearch, type ArchiveSearchResult } from '@/hooks/useArchiveSearch'; import { WikipediaIcon } from '@/components/icons/WikipediaIcon'; +import { searchSidebarItems, type SidebarItemDef } from '@/lib/sidebarItems'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; import { cn } from '@/lib/utils'; interface MobileSearchSheetProps { @@ -30,6 +32,7 @@ interface MobileSearchSheetProps { export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { user } = useCurrentUser(); const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(-1); const inputRef = useRef(null); @@ -47,6 +50,9 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { // Country suggestion (local, synchronous) const countryMatch = useMemo(() => searchCountry(query), [query]); + // Nav item suggestions (local, synchronous) + const navItems = useMemo(() => searchSidebarItems(query, !!user), [query, user]); + // URL detection — show "Comment on" option when query is a full URL const queryIsUrl = useMemo(() => isFullUrl(query), [query]); const hasUrlComment = queryIsUrl; @@ -79,11 +85,14 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { const hasIdentifier = !!identifierMatch; const hasWikipedia = !!wikipediaResult; const hasArchive = !!archiveResult; + const navItemCount = navItems.length; - const totalItems = profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0); + const totalItems = navItemCount + profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0); - // Order: [identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?] + // Order: [...navItems, identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?] let nextMobileIdx = 0; + const navItemStartIndex = nextMobileIdx; + nextMobileIdx += navItemCount; const identifierIndex = hasIdentifier ? nextMobileIdx++ : -1; const urlCommentIndex = hasUrlComment ? nextMobileIdx++ : -1; const countryTopIndex = (hasCountry && countryAtTop) ? nextMobileIdx++ : -1; @@ -132,6 +141,11 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { navigate(path); }, [navigate, handleClose]); + const handleSelectNavItem = useCallback((item: SidebarItemDef) => { + handleClose(); + navigate(item.path); + }, [navigate, handleClose]); + const handleSelectWikipedia = useCallback((result: WikipediaSearchResult) => { handleClose(); navigate(`/i/${encodeURIComponent(result.url)}`); @@ -166,7 +180,9 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { if (e.key === 'Enter') { e.preventDefault(); if (selectedIndex >= 0 && selectedIndex < totalItems) { - if (hasIdentifier && selectedIndex === identifierIndex) { + if (navItemCount > 0 && selectedIndex >= navItemStartIndex && selectedIndex < navItemStartIndex + navItemCount) { + handleSelectNavItem(navItems[selectedIndex - navItemStartIndex]); + } else if (hasIdentifier && selectedIndex === identifierIndex) { // Identifier item navigation path is determined by the component // Trigger via its onClick handler const sheet = document.querySelector('[data-mobile-search-results]'); @@ -198,7 +214,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { } }; - const hasResults = query.trim().length > 0 && (hasIdentifier || hasUrlComment || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)); + const hasResults = query.trim().length > 0 && (navItemCount > 0 || hasIdentifier || hasUrlComment || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)); if (!open) return null; @@ -216,6 +232,14 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { {/* Results list — reversed so closest to input = most relevant */} {hasResults && (
+ {navItems.map((item, index) => ( + + ))} {hasIdentifier && ( void; +}) { + const Icon = item.icon; + + return ( + + ); +} + /** * Mobile autocomplete item for a detected Nostr identifier. */ diff --git a/src/components/ProfileSearchDropdown.tsx b/src/components/ProfileSearchDropdown.tsx index 60923d04..7e85bef4 100644 --- a/src/components/ProfileSearchDropdown.tsx +++ b/src/components/ProfileSearchDropdown.tsx @@ -22,6 +22,8 @@ import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; import { useWikipediaSearch, type WikipediaSearchResult } from '@/hooks/useWikipediaSearch'; import { useArchiveSearch, type ArchiveSearchResult } from '@/hooks/useArchiveSearch'; import { WikipediaIcon } from '@/components/icons/WikipediaIcon'; +import { searchSidebarItems, type SidebarItemDef } from '@/lib/sidebarItems'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; import { cn } from '@/lib/utils'; interface ProfileSearchDropdownProps { @@ -54,6 +56,7 @@ export function ProfileSearchDropdown({ const inputRef = useRef(null); const listRef = useRef(null); + const { user } = useCurrentUser(); const { data: rawProfiles, isFetching, followedPubkeys } = useSearchProfiles(query); // Wikipedia & Archive search (async, debounced by their hooks at >=2 chars) @@ -68,6 +71,9 @@ export function ProfileSearchDropdown({ const countryMatchRaw = useMemo(() => searchCountry(query), [query]); const countryMatch = hideCountry ? null : countryMatchRaw; + // Nav item suggestions (local, synchronous) + const navItems = useMemo(() => searchSidebarItems(query, !!user), [query, user]); + // URL detection — show "Comment on" option when query is a full URL const queryIsUrl = useMemo(() => isFullUrl(query), [query]); @@ -99,11 +105,11 @@ export function ProfileSearchDropdown({ // Show dropdown when we have results, or when text search is enabled and there's a query useEffect(() => { if (query.trim().length > 0) { - if (enableTextSearch || (profiles && profiles.length > 0) || countryMatch || wikipediaResult || archiveResult) { + if (enableTextSearch || (profiles && profiles.length > 0) || countryMatch || navItems.length > 0 || wikipediaResult || archiveResult) { setOpen(true); } } - }, [profiles, query, enableTextSearch, countryMatch, wikipediaResult, archiveResult]); + }, [profiles, query, enableTextSearch, countryMatch, navItems, wikipediaResult, archiveResult]); // Reset selected index when results change useEffect(() => { @@ -141,17 +147,20 @@ export function ProfileSearchDropdown({ navigate(`/search?q=${encodeURIComponent(query.trim())}`); }, [enableTextSearch, query, navigate]); - // Total selectable items: identifier? + URL comment? + country?(top) + profiles + country?(bottom) + wikipedia? + archive? + // Total selectable items: navItems + identifier? + URL comment? + country?(top) + profiles + country?(bottom) + wikipedia? + archive? const hasCountry = !!countryMatch; const hasUrlComment = queryIsUrl && enableTextSearch; const hasIdentifier = !!identifierMatch; const hasWikipedia = !!wikipediaResult; const hasArchive = !!archiveResult; - const totalItems = profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0); + const navItemCount = navItems.length; + const totalItems = navItemCount + profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0); // Map selectedIndex to what it refers to. - // Order: [identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?] + // Order: [...navItems, identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?] let nextIdx = 0; + const navItemStartIndex = nextIdx; + nextIdx += navItemCount; const identifierIndex = hasIdentifier ? nextIdx++ : -1; const urlCommentIndex = hasUrlComment ? nextIdx++ : -1; const countryTopIndex = (hasCountry && countryAtTop) ? nextIdx++ : -1; @@ -197,6 +206,13 @@ export function ProfileSearchDropdown({ navigate(path); }, [navigate]); + const handleSelectNavItem = useCallback((item: SidebarItemDef) => { + setOpen(false); + setQuery(''); + inputRef.current?.blur(); + navigate(item.path); + }, [navigate]); + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); @@ -208,7 +224,9 @@ export function ProfileSearchDropdown({ if (e.key === 'Enter') { e.preventDefault(); if (open && selectedIndex >= 0 && selectedIndex < totalItems) { - if (hasIdentifier && selectedIndex === identifierIndex) { + if (navItemCount > 0 && selectedIndex >= navItemStartIndex && selectedIndex < navItemStartIndex + navItemCount) { + handleSelectNavItem(navItems[selectedIndex - navItemStartIndex]); + } else if (hasIdentifier && selectedIndex === identifierIndex) { // Handled by the IdentifierItem component via its onClick // which calls handleSelectIdentifier — trigger via DOM click const items = listRef.current?.querySelectorAll('[data-search-item]'); @@ -303,13 +321,21 @@ export function ProfileSearchDropdown({
{/* Dropdown results — only when text search is not enabled */} - {!enableTextSearch && open && (hasIdentifier || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)) && ( + {!enableTextSearch && open && (navItemCount > 0 || hasIdentifier || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)) && (
+ {navItems.map((item, index) => ( + + ))} {hasIdentifier && ( + {/* Nav item results — sidebar pages matching query */} + {navItems.map((item, index) => ( + + ))} + {/* Identifier suggestion — NIP-05, NIP-19, hex */} {hasIdentifier && ( 0 && !isFetching && !hasIdentifier && !hasCountry && !hasWikipedia && !hasArchive && profiles && profiles.length === 0 && ( + {!enableTextSearch && open && query.trim().length > 0 && !isFetching && !hasIdentifier && !hasCountry && !hasWikipedia && !hasArchive && navItemCount === 0 && profiles && profiles.length === 0 && (
No profiles found @@ -459,6 +495,40 @@ export function ProfileSearchDropdown({ ); } +function NavItem({ + item, + isSelected, + onClick, +}: { + item: SidebarItemDef; + isSelected: boolean; + onClick: (item: SidebarItemDef) => void; +}) { + const Icon = item.icon; + + return ( + + ); +} + /** * Autocomplete item for a detected Nostr identifier (NIP-05, NIP-19, hex). * Resolves the identifier in the background and renders a profile or event preview. diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index 22332866..f19f39dd 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -227,6 +227,34 @@ export function itemPath( return SIDEBAR_ITEM_MAP.get(id)?.path ?? `/${id}`; } +/** + * Search sidebar items by label. Returns items whose label starts with or + * contains the query (case-insensitive). Prefix matches are sorted first. + * Auth-requiring items are excluded when the user is not logged in. + */ +export function searchSidebarItems( + query: string, + isLoggedIn: boolean, +): SidebarItemDef[] { + const q = query.trim().toLowerCase(); + if (q.length === 0) return []; + + const prefixMatches: SidebarItemDef[] = []; + const substringMatches: SidebarItemDef[] = []; + + for (const item of SIDEBAR_ITEMS) { + if (item.requiresAuth && !isLoggedIn) continue; + const label = item.label.toLowerCase(); + if (label.startsWith(q)) { + prefixMatches.push(item); + } else if (label.includes(q)) { + substringMatches.push(item); + } + } + + return [...prefixMatches, ...substringMatches]; +} + /** Check if a sidebar item is active given the current location. */ export function isItemActive( id: string, From dac2bf6bb02865eb1af5882940114c294b704358 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 19:49:21 -0500 Subject: [PATCH 002/104] Fix nav search to use word-boundary matching and remove path display Change search to only match from the start of words, so 'arch' matches 'Archive' but not 'Search'. Remove the pathname text from nav item results. --- src/components/MobileSearchSheet.tsx | 5 +---- src/components/ProfileSearchDropdown.tsx | 5 +---- src/lib/sidebarItems.tsx | 20 +++++++++++++------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/components/MobileSearchSheet.tsx b/src/components/MobileSearchSheet.tsx index cb9fddec..12067a8c 100644 --- a/src/components/MobileSearchSheet.tsx +++ b/src/components/MobileSearchSheet.tsx @@ -364,10 +364,7 @@ function MobileNavItem({
-
- {item.label} - {item.path} -
+ {item.label} ); } diff --git a/src/components/ProfileSearchDropdown.tsx b/src/components/ProfileSearchDropdown.tsx index 7e85bef4..4e55d160 100644 --- a/src/components/ProfileSearchDropdown.tsx +++ b/src/components/ProfileSearchDropdown.tsx @@ -521,10 +521,7 @@ function NavItem({
-
- {item.label} - {item.path} -
+ {item.label} ); } diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index f19f39dd..15f59d6a 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -228,9 +228,11 @@ export function itemPath( } /** - * Search sidebar items by label. Returns items whose label starts with or - * contains the query (case-insensitive). Prefix matches are sorted first. - * Auth-requiring items are excluded when the user is not logged in. + * Search sidebar items by label. Matches when the query is a prefix of the + * full label or of any word within the label (e.g. "arch" matches "Archive" + * and "Internet Archive" but not "Search"). Whole-label prefix matches are + * sorted before word-boundary matches. Auth-requiring items are excluded + * when the user is not logged in. */ export function searchSidebarItems( query: string, @@ -240,19 +242,23 @@ export function searchSidebarItems( if (q.length === 0) return []; const prefixMatches: SidebarItemDef[] = []; - const substringMatches: SidebarItemDef[] = []; + const wordMatches: SidebarItemDef[] = []; for (const item of SIDEBAR_ITEMS) { if (item.requiresAuth && !isLoggedIn) continue; const label = item.label.toLowerCase(); if (label.startsWith(q)) { prefixMatches.push(item); - } else if (label.includes(q)) { - substringMatches.push(item); + } else { + // Check if query matches the start of any word in the label + const words = label.split(/\s+/); + if (words.some((word) => word.startsWith(q))) { + wordMatches.push(item); + } } } - return [...prefixMatches, ...substringMatches]; + return [...prefixMatches, ...wordMatches]; } /** Check if a sidebar item is active given the current location. */ From 221aa033628a3df5f2d41b1490a3406eba6a6f75 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 20:11:29 -0500 Subject: [PATCH 003/104] Add nsite deployment support to development feed Display kind 15128 (root site) and 35128 (named site) events as deployment cards in the development feed with a link to view the site via the nosto.re gateway. --- src/components/NoteCard.tsx | 19 +++++- src/components/NsiteCard.tsx | 115 +++++++++++++++++++++++++++++++++++ src/lib/extraKinds.ts | 6 +- 3 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 src/components/NsiteCard.tsx diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 2c53189c..817d25e9 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -5,6 +5,7 @@ import { FileText, GitBranch, GitPullRequest, + Globe, MessageCircle, MoreHorizontal, Package, @@ -42,6 +43,7 @@ import { FollowPackContent } from "@/components/FollowPackContent"; import { FoundLogContent } from "@/components/FoundLogContent"; import { GeocacheContent } from "@/components/GeocacheContent"; import { GitRepoCard } from "@/components/GitRepoCard"; +import { NsiteCard } from "@/components/NsiteCard"; import { ImageGallery } from "@/components/ImageGallery"; import { CardsIcon } from "@/components/icons/CardsIcon"; import { ChestIcon } from "@/components/icons/ChestIcon"; @@ -257,9 +259,10 @@ export const NoteCard = memo(function NoteCard({ const isPatch = event.kind === 1617; const isPullRequest = event.kind === 1618; const isCustomNip = event.kind === 30817; + const isNsite = event.kind === 15128 || event.kind === 35128; const isZapstoreApp = event.kind === 32267; const isVanish = event.kind === 62; - const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip; + const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite; const isTextNote = !isVine && !isPoll && @@ -463,6 +466,8 @@ export const NoteCard = memo(function NoteCard({ ) : isCustomNip ? ( + ) : isNsite ? ( + ) : isZapstoreApp ? ( ) : ( @@ -1662,6 +1667,18 @@ const KIND_HEADER_MAP: Record = { noun: "NIP", nounRoute: "/development", }, + 15128: { + icon: Globe, + action: "deployed an", + noun: "nsite", + nounRoute: "/development", + }, + 35128: { + icon: Globe, + action: "deployed an", + noun: "nsite", + nounRoute: "/development", + }, }; /** Generic action header: icon · [author name] [action] [linked noun] */ diff --git a/src/components/NsiteCard.tsx b/src/components/NsiteCard.tsx new file mode 100644 index 00000000..ffb07fd1 --- /dev/null +++ b/src/components/NsiteCard.tsx @@ -0,0 +1,115 @@ +import type { NostrEvent } from "@nostrify/nostrify"; +import { ExternalLink, FileText, Globe } from "lucide-react"; +import { nip19 } from "nostr-tools"; + +interface NsiteCardProps { + event: NostrEvent; +} + +/** Encode a 32-byte hex pubkey as a base36 string (50 chars, zero-padded). */ +function hexToBase36(hex: string): string { + // Process the hex string in chunks to build a BigInt + let n = 0n; + for (let i = 0; i < hex.length; i++) { + n = n * 16n + BigInt(parseInt(hex[i], 16)); + } + const b36 = n.toString(36); + return b36.padStart(50, "0"); +} + +/** Build the nosto.re gateway URL for an nsite event. */ +function getNostoreUrl(event: NostrEvent): string { + const dTag = event.tags.find(([n]) => n === "d")?.[1]; + + if (event.kind === 35128 && dTag) { + // Named site: .nosto.re + const pubkeyB36 = hexToBase36(event.pubkey); + return `https://${pubkeyB36}${dTag}.nosto.re`; + } + + // Root site (kind 15128): .nosto.re + const npub = nip19.npubEncode(event.pubkey); + return `https://${npub}.nosto.re`; +} + +/** Renders an nsite deployment card for kind 15128 (root site) or 35128 (named site). */ +export function NsiteCard({ event }: NsiteCardProps) { + const title = event.tags.find(([n]) => n === "title")?.[1]; + const description = event.tags.find(([n]) => n === "description")?.[1]; + const dTag = event.tags.find(([n]) => n === "d")?.[1]; + const sourceUrl = event.tags.find(([n]) => n === "source")?.[1]; + const pathTags = event.tags.filter(([n]) => n === "path"); + const serverTags = event.tags.filter(([n]) => n === "server"); + + const isNamed = event.kind === 35128 && !!dTag; + const siteUrl = getNostoreUrl(event); + const displayName = title || (isNamed ? dTag : "Root Site"); + + return ( +
+
+ {/* Site name + type badge */} +
+ + + {displayName} + + + {isNamed ? "Named Site" : "Root Site"} + +
+ + {/* Description */} + {description && ( +

+ {description} +

+ )} + + {/* File count + server info */} +
+ {pathTags.length > 0 && ( + + + {pathTags.length} {pathTags.length === 1 ? "file" : "files"} + + )} + {serverTags.length > 0 && ( + + {serverTags.length}{" "} + {serverTags.length === 1 ? "server" : "servers"} + + )} +
+ + {/* Action buttons */} +
+ + {sourceUrl && ( + + )} +
+
+
+ ); +} diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index 77439c6d..a0cd75b1 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -463,13 +463,13 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ id: 'development', showKey: 'showDevelopment', feedKey: 'feedIncludeDevelopment', - extraFeedKinds: [1617, 1618, 30817], + extraFeedKinds: [1617, 1618, 30817, 15128, 35128], label: 'Development', - description: 'Git repos, patches, PRs, and custom NIPs', + description: 'Git repos, patches, PRs, nsites, and custom NIPs', route: 'development', addressable: true, section: 'development', - blurb: 'Nostr-native git repositories, patches, pull requests, custom NIPs, and published applications.', + blurb: 'Nostr-native git repositories, patches, pull requests, nsite deployments, custom NIPs, and published applications.', sites: [{ url: 'https://gitworkshop.dev', name: 'Gitworkshop' }, { url: 'https://nostrhub.io', name: 'NostrHub' }], }, ]; From dc406ff276e6c756960ab619ca3eba5d02aef889 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 20:14:50 -0500 Subject: [PATCH 004/104] Use nsite.lol gateway instead of nosto.re --- src/components/NsiteCard.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/NsiteCard.tsx b/src/components/NsiteCard.tsx index ffb07fd1..53dc5a9d 100644 --- a/src/components/NsiteCard.tsx +++ b/src/components/NsiteCard.tsx @@ -17,19 +17,19 @@ function hexToBase36(hex: string): string { return b36.padStart(50, "0"); } -/** Build the nosto.re gateway URL for an nsite event. */ -function getNostoreUrl(event: NostrEvent): string { +/** Build the nsite.lol gateway URL for an nsite event. */ +function getNsiteUrl(event: NostrEvent): string { const dTag = event.tags.find(([n]) => n === "d")?.[1]; if (event.kind === 35128 && dTag) { - // Named site: .nosto.re + // Named site: .nsite.lol const pubkeyB36 = hexToBase36(event.pubkey); - return `https://${pubkeyB36}${dTag}.nosto.re`; + return `https://${pubkeyB36}${dTag}.nsite.lol`; } - // Root site (kind 15128): .nosto.re + // Root site (kind 15128): .nsite.lol const npub = nip19.npubEncode(event.pubkey); - return `https://${npub}.nosto.re`; + return `https://${npub}.nsite.lol`; } /** Renders an nsite deployment card for kind 15128 (root site) or 35128 (named site). */ @@ -42,7 +42,7 @@ export function NsiteCard({ event }: NsiteCardProps) { const serverTags = event.tags.filter(([n]) => n === "server"); const isNamed = event.kind === 35128 && !!dTag; - const siteUrl = getNostoreUrl(event); + const siteUrl = getNsiteUrl(event); const displayName = title || (isNamed ? dTag : "Root Site"); return ( From 8e76650a5f0a8e1f31c2b23ec985832bb2ad6a85 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 20:23:17 -0500 Subject: [PATCH 005/104] Render nsite events correctly across all views Add NsiteCard rendering to PostDetailPage, reply composer, quote embeds, comment context labels, and external content headers so kind 15128/35128 events display properly everywhere. --- src/components/CommentContext.tsx | 1 + src/components/EmbeddedNaddr.tsx | 2 +- src/components/EmbeddedNote.tsx | 2 +- src/components/ExternalContentHeader.tsx | 3 +++ src/components/ReplyComposeModal.tsx | 10 ++++++++++ src/pages/PostDetailPage.tsx | 9 ++++++++- 6 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index dcd984f4..df9c8eea 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -95,6 +95,7 @@ function getKindLabel(rootKind: string | undefined): string { } if (kindNum === 7) return 'a reaction'; + if (kindNum === 15128 || kindNum === 35128) return 'an nsite'; if (kindNum === 32267) return 'an app'; if (kindNum === 30063) return 'a release'; diff --git a/src/components/EmbeddedNaddr.tsx b/src/components/EmbeddedNaddr.tsx index ee1d2cd4..8adfaf43 100644 --- a/src/components/EmbeddedNaddr.tsx +++ b/src/components/EmbeddedNaddr.tsx @@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'; import type { NostrEvent } from '@nostrify/nostrify'; /** Kinds that render as a full NoteCard instead of a generic embed. */ -const NOTECARD_KINDS = new Set([30000, 39089]); +const NOTECARD_KINDS = new Set([30000, 39089, 35128]); interface EmbeddedNaddrProps { /** The decoded naddr coordinates. */ diff --git a/src/components/EmbeddedNote.tsx b/src/components/EmbeddedNote.tsx index 8328627c..4e3ce7b6 100644 --- a/src/components/EmbeddedNote.tsx +++ b/src/components/EmbeddedNote.tsx @@ -22,7 +22,7 @@ import { IMAGE_URL_REGEX, IMETA_MEDIA_URL_REGEX, extractVideoUrls, extractAudioU const VANISH_KIND = 62; /** Kinds that render as a full NoteCard instead of the generic embed card. */ -const NOTECARD_KINDS = new Set([30000, 39089]); +const NOTECARD_KINDS = new Set([30000, 39089, 15128]); /** Bech32 charset used by NIP-19 identifiers. */ const B32 = '023456789acdefghjklmnpqrstuvwxyz'; diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index 4de6b997..193f3e8b 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -1084,6 +1084,8 @@ function hasVideo(tags: string[][]): boolean { const WELL_KNOWN_KIND_LABELS: Record = { 32267: 'App', 30063: 'Release', + 15128: 'Nsite', + 35128: 'Nsite', }; export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey: string; identifier: string } }) { @@ -1107,6 +1109,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey if (kindDef?.id) return CONTENT_KIND_ICONS[kindDef.id] ?? FileText; // Fallback icons for well-known kinds not in EXTRA_KINDS if (addr.kind === 32267 || addr.kind === 30063) return Package; + if (addr.kind === 15128 || addr.kind === 35128) return Globe; return FileText; }, [kindDef, addr.kind]); diff --git a/src/components/ReplyComposeModal.tsx b/src/components/ReplyComposeModal.tsx index c7f771fd..8919ffa5 100644 --- a/src/components/ReplyComposeModal.tsx +++ b/src/components/ReplyComposeModal.tsx @@ -13,6 +13,7 @@ import { PortalContainerProvider } from '@/contexts/PortalContainerContext'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { NoteContent } from '@/components/NoteContent'; +import { NsiteCard } from '@/components/NsiteCard'; import { ComposeBox } from '@/components/ComposeBox'; import { LinkEmbed } from '@/components/LinkEmbed'; import { VanishCardCompact } from '@/components/VanishEventContent'; @@ -181,6 +182,15 @@ function EmbeddedPost({ event }: { event: NostrEvent }) { return ; } + // Nsite deployments — show the NsiteCard instead of empty content + if (event.kind === 15128 || event.kind === 35128) { + return ( +
+ +
+ ); + } + return ; } diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index f8211320..8c96a7cb 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -50,6 +50,7 @@ import { MagicDeckContent } from "@/components/MagicDeckContent"; import { MusicDetailContent } from "@/components/MusicDetailContent"; import { NoteCard } from "@/components/NoteCard"; import { NoteContent } from "@/components/NoteContent"; +import { NsiteCard } from "@/components/NsiteCard"; import { NoteMoreMenu } from "@/components/NoteMoreMenu"; import { PatchCard } from "@/components/PatchCard"; import { PodcastDetailContent } from "@/components/PodcastDetailContent"; @@ -123,6 +124,7 @@ function shellTitleForKind(kind?: number): string { if (kind === BADGE_PROFILE_KIND) return "Badge Collection"; if (kind === BOOK_REVIEW_KIND) return "Book Review"; if (kind === 32267) return "App Details"; + if (kind === 15128 || kind === 35128) return "Nsite"; if (kind === VANISH_KIND) return "Request to Vanish"; return "Post Details"; } @@ -871,9 +873,10 @@ function PostDetailContent({ event }: { event: NostrEvent }) { const isPatch = event.kind === 1617; const isPullRequest = event.kind === 1618; const isCustomNip = event.kind === 30817; + const isNsite = event.kind === 15128 || event.kind === 35128; const isZapstoreApp = event.kind === 32267; const isVanish = event.kind === VANISH_KIND; - const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip; + const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite; const isTextNote = !isVine && !isPoll && @@ -1555,6 +1558,10 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
+ ) : isNsite ? ( +
+ +
) : isZapstoreApp ? ( ) : isVine || From 6b2de0f2c1a7cccf4fb22d85c8c3c9e26496bd91 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 20:25:04 -0500 Subject: [PATCH 006/104] Document the checklist for implementing new event kinds in the UI --- AGENTS.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b038a74a..87b2be74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,6 +264,46 @@ When designing new event kinds, the `content` field should be used for semantica } ``` +### Implementing New Event Kinds in the UI + +When adding support for a new Nostr event kind to the application, the kind must be registered in **multiple locations** across the codebase. Missing any of these will cause the event to render incorrectly in certain views (e.g. showing blank content in quote posts, or "Kind 12345" as a label). + +#### Checklist for adding a new event kind + +1. **Content card component** (`src/components/`): Create a dedicated `` component that renders the event's tags/content appropriately. + +2. **Feed rendering** (`src/components/NoteCard.tsx`): + - Add a `const isMyKind = event.kind === XXXX;` detection flag + - Include it in the appropriate group flag (e.g. `isDevKind`) or add it to the `isTextNote` exclusion list + - Add the content dispatch: `isMyKind ? : ...` + - Add an entry to `KIND_HEADER_MAP` for the action header (e.g. "deployed an nsite") + - Import the new component and any new icons (e.g. `Globe` from lucide-react) + +3. **Detail page** (`src/pages/PostDetailPage.tsx`): + - Add the same `isMyKind` detection flag and include it in the group/exclusion flags (mirrors NoteCard) + - Add the content dispatch for the detail view + - Add an entry in `shellTitleForKind()` for the loading state title + - Import the new component + +4. **Feed registration** (`src/lib/extraKinds.ts`): + - Add the kind number to an existing feed definition's `extraFeedKinds` array, or create a new `ExtraKindDef` entry + +5. **Kind label registries** -- these are separate maps that resolve kind numbers to human-readable strings. All must be updated: + - `getKindLabel()` in `src/components/CommentContext.tsx` -- used for "Commenting on an nsite" text + - `WELL_KNOWN_KIND_LABELS` in `src/components/ExternalContentHeader.tsx` -- used in addressable event preview headers + - The icon fallback in `AddressableEventPreview` in the same file + +6. **Inline embeds / quote posts** -- events can be quoted inline via `nostr:nevent1...` or `nostr:naddr1...` URIs in note content: + - `src/components/EmbeddedNote.tsx` -- for non-addressable kinds: add to `NOTECARD_KINDS` set so it renders the full NoteCard (with your card component inside) instead of trying to show `event.content` as text + - `src/components/EmbeddedNaddr.tsx` -- for addressable kinds: same pattern, add to `NOTECARD_KINDS` + +7. **Reply composer** (`src/components/ReplyComposeModal.tsx`): + - Add a kind check in the `EmbeddedPost` component so the reply-to preview shows the card component instead of empty/raw content + +#### Why so many places? + +These are genuinely different UI contexts (feed cards, detail pages, inline embeds, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points. + ### NIP.md The file `NIP.md` is used by this project to define a custom Nostr protocol document. If the file doesn't exist, it means this project doesn't have any custom kinds associated with it. From 81fe69dc6bbd3e3892786b420873777a2dec15ea Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 20:27:11 -0500 Subject: [PATCH 007/104] Use rocket icon and show site title in nsite action headers Changes 'deployed an nsite' to 'deployed Ditto' (or whatever the title tag contains) and swaps the Globe icon for a Rocket. --- src/components/NoteCard.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 817d25e9..3c554f90 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -5,8 +5,8 @@ import { FileText, GitBranch, GitPullRequest, - Globe, MessageCircle, + Rocket, MoreHorizontal, Package, Palette, @@ -1668,16 +1668,18 @@ const KIND_HEADER_MAP: Record = { nounRoute: "/development", }, 15128: { - icon: Globe, - action: "deployed an", - noun: "nsite", - nounRoute: "/development", + icon: Rocket, + action: (tags) => { + const title = tags.find(([n]) => n === "title")?.[1]; + return title ? `deployed ${title}` : "deployed an nsite"; + }, }, 35128: { - icon: Globe, - action: "deployed an", - noun: "nsite", - nounRoute: "/development", + icon: Rocket, + action: (tags) => { + const title = tags.find(([n]) => n === "title")?.[1]; + return title ? `deployed ${title}` : "deployed an nsite"; + }, }, }; From b3193bdde00cac5e943498bc7d7e5f4e4f811f61 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 20:27:39 -0500 Subject: [PATCH 008/104] Use static 'deployed an nsite' header for consistency with other kinds --- src/components/NoteCard.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 3c554f90..61ec3e46 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -1669,17 +1669,15 @@ const KIND_HEADER_MAP: Record = { }, 15128: { icon: Rocket, - action: (tags) => { - const title = tags.find(([n]) => n === "title")?.[1]; - return title ? `deployed ${title}` : "deployed an nsite"; - }, + action: "deployed an", + noun: "nsite", + nounRoute: "/development", }, 35128: { icon: Rocket, - action: (tags) => { - const title = tags.find(([n]) => n === "title")?.[1]; - return title ? `deployed ${title}` : "deployed an nsite"; - }, + action: "deployed an", + noun: "nsite", + nounRoute: "/development", }, }; From 76b9b3d6234c48981f90dddd9b9cbd3253deab71 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 20:32:54 -0500 Subject: [PATCH 009/104] Show rocket icon and site name in comment context for nsite events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Commenting on ditto' becomes 'Commenting on 🚀 ditto nsite' with a Rocket icon rendered inline. --- src/components/CommentContext.tsx | 37 ++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index df9c8eea..e6683db7 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -1,6 +1,8 @@ +import type React from 'react'; import { useMemo } from 'react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; +import { Rocket } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; import { EmbeddedNote } from '@/components/EmbeddedNote'; @@ -59,27 +61,35 @@ function parseCommentRoot(event: NostrEvent): CommentRoot | undefined { } /** Get a display name for an event based on its kind and tags. */ -function getEventDisplayName(event: NostrEvent): string { +function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.ComponentType<{ className?: string }> } { + // Nsite deployments: "ditto nsite" with rocket icon + if (event.kind === 15128 || event.kind === 35128) { + const title = event.tags.find(([name]) => name === 'title')?.[1]; + const dTag = event.tags.find(([name]) => name === 'd')?.[1]; + const siteName = title || dTag; + return { text: siteName ? `${siteName} nsite` : 'an nsite', icon: Rocket }; + } + // Try title tag first (used by articles, themes, follow packs, etc.) const title = event.tags.find(([name]) => name === 'title')?.[1]; - if (title) return title; + if (title) return { text: title }; // Try name tag (used by communities, emoji packs, etc.) const name = event.tags.find(([name]) => name === 'name')?.[1]; - if (name) return name; + if (name) return { text: name }; // Try d tag (addressable events use this as an identifier) const dTag = event.tags.find(([name]) => name === 'd')?.[1]; - if (dTag) return dTag; + if (dTag) return { text: dTag }; // Fall back to kind label from EXTRA_KINDS const kindDef = EXTRA_KINDS.find((def) => def.subKinds?.some((sub) => sub.kind === event.kind) || def.kind === event.kind, ); - if (kindDef) return kindDef.label.toLowerCase(); + if (kindDef) return { text: kindDef.label.toLowerCase() }; // Last resort - return 'a post'; + return { text: 'a post' }; } /** Get a human-readable kind label for fallback display. */ @@ -243,22 +253,24 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla ); } - const displayName = event ? getEventDisplayName(event) : getKindLabel(root.rootKind); + const display = event ? getEventDisplayName(event) : { text: getKindLabel(root.rootKind) }; + const DisplayIcon = display.icon; const link = event ? getRootLink(event) : undefined; return (
{prefix} + {DisplayIcon && } {link ? ( e.stopPropagation()} > - {displayName} + {display.text} ) : ( - {displayName} + {display.text} )}
); @@ -272,15 +284,18 @@ function EventCommentContext({ root, className }: { root: CommentRoot; className if (!root.eventId) return undefined; if (event) { + const display = getEventDisplayName(event); + const DisplayIcon = display.icon; return ( e.stopPropagation()} > - {getEventDisplayName(event)} + {DisplayIcon && } + {display.text} Date: Tue, 24 Mar 2026 20:42:44 -0500 Subject: [PATCH 010/104] Improve CommentContext consistency and unify event preview rendering - Extract shared CommentContextRow wrapper to eliminate duplicated div/className/skeleton patterns - Unify getKindLabel into getEventDisplayName so kind-to-label mappings exist in one place - Fix inconsistent min-w-0 overflow-hidden between loading and content states - Add hover card previews to addressable event roots (A tag) using EmbeddedNaddr - Replace bespoke reply composer embed with shared EmbeddedNote/EmbeddedNaddr components --- src/components/CommentContext.tsx | 346 +++++++++++++-------------- src/components/ReplyComposeModal.tsx | 100 ++------ 2 files changed, 180 insertions(+), 266 deletions(-) diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index e6683db7..c96a008c 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -1,11 +1,12 @@ import type React from 'react'; -import { useMemo } from 'react'; +import { type ReactNode, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { Rocket } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; import { EmbeddedNote } from '@/components/EmbeddedNote'; +import { EmbeddedNaddr } from '@/components/EmbeddedNaddr'; import { LinkPreview } from '@/components/LinkPreview'; import { ReactionEmoji } from '@/components/CustomEmoji'; import { ExternalFavicon } from '@/components/ExternalFavicon'; @@ -20,6 +21,9 @@ import { genUserName } from '@/lib/genUserName'; import { getCountryInfo } from '@/lib/countries'; import { EXTRA_KINDS } from '@/lib/extraKinds'; +/** Default classes shared by all comment context rows. */ +const ROW_CLASS = 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1 min-w-0 overflow-hidden'; + /** Parsed root reference from a kind 1111 comment's uppercase tags. */ interface CommentRoot { type: 'event' | 'addr' | 'external'; @@ -60,61 +64,77 @@ function parseCommentRoot(event: NostrEvent): CommentRoot | undefined { return undefined; } -/** Get a display name for an event based on its kind and tags. */ -function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.ComponentType<{ className?: string }> } { - // Nsite deployments: "ditto nsite" with rocket icon - if (event.kind === 15128 || event.kind === 35128) { - const title = event.tags.find(([name]) => name === 'title')?.[1]; - const dTag = event.tags.find(([name]) => name === 'd')?.[1]; - const siteName = title || dTag; - return { text: siteName ? `${siteName} nsite` : 'an nsite', icon: Rocket }; - } +/** Hardcoded kind-to-label map for kinds not covered by EXTRA_KINDS. */ +const KIND_LABELS: Record = { + 7: 'a reaction', + 15128: 'an nsite', + 35128: 'an nsite', + 32267: 'an app', + 30063: 'a release', +}; - // Try title tag first (used by articles, themes, follow packs, etc.) - const title = event.tags.find(([name]) => name === 'title')?.[1]; - if (title) return { text: title }; +/** Kind-specific icons. */ +const KIND_ICONS: Partial>> = { + 15128: Rocket, + 35128: Rocket, +}; - // Try name tag (used by communities, emoji packs, etc.) - const name = event.tags.find(([name]) => name === 'name')?.[1]; - if (name) return { text: name }; +/** + * Get a human-readable label for a kind number. + * Used both as a fallback when an event hasn't loaded yet (from rootKind string) + * and as a last resort inside getEventDisplayName. + */ +function getKindLabel(kind: number): string { + if (KIND_LABELS[kind]) return KIND_LABELS[kind]; - // Try d tag (addressable events use this as an identifier) - const dTag = event.tags.find(([name]) => name === 'd')?.[1]; - if (dTag) return { text: dTag }; - - // Fall back to kind label from EXTRA_KINDS const kindDef = EXTRA_KINDS.find((def) => - def.subKinds?.some((sub) => sub.kind === event.kind) || def.kind === event.kind, + def.subKinds?.some((sub) => sub.kind === kind) || def.kind === kind, ); - if (kindDef) return { text: kindDef.label.toLowerCase() }; + if (kindDef) return kindDef.label.toLowerCase(); - // Last resort - return { text: 'a post' }; + return 'a post'; } -/** Get a human-readable kind label for fallback display. */ -function getKindLabel(rootKind: string | undefined): string { +/** Parse a rootKind string into a label, handling both numeric and external content kinds. */ +function getRootKindLabel(rootKind: string | undefined): string { if (!rootKind) return 'a post'; const kindNum = parseInt(rootKind, 10); if (isNaN(kindNum)) { - // External content kinds (web, #, etc.) if (rootKind === 'web' || rootKind === 'https' || rootKind === 'http') return 'a link'; if (rootKind === '#') return 'a hashtag'; return rootKind; } - if (kindNum === 7) return 'a reaction'; - if (kindNum === 15128 || kindNum === 35128) return 'an nsite'; - if (kindNum === 32267) return 'an app'; - if (kindNum === 30063) return 'a release'; + return getKindLabel(kindNum); +} - const kindDef = EXTRA_KINDS.find((def) => - def.subKinds?.some((sub) => sub.kind === kindNum) || def.kind === kindNum, - ); - if (kindDef) return kindDef.label.toLowerCase(); +/** Get a display name for an event based on its kind and tags. */ +function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.ComponentType<{ className?: string }> } { + const icon = KIND_ICONS[event.kind]; - return 'a post'; + // Nsite deployments: "{siteName} nsite" with rocket icon + if (event.kind === 15128 || event.kind === 35128) { + const title = event.tags.find(([name]) => name === 'title')?.[1]; + const dTag = event.tags.find(([name]) => name === 'd')?.[1]; + const siteName = title || dTag; + return { text: siteName ? `${siteName} nsite` : 'an nsite', icon }; + } + + // Try title tag first (used by articles, themes, follow packs, etc.) + const title = event.tags.find(([name]) => name === 'title')?.[1]; + if (title) return { text: title, icon }; + + // Try name tag (used by communities, emoji packs, etc.) + const name = event.tags.find(([name]) => name === 'name')?.[1]; + if (name) return { text: name, icon }; + + // Try d tag (addressable events use this as an identifier) + const dTag = event.tags.find(([name]) => name === 'd')?.[1]; + if (dTag) return { text: dTag, icon }; + + // Fall back to kind label + return { text: getKindLabel(event.kind), icon }; } /** Build a navigation link for the root event. */ @@ -128,6 +148,63 @@ function getRootLink(event: NostrEvent): string { return `/${nip19.neventEncode({ id: event.id, author: event.pubkey })}`; } +// ─── Shared wrapper ──────────────────────────────────────────── + +interface CommentContextRowProps { + prefix: string; + className?: string; + loading?: boolean; + children?: ReactNode; +} + +/** Shared row wrapper for all comment context variants. */ +function CommentContextRow({ prefix, className, loading, children }: CommentContextRowProps) { + return ( +
+ {prefix} + {loading ? : children} +
+ ); +} + +// ─── Shared hover card for Nostr events ──────────────────────── + +interface EventHoverLinkProps { + display: { text: string; icon?: React.ComponentType<{ className?: string }> }; + link: string; + hoverContent: ReactNode; +} + +/** Link with icon that shows a hover card preview. */ +function EventHoverLink({ display, link, hoverContent }: EventHoverLinkProps) { + const DisplayIcon = display.icon; + return ( + + + e.stopPropagation()} + > + {DisplayIcon && } + {display.text} + + + e.stopPropagation()} + > + {hoverContent} + + + ); +} + +// ─── Main export ─────────────────────────────────────────────── + interface CommentContextProps { event: NostrEvent; className?: string; @@ -163,6 +240,8 @@ export function CommentContext({ event, className }: CommentContextProps) { } } +// ─── Sub-components ──────────────────────────────────────────── + /** Comment context when replying directly to another kind 1111 comment — shows "Replying to @user". */ function ReplyToCommentContext({ pubkey, eventId, className }: { pubkey: string; eventId?: string; className?: string }) { const author = useAuthor(pubkey); @@ -174,18 +253,8 @@ function ReplyToCommentContext({ pubkey, eventId, className }: { pubkey: string; try { return `/${nip19.neventEncode({ id: eventId, author: pubkey })}`; } catch { return undefined; } }, [eventId, pubkey]); - if (author.isLoading) { - return ( -
- Replying to - -
- ); - } - return ( -
- Replying to + @{displayName} -
+ ); } @@ -214,18 +283,8 @@ function ProfileCommentContext({ pubkey, className }: { pubkey: string; classNam const displayName = metadata?.name ?? genUserName(pubkey); const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]); - if (author.isLoading) { - return ( -
- Commenting on - -
- ); - } - return ( -
- Commenting on + @{displayName} -
+ ); } @@ -244,35 +303,34 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla const isCommunity = root.rootKind === '34550' || root.addr?.kind === 34550; const prefix = isCommunity ? 'Posted in' : 'Commenting on'; - if (isLoading) { - return ( -
- {prefix} - -
- ); - } - - const display = event ? getEventDisplayName(event) : { text: getKindLabel(root.rootKind) }; - const DisplayIcon = display.icon; + const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) }; const link = event ? getRootLink(event) : undefined; + // Build hover card content for addressable events + const hoverContent = root.addr ? ( + + ) : undefined; + return ( -
- {prefix} - {DisplayIcon && } - {link ? ( + + {link && hoverContent ? ( + + ) : link ? ( e.stopPropagation()} > + {display.icon && } {display.text} ) : ( {display.text} )} -
+ ); } @@ -280,69 +338,26 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla function EventCommentContext({ root, className }: { root: CommentRoot; className?: string }) { const { data: event, isLoading } = useEvent(root.eventId); - const label = useMemo(() => { - if (!root.eventId) return undefined; - - if (event) { - const display = getEventDisplayName(event); - const DisplayIcon = display.icon; - return ( - - - e.stopPropagation()} - > - {DisplayIcon && } - {display.text} - - - e.stopPropagation()} - > - - - - ); - } - - return undefined; - }, [event, root.eventId]); - // Kind 7 reactions get special treatment if (event?.kind === 7) { return ; } - if (isLoading) { - return ( -
- Commenting on - -
- ); - } + const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) }; + const link = event ? getRootLink(event) : undefined; - // Fallback for kind 7 when event hasn't loaded yet but rootKind indicates it - if (root.rootKind === '7') { - return ( -
- Commenting on - a reaction -
- ); - } + const hoverContent = root.eventId ? ( + + ) : undefined; return ( -
- Commenting on - {label ?? {getKindLabel(root.rootKind)}} -
+ + {link && hoverContent ? ( + + ) : ( + {display.text} + )} + ); } @@ -355,8 +370,7 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class const profileLink = `/${nip19.npubEncode(event.pubkey)}`; return ( -
- Commenting on + )} -
+ ); } @@ -396,7 +410,6 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN // Determine display text and link let displayText: string; - let link: string | undefined; if (identifier.startsWith('iso3166:')) { const code = identifier.slice('iso3166:'.length); @@ -408,31 +421,22 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN } else { displayText = identifier; } - link = `/i/${encodeURIComponent(identifier)}`; - } else if (identifier.startsWith('#')) { - displayText = identifier; - link = `/i/${encodeURIComponent(identifier)}`; } else { - // podcast:guid:, etc. displayText = identifier; - link = `/i/${encodeURIComponent(identifier)}`; } + const link = `/i/${encodeURIComponent(identifier)}`; + return ( -
- Commenting on - {link ? ( - e.stopPropagation()} - > - {displayText} - - ) : ( - {displayText} - )} -
+ + e.stopPropagation()} + > + {displayText} + + ); } @@ -448,20 +452,10 @@ function UrlCommentContext({ url, className }: { url: string; className?: string fallbackHost = url; } - if (isLoading) { - return ( -
- Commenting on - -
- ); - } - const title = preview?.title; return ( -
- Commenting on + @@ -483,7 +477,7 @@ function UrlCommentContext({ url, className }: { url: string; className?: string -
+ ); } @@ -494,18 +488,8 @@ function IsbnCommentContext({ identifier, className }: { identifier: string; cla const link = `/i/${encodeURIComponent(identifier)}`; const displayText = bookInfo?.title ?? identifier; - if (isLoading) { - return ( -
- Commenting on - -
- ); - } - return ( -
- Commenting on + {displayText} -
+ ); } diff --git a/src/components/ReplyComposeModal.tsx b/src/components/ReplyComposeModal.tsx index 8919ffa5..0a1452f5 100644 --- a/src/components/ReplyComposeModal.tsx +++ b/src/components/ReplyComposeModal.tsx @@ -1,7 +1,5 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; import { X } from 'lucide-react'; -import { Link } from 'react-router-dom'; -import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '@nostrify/nostrify'; import { @@ -10,18 +8,11 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { PortalContainerProvider } from '@/contexts/PortalContainerContext'; -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; -import { getAvatarShape } from '@/lib/avatarShape'; -import { NoteContent } from '@/components/NoteContent'; -import { NsiteCard } from '@/components/NsiteCard'; +import { EmbeddedNote } from '@/components/EmbeddedNote'; +import { EmbeddedNaddr } from '@/components/EmbeddedNaddr'; import { ComposeBox } from '@/components/ComposeBox'; import { LinkEmbed } from '@/components/LinkEmbed'; -import { VanishCardCompact } from '@/components/VanishEventContent'; import { ProfilePreview } from '@/components/ExternalContentHeader'; -import { useAuthor } from '@/hooks/useAuthor'; -import { genUserName } from '@/lib/genUserName'; -import { VerifiedNip05Text } from '@/components/Nip05Badge'; -import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; interface ReplyComposeModalProps { @@ -43,12 +34,6 @@ interface ReplyComposeModalProps { placeholder?: string; } -/** Extracts image URLs from note content. */ -function extractImages(content: string): string[] { - const urlRegex = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?/gi; - return content.match(urlRegex) || []; -} - export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSuccess, initialContent, initialMode, title: titleOverride, placeholder: placeholderOverride }: ReplyComposeModalProps) { const isUrl = event instanceof URL; const isReply = !!event; @@ -166,7 +151,11 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu ); } -/** Compact embedded preview of the post being replied to. */ +/** + * Compact embedded preview of the post being replied to. + * Delegates to the shared EmbeddedNote / EmbeddedNaddr components used by + * quote posts and hover cards, so every context renders events consistently. + */ function EmbeddedPost({ event }: { event: NostrEvent }) { // Kind 0 (profile) — show a profile card instead of trying to render the raw JSON content if (event.kind === 0) { @@ -177,79 +166,20 @@ function EmbeddedPost({ event }: { event: NostrEvent }) { ); } - // Kind 62 (Request to Vanish) — show a compact vanish preview - if (event.kind === 62) { - return ; - } - - // Nsite deployments — show the NsiteCard instead of empty content - if (event.kind === 15128 || event.kind === 35128) { + // Addressable events (kind 30000-39999) — use EmbeddedNaddr + if (event.kind >= 30000 && event.kind < 40000) { + const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? ''; return (
- +
); } - return ; -} - - - -/** Compact embedded preview for regular note events. */ -function EmbeddedNote({ event }: { event: NostrEvent }) { - const author = useAuthor(event.pubkey); - const metadata = author.data?.metadata; - const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(event.pubkey); - const nip05 = metadata?.nip05; - const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]); - const images = useMemo(() => extractImages(event.content), [event.content]); - + // Everything else — use EmbeddedNote (the event is already in the query cache) return ( -
-
- {/* Author row */} -
- - - - - {displayName[0].toUpperCase()} - - - - -
- {displayName} - {nip05 && ( - - )} - {metadata?.bot && ( - 🤖 - )} - · - {timeAgo(event.created_at)} -
-
- - {/* Content preview – clamp to a few lines */} -
- -
- - {/* Show first image thumbnail if any */} - {images.length > 0 && ( -
- -
- )} -
+
+
); } From ae236718e3315a897ac890fb6d57ed4d32829795 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 20:48:20 -0500 Subject: [PATCH 011/104] =?UTF-8?q?Remove=20NOTECARD=5FKINDS=20=E2=80=94?= =?UTF-8?q?=20render=20all=20quote=20posts=20and=20hover=20cards=20as=20co?= =?UTF-8?q?mpact=20embed=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quote posts and hover previews should always be compact (author + title/content). Rendering a full NoteCard with action headers, buttons, and nested card components inside a quote post or hover card is too much UI for an inline context. - Remove NOTECARD_KINDS from EmbeddedNote.tsx and EmbeddedNaddr.tsx - Remove NoteCard imports from both embed components - Add tag-based metadata fallback (title/name/d, summary/description) to EmbeddedNoteCard for non-text kinds with empty content - Update AGENTS.md checklist to reflect that embeds no longer need per-kind registration --- AGENTS.md | 8 +++----- src/components/EmbeddedNaddr.tsx | 13 ------------ src/components/EmbeddedNote.tsx | 34 +++++++++++++++++++------------- 3 files changed, 23 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 87b2be74..7409667d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -289,16 +289,14 @@ When adding support for a new Nostr event kind to the application, the kind must - Add the kind number to an existing feed definition's `extraFeedKinds` array, or create a new `ExtraKindDef` entry 5. **Kind label registries** -- these are separate maps that resolve kind numbers to human-readable strings. All must be updated: - - `getKindLabel()` in `src/components/CommentContext.tsx` -- used for "Commenting on an nsite" text + - `KIND_LABELS` and `KIND_ICONS` in `src/components/CommentContext.tsx` -- used for "Commenting on an nsite" text and inline icons - `WELL_KNOWN_KIND_LABELS` in `src/components/ExternalContentHeader.tsx` -- used in addressable event preview headers - The icon fallback in `AddressableEventPreview` in the same file -6. **Inline embeds / quote posts** -- events can be quoted inline via `nostr:nevent1...` or `nostr:naddr1...` URIs in note content: - - `src/components/EmbeddedNote.tsx` -- for non-addressable kinds: add to `NOTECARD_KINDS` set so it renders the full NoteCard (with your card component inside) instead of trying to show `event.content` as text - - `src/components/EmbeddedNaddr.tsx` -- for addressable kinds: same pattern, add to `NOTECARD_KINDS` +6. **Inline embeds / quote posts** -- events can be quoted inline via `nostr:nevent1...` or `nostr:naddr1...` URIs in note content. Both `EmbeddedNote` and `EmbeddedNaddr` render a compact card (author + title/content preview) for all kinds automatically — no per-kind registration needed. The same components are reused by CommentContext hover cards and the reply composer. 7. **Reply composer** (`src/components/ReplyComposeModal.tsx`): - - Add a kind check in the `EmbeddedPost` component so the reply-to preview shows the card component instead of empty/raw content + - The `EmbeddedPost` component delegates to the shared `EmbeddedNote`/`EmbeddedNaddr` components — no per-kind registration needed #### Why so many places? diff --git a/src/components/EmbeddedNaddr.tsx b/src/components/EmbeddedNaddr.tsx index 8adfaf43..7948c2ca 100644 --- a/src/components/EmbeddedNaddr.tsx +++ b/src/components/EmbeddedNaddr.tsx @@ -6,7 +6,6 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { Skeleton } from '@/components/ui/skeleton'; import { EmojifiedText } from '@/components/CustomEmoji'; -import { NoteCard } from '@/components/NoteCard'; import { parseBadgeDefinition } from '@/components/BadgeContent'; import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; import { useAuthor } from '@/hooks/useAuthor'; @@ -15,9 +14,6 @@ import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; import type { NostrEvent } from '@nostrify/nostrify'; -/** Kinds that render as a full NoteCard instead of a generic embed. */ -const NOTECARD_KINDS = new Set([30000, 39089, 35128]); - interface EmbeddedNaddrProps { /** The decoded naddr coordinates. */ addr: AddrCoords; @@ -72,15 +68,6 @@ export function EmbeddedNaddr({ addr, className }: EmbeddedNaddrProps) { return ; } - // For follow packs / starter packs, render the same NoteCard used in feeds (without actions) - if (NOTECARD_KINDS.has(event.kind)) { - return ( -
e.stopPropagation()}> - -
- ); - } - // Badge definitions get a compact showcase instead of a link-preview card if (event.kind === 30009) { return ; diff --git a/src/components/EmbeddedNote.tsx b/src/components/EmbeddedNote.tsx index 4e3ce7b6..d46e6add 100644 --- a/src/components/EmbeddedNote.tsx +++ b/src/components/EmbeddedNote.tsx @@ -6,7 +6,6 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { Skeleton } from '@/components/ui/skeleton'; import { EmojifiedText } from '@/components/CustomEmoji'; -import { NoteCard } from '@/components/NoteCard'; import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { VanishCardCompact } from '@/components/VanishEventContent'; import { useEvent } from '@/hooks/useEvent'; @@ -21,9 +20,6 @@ import { IMAGE_URL_REGEX, IMETA_MEDIA_URL_REGEX, extractVideoUrls, extractAudioU /** NIP-62 Request to Vanish. */ const VANISH_KIND = 62; -/** Kinds that render as a full NoteCard instead of the generic embed card. */ -const NOTECARD_KINDS = new Set([30000, 39089, 15128]); - /** Bech32 charset used by NIP-19 identifiers. */ const B32 = '023456789acdefghjklmnpqrstuvwxyz'; @@ -95,15 +91,6 @@ export function EmbeddedNote({ eventId, relays, authorHint, className, disableHo return ; } - // For follow packs / lists, render the same rich NoteCard used in feeds - if (NOTECARD_KINDS.has(event.kind)) { - return ( -
e.stopPropagation()}> - -
- ); - } - // NIP-62 vanish events get their own dramatic inline card if (event.kind === VANISH_KIND) { return ; @@ -149,6 +136,16 @@ function EmbeddedNoteCard({ return cleaned.slice(0, MAX_CONTENT_LENGTH).trimEnd() + '…'; }, [event.content]); + // For non-text kinds with empty content, extract title/description from tags + const tagMeta = useMemo(() => { + if (truncatedContent) return undefined; + const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1]; + const title = getTag('title') || getTag('name') || getTag('d'); + const description = getTag('summary') || getTag('description'); + if (!title && !description) return undefined; + return { title, description }; + }, [truncatedContent, event.tags]); + // Extract first image for a small thumbnail const firstImage = useMemo(() => { return event.content.match(IMAGE_URL_REGEX)?.[0] ?? null; @@ -255,13 +252,22 @@ function EmbeddedNoteCard({
- {/* Content warning notice or text preview */} + {/* Content warning notice or text preview or tag-based metadata */} {hasCW && config.contentWarningPolicy === 'blur' ? (

Content warning{cwTag?.[1] ? <>{' '}“{cwTag[1]}” : ''}

) : truncatedContent ? ( + ) : tagMeta ? ( + <> + {tagMeta.title && ( +

{tagMeta.title}

+ )} + {tagMeta.description && ( +

{tagMeta.description}

+ )} + ) : null} {/* Attachment indicators for stripped media/links */} From d94ff90bc7eb54a381ceec802a35a003ec5c5e22 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 20:53:13 -0500 Subject: [PATCH 012/104] Show kind label with icon in compact embed cards for non-text kinds Embed cards for non-text kinds (nsites, follow packs, etc.) now show a kind label line with icon below the title/description, giving context about what the event is. Previously a quoted nsite just showed 'ditto' with no indication it was an nsite deployment. --- src/components/EmbeddedNaddr.tsx | 18 ++++++++++++++++++ src/components/EmbeddedNote.tsx | 24 ++++++++++++++++++++---- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/components/EmbeddedNaddr.tsx b/src/components/EmbeddedNaddr.tsx index 7948c2ca..5630b873 100644 --- a/src/components/EmbeddedNaddr.tsx +++ b/src/components/EmbeddedNaddr.tsx @@ -12,6 +12,7 @@ import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; +import { EXTRA_KINDS, getKindIcon } from '@/lib/extraKinds'; import type { NostrEvent } from '@nostrify/nostrify'; interface EmbeddedNaddrProps { @@ -190,6 +191,16 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className? return description.slice(0, MAX_CONTENT_LENGTH).trimEnd() + '…'; }, [description]); + // Kind label for context (e.g. "development" with icon) + const kindMeta = useMemo(() => { + const kindDef = EXTRA_KINDS.find((def) => + def.subKinds?.some((sub) => sub.kind === event.kind) || def.kind === event.kind + || def.extraFeedKinds?.includes(event.kind), + ); + if (!kindDef) return undefined; + return { label: kindDef.label.toLowerCase(), Icon: getKindIcon(event.kind) }; + }, [event.kind]); + return (
)} + {/* Kind label for context */} + {kindMeta && ( +

+ {kindMeta.Icon && } + {kindMeta.label} +

+ )}
); diff --git a/src/components/EmbeddedNote.tsx b/src/components/EmbeddedNote.tsx index d46e6add..c14381cf 100644 --- a/src/components/EmbeddedNote.tsx +++ b/src/components/EmbeddedNote.tsx @@ -16,6 +16,7 @@ import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; import { useAppContext } from '@/hooks/useAppContext'; import { IMAGE_URL_REGEX, IMETA_MEDIA_URL_REGEX, extractVideoUrls, extractAudioUrls } from '@/lib/mediaUrls'; +import { EXTRA_KINDS, getKindIcon } from '@/lib/extraKinds'; /** NIP-62 Request to Vanish. */ const VANISH_KIND = 62; @@ -105,7 +106,7 @@ function EmbeddedNoteCard({ className, disableHoverCards, }: { - event: { id: string; pubkey: string; content: string; created_at: number; tags: string[][] }; + event: { id: string; kind: number; pubkey: string; content: string; created_at: number; tags: string[][] }; className?: string; disableHoverCards?: boolean; }) { @@ -142,9 +143,18 @@ function EmbeddedNoteCard({ const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1]; const title = getTag('title') || getTag('name') || getTag('d'); const description = getTag('summary') || getTag('description'); - if (!title && !description) return undefined; - return { title, description }; - }, [truncatedContent, event.tags]); + + // Build a kind label line for context (e.g. "nsite · 31 files") + const kindDef = EXTRA_KINDS.find((def) => + def.subKinds?.some((sub) => sub.kind === event.kind) || def.kind === event.kind + || def.extraFeedKinds?.includes(event.kind), + ); + const kindLabel = kindDef?.label.toLowerCase(); + const KindIcon = getKindIcon(event.kind); + + if (!title && !description && !kindLabel) return undefined; + return { title, description, kindLabel, KindIcon }; + }, [truncatedContent, event.tags, event.kind]); // Extract first image for a small thumbnail const firstImage = useMemo(() => { @@ -267,6 +277,12 @@ function EmbeddedNoteCard({ {tagMeta.description && (

{tagMeta.description}

)} + {tagMeta.kindLabel && ( +

+ {tagMeta.KindIcon && } + {tagMeta.kindLabel} +

+ )} ) : null} From a8cb7240bfa2fe9dba448f7f7d2df77011130e76 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 20:57:20 -0500 Subject: [PATCH 013/104] Use specific kind labels in embed cards instead of parent category name Add getKindLabel() to extraKinds.ts as the canonical resolver for per-kind labels. Kinds in extraFeedKinds (like nsites 15128/35128) now show 'nsite' instead of the parent category 'development'. --- src/components/EmbeddedNaddr.tsx | 13 +++++------- src/components/EmbeddedNote.tsx | 10 +++------ src/lib/extraKinds.ts | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/components/EmbeddedNaddr.tsx b/src/components/EmbeddedNaddr.tsx index 5630b873..5f234e22 100644 --- a/src/components/EmbeddedNaddr.tsx +++ b/src/components/EmbeddedNaddr.tsx @@ -12,7 +12,7 @@ import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; -import { EXTRA_KINDS, getKindIcon } from '@/lib/extraKinds'; +import { getKindLabel, getKindIcon } from '@/lib/extraKinds'; import type { NostrEvent } from '@nostrify/nostrify'; interface EmbeddedNaddrProps { @@ -191,14 +191,11 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className? return description.slice(0, MAX_CONTENT_LENGTH).trimEnd() + '…'; }, [description]); - // Kind label for context (e.g. "development" with icon) + // Kind label for context (e.g. "nsite" with icon) const kindMeta = useMemo(() => { - const kindDef = EXTRA_KINDS.find((def) => - def.subKinds?.some((sub) => sub.kind === event.kind) || def.kind === event.kind - || def.extraFeedKinds?.includes(event.kind), - ); - if (!kindDef) return undefined; - return { label: kindDef.label.toLowerCase(), Icon: getKindIcon(event.kind) }; + const label = getKindLabel(event.kind); + if (!label) return undefined; + return { label, Icon: getKindIcon(event.kind) }; }, [event.kind]); return ( diff --git a/src/components/EmbeddedNote.tsx b/src/components/EmbeddedNote.tsx index c14381cf..5eb1742e 100644 --- a/src/components/EmbeddedNote.tsx +++ b/src/components/EmbeddedNote.tsx @@ -16,7 +16,7 @@ import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; import { useAppContext } from '@/hooks/useAppContext'; import { IMAGE_URL_REGEX, IMETA_MEDIA_URL_REGEX, extractVideoUrls, extractAudioUrls } from '@/lib/mediaUrls'; -import { EXTRA_KINDS, getKindIcon } from '@/lib/extraKinds'; +import { getKindLabel, getKindIcon } from '@/lib/extraKinds'; /** NIP-62 Request to Vanish. */ const VANISH_KIND = 62; @@ -144,12 +144,8 @@ function EmbeddedNoteCard({ const title = getTag('title') || getTag('name') || getTag('d'); const description = getTag('summary') || getTag('description'); - // Build a kind label line for context (e.g. "nsite · 31 files") - const kindDef = EXTRA_KINDS.find((def) => - def.subKinds?.some((sub) => sub.kind === event.kind) || def.kind === event.kind - || def.extraFeedKinds?.includes(event.kind), - ); - const kindLabel = kindDef?.label.toLowerCase(); + // Build a kind label line for context (e.g. "nsite") + const kindLabel = getKindLabel(event.kind); const KindIcon = getKindIcon(event.kind); if (!title && !description && !kindLabel) return undefined; diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index a0cd75b1..73c086c8 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -513,6 +513,41 @@ export function getPageKinds(def: ExtraKindDef, feedSettings: FeedSettings): num .map((sub) => sub.kind); } +/** + * Specific labels for kinds that don't have their own top-level ExtraKindDef. + * These are kinds buried in `extraFeedKinds` arrays or otherwise needing + * a label more specific than their parent category. + */ +const KIND_SPECIFIC_LABELS: Record = { + 7: 'reaction', + 1617: 'patch', + 1618: 'patch comment', + 15128: 'nsite', + 35128: 'nsite', + 30817: 'repository issue', + 32267: 'app', + 30063: 'release', +}; + +/** + * Get a human-readable label for a specific kind number. + * Resolution order: subKind label → KIND_SPECIFIC_LABELS → direct def label. + * Returns undefined if the kind is completely unknown. + */ +export function getKindLabel(kind: number): string | undefined { + // Check subKinds first (they carry their own label) + for (const def of EXTRA_KINDS) { + const sub = def.subKinds?.find((s) => s.kind === kind); + if (sub) return sub.label.toLowerCase(); + } + // Check specific overrides (extraFeedKinds items, etc.) + if (KIND_SPECIFIC_LABELS[kind]) return KIND_SPECIFIC_LABELS[kind]; + // Check top-level def + const def = EXTRA_KINDS.find((d) => d.kind === kind); + if (def) return def.label.toLowerCase(); + return undefined; +} + /** Map from kind number to ExtraKindDef id, for quick icon lookup. */ const KIND_TO_ID = new Map(); for (const def of EXTRA_KINDS) { From 4fecb7b32e7f822cd966e234e68f70aa2342402f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 20:59:03 -0500 Subject: [PATCH 014/104] Show globe icon for nsites in embed cards instead of parent category code icon Add KIND_SPECIFIC_ICONS override map in extraKinds.ts so kinds in extraFeedKinds arrays get their own icon instead of inheriting the parent def's icon. Nsites (15128/35128) now show Globe, patches show GitPullRequestArrow, etc. --- src/lib/extraKinds.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index 73c086c8..45238575 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -1,5 +1,6 @@ import type { FeedSettings } from '@/contexts/AppContext'; import type { ComponentType } from 'react'; +import { Globe, GitPullRequestArrow, MessageSquareMore, CircleAlert } from 'lucide-react'; import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems'; /** A sub-kind that lives under a parent ExtraKindDef. */ @@ -529,6 +530,17 @@ const KIND_SPECIFIC_LABELS: Record = { 30063: 'release', }; +/** + * Specific icons for kinds that need a different icon than their parent category. + */ +const KIND_SPECIFIC_ICONS: Partial>> = { + 1617: GitPullRequestArrow, + 1618: MessageSquareMore, + 15128: Globe, + 35128: Globe, + 30817: CircleAlert, +}; + /** * Get a human-readable label for a specific kind number. * Resolution order: subKind label → KIND_SPECIFIC_LABELS → direct def label. @@ -575,10 +587,11 @@ export function getKindId(kind: number): string | undefined { /** * Get the icon component for a given kind number. - * Looks up the kind in EXTRA_KINDS and resolves to the matching icon from CONTENT_KIND_ICONS. + * Checks KIND_SPECIFIC_ICONS first, then falls back to the parent def's icon via CONTENT_KIND_ICONS. * Returns undefined if no icon mapping exists (caller provides fallback). */ export function getKindIcon(kind: number): ComponentType<{ className?: string }> | undefined { + if (KIND_SPECIFIC_ICONS[kind]) return KIND_SPECIFIC_ICONS[kind]; const id = KIND_TO_ID.get(kind); if (!id) return undefined; return CONTENT_KIND_ICONS[id]; From 3b6633c330a98d93d45a4179441a4f92ccb5ffde Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 21:06:13 -0500 Subject: [PATCH 015/104] Add missing ProfileHoverCards to author links across embed and context components - EmbeddedNaddrCard: Add MaybeProfileHoverCard wrapper on avatar and name links with disableHoverCards prop (matching EmbeddedNoteCard) - NoteCard EventActionHeader: Wrap author name in ProfileHoverCard - CommentContext: Wrap author links in ReplyToCommentContext, ProfileCommentContext, and ReactionCommentContext - LiveStreamPage ParticipantRow: Wrap avatar and name links --- src/components/CommentContext.tsx | 49 ++++++++++++---------- src/components/EmbeddedNaddr.tsx | 69 ++++++++++++++++++++----------- src/components/LiveStreamPage.tsx | 30 ++++++++------ src/components/NoteCard.tsx | 28 +++++++------ 4 files changed, 104 insertions(+), 72 deletions(-) diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index c96a008c..5126b1f1 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -8,6 +8,7 @@ import type { NostrEvent } from '@nostrify/nostrify'; import { EmbeddedNote } from '@/components/EmbeddedNote'; import { EmbeddedNaddr } from '@/components/EmbeddedNaddr'; import { LinkPreview } from '@/components/LinkPreview'; +import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { ReactionEmoji } from '@/components/CustomEmoji'; import { ExternalFavicon } from '@/components/ExternalFavicon'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; @@ -255,13 +256,15 @@ function ReplyToCommentContext({ pubkey, eventId, className }: { pubkey: string; return ( - e.stopPropagation()} - > - @{displayName} - + + e.stopPropagation()} + > + @{displayName} + + ); } @@ -285,13 +288,15 @@ function ProfileCommentContext({ pubkey, className }: { pubkey: string; classNam return ( - e.stopPropagation()} - > - @{displayName} - + + e.stopPropagation()} + > + @{displayName} + + ); } @@ -382,13 +387,15 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class {author.isLoading ? ( ) : ( - e.stopPropagation()} - > - {displayName} - + + e.stopPropagation()} + > + {displayName} + + )} ); diff --git a/src/components/EmbeddedNaddr.tsx b/src/components/EmbeddedNaddr.tsx index 5f234e22..f1d3466a 100644 --- a/src/components/EmbeddedNaddr.tsx +++ b/src/components/EmbeddedNaddr.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { type ReactNode, useMemo } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { Award, MessageSquareOff } from 'lucide-react'; @@ -6,6 +6,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { Skeleton } from '@/components/ui/skeleton'; import { EmojifiedText } from '@/components/CustomEmoji'; +import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { parseBadgeDefinition } from '@/components/BadgeContent'; import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; import { useAuthor } from '@/hooks/useAuthor'; @@ -19,6 +20,8 @@ interface EmbeddedNaddrProps { /** The decoded naddr coordinates. */ addr: AddrCoords; className?: string; + /** When true, ProfileHoverCards inside the card are disabled to prevent nested hover cards. */ + disableHoverCards?: boolean; } /** Maximum characters of content to show in the embedded preview. */ @@ -58,7 +61,7 @@ function extractMetadata(event: NostrEvent): { } /** Inline embedded card for an addressable Nostr event (naddr). */ -export function EmbeddedNaddr({ addr, className }: EmbeddedNaddrProps) { +export function EmbeddedNaddr({ addr, className, disableHoverCards }: EmbeddedNaddrProps) { const { data: event, isLoading, isError } = useAddrEvent(addr); if (isLoading) { @@ -74,7 +77,7 @@ export function EmbeddedNaddr({ addr, className }: EmbeddedNaddrProps) { return ; } - return ; + return ; } /** Compact badge showcase for kind 30009 embeds — smaller version of the feed BadgeContent. */ @@ -170,7 +173,7 @@ function EmbeddedBadgeCard({ event, className }: { event: NostrEvent; className? ); } -function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className?: string }) { +function EmbeddedNaddrCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) { const navigate = useNavigate(); const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; @@ -245,28 +248,32 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className? ) : ( <> - e.stopPropagation()} - > - - - - {displayName[0]?.toUpperCase()} - - - + + e.stopPropagation()} + > + + + + {displayName[0]?.toUpperCase()} + + + + - e.stopPropagation()} - > - {author.data?.event ? ( - {displayName} - ) : displayName} - + + e.stopPropagation()} + > + {author.data?.event ? ( + {displayName} + ) : displayName} + + )} @@ -301,6 +308,18 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className? ); } +/** Conditionally wraps children in a ProfileHoverCard. When disabled, renders children directly. */ +function MaybeProfileHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) { + if (disabled) { + return <>{children}; + } + return ( + + {children} + + ); +} + /** Tombstone shown when an addressable event could not be loaded. */ function EmbeddedNaddrTombstone({ addr, className }: { addr: AddrCoords; className?: string }) { const navigate = useNavigate(); diff --git a/src/components/LiveStreamPage.tsx b/src/components/LiveStreamPage.tsx index 8ba8925a..7b0e7a63 100644 --- a/src/components/LiveStreamPage.tsx +++ b/src/components/LiveStreamPage.tsx @@ -374,19 +374,23 @@ function ParticipantRow({ pubkey, role }: { pubkey: string; role?: string }) { return (
- - - - - {displayName[0]?.toUpperCase()} - - - - - {author.data?.event ? ( - {displayName} - ) : displayName} - + + + + + + {displayName[0]?.toUpperCase()} + + + + + + + {author.data?.event ? ( + {displayName} + ) : displayName} + + {role && ( {role} diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 61ec3e46..a3cb1e2d 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -1708,19 +1708,21 @@ function EventActionHeader({ {author.isLoading ? ( ) : ( - e.stopPropagation()} - > - {author.data?.event ? ( - - {name} - - ) : ( - name - )} - + + e.stopPropagation()} + > + {author.data?.event ? ( + + {name} + + ) : ( + name + )} + + )} {action} From acddb196d5c293872658cba7c3c00d1b005e1b65 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 21:17:27 -0500 Subject: [PATCH 016/104] Add hover card previews for books and countries in CommentContext - IsbnCommentContext: Wrap book title link in HoverCard showing cover thumbnail, title, and authors (reusing useBookInfo data) - CountryCommentContext: New component extracted from ExternalCommentContext, wraps country link in HoverCard showing flag, region/country label, name, and parent country for subdivisions - Both follow the same HoverCard pattern as UrlCommentContext --- src/components/CommentContext.tsx | 135 ++++++++++++++++++++++++------ 1 file changed, 111 insertions(+), 24 deletions(-) diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index 5126b1f1..bbb67198 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -2,7 +2,7 @@ import type React from 'react'; import { type ReactNode, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; -import { Rocket } from 'lucide-react'; +import { BookOpen, MapPin, Rocket } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; import { EmbeddedNote } from '@/components/EmbeddedNote'; @@ -415,23 +415,12 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN return ; } - // Determine display text and link - let displayText: string; - + // ISO 3166 country/subdivision identifiers get special treatment if (identifier.startsWith('iso3166:')) { - const code = identifier.slice('iso3166:'.length); - const info = getCountryInfo(code); - if (info) { - displayText = info.subdivisionName - ? `${info.flag} ${info.subdivisionName}` - : `${info.flag} ${info.name}`; - } else { - displayText = identifier; - } - } else { - displayText = identifier; + return ; } + // Generic fallback for other external identifiers const link = `/i/${encodeURIComponent(identifier)}`; return ( @@ -441,7 +430,7 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN className="text-primary hover:underline truncate" onClick={(e) => e.stopPropagation()} > - {displayText} + {identifier} ); @@ -488,22 +477,120 @@ function UrlCommentContext({ url, className }: { url: string; className?: string ); } -/** Comment context for ISBN identifiers — fetches and displays the book title. */ +/** Comment context for ISO 3166 country/subdivision identifiers — shows flag and name with hover preview. */ +function CountryCommentContext({ identifier, className }: { identifier: string; className?: string }) { + const code = identifier.slice('iso3166:'.length); + const info = getCountryInfo(code); + const link = `/i/${encodeURIComponent(identifier)}`; + + const displayText = info + ? info.subdivisionName + ? `${info.flag} ${info.subdivisionName}` + : `${info.flag} ${info.name}` + : identifier; + + return ( + + + + e.stopPropagation()} + > + {displayText} + + + e.stopPropagation()} + > +
+ + {info?.flag ?? '🌍'} + +
+
+ + {info?.subdivisionName ? 'Region' : 'Country'} +
+

+ {info?.subdivisionName ?? info?.name ?? code} +

+ {info?.subdivisionName && info.name && ( +

+ {info.name} +

+ )} +
+
+
+
+
+ ); +} + +/** Comment context for ISBN identifiers — fetches and displays the book title with hover preview. */ function IsbnCommentContext({ identifier, className }: { identifier: string; className?: string }) { const isbn = identifier.slice('isbn:'.length); const { data: bookInfo, isLoading } = useBookInfo(isbn); const link = `/i/${encodeURIComponent(identifier)}`; const displayText = bookInfo?.title ?? identifier; + const coverUrl = bookInfo?.cover?.medium || bookInfo?.cover?.large; + const authors = bookInfo?.authors?.map((a) => a.name).join(', '); return ( - e.stopPropagation()} - > - {displayText} - + + + e.stopPropagation()} + > + {displayText} + + + e.stopPropagation()} + > +
+ {coverUrl ? ( + {bookInfo?.title + ) : ( +
+ +
+ )} +
+
+ + Book +
+

+ {bookInfo?.title || `ISBN ${isbn}`} +

+ {authors && ( +

+ by {authors} +

+ )} +
+
+
+
); } From 946e5823bd36f0ba6e0d479d8e389d0dfd9fba78 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 21:29:19 -0500 Subject: [PATCH 017/104] Improve CommentContext display for all supported kinds - Add kind-specific suffixes: badge, theme, follow pack, emoji pack, etc. - Add kind-specific postfixes: apps show 'on Zapstore', releases show 'release' - Profile badges (30008) show '@User's profile badges' with author hover card - Add icons for all supported kinds (Award, Palette, Package, etc.) - Add BookOpen icon inline next to book titles in ISBN context - Add @ prefix to author names in reaction context - Expand KIND_LABELS to cover all Ditto-supported kinds with proper articles --- src/components/CommentContext.tsx | 173 +++++++++++++++++++++++++++--- 1 file changed, 159 insertions(+), 14 deletions(-) diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index bbb67198..a899f798 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -2,7 +2,10 @@ import type React from 'react'; import { type ReactNode, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; -import { BookOpen, MapPin, Rocket } from 'lucide-react'; +import { + Award, BookOpen, FileText, Globe, Layers, MapPin, MessageSquare, Mic, + Package, Palette, Radio, Rocket, SmilePlus, Users, +} from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; import { EmbeddedNote } from '@/components/EmbeddedNote'; @@ -65,19 +68,76 @@ function parseCommentRoot(event: NostrEvent): CommentRoot | undefined { return undefined; } -/** Hardcoded kind-to-label map for kinds not covered by EXTRA_KINDS. */ +/** Hardcoded kind-to-label map for fallback when the event hasn't loaded. */ const KIND_LABELS: Record = { + 1: 'a post', 7: 'a reaction', + 20: 'a photo', + 21: 'a video', + 22: 'a short video', + 1063: 'a file', + 1068: 'a poll', + 1111: 'a comment', + 1222: 'a voice message', + 1617: 'a patch', + 1618: 'a pull request', 15128: 'an nsite', 35128: 'an nsite', - 32267: 'an app', + 30008: 'profile badges', + 30009: 'a badge', + 30023: 'an article', + 30030: 'an emoji pack', + 30054: 'a podcast episode', + 30055: 'a podcast trailer', 30063: 'a release', + 30311: 'a stream', + 30617: 'a repository', + 30817: 'a custom NIP', + 31922: 'a calendar event', + 31923: 'a calendar event', + 32267: 'an app', + 34236: 'a vine', + 34550: 'a community', + 36767: 'a theme', + 16767: 'a theme', + 36787: 'a track', + 34139: 'a playlist', + 37381: 'a Magic deck', + 37516: 'a geocache', + 39089: 'a follow pack', + 3367: 'a color moment', + 7516: 'a found log', }; /** Kind-specific icons. */ const KIND_ICONS: Partial>> = { + 1: MessageSquare, + 20: Globe, + 21: Globe, + 22: Globe, + 1063: FileText, + 1068: Layers, + 1222: Mic, + 1617: FileText, + 1618: FileText, 15128: Rocket, 35128: Rocket, + 30008: Award, + 30009: Award, + 30023: FileText, + 30030: SmilePlus, + 30054: Radio, + 30055: Radio, + 30063: Package, + 30311: Radio, + 30617: Globe, + 32267: Package, + 34236: Globe, + 36767: Palette, + 16767: Palette, + 36787: Globe, + 37381: Globe, + 39089: Users, }; /** @@ -110,6 +170,27 @@ function getRootKindLabel(rootKind: string | undefined): string { return getKindLabel(kindNum); } +/** Suffix that describes the kind, appended after a title (e.g. "Wet Dry World theme"). */ +const KIND_SUFFIXES: Partial> = { + 30009: 'badge', + 30030: 'emoji pack', + 36767: 'theme', + 16767: 'theme', + 39089: 'follow pack', + 37381: 'deck', + 37516: 'geocache', + 34550: 'community', + 30054: 'episode', + 30055: 'trailer', + 34139: 'playlist', +}; + +/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto app"). */ +const KIND_POSTFIXES: Partial> = { + 32267: 'on Zapstore', + 30063: 'release', +}; + /** Get a display name for an event based on its kind and tags. */ function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.ComponentType<{ className?: string }> } { const icon = KIND_ICONS[event.kind]; @@ -122,17 +203,26 @@ function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.Co return { text: siteName ? `${siteName} nsite` : 'an nsite', icon }; } - // Try title tag first (used by articles, themes, follow packs, etc.) + // Extract a title-like string from tags const title = event.tags.find(([name]) => name === 'title')?.[1]; - if (title) return { text: title, icon }; - - // Try name tag (used by communities, emoji packs, etc.) const name = event.tags.find(([name]) => name === 'name')?.[1]; - if (name) return { text: name, icon }; - - // Try d tag (addressable events use this as an identifier) const dTag = event.tags.find(([name]) => name === 'd')?.[1]; - if (dTag) return { text: dTag, icon }; + const displayTitle = title || name || dTag; + + // Kinds with a custom postfix (e.g. "Ditto on Zapstore") + const postfix = KIND_POSTFIXES[event.kind]; + if (postfix && displayTitle) { + return { text: `${displayTitle} ${postfix}`, icon }; + } + + // Kinds with a suffix (e.g. "Beagle Owner badge", "Wet Dry World theme") + const suffix = KIND_SUFFIXES[event.kind]; + if (suffix && displayTitle) { + return { text: `${displayTitle} ${suffix}`, icon }; + } + + // Generic: just use the title if available + if (displayTitle) return { text: displayTitle, icon }; // Fall back to kind label return { text: getKindLabel(event.kind), icon }; @@ -276,6 +366,11 @@ function AddrCommentContext({ root, className }: { root: CommentRoot; className? return ; } + // Kind 30008 (profile badges) roots — show "@User's profile badges" + if (root.addr?.kind === 30008) { + return ; + } + return ; } @@ -301,6 +396,55 @@ function ProfileCommentContext({ pubkey, className }: { pubkey: string; classNam ); } +/** Comment context for kind 30008 (profile badges) roots — shows "Commenting on @User's profile badges". */ +function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; className?: string }) { + const pubkey = root.addr?.pubkey ?? ''; + const author = useAuthor(pubkey); + const metadata = author.data?.metadata; + const displayName = metadata?.name ?? genUserName(pubkey); + const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]); + + // Build naddr link for the profile badges event + const link = useMemo(() => { + if (!root.addr) return undefined; + try { return `/${nip19.naddrEncode({ kind: root.addr.kind, pubkey: root.addr.pubkey, identifier: root.addr.identifier })}`; } catch { return undefined; } + }, [root.addr]); + + // Hover content for the addressable event + const hoverContent = root.addr ? ( + + ) : undefined; + + return ( + + + e.stopPropagation()} + > + @{displayName} + + + {link && hoverContent ? ( + <> + 's + + + ) : ( + 's profile badges + )} + + ); +} + /** Comment context for non-profile addressable event roots (A tag). */ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) { const { data: event, isLoading } = useAddrEvent(root.addr); @@ -366,7 +510,7 @@ function EventCommentContext({ root, className }: { root: CommentRoot; className ); } -/** Comment context for kind 7 reaction roots — shows "Commenting on {emoji} by {name}". */ +/** Comment context for kind 7 reaction roots — shows "Commenting on {emoji} by @{name}". */ function ReactionCommentContext({ event, className }: { event: NostrEvent; className?: string }) { const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; @@ -393,7 +537,7 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class className="text-primary hover:underline truncate cursor-pointer" onClick={(e) => e.stopPropagation()} > - {displayName} + @{displayName} )} @@ -548,9 +692,10 @@ function IsbnCommentContext({ identifier, className }: { identifier: string; cla e.stopPropagation()} > + {displayText} From 1c6ccaaecab214a2797dc72f95dcf764589b403a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 21:35:16 -0500 Subject: [PATCH 018/104] Use correct icons in CommentContext KIND_ICONS matching sidebar and NoteCard CardsIcon for Magic decks, ChestIcon for geocaches/found logs, Clapperboard for vines, Music for tracks/playlists, Camera for photos, Film for videos, GitBranch for repos, GitPullRequest for PRs, BookOpen for articles, Podcast for episodes/trailers, BarChart3 for polls, PartyPopper for follow packs, Palette for color moments. --- src/components/CommentContext.tsx | 39 ++++++++++++++++++------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index a899f798..e6163e84 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -3,11 +3,14 @@ import { type ReactNode, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { - Award, BookOpen, FileText, Globe, Layers, MapPin, MessageSquare, Mic, - Package, Palette, Radio, Rocket, SmilePlus, Users, + Award, BarChart3, BookOpen, Camera, Clapperboard, FileText, Film, + GitBranch, GitPullRequest, MapPin, MessageSquare, Mic, Music, + Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Users, } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; +import { CardsIcon } from '@/components/icons/CardsIcon'; +import { ChestIcon } from '@/components/icons/ChestIcon'; import { EmbeddedNote } from '@/components/EmbeddedNote'; import { EmbeddedNaddr } from '@/components/EmbeddedNaddr'; import { LinkPreview } from '@/components/LinkPreview'; @@ -109,35 +112,39 @@ const KIND_LABELS: Record = { 7516: 'a found log', }; -/** Kind-specific icons. */ +/** Kind-specific icons — matches sidebar and NoteCard icons. */ const KIND_ICONS: Partial>> = { 1: MessageSquare, - 20: Globe, - 21: Globe, - 22: Globe, + 20: Camera, + 21: Film, + 22: Film, 1063: FileText, - 1068: Layers, + 1068: BarChart3, 1222: Mic, 1617: FileText, - 1618: FileText, + 1618: GitPullRequest, 15128: Rocket, 35128: Rocket, 30008: Award, 30009: Award, - 30023: FileText, + 30023: BookOpen, 30030: SmilePlus, - 30054: Radio, - 30055: Radio, + 30054: Podcast, + 30055: Podcast, 30063: Package, 30311: Radio, - 30617: Globe, + 30617: GitBranch, 32267: Package, - 34236: Globe, + 34236: Clapperboard, 36767: Palette, 16767: Palette, - 36787: Globe, - 37381: Globe, - 39089: Users, + 36787: Music, + 34139: Music, + 37381: CardsIcon, + 37516: ChestIcon, + 7516: ChestIcon, + 39089: PartyPopper, + 3367: Palette, }; /** From bf4ec364f795ff5b413d22cc5132f9d3159fda60 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 21:37:11 -0500 Subject: [PATCH 019/104] Fix theme icon to Sparkles and rephrase profile badges to 'by @User' - Themes use Sparkles (matching sidebar), not Palette (that's color moments) - Profile badges now reads 'Commenting on profile badges by @Mary' instead of 'Commenting on @Mary's profile badges' --- src/components/CommentContext.tsx | 33 ++++++++++++++++--------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index e6163e84..46c9f7e2 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -5,7 +5,7 @@ import { nip19 } from 'nostr-tools'; import { Award, BarChart3, BookOpen, Camera, Clapperboard, FileText, Film, GitBranch, GitPullRequest, MapPin, MessageSquare, Mic, Music, - Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Users, + Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles, Users, } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -136,8 +136,8 @@ const KIND_ICONS: Partial + {link && hoverContent ? ( + + ) : ( + + + profile badges + + )} + by - {link && hoverContent ? ( - <> - 's - - - ) : ( - 's profile badges - )} ); } From 73b063bf1b7f96a0ee0928e36efb327f6d563df4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 21:39:50 -0500 Subject: [PATCH 020/104] =?UTF-8?q?Fix=20plural=20kind=20labels=20in=20com?= =?UTF-8?q?ment=20context=20=E2=80=94=20use=20singular=20forms=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallthrough to EXTRA_KINDS produced plural/categorical labels like 'requests to vanish' and 'user statuses'. Now getKindLabel only uses KIND_LABELS which has proper singular forms ('a request to vanish', 'a status'). Added missing entries for kinds 6, 16, 62, 30315. --- src/components/CommentContext.tsx | 38 ++++++++++++++++--------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index 46c9f7e2..912c602b 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -26,7 +26,7 @@ import { useLinkPreview } from '@/hooks/useLinkPreview'; import { getDisplayName } from '@/lib/getDisplayName'; import { genUserName } from '@/lib/genUserName'; import { getCountryInfo } from '@/lib/countries'; -import { EXTRA_KINDS } from '@/lib/extraKinds'; + /** Default classes shared by all comment context rows. */ const ROW_CLASS = 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1 min-w-0 overflow-hidden'; @@ -71,21 +71,31 @@ function parseCommentRoot(event: NostrEvent): CommentRoot | undefined { return undefined; } -/** Hardcoded kind-to-label map for fallback when the event hasn't loaded. */ +/** + * Singular comment-context labels for every supported kind. + * Must use singular form with article ("a post", "an article") since these + * appear as "Commenting on {label}". EXTRA_KINDS labels are plural/categorical + * ("Requests to Vanish", "User Statuses") and must NOT be used directly. + */ const KIND_LABELS: Record = { 1: 'a post', + 6: 'a repost', 7: 'a reaction', + 16: 'a repost', 20: 'a photo', 21: 'a video', 22: 'a short video', + 62: 'a request to vanish', 1063: 'a file', 1068: 'a poll', 1111: 'a comment', 1222: 'a voice message', 1617: 'a patch', 1618: 'a pull request', + 3367: 'a color moment', + 7516: 'a found log', 15128: 'an nsite', - 35128: 'an nsite', + 16767: 'a theme', 30008: 'profile badges', 30009: 'a badge', 30023: 'an article', @@ -94,22 +104,21 @@ const KIND_LABELS: Record = { 30055: 'a podcast trailer', 30063: 'a release', 30311: 'a stream', + 30315: 'a status', 30617: 'a repository', 30817: 'a custom NIP', 31922: 'a calendar event', 31923: 'a calendar event', 32267: 'an app', + 34139: 'a playlist', 34236: 'a vine', 34550: 'a community', + 35128: 'an nsite', 36767: 'a theme', - 16767: 'a theme', 36787: 'a track', - 34139: 'a playlist', 37381: 'a Magic deck', 37516: 'a geocache', 39089: 'a follow pack', - 3367: 'a color moment', - 7516: 'a found log', }; /** Kind-specific icons — matches sidebar and NoteCard icons. */ @@ -148,19 +157,12 @@ const KIND_ICONS: Partial - def.subKinds?.some((sub) => sub.kind === kind) || def.kind === kind, - ); - if (kindDef) return kindDef.label.toLowerCase(); - - return 'a post'; + return KIND_LABELS[kind] ?? 'a post'; } /** Parse a rootKind string into a label, handling both numeric and external content kinds. */ From 8143d8b9ace101eddc54b61ea173e7efd3736253 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 21:51:58 -0500 Subject: [PATCH 021/104] Add Zapstore apps (32267) to development feed kinds --- src/lib/extraKinds.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index 45238575..616b8bb6 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -464,9 +464,9 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ id: 'development', showKey: 'showDevelopment', feedKey: 'feedIncludeDevelopment', - extraFeedKinds: [1617, 1618, 30817, 15128, 35128], + extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267], label: 'Development', - description: 'Git repos, patches, PRs, nsites, and custom NIPs', + description: 'Git repos, patches, PRs, nsites, apps, and custom NIPs', route: 'development', addressable: true, section: 'development', From 215ae071436680a301d64338a273a4cf94802230 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 21:52:54 -0500 Subject: [PATCH 022/104] Enable development kinds in feed by default --- src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index be106fd7..fb65c9a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -102,8 +102,8 @@ const hardcodedConfig: AppConfig = { showPodcasts: true, feedIncludePodcastEpisodes: true, feedIncludePodcastTrailers: true, - showDevelopment: false, - feedIncludeDevelopment: false, + showDevelopment: true, + feedIncludeDevelopment: true, showBadges: true, showBadgeDefinitions: true, showProfileBadges: true, From 2ad3fe1e4aca10f6bc14c85b683899594eab5d1a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 22:05:25 -0500 Subject: [PATCH 023/104] Render nsite events with rich link preview from OEmbed data --- src/components/NsiteCard.tsx | 154 +++++++++++++++++++++++------------ 1 file changed, 104 insertions(+), 50 deletions(-) diff --git a/src/components/NsiteCard.tsx b/src/components/NsiteCard.tsx index 53dc5a9d..f21a2f3e 100644 --- a/src/components/NsiteCard.tsx +++ b/src/components/NsiteCard.tsx @@ -1,14 +1,18 @@ import type { NostrEvent } from "@nostrify/nostrify"; -import { ExternalLink, FileText, Globe } from "lucide-react"; +import { ExternalLink, FileText, Globe, Server } from "lucide-react"; import { nip19 } from "nostr-tools"; +import { ExternalFavicon } from "@/components/ExternalFavicon"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useLinkPreview } from "@/hooks/useLinkPreview"; +import { cn } from "@/lib/utils"; + interface NsiteCardProps { event: NostrEvent; } /** Encode a 32-byte hex pubkey as a base36 string (50 chars, zero-padded). */ function hexToBase36(hex: string): string { - // Process the hex string in chunks to build a BigInt let n = 0n; for (let i = 0; i < hex.length; i++) { n = n * 16n + BigInt(parseInt(hex[i], 16)); @@ -22,17 +26,24 @@ function getNsiteUrl(event: NostrEvent): string { const dTag = event.tags.find(([n]) => n === "d")?.[1]; if (event.kind === 35128 && dTag) { - // Named site: .nsite.lol const pubkeyB36 = hexToBase36(event.pubkey); return `https://${pubkeyB36}${dTag}.nsite.lol`; } - // Root site (kind 15128): .nsite.lol const npub = nip19.npubEncode(event.pubkey); return `https://${npub}.nsite.lol`; } -/** Renders an nsite deployment card for kind 15128 (root site) or 35128 (named site). */ +/** Extract display domain from a URL. */ +function displayDomain(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +/** Renders an nsite deployment card with a rich link preview. */ export function NsiteCard({ event }: NsiteCardProps) { const title = event.tags.find(([n]) => n === "title")?.[1]; const description = event.tags.find(([n]) => n === "description")?.[1]; @@ -45,29 +56,65 @@ export function NsiteCard({ event }: NsiteCardProps) { const siteUrl = getNsiteUrl(event); const displayName = title || (isNamed ? dTag : "Root Site"); + const { data: preview, isLoading } = useLinkPreview(siteUrl); + const image = preview?.thumbnail_url; + const previewTitle = preview?.title; + const domain = preview?.provider_name || displayDomain(siteUrl); + + if (isLoading) { + return ; + } + return ( -
-
- {/* Site name + type badge */} -
- - - {displayName} - - + e.stopPropagation()} + > + {/* Link preview thumbnail */} + {image && ( +
+ { + (e.currentTarget.parentElement as HTMLElement).style.display = "none"; + }} + /> +
+ )} + +
+ {/* Domain bar with favicon */} +
+ + {domain} + {isNamed ? "Named Site" : "Root Site"}
- {/* Description */} - {description && ( -

- {description} + {/* Title — use OEmbed title if available, fall back to event title/d-tag */} +

+ {previewTitle || displayName} +

+ + {/* Description — prefer event description (it's curated), fall back to OEmbed author */} + {(description || preview?.author_name) && ( +

+ {description || preview?.author_name}

)} - {/* File count + server info */} -
+ + ); +} - {/* Action buttons */} -
- - {sourceUrl && ( - - )} -
+function NsiteCardSkeleton() { + return ( +
+ +
+ + + +
); From beee52e3e5c60f709158bf3fe76f9762dcf5756b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 22:08:26 -0500 Subject: [PATCH 024/104] Remove unused 'Users' import to fix ESLint error --- src/components/CommentContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index 912c602b..7240c1ee 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -5,7 +5,7 @@ import { nip19 } from 'nostr-tools'; import { Award, BarChart3, BookOpen, Camera, Clapperboard, FileText, Film, GitBranch, GitPullRequest, MapPin, MessageSquare, Mic, Music, - Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles, Users, + Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles, } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; From 973e62115d314777272af28ea0929af89c994138 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 22:09:20 -0500 Subject: [PATCH 025/104] Show 'nsite.lol' instead of the full gateway URL in the domain bar --- src/components/NsiteCard.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/components/NsiteCard.tsx b/src/components/NsiteCard.tsx index f21a2f3e..3d96ebe4 100644 --- a/src/components/NsiteCard.tsx +++ b/src/components/NsiteCard.tsx @@ -34,15 +34,6 @@ function getNsiteUrl(event: NostrEvent): string { return `https://${npub}.nsite.lol`; } -/** Extract display domain from a URL. */ -function displayDomain(url: string): string { - try { - return new URL(url).hostname.replace(/^www\./, ""); - } catch { - return url; - } -} - /** Renders an nsite deployment card with a rich link preview. */ export function NsiteCard({ event }: NsiteCardProps) { const title = event.tags.find(([n]) => n === "title")?.[1]; @@ -59,7 +50,7 @@ export function NsiteCard({ event }: NsiteCardProps) { const { data: preview, isLoading } = useLinkPreview(siteUrl); const image = preview?.thumbnail_url; const previewTitle = preview?.title; - const domain = preview?.provider_name || displayDomain(siteUrl); + const domain = preview?.provider_name || "nsite.lol"; if (isLoading) { return ; From a8fed56929c6321f3f2913d839f33ca743346c0f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 22:11:13 -0500 Subject: [PATCH 026/104] Always display 'nsite.lol' as domain, ignore OEmbed provider_name --- src/components/NsiteCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/NsiteCard.tsx b/src/components/NsiteCard.tsx index 3d96ebe4..5068ea2d 100644 --- a/src/components/NsiteCard.tsx +++ b/src/components/NsiteCard.tsx @@ -50,7 +50,7 @@ export function NsiteCard({ event }: NsiteCardProps) { const { data: preview, isLoading } = useLinkPreview(siteUrl); const image = preview?.thumbnail_url; const previewTitle = preview?.title; - const domain = preview?.provider_name || "nsite.lol"; + const domain = "nsite.lol"; if (isLoading) { return ; From 0459590d24e494c3b547e779f2cb69ce5df29193 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 22:12:35 -0500 Subject: [PATCH 027/104] Replace domain bar with favicon next to the title --- src/components/NsiteCard.tsx | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/components/NsiteCard.tsx b/src/components/NsiteCard.tsx index 5068ea2d..7454b508 100644 --- a/src/components/NsiteCard.tsx +++ b/src/components/NsiteCard.tsx @@ -50,7 +50,6 @@ export function NsiteCard({ event }: NsiteCardProps) { const { data: preview, isLoading } = useLinkPreview(siteUrl); const image = preview?.thumbnail_url; const previewTitle = preview?.title; - const domain = "nsite.lol"; if (isLoading) { return ; @@ -83,20 +82,14 @@ export function NsiteCard({ event }: NsiteCardProps) { )}
- {/* Domain bar with favicon */} -
- - {domain} - - {isNamed ? "Named Site" : "Root Site"} - + {/* Title with favicon */} +
+ +

+ {previewTitle || displayName} +

- {/* Title — use OEmbed title if available, fall back to event title/d-tag */} -

- {previewTitle || displayName} -

- {/* Description — prefer event description (it's curated), fall back to OEmbed author */} {(description || preview?.author_name) && (

From 1b85be36f5dfca1efd07305f361522377bbc7919 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 23:07:39 -0500 Subject: [PATCH 028/104] Fix theme icon in NoteCard: use Sparkles instead of Palette --- src/components/NoteCard.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index a3cb1e2d..9808baaa 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -9,11 +9,11 @@ import { Rocket, MoreHorizontal, Package, - Palette, Play, Radio, Share2, SmilePlus, + Sparkles, Users, Zap, } from "lucide-react"; @@ -1602,13 +1602,13 @@ const KIND_HEADER_MAP: Record = { nounRoute: "/decks", }, 36767: { - icon: Palette, + icon: Sparkles, action: "shared a", noun: "theme", nounRoute: "/themes", }, 16767: { - icon: Palette, + icon: Sparkles, action: "updated their", noun: "theme", nounRoute: "/themes", From a3acf4809109221694d925d1ee7dc7ae3ecbe25d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 23:55:37 -0500 Subject: [PATCH 029/104] Display kind 4 encrypted DMs as mail-in-transit instead of raw ciphertext Kind 4 (NIP-04 encrypted DM) events were rendering their encrypted content as plain text. Now they show a visual card with sender and recipient avatars connected by a mail icon, with encryption type detection (NIP-04 vs NIP-44) displayed in a footer badge with a lock icon. Registered kind 4 across NoteCard, PostDetailPage, and CommentContext. --- src/components/CommentContext.tsx | 4 +- src/components/EncryptedMessageContent.tsx | 132 +++++++++++++++++++++ src/components/NoteCard.tsx | 15 ++- src/pages/PostDetailPage.tsx | 6 + 4 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 src/components/EncryptedMessageContent.tsx diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index 7240c1ee..a40d22ac 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -4,7 +4,7 @@ import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { Award, BarChart3, BookOpen, Camera, Clapperboard, FileText, Film, - GitBranch, GitPullRequest, MapPin, MessageSquare, Mic, Music, + GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music, Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles, } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -79,6 +79,7 @@ function parseCommentRoot(event: NostrEvent): CommentRoot | undefined { */ const KIND_LABELS: Record = { 1: 'a post', + 4: 'an encrypted message', 6: 'a repost', 7: 'a reaction', 16: 'a repost', @@ -124,6 +125,7 @@ const KIND_LABELS: Record = { /** Kind-specific icons — matches sidebar and NoteCard icons. */ const KIND_ICONS: Partial>> = { 1: MessageSquare, + 4: Mail, 20: Camera, 21: Film, 22: Film, diff --git a/src/components/EncryptedMessageContent.tsx b/src/components/EncryptedMessageContent.tsx new file mode 100644 index 00000000..906edec2 --- /dev/null +++ b/src/components/EncryptedMessageContent.tsx @@ -0,0 +1,132 @@ +import type { NostrEvent } from '@nostrify/nostrify'; +import { Lock, Mail } from 'lucide-react'; +import { Link } from 'react-router-dom'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { ProfileHoverCard } from '@/components/ProfileHoverCard'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useProfileUrl } from '@/hooks/useProfileUrl'; +import { getAvatarShape } from '@/lib/avatarShape'; +import { getDisplayName } from '@/lib/getDisplayName'; +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; + +interface EncryptedMessageContentProps { + event: NostrEvent; + /** Whether to use the compact card layout (feed) vs expanded (detail page). */ + compact?: boolean; + className?: string; +} + +/** + * Detect whether the content uses NIP-04 (AES-256-CBC with ?iv=) or NIP-44 encryption. + * NIP-04 content contains a base64 body followed by `?iv=`. + * NIP-44 uses a versioned binary blob encoded as base64 without the ?iv= suffix. + */ +function detectEncryptionType(content: string): 'NIP-04' | 'NIP-44' | 'Unknown' { + if (!content) return 'Unknown'; + if (/\?iv=/.test(content)) return 'NIP-04'; + // NIP-44 payloads are pure base64 without the ?iv= marker + if (/^[A-Za-z0-9+/]+=*$/.test(content.trim()) && content.length > 20) return 'NIP-44'; + return 'Unknown'; +} + +/** Renders a single participant avatar with name and link. */ +function Participant({ pubkey, side }: { pubkey: string; side: 'sender' | 'recipient' }) { + const author = useAuthor(pubkey); + const metadata = author.data?.metadata; + const avatarShape = getAvatarShape(metadata); + const displayName = getDisplayName(metadata, pubkey); + const profileUrl = useProfileUrl(pubkey, metadata); + + if (author.isLoading) { + return ( +

+ + +
+ ); + } + + return ( +
+ + e.stopPropagation()}> + + + + {displayName[0]?.toUpperCase()} + + + + + + e.stopPropagation()} + className="text-xs font-medium text-foreground/80 hover:text-foreground truncate max-w-[80px] transition-colors" + > + {displayName} + + +
+ ); +} + +/** + * Compact card for kind 4 (NIP-04 encrypted DM) events. + * + * Instead of rendering the encrypted ciphertext, it shows a visual + * "mail in transit" display: sender avatar -> animated path with lock -> recipient avatar. + * Detects and labels the encryption type (NIP-04 vs NIP-44). + */ +export function EncryptedMessageContent({ event, compact: _compact, className }: EncryptedMessageContentProps) { + const recipientPubkey = event.tags.find(([n]) => n === 'p')?.[1]; + const encryptionType = detectEncryptionType(event.content); + + if (!recipientPubkey) { + return ( +
+

Encrypted message (no recipient found)

+
+ ); + } + + return ( +
+
+ {/* Main transit visualization */} +
+
+ {/* Sender */} + + + {/* Animated transit path */} +
+ {/* Dotted line */} +
+ + {/* Mail icon in the center */} +
+
+ +
+
+
+ + {/* Recipient */} + +
+
+ + {/* Footer with encryption badge */} +
+ + + Encrypted with {encryptionType} + +
+
+
+ ); +} diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 9808baaa..a96abf55 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -5,6 +5,7 @@ import { FileText, GitBranch, GitPullRequest, + Mail, MessageCircle, Rocket, MoreHorizontal, @@ -64,6 +65,7 @@ import { ReplyComposeModal } from "@/components/ReplyComposeModal"; import { ReplyContext } from "@/components/ReplyContext"; import { RepostMenu } from "@/components/RepostMenu"; import { ThemeContent } from "@/components/ThemeContent"; +import { EncryptedMessageContent } from "@/components/EncryptedMessageContent"; import { VanishCardCompact } from "@/components/VanishEventContent"; import { ZapstoreAppContent } from "@/components/ZapstoreAppContent"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -261,6 +263,7 @@ export const NoteCard = memo(function NoteCard({ const isCustomNip = event.kind === 30817; const isNsite = event.kind === 15128 || event.kind === 35128; const isZapstoreApp = event.kind === 32267; + const isEncryptedDM = event.kind === 4; const isVanish = event.kind === 62; const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite; const isTextNote = @@ -285,6 +288,7 @@ export const NoteCard = memo(function NoteCard({ !isAudioKind && !isDevKind && !isZapstoreApp && + !isEncryptedDM && !isVanish; // Kind 1 specific — images now render inline in NoteContent, only videos go to NoteMedia @@ -470,6 +474,8 @@ export const NoteCard = memo(function NoteCard({ ) : isZapstoreApp ? ( + ) : isEncryptedDM ? ( + ) : ( = { + 4: { + icon: Mail, + action: "sent an", + noun: "encrypted message", + }, 37516: { icon: ChestIcon, action: "hid a", @@ -1682,7 +1693,7 @@ const KIND_HEADER_MAP: Record = { }; /** Generic action header: icon · [author name] [action] [linked noun] */ -function EventActionHeader({ +export function EventActionHeader({ pubkey, icon: Icon, iconClassName, diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 8c96a7cb..c16dc7bd 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -71,6 +71,7 @@ import { } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; +import { EncryptedMessageContent } from "@/components/EncryptedMessageContent"; import { VanishEventContent } from "@/components/VanishEventContent"; import { VideoPlayer } from "@/components/VideoPlayer"; import { VoiceMessagePlayer } from "@/components/VoiceMessagePlayer"; @@ -126,6 +127,7 @@ function shellTitleForKind(kind?: number): string { if (kind === 32267) return "App Details"; if (kind === 15128 || kind === 35128) return "Nsite"; if (kind === VANISH_KIND) return "Request to Vanish"; + if (kind === 4) return "Encrypted Message"; return "Post Details"; } @@ -875,6 +877,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { const isCustomNip = event.kind === 30817; const isNsite = event.kind === 15128 || event.kind === 35128; const isZapstoreApp = event.kind === 32267; + const isEncryptedDM = event.kind === 4; const isVanish = event.kind === VANISH_KIND; const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite; const isTextNote = @@ -895,6 +898,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { !isCommunity && !isDevKind && !isZapstoreApp && + !isEncryptedDM && !isVanish; const videos = useMemo( @@ -1564,6 +1568,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
) : isZapstoreApp ? ( + ) : isEncryptedDM ? ( + ) : isVine || isPoll || isGeocache || From 6dde7bba0ff403e05a005d63e3e1d42eb47dab82 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 00:26:07 -0500 Subject: [PATCH 030/104] Improve kind 4 DM display: simplify card style and add compact view for embeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove gradient background and encryption footer from the full card, use a clean bordered card with centered sender description. Add EncryptedMessageCompact for quote posts, reply indicators, and the reply composer — dispatched from EmbeddedNote like vanish events. --- src/components/EmbeddedNote.tsx | 6 + src/components/EncryptedMessageContent.tsx | 184 +++++++++++++++------ 2 files changed, 142 insertions(+), 48 deletions(-) diff --git a/src/components/EmbeddedNote.tsx b/src/components/EmbeddedNote.tsx index 5eb1742e..1ab8aee5 100644 --- a/src/components/EmbeddedNote.tsx +++ b/src/components/EmbeddedNote.tsx @@ -8,6 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { EmojifiedText } from '@/components/CustomEmoji'; import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { VanishCardCompact } from '@/components/VanishEventContent'; +import { EncryptedMessageCompact } from '@/components/EncryptedMessageContent'; import { useEvent } from '@/hooks/useEvent'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; @@ -97,6 +98,11 @@ export function EmbeddedNote({ eventId, relays, authorHint, className, disableHo return ; } + // Kind 4 encrypted DMs get a compact card instead of rendering ciphertext + if (event.kind === 4) { + return ; + } + return ; } diff --git a/src/components/EncryptedMessageContent.tsx b/src/components/EncryptedMessageContent.tsx index 906edec2..eb916469 100644 --- a/src/components/EncryptedMessageContent.tsx +++ b/src/components/EncryptedMessageContent.tsx @@ -1,6 +1,8 @@ import type { NostrEvent } from '@nostrify/nostrify'; -import { Lock, Mail } from 'lucide-react'; -import { Link } from 'react-router-dom'; +import { useMemo } from 'react'; +import { Mail } from 'lucide-react'; +import { Link, useNavigate } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { ProfileHoverCard } from '@/components/ProfileHoverCard'; @@ -8,7 +10,9 @@ import { useAuthor } from '@/hooks/useAuthor'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { getAvatarShape } from '@/lib/avatarShape'; import { getDisplayName } from '@/lib/getDisplayName'; +import { genUserName } from '@/lib/genUserName'; import { Skeleton } from '@/components/ui/skeleton'; +import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; interface EncryptedMessageContentProps { @@ -18,21 +22,8 @@ interface EncryptedMessageContentProps { className?: string; } -/** - * Detect whether the content uses NIP-04 (AES-256-CBC with ?iv=) or NIP-44 encryption. - * NIP-04 content contains a base64 body followed by `?iv=`. - * NIP-44 uses a versioned binary blob encoded as base64 without the ?iv= suffix. - */ -function detectEncryptionType(content: string): 'NIP-04' | 'NIP-44' | 'Unknown' { - if (!content) return 'Unknown'; - if (/\?iv=/.test(content)) return 'NIP-04'; - // NIP-44 payloads are pure base64 without the ?iv= marker - if (/^[A-Za-z0-9+/]+=*$/.test(content.trim()) && content.length > 20) return 'NIP-44'; - return 'Unknown'; -} - /** Renders a single participant avatar with name and link. */ -function Participant({ pubkey, side }: { pubkey: string; side: 'sender' | 'recipient' }) { +function Participant({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); @@ -49,7 +40,7 @@ function Participant({ pubkey, side }: { pubkey: string; side: 'sender' | 'recip } return ( -
+
e.stopPropagation()}> @@ -74,19 +65,19 @@ function Participant({ pubkey, side }: { pubkey: string; side: 'sender' | 'recip } /** - * Compact card for kind 4 (NIP-04 encrypted DM) events. + * Visual display for kind 4 (NIP-04 encrypted DM) events. * - * Instead of rendering the encrypted ciphertext, it shows a visual - * "mail in transit" display: sender avatar -> animated path with lock -> recipient avatar. - * Detects and labels the encryption type (NIP-04 vs NIP-44). + * Instead of rendering the encrypted ciphertext, it shows a + * "mail in transit" visualization: sender avatar -> dashed path with mail icon -> recipient avatar. */ export function EncryptedMessageContent({ event, compact: _compact, className }: EncryptedMessageContentProps) { const recipientPubkey = event.tags.find(([n]) => n === 'p')?.[1]; - const encryptionType = detectEncryptionType(event.content); + const senderAuthor = useAuthor(event.pubkey); + const senderName = getDisplayName(senderAuthor.data?.metadata, event.pubkey); if (!recipientPubkey) { return ( -
+

Encrypted message (no recipient found)

); @@ -94,39 +85,136 @@ export function EncryptedMessageContent({ event, compact: _compact, className }: return (
-
- {/* Main transit visualization */} -
-
- {/* Sender */} - +
+ {/* Description */} +

+ {senderName} sent a direct message +

- {/* Animated transit path */} -
- {/* Dotted line */} -
+
+ {/* Sender */} + - {/* Mail icon in the center */} -
-
- -
+ {/* Transit path */} +
+ {/* Dotted line */} +
+ + {/* Mail icon in the center */} +
+
+
- - {/* Recipient */} -
-
- {/* Footer with encryption badge */} -
- - - Encrypted with {encryptionType} - + {/* Recipient */} +
); } + +interface EncryptedMessageCompactProps { + event: { id: string; kind: number; pubkey: string; content: string; created_at: number; tags: string[][] }; + className?: string; +} + +/** + * Compact inline card for kind 4 events used in quote posts, reply indicators, + * and the reply composer. Matches the style of EmbeddedNoteCard. + */ +export function EncryptedMessageCompact({ event, className }: EncryptedMessageCompactProps) { + const navigate = useNavigate(); + const author = useAuthor(event.pubkey); + const metadata = author.data?.metadata; + const avatarShape = getAvatarShape(metadata); + const displayName = metadata?.name || genUserName(event.pubkey); + const recipientPubkey = event.tags.find(([n]) => n === 'p')?.[1]; + const recipientAuthor = useAuthor(recipientPubkey ?? ''); + const recipientName = recipientPubkey + ? getDisplayName(recipientAuthor.data?.metadata, recipientPubkey) + : undefined; + + const neventId = useMemo( + () => nip19.neventEncode({ id: event.id, author: event.pubkey }), + [event.id, event.pubkey], + ); + + const profileUrl = useProfileUrl(event.pubkey, metadata); + + return ( +
{ + e.stopPropagation(); + navigate(`/${neventId}`); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + navigate(`/${neventId}`); + } + }} + > +
+ {/* Author row */} +
+ {author.isLoading ? ( + <> + + + + ) : ( + <> + + e.stopPropagation()} + > + + + + {displayName[0]?.toUpperCase()} + + + + + + + e.stopPropagation()} + > + {displayName} + + + + )} + + + · {timeAgo(event.created_at)} + +
+ + {/* Content line */} +

+ + + Sent a direct message{recipientName ? <> to {recipientName} : ''} + +

+
+
+ ); +} From 3252ae22479a5738b34be42523b84b5322021671 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 01:15:36 -0500 Subject: [PATCH 031/104] Add NIP-42 relay authentication support Sign kind 22242 AUTH events automatically when relays send challenges. Uses a signer ref pattern so the pool doesn't need to be recreated when the user logs in/out or switches accounts. --- src/components/NostrProvider.tsx | 58 ++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/components/NostrProvider.tsx b/src/components/NostrProvider.tsx index e51eee9d..da28626e 100644 --- a/src/components/NostrProvider.tsx +++ b/src/components/NostrProvider.tsx @@ -1,6 +1,8 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { NostrEvent, NostrFilter, NPool, NRelay1 } from '@nostrify/nostrify'; import { NostrContext } from '@nostrify/react'; +import { NUser, useNostrLogin } from '@nostrify/react/login'; +import type { NostrSigner } from '@nostrify/types'; import { useAppContext } from '@/hooks/useAppContext'; import { getEffectiveRelays, DITTO_RELAYS, DIVINE_RELAY, ZAPSTORE_RELAY } from '@/lib/appRelays'; import { NostrBatcher } from '@/lib/NostrBatcher'; @@ -12,6 +14,7 @@ interface NostrProviderProps { const NostrProvider: React.FC = (props) => { const { children } = props; const { config } = useAppContext(); + const { logins } = useNostrLogin(); // Create NPool instance only once const pool = useRef(undefined); @@ -19,6 +22,39 @@ const NostrProvider: React.FC = (props) => { // Use refs so the pool always has the latest data const effectiveRelays = useRef(getEffectiveRelays(config.relayMetadata, config.useAppRelays)); + // Stable ref to the current user's signer for NIP-42 AUTH. + // The `open()` callback reads from this ref when a relay sends an AUTH + // challenge, so it always uses the latest signer without recreating the pool. + const signerRef = useRef(undefined); + + // Derive the current signer from the active login. This mirrors the + // logic in useCurrentUser but avoids a circular dependency (useCurrentUser + // depends on NostrContext which we are providing here). + const currentLogin = logins[0]; + const currentSigner = useMemo(() => { + if (!currentLogin) return undefined; + try { + switch (currentLogin.type) { + case 'nsec': + return NUser.fromNsecLogin(currentLogin).signer; + case 'bunker': + // pool.current is guaranteed to exist here: the pool is created + // synchronously during the first render (below), and useMemo runs + // after the render body has executed. + return NUser.fromBunkerLogin(currentLogin, pool.current!).signer; + case 'extension': + return NUser.fromExtensionLogin(currentLogin).signer; + default: + return undefined; + } + } catch { + return undefined; + } + }, [currentLogin]); + + // Keep the ref in sync so the AUTH callback always sees the latest signer. + signerRef.current = currentSigner; + // Update effective relays ref when config changes. The NPool reads from // this ref, so new queries automatically use the updated relay set. // @@ -35,7 +71,25 @@ const NostrProvider: React.FC = (props) => { if (!pool.current) { pool.current = new NPool({ open(url: string) { - return new NRelay1(url); + return new NRelay1(url, { + // NIP-42: Respond to relay AUTH challenges by signing a kind + // 22242 ephemeral event with the current user's signer. + auth: async (challenge: string) => { + const signer = signerRef.current; + if (!signer) { + throw new Error('AUTH failed: no signer available (user not logged in)'); + } + return signer.signEvent({ + kind: 22242, + content: '', + tags: [ + ['relay', url], + ['challenge', challenge], + ], + created_at: Math.floor(Date.now() / 1000), + }); + }, + }); }, reqRouter(filters: NostrFilter[]): Map { const routes = new Map(); From 264358c223b666b62673c585aea88538598c9d84 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 01:37:07 -0500 Subject: [PATCH 032/104] Upgrade @nostrify/nostrify 0.50.5 -> 0.51.0 and @nostrify/react 0.3.0 -> 0.3.1 Includes NRelay1 AUTH fix for correct NIP-42 challenge handling. --- package-lock.json | 18 +++++++++--------- package.json | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 48ed8c15..57abf7f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,8 +39,8 @@ "@fontsource/silkscreen": "^5.2.8", "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^5.2.2", - "@nostrify/nostrify": "^0.50.5", - "@nostrify/react": "^0.3.0", + "@nostrify/nostrify": "^0.51.0", + "@nostrify/react": "^0.3.1", "@nostrify/types": "^0.36.9", "@plausible-analytics/tracker": "^0.4.4", "@radix-ui/react-accordion": "^1.2.0", @@ -1414,9 +1414,9 @@ } }, "node_modules/@nostrify/nostrify": { - "version": "0.50.5", - "resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.50.5.tgz", - "integrity": "sha512-0XlNz/aVKl3ODxAZYRbNsa9IzwLsQd0ACxq4EvPBCGZLvpSr3GW14GK13lX0FLx1UAy94xuvsbYGqkiVigBYnQ==", + "version": "0.51.0", + "resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.51.0.tgz", + "integrity": "sha512-GLka8FHu7o04kpz/NB69JppQy3rbwkadr8Au2fLmYbbB478kkGuthF+U5JS2qKaAI137n1p5BN1eFsCk2JyuXQ==", "dependencies": { "@nostrify/types": "0.36.9", "@scure/base": "^2.0.0", @@ -1455,11 +1455,11 @@ "license": "MIT" }, "node_modules/@nostrify/react": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.3.0.tgz", - "integrity": "sha512-Omoxiz3/u0Ab7uLo1Icx9FL8XDx8wiBpaPgdfHarxndwVg7LuAvTNLBVfKUj5n6iFFCZx3yorgg1jSBwi3k3NA==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.3.1.tgz", + "integrity": "sha512-AuB7mxK5vsczSqZzIfY/A+nJ9u4ycboiDYtXG2rRSI+4EPaj6BogmTiZp0gNz7vvIFSI/EP9ndR0j2leA1cyKA==", "dependencies": { - "@nostrify/nostrify": "0.50.5", + "@nostrify/nostrify": "0.51.0", "@nostrify/types": "0.36.9", "@tanstack/react-query": "^5.69.0", "nostr-tools": "^2.13.0", diff --git a/package.json b/package.json index e8694a98..58a3dd9f 100644 --- a/package.json +++ b/package.json @@ -47,8 +47,8 @@ "@fontsource/silkscreen": "^5.2.8", "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^5.2.2", - "@nostrify/nostrify": "^0.50.5", - "@nostrify/react": "^0.3.0", + "@nostrify/nostrify": "^0.51.0", + "@nostrify/react": "^0.3.1", "@nostrify/types": "^0.36.9", "@plausible-analytics/tracker": "^0.4.4", "@radix-ui/react-accordion": "^1.2.0", From 0668d6602ab8dd579873be6535adbcc8e7e93738 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 11:04:06 -0500 Subject: [PATCH 033/104] Fix FAB ignoring custom avatar shape due to unconditional rounded-full The FAB background div always had rounded-full applied, which added border-radius: 9999px even when an emoji mask was active. This caused square (and other non-circular) avatar shapes to still appear rounded. Now rounded-full is only applied when there is no emoji shape mask, matching the pattern used by the Avatar component. --- src/components/FloatingComposeButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/FloatingComposeButton.tsx b/src/components/FloatingComposeButton.tsx index 8fe6d932..f56428ea 100644 --- a/src/components/FloatingComposeButton.tsx +++ b/src/components/FloatingComposeButton.tsx @@ -77,7 +77,7 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon }: Floa > {/* FAB background: user's avatar shape (emoji mask) or circle (default) */}
{/* Plus icon centered on the button */} From a921e640cc03581e14a7eb7f42aab697438e66e7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 12:15:18 -0500 Subject: [PATCH 034/104] Add copyable Nostr clone URI to git repo cards Port NostrURI module from Shakespeare to generate nostr:// clone URIs from kind 30617 event data (pubkey, d-tag, relays). Display the Nostr clone URI in GitRepoCard as the sole clone URL, replacing the previous HTTPS clone URL. Also use the nostr:// URI for Shakespeare clone links. --- src/components/GitRepoCard.tsx | 44 ++++------- src/lib/NostrURI.ts | 130 +++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 29 deletions(-) create mode 100644 src/lib/NostrURI.ts diff --git a/src/components/GitRepoCard.tsx b/src/components/GitRepoCard.tsx index 3cfd57b7..ba3166ad 100644 --- a/src/components/GitRepoCard.tsx +++ b/src/components/GitRepoCard.tsx @@ -1,7 +1,8 @@ import type { NostrEvent } from "@nostrify/nostrify"; -import { BookMarked, Copy, ExternalLink, Globe, Wand2 } from "lucide-react"; +import { BookMarked, Copy, Check, ExternalLink, Globe, Wand2 } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; +import { NostrURI } from "@/lib/NostrURI"; interface GitRepoCardProps { event: NostrEvent; @@ -22,9 +23,6 @@ export function GitRepoCard({ event }: GitRepoCardProps) { const name = event.tags.find(([n]) => n === "name")?.[1]; const description = event.tags.find(([n]) => n === "description")?.[1]; const webUrls = event.tags.filter(([n]) => n === "web").map(([, v]) => v); - const cloneUrls = event.tags - .filter(([n]) => n === "clone") - .map(([, v]) => v); const isPersonalFork = event.tags.some( ([n, v]) => n === "t" && v === "personal-fork", ); @@ -33,6 +31,10 @@ export function GitRepoCard({ event }: GitRepoCardProps) { ); const dTag = event.tags.find(([n]) => n === "d")?.[1] ?? ""; + // Nostr clone URI (nostr://npub/relay/identifier) + const nostrUri = NostrURI.fromEvent(event); + const nostrCloneUrl = nostrUri.toString(); + // Shakespeare + web URL = this is a deployed application, not a repo const isApp = hasShakespeare && !!webUrls[0]; const faviconUrl = isApp ? getFaviconUrl(webUrls[0]) : undefined; @@ -48,9 +50,7 @@ export function GitRepoCard({ event }: GitRepoCardProps) { setTimeout(() => setCopied(false), 2000); }; - const shakespeareUrl = cloneUrls[0] - ? `https://shakespeare.diy/clone?url=${encodeURIComponent(cloneUrls[0])}` - : "https://shakespeare.diy"; + const shakespeareUrl = `https://shakespeare.diy/clone?url=${encodeURIComponent(nostrCloneUrl)}`; return (
@@ -85,31 +85,29 @@ export function GitRepoCard({ event }: GitRepoCardProps) {

)} - {/* Clone URL -- hidden for apps */} - {!isApp && cloneUrls[0] && ( -
+ {/* Nostr clone URI */} +
- {cloneUrls[0]} + {nostrCloneUrl} -
- )} +
{/* Action buttons */} - {(hasShakespeare || isApp || webUrls[0] || cloneUrls[0]) && ( + {(hasShakespeare || isApp || webUrls[0]) && (
{hasShakespeare && ( - ) : !hasShakespeare && cloneUrls[0] ? ( - ) : null}
)} diff --git a/src/lib/NostrURI.ts b/src/lib/NostrURI.ts new file mode 100644 index 00000000..edd7166e --- /dev/null +++ b/src/lib/NostrURI.ts @@ -0,0 +1,130 @@ +import { nip19 } from 'nostr-tools'; + +/** + * Represents a parsed Nostr URI with its components + */ +export interface NostrURIParts { + /** The public key (hex format) */ + pubkey: string; + /** The identifier (d-tag value) */ + identifier: string; + /** Optional relay URL */ + relay?: string; +} + +/** + * NostrURI class for constructing Nostr clone URIs for git repositories. + * + * Produces URIs in the format: + * - nostr://npub/identifier + * - nostr://npub/relay-hostname/identifier + * + * Ported from Shakespeare (src/lib/NostrURI.ts). + * + * @example + * ```ts + * const uri = new NostrURI({ + * pubkey: 'abc123...', + * identifier: 'my-repo', + * relay: 'wss://relay.example.com/' + * }); + * console.log(uri.toString()); // 'nostr://npub1.../relay.example.com/my-repo' + * ``` + */ +export class NostrURI { + public readonly pubkey: string; + public readonly identifier: string; + public readonly relay?: string; + + constructor(parts: NostrURIParts) { + this.pubkey = parts.pubkey; + this.identifier = parts.identifier; + this.relay = parts.relay; + } + + /** + * Construct a NostrURI from a kind 30617 git repository event. + * Extracts the pubkey, d-tag, and first relay from the event. + */ + static fromEvent(event: { pubkey: string; tags: string[][] }): NostrURI { + const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? ''; + const relays = event.tags.find(([n]) => n === 'relays'); + const relay = relays?.[1]; // First relay URL + + return new NostrURI({ + pubkey: event.pubkey, + identifier: dTag, + relay, + }); + } + + /** + * Create a NostrURI from an naddr (NIP-19 addressable event identifier). + */ + static fromNaddr(naddr: string): NostrURI { + const decoded = nip19.decode(naddr); + + if (decoded.type !== 'naddr') { + throw new Error('Invalid naddr: must be an addressable event pointer'); + } + + const data = decoded.data; + + return new NostrURI({ + pubkey: data.pubkey, + identifier: data.identifier, + relay: data.relays?.[0], + }); + } + + /** + * Convert to a `nostr://` URI string. + * + * The relay hostname is included between the npub and identifier + * when a relay URL is available, with the `wss://` scheme stripped. + */ + toString(): string { + const npub = nip19.npubEncode(this.pubkey); + + if (this.relay) { + try { + const url = new URL(this.relay); + return `nostr://${npub}/${url.hostname}/${this.identifier}`; + } catch { + // fallthrough + } + } + + return `nostr://${npub}/${this.identifier}`; + } + + /** + * Convert to an naddr (NIP-19 addressable event identifier) for kind 30617. + */ + toNaddr(): string { + const data: { + kind: number; + pubkey: string; + identifier: string; + relays?: string[]; + } = { + kind: 30617, + pubkey: this.pubkey, + identifier: this.identifier, + }; + + if (this.relay) { + data.relays = [this.relay]; + } + + return nip19.naddrEncode(data); + } + + toJSON(): NostrURIParts { + return { + pubkey: this.pubkey, + identifier: this.identifier, + relay: this.relay, + }; + } +} From be456853b0384251f969bf7ba0a0c42e98bfb5b6 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 13:04:12 -0500 Subject: [PATCH 035/104] Fix text layout gaps in markdown prose views by adding break-words and code styling Long unbreakable strings (sha256 hashes, URLs, base36 pubkeys) in NIP documents and articles caused large word-spacing gaps in narrow containers. Added break-words, horizontal scroll for code blocks, and consistent inline code styling to CustomNipCard, ArticleContent, and PullRequestCard. --- src/components/ArticleContent.tsx | 2 +- src/components/CustomNipCard.tsx | 18 +++++++++--------- src/components/PullRequestCard.tsx | 12 ++++++------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/components/ArticleContent.tsx b/src/components/ArticleContent.tsx index b39e0ba4..e34c957c 100644 --- a/src/components/ArticleContent.tsx +++ b/src/components/ArticleContent.tsx @@ -58,7 +58,7 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp className="w-full rounded-xl object-cover max-h-96 mb-6" /> )} -
+
{event.content} diff --git a/src/components/CustomNipCard.tsx b/src/components/CustomNipCard.tsx index 7e091408..928ef5ff 100644 --- a/src/components/CustomNipCard.tsx +++ b/src/components/CustomNipCard.tsx @@ -133,16 +133,16 @@ export function CustomNipCard({ event, preview = true }: CustomNipCardProps) {
- {/* Full markdown content -- detail view only, outside card */} - {!preview && event.content && ( -
-
- - {event.content} - -
+ {/* Full markdown content -- detail view only, outside card */} + {!preview && event.content && ( +
+
+ + {event.content} +
- )} +
+ )}
); } diff --git a/src/components/PullRequestCard.tsx b/src/components/PullRequestCard.tsx index 7444e60e..a68cb3d3 100644 --- a/src/components/PullRequestCard.tsx +++ b/src/components/PullRequestCard.tsx @@ -212,13 +212,13 @@ export function PullRequestCard({ {/* PR description */} {!preview && hasDescription && ( -
-
- - {event.content} - -
+
+
+ + {event.content} +
+
)}
); From 2326dac0b4417f725476a7e4d63bdedd35d2c687 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 13:18:07 -0500 Subject: [PATCH 036/104] Add title font role to f tag spec in NIP.md Extend the font tag format with a required role marker ("body" or "title") to support a dedicated profile display-name font. Body tags must be ordered before title tags for backward compatibility. Legacy 3-element f tags without a role are treated as body. --- NIP.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/NIP.md b/NIP.md index d8e027be..bd1b19c2 100644 --- a/NIP.md +++ b/NIP.md @@ -29,7 +29,8 @@ A theme consists of colors, optional fonts, and an optional background. Colors a ["c", "#1a1a2e", "background"], ["c", "#e0e0e0", "text"], ["c", "#6c3ce0", "primary"], - ["f", "Inter", "https://example.com/inter.woff2"], + ["f", "Inter", "https://example.com/inter.woff2", "body"], + ["f", "Playfair Display", "https://example.com/playfair.woff2", "title"], ["bg", "url https://example.com/bg.jpg", "mode cover", "m image/jpeg", "dim 1920x1080"], ["title", "MK Dark Theme"], ["alt", "Custom theme: MK Dark Theme"] @@ -74,7 +75,8 @@ Replaceable event that represents the user's currently active profile theme. Onl ["c", "#1a1a2e", "background"], ["c", "#e0e0e0", "text"], ["c", "#6c3ce0", "primary"], - ["f", "Inter", "https://example.com/inter.woff2"], + ["f", "Inter", "https://example.com/inter.woff2", "body"], + ["f", "Playfair Display", "https://example.com/playfair.woff2", "title"], ["bg", "url https://example.com/bg.jpg", "mode cover", "m image/jpeg"], ["title", "MK Dark Theme"], ["alt", "Active profile theme"] @@ -124,18 +126,30 @@ Format: `["c", "#rrggbb", ""]` ### Font Tag -Format: `["f", "", ""]` +Format: `["f", "", "", ""]` | Index | Required | Description | |-------|----------|-----------------------------------------------------------------------------------------------| | 0 | Yes | Tag name: `"f"` | | 1 | Yes | CSS `font-family` name (e.g. `"Inter"`) | | 2 | Yes | Direct URL to a font file (`.woff2`, `.ttf`, `.otf`) | +| 3 | Yes | Font role: `"body"` or `"title"` | + +**Roles:** + +| Role | Applies to | +|-----------|--------------------------------------------------| +| `"body"` | All text globally (body, headings, UI elements) | +| `"title"` | The user's profile display name | + +**Rules:** - The `f` tag is optional on the event. -- At most one `f` tag per event is allowed. -- The font applies globally to all text (body, headings, UI elements). +- At most one `f` tag per role is allowed (i.e. one body font and one title font). +- The `"body"` font tag MUST be ordered before the `"title"` font tag. This ensures backward-compatible clients that only read the first `f` tag will pick up the body font. - If the URL fails to load, the client SHOULD fall back to a default font gracefully. +- Clients that do not recognize a role SHOULD ignore that `f` tag. +- Legacy events with an `f` tag that has no role marker (only 3 elements) SHOULD be treated as `"body"`. - Variable font files (covering multiple weights in a single file) are preferred. ### Background Tag From f680ccd75df2e94ed50aea09d19e1a1f23eb906b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 13:33:08 -0500 Subject: [PATCH 037/104] Fix invisible bold text and other prose colors in dark mode The Tailwind Typography plugin sets bold/strong text to gray-900 by default, which is invisible on dark backgrounds. Added prose-strong:text-foreground and other theme-aware color overrides for list markers, blockquotes, rules, and table headers across all markdown prose views. --- src/components/ArticleContent.tsx | 2 +- src/components/CustomNipCard.tsx | 2 +- src/components/PullRequestCard.tsx | 2 +- src/pages/AIChatPage.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/ArticleContent.tsx b/src/components/ArticleContent.tsx index e34c957c..eaf63837 100644 --- a/src/components/ArticleContent.tsx +++ b/src/components/ArticleContent.tsx @@ -58,7 +58,7 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp className="w-full rounded-xl object-cover max-h-96 mb-6" /> )} -
+
{event.content} diff --git a/src/components/CustomNipCard.tsx b/src/components/CustomNipCard.tsx index 928ef5ff..cb8e6789 100644 --- a/src/components/CustomNipCard.tsx +++ b/src/components/CustomNipCard.tsx @@ -136,7 +136,7 @@ export function CustomNipCard({ event, preview = true }: CustomNipCardProps) { {/* Full markdown content -- detail view only, outside card */} {!preview && event.content && (
-
+
{event.content} diff --git a/src/components/PullRequestCard.tsx b/src/components/PullRequestCard.tsx index a68cb3d3..5b9e4969 100644 --- a/src/components/PullRequestCard.tsx +++ b/src/components/PullRequestCard.tsx @@ -213,7 +213,7 @@ export function PullRequestCard({ {/* PR description */} {!preview && hasDescription && (
-
+
{event.content} diff --git a/src/pages/AIChatPage.tsx b/src/pages/AIChatPage.tsx index 620ae414..40ba82b5 100644 --- a/src/pages/AIChatPage.tsx +++ b/src/pages/AIChatPage.tsx @@ -575,7 +575,7 @@ function MessageBubble({ message }: { message: DisplayMessage }) { {isUser ? (

{message.content}

) : ( -
+
{message.content} From 0d6ad1d3d5481e4414ae0e8421e9d3609e88d282 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 13:37:02 -0500 Subject: [PATCH 038/104] Fix illegible inline code in dark mode prose views Add prose-code:text-foreground so inline code elements use the theme foreground color instead of the typography plugin default (dark gray), which was invisible against the muted background in dark mode. --- src/components/ArticleContent.tsx | 2 +- src/components/CustomNipCard.tsx | 2 +- src/components/PullRequestCard.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/ArticleContent.tsx b/src/components/ArticleContent.tsx index eaf63837..13cbc920 100644 --- a/src/components/ArticleContent.tsx +++ b/src/components/ArticleContent.tsx @@ -58,7 +58,7 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp className="w-full rounded-xl object-cover max-h-96 mb-6" /> )} -
+
{event.content} diff --git a/src/components/CustomNipCard.tsx b/src/components/CustomNipCard.tsx index cb8e6789..00b7edbe 100644 --- a/src/components/CustomNipCard.tsx +++ b/src/components/CustomNipCard.tsx @@ -136,7 +136,7 @@ export function CustomNipCard({ event, preview = true }: CustomNipCardProps) { {/* Full markdown content -- detail view only, outside card */} {!preview && event.content && (
-
+
{event.content} diff --git a/src/components/PullRequestCard.tsx b/src/components/PullRequestCard.tsx index 5b9e4969..c5b00737 100644 --- a/src/components/PullRequestCard.tsx +++ b/src/components/PullRequestCard.tsx @@ -213,7 +213,7 @@ export function PullRequestCard({ {/* PR description */} {!preview && hasDescription && (
-
+
{event.content} From c9e447ff13ba7d31aa29436792448b8d0b772cca Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 13:37:29 -0500 Subject: [PATCH 039/104] Add title font support for profile display name Implement the title font feature across the full stack: - Type system: Add titleFont to ThemeConfig, ThemePreset, ThemeDefinition, ActiveProfileTheme interfaces and Zod schema - Event encoding: Update f-tag build/parse to emit role markers (body/title) with backward-compatible parsing of legacy roleless tags - Font loading: Add loadAndApplyTitleFont with --title-font-family CSS var - UI: Add Title Font picker to theme builder dialog and profile theme editor, with configurable label prop on FontPicker - Profile rendering: Apply --title-font-family to the display name h2 - Data flow: Thread titleFont through ThemeSelector snapshots, theme presets, publish/update, NostrSync, ThemeContent, and diff detection --- src/components/FontPicker.tsx | 6 ++- src/components/NostrSync.tsx | 1 + src/components/ThemeContent.tsx | 2 + src/components/ThemeSelector.tsx | 39 ++++++++++++++----- src/hooks/usePublishTheme.ts | 10 +++-- src/lib/fontLoader.ts | 42 +++++++++++++++++++++ src/lib/schemas.ts | 1 + src/lib/themeEvent.ts | 65 ++++++++++++++++++++++---------- src/pages/ProfilePage.tsx | 36 +++++++++++++++--- src/themes.ts | 4 ++ 10 files changed, 166 insertions(+), 40 deletions(-) diff --git a/src/components/FontPicker.tsx b/src/components/FontPicker.tsx index 719a26eb..9db9b017 100644 --- a/src/components/FontPicker.tsx +++ b/src/components/FontPicker.tsx @@ -74,11 +74,13 @@ function familyFromFilename(filename: string): string { * * Also supports uploading a custom font file via Blossom. */ -export function FontPicker({ value, onChange }: { +export function FontPicker({ value, onChange, label: labelText = 'Font' }: { /** Controlled value — overrides useTheme() when provided. */ value?: ThemeFont | undefined; /** Controlled onChange — called instead of applyCustomTheme() when provided. */ onChange?: (font: ThemeFont | undefined) => void; + /** Label text shown above the picker. Defaults to "Font". */ + label?: string; } = {}) { const { theme, customTheme, applyCustomTheme } = useTheme(); const { user } = useCurrentUser(); @@ -172,7 +174,7 @@ export function FontPicker({ value, onChange }: {
- Font + {labelText} diff --git a/src/components/NostrSync.tsx b/src/components/NostrSync.tsx index cd1e0285..d5ae7d42 100644 --- a/src/components/NostrSync.tsx +++ b/src/components/NostrSync.tsx @@ -484,6 +484,7 @@ export function NostrSync() { const remoteTheme: ThemeConfig = { colors: parsed.colors, ...(parsed.font && { font: parsed.font }), + ...(parsed.titleFont && { titleFont: parsed.titleFont }), ...(parsed.background && { background: parsed.background }), }; diff --git a/src/components/ThemeContent.tsx b/src/components/ThemeContent.tsx index bb7d223e..79e96d76 100644 --- a/src/components/ThemeContent.tsx +++ b/src/components/ThemeContent.tsx @@ -46,6 +46,7 @@ export function ThemeContent({ event }: ThemeContentProps) { identifier: undefined as string | undefined, background: active.background, font: active.font, + titleFont: active.titleFont, sourceRef: active.sourceRef, }; } @@ -68,6 +69,7 @@ export function ThemeContent({ event }: ThemeContentProps) { colors: parsed.colors, title: parsed.title, font: parsed.font, + titleFont: parsed.titleFont, background: parsed.background, }; applyCustomTheme(themeConfig); diff --git a/src/components/ThemeSelector.tsx b/src/components/ThemeSelector.tsx index 8fabbf29..3a098dfc 100644 --- a/src/components/ThemeSelector.tsx +++ b/src/components/ThemeSelector.tsx @@ -193,6 +193,7 @@ export function ThemeGrid({ label: preset.label, colors: preset.colors, font: preset.font, + titleFont: preset.titleFont, background: preset.background, })); @@ -211,15 +212,15 @@ export function ThemeGrid({ onSelect?.(); }, [setTheme, onSelect, onEditingThemeChange]); - const handleSelectPreset = useCallback((preset: { colors: CoreThemeColors; font?: ThemeConfig['font']; background?: ThemeConfig['background'] }) => { + const handleSelectPreset = useCallback((preset: { colors: CoreThemeColors; font?: ThemeConfig['font']; titleFont?: ThemeConfig['titleFont']; background?: ThemeConfig['background'] }) => { onEditingThemeChange?.(null); - applyCustomTheme({ colors: preset.colors, font: preset.font, background: preset.background }); + applyCustomTheme({ colors: preset.colors, font: preset.font, titleFont: preset.titleFont, background: preset.background }); onSelect?.(); }, [applyCustomTheme, onSelect, onEditingThemeChange]); const handleSelectUserTheme = useCallback((def: ThemeDefinition) => { onEditingThemeChange?.(def); - applyCustomTheme({ colors: def.colors, font: def.font, background: def.background, title: def.title }); + applyCustomTheme({ colors: def.colors, font: def.font, titleFont: def.titleFont, background: def.background, title: def.title }); onSelect?.(); }, [applyCustomTheme, onSelect, onEditingThemeChange]); @@ -505,6 +506,7 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }: const [snapshot, setSnapshot] = useState<{ colors: CoreThemeColors; font: ThemeFont | undefined; + titleFont: ThemeFont | undefined; background: ThemeBackground | undefined; } | null>(null); @@ -514,6 +516,7 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }: setSnapshot({ colors: getEffectiveColors(theme, customTheme, themes), font: customTheme?.font, + titleFont: customTheme?.titleFont, background: customTheme?.background, }); } else { @@ -526,13 +529,14 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }: const hasChanges = snapshot !== null && ( JSON.stringify(effectiveColors) !== JSON.stringify(snapshot.colors) || JSON.stringify(customTheme?.font) !== JSON.stringify(snapshot.font) || + JSON.stringify(customTheme?.titleFont) !== JSON.stringify(snapshot.titleFont) || JSON.stringify(customTheme?.background) !== JSON.stringify(snapshot.background) ); /** Reset all theme values to the snapshot */ const handleReset = useCallback(() => { if (!snapshot) return; - applyCustomTheme({ ...customTheme, colors: snapshot.colors, font: snapshot.font, background: snapshot.background }); + applyCustomTheme({ ...customTheme, colors: snapshot.colors, font: snapshot.font, titleFont: snapshot.titleFont, background: snapshot.background }); }, [snapshot, customTheme, applyCustomTheme]); /** Handle a color change from the inline editor */ @@ -564,6 +568,7 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }: ...editingTheme, colors: effectiveColors, font: customTheme?.font, + titleFont: customTheme?.titleFont, background: customTheme?.background, }); toast({ title: `"${editingTheme.title}" updated`, description: 'Your theme has been saved and republished.' }); @@ -593,6 +598,7 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }: description: publishDescription.trim() || undefined, colors: effectiveColors, font: customTheme?.font, + titleFont: customTheme?.titleFont, background: customTheme?.background, event: {} as ThemeDefinition['event'], }); @@ -632,6 +638,7 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }: label: preset.label, colors: preset.colors, font: preset.font, + titleFont: preset.titleFont, background: preset.background, })); @@ -649,14 +656,14 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }: setTheme(id); }, [setTheme]); - const handleSelectPreset = useCallback((preset: { colors: CoreThemeColors; font?: ThemeConfig['font']; background?: ThemeConfig['background'] }) => { + const handleSelectPreset = useCallback((preset: { colors: CoreThemeColors; font?: ThemeConfig['font']; titleFont?: ThemeConfig['titleFont']; background?: ThemeConfig['background'] }) => { setEditingTheme(null); - applyCustomTheme({ colors: preset.colors, font: preset.font, background: preset.background }); + applyCustomTheme({ colors: preset.colors, font: preset.font, titleFont: preset.titleFont, background: preset.background }); }, [applyCustomTheme]); const handleSelectUserTheme = useCallback((def: ThemeDefinition) => { setEditingTheme(def); - applyCustomTheme({ colors: def.colors, font: def.font, background: def.background, title: def.title }); + applyCustomTheme({ colors: def.colors, font: def.font, titleFont: def.titleFont, background: def.background, title: def.title }); }, [applyCustomTheme]); type SectionItem = { @@ -859,8 +866,22 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }: ))}
- {/* Font */} - + {/* Body Font */} + + + {/* Title Font */} + { + const currentColors = customTheme?.colors ?? { + background: '228 20% 10%', + text: '210 40% 98%', + primary: '258 70% 60%', + }; + applyCustomTheme({ ...customTheme, colors: currentColors, titleFont }); + }} + /> {/* Background */} diff --git a/src/hooks/usePublishTheme.ts b/src/hooks/usePublishTheme.ts index 6050151f..483e29ac 100644 --- a/src/hooks/usePublishTheme.ts +++ b/src/hooks/usePublishTheme.ts @@ -19,14 +19,16 @@ import { resolveFontUrl } from '@/lib/fontLoader'; * Bundled fonts get CDN URLs, others keep their existing URL. */ function resolveThemeForPublishing(config: ThemeConfig): ThemeConfig { - if (!config.font) return config; - return { ...config, - font: { + font: config.font ? { family: config.font.family, url: resolveFontUrl(config.font.family, config.font.url), - }, + } : undefined, + titleFont: config.titleFont ? { + family: config.titleFont.family, + url: resolveFontUrl(config.titleFont.family, config.titleFont.url), + } : undefined, }; } diff --git a/src/lib/fontLoader.ts b/src/lib/fontLoader.ts index 61721b77..fbf7aa6c 100644 --- a/src/lib/fontLoader.ts +++ b/src/lib/fontLoader.ts @@ -108,6 +108,48 @@ export async function loadAndApplyFont(font: ThemeFont | undefined): Promise { + if (!font) { + applyTitleFontOverride(undefined); + return; + } + + await loadFont(font.family, font.url); + applyTitleFontOverride(font); +} + /** * Resolve font URLs for publishing to Nostr. * For bundled fonts, returns the CDN URL. For others, preserves the existing URL. diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 2da05d0e..70b91513 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -86,6 +86,7 @@ export const ThemeConfigSchema = z.object({ title: z.string().optional(), colors: CoreThemeColorsSchema, font: ThemeFontSchema.optional(), + titleFont: ThemeFontSchema.optional(), background: ThemeBackgroundSchema.optional(), }); diff --git a/src/lib/themeEvent.ts b/src/lib/themeEvent.ts index d878d73f..7c95eb81 100644 --- a/src/lib/themeEvent.ts +++ b/src/lib/themeEvent.ts @@ -48,23 +48,44 @@ function parseColorTags(tags: string[][]): CoreThemeColors | null { // ─── Font Tag Helpers ───────────────────────────────────────────────── -/** Build an `f` tag from a ThemeFont. */ -function buildFontTag(font: ThemeFont | undefined): string[][] { - if (!font?.family) return []; - const tag = ['f', font.family]; - if (font.url) tag.push(font.url); - return [tag]; +/** Build `f` tags from body and title fonts. Body tag is always ordered before title tag. */ +function buildFontTags(font: ThemeFont | undefined, titleFont: ThemeFont | undefined): string[][] { + const tags: string[][] = []; + if (font?.family) { + const tag = ['f', font.family]; + if (font.url) tag.push(font.url); else tag.push(''); + tag.push('body'); + tags.push(tag); + } + if (titleFont?.family) { + const tag = ['f', titleFont.family]; + if (titleFont.url) tag.push(titleFont.url); else tag.push(''); + tag.push('title'); + tags.push(tag); + } + return tags; } -/** Parse the first `f` tag into a ThemeFont. Returns undefined if no f tag. */ -function parseFontTag(tags: string[][]): ThemeFont | undefined { +/** Parse `f` tags into body and title ThemeFonts. Legacy tags without a role are treated as body. */ +function parseFontTags(tags: string[][]): { font?: ThemeFont; titleFont?: ThemeFont } { + let font: ThemeFont | undefined; + let titleFont: ThemeFont | undefined; + for (const tag of tags) { if (tag[0] !== 'f' || !tag[1]) continue; - const font: ThemeFont = { family: tag[1] }; - if (tag[2]) font.url = tag[2]; - return font; + const role = tag[3]; // 4th element: "body", "title", or absent (legacy) + const parsed: ThemeFont = { family: tag[1] }; + if (tag[2]) parsed.url = tag[2]; + + if (role === 'title') { + if (!titleFont) titleFont = parsed; + } else { + // "body" or absent (legacy) — treat as body font + if (!font) font = parsed; + } } - return undefined; + + return { font, titleFont }; } // ─── Background Tag Helpers ─────────────────────────────────────────── @@ -119,8 +140,10 @@ export interface ThemeDefinition { description?: string; /** The 3 core theme colors */ colors: CoreThemeColors; - /** Optional custom font */ + /** Optional custom body font */ font?: ThemeFont; + /** Optional title/header font (profile display name) */ + titleFont?: ThemeFont; /** Optional background */ background?: ThemeBackground; /** The original Nostr event */ @@ -154,10 +177,10 @@ export function parseThemeDefinition(event: NostrEvent): ThemeDefinition | null if (!colors) return null; - const font = parseFontTag(event.tags); + const { font, titleFont } = parseFontTags(event.tags); const background = parseBackgroundTag(event.tags); - return { identifier, title, description, colors, font, background, event }; + return { identifier, title, description, colors, font, titleFont, background, event }; } /** Create tags for a kind 36767 theme definition event. */ @@ -170,7 +193,7 @@ export function buildThemeDefinitionTags( const tags: string[][] = [ ['d', identifier], ...buildColorTags(themeConfig.colors), - ...buildFontTag(themeConfig.font), + ...buildFontTags(themeConfig.font, themeConfig.titleFont), ...buildBackgroundTag(themeConfig.background), ['title', title], ['alt', `Custom theme: ${title}`], @@ -198,8 +221,10 @@ export function titleToSlug(title: string): string { export interface ActiveProfileTheme { /** The 3 core theme colors */ colors: CoreThemeColors; - /** Optional custom font */ + /** Optional custom body font */ font?: ThemeFont; + /** Optional title/header font (profile display name) */ + titleFont?: ThemeFont; /** Optional background */ background?: ThemeBackground; /** naddr-style reference to the source theme definition, if any */ @@ -227,11 +252,11 @@ export function parseActiveProfileTheme(event: NostrEvent): ActiveProfileTheme | if (!colors) return null; - const font = parseFontTag(event.tags); + const { font, titleFont } = parseFontTags(event.tags); const background = parseBackgroundTag(event.tags); const sourceRef = event.tags.find(([n]) => n === 'a')?.[1]; - return { colors, font, background, sourceRef, event }; + return { colors, font, titleFont, background, sourceRef, event }; } /** Create tags for a kind 16767 active profile theme event. */ @@ -242,7 +267,7 @@ export function buildActiveThemeTags( ): string[][] { const tags: string[][] = [ ...buildColorTags(themeConfig.colors), - ...buildFontTag(themeConfig.font), + ...buildFontTags(themeConfig.font, themeConfig.titleFont), ...buildBackgroundTag(themeConfig.background), ['alt', 'Active profile theme'], ]; diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 4a038b81..ae457bf4 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -81,7 +81,7 @@ import { } from '@dnd-kit/sortable'; import { CSS as DndCSS } from '@dnd-kit/utilities'; import { buildThemeCssFromCore, coreToTokens, buildThemeCss, resolveTheme, resolveThemeConfig, toThemeVar, type CoreThemeColors, type ThemeConfig, type ThemeFont, type ThemeBackground } from '@/themes'; -import { loadAndApplyFont } from '@/lib/fontLoader'; +import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader'; import { hslStringToHex, hexToHslString } from '@/lib/colorUtils'; import { ColorPicker } from '@/components/ui/color-picker'; import { FontPicker } from '@/components/FontPicker'; @@ -1327,6 +1327,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; primary: '258 70% 60%', }); const [localProfileFont, setLocalProfileFont] = useState(); + const [localProfileTitleFont, setLocalProfileTitleFont] = useState(); const [localProfileBg, setLocalProfileBg] = useState(); // Initialize local state from profile theme when dialog opens @@ -1334,6 +1335,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; if (editProfileThemeOpen && profileTheme) { setLocalProfileColors(profileTheme.colors); setLocalProfileFont(profileTheme.font); + setLocalProfileTitleFont(profileTheme.titleFont); setLocalProfileBg(profileTheme.background); } }, [editProfileThemeOpen, profileTheme]); @@ -1347,6 +1349,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; const ownThemeRef = useRef({ ownTheme, ownCustomTheme, configuredThemes }); ownThemeRef.current = { ownTheme, ownCustomTheme, configuredThemes }; const profileThemeFont = (showCustomProfileThemes || isOwnProfile) ? profileTheme?.font : undefined; + const profileThemeTitleFont = (showCustomProfileThemes || isOwnProfile) ? profileTheme?.titleFont : undefined; const profileThemeBackground = (showCustomProfileThemes || isOwnProfile) ? profileTheme?.background : undefined; // Whether we need to override the custom theme on this profile. @@ -1367,11 +1370,12 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; `${hslStringToHex(c.primary)}${hslStringToHex(c.text)}${hslStringToHex(c.background)}`; const fontFamily = (f?: { family: string }) => f?.family ?? ''; const ownCustomThemeSnapshot = ownCustomTheme - ? colorsToHex(ownCustomTheme.colors) + fontFamily(ownCustomTheme.font) + JSON.stringify(ownCustomTheme.background ?? '') + ? colorsToHex(ownCustomTheme.colors) + fontFamily(ownCustomTheme.font) + fontFamily(ownCustomTheme.titleFont) + JSON.stringify(ownCustomTheme.background ?? '') : null; const profileThemeDiffers = profileHasTheme && ownCustomThemeSnapshot && profileTheme && ownCustomTheme ? (colorsToHex(profileTheme.colors) !== colorsToHex(ownCustomTheme.colors) || fontFamily(profileTheme.font) !== fontFamily(ownCustomTheme.font) + || fontFamily(profileTheme.titleFont) !== fontFamily(ownCustomTheme.titleFont) || JSON.stringify(profileTheme.background ?? '') !== JSON.stringify(ownCustomTheme.background ?? '')) : false; @@ -1397,6 +1401,10 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; () => profileThemeColors ? (profileThemeFont ?? { family: 'Inter' }) : undefined, [profileThemeColors, profileThemeFont], ); + const effectiveProfileTitleFont = useMemo( + () => profileThemeColors ? profileThemeTitleFont : undefined, + [profileThemeColors, profileThemeTitleFont], + ); const effectiveProfileBackground = profileThemeColors ? profileThemeBackground : undefined; useLayoutEffect(() => { @@ -1416,6 +1424,9 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; // Apply profile font (if any) loadAndApplyFont(effectiveProfileFont); + // Apply profile title font (if any) + loadAndApplyTitleFont(effectiveProfileTitleFont); + // Apply profile background image (if any) const bgStyleId = 'theme-background'; const previousBgEl = document.getElementById(bgStyleId) as HTMLStyleElement | null; @@ -1461,6 +1472,9 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; // Restore own font or clear override loadAndApplyFont(ownActiveConfig?.font); + // Restore own title font or clear override + loadAndApplyTitleFont(ownActiveConfig?.titleFont); + // Restore own background or remove override const bgEl = document.getElementById(bgStyleId) as HTMLStyleElement | null; const ownBgUrl = ownActiveConfig?.background?.url; @@ -1485,7 +1499,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; bgEl?.remove(); } }; - }, [effectiveProfileColors, effectiveProfileFont, effectiveProfileBackground]); + }, [effectiveProfileColors, effectiveProfileFont, effectiveProfileTitleFont, effectiveProfileBackground]); const pinnedIds = useMemo(() => supplementary?.pinnedIds ?? [], [supplementary?.pinnedIds]); @@ -2060,7 +2074,10 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
-

+

{metadataEvent ? ( {displayName} ) : displayName} @@ -2705,12 +2722,20 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; ))}

- {/* Font */} + {/* Body Font */} + {/* Title Font */} + + {/* Background */} Date: Wed, 25 Mar 2026 13:48:23 -0500 Subject: [PATCH 040/104] Replace full images in compact post views with 'Image' indicator chip Embedded/quoted posts (EmbeddedNote, EmbeddedNaddr) no longer display full image thumbnails. Instead, images are shown as a small icon+label indicator at the bottom, consistent with how videos and audio are already represented. This keeps compact views lightweight and focused on text content. --- src/components/EmbeddedNaddr.tsx | 40 +++++++++++++------------------- src/components/EmbeddedNote.tsx | 26 +++------------------ 2 files changed, 19 insertions(+), 47 deletions(-) diff --git a/src/components/EmbeddedNaddr.tsx b/src/components/EmbeddedNaddr.tsx index f1d3466a..9802f989 100644 --- a/src/components/EmbeddedNaddr.tsx +++ b/src/components/EmbeddedNaddr.tsx @@ -1,7 +1,7 @@ import { type ReactNode, useMemo } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; -import { Award, MessageSquareOff } from 'lucide-react'; +import { Award, Image, MessageSquareOff } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { Skeleton } from '@/components/ui/skeleton'; @@ -222,21 +222,6 @@ function EmbeddedNaddrCard({ event, className, disableHoverCards }: { event: Nos } }} > - {/* Image */} - {image && ( -
- { - (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; - }} - /> -
- )} - {/* Text content */}
{/* Author row */} @@ -296,13 +281,21 @@ function EmbeddedNaddrCard({ event, className, disableHoverCards }: { event: Nos

)} - {/* Kind label for context */} - {kindMeta && ( -

- {kindMeta.Icon && } - {kindMeta.label} -

- )} + {/* Kind label and attachment indicators */} +
+ {kindMeta && ( + + {kindMeta.Icon && } + {kindMeta.label} + + )} + {image && ( + + + Image + + )} +
); @@ -365,7 +358,6 @@ function EmbeddedNaddrTombstone({ addr, className }: { addr: AddrCoords; classNa function EmbeddedNaddrSkeleton({ className }: { className?: string }) { return (
-
diff --git a/src/components/EmbeddedNote.tsx b/src/components/EmbeddedNote.tsx index 1ab8aee5..e16b7b29 100644 --- a/src/components/EmbeddedNote.tsx +++ b/src/components/EmbeddedNote.tsx @@ -158,11 +158,6 @@ function EmbeddedNoteCard({ return { title, description, kindLabel, KindIcon }; }, [truncatedContent, event.tags, event.kind]); - // Extract first image for a small thumbnail - const firstImage = useMemo(() => { - return event.content.match(IMAGE_URL_REGEX)?.[0] ?? null; - }, [event.content]); - // Detect stripped attachments to show indicator chips const attachments = useMemo(() => { const imgs = (event.content.match(new RegExp(IMAGE_URL_REGEX.source, 'gi')) || []).length; @@ -204,21 +199,6 @@ function EmbeddedNoteCard({ } }} > - {/* Optional image thumbnail — skip when content-warning is blurred */} - {firstImage && !(hasCW && config.contentWarningPolicy === 'blur') && ( -
- { - (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; - }} - /> -
- )} - {/* Note content */}
{/* Author row */} @@ -289,12 +269,12 @@ function EmbeddedNoteCard({ ) : null} {/* Attachment indicators for stripped media/links */} - {!hasCW && (attachments.imgs > (firstImage ? 1 : 0) || attachments.vids > 0 || attachments.auds > 0 || attachments.apps > 0 || attachments.links > 0) && ( + {!hasCW && (attachments.imgs > 0 || attachments.vids > 0 || attachments.auds > 0 || attachments.apps > 0 || attachments.links > 0) && (
- {attachments.imgs > (firstImage ? 1 : 0) && ( + {attachments.imgs > 0 && ( - {attachments.imgs > 1 ? `${attachments.imgs} images` : '1 image'} + {attachments.imgs > 1 ? `${attachments.imgs} images` : 'Image'} )} {attachments.vids > 0 && ( From dd55fce88cf79218d87a14309e0f34f78414623e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 13:52:20 -0500 Subject: [PATCH 041/104] Redesign font pickers into unified FontSection with title font fallback - Refactor FontPicker into a bare combobox (no section header, no 'quick brown fox' preview text) - Add FontSection component that combines Body and Title font pickers in a single section with inline labels and a live preview showing both fonts in context (display name + body text) - Title font falls back to the body font when not explicitly set, so the profile display name inherits the theme's body font rather than the default Inter - Update ThemeSelector builder dialog and ProfilePage edit dialog to use FontSection with controlled body/title font props --- src/components/FontPicker.tsx | 86 +++++++++++++++++++++++++------- src/components/ThemeSelector.tsx | 23 +++++---- src/pages/ProfilePage.tsx | 26 ++++------ 3 files changed, 93 insertions(+), 42 deletions(-) diff --git a/src/components/FontPicker.tsx b/src/components/FontPicker.tsx index 9db9b017..e9c66006 100644 --- a/src/components/FontPicker.tsx +++ b/src/components/FontPicker.tsx @@ -66,21 +66,19 @@ function familyFromFilename(filename: string): string { } /** - * Font picker component for selecting a single custom font. + * Bare font picker combobox — no section header, no preview text. * * Supports two modes: - * - **Uncontrolled** (default): reads/writes via `useTheme().applyCustomTheme()` + * - **Uncontrolled** (default): reads/writes the body font via `useTheme().applyCustomTheme()` * - **Controlled**: pass `value` and `onChange` props to manage state externally * * Also supports uploading a custom font file via Blossom. */ -export function FontPicker({ value, onChange, label: labelText = 'Font' }: { +export function FontPicker({ value, onChange }: { /** Controlled value — overrides useTheme() when provided. */ value?: ThemeFont | undefined; /** Controlled onChange — called instead of applyCustomTheme() when provided. */ onChange?: (font: ThemeFont | undefined) => void; - /** Label text shown above the picker. Defaults to "Font". */ - label?: string; } = {}) { const { theme, customTheme, applyCustomTheme } = useTheme(); const { user } = useCurrentUser(); @@ -171,12 +169,7 @@ export function FontPicker({ value, onChange, label: labelText = 'Font' }: { }; return ( -
- - - {labelText} - - + <>
- {/* Body Font */} - - - {/* Title Font */} - { + {/* Fonts (body + title) */} + { + const currentColors = customTheme?.colors ?? { + background: '228 20% 10%', + text: '210 40% 98%', + primary: '258 70% 60%', + }; + applyCustomTheme({ ...customTheme, colors: currentColors, font }); + }} + titleFont={theme === 'custom' ? customTheme?.titleFont : undefined} + onTitleFontChange={(titleFont) => { const currentColors = customTheme?.colors ?? { background: '228 20% 10%', text: '210 40% 98%', diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index ae457bf4..42f65942 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -84,7 +84,7 @@ import { buildThemeCssFromCore, coreToTokens, buildThemeCss, resolveTheme, resol import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader'; import { hslStringToHex, hexToHslString } from '@/lib/colorUtils'; import { ColorPicker } from '@/components/ui/color-picker'; -import { FontPicker } from '@/components/FontPicker'; +import { FontSection } from '@/components/FontPicker'; import { BackgroundPicker } from '@/components/BackgroundPicker'; import { PortalContainerProvider } from '@/contexts/PortalContainerContext'; import { formatNumber } from '@/lib/formatNumber'; @@ -1401,9 +1401,11 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; () => profileThemeColors ? (profileThemeFont ?? { family: 'Inter' }) : undefined, [profileThemeColors, profileThemeFont], ); + // Title font falls back to the body font when not explicitly set, + // so the display name inherits the theme's body font rather than the default. const effectiveProfileTitleFont = useMemo( - () => profileThemeColors ? profileThemeTitleFont : undefined, - [profileThemeColors, profileThemeTitleFont], + () => profileThemeColors ? (profileThemeTitleFont ?? effectiveProfileFont) : undefined, + [profileThemeColors, profileThemeTitleFont, effectiveProfileFont], ); const effectiveProfileBackground = profileThemeColors ? profileThemeBackground : undefined; @@ -2722,18 +2724,12 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; ))}
- {/* Body Font */} - - - {/* Title Font */} - {/* Background */} From e0bca85cb6e26c55569862e077eb3443e391addd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 13:56:25 -0500 Subject: [PATCH 042/104] Show 'Same as body' placeholder for title font when unset The title font combobox was showing 'Default (Inter)' when no title font was set, but the actual fallback is the body font. Add a placeholder prop to FontPicker and pass 'Same as body' from FontSection for the title picker. --- src/components/FontPicker.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/FontPicker.tsx b/src/components/FontPicker.tsx index e9c66006..66ea7ac3 100644 --- a/src/components/FontPicker.tsx +++ b/src/components/FontPicker.tsx @@ -74,11 +74,13 @@ function familyFromFilename(filename: string): string { * * Also supports uploading a custom font file via Blossom. */ -export function FontPicker({ value, onChange }: { +export function FontPicker({ value, onChange, placeholder = 'Default (Inter)' }: { /** Controlled value — overrides useTheme() when provided. */ value?: ThemeFont | undefined; /** Controlled onChange — called instead of applyCustomTheme() when provided. */ onChange?: (font: ThemeFont | undefined) => void; + /** Text shown when no font is selected. Defaults to "Default (Inter)". */ + placeholder?: string; } = {}) { const { theme, customTheme, applyCustomTheme } = useTheme(); const { user } = useCurrentUser(); @@ -180,7 +182,7 @@ export function FontPicker({ value, onChange }: { style={currentFont ? { fontFamily: `"${resolveCssFamily(currentFont.family)}", sans-serif` } : undefined} > - {currentFont?.family ?? 'Default (Inter)'} + {currentFont?.family ?? placeholder} {isCustomUpload && ( (uploaded) )} @@ -324,7 +326,7 @@ export function FontSection({ bodyFont, onBodyFontChange, titleFont, onTitleFont
Title
- +
From 247f174158c821df095ad87b7f33db01144bdcb1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 13:59:52 -0500 Subject: [PATCH 043/104] Show actual fallback font name in title font picker when unset Instead of a generic 'Same as body' placeholder, the title font combobox now displays the body font's name (rendered in that font's typeface) when no title font is explicitly set. Falls back to 'Default (Inter)' when neither font is set. --- src/components/FontPicker.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/FontPicker.tsx b/src/components/FontPicker.tsx index 66ea7ac3..a3998425 100644 --- a/src/components/FontPicker.tsx +++ b/src/components/FontPicker.tsx @@ -74,13 +74,15 @@ function familyFromFilename(filename: string): string { * * Also supports uploading a custom font file via Blossom. */ -export function FontPicker({ value, onChange, placeholder = 'Default (Inter)' }: { +export function FontPicker({ value, onChange, placeholder = 'Default (Inter)', placeholderFont }: { /** Controlled value — overrides useTheme() when provided. */ value?: ThemeFont | undefined; /** Controlled onChange — called instead of applyCustomTheme() when provided. */ onChange?: (font: ThemeFont | undefined) => void; /** Text shown when no font is selected. Defaults to "Default (Inter)". */ placeholder?: string; + /** Font to render the placeholder text in (when no value is selected). */ + placeholderFont?: ThemeFont | undefined; } = {}) { const { theme, customTheme, applyCustomTheme } = useTheme(); const { user } = useCurrentUser(); @@ -179,7 +181,12 @@ export function FontPicker({ value, onChange, placeholder = 'Default (Inter)' }: role="combobox" aria-expanded={open} className="w-full justify-between font-normal h-9 text-sm" - style={currentFont ? { fontFamily: `"${resolveCssFamily(currentFont.family)}", sans-serif` } : undefined} + style={currentFont + ? { fontFamily: `"${resolveCssFamily(currentFont.family)}", sans-serif` } + : placeholderFont + ? { fontFamily: `"${resolveCssFamily(placeholderFont.family)}", sans-serif` } + : undefined + } > {currentFont?.family ?? placeholder} @@ -326,7 +333,7 @@ export function FontSection({ bodyFont, onBodyFontChange, titleFont, onTitleFont
Title
- +
From 9726fe3f8e9807f87a7713523fd048aabfd226cf Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 14:00:09 -0500 Subject: [PATCH 044/104] Move Title font picker above Body in FontSection layout --- src/components/FontPicker.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/FontPicker.tsx b/src/components/FontPicker.tsx index a3998425..7b920f10 100644 --- a/src/components/FontPicker.tsx +++ b/src/components/FontPicker.tsx @@ -324,18 +324,18 @@ export function FontSection({ bodyFont, onBodyFontChange, titleFont, onTitleFont {/* Two-row picker layout */}
-
- Body -
- -
-
Title
+
+ Body +
+ +
+
{/* Combined live preview */} From d0e39ed8bf3257572a2161ed525ab57a033b9a8b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 16:20:23 -0500 Subject: [PATCH 045/104] Fix blank pages for logged-out users on Videos, Vines, Photos, Events, Books useFeedTab defaulted to 'ditto' when logged out, but pages like Videos only accept ['follows', 'global'] as valid tabs. The 'ditto' default was never validated against validTabs, so no feed query matched and nothing loaded. Now the default is validated and falls back to the last valid tab ('global') when it's not in the list. --- src/hooks/useFeedTab.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/hooks/useFeedTab.ts b/src/hooks/useFeedTab.ts index 62969800..46fc00e9 100644 --- a/src/hooks/useFeedTab.ts +++ b/src/hooks/useFeedTab.ts @@ -30,6 +30,11 @@ export function useFeedTab( } } } catch { /* sessionStorage unavailable */ } + // Validate the default tab against validTabs. If it's not in the list, + // fall back to the last valid tab (typically 'global'). + if (validTabs && !validTabs.includes(defaultTab)) { + return validTabs[validTabs.length - 1]; + } return defaultTab; }); From 50d0bcb5ad1c20d22f733b0b18417713965323e7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 25 Mar 2026 18:32:36 -0500 Subject: [PATCH 046/104] Apply title font to sidebar nav items and widget headings Use the --title-font-family CSS custom property on left sidebar navigation labels and all right sidebar widget titles (Trends, Hot Posts, New Accounts, Media, Profile fields). Falls back to inherit when no title font is configured. Also removes the font preview section from FontSection. --- src/components/FontPicker.tsx | 28 -------------------------- src/components/ProfileRightSidebar.tsx | 4 ++-- src/components/RightSidebar.tsx | 6 +++--- src/components/SidebarNavItem.tsx | 2 +- 4 files changed, 6 insertions(+), 34 deletions(-) diff --git a/src/components/FontPicker.tsx b/src/components/FontPicker.tsx index 7b920f10..44972286 100644 --- a/src/components/FontPicker.tsx +++ b/src/components/FontPicker.tsx @@ -305,16 +305,6 @@ export function FontSection({ bodyFont, onBodyFontChange, titleFont, onTitleFont titleFont?: ThemeFont | undefined; onTitleFontChange?: (font: ThemeFont | undefined) => void; }) { - const bodyFamily = bodyFont ? resolveCssFamily(bodyFont.family) : undefined; - const titleFamily = titleFont ? resolveCssFamily(titleFont.family) : undefined; - - // Resolve display families for the preview. - // Title falls back to body, body falls back to default. - const previewTitleFamily = titleFamily ?? bodyFamily; - const previewBodyFamily = bodyFamily; - - const hasAnyFont = bodyFont || titleFont; - return (
@@ -337,24 +327,6 @@ export function FontSection({ bodyFont, onBodyFontChange, titleFont, onTitleFont
- - {/* Combined live preview */} - {hasAnyFont && ( -
-

- Display Name -

-

- This is how body text looks on your profile. -

-
- )}
); } diff --git a/src/components/ProfileRightSidebar.tsx b/src/components/ProfileRightSidebar.tsx index a03ed4f4..7a8945e7 100644 --- a/src/components/ProfileRightSidebar.tsx +++ b/src/components/ProfileRightSidebar.tsx @@ -499,7 +499,7 @@ export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLo