SkateSpots/src/hooks/useUserStatus.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

293 lines
8.7 KiB
TypeScript

import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import type { NostrEvent } from '@nostrify/nostrify';
// NIP-38 User Status Kind
export const USER_STATUS_KIND = 30315;
// Skating status presets with emojis
export const SKATING_STATUSES = [
{ id: 'skating', label: 'Skating', emoji: '🛹', description: 'Currently skating' },
{ id: 'filming', label: 'Filming', emoji: '🎬', description: 'Filming tricks' },
{ id: 'looking-for-sesh', label: 'Looking for Sesh', emoji: '👀', description: 'Want to skate with others' },
{ id: 'coaching', label: 'Coaching', emoji: '🎓', description: 'Teaching/coaching' },
{ id: 'chilling', label: 'Chilling', emoji: '😎', description: 'Hanging out at the spot' },
{ id: 'driving-to-spot', label: 'On My Way', emoji: '🚗', description: 'Heading to a spot' },
{ id: 'taking-a-break', label: 'Taking a Break', emoji: '🧃', description: 'Resting/hydrating' },
{ id: 'done-for-today', label: 'Done for Today', emoji: '🌙', description: 'Finished skating' },
] as const;
export type SkatingStatusId = typeof SKATING_STATUSES[number]['id'];
export interface UserStatus {
pubkey: string;
status: string;
statusId?: SkatingStatusId;
emoji?: string;
linkedSpot?: {
pubkey: string;
identifier: string;
title?: string;
};
expiration?: number;
createdAt: number;
}
// Parse a NIP-38 status event into our format
function parseStatusEvent(event: NostrEvent): UserStatus | null {
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
if (dTag !== 'general') return null; // Only handle general statuses for now
const status = event.content;
if (!status) return null;
// Find linked spot (a tag)
const aTag = event.tags.find(([name]) => name === 'a')?.[1];
let linkedSpot: UserStatus['linkedSpot'] | undefined;
if (aTag) {
const parts = aTag.split(':');
if (parts.length >= 3 && parts[0] === '30818') {
linkedSpot = {
pubkey: parts[1],
identifier: parts[2],
};
}
}
// Find expiration
const expirationTag = event.tags.find(([name]) => name === 'expiration')?.[1];
const expiration = expirationTag ? parseInt(expirationTag, 10) : undefined;
// Check if expired
if (expiration && expiration < Math.floor(Date.now() / 1000)) {
return null;
}
// Try to match to a skating status preset
const matchedStatus = SKATING_STATUSES.find(s =>
status.toLowerCase().includes(s.label.toLowerCase()) ||
status.includes(s.emoji)
);
return {
pubkey: event.pubkey,
status,
statusId: matchedStatus?.id,
emoji: matchedStatus?.emoji,
linkedSpot,
expiration,
createdAt: event.created_at,
};
}
// Hook to get a user's current status
export function useUserStatus(pubkey: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['user-status', pubkey],
queryFn: async (c) => {
if (!pubkey) return null;
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
const events = await nostr.query([
{
kinds: [USER_STATUS_KIND],
authors: [pubkey],
'#d': ['general'],
limit: 1,
},
], { signal });
if (events.length === 0) return null;
return parseStatusEvent(events[0]);
},
enabled: !!pubkey,
staleTime: 1000 * 60, // 1 minute
refetchInterval: 1000 * 60 * 2, // Refetch every 2 minutes
});
}
// Hook to get statuses for multiple users
export function useBatchUserStatuses(pubkeys: string[]) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['batch-user-statuses', pubkeys.join(',')],
queryFn: async (c) => {
if (pubkeys.length === 0) return new Map<string, UserStatus>();
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const events = await nostr.query([
{
kinds: [USER_STATUS_KIND],
authors: pubkeys,
'#d': ['general'],
},
], { signal });
const statusMap = new Map<string, UserStatus>();
// Group by pubkey and get most recent
const latestByPubkey = new Map<string, NostrEvent>();
for (const event of events) {
const existing = latestByPubkey.get(event.pubkey);
if (!existing || event.created_at > existing.created_at) {
latestByPubkey.set(event.pubkey, event);
}
}
for (const event of latestByPubkey.values()) {
const status = parseStatusEvent(event);
if (status) {
statusMap.set(event.pubkey, status);
}
}
return statusMap;
},
enabled: pubkeys.length > 0,
staleTime: 1000 * 60,
refetchInterval: 1000 * 60 * 2,
});
}
// Hook to get users skating at a specific spot
export function useWhosSkatingAtSpot(spotPubkey: string, spotIdentifier: string) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['whos-skating', spotPubkey, spotIdentifier],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const spotRef = `30818:${spotPubkey}:${spotIdentifier}`;
// Query statuses that reference this spot
const events = await nostr.query([
{
kinds: [USER_STATUS_KIND],
'#a': [spotRef],
'#d': ['general'],
limit: 50,
},
], { signal });
const now = Math.floor(Date.now() / 1000);
const statuses: UserStatus[] = [];
const seenPubkeys = new Set<string>();
// Sort by created_at descending to get most recent first
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at);
for (const event of sortedEvents) {
if (seenPubkeys.has(event.pubkey)) continue;
const status = parseStatusEvent(event);
if (status) {
// Only include if not expired and created within last 4 hours
if (!status.expiration || status.expiration > now) {
if (now - status.createdAt < 4 * 60 * 60) {
statuses.push(status);
seenPubkeys.add(event.pubkey);
}
}
}
}
return statuses;
},
staleTime: 1000 * 30, // 30 seconds
refetchInterval: 1000 * 60, // 1 minute
});
}
// Hook to set current user's status
export function useSetUserStatus() {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
statusId,
customStatus,
linkedSpot,
expirationMinutes,
}: {
statusId?: SkatingStatusId;
customStatus?: string;
linkedSpot?: { pubkey: string; identifier: string };
expirationMinutes?: number;
}) => {
if (!user) throw new Error('Must be logged in');
// Build status content
let content = '';
if (statusId) {
const preset = SKATING_STATUSES.find(s => s.id === statusId);
if (preset) {
content = `${preset.emoji} ${preset.label}`;
}
}
if (customStatus) {
content = customStatus;
}
// Build tags
const tags: string[][] = [
['d', 'general'], // Required for NIP-38
];
// Add spot reference if provided
if (linkedSpot) {
tags.push(['a', `30818:${linkedSpot.pubkey}:${linkedSpot.identifier}`]);
}
// Add expiration if provided
if (expirationMinutes && expirationMinutes > 0) {
const expiration = Math.floor(Date.now() / 1000) + expirationMinutes * 60;
tags.push(['expiration', expiration.toString()]);
}
await publishEvent({
kind: USER_STATUS_KIND,
content,
tags,
});
return { content, linkedSpot };
},
onSuccess: () => {
// Invalidate status queries
queryClient.invalidateQueries({ queryKey: ['user-status'] });
queryClient.invalidateQueries({ queryKey: ['whos-skating'] });
},
});
}
// Hook to clear current user's status
export function useClearUserStatus() {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (!user) throw new Error('Must be logged in');
// Publish empty status to clear
await publishEvent({
kind: USER_STATUS_KIND,
content: '',
tags: [['d', 'general']],
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user-status'] });
queryClient.invalidateQueries({ queryKey: ['whos-skating'] });
},
});
}