mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-06-04 09:31:14 +02:00
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:
@@ -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}
|
||||
|
||||
42
src/hooks/useFollowerCount.ts
Normal file
42
src/hooks/useFollowerCount.ts
Normal 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
|
||||
});
|
||||
}
|
||||
38
src/hooks/useFollowingCount.ts
Normal file
38
src/hooks/useFollowingCount.ts
Normal 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
|
||||
});
|
||||
}
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user