Add NIP-17/NIP-04 direct-message protocol layer, hooks and Messages app

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-07 21:34:22 +00:00
committed by GitHub
parent ffc455f5b8
commit d82adbfe0a
6 changed files with 1677 additions and 1 deletions

695
src/apps/messages/index.tsx Normal file
View File

@@ -0,0 +1,695 @@
import { useEffect, useState } from 'react';
import { ChevronLeft, Loader2, Lock, MessageSquarePlus, RotateCcw, Send, TriangleAlert, X } from 'lucide-react';
import {
AppBody,
AppLayout,
AppSectionTitle,
AppSidebar,
AppSplit,
AppToolbar,
EmptyState,
} from '@/components/os/AppChrome';
import { LoginRequired } from '@/components/nostr/LoginRequired';
import { NoteContent } from '@/components/nostr/NoteContent';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useIsMobile } from '@/hooks/useIsMobile';
import {
resolveRecipient,
useDmConversations,
useDmMessages,
useDmReadState,
useHideDmConversation,
useSendDirectMessage,
type DmConversation,
} from '@/hooks/useDirectMessages';
import { useToast } from '@/hooks/useToast';
import { dmCapabilityHint, parseRecipient, type DmMessage } from '@/lib/dm';
import { displayName, relativeTime, sanitizeUrl } from '@/lib/nostrUtils';
import { cn } from '@/lib/utils';
import type { AppProps } from '@/os/types';
/**
* A message composed locally but not yet confirmed by a relay. Tracking it
* here — instead of pretending it was sent — is what gives the conversation
* honest pending/failed states: only a relay round-trip moves it to "sent",
* and nothing here claims the recipient ever received it.
*/
interface PendingMessage {
localId: number;
peer: string;
content: string;
created_at: number;
status: 'pending' | 'failed';
}
export default function MessagesApp({ params, setTitle, setParams }: AppProps) {
const { user } = useCurrentUser();
const isMobile = useIsMobile();
const conversations = useDmConversations();
const hide = useHideDmConversation();
const readState = useDmReadState();
const [composeOpen, setComposeOpen] = useState(false);
const [pending, setPending] = useState<PendingMessage[]>([]);
const [nextId, setNextId] = useState(1);
const selected = params.peer ?? null;
useEffect(() => setTitle('Messages'), [setTitle]);
if (!user) {
return <LoginRequired action="read and send direct messages" />;
}
const visible = (conversations.data ?? []).filter(
(conversation) => !hide.hidden.includes(conversation.peer),
);
const unreadPeers = new Set(visible.filter((c) => readState.isUnread(c)).map((c) => c.peer));
const openConversation = (peer: string) => {
setParams({ peer });
const conversation = visible.find((entry) => entry.peer === peer);
if (conversation) readState.markRead(peer, conversation.lastAt);
};
const addPending = (content: string): PendingMessage | null => {
if (!selected) return null;
const entry: PendingMessage = {
localId: nextId,
peer: selected,
content,
created_at: Math.floor(Date.now() / 1000),
status: 'pending',
};
setNextId(nextId + 1);
setPending([...pending, entry]);
return entry;
};
const settlePending = (entry: PendingMessage, status: 'sent' | 'failed') => {
setPending((list) =>
status === 'sent'
? list.filter((item) => item.localId !== entry.localId)
: list.map((item) => (item.localId === entry.localId ? { ...item, status } : item)),
);
};
const retryPending = (entry: PendingMessage) => {
setPending((list) =>
list.map((item) => (item.localId === entry.localId ? { ...item, status: 'pending' as const } : item)),
);
};
const removePending = (entry: PendingMessage) => {
setPending((list) => list.filter((item) => item.localId !== entry.localId));
};
const listPane = (
<ConversationList
query={conversations}
conversations={visible}
unreadPeers={unreadPeers}
selected={selected}
onSelect={openConversation}
/>
);
const detailPane = !selected ? (
<EmptyState title="Pick a conversation" hint="Choose one from the list, or start a new one." />
) : (
<ConversationView
key={selected}
peer={selected}
pending={pending.filter((entry) => entry.peer === selected)}
onAddPending={addPending}
onSettlePending={settlePending}
onRetryPending={retryPending}
onRemovePending={removePending}
onHide={() => {
const conversation = visible.find((entry) => entry.peer === selected);
if (!conversation) return;
hide.mutate(conversation, {
onSuccess: () => setParams({}),
});
}}
hiding={hide.isPending}
/>
);
if (isMobile) {
return (
<AppLayout>
<AppToolbar>
{selected ? (
<>
<button
type="button"
onClick={() => setParams({})}
className="-ml-1 flex items-center gap-1 rounded px-1 py-0.5 text-[13px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<ChevronLeft className="size-4" aria-hidden />
Messages
</button>
<PeerName pubkey={selected} className="truncate text-[13px] text-muted-foreground" />
</>
) : (
<>
<span className="text-[13px] font-medium">Messages</span>
<NewMessageButton className="ml-auto" onClick={() => setComposeOpen(true)} />
</>
)}
</AppToolbar>
{selected ? (
<div className="flex min-h-0 flex-1 flex-col">{detailPane}</div>
) : (
<AppBody>{listPane}</AppBody>
)}
<NewConversationDialog
open={composeOpen}
onOpenChange={setComposeOpen}
onResolved={openConversation}
/>
</AppLayout>
);
}
return (
<AppLayout>
<AppToolbar>
<span className="text-[13px] font-medium">Messages</span>
<NewMessageButton className="ml-auto" onClick={() => setComposeOpen(true)} />
</AppToolbar>
<AppSplit>
<AppSidebar className="w-64 p-0">{listPane}</AppSidebar>
<div className="flex min-w-0 flex-1 flex-col">{detailPane}</div>
</AppSplit>
<NewConversationDialog
open={composeOpen}
onOpenChange={setComposeOpen}
onResolved={openConversation}
/>
</AppLayout>
);
}
function NewMessageButton({ onClick, className }: { onClick: () => void; className?: string }) {
return (
<Button
size="sm"
variant="outline"
className={cn('h-7 gap-1.5 px-2 text-xs', className)}
onClick={onClick}
>
<MessageSquarePlus className="size-3.5" aria-hidden />
New message
</Button>
);
}
/** Conversation list, with the privacy-preserving preview rules applied. */
function ConversationList({
query,
conversations,
unreadPeers,
selected,
onSelect,
}: {
query: ReturnType<typeof useDmConversations>;
conversations: DmConversation[];
unreadPeers: Set<string>;
selected: string | null;
onSelect: (peer: string) => void;
}) {
if (query.isLoading) {
return (
<div className="space-y-3 p-3" aria-label="Loading conversations">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="flex items-center gap-2.5">
<Skeleton className="size-9 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-24" />
<Skeleton className="h-3 w-36" />
</div>
</div>
))}
</div>
);
}
if (query.isError) {
return (
<div className="p-3">
<EmptyState
title="Couldn't load messages"
hint="None of your relays responded. Check the Relays app, or try again — gift-wrapped messages can take a moment to arrive."
action={
<Button size="sm" variant="outline" onClick={() => query.refetch()}>
Try again
</Button>
}
/>
</div>
);
}
if (conversations.length === 0) {
return (
<div className="p-3">
<EmptyState
title="No conversations yet"
hint="Start a new message to someones npub, nprofile, or NIP-05 address. Message history is recoverable on any client with your keys."
/>
</div>
);
}
return (
<>
<AppSectionTitle>Conversations</AppSectionTitle>
<ul className="pb-2">
{conversations.map((conversation) => (
<li key={conversation.peer}>
<ConversationRow
conversation={conversation}
active={selected === conversation.peer}
unread={unreadPeers.has(conversation.peer)}
onSelect={() => onSelect(conversation.peer)}
/>
</li>
))}
</ul>
</>
);
}
function ConversationRow({
conversation,
active,
unread,
onSelect,
}: {
conversation: DmConversation;
active: boolean;
unread: boolean;
onSelect: () => void;
}) {
const author = useAuthor(conversation.peer);
const name = displayName(conversation.peer, author.data?.metadata);
const picture = sanitizeUrl(author.data?.metadata?.picture);
const latest = conversation.messages[0];
return (
<button
type="button"
onClick={onSelect}
aria-current={active ? 'true' : undefined}
className={cn(
'flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-muted/40 focus-visible:outline-2 focus-visible:outline-ring',
active && 'bg-muted/60',
)}
>
<Avatar className="size-9 shrink-0">
<AvatarImage src={picture} alt="" />
<AvatarFallback>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className={cn('truncate text-[13px]', unread ? 'font-semibold' : 'font-medium')}>
{name}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{relativeTime(conversation.lastAt)}
</span>
</span>
<span className="mt-0.5 flex items-center gap-1.5">
<span className="truncate text-xs text-muted-foreground">
{previewFor(latest)}
</span>
{unread && <span className="size-1.5 shrink-0 rounded-full bg-primary" aria-hidden />}
</span>
</span>
</button>
);
}
/**
* The list preview deliberately does not leak plaintext: the sender's own
* message previews as "You: …" (the device owner typed it), while a received
* message shows only that one arrived. Protocol and time are not sensitive;
* content is.
*/
function previewFor(latest: DmMessage): string {
const legacy = latest.protocol === 'nip04';
if (latest.mine) {
return `You: ${latest.content}${legacy ? ' · legacy' : ''}`;
}
return legacy ? 'New message · legacy encryption' : 'New message';
}
function PeerName({ pubkey, className }: { pubkey: string; className?: string }) {
const author = useAuthor(pubkey);
return <span className={className}>{displayName(pubkey, author.data?.metadata)}</span>;
}
/** One conversation thread with its composer. */
function ConversationView({
peer,
pending,
onAddPending,
onSettlePending,
onRetryPending,
onRemovePending,
onHide,
hiding,
}: {
peer: string;
pending: PendingMessage[];
onAddPending: (content: string) => PendingMessage | null;
onSettlePending: (entry: PendingMessage, status: 'sent' | 'failed') => void;
onRetryPending: (entry: PendingMessage) => void;
onRemovePending: (entry: PendingMessage) => void;
onHide: () => void;
hiding: boolean;
}) {
const { user } = useCurrentUser();
const { messages, isLoading, isError, refetch } = useDmMessages(peer);
const send = useSendDirectMessage();
const { toast } = useToast();
const [draft, setDraft] = useState('');
const capability = send.capability;
const trimmed = draft.trim();
const canSend = Boolean(user) && capability !== 'none' && trimmed.length > 0;
const deliver = async (entry: PendingMessage) => {
try {
await send.mutateAsync({ peer: entry.peer, content: entry.content });
onSettlePending(entry, 'sent');
} catch (error) {
onSettlePending(entry, 'failed');
toast({
title: 'Message not sent',
description:
error instanceof Error
? error.message
: 'No relay accepted the message. Check the Relays app and try again.',
variant: 'destructive',
});
}
};
const submit = () => {
if (!canSend) return;
const entry = onAddPending(trimmed);
setDraft('');
if (entry) void deliver(entry);
};
return (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2">
<PeerName pubkey={peer} className="truncate text-[13px] font-medium" />
<EncryptionBadge
protocol={
messages.some((m) => m.protocol === 'nip04') || capability === 'nip04' ? 'nip04' : 'nip17'
}
/>
<Button
size="sm"
variant="ghost"
className="ml-auto h-7 gap-1.5 px-2 text-xs text-muted-foreground"
onClick={onHide}
disabled={hiding}
>
<X className="size-3.5" aria-hidden />
Hide
</Button>
</div>
{capability !== 'nip17' && (
<p
role="status"
className="shrink-0 border-b border-border bg-muted/40 px-4 py-2 text-xs text-muted-foreground"
>
{dmCapabilityHint(capability)}
</p>
)}
<div className="os-scroll min-h-0 flex-1 overflow-y-auto">
{isLoading ? (
<div className="space-y-3 p-4" aria-label="Loading messages">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={index}
className={cn('h-10 w-2/3 rounded-2xl', index % 2 === 1 && 'ml-auto')}
/>
))}
</div>
) : isError ? (
<EmptyState
title="Couldn't load this conversation"
hint="The relays did not respond. Delayed gift wraps may still arrive — try again in a moment."
action={
<Button size="sm" variant="outline" onClick={() => refetch()}>
Try again
</Button>
}
/>
) : messages.length === 0 && pending.length === 0 ? (
<EmptyState
title="No messages yet"
hint="Say hello. Your message is encrypted for the recipient before it leaves this device."
/>
) : (
<ol className="flex flex-col gap-1.5 px-4 py-3">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{pending.map((entry) => (
<PendingBubble
key={entry.localId}
entry={entry}
onRetry={() => {
onRetryPending(entry);
void deliver({ ...entry, status: 'pending' });
}}
onDiscard={() => onRemovePending(entry)}
/>
))}
</ol>
)}
</div>
<div className="flex shrink-0 items-end gap-2 border-t border-border p-3">
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
submit();
}
}}
aria-label="Message"
placeholder={capability === 'none' ? 'Sending unavailable with this signer' : 'Write a message…'}
rows={1}
disabled={capability === 'none'}
className="min-h-9 flex-1 resize-none py-2 text-[14px]"
/>
<Button size="sm" onClick={submit} disabled={!canSend} className="shrink-0 gap-1.5">
{send.isPending ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden />
) : (
<Send className="size-3.5" aria-hidden />
)}
Send
</Button>
</div>
</>
);
}
function EncryptionBadge({ protocol }: { protocol: 'nip17' | 'nip04' }) {
if (protocol === 'nip17') {
return (
<span className="flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
<Lock className="size-3" aria-hidden />
NIP-17
</span>
);
}
return (
<span className="flex items-center gap-1 rounded-full bg-warning/15 px-2 py-0.5 text-[11px] text-warning-foreground">
<TriangleAlert className="size-3" aria-hidden />
Legacy NIP-04
</span>
);
}
function MessageBubble({ message }: { message: DmMessage }) {
return (
<li className={cn('flex', message.mine ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[75%] rounded-2xl px-3.5 py-2',
message.mine ? 'rounded-br-md bg-primary text-primary-foreground' : 'rounded-bl-md bg-muted',
)}
>
<NoteContent content={message.content} className="text-[14px]" />
<span
className={cn(
'mt-1 flex items-center justify-end gap-1.5 text-[10px]',
message.mine ? 'text-primary-foreground/70' : 'text-muted-foreground',
)}
>
{message.protocol === 'nip04' && <span>legacy</span>}
<span>
Sent {relativeTime(message.created_at)}
{message.mine ? ' · received when their client fetches it' : ''}
</span>
</span>
</div>
</li>
);
}
function PendingBubble({
entry,
onRetry,
onDiscard,
}: {
entry: PendingMessage;
onRetry: () => void;
onDiscard: () => void;
}) {
return (
<li className="flex justify-end">
<div
className={cn(
'max-w-[75%] rounded-2xl rounded-br-md px-3.5 py-2',
entry.status === 'pending'
? 'bg-primary/60 text-primary-foreground'
: 'border border-destructive/40 bg-transparent',
)}
aria-live="polite"
>
<NoteContent content={entry.content} className="text-[14px]" />
<span className="mt-1 flex items-center justify-end gap-2 text-[10px]">
{entry.status === 'pending' ? (
<span className="flex items-center gap-1 text-primary-foreground/80">
<Loader2 className="size-3 animate-spin" aria-hidden />
Sending
</span>
) : (
<span className="flex items-center gap-2 text-muted-foreground">
Not sent
<button
type="button"
onClick={onRetry}
className="flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
<RotateCcw className="size-3" aria-hidden />
Retry
</button>
<button type="button" onClick={onDiscard} className="underline-offset-2 hover:underline">
Discard
</button>
</span>
)}
</span>
</div>
</li>
);
}
/** Start a conversation with a npub, nprofile, hex key or NIP-05 address. */
function NewConversationDialog({
open,
onOpenChange,
onResolved,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onResolved: (peer: string) => void;
}) {
const [value, setValue] = useState('');
const [error, setError] = useState<string | null>(null);
const [resolving, setResolving] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const parsed = parseRecipient(value);
if (parsed.type === 'invalid') {
setError(
'That is not a supported identifier. Try an npub, nprofile, hex pubkey, or a NIP-05 address like name@example.com.',
);
return;
}
setResolving(true);
try {
const pubkey = await resolveRecipient(parsed);
if (!pubkey) {
setError(
'That NIP-05 address could not be resolved. Check the spelling, or ask the person for their npub instead.',
);
return;
}
setValue('');
setError(null);
onOpenChange(false);
onResolved(pubkey);
} finally {
setResolving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>New message</DialogTitle>
<DialogDescription>
Start a conversation with any Nostr user. The first message is sent when you write it in
the conversation.
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-3">
<Input
aria-label="Recipient"
value={value}
onChange={(event) => {
setValue(event.target.value);
setError(null);
}}
placeholder="npub1…, nprofile1…, or name@example.com"
autoFocus
/>
{error && (
<p role="alert" className="text-xs text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button type="submit" size="sm" disabled={!value.trim() || resolving}>
{resolving ? <Loader2 className="size-3.5 animate-spin" aria-hidden /> : null}
Open conversation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { NSecSigner, type NostrEvent } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { TestApp } from '@/test/TestApp';
import { buildGiftWraps, DM_GIFT_WRAP_KIND, LEGACY_DM_KIND } from '@/lib/dm';
import { useLoginActions } from './useLoginActions';
import { groupIntoConversations, useDmConversations, type DmMessage } from './useDirectMessages';
const aliceSecret = generateSecretKey();
const alice = getPublicKey(aliceSecret);
const aliceSigner = new NSecSigner(aliceSecret);
const bobSecret = generateSecretKey();
const bob = getPublicKey(bobSecret);
beforeEach(() => {
// NostrLoginProvider persists logins to localStorage; start each test logged out.
window.localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
/**
* Renders the conversations hook and logs in a fresh user while `nostr.query`
* is mocked. The mock must be installed before login, because logging in
* immediately triggers other queries (authors, notifications).
*/
async function renderLoggedInDms(events: (pubkey: string) => NostrEvent[]) {
const nsec = nip19.nsecEncode(generateSecretKey());
const pubkey = getPublicKey(nip19.decode(nsec).data as Uint8Array);
const { result } = renderHook(
() => ({ actions: useLoginActions(), nostr: useNostr(), dms: useDmConversations() }),
{ wrapper: TestApp },
);
// NostrLoginProvider renders null while it reads logins from storage.
await waitFor(() => expect(result.current).not.toBeNull());
const query = vi
.spyOn(result.current.nostr.nostr, 'query')
.mockImplementation(async () => events(pubkey));
act(() => result.current.actions.nsec(nsec));
await waitFor(() => expect(result.current.dms.isSuccess).toBe(true));
return { result, query, pubkey };
}
function dmEvent(overrides: Partial<NostrEvent>): NostrEvent {
return { id: 'x', pubkey: alice, created_at: 1000, kind: DM_GIFT_WRAP_KIND, tags: [], content: '', sig: '', ...overrides };
}
function message(overrides: Partial<DmMessage>): DmMessage {
return {
id: 'm1',
pubkey: alice,
created_at: 1000,
content: 'hi',
peer: bob,
protocol: 'nip17',
mine: false,
eventId: 'w1',
...overrides,
};
}
describe('useDmConversations', () => {
it('queries gift wraps addressed to the user plus both legacy directions', async () => {
const { query } = await renderLoggedInDms(() => []);
const filters = query.mock.calls.flatMap(([f]) => f).filter(
(filter) => filter.kinds?.some((kind) => [DM_GIFT_WRAP_KIND, LEGACY_DM_KIND].includes(kind)),
);
expect(filters).toHaveLength(3);
expect(filters).toContainEqual(
expect.objectContaining({ kinds: [DM_GIFT_WRAP_KIND], '#p': [expect.any(String)] }),
);
expect(filters).toContainEqual(
expect.objectContaining({ kinds: [LEGACY_DM_KIND], authors: [expect.any(String)] }),
);
expect(filters).toContainEqual(
expect.objectContaining({ kinds: [LEGACY_DM_KIND], '#p': [expect.any(String)] }),
);
});
it('drops gift wraps that do not decrypt instead of showing ghost conversations', async () => {
const { result } = await renderLoggedInDms((pubkey) => [
dmEvent({ id: 'bad', pubkey: bob, content: 'not a real ciphertext', tags: [['p', pubkey]] }),
]);
expect(result.current.dms.data).toEqual([]);
});
it('decrypts an end-to-end NIP-17 message and collapses duplicate deliveries', async () => {
// Log in as a user whose key we control: wrap a message from Alice to
// that key, then log in with the same key.
const userSecret = generateSecretKey();
const userPubkey = getPublicKey(userSecret);
const { wraps } = await buildGiftWraps(aliceSigner, userPubkey, 'hi there');
const { result } = renderHook(
() => ({ actions: useLoginActions(), nostr: useNostr(), dms: useDmConversations() }),
{ wrapper: TestApp },
);
await waitFor(() => expect(result.current).not.toBeNull());
// Only the wrap addressed to the user is "on the relay", delivered twice
// (partial/duplicate relay availability is normal).
const events = [wraps[0], wraps[0]];
vi.spyOn(result.current.nostr.nostr, 'query').mockImplementation(async () => events);
act(() => result.current.actions.nsec(nip19.nsecEncode(userSecret)));
await waitFor(() => expect(result.current.dms.isSuccess).toBe(true));
const conversations = result.current.dms.data ?? [];
expect(conversations).toHaveLength(1);
expect(conversations[0].peer).toBe(alice);
expect(conversations[0].messages).toHaveLength(1);
expect(conversations[0].messages[0].content).toBe('hi there');
expect(conversations[0].messages[0].protocol).toBe('nip17');
expect(conversations[0].messages[0].mine).toBe(false);
});
});
describe('groupIntoConversations', () => {
it('groups by peer and orders newest first', () => {
const conversations = groupIntoConversations([
message({ id: '1', peer: alice, created_at: 100 }),
message({ id: '2', peer: bob, created_at: 300 }),
message({ id: '3', peer: alice, created_at: 200 }),
]);
expect(conversations.map((c) => c.peer)).toEqual([bob, alice]);
expect(conversations[1].messages.map((m) => m.id)).toEqual(['3', '1']);
expect(conversations[0].lastAt).toBe(300);
});
});

View File

@@ -0,0 +1,338 @@
import { useNostr } from '@nostrify/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { nip05 } from 'nostr-tools';
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
import {
buildGiftWraps,
decryptLegacyDm,
dmCapability,
dmPeer,
unwrapGiftWrap,
DM_GIFT_WRAP_KIND,
DM_INBOX_RELAYS_KIND,
LEGACY_DM_KIND,
type DmMessage,
type DmRecipient,
} from '@/lib/dm';
import { useCurrentUser } from './useCurrentUser';
import { useLocalStorage } from './useLocalStorage';
import { useNostrPublish } from './useNostrPublish';
const QUERY_LIMIT = 300;
function dmsQueryKey(pubkey: string | undefined) {
return ['nostr', 'dms', pubkey ?? ''] as const;
}
export interface DmConversation {
/** The other party's pubkey. */
peer: string;
/** Newest message first. */
messages: DmMessage[];
/** Unix timestamp of the newest message, for list ordering. */
lastAt: number;
}
/**
* A conversation exists once at least one message decrypts successfully — an
* event that does not decrypt does not create an entry, so tampered or
* mistargeted ciphertexts never surface as ghost conversations.
*/
export function groupIntoConversations(messages: DmMessage[]): DmConversation[] {
const byPeer = new Map<string, DmMessage[]>();
for (const message of messages) {
const list = byPeer.get(message.peer) ?? [];
list.push(message);
byPeer.set(message.peer, list);
}
return [...byPeer.entries()]
.map(([peer, peerMessages]) => ({
peer,
messages: peerMessages.sort((a, b) => b.created_at - a.created_at),
lastAt: Math.max(...peerMessages.map((m) => m.created_at)),
}))
.sort((a, b) => b.lastAt - a.lastAt);
}
/**
* Fetches gift-wrapped and legacy DMs involving the current user and decrypts
* them. The pool subscribes to kind 1059, so messages that arrive after the
* first fetch (relay delay) are added to the result as they come in; relays
* deduplicate by event id per NIP-01, and the rumor-id dedupe below collapses
* the rare re-wrap of the same message.
*
* Plaintexts are intentionally **not** persisted to localStorage or the query
* cache's storage — they live in memory only, so a device with the key is
* required to re-read history.
*/
export function useDmConversations() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
return useQuery<DmConversation[]>({
queryKey: dmsQueryKey(user?.pubkey),
enabled: Boolean(user),
queryFn: async ({ signal }) => {
if (!user) return [];
const events = await nostr.query(
[
{ kinds: [DM_GIFT_WRAP_KIND], '#p': [user.pubkey], limit: QUERY_LIMIT },
{ kinds: [LEGACY_DM_KIND], authors: [user.pubkey], limit: QUERY_LIMIT },
{ kinds: [LEGACY_DM_KIND], '#p': [user.pubkey], limit: QUERY_LIMIT },
],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
const messages = await decryptDmEvents(user, events);
return groupIntoConversations(messages);
},
// Fresh on mount and kept live by the pool's subscription; a short stale
// time keeps opening the app snappy without hammering relays.
staleTime: 15_000,
});
}
/**
* Decrypts every DM event for the viewer, dropping anything undecryptable or
* malformed. Rumors re-wrapped under different gift wraps collapse onto one
* message by rumor id.
*/
async function decryptDmEvents(
user: { pubkey: string; signer: NostrSigner },
events: NostrEvent[],
): Promise<DmMessage[]> {
const messages: DmMessage[] = [];
const seenRumors = new Set<string>();
for (const event of events) {
if (event.kind === DM_GIFT_WRAP_KIND) {
const rumor = await unwrapGiftWrap(user.signer, event, user.pubkey);
if (!rumor || seenRumors.has(rumor.id)) continue;
const peer = dmPeer(rumor, user.pubkey);
if (!peer) continue;
seenRumors.add(rumor.id);
messages.push({
id: rumor.id,
pubkey: rumor.pubkey,
created_at: rumor.created_at,
content: rumor.content,
peer,
protocol: 'nip17',
mine: rumor.pubkey === user.pubkey,
eventId: event.id,
});
} else if (event.kind === LEGACY_DM_KIND) {
const peer = dmPeer(event, user.pubkey);
if (!peer) continue;
const content = await decryptLegacyDm(user.signer, event, user.pubkey);
if (content === null) continue;
messages.push({
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
content,
peer,
protocol: 'nip04',
mine: event.pubkey === user.pubkey,
eventId: event.id,
});
}
}
return messages;
}
/** All messages in one conversation, oldest first. */
export function useDmMessages(peer: string | undefined) {
const conversations = useDmConversations();
const conversation = peer
? conversations.data?.find((entry) => entry.peer === peer)
: undefined;
return {
...conversations,
conversation,
messages: conversation ? [...conversation.messages].reverse() : [],
};
}
/** What a send attempt is currently doing, for the bubble state. */
export type DmSendStatus = 'pending' | 'failed' | 'sent';
export interface SendDirectMessageInput {
peer: string;
content: string;
}
/**
* The recipient's kind 10050 inbox relays. NIP-17 says to publish only to
* those; when none are advertised we fall back to the sender's own relays so
* the sender copy still lands somewhere, and the UI explains that the
* recipient may not be reachable.
*/
async function fetchInboxRelays(
nostr: ReturnType<typeof useNostr>['nostr'],
pubkey: string,
): Promise<string[]> {
try {
const [event] = await nostr.query(
[{ kinds: [DM_INBOX_RELAYS_KIND], authors: [pubkey], limit: 1 }],
{ signal: AbortSignal.timeout(3000) },
);
if (!event) return [];
return event.tags
.filter(([name, value]) => name === 'relay' && /^wss?:\/\//.test(value ?? ''))
.map(([, value]) => value)
.slice(0, 3);
} catch {
return [];
}
}
/** Error thrown when the recipient advertises no inbox relays, so the UI can give recovery guidance. */
export class DmRecipientUnreachableError extends Error {
constructor(peer: string) {
super(
`This user has not announced where they receive private messages (no kind ${DM_INBOX_RELAYS_KIND} inbox relay list). The message was stored on your relays only — they may not see it until their client checks your relays. Ask them which client they use, or send a public note pointing them here.`,
);
this.name = 'DmRecipientUnreachableError';
this.peer = peer;
}
peer: string;
}
/**
* Sends a direct message. NIP-17 is used whenever the signer supports NIP-44;
* a signer without it falls back to legacy NIP-04 — visibly, never silently:
* the caller reads `capability` to show the notice and the sent bubble is
* labelled. If neither is available the mutation throws instead of sending
* something the user did not agree to.
*/
export function useSendDirectMessage() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const capability = user ? dmCapability(user.signer) : 'none';
const mutation = useMutation({
mutationFn: async ({ peer, content }: SendDirectMessageInput) => {
if (!user) throw new Error('You must be logged in to send messages');
const trimmed = content.trim();
if (!trimmed) throw new Error('Cannot send an empty message');
if (dmCapability(user.signer) === 'nip17') {
const inboxRelays = await fetchInboxRelays(nostr, peer);
const { wraps } = await buildGiftWraps(user.signer, peer, trimmed, inboxRelays);
if (inboxRelays.length > 0) {
// NIP-17: recipient copy to their inbox relays, sender copy to ours.
await nostr.event(wraps[0], {
signal: AbortSignal.timeout(5000),
relays: inboxRelays,
});
await nostr.event(wraps[1], { signal: AbortSignal.timeout(5000) });
} else {
// No inbox list: keep both copies on our own relays so the sender
// at least has a recoverable history, then say so.
for (const wrap of wraps) {
await nostr.event(wrap, { signal: AbortSignal.timeout(5000) });
}
throw new DmRecipientUnreachableError(peer);
}
return { protocol: 'nip17' as const };
}
if (user.signer.nip04) {
const ciphertext = await user.signer.nip04.encrypt(peer, trimmed);
const event = await user.signer.signEvent({
kind: LEGACY_DM_KIND,
content: ciphertext,
tags: [['p', peer]],
created_at: Math.floor(Date.now() / 1000),
});
await nostr.event(event, { signal: AbortSignal.timeout(5000) });
return { protocol: 'nip04' as const };
}
throw new Error('This signer cannot encrypt messages');
},
onSettled: () => {
// Re-decrypt so the sent message appears from the same pipeline as
// received ones — the sender copy is another event the pool returns.
void queryClient.invalidateQueries({ queryKey: dmsQueryKey(user?.pubkey) });
},
});
return { ...mutation, capability };
}
/**
* Resolves a recipient field to a hex pubkey: hex / npub / nprofile decode
* locally; a NIP-05 address goes to the network and can fail, which the
* caller turns into recovery guidance.
*/
export async function resolveRecipient(recipient: DmRecipient): Promise<string | null> {
if (recipient.type === 'pubkey') return recipient.pubkey;
if (recipient.type === 'nip05') {
try {
const pointer = await nip05.queryProfile(recipient.value);
return pointer && /^[0-9a-f]{64}$/.test(pointer.pubkey) ? pointer.pubkey : null;
} catch {
return null;
}
}
return null;
}
/**
* Hides a conversation from the list. Relays are asked to delete the copies
* we published (NIP-09 kind 5, best-effort — relays may refuse, and nothing
* can recall a message the recipient already has). Received gift wraps belong
* to throwaway keys and cannot be deletion-requested, so the conversation is
* additionally hidden locally on this device.
*/
export function useHideDmConversation() {
const { user } = useCurrentUser();
const publish = useNostrPublish();
const queryClient = useQueryClient();
const [hidden, setHidden] = useLocalStorage<string[]>(
`nostr:dm-hidden:${user?.pubkey ?? 'anonymous'}`,
[],
);
const mutation = useMutation({
mutationFn: async (conversation: DmConversation) => {
const deletable = conversation.messages
.filter((message) => message.mine)
.map((message) => message.eventId);
if (deletable.length > 0) {
await publish.mutateAsync({
kind: 5,
content: 'Conversation hidden',
tags: deletable.map((id) => ['e', id]),
});
}
},
onSuccess: (_data, conversation) => {
if (!hidden.includes(conversation.peer)) setHidden([...hidden, conversation.peer]);
void queryClient.invalidateQueries({ queryKey: dmsQueryKey(user?.pubkey) });
},
});
return { ...mutation, hidden };
}
/** Per-conversation "read up to" timestamps, on this device only. */
export function useDmReadState() {
const { user } = useCurrentUser();
const [readAt, setReadAt] = useLocalStorage<Record<string, number>>(
`nostr:dm-read:${user?.pubkey ?? 'anonymous'}`,
{},
);
return {
isUnread: (conversation: DmConversation) =>
conversation.messages.some(
(message) => !message.mine && message.created_at > (readAt[conversation.peer] ?? 0),
),
markRead: (peer: string, at: number) => setReadAt({ ...readAt, [peer]: at }),
};
}

239
src/lib/dm.test.ts Normal file
View File

@@ -0,0 +1,239 @@
import { describe, expect, it } from 'vitest';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { NSecSigner, type NostrEvent, type NostrSigner } from '@nostrify/nostrify';
import {
buildGiftWraps,
decryptLegacyDm,
dmCapability,
dmCapabilityHint,
dmPeer,
isValidChatMessage,
parseRecipient,
unwrapGiftWrap,
DM_CHAT_KIND,
DM_GIFT_WRAP_KIND,
DM_SEAL_KIND,
LEGACY_DM_KIND,
} from './dm';
const aliceSecret = generateSecretKey();
const alice = getPublicKey(aliceSecret);
const bobSecret = generateSecretKey();
const bob = getPublicKey(bobSecret);
const aliceSigner = new NSecSigner(aliceSecret);
const bobSigner = new NSecSigner(bobSecret);
function event(overrides: Partial<NostrEvent>): NostrEvent {
return { id: 'x', pubkey: alice, created_at: 1000, kind: DM_CHAT_KIND, tags: [], content: '', sig: '', ...overrides };
}
describe('dmCapability', () => {
it('prefers NIP-17 when NIP-44 is available', () => {
expect(dmCapability(new NSecSigner(generateSecretKey()))).toBe('nip17');
});
it('falls back to legacy NIP-04 when only nip04 exists', () => {
const signer: NostrSigner = {
getPublicKey: async () => alice,
signEvent: async () => event({}),
nip04: { encrypt: async () => '', decrypt: async () => '' },
};
expect(dmCapability(signer)).toBe('nip04');
});
it('is none without any encryption support', () => {
const signer: NostrSigner = {
getPublicKey: async () => alice,
signEvent: async () => event({}),
};
expect(dmCapability(signer)).toBe('none');
expect(dmCapability(undefined)).toBe('none');
});
it('labels nip04 as the visible legacy fallback, never a silent one', () => {
expect(dmCapabilityHint('nip17')).toBe('');
expect(dmCapabilityHint('nip04')).toMatch(/legacy NIP-04/);
expect(dmCapabilityHint('none')).toMatch(/unavailable/);
});
});
describe('parseRecipient', () => {
it('accepts a hex pubkey, lowercased', () => {
expect(parseRecipient(alice.toUpperCase())).toEqual({ type: 'pubkey', pubkey: alice });
});
it('accepts an npub', () => {
expect(parseRecipient(nip19.npubEncode(bob))).toEqual({ type: 'pubkey', pubkey: bob });
});
it('accepts an nprofile and keeps its relay hints', () => {
const nprofile = nip19.nprofileEncode({ pubkey: bob, relays: ['wss://relay.example.com'] });
expect(parseRecipient(nprofile)).toEqual({
type: 'pubkey',
pubkey: bob,
relays: ['wss://relay.example.com'],
});
});
it('accepts a NIP-05 address', () => {
expect(parseRecipient('Alice@Example.COM')).toEqual({ type: 'nip05', value: 'alice@example.com' });
});
it('rejects note/nevent/naddr identifiers — they are not people', () => {
const note = nip19.noteEncode('a'.repeat(64));
expect(parseRecipient(note).type).toBe('invalid');
const nevent = nip19.neventEncode({ id: 'a'.repeat(64) });
expect(parseRecipient(nevent.type ? nevent : '').type).toBe('invalid');
});
it('rejects an nsec — a secret must never be typed into a recipient field', () => {
expect(parseRecipient(nip19.nsecEncode(generateSecretKey())).type).toBe('invalid');
});
it('rejects empty and arbitrary input', () => {
expect(parseRecipient('').type).toBe('invalid');
expect(parseRecipient(' ').type).toBe('invalid');
expect(parseRecipient('not a key at all').type).toBe('invalid');
});
});
describe('isValidChatMessage / dmPeer', () => {
it('requires kind 14, content and a recipient tag', () => {
expect(isValidChatMessage(event({ content: 'hi', tags: [['p', bob]] }))).toBe(true);
expect(isValidChatMessage(event({ content: ' ', tags: [['p', bob]] }))).toBe(false);
expect(isValidChatMessage(event({ content: 'hi' }))).toBe(false);
expect(isValidChatMessage(event({ content: 'hi', tags: [['p', bob]], kind: 15 }))).toBe(false);
});
it('the peer of an outgoing message is the recipient, of an incoming one the author', () => {
const outgoing = event({ pubkey: alice, tags: [['p', bob]] });
expect(dmPeer(outgoing, alice)).toBe(bob);
expect(dmPeer(outgoing, bob)).toBe(alice);
});
it('a self-message has no peer and is not a conversation', () => {
const self = event({ pubkey: alice, tags: [['p', alice]] });
expect(dmPeer(self, alice)).toBeUndefined();
});
});
describe('buildGiftWraps / unwrapGiftWrap', () => {
it('round-trips a message to the recipient with full NIP-17 shape', async () => {
const { wraps, rumorId } = await buildGiftWraps(aliceSigner, bob, 'secret hello');
// One wrap for the recipient, one sender copy.
expect(wraps).toHaveLength(2);
const [recipientWrap, senderWrap] = wraps;
for (const wrap of wraps) {
expect(wrap.kind).toBe(DM_GIFT_WRAP_KIND);
// The wrap is signed by a throwaway key, never by Alice.
expect(wrap.pubkey).not.toBe(alice);
expect(wrap.pubkey).not.toBe(bob);
// NIP-59: timestamps randomized up to two days in the past.
expect(wrap.created_at).toBeLessThanOrEqual(Math.floor(Date.now() / 1000));
}
// The two wraps must not share the throwaway key, or relays could link them.
expect(recipientWrap.pubkey).not.toBe(senderWrap.pubkey);
expect(recipientWrap.tags).toEqual([['p', bob]]);
expect(senderWrap.tags).toEqual([['p', alice]]);
const rumor = await unwrapGiftWrap(bobSigner, recipientWrap, bob);
expect(rumor).not.toBeNull();
expect(rumor!.kind).toBe(DM_CHAT_KIND);
expect(rumor!.pubkey).toBe(alice);
expect(rumor!.content).toBe('secret hello');
expect(rumor!.tags[0][0]).toBe('p');
expect(rumor!.tags[0][1]).toBe(bob);
expect(rumor!.id).toBe(rumorId);
});
it('the sender copy decrypts for the sender, so history is recoverable', async () => {
const { wraps } = await buildGiftWraps(aliceSigner, bob, 'recoverable');
const rumor = await unwrapGiftWrap(aliceSigner, wraps[1], alice);
expect(rumor?.content).toBe('recoverable');
expect(dmPeer(rumor!, alice)).toBe(bob);
});
it('attaches the recipient inbox relay as a hint on the rumor p tag', async () => {
const { wraps } = await buildGiftWraps(aliceSigner, bob, 'hi', ['wss://inbox.example.com']);
const rumor = await unwrapGiftWrap(bobSigner, wraps[0], bob);
expect(rumor!.tags[0]).toEqual(['p', bob, 'wss://inbox.example.com']);
});
it('does not decrypt for a third party', async () => {
const { wraps } = await buildGiftWraps(aliceSigner, bob, 'not for you');
const eve = new NSecSigner(generateSecretKey());
expect(await unwrapGiftWrap(eve, wraps[0], getPublicKey(generateSecretKey()))).toBeNull();
});
it('rejects a rumor whose author does not match the seal — the NIP-17 impersonation check', async () => {
// Forge a wrap whose rumor claims a different author than the seal's.
const forger = new NSecSigner(generateSecretKey());
const forgerPubkey = await forger.getPublicKey();
const rumor = event({
pubkey: bob, // claims to be Bob…
content: 'spoofed',
tags: [['p', forgerPubkey]],
});
const seal = await forger.signEvent({
kind: DM_SEAL_KIND,
content: await forger.nip44.encrypt(forgerPubkey, JSON.stringify(rumor)),
tags: [],
created_at: 1000,
});
const wrap = await forger.signEvent({
kind: DM_GIFT_WRAP_KIND,
content: await forger.nip44.encrypt(forgerPubkey, JSON.stringify(seal)),
tags: [['p', forgerPubkey]],
created_at: 1000,
});
expect(await unwrapGiftWrap(forger, wrap, forgerPubkey)).toBeNull();
});
it('rejects a rumor that is not addressed to the unwrap peer', async () => {
// A wrap for Carol containing a rumor between Alice and Bob.
const carolSecret = generateSecretKey();
const carol = getPublicKey(carolSecret);
const { rumorId } = await buildGiftWraps(aliceSigner, bob, 'carol sees this');
void rumorId;
const seal = await aliceSigner.signEvent({
kind: DM_SEAL_KIND,
content: await aliceSigner.nip44.encrypt(carol, JSON.stringify(
event({ pubkey: alice, content: 'carol sees this', tags: [['p', bob]], id: '', sig: '' }),
)),
tags: [],
created_at: 1000,
});
const wrapSigner = new NSecSigner(generateSecretKey());
const wrap = await wrapSigner.signEvent({
kind: DM_GIFT_WRAP_KIND,
content: await wrapSigner.nip44.encrypt(carol, JSON.stringify(seal)),
tags: [['p', carol]],
created_at: 1000,
});
expect(await unwrapGiftWrap(new NSecSigner(carolSecret), wrap, carol)).toBeNull();
});
it('returns null instead of throwing on malformed ciphertext', async () => {
const wrap = event({ kind: DM_GIFT_WRAP_KIND, pubkey: alice, content: 'not encrypted', tags: [['p', bob]] });
expect(await unwrapGiftWrap(bobSigner, wrap, bob)).toBeNull();
});
});
describe('decryptLegacyDm', () => {
it('round-trips a NIP-04 message in both directions', async () => {
const ciphertext = await aliceSigner.nip04.encrypt(bob, 'legacy hi');
const incoming = event({ kind: LEGACY_DM_KIND, pubkey: alice, content: ciphertext, tags: [['p', bob]] });
expect(await decryptLegacyDm(bobSigner, incoming, bob)).toBe('legacy hi');
const outgoing = event({ kind: LEGACY_DM_KIND, pubkey: alice, content: ciphertext, tags: [['p', bob]] });
expect(await decryptLegacyDm(aliceSigner, outgoing, alice)).toBe('legacy hi');
});
it('returns null for malformed ciphertext instead of throwing', async () => {
const broken = event({ kind: LEGACY_DM_KIND, pubkey: alice, content: 'garbage', tags: [['p', bob]] });
expect(await decryptLegacyDm(bobSigner, broken, bob)).toBeNull();
});
});

251
src/lib/dm.ts Normal file
View File

@@ -0,0 +1,251 @@
import { generateSecretKey, getEventHash, nip19 } from 'nostr-tools';
import { NSecSigner, type NostrEvent, type NostrSigner } from '@nostrify/nostrify';
/**
* Direct messages follow NIP-17: the plaintext lives in an unsigned kind 14
* "chat message", which is NIP-44-encrypted into a kind 13 seal signed by the
* sender, which in turn is encrypted to the recipient and wrapped in a kind
* 1059 gift wrap signed by a throwaway key. Relays only ever see the gift
* wrap, so sender identity and conversation membership stay off the wire.
*
* NIP-04 (kind 4) is the deprecated predecessor: weaker crypto, and sender +
* recipient are public in the event tags. It is kept for reading old messages
* and as an explicitly-labelled send fallback for signers without NIP-44 —
* never silently, see `useSendDirectMessage`.
*/
export const DM_CHAT_KIND = 14;
export const DM_SEAL_KIND = 13;
export const DM_GIFT_WRAP_KIND = 1059;
export const DM_INBOX_RELAYS_KIND = 10050;
export const LEGACY_DM_KIND = 4;
const HEX_64_RE = /^[0-9a-f]{64}$/i;
const NIP05_RE = /^(?:[a-z0-9._-]+)@(?:[a-z0-9-]+(?:\.[a-z0-9-]+)+)$/i;
/** NIP-59 timestamps are randomized up to two days into the past. */
const TWO_DAYS_S = 2 * 24 * 60 * 60;
function randomizeTimestamp(now = Date.now()): number {
return Math.floor(now / 1000 - Math.random() * TWO_DAYS_S);
}
/** The encryption a signer can offer for direct messages. */
export type DmCapability = 'nip17' | 'nip04' | 'none';
/**
* NIP-44 is required for gift-wrapped messages; without any encryption method
* (a barebones extension or remote signer) DMs are impossible. Capability is
* probed once per session in the hook and never silently downgraded.
*/
export function dmCapability(signer: NostrSigner | undefined): DmCapability {
if (signer?.nip44) return 'nip17';
if (signer?.nip04) return 'nip04';
return 'none';
}
/** Why a signer cannot use NIP-17, for the notice in the Messages window. */
export function dmCapabilityHint(capability: DmCapability): string {
switch (capability) {
case 'nip17':
return '';
case 'nip04':
return 'Your signer cannot do modern NIP-17 encryption, so sending uses legacy NIP-04: it reveals who messaged whom and when. Use a signer with NIP-44 support (e.g. Alby or a native key) to keep that metadata private.';
case 'none':
return 'Your signer cannot encrypt messages at all, so sending is unavailable. Use a signer with NIP-44 support to send direct messages.';
}
}
export type DmRecipient =
| { type: 'pubkey'; pubkey: string; relays?: string[] }
| { type: 'nip05'; value: string }
| { type: 'invalid'; value: string };
/** Accepts hex, npub, nprofile or a NIP-05 address — everything else is rejected. */
export function parseRecipient(input: string): DmRecipient {
const value = input.trim();
if (!value) return { type: 'invalid', value };
if (HEX_64_RE.test(value)) return { type: 'pubkey', pubkey: value.toLowerCase() };
try {
const decoded = nip19.decode(value);
if (decoded.type === 'npub') return { type: 'pubkey', pubkey: decoded.data };
if (decoded.type === 'nprofile') {
return { type: 'pubkey', pubkey: decoded.data.pubkey, relays: decoded.data.relays };
}
// note/nevent/naddr point at events, not people; nsec is a secret that
// must never be typed into a recipient field.
return { type: 'invalid', value };
} catch {
// Not bech32 — maybe a NIP-05 address.
}
if (NIP05_RE.test(value)) return { type: 'nip05', value: value.toLowerCase() };
return { type: 'invalid', value };
}
/** A NIP-17 chat message is only renderable with content and a recipient tag. */
export function isValidChatMessage(event: NostrEvent): boolean {
return event.kind === DM_CHAT_KIND && event.content.trim().length > 0 && hasRecipientTag(event);
}
function hasRecipientTag(event: NostrEvent): boolean {
return event.tags.some(([name, value]) => name === 'p' && HEX_64_RE.test(value ?? ''));
}
/** The other party of a chat message, from the perspective of `viewer`. */
export function dmPeer(event: NostrEvent, viewer: string): string | undefined {
if (event.pubkey === viewer) {
const recipients = event.tags
.filter(([name, value]) => name === 'p' && HEX_64_RE.test(value ?? ''))
.map(([, value]) => value.toLowerCase());
// A self-DM (own pubkey as the only p tag) has no "other party" — and no
// one to reach anyway — so it is not a conversation.
return recipients.find((pubkey) => pubkey !== viewer);
}
return event.pubkey;
}
export interface GiftWrapResult {
/** Kind 1059 events: one per receiver, the sender's own copy last. */
wraps: NostrEvent[];
/** Id of the kind 14 rumor inside — the message's stable identity. */
rumorId: string;
}
/**
* Seals and gift-wraps a kind 14 chat message, then wraps one copy for the
* recipient and one for the sender (NIP-17 requires both: the sender copy is
* what makes history recoverable on another device). Each wrap is signed by a
* fresh throwaway key, so relays cannot link sender and receiver copies.
*
* `receiverRelays` (the recipient's kind 10050 inbox relays, when known) is
* attached as relay hints on the rumor's `p` tag.
*/
export async function buildGiftWraps(
senderSigner: NostrSigner,
recipientPubkey: string,
plaintext: string,
receiverRelays: string[] = [],
): Promise<GiftWrapResult> {
if (!senderSigner.nip44) {
throw new Error('Signer has no NIP-44 encryption');
}
const senderPubkey = await senderSigner.getPublicKey();
const rumor: NostrEvent = {
kind: DM_CHAT_KIND,
pubkey: senderPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', recipientPubkey, ...receiverRelays.slice(0, 1)]],
content: plaintext,
id: '',
sig: '',
};
const wrapFor = async (receiverPubkey: string): Promise<NostrEvent> => {
const seal = await senderSigner.signEvent({
kind: DM_SEAL_KIND,
content: await senderSigner.nip44!.encrypt(receiverPubkey, JSON.stringify(rumor)),
tags: [],
created_at: randomizeTimestamp(),
});
// A one-time key signs the wrap, so relays cannot tell who sent it.
const wrapSigner = new NSecSigner(generateSecretKey());
return wrapSigner.signEvent({
kind: DM_GIFT_WRAP_KIND,
content: await wrapSigner.nip44.encrypt(receiverPubkey, JSON.stringify(seal)),
tags: [['p', receiverPubkey]],
created_at: randomizeTimestamp(),
});
};
const wraps = [await wrapFor(recipientPubkey), await wrapFor(senderPubkey)];
return { wraps, rumorId: rumorId(rumor) };
}
/**
* A rumor is unsigned; its id is recomputed as the sha256 of the serialized
* event per NIP-01, so both sender and recipient refer to the same message.
*/
function rumorId(rumor: NostrEvent): string {
return getEventHash({
pubkey: rumor.pubkey,
created_at: rumor.created_at,
kind: rumor.kind,
tags: rumor.tags,
content: rumor.content,
});
}
/** A decrypted conversation message, provenance included. */
export interface DmMessage {
id: string;
/** Author of the (rumor/legacy) event. */
pubkey: string;
created_at: number;
content: string;
/** The other party's pubkey, from the viewer's perspective. */
peer: string;
/** Encryption the message arrived with. */
protocol: 'nip17' | 'nip04';
/** True for our own outgoing messages. */
mine: boolean;
/** Id of the enclosing gift wrap / kind 4 event, for deletion requests. */
eventId: string;
}
/** Unwraps a kind 1059 gift wrap into its kind 14 rumor, or null when malformed. */
export async function unwrapGiftWrap(
signer: NostrSigner,
wrap: NostrEvent,
viewer: string,
): Promise<NostrEvent | null> {
if (!signer.nip44) return null;
try {
const sealJson = await signer.nip44.decrypt(wrap.pubkey, wrap.content);
const seal = JSON.parse(sealJson) as NostrEvent;
if (seal.kind !== DM_SEAL_KIND || typeof seal.content !== 'string') return null;
const rumorJson = await signer.nip44.decrypt(seal.pubkey, seal.content);
const rumor = JSON.parse(rumorJson) as NostrEvent;
if (rumor.kind !== DM_CHAT_KIND) return null;
// NIP-17: the rumor's pubkey must match the seal's, otherwise anyone
// could impersonate anyone by rewriting the unsigned rumor.
if (rumor.pubkey !== seal.pubkey) return null;
if (!isValidChatMessage(rumor)) return null;
// The rumor's room (author + p tags) must contain the viewer — otherwise
// a wrap addressed to us would render a conversation between third
// parties as if it were ours.
if (!isRoomMember(rumor, viewer)) return null;
rumor.id = rumorId(rumor);
return rumor;
} catch {
return null;
}
}
function isRoomMember(rumor: NostrEvent, pubkey: string): boolean {
if (rumor.pubkey === pubkey) return true;
return rumor.tags.some(([name, value]) => name === 'p' && value?.toLowerCase() === pubkey);
}
/** Decrypts a legacy NIP-04 message; `peer` is the other party's pubkey. */
export async function decryptLegacyDm(
signer: NostrSigner,
event: NostrEvent,
viewer: string,
): Promise<string | null> {
if (!signer.nip04) return null;
const peer = dmPeer(event, viewer);
if (!peer) return null;
try {
return await signer.nip04.decrypt(peer, event.content);
} catch {
return null;
}
}
export { HEX_64_RE as DM_PUBKEY_RE };

View File

@@ -1,5 +1,5 @@
import { lazy } from 'react';
import { Activity, Bookmark, BookOpen, CalendarDays, FileText, Image, Info, Link2, Radio, Rss, Search, Settings, ShieldCheck, Sparkles, User } from 'lucide-react';
import { Activity, Bookmark, BookOpen, CalendarDays, FileText, Image, Info, Link2, MessagesSquare, Radio, Rss, Search, Settings, ShieldCheck, Sparkles, User } from 'lucide-react';
import type { AppDefinition } from './types';
/**
@@ -89,6 +89,17 @@ export const APPS: AppDefinition[] = [
defaultSize: { width: 600, height: 660 },
minSize: { width: 340, height: 300 },
},
{
id: 'messages',
title: 'Messages',
description: 'Private, encrypted direct messages (NIP-17)',
icon: MessagesSquare,
category: 'social',
component: lazy(() => import('@/apps/messages')),
defaultSize: { width: 760, height: 700 },
minSize: { width: 360, height: 320 },
requiresAuth: true,
},
{
id: 'live',
title: 'Live',