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:
@@ -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" />
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
{' '}·{' '}
|
||||
<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 />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user