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 ;
}