diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 11c1fff..d3705fd 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -122,7 +122,7 @@ await send('Page.enable'); const evalJS = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }))?.result?.result?.value; // ── boot each route (lazy route chunks can fail independently) ────────────── -const ROUTES = ['/', '/leaderboards', '/bounties']; +const ROUTES = ['/', '/leaderboards', '/bounties', '/messages/group/abc123']; const problems = []; let lastText = ''; diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 870a13d..4ec597d 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -48,6 +48,7 @@ const SpotCollectionsPage = lazy(() => import("./pages/SpotCollectionsPage")); const CollectionDetailPage = lazy(() => import("./pages/CollectionDetailPage")); const DirectMessagesPage = lazy(() => import("./pages/DirectMessagesPage")); const ConversationPage = lazy(() => import("./pages/ConversationPage")); +const GroupConversationPage = lazy(() => import("./pages/GroupConversationPage")); const ContactsPage = lazy(() => import("./pages/ContactsPage")); const BountyLeaderboardPage = lazy(() => import("./pages/BountyLeaderboardPage")); const ZapLeaderboardPage = lazy(() => import("./pages/ZapLeaderboardPage")); @@ -138,6 +139,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> {/* Bounty System */} diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts index c69a8b6..511acfa 100644 --- a/src/hooks/useDirectMessages.ts +++ b/src/hooks/useDirectMessages.ts @@ -494,6 +494,116 @@ export function useConversations() { }); } +/** + * List **group** (multi-recipient) conversations — the NIP-17 group primitive. + * + * A NIP-17 group message is the same kind-14 rumor inside kind-13/1059 gift + * wraps; the only difference is that it carries one `p` tag per recipient. We + * bucket those by the same sorted participant key `useGroupConversation` uses. + * + * Scope note: this is fan-out share-the-rumor group messaging. It has no + * forward secrecy or membership ratcheting/roster — Marmot (MLS) and Cordn are + * the protocols to adopt if those guarantees are required later. + */ +export function useGroupConversations() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const { logins } = useNostrLogin(); + const { config } = useAppContext(); + const nip44Available = checkNip44(user, logins); + + const relayUrls = config.relayMetadata.relays + .filter(r => r.read) + .map(r => r.url) + .sort() + .join(','); + + return useQuery({ + queryKey: ['group-conversations', user?.pubkey, relayUrls], + queryFn: async (c): Promise => { + if (!user?.signer?.nip44) return []; + + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]); + const readRelays = config.relayMetadata.relays.filter(r => r.read).map(r => r.url); + + const allEvents: NostrEvent[] = []; + for (const relayUrl of readRelays) { + try { + const relay = nostr.relay(relayUrl); + const events = await relay.query( + [{ kinds: [GIFT_WRAP_KIND], '#p': [user.pubkey], limit: 200 }], + { signal }, + ); + allEvents.push(...events); + } catch (error) { + console.warn(`[DM] Failed to query ${relayUrl}:`, error); + } + } + + const uniqueEvents = Array.from(new Map(allEvents.map(e => [e.id, e])).values()); + const byGroup = new Map(); + + for (const giftWrap of uniqueEvents) { + try { + const sealJson = await user.signer.nip44.decrypt(giftWrap.pubkey, giftWrap.content); + const seal = JSON.parse(sealJson) as NostrEvent; + if (seal.kind !== SEAL_KIND) continue; + + const rumorJson = await user.signer.nip44.decrypt(seal.pubkey, seal.content); + const rumor = JSON.parse(rumorJson) as NostrEvent; + if (rumor.pubkey !== seal.pubkey) continue; + if (rumor.kind !== DM_KIND) continue; + + const pTags = rumor.tags.filter(t => t[0] === 'p').map(t => t[1]); + // 1:1 messages are surfaced by useConversations, not here. + if (pTags.length <= 1) continue; + + const participants = Array.from(new Set([rumor.pubkey, ...pTags])); + const groupId = getGroupId(participants); + + const message: DirectMessage = { + id: rumor.id, + pubkey: rumor.pubkey, + content: rumor.content, + createdAt: rumor.created_at, + conversationWith: groupId, + participants, + subject: rumor.tags.find(t => t[0] === 'subject')?.[1], + }; + + const existing = byGroup.get(groupId) || []; + existing.push(message); + byGroup.set(groupId, existing); + } catch (error) { + console.debug('[DM] Failed to decrypt group message:', error); + } + } + + const conversations: Conversation[] = []; + for (const [groupId, messages] of byGroup) { + const sorted = messages.sort((a, b) => b.createdAt - a.createdAt); + const latest = sorted[0]; + const participants = latest.participants ?? []; + conversations.push({ + pubkey: groupId, + // Everyone except me — what the UI shows and the group URL encodes. + participants: participants.filter(p => p !== user.pubkey), + lastMessage: latest, + unreadCount: 0, + isGroup: true, + subject: latest.subject, + }); + } + + return conversations.sort( + (a, b) => (b.lastMessage?.createdAt || 0) - (a.lastMessage?.createdAt || 0), + ); + }, + enabled: nip44Available, + ...amberSafeQueryOptions(isInteractiveSigner(user, logins)), + }); +} + /** * Helper to format pubkey for display */ diff --git a/src/pages/DirectMessagesPage.tsx b/src/pages/DirectMessagesPage.tsx index 9e8e498..dd12322 100644 --- a/src/pages/DirectMessagesPage.tsx +++ b/src/pages/DirectMessagesPage.tsx @@ -13,7 +13,7 @@ import { NewMessageDialog } from '@/components/NewMessageDialog'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrLogin } from '@nostrify/react/login'; import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; -import { useConversations } from '@/hooks/useDirectMessages'; +import { useConversations, useGroupConversations, type Conversation } from '@/hooks/useDirectMessages'; import { useDMRead } from '@/hooks/useDMRead'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; @@ -26,6 +26,7 @@ import { AlertCircle, ShieldCheck, X, + Users, } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -63,6 +64,61 @@ function PhishingWarningBanner() { } // Conversation preview component +/** One participant's display name (own component so hooks aren't called in a loop). */ +function GroupParticipantName({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + return <>{author.data?.metadata?.name ?? genUserName(pubkey)}; +} + +/** A group (multi-recipient) DM row in the inbox. */ +function GroupPreview({ conversation }: { conversation: Conversation }) { + const participants = conversation.participants ?? []; + const { user } = useCurrentUser(); + const last = conversation.lastMessage; + const isFromMe = last?.pubkey === user?.pubkey; + const timeAgo = last + ? formatDistanceToNow(new Date(last.createdAt * 1000), { addSuffix: true }) + : ''; + + return ( + + + +
+
+ +
+
+
+

{participants.length} people

+ {timeAgo} +
+

+ {participants.slice(0, 3).map((p, i) => ( + + {i > 0 && ', '} + + + ))} + {participants.length > 3 && ` +${participants.length - 3}`} +

+ {last && ( +

+ {isFromMe && You: } + {last.content} +

+ )} +
+
+
+
+ + ); +} + function ConversationPreview({ pubkey, lastMessage, unread }: { pubkey: string; lastMessage?: { content: string; createdAt: number; pubkey: string }; @@ -153,6 +209,7 @@ export default function DirectMessagesPage() { const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const { data: conversations, isLoading, error, refetch } = useConversations(); + const { data: groups } = useGroupConversations(); const { getLastRead } = useDMRead(); const [showNewMessage, setShowNewMessage] = useState(false); @@ -273,6 +330,20 @@ export default function DirectMessagesPage() { } /> + {/* Group conversations (NIP-17 group DMs) */} + {groups && groups.length > 0 && ( +
+

+ Groups +

+
+ {groups.map((g) => ( + + ))} +
+
+ )} + {/* Conversations List */}
{isLoading ? ( diff --git a/src/pages/GroupConversationPage.tsx b/src/pages/GroupConversationPage.tsx new file mode 100644 index 0000000..7e757e0 --- /dev/null +++ b/src/pages/GroupConversationPage.tsx @@ -0,0 +1,227 @@ +import { useState, useRef, useEffect, useMemo } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { useSeoMeta } from '@unhead/react'; +import { Layout } from '@/components/Layout'; +import { LoginArea } from '@/components/auth/LoginArea'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useToast } from '@/hooks/useToast'; +import { useNostrLogin } from '@nostrify/react/login'; +import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; +import { useGroupConversation, useSendDirectMessage } from '@/hooks/useDirectMessages'; +import { useAuthor } from '@/hooks/useAuthor'; +import { genUserName } from '@/lib/genUserName'; +import { formatDistanceToNow } from 'date-fns'; +import { ArrowLeft, Lock, Loader2, RefreshCw, Send, Users } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +function ParticipantName({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + const name = author.data?.metadata?.name ?? genUserName(pubkey); + return {name}; +} + +/** + * Group DM thread (NIP-17 fan-out group messaging). + * + * The URL carries the OTHER participants' hex pubkeys (comma-separated); + * `useGroupConversation` takes the full set including us. This is the same + * gift-wrap primitive as 1:1 DMs with one `p` tag per recipient. + */ +export default function GroupConversationPage() { + const { participants: participantsParam } = useParams<{ participants: string }>(); + const { user } = useCurrentUser(); + const { logins } = useNostrLogin(); + const { toast } = useToast(); + const { mutate: sendMessage, isPending: isSending } = useSendDirectMessage(); + + const [newMessage, setNewMessage] = useState(''); + const messagesEndRef = useRef(null); + + const otherPubkeys = useMemo( + () => (participantsParam ? participantsParam.split(',').filter(Boolean) : []), + [participantsParam], + ); + const allParticipants = useMemo( + () => (user ? [user.pubkey, ...otherPubkeys] : otherPubkeys), + [user, otherPubkeys], + ); + + useSeoMeta({ title: 'Group chat - SkateSpots' }); + + const { data: messages, isLoading, refetch, isRefetching } = useGroupConversation(allParticipants); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + if (!participantsParam || otherPubkeys.length === 0) { + return ( + +
+ This group chat link is invalid. +
+
+ ); + } + + if (!user) { + return ( + +
+ + + +

Log in to view this group

+
+ +
+
+
+
+
+ ); + } + + if (!checkNip44(user, logins)) { + return ( + +
+ + + +

Secure signer required

+

Group messages need a NIP-44 capable signer.

+
+
+
+
+ ); + } + + const handleSend = () => { + if (!newMessage.trim() || isSending) return; + sendMessage( + { recipients: otherPubkeys, content: newMessage.trim() }, + { + onSuccess: () => setNewMessage(''), + onError: (error) => { + toast({ + title: 'Message failed to send', + description: error instanceof Error ? error.message : 'Please try again in a moment.', + variant: 'destructive', + }); + }, + }, + ); + }; + + return ( + +
+ {/* Header */} +
+ + + +
+ +
+
+

+ {otherPubkeys.length === 1 ? ( + + ) : ( + `${otherPubkeys.length + 1} people` + )} +

+

+ + End-to-end encrypted group +

+
+ +
+ + {/* Messages */} +
+ {isLoading ? ( + <> + + + + + ) : messages && messages.length > 0 ? ( + messages.map((msg) => { + const isMine = msg.pubkey === user.pubkey; + return ( +
+ {!isMine && ( + + + + )} +
+ {msg.content} +
+ + {formatDistanceToNow(new Date(msg.createdAt * 1000), { addSuffix: true })} + +
+ ); + }) + ) : ( +
+ No messages yet — say hi to the group. +
+ )} +
+
+ + {/* Composer */} +
+