mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 13:43:01 +02:00
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:
committed by
GitHub
parent
ffc455f5b8
commit
d82adbfe0a
695
src/apps/messages/index.tsx
Normal file
695
src/apps/messages/index.tsx
Normal 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 someone’s 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>
|
||||
);
|
||||
}
|
||||
142
src/hooks/useDirectMessages.test.tsx
Normal file
142
src/hooks/useDirectMessages.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
338
src/hooks/useDirectMessages.ts
Normal file
338
src/hooks/useDirectMessages.ts
Normal 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
239
src/lib/dm.test.ts
Normal 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
251
src/lib/dm.ts
Normal 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 };
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user