diff --git a/NIP.md b/NIP.md index 8bb9660c..8c1a6d87 100644 --- a/NIP.md +++ b/NIP.md @@ -12,8 +12,6 @@ | Kind | Name | Description | |-------|----------------------------|----------------------------------------------------------------| -| 20000 | Ephemeral Geo Chat (public) | Geo-anchored ephemeral chat message (kind 20000, public) | -| 20001 | Ephemeral Geo Heartbeat | Geo-anchored ephemeral presence heartbeat (kind 20001) | | 30223 | Campaign | Fundraising campaign with a list of on-chain Bitcoin recipients | | 30385 | Community Stats Snapshot | Pre-computed per-country / global community leaderboards | | 36639 | Activist Action | Country-scoped activist challenge with a sats bounty | @@ -614,66 +612,6 @@ After fetching, take the event with the highest `created_at` and parse it. Cache --- -## Kinds 20000 / 20001: Ephemeral Geo Chat - -### Summary - -Ephemeral events used to power realtime location-anchored chat on the world map. Both kinds live in NIP-01's ephemeral range (`20000 ≤ kind < 30000`), so relays MUST NOT persist them — they are short-lived signals only. - -- **Kind 20000** — public chat message. The `content` field carries the message text. -- **Kind 20001** — presence "heartbeat". Same tag schema, but `content` MAY be empty (the event simply broadcasts that someone is listening at the geohash). - -This kind range is shared with the wider Bitchat / geo-chat ecosystem; Agora interoperates with Pathos and other clients producing the same shape. - -### Tags - -| Tag | Required | Purpose | -|-----|----------|-------------------------------------------------------------------------| -| `g` | Yes | Geohash anchoring the message. Any precision is allowed; the dialog filters by exact-match `g` value, while the map clusters by full geohash. | -| `n` | No | Display nickname (≤ 16 chars after client-side truncation). Anonymous senders pick a random "ghost" handle; logged-in senders may use their account display name. | - -Events without a `g` tag MUST be ignored — they cannot be plotted. - -### Identity - -There are two valid signing paths: - -1. **Real identity** — a logged-in user signs with their existing Nostr key (typically via NIP-07 / NIP-46). Other clients can correlate the chat message with the author's public profile. -2. **Ephemeral "ghost" identity** — the client generates a fresh in-memory keypair (never persisted) and signs locally. Only the chosen `n` nickname is persisted (in `localStorage`) so the user keeps a stable handle even though the pubkey rotates per session. - -Clients SHOULD let logged-in users toggle between modes per-session and SHOULD default to the ghost mode when no account is available. - -### Relay Routing - -Because ephemeral events are not stored, latency dominates the experience. Clients SHOULD: - -1. Always include a baseline of widely-reachable relays (`wss://nos.lol`, `wss://relay.damus.io`, `wss://relay.primal.net`). -2. Augment with geo-located relays drawn from the [permissionlesstech/georelays](https://github.com/permissionlesstech/georelays) CSV catalogue (`relayUrl,latitude,longitude` per line). -3. For a specific geohash conversation, prefer the relays nearest the decoded coordinates (Haversine distance, top-N). -4. For the global map heatmap, take a rotating window (e.g. 8 relays, rotated every 5 minutes) so coverage spreads without saturating any single relay. - -### Time Window - -Clients SHOULD only surface events from the last hour (`since = now - 3600`). Older ephemeral events are uninteresting for "what's happening right now" and most relays will have dropped them anyway. - -### Example - -```json -{ - "kind": 20000, - "created_at": 1734567890, - "pubkey": "...", - "tags": [ - ["g", "u4pruydqqvj"], - ["n", "stealthranger4242"] - ], - "content": "anyone in berlin tonight?", - "sig": "..." -} -``` - ---- - ## Flat Communities Flat communities on Nostr, composed from existing event kinds. Communities have one membership badge, explicit moderators, and no recursive badge-chain authority. diff --git a/package-lock.json b/package-lock.json index 94dd3234..444537a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,7 +125,6 @@ "iso-3166": "^4.4.0", "leaflet": "^1.9.4", "lucide-react": "^1.8.0", - "ngeohash": "^0.6.3", "nostr-tools": "^2.13.0", "qrcode": "^1.5.4", "react": "^19.2.4", @@ -11724,15 +11723,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ngeohash": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz", - "integrity": "sha512-kltF0cOxgx1AbmVzKxYZaoB0aj7mOxZeHaerEtQV0YaqnkXNq26WWqMmJ6lTqShYxVRWZ/mwvvTrNeOwdslWiw==", - "license": "MIT", - "engines": { - "node": ">=v0.2.0" - } - }, "node_modules/node-abi": { "version": "3.89.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", diff --git a/package.json b/package.json index 97c43260..dea817b6 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,6 @@ "iso-3166": "^4.4.0", "leaflet": "^1.9.4", "lucide-react": "^1.8.0", - "ngeohash": "^0.6.3", "nostr-tools": "^2.13.0", "qrcode": "^1.5.4", "react": "^19.2.4", diff --git a/src/components/chat/ChatDialog.tsx b/src/components/chat/ChatDialog.tsx deleted file mode 100644 index a9edc9c0..00000000 --- a/src/components/chat/ChatDialog.tsx +++ /dev/null @@ -1,515 +0,0 @@ -import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Send, MapPin, ChevronDown, UserRound, Ghost, Edit2 } from 'lucide-react'; -import type { NostrMetadata } from '@nostrify/nostrify'; -import { formatDistanceToNow } from 'date-fns'; -import { - Dialog, DialogContent, DialogHeader, DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { - useChatSession, - type EphemeralEventMessage, -} from '@/hooks/useChatSession'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useToast } from '@/hooks/useToast'; -import { useAuthor } from '@/hooks/useAuthor'; -import { getDisplayName } from '@/lib/getDisplayName'; -import { COUNTRIES } from '@/lib/countries'; - -/** - * Convert an ISO 3166-1 alpha-2 code to its flag emoji via the Regional - * Indicator block. Falls back to the bare code when given garbage. - */ -function getCountryFlag(countryCode: string): string { - const code = countryCode.toUpperCase(); - if (code.length !== 2) return code; - return code - .split('') - .map((char) => String.fromCodePoint(127397 + char.charCodeAt(0))) - .join(''); -} - -/** - * Coarse mapping from geohash prefix → ISO 3166-1 alpha-2 country code. The - * geohash grid system roughly partitions the globe into letter cells, so the - * first 1-3 characters give us "good enough for chips" country attribution. - * - * This is only used for the cosmetic country chip in the chat header — it is - * not authoritative geography. - */ -const GEOHASH_TO_COUNTRY: Record = { - '9': 'US', '9q': 'US', '9r': 'US', '9x': 'US', '9w': 'US', '9t': 'US', - '9m': 'US', '9y': 'US', '9z': 'US', '9p': 'US', '9n': 'US', - '9g': 'MX', '9e': 'MX', '9d': 'MX', '9f': 'MX', '9c': 'MX', '9b': 'MX', - c: 'CA', b: 'US', d: 'US', dn: 'US', dp: 'US', dr: 'US', dq: 'US', - dj: 'US', dk: 'US', dm: 'US', f: 'CA', - u: 'EU', gc: 'GB', gf: 'GB', ey: 'NO', ez: 'NO', - u0: 'ES', u1: 'ES', u2: 'FR', u3: 'FR', u4: 'FR', - u6: 'DE', u7: 'DE', u8: 'DE', u9: 'DE', - uc: 'PL', ud: 'PL', ue: 'SE', ug: 'SE', - sr: 'IT', sp: 'IT', tf: 'CH', - '6': 'BR', '7': 'CL', - w: 'CN', x: 'CN', y: 'CN', xn: 'JP', xp: 'JP', - t: 'IN', tu: 'IN', tv: 'IN', tw: 'IN', v: 'RU', - s: 'SA', - k: 'ZA', e: 'NG', - q: 'AU', r: 'AU', -}; - -function getCountryFromGeohash( - geohash: string, -): { code: string; name: string; flag: string } | null { - if (!geohash) return null; - for (let len = Math.min(geohash.length, 3); len >= 1; len--) { - const prefix = geohash.substring(0, len).toLowerCase(); - const countryCode = GEOHASH_TO_COUNTRY[prefix]; - if (!countryCode) continue; - const info = COUNTRIES[countryCode]; - return { - code: countryCode, - name: info?.name || countryCode, - flag: getCountryFlag(countryCode), - }; - } - return null; -} - -function getPubkeySuffix(pubkey: string): string { - return pubkey.slice(-4); -} - -/** Stable colour-from-pubkey for nickname suffix accents. */ -function getPubkeyColor(pubkey: string): string { - const colors = [ - '#f87171', '#fb923c', '#fbbf24', '#a3e635', '#4ade80', - '#34d399', '#2dd4bf', '#22d3ee', '#38bdf8', '#60a5fa', - '#818cf8', '#a78bfa', '#c084fc', '#e879f9', '#f472b6', - ]; - const hash = pubkey.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); - return colors[hash % colors.length]; -} - -function truncateNickname(nickname: string | undefined, maxLength = 16): string { - if (!nickname) return 'anonymous'; - const cleaned = nickname.trim(); - if (!cleaned) return 'anonymous'; - if (cleaned.length <= maxLength) return cleaned; - return cleaned.substring(0, maxLength - 1) + '...'; -} - -function MessageAuthor({ pubkey, nickname }: { pubkey: string; nickname?: string }) { - const author = useAuthor(pubkey); - const metadata: NostrMetadata | undefined = author.data?.metadata; - const displayName = - metadata?.display_name || metadata?.name || nickname || getDisplayName(undefined, pubkey); - - return ( -
- {metadata?.picture && ( - - - - {displayName.slice(0, 2).toUpperCase()} - - - )} - - {truncateNickname(displayName)} - - #{getPubkeySuffix(pubkey)} - - -
- ); -} - -interface ChatDialogProps { - isOpen: boolean; - onClose: () => void; - geohash: string; - initialEvents?: EphemeralEventMessage[]; -} - -/** - * Per-geohash chat surface for ephemeral kind 20000 events. Opens from the - * world map's ephemeral-marker popovers. - * - * Logged-in users can toggle between their real Nostr identity and an - * ephemeral "ghost" handle (signed with an in-memory keypair, nickname - * persisted in localStorage). Anonymous visitors get the ghost path only. - */ -export function ChatDialog({ isOpen, onClose, geohash, initialEvents = [] }: ChatDialogProps) { - const { t } = useTranslation(); - const [message, setMessage] = useState(''); - const [isEditingNickname, setIsEditingNickname] = useState(false); - const [newNickname, setNewNickname] = useState(''); - const scrollRef = useRef(null); - const [isAtBottom, setIsAtBottom] = useState(true); - const [showScrollButton, setShowScrollButton] = useState(false); - const scrollTimeoutRef = useRef | null>(null); - const previousMessageCountRef = useRef(0); - - const { user, metadata } = useCurrentUser(); - const { toast } = useToast(); - - const countryInfo = useMemo(() => getCountryFromGeohash(geohash), [geohash]); - - const handleNewMessage = useCallback( - (incomingMessage: EphemeralEventMessage) => { - const currentUserPubkey = user?.pubkey; - if ( - incomingMessage.event.pubkey !== currentUserPubkey && - incomingMessage.message.trim() - ) { - const senderName = incomingMessage.nickname || 'Anonymous'; - const preview = - incomingMessage.message.slice(0, 50) + - (incomingMessage.message.length > 50 ? '...' : ''); - toast({ - title: `New message from ${senderName}`, - description: preview, - }); - } - }, - [user?.pubkey, toast], - ); - - const { - session, - sendMessage: sendChatMessage, - isLoading, - messages: chatMessages, - updateNickname, - identityMode, - setIdentityMode, - canToggleIdentity, - connectionStatus, - } = useChatSession(geohash, initialEvents, handleNewMessage); - - const isMessagesLoading = isLoading && chatMessages.length === 0; - - const checkIsAtBottom = useCallback(() => { - if (!scrollRef.current) return true; - const viewport = scrollRef.current.querySelector('[data-radix-scroll-area-viewport]'); - if (!viewport) return true; - const { scrollTop, scrollHeight, clientHeight } = viewport as HTMLElement; - const isBottom = scrollHeight - scrollTop - clientHeight <= 50; - setIsAtBottom(isBottom); - return isBottom; - }, []); - - useEffect(() => { - if (!isOpen) return; - previousMessageCountRef.current = 0; - - const handleScroll = () => { - checkIsAtBottom(); - setShowScrollButton(false); - }; - - document.addEventListener('scroll', handleScroll, { passive: true, capture: true }); - checkIsAtBottom(); - - return () => { - if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); - document.removeEventListener('scroll', handleScroll, { capture: true }); - }; - }, [isOpen, checkIsAtBottom]); - - // Auto-scroll to bottom when new messages arrive, but only if the user is - // already at the bottom — otherwise reveal the floating "scroll to latest" - // button so we don't yank them away from history they're reading. - useEffect(() => { - if (chatMessages.length === 0) return; - const wasAtBottom = checkIsAtBottom(); - const newMessageCount = chatMessages.length - previousMessageCountRef.current; - previousMessageCountRef.current = chatMessages.length; - - if (wasAtBottom && scrollRef.current) { - if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); - scrollTimeoutRef.current = setTimeout(() => { - const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]'); - if (viewport) { - (viewport as HTMLElement).scrollTop = (viewport as HTMLElement).scrollHeight; - } - }, newMessageCount > 1 ? 100 : 0); - } else if (!wasAtBottom) { - setShowScrollButton(true); - } - - return () => { - if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); - }; - }, [chatMessages, checkIsAtBottom]); - - useEffect(() => { - if (isAtBottom) setShowScrollButton(false); - }, [isAtBottom]); - - const scrollToBottom = useCallback(() => { - const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]'); - if (!viewport) return; - (viewport as HTMLElement).scrollTop = (viewport as HTMLElement).scrollHeight; - setShowScrollButton(false); - setIsAtBottom(true); - }, []); - - const handleStartEditNickname = () => { - if (session && identityMode === 'ephemeral') { - setNewNickname(session.nickname); - setIsEditingNickname(true); - } - }; - - const handleSaveNickname = () => { - if (newNickname.trim() && session) { - updateNickname(newNickname.trim()); - setIsEditingNickname(false); - } - }; - - const handleCancelEditNickname = () => { - setIsEditingNickname(false); - setNewNickname(''); - }; - - const handleSendMessage = async () => { - if (!message.trim() || !session || isLoading) return; - try { - const success = await sendChatMessage(message.trim()); - if (success) { - setMessage(''); - } else { - toast({ - title: t('chat.sendFailed', 'Failed to send message'), - description: t('chat.tryAgain', 'Please try again'), - variant: 'destructive', - }); - } - } catch { - toast({ - title: t('chat.error', 'Error'), - description: t('chat.sendFailed', 'Failed to send message'), - variant: 'destructive', - }); - } - }; - - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSendMessage(); - } - }; - - return ( - - - - -
- -
-
- {t('chat.geoChat', 'Geo chat')} - - -
-
- {countryInfo && ( - - {countryInfo.flag} - - )} -
- - {geohash} -
-
- -
- {canToggleIdentity ? ( -
- - -
- ) : ( -
- - {isEditingNickname ? ( - setNewNickname(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleSaveNickname(); - if (e.key === 'Escape') handleCancelEditNickname(); - }} - onBlur={handleSaveNickname} - placeholder={t('chat.newNickname', 'Nickname...')} - className="h-5 text-xs w-24 px-1" - autoFocus - /> - ) : ( - - )} -
- )} -
-
- - -
- -
- {(() => { - const messagesWithContent = chatMessages.filter( - (msg) => msg.message && msg.message.trim().length > 0, - ); - - if (isMessagesLoading) { - return ( -
-
{t('chat.connecting', 'Connecting...')}
-
- ); - } - - if (messagesWithContent.length === 0) { - return ( -
- -

{t('chat.noMessages', 'No messages yet. Be the first to say something!')}

-
- ); - } - - return messagesWithContent.map((msg) => { - const isOwn = - session?.pubkey === msg.event.pubkey || - (identityMode === 'real' && user?.pubkey === msg.event.pubkey); - const timestamp = formatDistanceToNow(msg.event.created_at * 1000, { addSuffix: true }); - - return ( -
-
- {!isOwn && ( -
- -
- )} -

{msg.message}

-
- {timestamp} -
- ); - }); - })()} -
- - {showScrollButton && ( - - )} -
- -
-
- setMessage(e.target.value)} - onKeyDown={handleKeyPress} - placeholder={ - session - ? t('chat.typeMessage', 'Type your message...') - : t('chat.connecting', 'Connecting...') - } - disabled={!session || isLoading} - className="flex-1" - /> - -
-
-
- -
- ); -} diff --git a/src/components/world/EphemeralMarkersLayer.tsx b/src/components/world/EphemeralMarkersLayer.tsx deleted file mode 100644 index 55fd5ce4..00000000 --- a/src/components/world/EphemeralMarkersLayer.tsx +++ /dev/null @@ -1,504 +0,0 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; -import { useMap } from 'react-leaflet'; -import { decode } from 'ngeohash'; -import { scaleSqrt } from 'd3-scale'; -import type { Map as LeafletMap } from 'leaflet'; -import { MessageCircle, Users } from 'lucide-react'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Badge } from '@/components/ui/badge'; -import { COUNTRIES } from '@/lib/countries'; -import { cn } from '@/lib/utils'; -import type { EphemeralEventData } from '@/hooks/useEphemeralEvents'; - -// ── Types ─────────────────────────────────────────────────────────────────── - -/** One physical location (full geohash) carrying recent ephemeral events. */ -interface GeohashLocation { - geohash: string; - /** `[lng, lat]` — note the order; Leaflet uses `[lat, lng]`. */ - coordinates: [number, number]; - events: EphemeralEventData[]; -} - -/** Pixel-clustered set of geohash locations rendered as a single bubble. */ -interface ClusteredEphemeralMarker { - id: string; - coordinates: [number, number]; - locations: GeohashLocation[]; - totalEvents: number; -} - -// ── Sizing + clustering tuning ────────────────────────────────────────────── - -const ephemeralSizeScale = scaleSqrt().domain([1, 50]).range([18, 36]); -const ephemeralClusterScale = scaleSqrt().domain([2, 200]).range([24, 48]); -const CLUSTER_RADIUS_PX = 50; -const DISABLE_CLUSTERING_AT_ZOOM = 6; - -// ── Country chip helpers ──────────────────────────────────────────────────── - -/** Coarse geohash-prefix → ISO 3166 alpha-2 mapping, only used for cosmetic - * flag/name chips inside popovers. Not authoritative geography. */ -const GEOHASH_TO_COUNTRY: Record = { - '9': 'US', '9q': 'US', '9r': 'US', '9x': 'US', '9w': 'US', '9t': 'US', - '9m': 'US', '9y': 'US', '9z': 'US', '9p': 'US', '9n': 'US', - '9g': 'MX', '9e': 'MX', '9d': 'MX', '9f': 'MX', '9c': 'MX', '9b': 'MX', - c: 'CA', b: 'US', d: 'US', dn: 'US', dp: 'US', dr: 'US', dq: 'US', - dj: 'US', dk: 'US', dm: 'US', f: 'CA', - u: 'EU', gc: 'GB', gf: 'GB', ey: 'NO', ez: 'NO', - u0: 'ES', u1: 'ES', u2: 'FR', u3: 'FR', u4: 'FR', - u6: 'DE', u7: 'DE', u8: 'DE', u9: 'DE', - uc: 'PL', ud: 'PL', ue: 'SE', ug: 'SE', - sr: 'IT', sp: 'IT', tf: 'CH', - '6': 'BR', '7': 'CL', - w: 'CN', x: 'CN', y: 'CN', xn: 'JP', xp: 'JP', - t: 'IN', tu: 'IN', tv: 'IN', tw: 'IN', v: 'RU', - s: 'SA', - k: 'ZA', e: 'NG', - q: 'AU', r: 'AU', -}; - -function getCountryFlag(countryCode: string): string { - const code = countryCode.toUpperCase(); - if (code.length !== 2) return code; - return code - .split('') - .map((c) => String.fromCodePoint(127397 + c.charCodeAt(0))) - .join(''); -} - -function getCountryFromGeohash(geohash: string) { - for (let len = Math.min(geohash.length, 3); len >= 1; len--) { - const prefix = geohash.substring(0, len).toLowerCase(); - const code = GEOHASH_TO_COUNTRY[prefix]; - if (!code) continue; - return { - code, - name: COUNTRIES[code]?.name || code, - flag: getCountryFlag(code), - }; - } - return null; -} - -function truncateGeohash(geohash: string, length = 6) { - return geohash.length > length ? geohash.substring(0, length) : geohash; -} - -// ── Clustering ────────────────────────────────────────────────────────────── - -function clusterEphemeralLocations( - locations: GeohashLocation[], - map: LeafletMap, - radiusPx: number, -): ClusteredEphemeralMarker[] { - if (locations.length === 0) return []; - - const withPixels = locations.map((loc) => ({ - loc, - pixel: map.latLngToContainerPoint([loc.coordinates[1], loc.coordinates[0]]), - })); - - const out: ClusteredEphemeralMarker[] = []; - const assigned = new Set(); - - for (let i = 0; i < withPixels.length; i++) { - if (assigned.has(i)) continue; - const members: GeohashLocation[] = [withPixels[i].loc]; - assigned.add(i); - for (let j = i + 1; j < withPixels.length; j++) { - if (assigned.has(j)) continue; - const dx = withPixels[i].pixel.x - withPixels[j].pixel.x; - const dy = withPixels[i].pixel.y - withPixels[j].pixel.y; - if (Math.sqrt(dx * dx + dy * dy) <= radiusPx) { - members.push(withPixels[j].loc); - assigned.add(j); - } - } - let totalLng = 0; - let totalLat = 0; - let totalEvents = 0; - for (const m of members) { - const w = Math.max(1, m.events.length); - totalLng += m.coordinates[0] * w; - totalLat += m.coordinates[1] * w; - totalEvents += m.events.length; - } - const totalWeight = members.reduce( - (sum, m) => sum + Math.max(1, m.events.length), - 0, - ); - out.push({ - id: members.map((m) => m.geohash).sort().join('|'), - coordinates: [totalLng / totalWeight, totalLat / totalWeight], - locations: members, - totalEvents, - }); - } - return out; -} - -// ── Popover ───────────────────────────────────────────────────────────────── - -function EphemeralPopover({ - locations, - onOpenChat, - isMobile, -}: { - locations: GeohashLocation[]; - onOpenChat: (geohash: string) => void; - isMobile?: boolean; -}) { - const rows = useMemo( - () => - locations - .map((loc) => { - const messagesWithContent = loc.events.filter( - (e) => e.message && e.message.trim().length > 0, - ); - const userCount = new Set(loc.events.map((e) => e.event.pubkey)).size; - // Newest message first. - const latest = [...messagesWithContent].sort( - (a, b) => b.event.created_at - a.event.created_at, - )[0]; - return { - geohash: loc.geohash, - country: getCountryFromGeohash(loc.geohash), - messageCount: messagesWithContent.length, - userCount, - latest, - }; - }) - .filter((r) => r.messageCount > 0) - .sort((a, b) => b.messageCount - a.messageCount), - [locations], - ); - - const heartbeatCount = locations.reduce( - (sum, loc) => - sum + loc.events.filter((e) => !e.message || !e.message.trim()).length, - 0, - ); - - return ( - - - - - Live geo chat - -

- {rows.length} {rows.length === 1 ? 'location' : 'locations'} - {heartbeatCount > 0 && ` · ${heartbeatCount} active`} -

-
- - {rows.length === 0 ? ( -

- {heartbeatCount} {heartbeatCount === 1 ? 'user is' : 'users are'} listening nearby — be the first to say something. -

- ) : ( - -
- {rows.map(({ geohash, country, messageCount, userCount, latest }) => ( - - ))} -
-
- )} -
-
- ); -} - -// ── Marker bubble ─────────────────────────────────────────────────────────── - -function EphemeralBubble({ - size, - count, - isCluster, -}: { - size: number; - count: number; - isCluster?: boolean; -}) { - const opacityMultiplier = count < 3 ? 0.6 : 1; - return ( -
-
-
-
-
- - {count} - -
-
- ); -} - -// ── Layer ─────────────────────────────────────────────────────────────────── - -interface EphemeralMarkersLayerProps { - events: EphemeralEventData[]; - onOpenChat: (geohash: string) => void; - isMobile?: boolean; -} - -/** - * DOM overlay that renders ephemeral chat activity (kinds 20000/20001) as - * sky-blue glowing bubbles distinct from the orange community-activity - * bubbles. Clicking a bubble opens a popover with one row per active - * geohash, each linking into the realtime chat dialog. - * - * Mounted inside ``. Listens to Leaflet move/zoom events and - * recomputes pixel positions so bubbles follow the map. - */ -export function EphemeralMarkersLayer({ - events, - onOpenChat, - isMobile, -}: EphemeralMarkersLayerProps) { - const map = useMap(); - const [zoom, setZoom] = useState(map.getZoom()); - const [, force] = useState({}); - - useEffect(() => { - const onMove = () => force({}); - const onZoom = () => { - setZoom(map.getZoom()); - force({}); - }; - map.on('move', onMove); - map.on('zoom', onZoom); - map.on('moveend', onMove); - map.on('zoomend', onZoom); - return () => { - map.off('move', onMove); - map.off('zoom', onZoom); - map.off('moveend', onMove); - map.off('zoomend', onZoom); - }; - }, [map]); - - // Group raw events by full geohash → one location each. - const locations = useMemo(() => { - const grouped = new Map(); - for (const e of events) { - if (!e.geohash) continue; - const arr = grouped.get(e.geohash); - if (arr) arr.push(e); - else grouped.set(e.geohash, [e]); - } - const result: GeohashLocation[] = []; - for (const [geohash, evts] of grouped.entries()) { - try { - const { latitude, longitude } = decode(geohash); - result.push({ - geohash, - coordinates: [longitude, latitude], - events: evts, - }); - } catch { - // Skip malformed geohashes silently — Pathos producers occasionally - // ship bad strings. - } - } - return result; - }, [events]); - - const getPixel = useCallback( - (lng: number, lat: number) => { - try { - const p = map.latLngToContainerPoint([lat, lng]); - return { x: p.x, y: p.y }; - } catch { - return { x: 0, y: 0 }; - } - }, - [map], - ); - - const shouldCluster = zoom < DISABLE_CLUSTERING_AT_ZOOM; - - const clusters = useMemo(() => { - if (!shouldCluster || locations.length <= 1) return null; - return clusterEphemeralLocations(locations, map, CLUSTER_RADIUS_PX); - // Re-cluster on zoom changes too, since pixel positions change. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [locations, map, shouldCluster, zoom]); - - return ( -
- {shouldCluster && clusters - ? clusters.map((cluster) => { - const pos = getPixel(cluster.coordinates[0], cluster.coordinates[1]); - if (pos.x === 0 && pos.y === 0) return null; - const isCluster = cluster.locations.length > 1; - const size = isCluster - ? ephemeralClusterScale(Math.min(cluster.totalEvents, 200)) - : ephemeralSizeScale(Math.min(cluster.totalEvents, 50)); - return ( -
- - - - - - - - -
- ); - }) - : locations.map((loc) => { - const pos = getPixel(loc.coordinates[0], loc.coordinates[1]); - if (pos.x === 0 && pos.y === 0) return null; - const size = ephemeralSizeScale(Math.min(loc.events.length, 50)); - return ( -
- - - - - - - - -
- ); - })} -
- ); -} diff --git a/src/components/world/WorldMap.tsx b/src/components/world/WorldMap.tsx index 6a7df86d..5cc3a63a 100644 --- a/src/components/world/WorldMap.tsx +++ b/src/components/world/WorldMap.tsx @@ -16,8 +16,6 @@ import { import { cn } from '@/lib/utils'; import { useIsMobile } from '@/hooks/useIsMobile'; import { CountryActivityPopover } from './CountryActivityPopover'; -import { EphemeralMarkersLayer } from './EphemeralMarkersLayer'; -import type { EphemeralEventData } from '@/hooks/useEphemeralEvents'; // CARTO Positron tiles — clean, label-rich basemap with a dark variant. const POSITRON_LIGHT_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'; @@ -484,12 +482,6 @@ interface WorldMapProps { activities: Map; /** Map of country code → top trending hashtag (no leading `#`). */ topHashtags?: Map; - /** Recent ephemeral chat events (kinds 20000/20001) to plot as a sky-blue - * layer on top of the orange community-activity bubbles. */ - ephemeralEvents?: EphemeralEventData[]; - /** Called when a chat bubble row is clicked. The page is responsible for - * mounting the actual chat dialog. */ - onOpenChat?: (geohash: string) => void; /** Triggered when the underlying tile layer finishes initial load. */ onMapReady?: () => void; } @@ -501,14 +493,10 @@ interface WorldMapProps { * `/i/iso3166:XX` country feed. At low zoom levels nearby bubbles fold into * clusters with a roll-up popover. * - * Optionally overlays a sky-blue ephemeral chat layer (kinds 20000/20001) - * with a `onOpenChat` callback used to launch the per-geohash realtime chat. */ export function WorldMap({ activities, topHashtags, - ephemeralEvents, - onOpenChat, onMapReady, }: WorldMapProps) { const isMobile = useIsMobile(); @@ -573,13 +561,6 @@ export function WorldMap({ /> - {ephemeralEvents && ephemeralEvents.length > 0 && onOpenChat && ( - - )}
); diff --git a/src/hooks/useChatSession.ts b/src/hooks/useChatSession.ts deleted file mode 100644 index 46cd1771..00000000 --- a/src/hooks/useChatSession.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import { useNostr } from '@nostrify/react'; -import { finalizeEvent } from 'nostr-tools'; -import type { NostrEvent } from '@nostrify/nostrify'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { - useEphemeralIdentity, - type EphemeralIdentity, -} from '@/hooks/useEphemeralIdentity'; -import { fetchGeoRelays } from '@/lib/georelays'; - -/** - * Per-geohash chat session built on ephemeral kind 20000 events. Handles - * identity (real signer ↔ ephemeral keypair), relay routing, real-time - * subscription, optimistic local cache, and message send. - * - * Messages live in a TanStack Query cache keyed `['chat-messages', geohash]` - * so multiple components viewing the same geohash share state without prop - * drilling. - */ -export interface EphemeralEventMessage { - event: NostrEvent; - geohash?: string; - nickname?: string; - message: string; -} - -type IdentityMode = 'real' | 'ephemeral'; -type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; - -interface UseChatSessionReturn { - session: EphemeralIdentity | { privateKey: Uint8Array; pubkey: string; npub: string; nickname: string } | null; - isLoading: boolean; - messages: EphemeralEventMessage[]; - sendMessage: (content: string) => Promise; - updateNickname: (nickname: string) => void; - identityMode: IdentityMode; - setIdentityMode: (mode: IdentityMode) => void; - canToggleIdentity: boolean; - connectionStatus: ConnectionStatus; -} - -const ONE_HOUR_SECONDS = 60 * 60; -const NICKNAME_MAX_LENGTH = 16; - -const DEFAULT_CHAT_RELAYS = [ - 'wss://nos.lol', - 'wss://relay.damus.io', - 'wss://relay.primal.net', -]; - -function truncateNickname(nickname: string | undefined, maxLength = NICKNAME_MAX_LENGTH): string { - if (!nickname) return 'anonymous'; - const cleaned = nickname.trim(); - if (!cleaned) return 'anonymous'; - if (cleaned.length <= maxLength) return cleaned; - return cleaned.substring(0, maxLength - 1) + '...'; -} - -function isCompleteRelayFailure(error: unknown): boolean { - if (!(error instanceof Error)) return false; - const message = error.message.toLowerCase(); - return ( - message.includes('all relays failed') || - message.includes('no relays') || - message.includes('connection refused') - ); -} - -export function useChatSession( - geohash: string, - initialEvents: EphemeralEventMessage[] = [], - onNewMessage?: (message: EphemeralEventMessage) => void, -): UseChatSessionReturn { - const { nostr } = useNostr(); - const queryClient = useQueryClient(); - const { user } = useCurrentUser(); - const ephemeralIdentity = useEphemeralIdentity(); - - const [session, setSession] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [identityMode, setIdentityModeState] = useState('ephemeral'); - const [hasSeededInitialEvents, setHasSeededInitialEvents] = useState(false); - const [connectionStatus, setConnectionStatus] = useState('connecting'); - const [geoRelaysCache, setGeoRelaysCache] = useState([]); - const [messages, setMessages] = useState(initialEvents); - - const canToggleIdentity = !!user?.pubkey; - - const setIdentityMode = useCallback( - (mode: IdentityMode) => { - if (mode === 'real' && !user) return; - setIdentityModeState(mode); - }, - [user], - ); - - // ── Establish session for the chosen identity mode. ────────────────────── - // Destructure stable members rather than depending on the whole hook - // result — even with the memo on useEphemeralIdentity, depending on the - // wrapper object risks spurious effect re-runs that cascade into Radix - // ref callbacks and trip the "Maximum update depth" guard. - const { identity: ghostIdentity, generateIdentity: ghostGenerate } = ephemeralIdentity; - useEffect(() => { - if (!geohash) return; - if (identityMode === 'real' && user?.pubkey) { - setSession({ - privateKey: new Uint8Array(), - pubkey: user.pubkey, - npub: '', - nickname: 'user', - }); - } else { - setSession(ghostIdentity ?? ghostGenerate()); - } - setIsLoading(false); - }, [geohash, user, identityMode, ghostIdentity, ghostGenerate]); - - // ── Pull a rotating slice of geo relays so chat can spread reads/writes - // across nearby relays without us having to know coordinates here. ──── - useEffect(() => { - let cancelled = false; - fetchGeoRelays() - .then((relays) => { - if (cancelled || relays.length === 0) return; - const rotationIndex = Math.floor(Date.now() / 300_000) % Math.max(1, relays.length); - setGeoRelaysCache(relays.slice(rotationIndex, rotationIndex + 8).map((r) => r.url)); - }) - .catch((error) => console.warn('Failed to fetch geo relays for chat:', error)); - return () => { - cancelled = true; - }; - }, []); - - const getChatRelays = useCallback((): string[] => { - const set = new Set(DEFAULT_CHAT_RELAYS); - geoRelaysCache.forEach((r) => set.add(r)); - return Array.from(set); - }, [geoRelaysCache]); - - // ── Seed the cache once with whatever the map preview already had so the - // dialog opens populated instead of empty-flashing. ──────────────────── - useEffect(() => { - if (!geohash || hasSeededInitialEvents || initialEvents.length === 0) return; - const sorted = [...initialEvents].sort((a, b) => a.event.created_at - b.event.created_at); - queryClient.setQueryData(['chat-messages', geohash], sorted); - setHasSeededInitialEvents(true); - }, [geohash, initialEvents, hasSeededInitialEvents, queryClient]); - - // ── Fetch + subscribe for this geohash. ────────────────────────────────── - useEffect(() => { - if (!geohash || !nostr) return; - - const chatRelays = getChatRelays(); - const chatKey = ['chat-messages', geohash]; - const abortController = new AbortController(); - let isSubscribed = true; - - const fetchLatestMessages = async () => { - try { - setConnectionStatus('connecting'); - const signal = AbortSignal.any([ - abortController.signal, - AbortSignal.timeout(45_000), - ]); - - const existing = queryClient.getQueryData(chatKey) || []; - const existingIds = new Set(existing.map((m) => m.event.id)); - const since = Math.floor(Date.now() / 1000) - ONE_HOUR_SECONDS; - - const events = await nostr - .group(chatRelays) - .query([{ kinds: [20000], since, limit: 500 }], { signal }); - - const fetched = events - .filter((event) => event.tags.find(([n]) => n === 'g')?.[1] === geohash) - .map((event) => ({ - event, - geohash: event.tags.find(([n]) => n === 'g')?.[1], - nickname: truncateNickname(event.tags.find(([n]) => n === 'n')?.[1]), - message: event.content, - })); - - const merged = [ - ...existing, - ...fetched.filter((m) => !existingIds.has(m.event.id)), - ].sort((a, b) => a.event.created_at - b.event.created_at); - - if (isSubscribed) { - queryClient.setQueryData(chatKey, merged); - setConnectionStatus('connected'); - } - } catch (error) { - console.warn('Failed to fetch chat messages:', error); - if (isSubscribed) setConnectionStatus('error'); - } - }; - - const subscribeToMessages = async () => { - try { - const since = Math.floor(Date.now() / 1000); - const subscription = nostr - .group(chatRelays) - .req([{ kinds: [20000], since, limit: 100 }], { signal: abortController.signal }); - - for await (const msg of subscription) { - if (!isSubscribed) break; - - if (msg[0] === 'EVENT') { - const event = msg[2]; - const eventGeohash = event.tags.find(([n]) => n === 'g')?.[1]; - if (eventGeohash !== geohash) continue; - - const newMessage: EphemeralEventMessage = { - event, - geohash: eventGeohash, - nickname: truncateNickname(event.tags.find(([n]) => n === 'n')?.[1]), - message: event.content, - }; - - const current = queryClient.getQueryData(chatKey) || []; - if (current.some((m) => m.event.id === event.id)) continue; - - queryClient.setQueryData(chatKey, [...current, newMessage]); - onNewMessage?.(newMessage); - } else if (msg[0] === 'EOSE') { - if (isSubscribed) setConnectionStatus('connected'); - } else if (msg[0] === 'CLOSED') { - if (isSubscribed) setConnectionStatus('disconnected'); - break; - } - } - } catch (error) { - if (error instanceof Error && error.name !== 'AbortError') { - console.error('Chat subscription failed:', error); - } - } - }; - - fetchLatestMessages(); - subscribeToMessages(); - - return () => { - isSubscribed = false; - abortController.abort(); - }; - }, [geohash, nostr, queryClient, getChatRelays, onNewMessage]); - - // ── Reset local message buffer when the active geohash changes. ────────── - useEffect(() => { - setHasSeededInitialEvents(false); - setMessages(initialEvents); - // We intentionally only reset on geohash change; new initialEvents - // arrays for the same geohash should not blow away live state. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [geohash]); - - // ── Mirror the cache into local state so consumers can re-render. ──────── - useEffect(() => { - if (!geohash) return; - const chatKey = ['chat-messages', geohash]; - const cached = queryClient.getQueryData(chatKey); - setMessages(cached ?? initialEvents); - - const unsubscribe = queryClient.getQueryCache().subscribe((event) => { - if (event.query.queryKey[0] === 'chat-messages' && event.query.queryKey[1] === geohash) { - const data = queryClient.getQueryData(chatKey); - if (data) setMessages(data); - } - }); - - return () => unsubscribe(); - }, [geohash, queryClient, initialEvents]); - - const updateNickname = useCallback( - (newNickname: string) => { - if (!session || identityMode !== 'ephemeral') return; - setSession((prev) => (prev ? { ...prev, nickname: newNickname } : prev)); - ephemeralIdentity.updateNickname(newNickname); - }, - [session, identityMode, ephemeralIdentity], - ); - - const sendMessage = useCallback( - async (content: string): Promise => { - if (!session || !geohash || !nostr) return false; - - try { - const baseEvent = { - kind: 20000, - pubkey: session.pubkey, - created_at: Math.floor(Date.now() / 1000), - tags: [ - ['g', geohash], - ['n', session.nickname], - ], - content, - }; - - let eventToPublish: NostrEvent; - if (identityMode === 'real' && user?.signer) { - eventToPublish = await user.signer.signEvent(baseEvent); - } else if (session.privateKey.length > 0) { - eventToPublish = finalizeEvent(baseEvent, session.privateKey); - } else { - throw new Error('No valid signing method available'); - } - - const chatRelays = getChatRelays(); - const optimistic: EphemeralEventMessage = { - event: eventToPublish, - geohash, - nickname: session.nickname, - message: content, - }; - - const persistOptimistic = () => { - const chatKey = ['chat-messages', geohash]; - const existing = queryClient.getQueryData(chatKey) || []; - if (existing.some((m) => m.event.id === optimistic.event.id)) return; - queryClient.setQueryData(chatKey, [...existing, optimistic]); - }; - - try { - await nostr.group(chatRelays).event(eventToPublish, { - signal: AbortSignal.timeout(10_000), - }); - persistOptimistic(); - return true; - } catch (publishError) { - if (isCompleteRelayFailure(publishError)) { - console.error('All chat relays failed:', publishError); - return false; - } - // Partial failure — at least one relay accepted, treat as success. - console.warn('Some chat relays failed; treating publish as success:', publishError); - persistOptimistic(); - return true; - } - } catch (error) { - console.error('Failed to send chat message:', error); - return false; - } - }, - [session, geohash, nostr, queryClient, user, getChatRelays, identityMode], - ); - - return { - session, - isLoading, - messages, - sendMessage, - updateNickname, - identityMode, - setIdentityMode, - canToggleIdentity, - connectionStatus, - }; -} diff --git a/src/hooks/useEphemeralEvents.ts b/src/hooks/useEphemeralEvents.ts deleted file mode 100644 index ee3cad60..00000000 --- a/src/hooks/useEphemeralEvents.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { useNostr } from '@nostrify/react'; -import type { NostrEvent } from '@nostrify/nostrify'; -import { decode } from 'ngeohash'; -import { fetchGeoRelays, type GeoRelay } from '@/lib/georelays'; -import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts'; - -/** - * Map-ready ephemeral chat event (kind 20000 — public, or 20001 — private). - * - * See NIP.md → Kinds 20000/20001 for the tag schema. The `g` tag carries the - * geohash that anchors the message on the world map. - */ -export interface EphemeralEventData { - event: NostrEvent; - geohash?: string; - nickname?: string; - message: string; -} - -const EPHEMERAL_KINDS = [20000, 20001]; -const ONE_HOUR_SECONDS = 60 * 60; - -/** Default relay set, always included so chat works even before the geo CSV - * resolves. */ -const DEFAULT_CHAT_RELAYS = [ - 'wss://nos.lol', - 'wss://relay.damus.io', - 'wss://relay.primal.net', -]; - -function validateEphemeralEvent(event: NostrEvent): boolean { - if (event.kind !== 20000 && event.kind !== 20001) return false; - // Must be anchored to a geohash to be useful for the map / heat layer. - return !!event.tags.find(([name]) => name === 'g')?.[1]; -} - -function transformEphemeralEvent(event: NostrEvent): EphemeralEventData { - return { - event, - geohash: event.tags.find(([name]) => name === 'g')?.[1], - nickname: event.tags.find(([name]) => name === 'n')?.[1], - message: event.content, - }; -} - -/** - * Pick a stable rolling window of geo relays so the same set of pages share - * the same shard for ~5 minutes (cuts down on connection churn) without ever - * sticking to one slice forever. - */ -function rotatingGeoRelays(geoRelays: GeoRelay[], count: number): string[] { - if (geoRelays.length === 0) return []; - const rotationIndex = Math.floor(Date.now() / 300_000) % geoRelays.length; - return geoRelays.slice(rotationIndex, rotationIndex + count).map((r) => r.url); -} - -/** - * Fetch recent ephemeral chat events for the world map. - * - * - When `targetGeohash` is provided we only ask the closest geo relays for - * that prefix (chat-detail mode). - * - Without it we fan out to default relays plus a rotating window of geo - * relays so the map shows activity from everywhere (heatmap mode). - * - * Always returns events from the last `ONE_HOUR_SECONDS` window — older - * ephemeral events are uninteresting for "what's happening right now". - */ -export function useEphemeralEvents(targetGeohash?: string) { - const { nostr } = useNostr(); - const { isLoading: accountsLoading } = useLoggedInAccounts(); - - return useQuery({ - queryKey: ['ephemeral-events', targetGeohash ?? '__global__'], - enabled: !accountsLoading, - queryFn: async (c) => { - const signal = AbortSignal.any([c.signal, AbortSignal.timeout(60_000)]); - const since = Math.floor(Date.now() / 1000) - ONE_HOUR_SECONDS; - - // ── Chat detail mode: scope to nearest geo relays only. ────────────── - if (targetGeohash) { - try { - const geoRelays = await fetchGeoRelays(); - // Touch decode so an obviously bad geohash throws here rather than - // mid-render later. - decode(targetGeohash); - const closest = geoRelays.slice(0, 8).map((r) => r.url); - const relays = closest.length > 0 ? closest : DEFAULT_CHAT_RELAYS; - const events = await nostr - .group(relays) - .query([{ kinds: EPHEMERAL_KINDS, since, limit: 500 }], { signal }); - return events.filter(validateEphemeralEvent).map(transformEphemeralEvent); - } catch (error) { - console.error('Failed to fetch ephemeral events for geohash:', error); - const events = await nostr - .group(DEFAULT_CHAT_RELAYS) - .query([{ kinds: EPHEMERAL_KINDS, since, limit: 500 }], { signal }); - return events.filter(validateEphemeralEvent).map(transformEphemeralEvent); - } - } - - // ── Global heatmap mode: defaults + rotating geo relays. ───────────── - const allEvents: NostrEvent[] = []; - const failedRelays = new Set(); - - try { - const defaultEvents = await nostr - .group(DEFAULT_CHAT_RELAYS) - .query([{ kinds: EPHEMERAL_KINDS, since, limit: 300 }], { - signal: AbortSignal.timeout(8_000), - }); - allEvents.push(...defaultEvents); - } catch (error) { - console.warn('Ephemeral events: default relay phase failed:', error); - DEFAULT_CHAT_RELAYS.forEach((r) => failedRelays.add(r)); - } - - try { - const geoRelays = await fetchGeoRelays(); - const rotation = rotatingGeoRelays(geoRelays, 8).filter( - (url) => !failedRelays.has(url) && !DEFAULT_CHAT_RELAYS.includes(url), - ); - - // Process in batches of 4 to avoid overwhelming the connection pool. - const batchSize = 4; - for (let i = 0; i < rotation.length; i += batchSize) { - const batch = rotation.slice(i, i + batchSize); - const batchResults = await Promise.allSettled( - batch.map((url) => - nostr - .relay(url) - .query([{ kinds: EPHEMERAL_KINDS, since, limit: 200 }], { - signal: AbortSignal.timeout(8_000), - }) - .catch((err) => { - console.warn(`Ephemeral events: ${url} failed:`, err); - failedRelays.add(url); - return [] as NostrEvent[]; - }), - ), - ); - for (const result of batchResults) { - if (result.status === 'fulfilled') allEvents.push(...result.value); - } - if (i + batchSize < rotation.length) { - await new Promise((resolve) => setTimeout(resolve, 200)); - } - } - } catch (geoError) { - console.warn('Ephemeral events: geo relay phase failed:', geoError); - } - - const unique = Array.from(new Map(allEvents.map((e) => [e.id, e])).values()); - return unique.filter(validateEphemeralEvent).map(transformEphemeralEvent); - }, - refetchInterval: 30_000, - staleTime: 15_000, - placeholderData: (prev) => prev, - }); -} diff --git a/src/hooks/useEphemeralIdentity.ts b/src/hooks/useEphemeralIdentity.ts deleted file mode 100644 index 9cd03a34..00000000 --- a/src/hooks/useEphemeralIdentity.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { useState, useCallback, useEffect, useMemo } from 'react'; -import { getPublicKey, nip19 } from 'nostr-tools'; - -/** - * Anonymous "ghost" identity used for ephemeral geo chat (kinds 20000/20001). - * - * The private key lives only in React state — it is **not** persisted, so the - * identity dies with the tab. Only the chosen nickname is persisted in - * localStorage, so a returning visitor keeps the same display handle even - * though their pubkey rotates. - */ -export interface EphemeralIdentity { - privateKey: Uint8Array; - pubkey: string; - npub: string; - nickname: string; -} - -const NICKNAME_STORAGE_KEY = 'agora-ephemeral-nickname'; - -const NICKNAME_ADJECTIVES = [ - 'stealth', 'shadow', 'ghost', 'phantom', 'wisp', 'echo', 'veil', 'mist', 'haze', 'aura', -]; -const NICKNAME_NOUNS = [ - 'agent', 'runner', 'operative', 'scout', 'watcher', 'sentry', 'guardian', 'ranger', 'hunter', 'tracker', -]; - -function generateRandomNickname(): string { - const adjective = NICKNAME_ADJECTIVES[Math.floor(Math.random() * NICKNAME_ADJECTIVES.length)]; - const noun = NICKNAME_NOUNS[Math.floor(Math.random() * NICKNAME_NOUNS.length)]; - const number = Math.floor(Math.random() * 9999) + 1; - return `${adjective}${noun}${number}`; -} - -function generatePrivateKey(): Uint8Array { - const array = new Uint8Array(32); - crypto.getRandomValues(array); - return array; -} - -export interface UseEphemeralIdentityReturn { - identity: EphemeralIdentity | null; - generateIdentity: () => EphemeralIdentity; - updateNickname: (nickname: string) => void; - clearIdentity: () => void; -} - -export function useEphemeralIdentity(): UseEphemeralIdentityReturn { - const [identity, setIdentity] = useState(null); - - // If a nickname is already persisted, eagerly mint a fresh keypair against - // it on mount so chat surfaces have an identity to use without waiting on - // an explicit `generateIdentity()` call. - useEffect(() => { - if (identity) return; - const storedNickname = localStorage.getItem(NICKNAME_STORAGE_KEY); - if (!storedNickname) return; - const privateKey = generatePrivateKey(); - const pubkey = getPublicKey(privateKey); - setIdentity({ - privateKey, - pubkey, - npub: nip19.npubEncode(pubkey), - nickname: storedNickname, - }); - }, [identity]); - - const generateIdentity = useCallback(() => { - const privateKey = generatePrivateKey(); - const pubkey = getPublicKey(privateKey); - const storedNickname = localStorage.getItem(NICKNAME_STORAGE_KEY); - const nickname = storedNickname || generateRandomNickname(); - const newIdentity: EphemeralIdentity = { - privateKey, - pubkey, - npub: nip19.npubEncode(pubkey), - nickname, - }; - setIdentity(newIdentity); - return newIdentity; - }, []); - - const updateNickname = useCallback((newNickname: string) => { - setIdentity((prev) => (prev ? { ...prev, nickname: newNickname } : prev)); - localStorage.setItem(NICKNAME_STORAGE_KEY, newNickname); - }, []); - - const clearIdentity = useCallback(() => { - setIdentity(null); - localStorage.removeItem(NICKNAME_STORAGE_KEY); - }, []); - - // Memoise the return object so consumers using it as a useEffect dep don't - // re-fire on every render. Without this, `useChatSession`'s identity effect - // re-runs every render → can interact poorly with downstream ref callbacks. - return useMemo( - () => ({ identity, generateIdentity, updateNickname, clearIdentity }), - [identity, generateIdentity, updateNickname, clearIdentity], - ); -} diff --git a/src/lib/georelays.ts b/src/lib/georelays.ts deleted file mode 100644 index 98155f90..00000000 --- a/src/lib/georelays.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Geo-located Nostr relay catalogue. Used by the ephemeral chat surface - * (kinds 20000/20001) to route reads/writes to relays physically near the - * geohash being chatted about, which dramatically reduces latency for - * realtime conversations. - * - * Source CSV (`relayUrl,latitude,longitude` per line) is maintained by the - * permissionlesstech project. We cache the parsed catalogue in module scope - * for the lifetime of the page so we never re-fetch. - */ - -export interface GeoRelay { - url: string; - latitude: number; - longitude: number; -} - -const GEORELAYS_CSV_URL = - 'https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv'; - -let geoRelaysCache: GeoRelay[] | null = null; -let geoRelaysFetchPromise: Promise | null = null; - -export async function fetchGeoRelays(): Promise { - if (geoRelaysCache) return geoRelaysCache; - if (geoRelaysFetchPromise) return geoRelaysFetchPromise; - - geoRelaysFetchPromise = (async () => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10_000); - try { - const response = await fetch(GEORELAYS_CSV_URL, { signal: controller.signal }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - - const csvText = await response.text(); - clearTimeout(timeoutId); - - const relays: GeoRelay[] = []; - const lines = csvText.trim().split('\n'); - - // Yield to the UI thread between batches so a 1000+ row CSV doesn't - // block the main thread on slow devices. - const batchSize = 100; - for (let i = 0; i < lines.length; i += batchSize) { - const batch = lines.slice(i, i + batchSize); - await new Promise((resolve) => setTimeout(resolve, 0)); - for (const line of batch) { - const parts = line.split(','); - if (parts.length < 3) continue; - const rawUrl = parts[0].trim(); - const latitude = parseFloat(parts[1]); - const longitude = parseFloat(parts[2]); - if (!rawUrl || isNaN(latitude) || isNaN(longitude)) continue; - const url = rawUrl.startsWith('wss://') ? rawUrl : `wss://${rawUrl}`; - relays.push({ url, latitude, longitude }); - } - } - - geoRelaysCache = relays; - return relays; - } catch (error) { - clearTimeout(timeoutId); - console.error('Failed to fetch geo relays:', error); - geoRelaysFetchPromise = null; - return []; - } - })(); - - return geoRelaysFetchPromise; -} - -/** Haversine great-circle distance in kilometres. */ -function calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number { - const R = 6371; - const dLat = ((lat2 - lat1) * Math.PI) / 180; - const dLng = ((lng2 - lng1) * Math.PI) / 180; - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos((lat1 * Math.PI) / 180) * - Math.cos((lat2 * Math.PI) / 180) * - Math.sin(dLng / 2) ** 2; - return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - -/** Return the `count` geo-relays nearest to the given coordinates. */ -export function findClosestRelays( - relays: GeoRelay[], - targetLat: number, - targetLng: number, - count = 5, -): GeoRelay[] { - if (relays.length === 0) return []; - return relays - .map((relay) => ({ - relay, - distance: calculateDistance(targetLat, targetLng, relay.latitude, relay.longitude), - })) - .sort((a, b) => a.distance - b.distance) - .slice(0, count) - .map(({ relay }) => relay); -} diff --git a/src/pages/WorldPage.tsx b/src/pages/WorldPage.tsx index 1baffe10..c79cea58 100644 --- a/src/pages/WorldPage.tsx +++ b/src/pages/WorldPage.tsx @@ -3,11 +3,9 @@ import { useSeoMeta } from '@unhead/react'; import { Skeleton } from '@/components/ui/skeleton'; import { useAppContext } from '@/hooks/useAppContext'; import { useGlobalActivity, useTopCountryHashtags } from '@/hooks/useGlobalActivity'; -import { useEphemeralEvents } from '@/hooks/useEphemeralEvents'; import { useLayoutOptions } from '@/contexts/LayoutContext'; import { WorldDiscoveryDrawer } from '@/components/world/WorldDiscoveryDrawer'; import { WorldDiscoveryPanel } from '@/components/world/WorldDiscoveryPanel'; -import { ChatDialog } from '@/components/chat/ChatDialog'; // Lazy-load the map: react-leaflet + leaflet pull in ~150 KB of JS that we // don't want to ship with the rest of the app shell. @@ -51,20 +49,6 @@ export function WorldPage() { const { data: activities } = useGlobalActivity(); const { data: topHashtags } = useTopCountryHashtags(); - const { data: ephemeralEvents } = useEphemeralEvents(); - const [activeChatGeohash, setActiveChatGeohash] = useState(null); - - // Memoise the per-geohash slice so `ChatDialog` (and its `useChatSession` - // effects) get a stable array reference across renders — without this, - // every WorldPage re-render would feed a fresh array into ChatDialog, - // re-firing every dependent effect and risking ref-callback storms. - const chatInitialEvents = useMemo( - () => - activeChatGeohash - ? ephemeralEvents?.filter((e) => e.geohash === activeChatGeohash) ?? [] - : [], - [activeChatGeohash, ephemeralEvents], - ); // Memoise the activities/hashtags fallbacks too — `new Map()` literals // produce a fresh reference every render, which causes WorldMap's @@ -102,8 +86,6 @@ export function WorldPage() {
@@ -113,17 +95,6 @@ export function WorldPage() { `WorldDiscoveryPanel` (rendered as the layout's right sidebar) takes over and this component is unmounted. */} {!hasSidebar && } - - {/* Per-geohash realtime chat. Only mounted while open so the relay - subscription tears down cleanly when the dialog closes. */} - {activeChatGeohash && ( - setActiveChatGeohash(null)} - geohash={activeChatGeohash} - initialEvents={chatInitialEvents} - /> - )}
); }