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).
This commit is contained in:
Chad Curtis
2026-04-07 08:55:27 -05:00
parent c570f4689d
commit 17c1936817
4 changed files with 360 additions and 33 deletions
+5 -15
View File
@@ -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 (
<div className="flex items-center gap-3 px-4 py-3">
<Skeleton className="size-11 rounded-full shrink-0" />
+27 -7
View File
@@ -30,32 +30,52 @@ export function useActiveTabIndicator(active: boolean, elRef: React.RefObject<HT
const reportSlice = useCallback(() => {
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]);
+12
View File
@@ -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 };
}
+316 -11
View File
@@ -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<PackTab>('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 (
<div className="h-dvh flex flex-col bg-background/85">
<div className="shrink-0">
<div className="h-36 md:h-48 bg-secondary relative">
<Skeleton className="w-full h-full rounded-none" />
<Link to="/" className="absolute top-3 left-3">
<div className="bg-background/85 rounded-full">
<DittoLogo size={48} />
</div>
</Link>
</div>
<div className="bg-background/85">
<div className="flex flex-col items-center px-4 pt-6 pb-6 max-w-2xl mx-auto w-full space-y-3">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-64" />
<Skeleton className="h-10 w-56 rounded-full" />
</div>
</div>
</div>
</div>
);
}
if (!event) return <NotFound />;
return (
<div className="h-dvh flex flex-col bg-background/85">
{/* Header (not scrollable) */}
<div className="shrink-0">
{/* Banner */}
<div className="h-36 md:h-48 bg-secondary relative">
{bannerUrl ? (
<img src={bannerUrl} alt="" className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-accent/10 via-transparent to-primary/5" />
)}
<Link to="/" className="absolute top-3 left-3">
<div className="bg-background/85 rounded-full">
<DittoLogo size={48} />
</div>
</Link>
</div>
{/* Pack info */}
<div className="bg-background/85">
<div className="flex flex-col items-center px-4 -mt-6 pb-4 relative z-10 max-w-2xl mx-auto w-full">
{/* Avatar stack (first 5 members) */}
<div className="flex -space-x-3 mb-3">
{pubkeys.slice(0, 5).map((pk) => {
const member = membersMap?.get(pk);
const name = member?.metadata?.name || genUserName(pk);
const shape = getAvatarShape(member?.metadata);
return (
<Avatar key={pk} shape={shape} className="size-12 border-2 border-background shadow-md">
<AvatarImage src={member?.metadata?.picture} alt={name} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
);
})}
{pubkeys.length > 5 && (
<div className="size-12 rounded-full border-2 border-background bg-secondary flex items-center justify-center shadow-md">
<span className="text-xs font-medium text-muted-foreground">+{pubkeys.length - 5}</span>
</div>
)}
</div>
{/* Title */}
<h1 className="text-xl font-bold text-foreground text-center">{title}</h1>
{/* Author attribution */}
<Link to={`/${nip19.npubEncode(addr.pubkey)}`} className="flex items-center gap-1.5 mt-1.5 hover:underline">
<Avatar shape={getAvatarShape(authorMeta)} className="size-5">
<AvatarImage src={authorMeta?.picture} alt={authorName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{authorName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground">by {authorName}</span>
</Link>
{/* Description */}
{description && (
<p className="text-sm text-muted-foreground text-center mt-2 max-w-sm whitespace-pre-wrap">
{description}
</p>
)}
{/* Big CTA button */}
<div className="mt-4 w-full max-w-xs">
{!user ? (
<Button
onClick={() => setLoginOpen(true)}
className="w-full rounded-full py-3 text-base font-semibold gap-2"
size="lg"
>
<UserPlus className="size-5" />
Follow {pubkeys.length} people on Ditto
</Button>
) : isFollowingAll ? (
<Button disabled className="w-full rounded-full py-3 text-base font-semibold gap-2" size="lg">
<Loader2 className="size-5 animate-spin" />
Following...
</Button>
) : allFollowed ? (
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-2 text-primary">
<CheckCircle2 className="size-5" />
<p className="font-semibold">Following all {pubkeys.length} people</p>
</div>
<Button variant="secondary" className="rounded-full w-full" onClick={() => navigate('/feed')}>
Go to feed
</Button>
</div>
) : (
<div className="space-y-2">
<Button
onClick={handleFollowAll}
className="w-full rounded-full py-3 text-base font-semibold gap-2"
size="lg"
>
<UserPlus className="size-5" />
Follow All ({pubkeys.length})
</Button>
{followingCount > 0 && (
<p className="text-center text-sm text-muted-foreground">
Already following {followingCount} of {pubkeys.length}
{' '}&middot;{' '}
<span className="text-green-600 dark:text-green-400">{newCount} new</span>
</p>
)}
</div>
)}
</div>
</div>
</div>
</div>
{/* Tab bar */}
<SubHeaderBar className="shrink-0" innerClassName="max-w-2xl mx-auto">
<TabButton label="Feed" active={activeTab === 'feed'} onClick={() => setActiveTab('feed')} />
<TabButton label={`Members (${pubkeys.length})`} active={activeTab === 'members'} onClick={() => setActiveTab('members')} />
</SubHeaderBar>
{/* Tab content (scrollable) */}
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="max-w-2xl mx-auto w-full bg-background/85" style={{ paddingTop: ARC_OVERHANG_PX }}>
{activeTab === 'feed' ? (
<PackFeedTab pubkeys={pubkeys} />
) : membersLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: Math.min(pubkeys.length, 8) }).map((_, i) => (
<MemberCardSkeleton key={i} />
))}
</div>
) : (
<div className="divide-y divide-border">
{pubkeys.map((pk) => {
const member = membersMap?.get(pk);
const isFollowed = followedPubkeys.has(pk);
return (
<MemberCard
key={pk}
pubkey={pk}
metadata={member?.metadata}
isFollowed={isFollowed}
isSelf={pk === user?.pubkey}
/>
);
})}
</div>
)}
</div>
</div>
<LoginDialog
isOpen={loginOpen}
onClose={() => setLoginOpen(false)}
onLogin={() => setLoginOpen(false)}
onSignupClick={startSignup}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 <NotFound />;
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 <NotFound />;
}
decoded = nip19.decode(npub);
} catch {
return <NotFound />;
}
return <FollowView pubkey={pubkey} />;
// Handle npub / nprofile -> individual user follow view
if (decoded.type === 'npub') {
return <FollowView pubkey={decoded.data} />;
}
if (decoded.type === 'nprofile') {
return <FollowView pubkey={decoded.data.pubkey} />;
}
// 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 <NotFound />;
}
return (
<FollowPackView
addr={{ kind: addr.kind, pubkey: addr.pubkey, identifier: addr.identifier }}
relays={addr.relays}
/>
);
}
return <NotFound />;
}