SkateSpots/src/hooks/useFollowing.ts

249 lines
7.2 KiB
TypeScript

import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useToast } from './useToast';
import { useTranslation } from '@/contexts/language';
import { nip19 } from 'nostr-tools';
/**
* Hook to fetch the list of pubkeys that a user follows
*/
export function useFollowing(pubkey?: string) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const targetPubkey = pubkey || user?.pubkey;
return useQuery({
queryKey: ['following', targetPubkey],
queryFn: async (c) => {
if (!targetPubkey) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
const events = await nostr.query(
[{ kinds: [3], authors: [targetPubkey], limit: 1 }],
{ signal }
);
if (!events[0]) return [];
// Extract followed pubkeys from p tags
const following = events[0].tags
.filter(([tag]) => tag === 'p')
.map(([, pubkey]) => pubkey);
return following;
},
enabled: !!targetPubkey,
staleTime: 60000, // 1 minute
});
}
/**
* Hook to fetch the list of pubkeys that follow a user
*/
export function useFollowers(pubkey?: string) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const targetPubkey = pubkey || user?.pubkey;
return useQuery({
queryKey: ['followers', targetPubkey],
queryFn: async (c) => {
if (!targetPubkey) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
// Query all kind 3 events that have a p tag with our pubkey
const events = await nostr.query(
[{ kinds: [3], '#p': [targetPubkey], limit: 100 }],
{ signal }
);
// Extract the authors (people who follow us)
const followers = events.map((event) => event.pubkey);
// Deduplicate (same person might have multiple follow lists)
return Array.from(new Set(followers));
},
enabled: !!targetPubkey,
staleTime: 60000, // 1 minute
});
}
/**
* Hook to follow/unfollow users
*/
export function useFollowActions() {
const { t } = useTranslation();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { toast } = useToast();
const queryClient = useQueryClient();
const followMutation = useMutation({
mutationFn: async (targetPubkey: string) => {
if (!user) throw new Error('Must be logged in to follow users');
// Get current follow list
const events = await nostr.query(
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
{ signal: AbortSignal.timeout(5000) }
);
const currentFollowList = events[0];
const currentTags = currentFollowList?.tags || [];
// Check if already following
const alreadyFollowing = currentTags.some(
([tag, pubkey]) => tag === 'p' && pubkey === targetPubkey
);
if (alreadyFollowing) {
throw new Error('Already following this user');
}
// Add new p tag
const newTags = [...currentTags, ['p', targetPubkey]];
// Publish new follow list
await user.signer.signEvent({
kind: 3,
content: currentFollowList?.content || '',
tags: newTags,
created_at: Math.floor(Date.now() / 1000),
}).then((signedEvent) => nostr.event(signedEvent));
return targetPubkey;
},
onSuccess: (_targetPubkey) => {
// Invalidate queries to refetch
queryClient.invalidateQueries({ queryKey: ['following', user?.pubkey] });
queryClient.invalidateQueries({ queryKey: ['trust-score'] });
toast({
title: t('toastsUi.followingSuccess'),
description: t('toastsUi.followingSuccessDesc'),
});
},
onError: (error) => {
toast({
title: t('toastsUi.followFailed'),
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
},
});
const unfollowMutation = useMutation({
mutationFn: async (targetPubkey: string) => {
if (!user) throw new Error('Must be logged in to unfollow users');
// Get current follow list
const events = await nostr.query(
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
{ signal: AbortSignal.timeout(5000) }
);
const currentFollowList = events[0];
if (!currentFollowList) {
throw new Error('No follow list found');
}
// Remove the p tag for this pubkey
const newTags = currentFollowList.tags.filter(
([tag, pubkey]) => !(tag === 'p' && pubkey === targetPubkey)
);
// Publish updated follow list
await user.signer.signEvent({
kind: 3,
content: currentFollowList.content || '',
tags: newTags,
created_at: Math.floor(Date.now() / 1000),
}).then((signedEvent) => nostr.event(signedEvent));
return targetPubkey;
},
onSuccess: () => {
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ['following', user?.pubkey] });
queryClient.invalidateQueries({ queryKey: ['trust-score'] });
toast({
title: t('toastsUi.unfollowSuccess'),
description: t('toastsUi.unfollowSuccessDesc'),
});
},
onError: (error) => {
toast({
title: t('toastsUi.unfollowFailed'),
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
},
});
/**
* Parse npub/nip05 and follow the user
*/
const followByIdentifier = async (identifier: string) => {
try {
// Try to decode as npub first
if (identifier.startsWith('npub1')) {
const decoded = nip19.decode(identifier);
if (decoded.type === 'npub') {
await followMutation.mutateAsync(decoded.data);
return;
}
}
// Try to resolve as NIP-05
if (identifier.includes('@')) {
const [name, domain] = identifier.split('@');
const response = await fetch(
`https://${domain}/.well-known/nostr.json?name=${name}`
);
const data = await response.json();
const pubkey = data.names?.[name];
if (pubkey) {
await followMutation.mutateAsync(pubkey);
return;
}
}
// Try as raw hex pubkey
if (identifier.length === 64 && /^[0-9a-f]+$/.test(identifier)) {
await followMutation.mutateAsync(identifier);
return;
}
throw new Error(t('toastsUi.invalidIdentifier'));
} catch (error) {
toast({
title: t('toastsUi.followFailed'),
description: error instanceof Error ? error.message : 'Invalid identifier',
variant: 'destructive',
});
}
};
return {
follow: followMutation.mutateAsync,
unfollow: unfollowMutation.mutateAsync,
followByIdentifier,
isFollowing: followMutation.isPending,
isUnfollowing: unfollowMutation.isPending,
};
}
/**
* Check if current user is following a specific pubkey
*/
export function useIsFollowing(targetPubkey: string | undefined) {
const { data: following = [] } = useFollowing();
if (!targetPubkey) return false;
return following.includes(targetPubkey);
}