diff --git a/NIP.md b/NIP.md index a25fc56..bd6a570 100644 --- a/NIP.md +++ b/NIP.md @@ -1752,3 +1752,69 @@ custodian. > Only `noffer` (CLINK Offers) is supported in this release. `ndebit` (CLINK > Debits) and `nmanage` (CLINK Manage) are recognized by the scanner but not yet > actioned in-app. + +## Spot Chat (kind 24899) + +Spot Chat provides **ephemeral, geohash-anchored local chat channels**. Every +location on the map — and every skate spot — has a live public channel keyed to +its geohash, giving skaters a "here and now" side-groupchat for a place ("anyone +skating tonight?"). + +This is inspired by BitChat's location-channel concept but is **SkateSpots-native**: +it uses its own kind and tag conventions and is not intended to interoperate with +BitChat's channel format. + +### Event Format + +```json +{ + "kind": 24899, + "content": "", + "tags": [ + ["g", ""], + ["n", "skate"], + ["alt", "Spot Chat message in geohash "] + ] +} +``` + +### Behavior + +- **Ephemeral** (kind in the 20000–29999 range): relays are not expected to store + these events permanently, matching the transient nature of local chat. +- **Channel = geohash prefix.** The `g` tag holds a geohash truncated to a + precision level. Clients query `#g` for the channel key. Because a more precise + geohash *starts with* a coarser one, a block-level message naturally appears in + the enclosing neighborhood/city channel. + +| Precision | Geohash length | Approx. area | +|-----------|----------------|--------------| +| Block | 8 | ~40 m | +| Neighborhood | 6 | ~1.2 km | +| City | 5 | ~5 km | + +- Clients display roughly the last 24 hours of messages. +- Message content is limited to 500 characters. + +### IRC-style commands (client convention) + +These are a light, optional flavor layer handled entirely client-side; plain +typing always works. + +| Command | Effect | +|---------|--------| +| `/me ` | Content is published verbatim (prefixed with `/me `); clients render it as a third-person action line ("✳ alice does a kickflip"). | +| `/who` | Not published — the client shows the count of skaters who posted in the channel in the last 24h. | + +### Required Tags + +| Tag | Description | +|-----|-------------| +| `g` | Channel geohash (the truncated location key) | + +### Optional Tags + +| Tag | Description | +|-----|-------------| +| `n` | Namespace marker, always `skate` for SkateSpots channels | +| `alt` | NIP-31 human-readable description | diff --git a/src/components/SpotChat.tsx b/src/components/SpotChat.tsx new file mode 100644 index 0000000..73ebb1f --- /dev/null +++ b/src/components/SpotChat.tsx @@ -0,0 +1,264 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { formatDistanceToNowStrict } from 'date-fns'; +import { Link } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { MessageCircle, Send, Radio, Info } from 'lucide-react'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + ToggleGroup, + ToggleGroupItem, +} from '@/components/ui/toggle-group'; +import { LoginArea } from '@/components/auth/LoginArea'; +import { NoteContent } from '@/components/NoteContent'; + +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useToast } from '@/hooks/useToast'; +import { genUserName } from '@/lib/genUserName'; +import { + useSpotChat, + useSendSpotChat, + channelGeohash, + CHAT_PRECISION_LABEL, + type ChatPrecision, + type SpotChatMessage, +} from '@/hooks/useSpotChat'; +import { cn } from '@/lib/utils'; + +interface SpotChatProps { + /** The geohash to anchor this chat to (from a spot or the map center). */ + geohash: string; + /** Whether to show the Block/Neighborhood/City precision selector. */ + showPrecisionToggle?: boolean; + /** Initial precision. */ + defaultPrecision?: ChatPrecision; + className?: string; +} + +/** A single chat message row, resolving the author's profile. */ +function ChatMessageRow({ + message, + isOwn, +}: { + message: SpotChatMessage; + isOwn: boolean; +}) { + const author = useAuthor(message.pubkey); + const metadata = author.data?.metadata; + const name = metadata?.name ?? genUserName(message.pubkey); + const npub = useMemo(() => nip19.npubEncode(message.pubkey), [message.pubkey]); + + if (message.isAction) { + return ( +
+ ✳ + + {name} + {' '} + {message.content} +
+ ); + } + + return ( +
+ + + + + {name.slice(0, 2).toUpperCase()} + + + +
+
+ + {name} + + + {formatDistanceToNowStrict(message.createdAt * 1000, { addSuffix: true })} + +
+
+ +
+
+
+ ); +} + +export function SpotChat({ + geohash, + showPrecisionToggle = false, + defaultPrecision = 'block', + className, +}: SpotChatProps) { + const { user } = useCurrentUser(); + const { toast } = useToast(); + const [precision, setPrecision] = useState(defaultPrecision); + const [draft, setDraft] = useState(''); + const scrollRef = useRef(null); + + const { data: messages, isLoading } = useSpotChat(geohash, precision); + const { mutateAsync: sendMessage, isPending } = useSendSpotChat(); + + const channel = channelGeohash(geohash, precision); + + // Auto-scroll to newest message when the list grows. + const lastCount = useRef(0); + useEffect(() => { + const count = messages?.length ?? 0; + if (count !== lastCount.current) { + lastCount.current = count; + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); + } + }, [messages]); + + const handleSend = async (e: React.FormEvent) => { + e.preventDefault(); + const text = draft.trim(); + if (!text) return; + + // IRC-style /who — list recent participants (easter-egg power-user command). + if (text === '/who' || text.startsWith('/who ')) { + const recent = new Set((messages ?? []).map((m) => m.pubkey)); + toast({ + title: `${recent.size} skater${recent.size === 1 ? '' : 's'} active here`, + description: + recent.size > 0 + ? 'People who posted in this channel in the last 24h.' + : 'Be the first to say something.', + }); + setDraft(''); + return; + } + + try { + await sendMessage({ geohash, precision, content: text }); + setDraft(''); + } catch (err) { + toast({ + title: 'Message failed', + description: err instanceof Error ? err.message : 'Could not send message.', + variant: 'destructive', + }); + } + }; + + return ( + + +
+ + + Spot Chat + + + #{channel} + +
+ {showPrecisionToggle && ( +
+ v && setPrecision(v as ChatPrecision)} + className="justify-start" + size="sm" + variant="outline" + > + {(Object.keys(CHAT_PRECISION_LABEL) as ChatPrecision[]).map((p) => ( + + {CHAT_PRECISION_LABEL[p]} + + ))} + +
+ )} +
+ + +
+ {isLoading && ( +
+ Loading chat… +
+ )} + {!isLoading && (!messages || messages.length === 0) && ( +
+ +

+ No messages yet in this local channel. Say hi — "anyone skating tonight?" +

+
+ )} + {messages?.map((m) => ( + + ))} +
+ +
+ {user ? ( + <> +
+ setDraft(e.target.value)} + placeholder="Message this spot… (try /me or /who)" + maxLength={500} + className="flex-1" + /> + +
+

+ + Public & ephemeral — this is here-and-now chat, not stored forever. +

+ + ) : ( +
+

Log in to join the local chat.

+ +
+ )} +
+
+
+ ); +} diff --git a/src/hooks/&1 b/src/hooks/&1 deleted file mode 100644 index e69de29..0000000 diff --git a/src/hooks/useSpotChat.ts b/src/hooks/useSpotChat.ts new file mode 100644 index 0000000..ef53223 --- /dev/null +++ b/src/hooks/useSpotChat.ts @@ -0,0 +1,186 @@ +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 = { + block: 8, // ~40m + neighborhood: 6, // ~1.2km + city: 5, // ~5km +}; + +export const CHAT_PRECISION_LABEL: Record = { + 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(); + 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] }); + }, + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 646a5cc..b287acb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -54,6 +54,7 @@ export const SKATE_CREW_KIND = 35006; // Skate crews (community identity, map-an export const MARKETPLACE_ITEM_KIND = 30402; // NIP-99 Classified Listings export const SPOT_COLLECTION_KIND = 30003; // NIP-51 Bookmark sets for spot collections export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots +export const SPOT_CHAT_KIND = 24899; // Ephemeral geohash-anchored local chat ("Spot Chat"). SkateSpots-native. /** * Lab provider entity type. diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index 35d152e..0978ca7 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -75,8 +75,10 @@ import { Info, Layers, Calendar, + MessageCircle, } from 'lucide-react'; import { CameraCapture } from '@/components/CameraCapture'; +import { SpotChat } from '@/components/SpotChat'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; @@ -1343,6 +1345,43 @@ export default function MapPage() { {/* Spacer */}
+ {/* Local Chat - live geohash channel for wherever the map is centered */} + + + + + + + + + Local Chat + +

+ Live chat for wherever the map is centered. Pick how wide the channel reaches. +

+
+
+ +
+
+
+ {/* Add buttons - icons only on mobile */} {user ? ( <> diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx index c6a520e..5d4d7c6 100644 --- a/src/pages/SpotDetailPage.tsx +++ b/src/pages/SpotDetailPage.tsx @@ -12,6 +12,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Button } from '@/components/ui/button'; import { ZapButton } from '@/components/ZapButton'; import { CommentsSection } from '@/components/comments/CommentsSection'; +import { SpotChat } from '@/components/SpotChat'; import { useSkateSpot } from '@/hooks/useSkateSpots'; import { useAuthor } from '@/hooks/useAuthor'; import { useNostrPublish } from '@/hooks/useNostrPublish'; @@ -697,6 +698,11 @@ export default function SpotDetailPage() { )} + {/* Spot Chat - live local channel for this spot */} + {!isShop && spot.geohash && ( + + )} + {/* Comments Section */} {spot.event && }