From 17c1936817a420aa5d2a544f0c38cde718968a70 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Tue, 7 Apr 2026 08:55:27 -0500 Subject: [PATCH] Support follow pack/set naddr identifiers on /follow URL The /follow route now accepts naddr1 identifiers for follow packs (kind 39089) and follow sets (kind 30000) in addition to npub/nprofile. Renders an immersive fullscreen layout with pack info hero, avatar stack, big Follow All CTA with status indicator, and Feed/Members tabs using the standard SubHeaderBar arc. Follow All uses the safe fetch-fresh -> modify -> publish pattern with prev for published_at preservation. Shared components (PackFeedTab, MemberCard, MemberCardSkeleton) and parsePackEvent are reused from FollowPackDetailContent and packUtils. Also fixes SubHeaderBar tab indicator positioning when innerClassName centers the tab container (adds containerOffset + ResizeObserver for layout-dependent recalculation). --- src/components/FollowPackDetailContent.tsx | 20 +- src/components/SubHeaderBarContext.ts | 34 ++- src/lib/packUtils.ts | 12 + src/pages/FollowPage.tsx | 327 ++++++++++++++++++++- 4 files changed, 360 insertions(+), 33 deletions(-) create mode 100644 src/lib/packUtils.ts diff --git a/src/components/FollowPackDetailContent.tsx b/src/components/FollowPackDetailContent.tsx index 37e5fc97..b59fe91f 100644 --- a/src/components/FollowPackDetailContent.tsx +++ b/src/components/FollowPackDetailContent.tsx @@ -22,25 +22,15 @@ import { useMuteList } from '@/hooks/useMuteList'; import { isEventMuted } from '@/lib/muteHelpers'; import { useNostr } from '@nostrify/react'; import { genUserName } from '@/lib/genUserName'; +import { parsePackEvent } from '@/lib/packUtils'; import { VerifiedNip05Text } from '@/components/Nip05Badge'; import { SubHeaderBar } from '@/components/SubHeaderBar'; -/** Parse a follow pack / starter pack event into structured data. */ -function parsePackEvent(event: NostrEvent) { - const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1]; - const title = getTag('title') || getTag('name') || 'Untitled Pack'; - const description = getTag('description') || getTag('summary') || ''; - const image = getTag('image') || getTag('thumb') || getTag('banner'); - const pubkeys = event.tags.filter(([n]) => n === 'p').map(([, pk]) => pk); - - return { title, description, image, pubkeys }; -} - type Tab = 'feed' | 'members'; // ─── Feed Tab ───────────────────────────────────────────────────────────────── -function PackFeedTab({ pubkeys }: { pubkeys: string[] }) { +export function PackFeedTab({ pubkeys }: { pubkeys: string[] }) { const { muteItems } = useMuteList(); const { posts, isLoading } = useStreamPosts('', { @@ -101,7 +91,7 @@ function PackFeedTab({ pubkeys }: { pubkeys: string[] }) { // ─── Members Tab ────────────────────────────────────────────────────────────── -function PackMembersTab({ +export function PackMembersTab({ pubkeys, membersMap, membersLoading, @@ -357,7 +347,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) { } /** Individual member card in the follow pack. */ -function MemberCard({ +export function MemberCard({ pubkey, metadata, isFollowed, @@ -437,7 +427,7 @@ function MemberCard({ ); } -function MemberCardSkeleton() { +export function MemberCardSkeleton() { return (
diff --git a/src/components/SubHeaderBarContext.ts b/src/components/SubHeaderBarContext.ts index dc630607..0404a4a8 100644 --- a/src/components/SubHeaderBarContext.ts +++ b/src/components/SubHeaderBarContext.ts @@ -30,32 +30,52 @@ export function useActiveTabIndicator(active: boolean, elRef: React.RefObject { const el = elRef.current; if (!el) return null; - const scrollOffset = scrollContainerRef.current?.scrollLeft ?? 0; - return { left: el.offsetLeft - scrollOffset, width: el.offsetWidth }; + const container = scrollContainerRef.current; + const scrollOffset = container?.scrollLeft ?? 0; + // Account for the scroll container's own offset within its parent + // (e.g. when innerClassName adds mx-auto centering). + const containerOffset = container?.offsetLeft ?? 0; + return { left: el.offsetLeft - scrollOffset + containerOffset, width: el.offsetWidth }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Report active slice to SubHeaderBar so the arc indicator renders. + // Schedule a second report after paint so that layout-dependent values + // (e.g. offsetLeft from mx-auto centering) are fully resolved. useLayoutEffect(() => { if (!active) return; const s = reportSlice(); if (s) onActive(s); - return () => onActive(null); + + const raf = requestAnimationFrame(() => { + const updated = reportSlice(); + if (updated) onActive(updated); + }); + + return () => { + cancelAnimationFrame(raf); + onActive(null); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [active]); - // Re-report position when the scroll container scrolls, + // Re-report position when the scroll container scrolls or resizes, // so the SVG clip-path stays aligned with the visually shifted tab. useEffect(() => { if (!active) return; const container = scrollContainerRef.current; if (!container) return; - const handleScroll = () => { + const update = () => { const s = reportSlice(); if (s) onActive(s); }; - container.addEventListener('scroll', handleScroll, { passive: true }); - return () => container.removeEventListener('scroll', handleScroll); + container.addEventListener('scroll', update, { passive: true }); + const ro = new ResizeObserver(update); + ro.observe(container); + return () => { + container.removeEventListener('scroll', update); + ro.disconnect(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [active]); diff --git a/src/lib/packUtils.ts b/src/lib/packUtils.ts new file mode 100644 index 00000000..36f9f24a --- /dev/null +++ b/src/lib/packUtils.ts @@ -0,0 +1,12 @@ +import type { NostrEvent } from '@nostrify/nostrify'; + +/** Parse a follow pack / starter pack event into structured data. */ +export function parsePackEvent(event: NostrEvent) { + const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1]; + const title = getTag('title') || getTag('name') || 'Untitled Pack'; + const description = getTag('description') || getTag('summary') || ''; + const image = getTag('image') || getTag('thumb') || getTag('banner'); + const pubkeys = event.tags.filter(([n]) => n === 'p').map(([, pk]) => pk); + + return { title, description, image, pubkeys }; +} diff --git a/src/pages/FollowPage.tsx b/src/pages/FollowPage.tsx index 71dff30f..5f53c7db 100644 --- a/src/pages/FollowPage.tsx +++ b/src/pages/FollowPage.tsx @@ -1,8 +1,9 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import { useInView } from 'react-intersection-observer'; import { nip19 } from 'nostr-tools'; import { UserPlus, Loader2, CheckCircle2 } from 'lucide-react'; +import { useNostr } from '@nostrify/react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; @@ -11,21 +12,30 @@ import { NoteCard } from '@/components/NoteCard'; import { getAvatarShape, isEmoji, emojiAvatarBorderStyle } from '@/lib/avatarShape'; import { cn } from '@/lib/utils'; import { useAuthor } from '@/hooks/useAuthor'; +import { useAuthors } from '@/hooks/useAuthors'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useFollowList, useFollowActions } from '@/hooks/useFollowActions'; import { useToast } from '@/hooks/useToast'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useProfileFeed, filterByTab } from '@/hooks/useProfileFeed'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; +import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; +import { parsePackEvent } from '@/lib/packUtils'; +import { PackFeedTab, MemberCard, MemberCardSkeleton } from '@/components/FollowPackDetailContent'; import { genUserName } from '@/lib/genUserName'; import { ArcBackground, ARC_OVERHANG_PX } from '@/components/ArcBackground'; import { DittoLogo } from '@/components/DittoLogo'; import { Nip05Badge } from '@/components/Nip05Badge'; +import { SubHeaderBar } from '@/components/SubHeaderBar'; +import { TabButton } from '@/components/TabButton'; import { useActiveProfileTheme } from '@/hooks/useActiveProfileTheme'; import { useOnboarding } from '@/hooks/useOnboarding'; import { buildThemeCssFromCore } from '@/themes'; import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader'; import LoginDialog from '@/components/auth/LoginDialog'; import type { FeedItem } from '@/lib/feedUtils'; +import type { AddressPointer } from 'nostr-tools/nip19'; import NotFound from './NotFound'; // --------------------------------------------------------------------------- @@ -337,28 +347,323 @@ function FollowView({ pubkey }: { pubkey: string }) { ); } +// --------------------------------------------------------------------------- +// Immersive follow pack/set view +// --------------------------------------------------------------------------- + +type PackTab = 'feed' | 'members'; + +function FollowPackView({ addr, relays }: { addr: AddrCoords; relays?: string[] }) { + const { data: event, isLoading: eventLoading } = useAddrEvent(addr, relays); + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const { data: followList } = useFollowList(); + const { mutateAsync: publishEvent } = useNostrPublish(); + const { toast } = useToast(); + const navigate = useNavigate(); + const { startSignup } = useOnboarding(); + const [loginOpen, setLoginOpen] = useState(false); + const [activeTab, setActiveTab] = useState('feed'); + const [isFollowingAll, setIsFollowingAll] = useState(false); + + const author = useAuthor(addr.pubkey); + const authorMeta = author.data?.metadata; + const authorName = authorMeta?.name || genUserName(addr.pubkey); + + const { title, description, image, pubkeys } = useMemo( + () => (event ? parsePackEvent(event) : { title: 'Loading...', description: '', image: undefined, pubkeys: [] }), + [event], + ); + + const { data: membersMap, isLoading: membersLoading } = useAuthors(pubkeys); + + const followedPubkeys = useMemo(() => new Set(followList?.pubkeys ?? []), [followList]); + const followingCount = useMemo( + () => pubkeys.filter((pk) => followedPubkeys.has(pk)).length, + [pubkeys, followedPubkeys], + ); + const allFollowed = pubkeys.length > 0 && followingCount === pubkeys.length; + const newCount = pubkeys.length - followingCount; + + const bannerUrl = image || authorMeta?.banner; + + /** Follow All using fetch-fresh -> modify -> publish pattern. */ + const handleFollowAll = useCallback(async () => { + if (!user) return; + setIsFollowingAll(true); + try { + // 1. Fetch freshest kind 3 from relays (not cache) + const prev = await fetchFreshEvent(nostr, { + kinds: [3], + authors: [user.pubkey], + }); + + // 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc. + const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? []; + const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? []; + const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk)); + + // 3. Merge: add new pubkeys that aren't already followed + const newPTags = pubkeys + .filter((pk) => !existingPubkeys.has(pk)) + .map((pk) => ['p', pk]); + const added = newPTags.length; + + // 4. Publish with prev for published_at preservation + await publishEvent({ + kind: 3, + content: prev?.content ?? '', + tags: [...nonPTags, ...existingPTags, ...newPTags], + prev: prev ?? undefined, + }); + + toast({ + title: allFollowed ? 'Already following all!' : 'Following all!', + description: added > 0 + ? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.` + : 'You were already following everyone in this pack.', + }); + } catch (error) { + console.error('Failed to follow all:', error); + toast({ + title: 'Failed to follow', + description: 'There was an error updating your follow list.', + variant: 'destructive', + }); + } finally { + setIsFollowingAll(false); + } + }, [user, pubkeys, nostr, publishEvent, toast, allFollowed]); + + if (eventLoading) { + return ( +
+
+
+ + +
+ +
+ +
+
+
+ + + + +
+
+
+
+ ); + } + + if (!event) return ; + + return ( +
+ {/* Header (not scrollable) */} +
+ {/* Banner */} +
+ {bannerUrl ? ( + + ) : ( +
+ )} + +
+ +
+ +
+ + {/* Pack info */} +
+
+ {/* Avatar stack (first 5 members) */} +
+ {pubkeys.slice(0, 5).map((pk) => { + const member = membersMap?.get(pk); + const name = member?.metadata?.name || genUserName(pk); + const shape = getAvatarShape(member?.metadata); + return ( + + + + {name[0]?.toUpperCase()} + + + ); + })} + {pubkeys.length > 5 && ( +
+ +{pubkeys.length - 5} +
+ )} +
+ + {/* Title */} +

{title}

+ + {/* Author attribution */} + + + + + {authorName[0]?.toUpperCase()} + + + by {authorName} + + + {/* Description */} + {description && ( +

+ {description} +

+ )} + + {/* Big CTA button */} +
+ {!user ? ( + + ) : isFollowingAll ? ( + + ) : allFollowed ? ( +
+
+ +

Following all {pubkeys.length} people

+
+ +
+ ) : ( +
+ + {followingCount > 0 && ( +

+ Already following {followingCount} of {pubkeys.length} + {' '}·{' '} + {newCount} new +

+ )} +
+ )} +
+
+
+
+ + {/* Tab bar */} + + setActiveTab('feed')} /> + setActiveTab('members')} /> + + + {/* Tab content (scrollable) */} +
+
+ {activeTab === 'feed' ? ( + + ) : membersLoading ? ( +
+ {Array.from({ length: Math.min(pubkeys.length, 8) }).map((_, i) => ( + + ))} +
+ ) : ( +
+ {pubkeys.map((pk) => { + const member = membersMap?.get(pk); + const isFollowed = followedPubkeys.has(pk); + return ( + + ); + })} +
+ )} +
+
+ + setLoginOpen(false)} + onLogin={() => setLoginOpen(false)} + onSignupClick={startSignup} + /> +
+ ); +} + // --------------------------------------------------------------------------- // Route component // --------------------------------------------------------------------------- +/** Kinds accepted as follow packs/sets at /follow URLs. */ +const FOLLOW_PACK_SET_KINDS = new Set([30000, 39089]); + export function FollowPage() { const { npub } = useParams<{ npub: string }>(); if (!npub) return ; - let pubkey: string; + // Try decoding as a NIP-19 identifier + let decoded; try { - const decoded = nip19.decode(npub); - if (decoded.type === 'npub') { - pubkey = decoded.data; - } else if (decoded.type === 'nprofile') { - pubkey = decoded.data.pubkey; - } else { - return ; - } + decoded = nip19.decode(npub); } catch { return ; } - return ; + // Handle npub / nprofile -> individual user follow view + if (decoded.type === 'npub') { + return ; + } + if (decoded.type === 'nprofile') { + return ; + } + + // Handle naddr -> follow pack/set view + if (decoded.type === 'naddr') { + const addr = decoded.data as AddressPointer; + if (!FOLLOW_PACK_SET_KINDS.has(addr.kind)) { + return ; + } + return ( + + ); + } + + return ; }