feat(dm): group DMs (NIP-17) — inbox list + group thread
Uses the existing gift-wrap primitive (kind 14 rumor, one p-tag per recipient): - useGroupConversations: lists multi-recipient threads, bucketed by the sorted participant key; the inbox shows a Groups section - GroupConversationPage (/messages/group/:participants): encrypted group thread with sender names, send + error feedback - smoke test now boots the new route Group creation UI (multi-select in NewMessageDialog) is the next step. For stronger guarantees (forward secrecy, rosters) Marmot/MLS or Cordn would be the protocol to adopt later; noted in the hook.
This commit is contained in:
parent
ca199fac6e
commit
2a47e5c9c5
5 changed files with 412 additions and 2 deletions
|
|
@ -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 = '';
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/collections" element={<SpotCollectionsPage />} />
|
||||
<Route path="/collections/:pubkey/:identifier" element={<CollectionDetailPage />} />
|
||||
<Route path="/messages" element={<DirectMessagesPage />} />
|
||||
<Route path="/messages/group/:participants" element={<GroupConversationPage />} />
|
||||
<Route path="/messages/:npub" element={<ConversationPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
{/* Bounty System */}
|
||||
|
|
|
|||
|
|
@ -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<Conversation[]> => {
|
||||
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<string, DirectMessage[]>();
|
||||
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Link to={`/messages/group/${participants.join(',')}`}>
|
||||
<Card className={cn(
|
||||
'hover:bg-gray-900/50 transition-colors cursor-pointer border-gray-800',
|
||||
'hover:border-[#ccff00]/50 active:scale-[0.99]',
|
||||
)}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-[#ccff00]/15 text-[#ccff00]">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="truncate font-bold text-white">{participants.length} people</h3>
|
||||
<span className="shrink-0 text-xs text-gray-500">{timeAgo}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-gray-500">
|
||||
{participants.slice(0, 3).map((p, i) => (
|
||||
<span key={p}>
|
||||
{i > 0 && ', '}
|
||||
<GroupParticipantName pubkey={p} />
|
||||
</span>
|
||||
))}
|
||||
{participants.length > 3 && ` +${participants.length - 3}`}
|
||||
</p>
|
||||
{last && (
|
||||
<p className="truncate text-sm text-gray-500">
|
||||
{isFromMe && <span className="text-gray-400">You: </span>}
|
||||
{last.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
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 && (
|
||||
<div className="mb-5">
|
||||
<h2 className="mb-2 flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-gray-500">
|
||||
<Users className="h-3.5 w-3.5" /> Groups
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{groups.map((g) => (
|
||||
<GroupPreview key={g.pubkey} conversation={g} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conversations List */}
|
||||
<div className="space-y-3">
|
||||
{isLoading ? (
|
||||
|
|
|
|||
227
src/pages/GroupConversationPage.tsx
Normal file
227
src/pages/GroupConversationPage.tsx
Normal file
|
|
@ -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 <span className="truncate">{name}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLDivElement>(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 (
|
||||
<Layout>
|
||||
<div className="container mx-auto max-w-2xl px-4 py-16 text-center text-gray-400">
|
||||
This group chat link is invalid.
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto max-w-2xl px-4 py-8">
|
||||
<Card className="border-dashed border-2 border-gray-700">
|
||||
<CardContent className="py-16 text-center">
|
||||
<Lock className="mx-auto mb-4 h-16 w-16 text-gray-600" />
|
||||
<h2 className="mb-2 text-xl font-bold">Log in to view this group</h2>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<LoginArea className="max-w-60" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!checkNip44(user, logins)) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto max-w-2xl px-4 py-12 text-center">
|
||||
<Card className="border-2 border-dashed border-orange-500/30">
|
||||
<CardContent className="py-12">
|
||||
<Lock className="mx-auto mb-4 h-12 w-12 text-orange-500" />
|
||||
<h2 className="mb-2 text-lg font-bold">Secure signer required</h2>
|
||||
<p className="text-sm text-gray-400">Group messages need a NIP-44 capable signer.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Layout>
|
||||
<div className="container mx-auto flex h-[calc(100dvh-56px-80px)] max-w-2xl flex-col px-4 py-2 sm:h-[calc(100dvh-64px-60px)] sm:py-4 md:h-[calc(100vh-64px-60px)]">
|
||||
{/* Header */}
|
||||
<div className="flex flex-shrink-0 items-center gap-2 border-b border-gray-800 pb-3 sm:gap-3 sm:pb-4">
|
||||
<Link to="/messages">
|
||||
<Button variant="ghost" size="icon" aria-label="Back" className="text-gray-400 hover:text-white">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-[#ccff00]/15 text-[#ccff00]">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-white sm:text-base">
|
||||
{otherPubkeys.length === 1 ? (
|
||||
<ParticipantName pubkey={otherPubkeys[0]} />
|
||||
) : (
|
||||
`${otherPubkeys.length + 1} people`
|
||||
)}
|
||||
</p>
|
||||
<p className="flex items-center gap-1 text-[10px] text-gray-500 sm:text-xs">
|
||||
<Lock className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
End-to-end encrypted group
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Refresh"
|
||||
onClick={() => refetch()}
|
||||
disabled={isRefetching}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', isRefetching && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 space-y-3 overflow-y-auto py-4">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Skeleton className="h-16 w-3/4 rounded-2xl" />
|
||||
<Skeleton className="ml-auto h-16 w-2/3 rounded-2xl" />
|
||||
<Skeleton className="h-16 w-3/4 rounded-2xl" />
|
||||
</>
|
||||
) : messages && messages.length > 0 ? (
|
||||
messages.map((msg) => {
|
||||
const isMine = msg.pubkey === user.pubkey;
|
||||
return (
|
||||
<div key={msg.id} className={cn('flex flex-col', isMine ? 'items-end' : 'items-start')}>
|
||||
{!isMine && (
|
||||
<span className="mb-0.5 px-2 text-[10px] font-bold text-gray-500">
|
||||
<ParticipantName pubkey={msg.pubkey} />
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%] break-words rounded-2xl px-3.5 py-2 text-sm',
|
||||
isMine ? 'bg-[#ff0040] text-white' : 'bg-white/8 text-gray-100',
|
||||
)}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
<span className="mt-0.5 px-2 text-[10px] text-gray-600">
|
||||
{formatDistanceToNow(new Date(msg.createdAt * 1000), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="py-10 text-center text-sm text-gray-500">
|
||||
No messages yet — say hi to the group.
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<div className="flex flex-shrink-0 gap-2 border-t border-gray-800 pb-safe pt-3 sm:pt-4">
|
||||
<Textarea
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Message the group..."
|
||||
className="min-h-[44px] max-h-24 resize-none border-gray-700 bg-gray-900 text-sm text-white sm:max-h-32 sm:text-base"
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!newMessage.trim() || isSending}
|
||||
aria-label="Send message"
|
||||
className="h-[44px] w-[44px] flex-shrink-0 bg-[#ff0040] p-0 text-white hover:bg-[#ff0040]/90 sm:h-auto sm:w-auto sm:px-4"
|
||||
>
|
||||
{isSending ? <Loader2 className="h-5 w-5 animate-spin" /> : <Send className="h-5 w-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue