Remove ephemeral geo chat
This commit is contained in:
@@ -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.
|
||||
|
||||
Generated
-10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user