SkateSpots/src/hooks/useSpotChat.ts
shakespeare.diy ba3921b15c Add Spot Chat: ephemeral geohash-anchored local chat channels
Inspired by BitChat's location-channel concept, reframed for skaters and
kept fully SkateSpots-native (own kind + tag conventions, no interop).

- New ephemeral kind 24899 tagged with a geohash `g` (documented in NIP.md)
- useSpotChat / useSendSpotChat hooks (polled, community-relay aware)
- SpotChat component: live message list, composer, author profiles,
  Block/Neighborhood/City precision toggle, and light IRC-style flavor
  (/me action messages, /who participant count)
- Spot detail pages now show a per-spot block-level channel
- Map gains a "Local Chat" sheet anchored to the map center with the
  precision selector

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 14:52:48 +07:00

186 lines
5.8 KiB
TypeScript

import { useNostr } from '@nostrify/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { SPOT_CHAT_KIND } from '@/lib/types';
/**
* Spot Chat — ephemeral, geohash-anchored local chat channels.
*
* Inspired by BitChat's location-channel concept, reframed for skaters and
* kept fully SkateSpots-native (our own kind + tag conventions, not interop).
*
* Each message is an ephemeral event (kind 24899) tagged with a geohash `g`.
* Anyone viewing the same geohash channel sees the same live conversation.
* Because the events are ephemeral, relays are not expected to store them
* long-term — matching the "here and now" vibe of a spot's side-groupchat.
*/
/** A single parsed chat message. */
export interface SpotChatMessage {
id: string;
pubkey: string;
content: string;
createdAt: number;
geohash: string;
/** IRC-style action message (`/me does a kickflip`). */
isAction: boolean;
}
/** Geohash precision presets, mirroring BitChat's Block / Neighborhood / City. */
export type ChatPrecision = 'block' | 'neighborhood' | 'city';
export const CHAT_PRECISION_LENGTH: Record<ChatPrecision, number> = {
block: 8, // ~40m
neighborhood: 6, // ~1.2km
city: 5, // ~5km
};
export const CHAT_PRECISION_LABEL: Record<ChatPrecision, string> = {
block: 'Block',
neighborhood: 'Neighborhood',
city: 'City',
};
/**
* Normalize a geohash to a given precision (channel key).
* Lower precision = larger area = more people in the channel.
*/
export function channelGeohash(geohash: string, precision: ChatPrecision): string {
return geohash.slice(0, CHAT_PRECISION_LENGTH[precision]).toLowerCase();
}
/** Parse a raw event into a chat message, or null if invalid. */
function parseSpotChatMessage(event: NostrEvent, channel: string): SpotChatMessage | null {
if (event.kind !== SPOT_CHAT_KIND) return null;
const g = event.tags.find(([name]) => name === 'g')?.[1]?.toLowerCase();
if (!g) return null;
// The message must belong to this channel (its geohash starts with the
// channel key, so a block-level message shows up in the neighborhood channel).
if (!g.startsWith(channel)) return null;
const content = event.content.trim();
if (!content || content.length > 500) return null;
const isAction = content.startsWith('/me ');
return {
id: event.id,
pubkey: event.pubkey,
content: isAction ? content.slice(4).trim() : content,
createdAt: event.created_at,
geohash: g,
isAction,
};
}
/**
* Subscribe to (poll) messages for a geohash channel.
* Polls every few seconds so the channel feels live without a persistent socket.
*/
export function useSpotChat(geohash: string | undefined, precision: ChatPrecision = 'block') {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const channel = geohash ? channelGeohash(geohash, precision) : '';
return useQuery({
queryKey: ['spot-chat', channel, getQueryKeySuffix()],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
// Only keep messages from roughly the last 24h — this is "here and now" chat.
const since = Math.floor(Date.now() / 1000) - 24 * 60 * 60;
const events = await querySkateContent(
[
{
kinds: [SPOT_CHAT_KIND],
'#g': [channel],
since,
limit: 200,
},
],
{ signal },
);
const messages: SpotChatMessage[] = [];
const seen = new Set<string>();
for (const event of events) {
if (seen.has(event.id)) continue;
seen.add(event.id);
const msg = parseSpotChatMessage(event, channel);
if (msg) messages.push(msg);
}
// Oldest first for chat display.
return messages.sort((a, b) => a.createdAt - b.createdAt);
},
enabled: !!channel,
staleTime: 5000,
refetchInterval: 8000, // Poll for new messages
});
}
/**
* Send a message to a geohash channel.
* Publishes to the community relay + the user's write relays so both
* SkateSpots users and the sender's own network see it.
*/
export function useSendSpotChat() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { getWriteRelays } = useSkateRelays();
const queryClient = useQueryClient();
const send = useCallback(
async (geohash: string, precision: ChatPrecision, content: string) => {
if (!user) throw new Error('You must be logged in to chat');
const channel = channelGeohash(geohash, precision);
const trimmed = content.trim().slice(0, 500);
if (!trimmed) return;
const event = await user.signer.signEvent({
kind: SPOT_CHAT_KIND,
content: trimmed,
created_at: Math.floor(Date.now() / 1000),
tags: [
['g', channel],
['n', 'skate'], // SkateSpots-native namespace marker
['alt', `Spot Chat message in geohash ${channel}`],
],
});
const relays = getWriteRelays();
await Promise.allSettled(
relays.map((url) => nostr.relay(url).event(event, { signal: AbortSignal.timeout(8000) })),
);
return event;
},
[user, nostr, getWriteRelays],
);
return useMutation({
mutationFn: async ({
geohash,
precision,
content,
}: {
geohash: string;
precision: ChatPrecision;
content: string;
}) => {
return send(geohash, precision, content);
},
onSuccess: (_data, variables) => {
const channel = channelGeohash(variables.geohash, variables.precision);
queryClient.invalidateQueries({ queryKey: ['spot-chat', channel] });
},
});
}