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>
186 lines
5.8 KiB
TypeScript
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] });
|
|
},
|
|
});
|
|
}
|