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>
293 lines
8.7 KiB
TypeScript
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'] });
|
|
},
|
|
});
|
|
}
|