From d82adbfe0a983614b00b5b74f5d4b097472d2e24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:34:22 +0000 Subject: [PATCH] Add NIP-17/NIP-04 direct-message protocol layer, hooks and Messages app Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --- src/apps/messages/index.tsx | 695 +++++++++++++++++++++++++++ src/hooks/useDirectMessages.test.tsx | 142 ++++++ src/hooks/useDirectMessages.ts | 338 +++++++++++++ src/lib/dm.test.ts | 239 +++++++++ src/lib/dm.ts | 251 ++++++++++ src/os/registry.ts | 13 +- 6 files changed, 1677 insertions(+), 1 deletion(-) create mode 100644 src/apps/messages/index.tsx create mode 100644 src/hooks/useDirectMessages.test.tsx create mode 100644 src/hooks/useDirectMessages.ts create mode 100644 src/lib/dm.test.ts create mode 100644 src/lib/dm.ts diff --git a/src/apps/messages/index.tsx b/src/apps/messages/index.tsx new file mode 100644 index 0000000..a9d8732 --- /dev/null +++ b/src/apps/messages/index.tsx @@ -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([]); + const [nextId, setNextId] = useState(1); + + const selected = params.peer ?? null; + + useEffect(() => setTitle('Messages'), [setTitle]); + + if (!user) { + return ; + } + + 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 = ( + + ); + + const detailPane = !selected ? ( + + ) : ( + 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 ( + + + {selected ? ( + <> + + + + ) : ( + <> + Messages + setComposeOpen(true)} /> + + )} + + {selected ? ( +
{detailPane}
+ ) : ( + {listPane} + )} + +
+ ); + } + + return ( + + + Messages + setComposeOpen(true)} /> + + + {listPane} +
{detailPane}
+
+ +
+ ); +} + +function NewMessageButton({ onClick, className }: { onClick: () => void; className?: string }) { + return ( + + ); +} + +/** Conversation list, with the privacy-preserving preview rules applied. */ +function ConversationList({ + query, + conversations, + unreadPeers, + selected, + onSelect, +}: { + query: ReturnType; + conversations: DmConversation[]; + unreadPeers: Set; + selected: string | null; + onSelect: (peer: string) => void; +}) { + if (query.isLoading) { + return ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ +
+ + +
+
+ ))} +
+ ); + } + + if (query.isError) { + return ( +
+ query.refetch()}> + Try again + + } + /> +
+ ); + } + + if (conversations.length === 0) { + return ( +
+ +
+ ); + } + + return ( + <> + Conversations +
    + {conversations.map((conversation) => ( +
  • + onSelect(conversation.peer)} + /> +
  • + ))} +
+ + ); +} + +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 ( + + ); +} + +/** + * 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 {displayName(pubkey, author.data?.metadata)}; +} + +/** 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 ( + <> +
+ + m.protocol === 'nip04') || capability === 'nip04' ? 'nip04' : 'nip17' + } + /> + +
+ + {capability !== 'nip17' && ( +

+ {dmCapabilityHint(capability)} +

+ )} + +
+ {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ ) : isError ? ( + refetch()}> + Try again + + } + /> + ) : messages.length === 0 && pending.length === 0 ? ( + + ) : ( +
    + {messages.map((message) => ( + + ))} + {pending.map((entry) => ( + { + onRetryPending(entry); + void deliver({ ...entry, status: 'pending' }); + }} + onDiscard={() => onRemovePending(entry)} + /> + ))} +
+ )} +
+ +
+