SkateSpots/src/hooks/useFollowing.ts
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
The entire skate-spots codebase was accidentally copied into src/src/ and
public/public/ instead of src/ and public/ directly. This left the real
src/ directory as blank MKStack template stubs, explaining why the landing
page showed the default template instead of SkateSpots.

Changes made:
- Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx,
  all pages, components, hooks, contexts, lib, i18n, test)
- Copied all files from public/public/ up to public/ (images, manifest, etc.)
- Deleted src/src/ and public/public/ nested directories
- Removed HelloWorld.tsx template stub from pages/
- Retained the previously fixed index.css (Tailwind v3 directives)

Full skate-spots feature set is now active:
- All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.)
- NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler
- Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.)
- Outfit Variable font, Amber pre-check in main.tsx
- All hooks (useSkateSpots, useCoaches, useZaps, etc.)
- i18n support (en/es)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 22:18:28 +07:00

247 lines
7.1 KiB
TypeScript

import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useToast } from './useToast';
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 { 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: 'Followed successfully',
description: 'This user is now in your network',
});
},
onError: (error) => {
toast({
title: 'Failed to follow',
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: 'Unfollowed successfully',
description: 'Removed from your network',
});
},
onError: (error) => {
toast({
title: 'Failed to unfollow',
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('Invalid identifier. Use npub1..., user@domain.com, or hex pubkey');
} catch (error) {
toast({
title: 'Failed to follow',
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);
}