feat: add follower and following count functionality to ProfileView (#37)

* feat: add follower and following count functionality to ProfileView

* Invalidate following-count cache on follow/unfollow actions (#53)

* Initial plan

* fix: invalidate following-count cache on follow/unfollow

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

* refactor: use specific query keys for cache invalidation

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

---------

Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
mroxso
2025-11-29 20:39:52 +01:00
committed by GitHub
parent 939d228756
commit 301b8b6125
5 changed files with 123 additions and 1 deletions

View File

@@ -24,6 +24,10 @@ interface ProfileViewProps {
postsLoading?: boolean;
bookmarksLoading?: boolean;
highlightsLoading?: boolean;
followerCount?: number;
followingCount?: number;
followerCountLoading?: boolean;
followingCountLoading?: boolean;
}
export function ProfileView({
@@ -35,6 +39,10 @@ export function ProfileView({
postsLoading = false,
bookmarksLoading = false,
highlightsLoading = false,
followerCount = 0,
followingCount = 0,
followerCountLoading = false,
followingCountLoading = false,
}: ProfileViewProps) {
const { toast } = useToast();
const [copied, setCopied] = useState(false);
@@ -138,6 +146,26 @@ export function ProfileView({
)}
</div>
{/* Follower/Following Stats */}
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-1">
{followingCountLoading ? (
<Skeleton className="h-5 w-12" />
) : (
<span className="font-semibold">{followingCount?.toLocaleString() ?? 0}</span>
)}
<span className="text-muted-foreground">Following</span>
</div>
<div className="flex items-center gap-1">
{followerCountLoading ? (
<Skeleton className="h-5 w-12" />
) : (
<span className="font-semibold">{followerCount?.toLocaleString() ?? 0}</span>
)}
<span className="text-muted-foreground">Followers</span>
</div>
</div>
{about && (
<p className="text-sm md:text-base text-muted-foreground whitespace-pre-wrap max-w-2xl">
{about}

View File

@@ -0,0 +1,42 @@
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
/**
* Hook to get follower count for a pubkey.
*
* Ideally this would use NIP-45 COUNT verb for efficiency:
* `["COUNT", <query_id>, {"kinds": [3], "#p": [<pubkey>]}]`
*
* However, since the Nostrify library doesn't directly expose COUNT,
* we query kind-3 events that reference the pubkey and count them.
*
* Note: This queries up to 5000 events. For users with more followers,
* the count will be capped at 5000 until NIP-45 COUNT is directly supported.
*/
export function useFollowerCount(pubkey: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['follower-count', pubkey],
queryFn: async (c) => {
if (!pubkey) return 0;
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
try {
// Query kind-3 events (contact lists) that tag this pubkey
// Using a high limit to approximate a count
const events = await nostr.query(
[{ kinds: [3], '#p': [pubkey], limit: 5000 }],
{ signal }
);
return events.length;
} catch (error) {
console.error('Failed to fetch follower count:', error);
return 0;
}
},
enabled: !!pubkey,
staleTime: 60000, // Cache for 1 minute
});
}

View File

@@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
/**
* Hook to get following count for a pubkey.
* Gets the most recent kind-3 event (contact list) and counts the 'p' tags.
*/
export function useFollowingCount(pubkey: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['following-count', pubkey],
queryFn: async (c) => {
if (!pubkey) return 0;
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
try {
// Query the user's most recent kind-3 event (contact list)
const events = await nostr.query(
[{ kinds: [3], authors: [pubkey], limit: 1 }],
{ signal }
);
if (events.length === 0) return 0;
// Count the number of 'p' tags (people being followed)
const followingCount = events[0].tags.filter(([tag]) => tag === 'p').length;
return followingCount;
} catch (error) {
console.error('Failed to fetch following count:', error);
return 0;
}
},
enabled: !!pubkey,
staleTime: 60000, // Cache for 1 minute
});
}

View File

@@ -57,10 +57,16 @@ export function useToggleFollow() {
return { event, isFollowing: !isFollowing };
},
onSuccess: () => {
onSuccess: (_, { pubkey: targetPubkey }) => {
// Invalidate following queries to refetch
queryClient.invalidateQueries({ queryKey: ['following'] });
queryClient.invalidateQueries({ queryKey: ['following-posts'] });
// Invalidate the current user's following count
if (user?.pubkey) {
queryClient.invalidateQueries({ queryKey: ['following-count', user.pubkey] });
}
// Invalidate the target user's follower count
queryClient.invalidateQueries({ queryKey: ['follower-count', targetPubkey] });
},
});
}

View File

@@ -5,6 +5,8 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useAuthorBlogPosts } from '@/hooks/useAuthorBlogPosts';
import { useUserBookmarkedArticles } from '@/hooks/useUserBookmarkedArticles';
import { useUserHighlights } from '@/hooks/useHighlights';
import { useFollowerCount } from '@/hooks/useFollowerCount';
import { useFollowingCount } from '@/hooks/useFollowingCount';
import { ProfileView } from '@/components/ProfileView';
import { ProfileSkeleton } from '@/components/ProfileSkeleton';
import NotFound from '@/pages/NotFound';
@@ -39,6 +41,8 @@ export default function ProfilePage() {
const { data: posts, isLoading: postsLoading } = useAuthorBlogPosts(pubkey);
const { data: bookmarkedArticles, isLoading: bookmarksLoading } = useUserBookmarkedArticles(pubkey);
const { data: highlights, isLoading: highlightsLoading } = useUserHighlights(pubkey);
const { data: followerCount, isLoading: followerCountLoading } = useFollowerCount(pubkey);
const { data: followingCount, isLoading: followingCountLoading } = useFollowingCount(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey);
@@ -104,6 +108,10 @@ export default function ProfilePage() {
postsLoading={postsLoading}
bookmarksLoading={bookmarksLoading}
highlightsLoading={highlightsLoading}
followerCount={followerCount}
followingCount={followingCount}
followerCountLoading={followerCountLoading}
followingCountLoading={followingCountLoading}
/>
);
}