Remove ephemeral geo chat

This commit is contained in:
lemon
2026-05-18 09:49:31 -07:00
parent ae41290b68
commit 634e161085
11 changed files with 0 additions and 1861 deletions
-62
View File
@@ -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.
-10
View File
@@ -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",
-1
View File
@@ -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",
-515
View File
@@ -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<string, string> = {
'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 (
<div className="inline-flex items-center gap-1">
{metadata?.picture && (
<Avatar className="h-4 w-4">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="text-[8px]">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
)}
<span className="font-medium text-primary">
{truncateNickname(displayName)}
<span className="text-[0.85em] opacity-70" style={{ color: getPubkeyColor(pubkey) }}>
#{getPubkeySuffix(pubkey)}
</span>
</span>
</div>
);
}
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<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [showScrollButton, setShowScrollButton] = useState(false);
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl h-[85vh] sm:h-[600px] flex flex-col p-0 gap-0 overflow-hidden">
<DialogHeader className="px-4 py-3 border-b bg-card/50">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<div className="relative">
<MapPin className="h-5 w-5 text-primary" />
<div
className={`absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full ${
connectionStatus === 'connected' ? 'bg-primary animate-pulse' :
connectionStatus === 'connecting' ? 'bg-yellow-500 animate-pulse' :
connectionStatus === 'error' ? 'bg-red-500' :
'bg-gray-500'
}`}
title={
connectionStatus === 'connected' ? 'Connected' :
connectionStatus === 'connecting' ? 'Connecting...' :
connectionStatus === 'error' ? 'Connection error' :
'Disconnected'
}
/>
</div>
{t('chat.geoChat', 'Geo chat')}
</DialogTitle>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 text-sm text-muted-foreground mt-2">
<div className="flex items-center gap-2 bg-muted/50 px-2 py-1 rounded">
{countryInfo && (
<span className="text-lg leading-none" title={countryInfo.name}>
{countryInfo.flag}
</span>
)}
<div className="flex items-center gap-1 font-mono text-xs">
<MapPin className="h-3 w-3" />
<span>{geohash}</span>
</div>
</div>
<div className="flex items-center gap-2 flex-1">
{canToggleIdentity ? (
<div className="flex items-center gap-2 bg-muted/30 rounded-lg p-1">
<button
onClick={() => setIdentityMode('ephemeral')}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors ${
identityMode === 'ephemeral'
? 'bg-background shadow-sm text-primary font-medium'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Ghost className="h-3 w-3" />
{isEditingNickname ? (
<Input
value={newNickname}
onChange={(e) => 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
onClick={(e) => e.stopPropagation()}
/>
) : (
<span onClick={(e) => { e.stopPropagation(); handleStartEditNickname(); }}>
{session?.nickname || 'anonymous'}
</span>
)}
</button>
<button
onClick={() => setIdentityMode('real')}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors ${
identityMode === 'real'
? 'bg-background shadow-sm text-primary font-medium'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<UserRound className="h-3 w-3" />
{getDisplayName(metadata, user?.pubkey || '')}
</button>
</div>
) : (
<div className="flex items-center gap-2 bg-muted/30 rounded-lg px-2 py-1">
<Ghost className="h-3 w-3 text-muted-foreground" />
{isEditingNickname ? (
<Input
value={newNickname}
onChange={(e) => 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
/>
) : (
<button
onClick={handleStartEditNickname}
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
{session?.nickname || 'anonymous'}
<Edit2 className="h-2.5 w-2.5 opacity-50" />
</button>
)}
</div>
)}
</div>
</div>
</DialogHeader>
<div className="flex-1 flex flex-col min-h-0">
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
<div className="space-y-3">
{(() => {
const messagesWithContent = chatMessages.filter(
(msg) => msg.message && msg.message.trim().length > 0,
);
if (isMessagesLoading) {
return (
<div className="text-center py-8 text-muted-foreground">
<div className="animate-pulse">{t('chat.connecting', 'Connecting...')}</div>
</div>
);
}
if (messagesWithContent.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
<Ghost className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>{t('chat.noMessages', 'No messages yet. Be the first to say something!')}</p>
</div>
);
}
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 (
<div
key={msg.event.id}
className={`flex flex-col gap-1 ${isOwn ? 'items-end' : 'items-start'}`}
>
<div className={`max-w-[85%] rounded-2xl px-4 py-2 ${
isOwn
? 'bg-primary text-primary-foreground rounded-br-md'
: 'bg-muted rounded-bl-md'
}`}>
{!isOwn && (
<div className="text-xs mb-1 opacity-80">
<MessageAuthor pubkey={msg.event.pubkey} nickname={msg.nickname} />
</div>
)}
<p className="text-sm whitespace-pre-wrap break-words">{msg.message}</p>
</div>
<span className="text-xs text-muted-foreground px-2">{timestamp}</span>
</div>
);
});
})()}
</div>
{showScrollButton && (
<Button
onClick={scrollToBottom}
size="icon"
className="fixed bottom-24 right-8 h-10 w-10 rounded-full shadow-lg z-10"
>
<ChevronDown className="h-5 w-5" />
</Button>
)}
</ScrollArea>
<div className="border-t p-4 bg-card/50">
<div className="flex gap-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={
session
? t('chat.typeMessage', 'Type your message...')
: t('chat.connecting', 'Connecting...')
}
disabled={!session || isLoading}
className="flex-1"
/>
<Button
onClick={handleSendMessage}
disabled={!message.trim() || !session || isLoading}
size="icon"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -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<string, string> = {
'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<number>();
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 (
<Card
className={cn(
'border-sky-400/30 bg-popover/95 backdrop-blur-sm shadow-xl',
isMobile ? 'w-[90vw]' : 'w-[28rem]',
)}
>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-sky-400 animate-pulse" />
Live geo chat
</CardTitle>
<p className="text-xs text-muted-foreground">
{rows.length} {rows.length === 1 ? 'location' : 'locations'}
{heartbeatCount > 0 && ` · ${heartbeatCount} active`}
</p>
</CardHeader>
<CardContent className="space-y-2">
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
{heartbeatCount} {heartbeatCount === 1 ? 'user is' : 'users are'} listening nearby be the first to say something.
</p>
) : (
<ScrollArea className="h-48 sm:h-64">
<div className="space-y-2 pr-2">
{rows.map(({ geohash, country, messageCount, userCount, latest }) => (
<button
key={geohash}
onClick={() => onOpenChat(geohash)}
className="w-full text-left p-3 rounded-lg bg-sky-50 dark:bg-sky-950/30 border border-sky-200/50 dark:border-sky-800/50 hover:bg-sky-100 dark:hover:bg-sky-900/40 transition-colors"
>
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0">
<span className="text-lg leading-none flex-shrink-0">
{country?.flag || '📍'}
</span>
<div className="min-w-0">
<div className="font-medium text-sm truncate">
{country?.name || 'Unknown'}
</div>
<div className="text-xs text-muted-foreground font-mono">
{truncateGeohash(geohash)}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Badge variant="secondary" className="text-xs gap-1">
<Users className="h-3 w-3" />
{userCount}
</Badge>
<Badge className="text-xs gap-1 bg-sky-500 hover:bg-sky-500">
<MessageCircle className="h-3 w-3" />
{messageCount}
</Badge>
</div>
</div>
{latest && (
<p className="text-xs text-muted-foreground line-clamp-1 mt-1">
{latest.nickname && (
<span className="font-medium">{latest.nickname}: </span>
)}
{latest.message}
</p>
)}
</button>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
);
}
// ── Marker bubble ───────────────────────────────────────────────────────────
function EphemeralBubble({
size,
count,
isCluster,
}: {
size: number;
count: number;
isCluster?: boolean;
}) {
const opacityMultiplier = count < 3 ? 0.6 : 1;
return (
<div
className="relative flex items-center justify-center cursor-pointer hover:scale-110 transition-transform group"
style={{ width: size, height: size }}
>
<div
className="absolute inset-0 rounded-full bg-sky-400 animate-accent-glow blur-md"
style={{ transform: 'scale(1.5)', opacity: 0.45 * opacityMultiplier }}
/>
<div
className="absolute inset-0 rounded-full bg-sky-400 animate-pulse blur-sm"
style={{ transform: 'scale(1.3)', opacity: 0.3 * opacityMultiplier }}
/>
<div
className="absolute inset-0 rounded-full shadow-[0_0_18px_rgba(56,189,248,0.6)] group-hover:shadow-[0_0_28px_rgba(56,189,248,0.85)] transition-shadow"
style={{
background:
'linear-gradient(135deg, hsl(199 89% 55%) 0%, hsl(199 89% 48%) 50%, hsl(199 89% 40%) 100%)',
opacity: opacityMultiplier,
}}
/>
<div className="relative z-10 flex items-center justify-center">
<span
className="text-white font-bold leading-none drop-shadow-[0_2px_4px_rgba(0,0,0,0.8)]"
style={{ fontSize: Math.max(10, size * (isCluster ? 0.32 : 0.36)) }}
>
{count}
</span>
</div>
</div>
);
}
// ── 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 `<MapContainer>`. 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<GeohashLocation[]>(() => {
const grouped = new Map<string, EphemeralEventData[]>();
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 (
<div
className="absolute inset-0 pointer-events-none"
style={{ overflow: 'visible', zIndex: 999 }}
>
{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 (
<div
key={cluster.id}
className="absolute pointer-events-auto"
style={{
left: pos.x,
top: pos.y,
transform: 'translate(-50%, -50%)',
}}
>
<Popover>
<PopoverTrigger asChild>
<button
aria-label={
isCluster
? `${cluster.locations.length} chat locations, ${cluster.totalEvents} events`
: `${cluster.totalEvents} events at ${cluster.locations[0].geohash}`
}
className="bg-transparent border-0 p-0"
>
<EphemeralBubble
size={size}
count={cluster.locations.length > 1 ? cluster.locations.length : cluster.totalEvents}
isCluster={isCluster}
/>
</button>
</PopoverTrigger>
<PopoverContent
className={cn(
'!w-auto p-0 border-0 shadow-xl z-[10000]',
isMobile && '!fixed !top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2',
)}
side={isMobile ? undefined : 'top'}
sideOffset={isMobile ? 0 : 10}
collisionPadding={
isMobile ? undefined : { top: 16, bottom: 16, left: 16, right: 16 }
}
avoidCollisions={!isMobile}
>
<EphemeralPopover
locations={cluster.locations}
onOpenChat={onOpenChat}
isMobile={isMobile}
/>
</PopoverContent>
</Popover>
</div>
);
})
: 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 (
<div
key={loc.geohash}
className="absolute pointer-events-auto"
style={{
left: pos.x,
top: pos.y,
transform: 'translate(-50%, -50%)',
}}
>
<Popover>
<PopoverTrigger asChild>
<button
aria-label={`${loc.events.length} events at ${loc.geohash}`}
className="bg-transparent border-0 p-0"
>
<EphemeralBubble size={size} count={loc.events.length} />
</button>
</PopoverTrigger>
<PopoverContent
className={cn(
'!w-auto p-0 border-0 shadow-xl z-[10000]',
isMobile && '!fixed !top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2',
)}
side={isMobile ? undefined : 'top'}
sideOffset={isMobile ? 0 : 10}
collisionPadding={
isMobile ? undefined : { top: 16, bottom: 16, left: 16, right: 16 }
}
avoidCollisions={!isMobile}
>
<EphemeralPopover
locations={[loc]}
onOpenChat={onOpenChat}
isMobile={isMobile}
/>
</PopoverContent>
</Popover>
</div>
);
})}
</div>
);
}
-19
View File
@@ -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<string, number>;
/** Map of country code → top trending hashtag (no leading `#`). */
topHashtags?: Map<string, string>;
/** 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({
/>
<MapSizeController />
<MarkersOverlay activityMarkers={activityMarkers} isMobile={isMobile} />
{ephemeralEvents && ephemeralEvents.length > 0 && onOpenChat && (
<EphemeralMarkersLayer
events={ephemeralEvents}
onOpenChat={onOpenChat}
isMobile={isMobile}
/>
)}
</MapContainer>
</div>
);
-360
View File
@@ -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<boolean>;
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<UseChatSessionReturn['session']>(null);
const [isLoading, setIsLoading] = useState(true);
const [identityMode, setIdentityModeState] = useState<IdentityMode>('ephemeral');
const [hasSeededInitialEvents, setHasSeededInitialEvents] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('connecting');
const [geoRelaysCache, setGeoRelaysCache] = useState<string[]>([]);
const [messages, setMessages] = useState<EphemeralEventMessage[]>(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<string>(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<EphemeralEventMessage[]>(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<EphemeralEventMessage>((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<EphemeralEventMessage[]>(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<EphemeralEventMessage[]>(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<EphemeralEventMessage[]>(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<boolean> => {
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<EphemeralEventMessage[]>(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,
};
}
-160
View File
@@ -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<string>();
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,
});
}
-100
View File
@@ -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<EphemeralIdentity | null>(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],
);
}
-101
View File
@@ -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<GeoRelay[]> | null = null;
export async function fetchGeoRelays(): Promise<GeoRelay[]> {
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);
}
-29
View File
@@ -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<string | null>(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() {
<WorldMap
activities={safeActivities}
topHashtags={safeTopHashtags}
ephemeralEvents={ephemeralEvents}
onOpenChat={setActiveChatGeohash}
/>
</div>
</Suspense>
@@ -113,17 +95,6 @@ export function WorldPage() {
`WorldDiscoveryPanel` (rendered as the layout's right sidebar)
takes over and this component is unmounted. */}
{!hasSidebar && <WorldDiscoveryDrawer activities={activities} />}
{/* Per-geohash realtime chat. Only mounted while open so the relay
subscription tears down cleanly when the dialog closes. */}
{activeChatGeohash && (
<ChatDialog
isOpen={!!activeChatGeohash}
onClose={() => setActiveChatGeohash(null)}
geohash={activeChatGeohash}
initialEvents={chatInitialEvents}
/>
)}
</div>
);
}