tinyrelaycommit

Commit b5738be3195e

browse files at this commit | history

1diff --git a/README.md b/README.md
2index 6005738..278c142 100644
3--- a/README.md
4+++ b/README.md
5@@ -10,7 +10,7 @@ flowchart LR
6 api --> tenant
7 subgraph tenant [one tenant]
8 policy[owner and policy] --> store[(durable store)]
9- store --> rooms[rooms]
10+ store --> rooms[chat rooms]
11 store --> wiki[wiki]
12 store --> repos[repositories]
13 store --> files[files and sites]
14@@ -59,7 +59,7 @@ Run `tiny templates` to see available templates. Templates that read from an ups
15
16 ## Configure and operate
17
18-The web interface uses plain HTML pages and forms. Connect a Nostr signer with NIP-07 or Nostr Connect to sign in and authorize management actions. Use the management pages to configure policy, members, relay connections, inbox and outbox jobs, backups, hosted sites, and stored files.
19+The web interface uses plain HTML pages and forms. Connect a Nostr signer with NIP-07 or Nostr Connect to sign in and authorize management actions. **Chat** combines group rooms with one-to-one conversations. Use the management pages to configure policy, members, relay connections, delivery jobs, backups, hosted sites, and stored files. Delivery and retry status lives under **Manage > Sync**.
20
21 The browsing pages provide repository lists, source files, branches, history, diffs, file previews, raw downloads, synchronization status, and storage status. Access follows the tenant's current permissions.
22
23@@ -69,7 +69,7 @@ See [Git collaboration](docs/git-collaboration.md) for issues, pull requests, co
24
25 See [Membership](docs/membership.md) for invites, joining with NIP-43 and reviewing access requests.
26
27-See [Rooms](docs/rooms.md) for chat rooms inside a relay, open and members-only access, room administration, connecting Buzz-compatible clients and agents, and the live room stream.
28+See [Chat](docs/rooms.md) for group rooms and one-to-one conversations, open and members-only access, room administration, NIP-17 direct messages, connecting Buzz-compatible clients and agents, and the live room stream.
29
30 See [Social](docs/social.md) for notes, long-form articles, NIP-10 and NIP-22 conversations, reactions, Markdown, media and mixed feeds.
31
32diff --git a/docs/app.md b/docs/app.md
33index 749af24..faf6acb 100644
34--- a/docs/app.md
35+++ b/docs/app.md
36@@ -16,9 +16,9 @@ The app registers for `web+nostr:` links. A link to an `npub`, `nprofile`, `note
37
38 ## Notifications
39
40-Notifications are off until you turn them on, and each device is enabled separately. Open **Inbox** and choose **Enable on this device**. The browser asks for permission at that moment, and the device is registered with a signed request. Choose **Disable on this device** to stop. The owner can choose **Send a test** to check delivery.
41+Notifications are off until you turn them on, and each device is enabled separately. Open **Chat** and choose **Enable on this device**. The browser asks for permission at that moment, and the device is registered with a signed request. Choose **Disable on this device** to stop. The owner can choose **Send a test** to check delivery.
42
43-Each device chooses what wakes it: private messages, replies to your posts, issues and pull requests, mentions, requests for a decision, and relay notices such as a report awaiting moderation or a finished job. A burst of activity produces one notification per category. The text is encrypted to the device, so the push service never reads it. The app icon shows how many conversation events arrived since you last opened Inbox, plus the requests that still wait for your decision. Opening Inbox clears the conversation count, and answering a request clears that request. Devices that stop accepting messages are removed.
44+Each device chooses what wakes it: private messages, replies to your posts, issues and pull requests, mentions, requests for a decision, and relay notices such as a report awaiting moderation or a finished job. A burst of activity produces one notification per category. The text is encrypted to the device, so the push service never reads it. The app icon shows how many conversation events arrived since you last opened Chat, plus the requests that still wait for your decision. Opening Chat clears the conversation count, and answering a request clears that request. Devices that stop accepting messages are removed.
45
46 A request for a decision arrives with **Approve**, **Deny** and **Reply** buttons on the notification; a question offers only **Reply**. Each button opens the request on the **Approvals** page, where Approve and Deny ask you to confirm before your signer signs the answer, and Reply opens the reply box. Nothing is signed from the notification alone. See [Asking a person](agents.md#asking-a-person) for how requests are made and answered.
47
48@@ -37,4 +37,4 @@ Rebuild the bundle after changing `scripts/nostr-name-setup.mjs` or updating the
49 ## Copying a page address
50
51 The crumb that names the current page, next to the relay name at the top on phones and in the footer on larger screens, copies the page's address when tapped and says "copied" for a moment. The address is the relay's public URL with the page's canonical path, so a link copied while browsing over a tailnet or LAN address still opens for anyone: wiki names are normalized the way the relay resolves them, and repositories and files keep the query that names them.
52-\n
53\ No newline at end of file
54+\n
55diff --git a/docs/files-and-private-repositories.md b/docs/files-and-private-repositories.md
56index 89292d6..ba4af34 100644
57--- a/docs/files-and-private-repositories.md
58+++ b/docs/files-and-private-repositories.md
59@@ -1,6 +1,6 @@
60 # Files and private repositories
61
62-Use **Files** to browse uploads by name, open folders, preview images and play supported audio and video files. **My files** shows your uploads and files shared with relay members. **Sites** groups published assets by site, and **Rooms** groups attachments by rooms you can access. Owners and moderators can open **Storage** for the full inventory.
63+Use **Files** to browse uploads by name, open folders, preview images and play supported audio and video files. **My files** shows your uploads and files shared with relay members. **Sites** groups published assets by site, and **Chat** groups attachments by rooms you can access. Owners and moderators can open **Storage** for the full inventory.
64
65 ## Uploading
66
67diff --git a/docs/rooms.md b/docs/rooms.md
68index c0287c5..1eaf24d 100644
69--- a/docs/rooms.md
70+++ b/docs/rooms.md
71@@ -1,6 +1,6 @@
72-# Rooms
73+# Chat
74
75-A relay hosts chat rooms for its members. Each room is a [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md) group with its own name, member list and admins, and any group chat client that speaks that dialect, including Buzz and agent gateways built on it, can take part.
76+A relay hosts conversations for its members. **Chat** brings together group rooms and one-to-one conversations in one place. Group rooms use [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md), with their own name, member list and admins. One-to-one conversations use encrypted [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) gift wraps and stay outside a room.
77
78 The relay's own group is the room whose id is the relay name. Its member list is the relay roster: the relay owner is the room owner and moderators are room admins. Its access follows the relay read rule, so a members-only relay has a members-only main room. The main room cannot be deleted.
79
80@@ -49,7 +49,7 @@ The relay accepts these kinds inside a room:
81 | 9005 | Delete messages: `e` tags naming messages in the room |
82 | 9008 | Delete the room |
83
84-Private messages between members travel as [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) gift wraps outside any room.
85+Private messages between members travel as [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) gift wraps outside any room. The relay stores and serves the ciphertext; the browser decrypts it with the connected NIP-44 signer and keeps plaintext only in memory. Both participants must advertise a kind 10050 DM relay list. Choose **Receive chats here** to add this relay to your existing list. The sender and recipient can then open the conversation at `/chat/dm/<pubkey>`.
86
87 The relay signs and publishes its own records for every room: 39000 with the room's name, description, picture, `closed` for a members-only room and `private` when the relay is members-only; 39001 listing owners and admins with their roles; and 39002 listing members. The relay also signs a 44100 notice when someone is added to a room and a 44101 notice when someone is removed, both tagged with the room's `h`. Clients cannot publish these kinds themselves. Records for a members-only room are served only to its members and the relay's moderators.
88
89@@ -57,7 +57,11 @@ Room events reach device notifications and relay push callbacks the same way as
90
91 ## In the web UI
92
93-**Rooms** in the relay navigation lists the rooms you can see with their access rule, member count and last message. Signed-in members create a room at the bottom of the list: the name becomes the room id, lowercased with punctuation replaced by hyphens, unless the **Id** field names one, and the room opens once the relay accepts it. Give the id yourself when a client expects a particular shape, such as a Buzz gateway that wants a UUID.
94+**Chat** in the relay navigation lists your group rooms and one-to-one conversations. Open a group room to see its access rule, member count and last message. Start a one-to-one conversation by entering the other person's public key. Signed-in members create a group room at the bottom of the room list: the name becomes the room id, lowercased with punctuation replaced by hyphens, unless the **Id** field names one, and the room opens once the relay accepts it. Give the id yourself when a client expects a particular shape, such as a Buzz gateway that wants a UUID.
95+
96+Direct conversations show replies, quoted messages and encrypted reactions. Choose **Reply** to answer a message or the heart to react. Encrypted file messages open on request, with previews for images, video, audio and text. Attachments are checked before decryption, and downloads up to 256 MiB are supported.
97+
98+One-to-one messages require a connected signer with NIP-44 support. The message body is encrypted before it leaves the browser, and the relay cannot search, preview or recover it. Group rooms continue to work for clients and agents that speak NIP-29. Existing room links at `/rooms/<id>` remain valid.
99
100 Open a room to read its messages, oldest first. Each message shows the author's name, their room role and the time; messages from agents carry an agent marker. Message text renders the common Markdown subset that people and agents type: paragraphs with line breaks, bold and italic, inline code and fenced code, lists, headings and links, and bare `https://` or `nostr:` references become links. Nothing else in a message is treated as markup. Links open in place and `nostr:` links resolve through the relay. A thread shows how many replies it has and opens on its own page, where replies read in order. Reactions appear under the message they answer, and an edited message shows its newest text with an edited marker. Choose **load earlier** for older messages.
101
102@@ -69,11 +73,11 @@ Room uploads follow the room's current access rule. A members-only room's files
103
104 Agents can call MCP `upload_attachment`, pass the returned descriptor to a room write tool and sign the resulting event to display generated media. `read_attachment` lets authorized agents retrieve incoming files. See [MCP room attachments](mcp.md#room-attachments).
105
106-The compose bar sits at the bottom of the column. Enter sends and Shift+Enter starts a new line. Mention a person with `@npub...` or `@<hex key>`; the relay notifies them. Sending, creating a room and every room action need JavaScript and a connected signer; without JavaScript the page still shows the newest messages.
107+The group-room compose bar sits at the bottom of the column. Enter sends and Shift+Enter starts a new line. Mention a person with `@npub...` or `@<hex key>`; the relay notifies them. Sending, creating a room and every room action need JavaScript and a connected signer; without JavaScript the page still shows the newest messages. One-to-one messages use the separate conversation composer and are available only after the signer is ready.
108
109 New messages arrive as they are accepted, and the page follows them when you are reading the end of the room. The rail lists your rooms with the age of each room's last message and a link back to the relay.
110
111-The panel shows the room's id, access rule and creation date, its members with their roles, and the actions your role allows: owners and admins add members and change the room's name, description, picture and access rule; members leave; others join an open room. On phones the panel is hidden and the rooms list opens from the menu.
112+The panel shows the room's id, access rule and creation date, its members with their roles, and the actions your role allows: owners and admins add members and change the room's name, description, picture and access rule; members leave; others join an open room. On phones the panel is hidden and the chat list opens from the menu. Direct-message settings are explicit: **Receive chats here** publishes this relay to the signed-in person's kind 10050 list without replacing other relays.
113
114 ## Browser tools
115
116diff --git a/docs/webmcp.md b/docs/webmcp.md
117index afb164b..f5b275c 100644
118--- a/docs/webmcp.md
119+++ b/docs/webmcp.md
120@@ -2,7 +2,7 @@
121
122 Every page registers tools for your browser agent; **Manage > Health** reports whether they are ready, and Chrome's Application panel lists them with their calls. The agent can browse repositories, files, issues and pull requests, read chat rooms and threads, list and read wiki pages and merge requests, list the requests that wait for your decision, review access requests, follow long tasks, inspect service status, manage background jobs, create backups, update relay settings, open Nostr links, the Files page, rooms, wiki pages and the Approvals page, read this device's notification state and, as the owner, send a test notification. Turning notifications on stays a manual step, since the browser asks the person for permission.
123
124-## Rooms
125+## Chat rooms
126
127 Rooms have their own tools. `tiny.list_rooms` lists the rooms you can see, `tiny.read_room` reads a room's members and newest messages, `tiny.read_thread` reads a thread's root and replies, and `tiny.open_room` opens a room in this tab. `tiny.post_message` posts a chat message to a room; it signs the message with your connected signer and lists mentioned keys as `p` tags so the relay notifies them.
128
129diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go
130index ca82cb3..d9fc298 100644
131--- a/internal/daemon/client_browse.go
132+++ b/internal/daemon/client_browse.go
133@@ -34,7 +34,7 @@ type clientBrowseRequest struct {
134 }
135
136 func clientBrowseMethod(method string) bool {
137- return wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || socialBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent", "browseprofile"}, method)
138+ return wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || socialBrowseMethod(method) || directMessagesBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent", "browseprofile"}, method)
139 }
140
141 func (t *Tenant) browseRead(ctx context.Context, actor string) error {
142@@ -70,6 +70,9 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params
143 if socialBrowseMethod(method) {
144 return t.executeSocial(ctx, actor, method, params)
145 }
146+ if directMessagesBrowseMethod(method) {
147+ return t.executeDirectMessages(ctx, actor, params)
148+ }
149 q := clientBrowseRequest{}
150 if len(params) > 0 {
151 if err := json.Unmarshal(params[0], &q); err != nil {
152diff --git a/internal/daemon/direct_messages.go b/internal/daemon/direct_messages.go
153new file mode 100644
154index 0000000..a6ab4bd
155--- /dev/null
156+++ b/internal/daemon/direct_messages.go
157@@ -0,0 +1,76 @@
158+package daemon
159+
160+import (
161+ "context"
162+ "encoding/json"
163+ "errors"
164+ "fmt"
165+ "time"
166+
167+ "github.com/FelineStateMachine/tinyrelay/internal/event"
168+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
169+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
170+)
171+
172+type directMessagesRequest struct {
173+ Cursor string `json:"cursor"`
174+ Limit int `json:"limit"`
175+}
176+
177+func directMessagesBrowseMethod(method string) bool { return method == "browsedirectmessages" }
178+
179+func (t *Tenant) executeDirectMessages(ctx context.Context, actor string, params []json.RawMessage) (any, error) {
180+ if actor == "" || !hex64(actor) {
181+ return nil, errors.New("auth-required: signed actor")
182+ }
183+ request := directMessagesRequest{Limit: 50}
184+ if len(params) > 0 {
185+ if err := json.Unmarshal(params[0], &request); err != nil {
186+ return nil, fmt.Errorf("invalid: direct message parameters: %w", err)
187+ }
188+ }
189+ if request.Limit <= 0 || request.Limit > 100 {
190+ request.Limit = 50
191+ }
192+ cursor, err := parseCollaborationCursor(request.Cursor)
193+ if err != nil {
194+ return nil, errors.New("invalid: direct message cursor")
195+ }
196+ return t.browseDirectMessages(ctx, actor, cursor, request.Limit)
197+}
198+
199+func (t *Tenant) browseDirectMessages(ctx context.Context, actor string, cursor *storage.EventCursor, limit int) (any, error) {
200+ filter := event.Filter{Kinds: []int{event.KIND_WRAP}, Tags: map[string][]string{"p": {actor}}}
201+ session := relay.Session{PubKeys: []string{actor}, RelayURL: t.RelayURL()}
202+ if _, err := t.gate.Read(ctx, []event.Filter{filter}, session); err != nil {
203+ return nil, err
204+ }
205+ rows := make([]event.Event, 0, limit)
206+ next := ""
207+ for scans := 0; scans < 100; scans++ {
208+ page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: []string{actor}}, Limit: 100, Before: cursor})
209+ if err != nil {
210+ return nil, err
211+ }
212+ for index, row := range page.Events {
213+ cursor = &storage.EventCursor{CreatedAt: row.CreatedAt, ID: row.ID}
214+ if !t.gate.CanSee(ctx, row, session, &filter) {
215+ continue
216+ }
217+ rows = append(rows, row)
218+ if len(rows) == limit {
219+ if page.More || index+1 < len(page.Events) {
220+ next = collaborationCursor(collaborationItem{CreatedAt: row.CreatedAt, ID: row.ID})
221+ }
222+ return map[string]any{"events": rows, "next_cursor": next}, nil
223+ }
224+ }
225+ if !page.More || len(page.Events) == 0 {
226+ return map[string]any{"events": rows, "next_cursor": ""}, nil
227+ }
228+ }
229+ if cursor != nil {
230+ next = collaborationCursor(collaborationItem{CreatedAt: cursor.CreatedAt, ID: cursor.ID})
231+ }
232+ return map[string]any{"events": rows, "next_cursor": next}, nil
233+}
234diff --git a/internal/daemon/direct_messages_test.go b/internal/daemon/direct_messages_test.go
235new file mode 100644
236index 0000000..5a8ae07
237--- /dev/null
238+++ b/internal/daemon/direct_messages_test.go
239@@ -0,0 +1,115 @@
240+package daemon
241+
242+import (
243+ "context"
244+ "encoding/json"
245+ "strings"
246+ "testing"
247+
248+ "github.com/FelineStateMachine/tinyrelay/internal/event"
249+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
250+)
251+
252+func TestBrowseDirectMessagesRequiresRecipientAndPaginates(t *testing.T) {
253+ _, tenant := testTenant(t)
254+ actorSecret := strings.Repeat("a", 63) + "1"
255+ otherSecret := strings.Repeat("b", 63) + "2"
256+ actor := signedEvent(t, actorSecret, event.KIND_WRAP, 100, [][]string{{"p", strings.Repeat("c", 64)}}, "ciphertext").Tags[0][1]
257+ recipient := actor
258+ first := signedEvent(t, otherSecret, event.KIND_WRAP, 200, [][]string{{"p", recipient}}, "first")
259+ second := signedEvent(t, otherSecret, event.KIND_WRAP, 200, [][]string{{"p", recipient}}, "second")
260+ third := signedEvent(t, otherSecret, event.KIND_WRAP, 199, [][]string{{"p", recipient}}, "third")
261+ for _, row := range []event.Event{first, second, third} {
262+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {
263+ t.Fatal(err)
264+ }
265+ }
266+ value, err := tenant.Execute(context.Background(), recipient, "browsedirectmessages", []json.RawMessage{rawJSON(map[string]any{"limit": 2})})
267+ if err != nil {
268+ t.Fatal(err)
269+ }
270+ page := value.(map[string]any)
271+ events := page["events"].([]event.Event)
272+ if len(events) != 2 || events[0].ID != first.ID || events[1].ID != second.ID {
273+ t.Fatalf("first page = %#v", events)
274+ }
275+ next := page["next_cursor"].(string)
276+ value, err = tenant.Execute(context.Background(), recipient, "browsedirectmessages", []json.RawMessage{rawJSON(map[string]any{"limit": 2, "cursor": next})})
277+ if err != nil {
278+ t.Fatal(err)
279+ }
280+ page = value.(map[string]any)
281+ events = page["events"].([]event.Event)
282+ if len(events) != 1 || events[0].ID != third.ID || page["next_cursor"].(string) != "" {
283+ t.Fatalf("second page = %#v", page)
284+ }
285+ if events[0].Content != "third" {
286+ t.Fatalf("message content changed: %q", events[0].Content)
287+ }
288+}
289+
290+func TestBrowseDirectMessagesDoesNotUseOwnerBypass(t *testing.T) {
291+ _, tenant := testTenant(t)
292+ owner := tenant.Policy().Owner
293+ other := strings.Repeat("d", 64)
294+ row := signedEvent(t, strings.Repeat("e", 63)+"f", event.KIND_WRAP, 100, [][]string{{"p", other}}, "secret")
295+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {
296+ t.Fatal(err)
297+ }
298+ if _, err := tenant.Execute(context.Background(), owner, "browsedirectmessages", []json.RawMessage{rawJSON(map[string]any{"limit": 10})}); err != nil {
299+ t.Fatal(err)
300+ }
301+ page := mustDirectMessagePage(t, tenant, owner)
302+ if len(page["events"].([]event.Event)) != 0 {
303+ t.Fatal("owner saw a message addressed to another user")
304+ }
305+}
306+
307+func TestBrowseDirectMessagesRequiresValidActor(t *testing.T) {
308+ _, tenant := testTenant(t)
309+ for _, actor := range []string{"", "not-a-pubkey"} {
310+ if _, err := tenant.Execute(context.Background(), actor, "browsedirectmessages", nil); err == nil || !strings.HasPrefix(err.Error(), "auth-required:") {
311+ t.Fatalf("actor %q error = %v", actor, err)
312+ }
313+ }
314+}
315+
316+func TestBrowseDirectMessagesFiltersRecipientAndKind(t *testing.T) {
317+ _, tenant := testTenant(t)
318+ actorSecret := strings.Repeat("1", 63) + "2"
319+ actor := signedEvent(t, actorSecret, event.KIND_WRAP, 99, nil, "").PubKey
320+ actorMessage := signedEvent(t, actorSecret, event.KIND_WRAP, 100, [][]string{{"p", actor}}, "recipient")
321+ otherRecipient := signedEvent(t, strings.Repeat("3", 63)+"4", event.KIND_WRAP, 101, [][]string{{"p", strings.Repeat("f", 64)}}, "other")
322+ wrongKind := signedEvent(t, strings.Repeat("5", 63)+"6", event.KIND_DM, 102, [][]string{{"p", actor}}, "legacy")
323+ for _, row := range []event.Event{actorMessage, otherRecipient, wrongKind} {
324+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {
325+ t.Fatal(err)
326+ }
327+ }
328+ value, err := tenant.Execute(context.Background(), actor, "browsedirectmessages", nil)
329+ if err != nil {
330+ t.Fatal(err)
331+ }
332+ events := value.(map[string]any)["events"].([]event.Event)
333+ if len(events) != 1 || events[0].ID != actorMessage.ID {
334+ t.Fatalf("filtered events = %#v", events)
335+ }
336+}
337+
338+func TestBrowseDirectMessagesRejectsInvalidCursor(t *testing.T) {
339+ _, tenant := testTenant(t)
340+ actor := strings.Repeat("a", 64)
341+ _, err := tenant.Execute(context.Background(), actor, "browsedirectmessages", []json.RawMessage{rawJSON(map[string]any{"cursor": "broken"})})
342+ if err == nil || !strings.HasPrefix(err.Error(), "invalid: direct message cursor") {
343+ t.Fatalf("cursor error = %v", err)
344+ }
345+}
346+
347+func mustDirectMessagePage(t *testing.T, tenant *Tenant, actor string) map[string]any {
348+ t.Helper()
349+ value, err := tenant.Execute(context.Background(), actor, "browsedirectmessages", nil)
350+ if err != nil {
351+ t.Fatal(err)
352+ }
353+ return value.(map[string]any)
354+}
355diff --git a/internal/daemon/webpush.go b/internal/daemon/webpush.go
356index 30d41be..a714516 100644
357--- a/internal/daemon/webpush.go
358+++ b/internal/daemon/webpush.go
359@@ -382,7 +382,7 @@ func (t *Tenant) pushMessage(ctx context.Context, payload pushPayload) pushMessa
360 }
361 url := payload.URL
362 if url == "" {
363- url = strings.TrimRight(t.publicURL, "/") + "/inbox"
364+ url = strings.TrimRight(t.publicURL, "/") + "/chat"
365 }
366 return pushMessage{Title: title, Body: body, URL: url, Tag: "tiny-" + payload.Kind, Badge: t.inboxUnread(ctx, payload.Recipient), Actions: payload.Actions}
367 }
368diff --git a/internal/webui/approvals_test.go b/internal/webui/approvals_test.go
369index 62883bf..049c34f 100644
370--- a/internal/webui/approvals_test.go
371+++ b/internal/webui/approvals_test.go
372@@ -87,7 +87,7 @@ func TestApprovalsPageRendersRequestsForTheOwner(t *testing.T) {
373 t.Fatal("page carries class attributes")
374 }
375 rail := body[strings.Index(body, `id="railbox"`):strings.Index(body, `id="rail-tools"`)]
376- if strings.Index(rail, "/inbox") > strings.Index(rail, "/approvals") || strings.Index(rail, "/approvals") > strings.Index(rail, "/repos") {
377+ if strings.Contains(rail, "/inbox") || strings.Contains(rail, "/outbox") || strings.Index(rail, "/approvals") < strings.Index(rail, "/repos") {
378 t.Fatalf("rail order:\n%s", rail)
379 }
380
381diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js
382index ce199b9..51d7ac4 100644
383--- a/internal/webui/bridge.js
384+++ b/internal/webui/bridge.js
385@@ -242,6 +242,7 @@
386 const response = await fetch(localPath("/session/logout"), {method: "POST", credentials: "same-origin"});
387 if (!response.ok) throw Error(await response.text());
388 forgetRemote();
389+ if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:logout"));
390 say("Signed out.");
391 location.reload();
392 } catch (err) {
393diff --git a/internal/webui/chat-files.js b/internal/webui/chat-files.js
394new file mode 100644
395index 0000000..8c1615e
396--- /dev/null
397+++ b/internal/webui/chat-files.js
398@@ -0,0 +1,209 @@
399+/* NIP-17 kind 15 attachments. File keys stay in the decrypted rumor and are
400+ never copied into markup, URLs, local storage, or the clipboard. */
401+(() => {
402+ "use strict";
403+
404+ const MAX_BYTES = 256 * 1024 * 1024;
405+ const textDecoder = new TextDecoder();
406+ const sizeLabel = size => {
407+ if (size === null) return "";
408+ if (size < 1024) return size + " B";
409+ if (size < 1024 * 1024) return (size / 1024).toFixed(1) + " KiB";
410+ if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + " MiB";
411+ return (size / (1024 * 1024 * 1024)).toFixed(1) + " GiB";
412+ };
413+ const bytes = value => globalThis.tiny.util.bytes(value);
414+ const hex = value => globalThis.tiny.util.hex(value);
415+ const fromHex = (value, message) => {
416+ if (typeof value !== "string" || !/^[0-9a-f]+$/i.test(value) || value.length % 2)
417+ throw Error(message);
418+ return Uint8Array.from(value.match(/../g), pair => parseInt(pair, 16));
419+ };
420+ const tagValue = (tags, name) => {
421+ const tag = (Array.isArray(tags) ? tags : []).find(item => item?.[0] === name);
422+ return typeof tag?.[1] === "string" ? tag[1] : "";
423+ };
424+
425+ function parse(rumor) {
426+ if (!rumor || rumor.kind !== 15 || typeof rumor.content !== "string")
427+ throw Error("expected a NIP-17 file message");
428+ let url;
429+ try { url = new URL(rumor.content); } catch { throw Error("attachment URL is invalid"); }
430+ if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password)
431+ throw Error("attachment URL must be HTTP(S) without credentials");
432+ const algorithm = tagValue(rumor.tags, "encryption-algorithm").toLowerCase();
433+ if (algorithm !== "aes-gcm") throw Error("unsupported attachment encryption");
434+ const keyText = tagValue(rumor.tags, "decryption-key");
435+ const nonceText = tagValue(rumor.tags, "decryption-nonce");
436+ const hash = tagValue(rumor.tags, "x").toLowerCase();
437+ const key = fromHex(keyText, "attachment key is invalid");
438+ const nonce = fromHex(nonceText, "attachment nonce is invalid");
439+ if (key.length !== 32) throw Error("attachment key is invalid");
440+ if (nonce.length < 8 || nonce.length > 32) throw Error("attachment nonce is invalid");
441+ if (!/^[0-9a-f]{64}$/.test(hash)) throw Error("attachment hash is invalid");
442+ const plaintextHash = tagValue(rumor.tags, "ox").toLowerCase();
443+ if (plaintextHash && !/^[0-9a-f]{64}$/.test(plaintextHash)) throw Error("plaintext hash is invalid");
444+ const sizeText = tagValue(rumor.tags, "size");
445+ const size = sizeText ? Number(sizeText) : null;
446+ if (size !== null && (!Number.isSafeInteger(size) || size < 0 || size > MAX_BYTES))
447+ throw Error("attachment is too large");
448+ return Object.freeze({
449+ url: url.href,
450+ mime: tagValue(rumor.tags, "file-type") || "application/octet-stream",
451+ name: tagValue(rumor.tags, "filename") || tagValue(rumor.tags, "name") || "attachment",
452+ hash,
453+ plaintextHash,
454+ size,
455+ key,
456+ nonce
457+ });
458+ }
459+
460+ async function responseBytes(response, signal) {
461+ const declared = Number(response.headers?.get?.("content-length"));
462+ if (Number.isFinite(declared) && declared > MAX_BYTES) throw Error("attachment is too large");
463+ if (response.body?.getReader) {
464+ const reader = response.body.getReader();
465+ const chunks = [];
466+ let total = 0;
467+ try {
468+ for (;;) {
469+ if (signal?.aborted) throw Error("attachment opening canceled");
470+ const part = await reader.read();
471+ if (part.done) break;
472+ const chunk = bytes(part.value);
473+ total += chunk.length;
474+ if (total > MAX_BYTES) {
475+ await reader.cancel();
476+ throw Error("attachment is too large");
477+ }
478+ chunks.push(chunk);
479+ }
480+ } finally { reader.releaseLock?.(); }
481+ const output = new Uint8Array(total);
482+ let offset = 0;
483+ chunks.forEach(chunk => { output.set(chunk, offset); offset += chunk.length; });
484+ return output;
485+ }
486+ const output = bytes(await response.arrayBuffer());
487+ if (output.length > MAX_BYTES) throw Error("attachment is too large");
488+ return output;
489+ }
490+
491+ async function fetchCiphertext(file, signal) {
492+ const init = {method: "GET", credentials: "omit", referrerPolicy: "no-referrer", signal};
493+ let response = await fetch(file.url, init);
494+ if (!response.ok && new URL(file.url).origin === globalThis.location.origin && response.status === 401 && globalThis.tiny.signedFetch) {
495+ const local = new URL(file.url).pathname + new URL(file.url).search;
496+ response = await globalThis.tiny.signedFetch(local, "GET", undefined, {signal});
497+ }
498+ if (!response.ok) throw Error("attachment could not be fetched");
499+ return responseBytes(response, signal);
500+ }
501+
502+ async function decrypt(file, ciphertext) {
503+ const encrypted = bytes(ciphertext);
504+ if (hex(await globalThis.tiny.util.sha256(encrypted)) !== file.hash) throw Error("attachment hash mismatch");
505+ let plain;
506+ try {
507+ const cryptoKey = await crypto.subtle.importKey("raw", file.key, {name: "AES-GCM"}, false, ["decrypt"]);
508+ plain = new Uint8Array(await crypto.subtle.decrypt({name: "AES-GCM", iv: file.nonce}, cryptoKey, encrypted));
509+ } catch { throw Error("attachment decryption failed"); }
510+ if (file.plaintextHash && hex(await globalThis.tiny.util.sha256(plain)) !== file.plaintextHash)
511+ throw Error("attachment plaintext hash mismatch");
512+ return plain;
513+ }
514+
515+ function downloadLink(owner, file, plain) {
516+ const url = URL.createObjectURL(new Blob([plain], {type: file.mime}));
517+ owner._attachmentURLs ||= new Set();
518+ owner._attachmentURLs.add(url);
519+ const link = document.createElement("a");
520+ link.textContent = "Download";
521+ link.download = file.name;
522+ link.rel = "noopener";
523+ link.href = url;
524+ return link;
525+ }
526+
527+ function preview(node, plain, file, owner = node) {
528+ const mime = file.mime.toLowerCase().split(";", 1)[0];
529+ if (mime === "text/plain" || mime === "text/markdown" || mime === "application/json") {
530+ const output = document.createElement("pre");
531+ output.textContent = textDecoder.decode(plain.slice(0, 1024 * 1024));
532+ const footer = document.createElement("p");
533+ footer.append(downloadLink(owner, file, plain));
534+ if (plain.length > 1024 * 1024) footer.append(" (preview truncated)");
535+ node.replaceChildren(output, footer);
536+ return;
537+ }
538+ if (mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/")) {
539+ const media = document.createElement(mime.startsWith("image/") ? "img" : mime.startsWith("video/") ? "video" : "audio");
540+ media.controls = mime.startsWith("video/") || mime.startsWith("audio/");
541+ if (media.localName === "img") media.alt = file.name;
542+ if (media.localName === "video") media.preload = "metadata";
543+ const url = URL.createObjectURL(new Blob([plain], {type: file.mime}));
544+ media.src = url;
545+ owner._attachmentURLs ||= new Set();
546+ owner._attachmentURLs.add(url);
547+ const footer = document.createElement("p");
548+ footer.append(downloadLink(owner, file, plain));
549+ node.replaceChildren(media, footer);
550+ return;
551+ }
552+ const output = document.createElement("p");
553+ output.append("Decrypted attachment is ready: ", downloadLink(owner, file, plain));
554+ node.replaceChildren(output);
555+ }
556+
557+ class DirectFile extends HTMLElement {
558+ connectedCallback() {
559+ this.revokeAttachments();
560+ this.generation = (this.generation || 0) + 1;
561+ const generation = this.generation;
562+ let file;
563+ try { file = parse(this.rumor); } catch (error) { this.textContent = error.message; return; }
564+ this.file = file;
565+ this.innerHTML = "";
566+ const article = document.createElement("article");
567+ const label = document.createElement("span");
568+ label.textContent = file.name + (file.size === null ? "" : " (" + sizeLabel(file.size) + ")");
569+ const button = document.createElement("button");
570+ button.type = "button";
571+ button.textContent = "Open attachment";
572+ const status = document.createElement("output");
573+ button.addEventListener("click", async () => {
574+ button.disabled = true;
575+ status.textContent = "Opening attachment...";
576+ this.abort?.abort();
577+ this.abort = new AbortController();
578+ try {
579+ const plain = await decrypt(file, await fetchCiphertext(file, this.abort.signal));
580+ if (this.generation !== generation || !this.isConnected) return;
581+ preview(article, plain, file, this);
582+ } catch (error) { if (this.generation === generation) status.textContent = error.message; }
583+ finally { if (this.generation === generation) button.disabled = false; }
584+ });
585+ article.append(label, button, status);
586+ this.append(article);
587+ }
588+ revokeAttachments() {
589+ for (const url of this._attachmentURLs || []) URL.revokeObjectURL(url);
590+ this._attachmentURLs?.clear();
591+ }
592+ disconnectedCallback() {
593+ this.generation = (this.generation || 0) + 1;
594+ this.abort?.abort();
595+ this.revokeAttachments();
596+ }
597+ }
598+
599+ globalThis.tiny = globalThis.tiny || {};
600+ globalThis.tiny.chat = globalThis.tiny.chat || {};
601+ globalThis.tiny.chat.files = Object.freeze({parse, decrypt, render: rumor => {
602+ const node = document.createElement("direct-file");
603+ node.rumor = rumor;
604+ return node;
605+ }, MAX_BYTES});
606+ if (globalThis.customElements && !customElements.get("direct-file")) customElements.define("direct-file", DirectFile);
607+})();
608diff --git a/internal/webui/chat.html b/internal/webui/chat.html
609new file mode 100644
610index 0000000..e6c0996
611--- /dev/null
612+++ b/internal/webui/chat.html
613@@ -0,0 +1,24 @@
614+{{define "chat"}}<section id="chat">
615+<h1>Chat</h1>
616+<p>Direct conversations and group rooms.</p>
617+<h2 id="new-chat">Direct chats</h2>
618+{{if .Actor}}<direct-start><label>Start a conversation <input name="peer" required placeholder="npub or public key" autocomplete="off" spellcheck="false"></label><button>New chat</button></direct-start>
619+<direct-chats actor="{{.Actor}}" relay="{{wsURL .URL}}"><p>Connect your signer to open your encrypted conversations.</p></direct-chats>
620+<noscript><p>Direct chats need JavaScript and a signer that supports NIP-44 encryption. Group rooms are available below.</p></noscript>
621+{{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to open your direct chats.</p>{{end}}
622+<h2>Rooms</h2>
623+{{template "roomList" .}}
624+</section>{{end}}
625+
626+{{define "direct"}}<section id="direct-chat">
627+<header><h1>{{template "nostrName" .View}}</h1><p>Private conversation. Only you and this person can read these messages.</p></header>
628+{{if .Actor}}<direct-thread actor="{{.Actor}}" peer="{{.View}}" relay="{{wsURL .URL}}"><p>Connect your signer to decrypt this conversation.</p></direct-thread>
629+<direct-compose actor="{{.Actor}}" peer="{{.View}}" relay="{{wsURL .URL}}"><label for="direct-content">Message</label><textarea id="direct-content" name="content" rows="2" required title="Enter to send; Shift+Enter for a new line" placeholder="Write a private message…"></textarea><button type="submit">Send</button></direct-compose>
630+<noscript><p>Reading and sending direct messages needs JavaScript and a signer that supports NIP-44 encryption.</p></noscript>
631+{{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to open this private conversation.</p>{{end}}
632+</section>{{end}}
633+
634+{{define "panel-chat"}}<section><h4>Direct messages</h4><p>Encrypted for you and the recipient. Your signer opens messages delivered to this relay.</p>
635+{{if .Actor}}<direct-settings actor="{{.Actor}}" relay="{{wsURL .URL}}"></direct-settings><p><small>Add this relay to your message relay list so other Nostr clients know where to reach you.</small></p>{{end}}</section>
636+{{if .Actor}}<section><h4>Notifications</h4><push-toggle></push-toggle><p><small>Chat and approval notices on this device.</small></p><noscript><p>Enabling notifications needs JavaScript.</p></noscript></section>{{end}}
637+<section><h4>Group rooms</h4><p>Open rooms are visible to everyone. Members-only rooms are visible to their members and relay moderators.</p><a href="/chat#new-room">Create a room</a></section>{{end}}
638diff --git a/internal/webui/chat.js b/internal/webui/chat.js
639new file mode 100644
640index 0000000..19c4cb0
641--- /dev/null
642+++ b/internal/webui/chat.js
643@@ -0,0 +1,330 @@
644+/* Direct conversations use verified NIP-17 rumors. Plaintext lives only in this page's memory. */
645+(() => {
646+ "use strict";
647+ const tiny = globalThis.tiny;
648+ const protocol = () => tiny.chat.protocol;
649+ const hex = value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
650+ const node = (name, text) => {
651+ const result = document.createElement(name);
652+ if (text !== undefined) result.textContent = text;
653+ return result;
654+ };
655+ const key = value => { try { return protocol().normalizePubkey(String(value || "").trim()); } catch { return ""; } };
656+ const participants = rumor => [...new Set([rumor.pubkey, ...rumor.tags.filter(tag => tag[0] === "p" && hex(tag[1])).map(tag => tag[1])])];
657+ const states = new Map();
658+ const listeners = new Set();
659+ let generation = 0;
660+ const stateFor = actor => {
661+ if (!states.has(actor)) states.set(actor, {actor, generation, events: new Map(), wraps: new Map(), cursor: "", loaded: false, pending: null, version: 0});
662+ return states.get(actor);
663+ };
664+ const current = value => value.generation === generation && states.get(value.actor) === value;
665+ const changed = value => { if (current(value)) { value.version++; for (const host of listeners) if (host.actor === value.actor) host.render(); } };
666+ const clear = () => {
667+ generation++;
668+ for (const value of states.values()) { value.events.clear(); value.wraps.clear(); }
669+ states.clear();
670+ for (const host of listeners) { host.renderVersion = ""; host.replaceChildren(node("p", "Connect your signer to open direct chats.")); }
671+ };
672+ const activeSigner = async actor => {
673+ const signer = tiny.signer?.();
674+ if (!signer || key(await signer.getPublicKey()) !== actor) throw Error("Connect the signer for this account to open direct chats.");
675+ return signer;
676+ };
677+ const checkSession = async (value, signer) => {
678+ if (!current(value) || tiny.signer() !== signer || key(await signer.getPublicKey()) !== value.actor) throw Error("The signer changed. Reopen this conversation.");
679+ if (!current(value)) throw Error("The session ended.");
680+ };
681+ const cacheEvents = async (value, rows, signer) => {
682+ for (const packet of rows) {
683+ if (!current(value)) return;
684+ let pending = value.wraps.get(packet.id);
685+ if (!pending) {
686+ pending = protocol().decrypt(packet, {actor: value.actor, signer});
687+ value.wraps.set(packet.id, pending);
688+ }
689+ try {
690+ const {rumor} = await pending;
691+ await checkSession(value, signer);
692+ if (value.events.has(rumor.id)) continue;
693+ const members = participants(rumor);
694+ if ([14, 15].includes(rumor.kind) && (members.length !== 2 || !members.includes(value.actor))) continue;
695+ if (![5, 7, 14, 15].includes(rumor.kind)) continue;
696+ value.events.set(rumor.id, rumor);
697+ } catch (error) {
698+ if (!current(value)) return;
699+ // A signer refusal can be retried. Structurally invalid events stay cached.
700+ if (/cancel|reject|denied|support|connect|unable to decrypt/i.test(error.message)) {
701+ value.wraps.delete(packet.id);
702+ throw error;
703+ }
704+ }
705+ }
706+ };
707+ const fetchPage = async (value, cursor, signer) => {
708+ const response = await tiny.signedFetch("/chat/events" + (cursor ? "?cursor=" + encodeURIComponent(cursor) : ""), "GET", new Uint8Array());
709+ const page = await response.json();
710+ if (!Array.isArray(page?.events) || typeof page.next_cursor !== "string") throw Error("Could not read the chat history.");
711+ await checkSession(value, signer);
712+ await cacheEvents(value, page.events, signer);
713+ return page;
714+ };
715+ const load = async (actor, older = false) => {
716+ const value = stateFor(actor);
717+ if (value.pending) return value.pending;
718+ if (older && !value.cursor) return value;
719+ value.pending = (async () => {
720+ const signer = await activeSigner(actor);
721+ await checkSession(value, signer);
722+ const wasLoaded = value.loaded, count = value.events.size;
723+ let page = await fetchPage(value, older ? value.cursor : "", signer);
724+ if (!current(value)) return value;
725+ if (older || !wasLoaded) value.cursor = page.next_cursor;
726+ // Gift wraps deliberately use older dates. Refresh the entire two-day
727+ // overlap so a new message cannot hide behind the first page of history.
728+ if (wasLoaded && !older) {
729+ const cutoff = Math.floor(Date.now() / 1000) - 172860;
730+ const seen = new Set();
731+ while (page.next_cursor && page.events.length && page.events.at(-1).created_at >= cutoff && !seen.has(page.next_cursor)) {
732+ seen.add(page.next_cursor);
733+ page = await fetchPage(value, page.next_cursor, signer);
734+ if (!current(value)) return value;
735+ }
736+ }
737+ value.loaded = true;
738+ if (count !== value.events.size || !wasLoaded || older) changed(value);
739+ return value;
740+ })().finally(() => { value.pending = null; });
741+ return value.pending;
742+ };
743+ const chronological = value => [...value.events.values()].sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id));
744+ const peerFor = (rumor, actor) => participants(rumor).find(member => member !== actor);
745+ const deleted = (value, rumor) => {
746+ if (value.deletedVersion !== value.events.size) {
747+ value.deletedIDs = new Set();
748+ for (const deletion of value.events.values()) {
749+ if (deletion.kind !== 5) continue;
750+ for (const tag of deletion.tags) {
751+ const target = tag[0] === "e" && value.events.get(tag[1]);
752+ if (target && target.pubkey === deletion.pubkey) value.deletedIDs.add(target.id);
753+ }
754+ }
755+ value.deletedVersion = value.events.size;
756+ }
757+ return value.deletedIDs.has(rumor.id);
758+ };
759+ const messages = (value, peer) => chronological(value).filter(rumor => [14, 15].includes(rumor.kind) && !deleted(value, rumor) && (!peer || peerFor(rumor, value.actor) === peer));
760+ const profile = pubkey => {
761+ const author = node("span"); author.setAttribute("data-author", "");
762+ const avatar = node("nostr-avatar", pubkey.slice(0, 2)); avatar.setAttribute("pubkey", pubkey); avatar.setAttribute("aria-hidden", "true");
763+ const name = node("nostr-name", pubkey.slice(0, 8)); name.setAttribute("pubkey", pubkey);
764+ author.append(avatar, name); return author;
765+ };
766+ const time = timestamp => {
767+ const date = new Date(timestamp * 1000);
768+ const result = node("time", date.toLocaleString()); result.dateTime = date.toISOString(); return result;
769+ };
770+ const textBody = content => {
771+ const body = node("p");
772+ if (tiny.rooms?.linkify) tiny.rooms.linkify(body, content); else body.textContent = content;
773+ return body;
774+ };
775+ const targetID = rumor => rumor.tags.filter(tag => ["e", "q"].includes(tag[0]) && hex(tag[1])).at(-1)?.[1];
776+ const reactions = (value, target) => {
777+ if (value.reactionVersion !== value.events.size) {
778+ const grouped = new Map();
779+ for (const rumor of value.events.values()) {
780+ if (rumor.kind !== 7 || deleted(value, rumor)) continue;
781+ const target = value.events.get(targetID(rumor));
782+ if (!target || ![14, 15].includes(target.kind)) continue;
783+ const allowed = new Set(participants(target));
784+ if (!allowed.has(rumor.pubkey) || participants(rumor).some(pubkey => !allowed.has(pubkey))) continue;
785+ const label = !rumor.content || rumor.content === "+" ? "♥" : rumor.content;
786+ if (!grouped.has(target.id)) grouped.set(target.id, new Map());
787+ grouped.get(target.id).set(rumor.pubkey + ":" + label, label);
788+ }
789+ value.reactionIndex = new Map();
790+ for (const [id, unique] of grouped) {
791+ const counts = new Map();
792+ for (const label of unique.values()) counts.set(label, (counts.get(label) || 0) + 1);
793+ value.reactionIndex.set(id, counts);
794+ }
795+ value.reactionVersion = value.events.size;
796+ }
797+ return value.reactionIndex.get(target.id) || new Map();
798+ };
799+ const accepted = values => Array.isArray(values) ? values.some(value => value === true) : values === true;
800+ const deliver = async (value, input, previous, report) => {
801+ const signer = await activeSigner(value.actor);
802+ await checkSession(value, signer);
803+ const pending = previous || {built: await protocol().build(input, {signer}), delivered: [false, false]};
804+ await checkSession(value, signer);
805+ if (pending.built.rumor.pubkey !== value.actor) throw Error("The signer changed.");
806+ if (!pending.relays) {
807+ const lists = await Promise.all([protocol().queryRelayList(input.recipient), protocol().queryRelayList(value.actor)]);
808+ pending.relays = lists.map((list, index) => protocol().relayList(list, index ? value.actor : input.recipient));
809+ }
810+ await checkSession(value, signer);
811+ // Retain the exact gift wraps before network publication, including failure.
812+ report(pending);
813+ for (let index = 0; index < 2; index++) {
814+ if (pending.delivered[index]) continue;
815+ const result = await protocol().publishEvent(pending.built.events[index].event, pending.relays[index], {signer});
816+ await checkSession(value, signer);
817+ if (!accepted(result)) throw Error(index ? "Sent to the recipient's relay. Your history copy failed; press Send again to retry it." : "The recipient's relay did not accept the message. Press Send to retry.");
818+ pending.delivered[index] = true;
819+ if (index === 0) { value.events.set(pending.built.rumor.id, pending.built.rumor); changed(value); }
820+ }
821+ return pending;
822+ };
823+ class ConversationView extends HTMLElement {
824+ connectedCallback() {
825+ this.actor = key(this.getAttribute("actor")); this.peer = key(this.getAttribute("peer"));
826+ this.renderVersion = ""; listeners.add(this); this.refresh();
827+ this.timer = setInterval(() => { if (document.visibilityState !== "hidden") this.refresh(); }, 10000);
828+ }
829+ disconnectedCallback() { listeners.delete(this); clearInterval(this.timer); }
830+ async refresh(older = false) {
831+ if (!this.actor || !tiny.signer?.()) return;
832+ const epoch = generation;
833+ try { await load(this.actor, older); if (epoch === generation && this.isConnected) this.render(); }
834+ catch (error) { if (epoch === generation && this.isConnected) { let status = this.querySelector(":scope > output"); if (!status) { status = node("output"); status.setAttribute("role", "status"); this.append(status); } status.textContent = error.message; } }
835+ }
836+ unchanged(value) {
837+ const version = [value.version, value.cursor, this.peer].join(":");
838+ if (this.renderVersion === version) return true;
839+ this.renderVersion = version; return false;
840+ }
841+ older(value) {
842+ if (!value.cursor) return;
843+ const button = node("button", "Load older conversations"); button.type = "button";
844+ button.addEventListener("click", async () => { button.disabled = true; try { await this.refresh(true); } finally { button.disabled = false; } });
845+ this.append(button);
846+ }
847+ }
848+ class DirectChats extends ConversationView {
849+ render() {
850+ const value = stateFor(this.actor); if (this.unchanged(value)) return;
851+ const groups = new Map(); for (const rumor of messages(value)) groups.set(peerFor(rumor, this.actor), rumor);
852+ this.replaceChildren();
853+ for (const [peer, latest] of [...groups].sort((a, b) => b[1].created_at - a[1].created_at || a[0].localeCompare(b[0]))) {
854+ const link = node("a"); link.href = tiny.localPath("/chat/dm/" + peer);
855+ if (location.pathname.endsWith("/chat/dm/" + peer)) link.setAttribute("aria-current", "page");
856+ link.append(profile(peer), node("p", latest.kind === 15 ? "Encrypted attachment" : latest.content.slice(0, 120)), time(latest.created_at)); this.append(link);
857+ }
858+ if (!groups.size) this.append(node("p", "No direct conversations yet."));
859+ if (!this.hasAttribute("compact")) this.older(value);
860+ }
861+ }
862+ class DirectThread extends ConversationView {
863+ render() {
864+ const value = stateFor(this.actor); if (this.unchanged(value)) return;
865+ const near = !this.childElementCount || this.scrollHeight - this.scrollTop - this.clientHeight < 100;
866+ const top = this.scrollTop, height = this.scrollHeight;
867+ const rows = messages(value, this.peer), existing = new Map([...this.querySelectorAll(":scope > article")].map(article => [article.id, article]));
868+ const result = [];
869+ if (value.cursor) {
870+ const button = node("button", "Load older messages"); button.type = "button"; button.addEventListener("click", () => this.refresh(true)); result.push(button);
871+ }
872+ for (const rumor of rows) {
873+ let article = existing.get("chat-message-" + rumor.id);
874+ if (!article) {
875+ article = node("article"); article.id = "chat-message-" + rumor.id;
876+ if (rumor.pubkey === this.actor) article.dataset.own = "";
877+ const header = node("header"); header.append(profile(rumor.pubkey), time(rumor.created_at)); article.append(header);
878+ const parent = targetID(rumor);
879+ if (parent) { const quote = node("blockquote"); quote.dataset.quote = parent; article.append(quote); }
880+ if (rumor.kind === 15 && tiny.chat.files) article.append(tiny.chat.files.render(rumor)); else article.append(textBody(rumor.content));
881+ const footer = node("footer");
882+ const reply = node("button", "Reply"); reply.type = "button";
883+ reply.addEventListener("click", () => document.querySelector(`direct-compose[peer="${this.peer}"]`)?.replyTo(rumor)); footer.append(reply);
884+ const react = node("button", "♥"); react.type = "button"; react.title = "Like this message";
885+ react.addEventListener("click", async () => {
886+ react.disabled = true;
887+ try { await deliver(value, {recipient: this.peer, reaction: {id: rumor.id, pubkey: rumor.pubkey, kind: rumor.kind, content: "+"}}, react.pending, pending => { react.pending = pending; }); react.pending = null; }
888+ catch (error) { let status = footer.querySelector("output"); if (!status) { status = node("output"); footer.append(status); } status.textContent = error.message; }
889+ finally { react.disabled = false; }
890+ }); footer.append(react, node("span")); article.append(footer);
891+ }
892+ const quote = article.querySelector("blockquote[data-quote]");
893+ if (quote) {
894+ const parent = value.events.get(quote.dataset.quote);
895+ quote.textContent = parent && [14, 15].includes(parent.kind) && peerFor(parent, this.actor) === this.peer
896+ ? deleted(value, parent) ? "Message deleted" : parent.content.slice(0, 220)
897+ : "Reply to an earlier message";
898+ }
899+ const summary = article.querySelector("footer > span"); summary.textContent = [...reactions(value, rumor)].map(([label, count]) => `${label} ${count}`).join(" | ");
900+ result.push(article);
901+ }
902+ if (!rows.length) result.push(node("p", "Start the conversation with a private message."));
903+ // Move existing message nodes instead of rebuilding media players.
904+ let before = this.firstChild;
905+ for (const child of result) { if (before === child) before = before.nextSibling; else this.insertBefore(child, before); }
906+ while (before) { const next = before.nextSibling; before.remove(); before = next; }
907+ if (near) this.scrollTop = this.scrollHeight;
908+ else this.scrollTop = top + Math.max(0, this.scrollHeight - height);
909+ }
910+ }
911+ class DirectCompose extends tiny.ui.FormElement {
912+ connectedCallback() {
913+ if (this.form) return;
914+ super.connectedCallback();
915+ this.querySelector("textarea")?.addEventListener("keydown", event => {
916+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); if (!this.submitting) this.form.requestSubmit(); }
917+ });
918+ }
919+ replyTo(rumor) {
920+ this.reply = {id: rumor.id, pubkey: rumor.pubkey};
921+ this.report("Replying to: " + rumor.content.slice(0, 100));
922+ let cancel = this.querySelector("[data-cancel-reply]");
923+ if (!cancel) { cancel = node("button", "Cancel reply"); cancel.type = "button"; cancel.setAttribute("data-cancel-reply", ""); cancel.addEventListener("click", () => { this.reply = null; cancel.remove(); this.report(""); }); this.append(cancel); }
924+ this.querySelector("textarea")?.focus();
925+ }
926+ async submit(form) {
927+ const actor = key(this.getAttribute("actor")), peer = key(this.getAttribute("peer")), content = String(form.elements.content.value || "");
928+ if (!actor || !peer || actor === peer || !content.trim()) throw Error("Choose another person and write a message.");
929+ const value = stateFor(actor), pendingKey = JSON.stringify([actor, peer, content, this.reply?.id]);
930+ if (this.pendingKey !== pendingKey) { this.pending = null; this.pendingKey = pendingKey; }
931+ this.report(this.pending ? "Retrying delivery…" : "Encrypting and sending…");
932+ await deliver(value, {recipient: peer, content, reply: this.reply}, this.pending, pending => { this.pending = pending; });
933+ if (!current(value) || !this.isConnected) return;
934+ this.pending = null;
935+ if (form.elements.content.value === content && pendingKey === JSON.stringify([actor, peer, content, this.reply?.id])) {
936+ this.reply = null; this.querySelector("[data-cancel-reply]")?.remove(); form.reset();
937+ }
938+ this.report("Sent.");
939+ }
940+ }
941+ class DirectStart extends tiny.ui.FormElement {
942+ async submit(form) {
943+ const peer = key(form.elements.peer.value); if (!peer) throw Error("Enter an npub or a 64-character public key.");
944+ await tiny.navigate(tiny.localPath("/chat/dm/" + peer));
945+ }
946+ }
947+ class DirectSettings extends HTMLElement {
948+ connectedCallback() {
949+ const button = node("button", "Receive chats here"); button.type = "button";
950+ const status = node("output"); status.setAttribute("role", "status"); this.replaceChildren(button, status);
951+ button.addEventListener("click", async () => {
952+ button.disabled = true;
953+ try {
954+ const actor = key(this.getAttribute("actor")), value = stateFor(actor), signer = await activeSigner(actor);
955+ const url = tiny.util.relayURL(this.getAttribute("relay")); if (!url) throw Error("This relay's address is unavailable.");
956+ const rows = await protocol().queryRelayList(actor);
957+ const events = Array.isArray(rows) ? rows : rows?.events || [];
958+ const prior = events.filter(event => event.kind === 10050 && event.pubkey === actor && NostrSigner.verifyEvent(event)).sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id))[0];
959+ const tags = (prior?.tags || []).filter(tag => tag[0] !== "relay" || tiny.util.relayURL(tag[1]));
960+ if (!tags.some(tag => tag[0] === "relay" && tiny.util.relayURL(tag[1]) === url)) tags.push(["relay", url]);
961+ await checkSession(value, signer);
962+ await tiny.signing.publish({kind: 10050, created_at: Math.max(Math.floor(Date.now() / 1000), (prior?.created_at || 0) + 1), tags, content: prior?.content || ""});
963+ status.textContent = "This relay now receives your direct messages.";
964+ } catch (error) { status.textContent = error.message; } finally { button.disabled = false; }
965+ });
966+ }
967+ }
968+ document.addEventListener("tiny:signer", () => { clear(); for (const host of listeners) host.refresh(); });
969+ document.addEventListener("tiny:logout", clear);
970+ document.addEventListener("visibilitychange", () => { if (document.visibilityState !== "hidden") for (const host of listeners) host.refresh(); });
971+ for (const [name, element] of [["direct-chats", DirectChats], ["direct-thread", DirectThread], ["direct-compose", DirectCompose], ["direct-start", DirectStart], ["direct-settings", DirectSettings]]) customElements.define(name, element);
972+ tiny.chat.ui = Object.freeze({state: states, stateFor, clear, cacheEvents, participants, reactions, load, deliver, messages});
973+})();
974diff --git a/internal/webui/chat_pages.go b/internal/webui/chat_pages.go
975new file mode 100644
976index 0000000..3752224
977--- /dev/null
978+++ b/internal/webui/chat_pages.go
979@@ -0,0 +1,73 @@
980+package webui
981+
982+import (
983+ "encoding/json"
984+ "net/http"
985+ "strings"
986+)
987+
988+func (a *App) handleChatRoute(w http.ResponseWriter, r *http.Request) bool {
989+ path := r.URL.Path
990+ if path != "/rooms" && path != "/chat" && !strings.HasPrefix(path, "/chat/") {
991+ return false
992+ }
993+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
994+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
995+ return true
996+ }
997+ if path == "/rooms" {
998+ target := requestPrefix(r) + "/chat"
999+ if r.URL.RawQuery != "" {
1000+ target += "?" + r.URL.RawQuery
1001+ }
1002+ http.Redirect(w, r, target, http.StatusMovedPermanently)
1003+ return true
1004+ }
1005+ if path == "/chat/events" {
1006+ a.directMessagesJSON(w, r)
1007+ return true
1008+ }
1009+ actor, _ := a.resolveActor(r)
1010+ data := PageData{Tab: "chat", Title: "Chat | " + a.backend.Slug(), Query: r.URL.Query()}
1011+ if path != "/chat" {
1012+ peer, ok := strings.CutPrefix(path, "/chat/dm/")
1013+ if !ok || !eventIDPattern.MatchString(peer) {
1014+ http.NotFound(w, r)
1015+ return true
1016+ }
1017+ data.Tab, data.View, data.Title = "direct", peer, "Direct chat | "+a.backend.Slug()
1018+ }
1019+ if data.Tab == "chat" {
1020+ result, err, typed := a.typedRoomResult(r.Context(), roomPath{tab: "rooms"}, r.URL.Query(), actor)
1021+ if !typed {
1022+ params, _ := json.Marshal(map[string]any{"limit": 100, "cursor": r.URL.Query().Get("cursor")})
1023+ result, err = a.backend.Query(r.Context(), "browserooms", []json.RawMessage{params}, actor)
1024+ }
1025+ data.Event, data.Feed = result, browseRows(result)
1026+ data.Rooms = data.Feed
1027+ if err != nil {
1028+ data.Error = err.Error()
1029+ }
1030+ } else {
1031+ data.Rooms = a.roomList(r.Context(), actor)
1032+ }
1033+ a.render(w, r, data)
1034+ return true
1035+}
1036+
1037+func (a *App) directMessagesJSON(w http.ResponseWriter, r *http.Request) {
1038+ w.Header().Set("Cache-Control", "private, no-store")
1039+ actor, err := a.resolveActor(r)
1040+ if err != nil || actor == "" {
1041+ http.Error(w, "Sign in to open direct chats.", http.StatusUnauthorized)
1042+ return
1043+ }
1044+ params, _ := json.Marshal(map[string]any{"limit": 50, "cursor": r.URL.Query().Get("cursor")})
1045+ result, err := a.backend.Query(r.Context(), "browsedirectmessages", []json.RawMessage{params}, actor)
1046+ if err != nil {
1047+ http.Error(w, err.Error(), publicErrorStatus(err))
1048+ return
1049+ }
1050+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
1051+ _ = json.NewEncoder(w).Encode(result)
1052+}
1053diff --git a/internal/webui/chat_pages_test.go b/internal/webui/chat_pages_test.go
1054new file mode 100644
1055index 0000000..552d78a
1056--- /dev/null
1057+++ b/internal/webui/chat_pages_test.go
1058@@ -0,0 +1,131 @@
1059+package webui
1060+
1061+import (
1062+ "context"
1063+ "encoding/json"
1064+ "net/http"
1065+ "net/http/httptest"
1066+ "strings"
1067+ "testing"
1068+
1069+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
1070+)
1071+
1072+type chatBackend struct {
1073+ fakeBackend
1074+ methods []string
1075+ actors []string
1076+ params []json.RawMessage
1077+}
1078+
1079+func (b *chatBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {
1080+ b.methods = append(b.methods, method)
1081+ b.actors = append(b.actors, actor)
1082+ if len(params) != 0 {
1083+ b.params = append(b.params, params[0])
1084+ }
1085+ switch method {
1086+ case "browserooms":
1087+ return map[string]any{"items": []any{roomRecord("general", "General", "open", "")}, "next_cursor": ""}, nil
1088+ case "browsedirectmessages":
1089+ return map[string]any{"events": []any{}, "next_cursor": "next"}, nil
1090+ default:
1091+ return nil, nil
1092+ }
1093+}
1094+
1095+func chatApp(t *testing.T, actor string) (*App, *chatBackend) {
1096+ t.Helper()
1097+ backend := &chatBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}
1098+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return actor, nil }})
1099+ if err != nil {
1100+ t.Fatal(err)
1101+ }
1102+ return app, backend
1103+}
1104+
1105+func TestRoomsRedirectToChatPreservesPrefixAndQuery(t *testing.T) {
1106+ app, _ := chatApp(t, roomOwner)
1107+ req := httptest.NewRequest(http.MethodGet, "/rooms?cursor=older", nil)
1108+ // Tenant routing normally strips the /r/<slug> prefix before webui sees
1109+ // the URL. Keep the original request URI here to cover the redirect helper.
1110+ req.RequestURI = "/r/demo/rooms?cursor=older"
1111+ res := httptest.NewRecorder()
1112+ app.ServeHTTP(res, req)
1113+ if res.Code != http.StatusMovedPermanently || res.Header().Get("Location") != "/r/demo/chat?cursor=older" {
1114+ t.Fatalf("redirect = %d %q", res.Code, res.Header().Get("Location"))
1115+ }
1116+}
1117+
1118+func TestChatPageRendersGroupsAndGuestSignin(t *testing.T) {
1119+ app, _ := chatApp(t, roomOwner)
1120+ res := httptest.NewRecorder()
1121+ app.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/chat", nil))
1122+ body := res.Body.String()
1123+ if res.Code != http.StatusOK || !strings.Contains(body, "<h1>Chat</h1>") || !strings.Contains(body, "Direct chats") || !strings.Contains(body, `href="/rooms/general"`) {
1124+ t.Fatalf("signed chat page: %d %s", res.Code, body)
1125+ }
1126+ guest, _ := chatApp(t, "")
1127+ res = httptest.NewRecorder()
1128+ guest.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/chat", nil))
1129+ body = res.Body.String()
1130+ if res.Code != http.StatusOK || !strings.Contains(body, `href="/signin?next=%2Fchat">Sign in</a> to open your direct chats.`) || strings.Contains(body, "<direct-chats") || strings.Contains(body, "<direct-start") {
1131+ t.Fatalf("guest chat page: %d %s", res.Code, body)
1132+ }
1133+}
1134+
1135+func TestDirectChatPageDoesNotRenderCiphertextForGuest(t *testing.T) {
1136+ app, backend := chatApp(t, "")
1137+ peer := strings.Repeat("d", 64)
1138+ res := httptest.NewRecorder()
1139+ app.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/chat/dm/"+peer, nil))
1140+ body := res.Body.String()
1141+ if res.Code != http.StatusOK || !strings.Contains(body, "Private conversation") || !strings.Contains(body, `href="/signin?next=%2Fchat%2Fdm%2F`+peer) || strings.Contains(body, "<direct-thread") || strings.Contains(body, "ciphertext") {
1142+ t.Fatalf("guest direct chat: %d %s", res.Code, body)
1143+ }
1144+ for _, method := range backend.methods {
1145+ if method == "browsedirectmessages" {
1146+ t.Fatal("guest direct chat queried encrypted messages")
1147+ }
1148+ }
1149+}
1150+
1151+func TestDirectMessagesEndpointRequiresActorAndForwardsCursor(t *testing.T) {
1152+ guest, _ := chatApp(t, "")
1153+ res := httptest.NewRecorder()
1154+ guest.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/chat/events?cursor=abc", nil))
1155+ if res.Code != http.StatusUnauthorized || res.Header().Get("Cache-Control") != "private, no-store" {
1156+ t.Fatalf("guest events endpoint: %d %q", res.Code, res.Header().Get("Cache-Control"))
1157+ }
1158+ app, backend := chatApp(t, roomOwner)
1159+ res = httptest.NewRecorder()
1160+ app.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/chat/events?cursor=abc", nil))
1161+ if res.Code != http.StatusOK || res.Header().Get("Cache-Control") != "private, no-store" || res.Header().Get("Content-Type") != "application/json; charset=utf-8" {
1162+ t.Fatalf("events endpoint: %d headers=%v body=%s", res.Code, res.Header(), res.Body.String())
1163+ }
1164+ if len(backend.methods) != 1 || backend.methods[0] != "browsedirectmessages" || backend.actors[0] != roomOwner {
1165+ t.Fatalf("backend calls = %#v %#v", backend.methods, backend.actors)
1166+ }
1167+ var query map[string]any
1168+ if err := json.Unmarshal(backend.params[0], &query); err != nil || query["cursor"] != "abc" || query["limit"] != float64(50) {
1169+ t.Fatalf("query = %#v", query)
1170+ }
1171+}
1172+
1173+func TestChatRouteRejectsUnsupportedMethodsAndPaths(t *testing.T) {
1174+ app, _ := chatApp(t, roomOwner)
1175+ for _, tc := range []struct {
1176+ method, path string
1177+ status int
1178+ }{
1179+ {http.MethodPost, "/chat/events", http.StatusMethodNotAllowed},
1180+ {http.MethodGet, "/chat/events/extra", http.StatusNotFound},
1181+ {http.MethodGet, "/chat/dm/not-a-pubkey", http.StatusNotFound},
1182+ } {
1183+ res := httptest.NewRecorder()
1184+ app.ServeHTTP(res, httptest.NewRequest(tc.method, tc.path, nil))
1185+ if res.Code != tc.status {
1186+ t.Errorf("%s %s = %d, want %d", tc.method, tc.path, res.Code, tc.status)
1187+ }
1188+ }
1189+}
1190diff --git a/internal/webui/components.js b/internal/webui/components.js
1191index 8d0c27f..6e69eea 100644
1192--- a/internal/webui/components.js
1193+++ b/internal/webui/components.js
1194@@ -1598,9 +1598,9 @@
1195 // page whose definition has not arrived, such as after an in-place
1196 // navigation to the Files or Connect pages.
1197 const bundlesFor = {"file-upload": "files", "file-workspace-root": "files", "private-services": "private"};
1198- // Opening the inbox records the visit for the badge and clears it.
1199+ // Opening Chat records the visit for the notification badge.
1200 const markInboxSeen = () => {
1201- if (!/\/inbox$/.test(location.pathname) || !document.getElementById("session-logout")) return;
1202+ if (!/\/(?:inbox|chat(?:\/dm\/[0-9a-f]{64})?)$/.test(location.pathname) || !document.getElementById("session-logout")) return;
1203 navigator.clearAppBadge?.().catch?.(() => {});
1204 fetch(tiny.localPath("/inbox/seen"), {method: "POST", credentials: "same-origin"}).catch(() => {});
1205 };
1206@@ -1667,7 +1667,7 @@
1207 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;
1208 return root ? url.pathname.slice(root.length) || "/" : url.pathname;
1209 };
1210- const allowedRoute = path => /^(?:\/(?:inbox|approvals|profile|outbox|search|social|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki|rooms)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki|social)\/.+|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || "");
1211+ const allowedRoute = path => /^(?:\/(?:inbox|approvals|profile|outbox|search|social|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki|rooms)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki|social)\/.+|\/chat\/dm\/[0-9a-f]{64}|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || "");
1212 let navigationSerial = 0, activeAbort;
1213 const streams = new Set();
1214 const closeStreams = () => {
1215diff --git a/internal/webui/direct-messages.js b/internal/webui/direct-messages.js
1216new file mode 100644
1217index 0000000..97ab293
1218--- /dev/null
1219+++ b/internal/webui/direct-messages.js
1220@@ -0,0 +1,131 @@
1221+/* NIP-17 direct messages: kind 14 rumors, NIP-59 seals and gift wraps. */
1222+(() => {
1223+ "use strict";
1224+
1225+ // Leave room for the rumor JSON envelope under NIP-44's 65535-byte limit.
1226+ const MAX_CONTENT = 60 * 1024;
1227+ const MAX_TAGS = 32;
1228+ const contentBytes = value => typeof TextEncoder === "function" ? new TextEncoder().encode(value).length : unescape(encodeURIComponent(value)).length;
1229+ const hex64 = value => typeof value === "string" && /^[0-9a-f]{64}$/i.test(value);
1230+ const signerAPI = () => {
1231+ const value = globalThis.NostrSigner;
1232+ if (!value?.finalizeEvent || !value.generateSecretKey || !value.verifyEvent || !value.getEventHash)
1233+ throw Error("Nostr signing support is unavailable");
1234+ return value;
1235+ };
1236+ const signerPublicKey = async signer => {
1237+ const key = await signer.getPublicKey();
1238+ if (!hex64(key)) throw Error("invalid signer public key");
1239+ return key.toLowerCase();
1240+ };
1241+ const normalizePubkey = value => {
1242+ if (globalThis.tiny?.files?.messages?.normalizePubkey)
1243+ return globalThis.tiny.files.messages.normalizePubkey(value);
1244+ if (hex64(value)) return value.toLowerCase();
1245+ const decoded = signerAPI().decodeNpub?.(String(value || ""));
1246+ if (hex64(decoded)) return decoded.toLowerCase();
1247+ throw Error("public key must be a 64 character hex key or npub");
1248+ };
1249+ const nip44Encrypt = async (signer, pubkey, text) => {
1250+ if (typeof globalThis.tiny?.nip44Encrypt === "function") return globalThis.tiny.nip44Encrypt(pubkey, text, signer);
1251+ if (typeof signer.nip44Encrypt === "function") return signer.nip44Encrypt(pubkey, text);
1252+ throw Error("NIP-44 encryption is unavailable");
1253+ };
1254+ const nip44Decrypt = async (signer, pubkey, text) => {
1255+ if (typeof globalThis.tiny?.nip44Decrypt === "function") return globalThis.tiny.nip44Decrypt(pubkey, text, signer);
1256+ if (typeof signer.nip44Decrypt === "function") return signer.nip44Decrypt(pubkey, text);
1257+ throw Error("NIP-44 decryption is unavailable");
1258+ };
1259+ const eventHash = (api, event) => api.getEventHash(event);
1260+ const past = now => Math.max(0, Math.floor(now) - Math.floor(Math.random() * 172801));
1261+
1262+ async function build(input = {}, options = {}) {
1263+ const signer = options.signer || globalThis.tiny?.signer?.();
1264+ if (!signer?.signEvent || !signer.getPublicKey) throw Error("a NIP-44 signer is required");
1265+ const recipient = normalizePubkey(input.recipient);
1266+ const sender = await signerPublicKey(signer);
1267+ const content = String(input.content ?? "");
1268+ if (contentBytes(content) > MAX_CONTENT) throw Error("message content is empty or too large");
1269+ const tags = [["p", recipient]];
1270+ if (input.reply) {
1271+ if (!hex64(input.reply.id) || !hex64(input.reply.pubkey)) throw Error("reply must include valid id and pubkey");
1272+ tags.push(["e", input.reply.id.toLowerCase(), input.reply.relay || "", "reply", input.reply.pubkey.toLowerCase()]);
1273+ }
1274+ const now = options.now ?? Date.now() / 1000;
1275+ let kind = 14;
1276+ if (input.reaction) {
1277+ const target = input.reaction;
1278+ if (!hex64(target.id) || !hex64(target.pubkey) || (target.kind != null && (!Number.isSafeInteger(target.kind) || target.kind < 0)))
1279+ throw Error("reaction target must include valid id and pubkey");
1280+ kind = 7;
1281+ tags.push(["e", target.id.toLowerCase(), target.relay || "", "root", target.pubkey.toLowerCase()]);
1282+ tags.push(["p", target.pubkey.toLowerCase()]);
1283+ if (target.kind != null) tags.push(["k", String(target.kind)]);
1284+ }
1285+ const rumorContent = input.reaction ? String(input.reaction.content ?? content) : content;
1286+ if (!rumorContent || contentBytes(rumorContent) > MAX_CONTENT) throw Error("message content is empty or too large");
1287+ const rumor = {pubkey: sender, created_at: Math.floor(now), kind, tags, content: rumorContent};
1288+ rumor.id = eventHash(signerAPI(), rumor);
1289+ // Keep both encryption layers within the original NIP-44 payload limit.
1290+ if (contentBytes(JSON.stringify(rumor)) > 40 * 1024) throw Error("Message is too long to send. Split it into shorter messages.");
1291+ const sealFor = async target => {
1292+ const encrypted = await nip44Encrypt(signer, target, JSON.stringify(rumor));
1293+ const sealInput = {kind: 13, created_at: past(now), tags: [], content: encrypted};
1294+ const seal = await signer.signEvent(sealInput);
1295+ if (!seal || seal.kind !== sealInput.kind || seal.tags.length || seal.content !== encrypted || seal.created_at !== sealInput.created_at || seal.pubkey !== sender || !signerAPI().verifyEvent(seal)) throw Error("invalid NIP-59 seal");
1296+ return seal;
1297+ };
1298+ const wrap = async (target, seal) => {
1299+ const api = signerAPI();
1300+ const secret = api.generateSecretKey();
1301+ const ephemeral = api.getPublicKey(secret);
1302+ const key = api.nip44.getConversationKey(secret, target);
1303+ const encrypted = api.nip44.encrypt(JSON.stringify(seal), key);
1304+ const event = api.finalizeEvent({kind: 1059, created_at: past(now), tags: [["p", target]], content: encrypted}, secret);
1305+ if (!api.verifyEvent(event)) throw Error("invalid NIP-59 gift wrap");
1306+ return {recipient: target, event};
1307+ };
1308+ const [receiverSeal, senderSeal] = await Promise.all([sealFor(recipient), sealFor(sender)]);
1309+ return {rumor, events: [await wrap(recipient, receiverSeal), await wrap(sender, senderSeal)]};
1310+ }
1311+
1312+ async function decrypt(wrap, options = {}) {
1313+ const signer = options.signer || globalThis.tiny?.signer?.();
1314+ if (!wrap || wrap.kind !== 1059 || !hex64(wrap.pubkey) || !Array.isArray(wrap.tags) || wrap.tags.some(tag => !Array.isArray(tag) || tag.some(value => typeof value !== "string")))
1315+ throw Error("malformed gift wrap");
1316+ const actor = await signerPublicKey(signer);
1317+ const recipients = wrap.tags.filter(tag => tag[0] === "p");
1318+ if (recipients.length !== 1 || !hex64(recipients[0][1])) throw Error("malformed gift wrap recipient");
1319+ const recipient = normalizePubkey(recipients[0][1]);
1320+ if (recipient !== actor || (options.actor && normalizePubkey(options.actor) !== actor)) throw Error("gift wrap is not addressed to this signer");
1321+ if (typeof wrap.content !== "string" || wrap.content.length > 256 * 1024) throw Error("gift wrap is too large");
1322+ if (!signerAPI().verifyEvent(wrap) || wrap.id !== eventHash(signerAPI(), {...wrap, id: undefined})) throw Error("invalid gift wrap signature");
1323+ let seal;
1324+ try { seal = JSON.parse(await nip44Decrypt(signer, wrap.pubkey, wrap.content)); } catch { throw Error("unable to decrypt gift wrap"); }
1325+ if (!seal || seal.kind !== 13 || !hex64(seal.pubkey) || !Array.isArray(seal.tags) || seal.tags.length > MAX_TAGS || seal.tags.some(tag => !Array.isArray(tag) || tag.length < 1 || tag.length > 32 || tag.some(value => typeof value !== "string")) || typeof seal.content !== "string" || seal.content.length > 256 * 1024 || !signerAPI().verifyEvent(seal)) throw Error("malformed seal");
1326+ const expirations = seal.tags.filter(tag => tag[0] === "expiration");
1327+ if (expirations.some(tag => tag.length !== 2 || !/^\d+$/.test(tag[1]) || Number(tag[1]) < Math.floor(Date.now() / 1000))) throw Error("gift wrap has expired");
1328+ let rumor;
1329+ try { rumor = JSON.parse(await nip44Decrypt(signer, seal.pubkey, seal.content)); } catch { throw Error("unable to decrypt seal"); }
1330+ if (!rumor || ![5, 7, 14, 15].includes(rumor.kind) || !hex64(rumor.pubkey) || Object.prototype.hasOwnProperty.call(rumor, "sig") || !Number.isSafeInteger(rumor.created_at) || rumor.created_at < 0 || rumor.created_at > 8640000000000 || typeof rumor.content !== "string" || contentBytes(rumor.content) > MAX_CONTENT || !Array.isArray(rumor.tags) || rumor.tags.length > MAX_TAGS || rumor.tags.reduce((total, tag) => total + JSON.stringify(tag).length, 0) > 16 * 1024 || rumor.tags.some(tag => !Array.isArray(tag) || tag.length < 1 || tag.length > 32 || tag.some(value => typeof value !== "string")) || rumor.id !== eventHash(signerAPI(), {...rumor, id: undefined}) || seal.pubkey !== rumor.pubkey)
1331+ throw Error("malformed direct message rumor");
1332+ const addressed = rumor.tags.filter(tag => Array.isArray(tag) && tag[0] === "p" && hex64(tag[1])).map(tag => tag[1].toLowerCase());
1333+ const selfCopy = rumor.pubkey === actor;
1334+ const interactionTarget = [5, 7].includes(rumor.kind) && rumor.tags.some(tag => (tag[0] === "e" || tag[0] === "q") && hex64(tag[1]));
1335+ if (![5, 7].includes(rumor.kind) && (!addressed.includes(actor) && !selfCopy || options.actor && !addressed.includes(normalizePubkey(options.actor)) && !selfCopy)) throw Error("rumor recipient mismatch");
1336+ if ([5, 7].includes(rumor.kind) && !interactionTarget) throw Error("interaction target is missing");
1337+ return {rumor, seal, wrap, sender: rumor.pubkey};
1338+ }
1339+
1340+ const files = () => globalThis.tiny?.files?.messages;
1341+ const protocol = Object.freeze({
1342+ normalizePubkey,
1343+ build,
1344+ decrypt,
1345+ relayList: (...args) => files()?.relayList(...args),
1346+ queryRelayList: (...args) => files()?.queryRelayList(...args),
1347+ publishEvent: (...args) => files()?.publishEvent(...args)
1348+ });
1349+ globalThis.tiny = globalThis.tiny || {};
1350+ globalThis.tiny.chat = {...globalThis.tiny.chat, protocol};
1351+})();
1352diff --git a/internal/webui/file-messages.js b/internal/webui/file-messages.js
1353index 28eb6df..085ce36 100644
1354--- a/internal/webui/file-messages.js
1355+++ b/internal/webui/file-messages.js
1356@@ -90,7 +90,7 @@
1357 return response.json();
1358 }
1359
1360- function publishEvent(event, relays, options) {
1361+ function publishEvent(event, relays, options = {}) {
1362 const signer = options.signer || activeSigner();
1363 const WebSocketImpl = options.WebSocket || globalThis.WebSocket;
1364 if (typeof WebSocketImpl !== "function") throw Error("WebSocket support is unavailable");
1365@@ -321,6 +321,7 @@
1366 const apiObject = Object.freeze({
1367 build,
1368 share,
1369+ normalizePubkey,
1370 relayList,
1371 queryRelayList,
1372 publishEvent
1373diff --git a/internal/webui/page.html b/internal/webui/page.html
1374index 1cf6b9a..ec0e301 100644
1375--- a/internal/webui/page.html
1376+++ b/internal/webui/page.html
1377@@ -37,6 +37,7 @@ then feature modules are loaded for the current page. */}}
1378 {{else if eq .Tab "social-thread"}}{{template "social-thread" .}}
1379 {{else if eq .Tab "private"}}{{template "private" .}}
1380 {{else if eq .Tab "chat"}}{{template "chat" .}}
1381+{{else if eq .Tab "direct"}}{{template "direct" .}}
1382 {{else if eq .Tab "media"}}{{template "media" .}}
1383 {{else if eq .Tab "sites"}}{{template "sites" .}}
1384 {{else if eq .Tab "marmot"}}{{template "marmot" .}}
1385@@ -79,11 +80,14 @@ then feature modules are loaded for the current page. */}}
1386 <script src="{{scriptURL "file-catalog.js"}}"></script>
1387 <script src="{{scriptURL "blossom-manifests.js"}}"></script>
1388 <script src="{{scriptURL "blossom-upload.js"}}"></script>
1389-<script src="{{scriptURL "file-messages.js"}}"></script>
1390 <script src="{{scriptURL "file-workspace.js"}}"></script>
1391 {{end}}<script src="{{scriptURL "nostr-name.js"}}"></script>
1392 <script src="{{scriptURL "components.js"}}"></script>
1393 <script src="{{scriptURL "rooms.js"}}"></script>
1394+<script src="{{scriptURL "file-messages.js"}}"></script>
1395+<script src="{{scriptURL "direct-messages.js"}}"></script>
1396+<script src="{{scriptURL "chat-files.js"}}"></script>
1397+<script src="{{scriptURL "chat.js"}}"></script>
1398 {{if eq .Tab "connect"}}<script src="{{scriptURL "private-services.js"}}"></script>
1399 {{end}}</body>
1400 </html>
1401@@ -97,10 +101,13 @@ then feature modules are loaded for the current page. */}}
1402 <nav>{{$v := repoView .Query}}<a href="{{repoURL .Query "home" ""}}"{{if eq $v "home"}} aria-current="page"{{end}}>/home</a><a href="{{repoURL .Query "tree" ""}}"{{if or (eq $v "tree") (eq $v "file")}} aria-current="page"{{end}}>/code</a><a href="{{repoURL .Query "history" ""}}"{{if or (eq $v "history") (eq $v "commit")}} aria-current="page"{{end}}>/history</a><a href="{{repoURL .Query "issues" ""}}"{{if or (eq $v "issues") (eq $v "issue")}} aria-current="page"{{end}}>/issues</a><a href="{{repoURL .Query "prs" ""}}"{{if or (eq $v "prs") (eq $v "pr")}} aria-current="page"{{end}}>/prs</a><a href="{{repoURL .Query "activity" ""}}"{{if eq $v "activity"}} aria-current="page"{{end}}>/activity</a></nav>
1403 {{else if eq $kind "manage"}}<header><span><a href="/">{{.Slug}}</a>/<b>manage</b></span></header>
1404 {{range $group := navGroups manageItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}
1405-{{else if eq $kind "rooms"}}<header><span><a href="/">{{.Slug}}</a>/<a href="/rooms"><b>rooms</b></a></span></header>
1406+{{else if eq $kind "rooms"}}<header><span><a href="/">{{.Slug}}</a>/<a href="/chat"><b>chat</b></a></span></header>
1407+<nav aria-label="Chat"><a href="/chat"{{if eq .Tab "chat"}} aria-current="page"{{end}}>All conversations</a></nav>
1408+{{if .Actor}}<h4>Direct chats</h4><direct-chats actor="{{.Actor}}" relay="{{wsURL .URL}}" compact><p>Connect your signer to see direct chats.</p></direct-chats>
1409+<p><a href="/chat#new-chat">+ new chat</a></p>{{end}}
1410 <h4>Rooms</h4>
1411-<nav id="room-list">{{range .Rooms}}{{$r := asMap .}}<a href="/rooms/{{str (index $r "id")}}"{{if eq (str (index $r "id")) $.View}} aria-current="page"{{end}}>{{or (str (index $r "name")) (str (index $r "id"))}}{{with age (index $r "last_message_at")}} <small>{{.}}</small>{{end}}</a>{{else}}<a href="/rooms"{{if eq $.Tab "rooms"}} aria-current="page"{{end}}>/rooms</a>{{end}}</nav>
1412-<p><a href="/rooms#new-room">+ new room</a></p>
1413+<nav id="room-list">{{range .Rooms}}{{$r := asMap .}}<a href="/rooms/{{str (index $r "id")}}"{{if eq (str (index $r "id")) $.View}} aria-current="page"{{end}}>{{or (str (index $r "name")) (str (index $r "id"))}}{{with age (index $r "last_message_at")}} <small>{{.}}</small>{{end}}</a>{{else}}<a href="/chat">Browse rooms</a>{{end}}</nav>
1414+<p><a href="/chat#new-room">+ new room</a></p>
1415 {{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header>
1416 {{range $group := navGroups relayItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}
1417 {{end}}<p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">&larr; all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms")}}<a href="/">&larr; relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}}
1418@@ -110,6 +117,7 @@ then feature modules are loaded for the current page. */}}
1419 {{else if eq .Tab "repo"}}{{template "panel-repo" .}}
1420 {{else if eq .Tab "agents"}}{{template "panel-agents" .}}
1421 {{else if eq $kind "manage"}}{{template "panel-manage" .}}
1422+{{else if or (eq .Tab "chat") (eq .Tab "direct")}}{{template "panel-chat" .}}
1423 {{else if eq $kind "rooms"}}{{template "panel-rooms" .}}
1424 {{else if or (eq .Tab "social") (eq .Tab "social-thread")}}{{template "panel-social" .}}
1425 {{else if or (eq .Tab "inbox") (eq .Tab "outbox") (eq .Tab "search") (eq .Tab "articles")}}{{template "panel-feed" .}}
1426@@ -126,7 +134,7 @@ then feature modules are loaded for the current page. */}}
1427 <section><h4>Relay</h4><table><tr><th>url</th><td><code>{{.URL}}</code></td></tr></table></section>{{end}}
1428
1429 {{define "panel-signin"}}<section><h4>Session</h4><table><tr><th>status</th><td>{{if .Actor}}signed in{{else}}guest{{end}}</td></tr><tr><th>method</th><td>NIP-98</td></tr></table></section>
1430-<section><h4>After sign in</h4><ul><li>read your inbox</li><li>manage members and policy</li><li>upload files</li><li>push repositories</li></ul></section>{{end}}
1431+<section><h4>After sign in</h4><ul><li>join chats</li><li>manage members and policy</li><li>upload files</li><li>push repositories</li></ul></section>{{end}}
1432
1433 {{define "panel-repos"}}<section><h4>Hosting</h4><table><tr><th>server</th><td><code>{{.URL}}</code></td></tr><tr><th>protocol</th><td>GRASP-01</td></tr></table></section>
1434 <section><h4>Publish</h4><p><small>Announce with ngit and push with plain git. Every push needs the owner's signed state.</small></p></section>{{end}}
1435@@ -135,7 +143,7 @@ then feature modules are loaded for the current page. */}}
1436 {{if .Actor}}<section><h4>Upload</h4><file-upload pubkey="{{.Actor}}"></file-upload><p><small>Members-only files require membership to view or download.</small></p><details><summary>Import from URL</summary><file-mirror action="/mirror"></file-mirror></details><noscript><p>Uploads need JavaScript and a connected signer.</p></noscript></section>{{end}}{{end}}
1437
1438 {{define "panel-approvals"}}{{$counts := approvalCounts .Event}}{{$data := asMap .Event}}<section><h4>Waiting</h4><table><tr><th>open</th><td>{{index $counts "open"}}</td></tr><tr><th>oldest</th><td>{{with index $data "oldest_open"}}<time datetime="{{datetime .}}">{{when .}}</time>{{else}}none{{end}}</td></tr><tr><th>answered</th><td>{{index $counts "answered"}}</td></tr><tr><th>expired</th><td>{{index $counts "expired"}}</td></tr></table></section>
1439-<section><h4>Devices</h4>{{if .Actor}}{{range $index, $device := approvalDevices .Event}}<p><i></i>device {{add $index 1}} | {{$device}}</p>{{else}}<p>No device receives notifications for this key. Enable them under Inbox.</p>{{end}}<p><small>Answers from the phone need the remembered signer.</small></p>{{else}}<p>Sign in to see which devices are notified.</p>{{end}}</section>{{end}}
1440+<section><h4>Devices</h4>{{if .Actor}}{{range $index, $device := approvalDevices .Event}}<p><i></i>device {{add $index 1}} | {{$device}}</p>{{else}}<p>No device receives notifications for this key. Enable them in Chat.</p>{{end}}<p><small>Answers from the phone need the remembered signer.</small></p>{{else}}<p>Sign in to see which devices are notified.</p>{{end}}</section>{{end}}
1441
1442 {{define "panel-feed"}}<section><h4>Filters</h4><form method="get" action="/{{.Tab}}"><label for="filter-kinds">kinds</label><select id="filter-kinds" name="kinds"><option value="">notes and articles</option><option value="1"{{if eq (.Query.Get "kinds") "1"}} selected{{end}}>1 notes</option><option value="30023"{{if eq (.Query.Get "kinds") "30023"}} selected{{end}}>30023 articles</option><option value="7"{{if eq (.Query.Get "kinds") "7"}} selected{{end}}>7 reactions</option><option value="all"{{if eq (.Query.Get "kinds") "all"}} selected{{end}}>everything</option></select><label for="filter-author">author</label><input id="filter-author" name="author" placeholder="64 hex pubkey" value="{{.Query.Get "author"}}"><label for="filter-since">since</label><input id="filter-since" name="since" type="date" value="{{.Query.Get "since"}}">{{if eq .Tab "search"}}<label for="filter-q">search</label><input id="filter-q" name="q" value="{{.Query.Get "q"}}">{{end}}<button>apply</button></form></section>
1443 {{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}
1444@@ -156,7 +164,7 @@ then feature modules are loaded for the current page. */}}
1445 <section><h4>Kill switch</h4><rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form><rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form><p><small>Pausing keeps grants and rejects writes until resumed.</small></p></section>
1446 <section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>{{end}}
1447 {{define "panel-rooms"}}{{if eq .Tab "rooms"}}<section><h4>Rooms</h4><table><tr><th>rooms</th><td>{{len .Rooms}}</td></tr><tr><th>protocol</th><td>NIP-29</td></tr></table></section>
1448-<section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/rooms#new-room">new room</a></li>{{else}}<li><a href="{{signinURL .Base .Path .Query}}">sign in</a></li>{{end}}</ul></section>
1449+<section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/chat#new-room">new room</a></li>{{else}}<li><a href="{{signinURL .Base .Path .Query}}">sign in</a></li>{{end}}</ul></section>
1450 {{else if .Error}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{.View}}</code></td></tr></table></section>
1451 {{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}{{$role := str (index $room "role")}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{$id}}</code></td></tr><tr><th>access</th><td>{{if eq (str (index $room "access")) "members"}}members only{{else}}open{{end}}</td></tr><tr><th>created</th><td>{{short (index $room "created_at")}}</td></tr>{{if $role}}<tr><th>you</th><td>{{$role}}</td></tr>{{end}}</table></section>
1452 {{if eq .Tab "thread"}}<section><h4>Actions</h4><ul><li><a href="/rooms/{{$id}}">open room</a></li></ul></section>
1453diff --git a/internal/webui/public.html b/internal/webui/public.html
1454index 48b27e3..796d75c 100644
1455--- a/internal/webui/public.html
1456+++ b/internal/webui/public.html
1457@@ -29,7 +29,7 @@
1458 <h1>{{if .Actor}}Your account{{else}}Sign in{{end}}</h1>
1459 {{if .Notice}}<p role="status">{{.Notice}}</p>{{end}}
1460 {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p>
1461-<p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a> | <a href="/inbox">Inbox</a></p>
1462+<p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a> | <a href="/chat">Chat</a></p>
1463 <p>Use the sign out control in the footer to end this session.</p>
1464 <details id="relay-setup"><summary>Your relay lists (optional)</summary>
1465 <p>Choose where other Nostr clients find your notes, messages and files. Add this relay to any lists you want to use. Existing entries stay.</p>
1466@@ -138,12 +138,6 @@
1467 </section>
1468 {{end}}
1469
1470-{{define "chat"}}<section>
1471-<h1>Chat</h1>
1472-<p>Group and direct conversations are available through NIP-17 and NIP-29 clients.</p>
1473-</section>
1474-{{end}}
1475-
1476 {{define "media"}}<section>
1477 <h1>Media</h1>
1478 <p>Blossom uploads and NIP-96 metadata are available from compatible clients.</p>
1479diff --git a/internal/webui/read_contracts_test.go b/internal/webui/read_contracts_test.go
1480index d958b5a..550d583 100644
1481--- a/internal/webui/read_contracts_test.go
1482+++ b/internal/webui/read_contracts_test.go
1483@@ -35,7 +35,7 @@ func TestTypedRoomsReaderAvoidsLegacyQuery(t *testing.T) {
1484 if err != nil {
1485 t.Fatal(err)
1486 }
1487- for _, path := range []string{"/rooms", "/rooms/build"} {
1488+ for _, path := range []string{"/rooms/build"} {
1489 response := httptest.NewRecorder()
1490 app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
1491 if response.Code != http.StatusOK {
1492diff --git a/internal/webui/rooms.go b/internal/webui/rooms.go
1493index 3817ffa..28f2670 100644
1494--- a/internal/webui/rooms.go
1495+++ b/internal/webui/rooms.go
1496@@ -289,13 +289,13 @@ func clock(value any) string {
1497 // roomItem is one message as the page shows it: the event's fields, the
1498 // author's markers, the thread state and the reactions it has received.
1499 type roomItem struct {
1500- ID, PubKey, Avatar, Kind, Content, Room, Root, Role, Notice string
1501- CreatedAt, UpdatedAt int64
1502- Agent, InThread, Edited, Own bool
1503- Mentions []string
1504- Replies int
1505- Reactions []reaction
1506- Attachments []roomAttachment
1507+ ID, PubKey, Avatar, Kind, Content, Room, Root, Quote, Role, Notice string
1508+ CreatedAt, UpdatedAt int64
1509+ Agent, InThread, Edited, Own bool
1510+ Mentions []string
1511+ Replies int
1512+ Reactions []reaction
1513+ Attachments []roomAttachment
1514 }
1515
1516 // roomItems turns a browse result's messages or replies into view items,
1517@@ -356,6 +356,11 @@ func newRoomItem(row any, room string, members map[string]map[string]any) (roomI
1518 item := roomItem{ID: plainString(e["id"]), PubKey: plainString(e["pubkey"]), Kind: plainString(e["kind"]), Content: plainString(e["content"]), Room: room, CreatedAt: unixSeconds(e["created_at"])}
1519 item.UpdatedAt = item.CreatedAt
1520 item.Attachments = roomAttachments(row)
1521+ // Flotilla and other NIP-29 clients use q tags for quoted events. Keep
1522+ // only a valid event id so malformed references never become links.
1523+ if quotes := tagValues(row, "q"); len(quotes) > 0 && eventIDPattern.MatchString(quotes[0]) {
1524+ item.Quote = quotes[0]
1525+ }
1526 switch item.Kind {
1527 case "7", "40003":
1528 return roomItem{}, false
1529diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html
1530index fc57789..d601ac3 100644
1531--- a/internal/webui/rooms.html
1532+++ b/internal/webui/rooms.html
1533@@ -3,6 +3,8 @@ thread. room-live receives the room stream after the server-rendered page
1534 establishes the initial state. */}}
1535 {{define "rooms"}}<section id="rooms">
1536 <h1>Rooms</h1>
1537+{{template "roomList" .}}</section>{{end}}
1538+{{define "roomList"}}
1539 {{if .Error}}{{template "alert" .Error}}
1540 {{else if .Feed}}<table id="rows">
1541 <thead><tr><th>room</th><th>access</th><th>members</th><th>last message</th></tr></thead>
1542@@ -11,12 +13,12 @@ establishes the initial state. */}}
1543 {{end}}</tbody>
1544 </table>
1545 {{else}}<p>No rooms yet.</p>
1546-{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/rooms" $.Query "cursor" .}}">next page</a></p>
1547+{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/chat" $.Query "cursor" .}}">next page</a></p>
1548 {{end}}{{if .Actor}}<h2 id="new-room">New room</h2>
1549 <room-create><label>Name <input name="name" required maxlength="200" placeholder="build"></label><label>Id <input name="id" maxlength="64" pattern="[a-z0-9_-]{1,64}" placeholder="from the name"></label><label>Access <select name="access"><option value="open">open</option><option value="members">members only</option></select></label><label>About <textarea name="about" maxlength="2000"></textarea></label><button>Create room</button></room-create>
1550 <noscript><p>Creating a room needs JavaScript and a connected signer.</p></noscript>
1551 {{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to create a room.</p>
1552-{{end}}</section>
1553+{{end}}
1554 {{end}}
1555
1556 {{define "room"}}{{if .Error}}<section id="room">
1557@@ -64,6 +66,6 @@ establishes the initial state. */}}
1558 {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}
1559 {{define "roomMessage"}}<room-message id="msg-{{.ID}}" data-id="{{.ID}}" data-kind="{{.Kind}}" data-updated-at="{{.UpdatedAt}}" data-pubkey="{{.PubKey}}"{{if .Own}} data-own{{end}}{{if .Agent}} data-agent{{end}}{{if .Notice}} data-notice{{end}}{{if .Edited}} data-edited{{end}}><nostr-avatar pubkey="{{.PubKey}}" aria-hidden="true">{{.Avatar}}</nostr-avatar>
1560 <header><b>{{template "nostrName" .PubKey}}</b>{{if .Own}}<span data-own-label>you</span>{{end}}{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header>
1561-{{if .Notice}}<p>{{.Notice}}</p>{{else}}<div>{{chatMarkdown (roomAttachmentContent .Content .Attachments)}}</div>{{roomAttachmentMarkup .Attachments}}{{end}}
1562+{{if .Notice}}<p>{{.Notice}}</p>{{else}}{{if .Quote}}<blockquote data-quote><a href="/open?target={{urlquery (printf "nostr:%s" .Quote)}}">quoted event {{shortID .Quote}}</a></blockquote>{{end}}<div>{{chatMarkdown (roomAttachmentContent .Content .Attachments)}}</div>{{roomAttachmentMarkup .Attachments}}{{end}}
1563 {{if or .Mentions (and (not .InThread) (or (eq .Kind "11") .Root)) .Reactions .Edited}}<footer>{{if .Mentions}}<span>to {{range .Mentions}}{{template "nostrName" .}} {{end}}</span>{{end}}{{if not .InThread}}{{if eq .Kind "11"}}<a href="/rooms/{{.Room}}/thread/{{.ID}}">thread{{if .Replies}} | {{.Replies}} {{if eq .Replies 1}}reply{{else}}replies{{end}}{{end}}</a>{{else if .Root}}<a href="/rooms/{{.Room}}/thread/{{.Root}}">in thread</a>{{end}}{{end}}{{range .Reactions}}<span data-reaction="{{.Content}}">{{.Content}} {{.Count}}</span>{{end}}{{if .Edited}}<span data-edited>edited</span>{{end}}</footer>{{end}}
1564 </room-message>{{end}}
1565diff --git a/internal/webui/rooms.js b/internal/webui/rooms.js
1566index 652ebc0..aeae3fa 100644
1567--- a/internal/webui/rooms.js
1568+++ b/internal/webui/rooms.js
1569@@ -35,6 +35,7 @@
1570 // roomID derives a room id from a name: lowercase letters, digits, hyphen and underscore.
1571 const roomID = name => String(name || "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
1572 const tagValue = (event, name) => (event.tags || []).find(tag => tag[0] === name)?.[1] || "";
1573+ const quoteID = event => { const id = tagValue(event, "q"); return isHex64(id) ? id : ""; };
1574 const clock = seconds => {
1575 const stamp = new Date(seconds * 1000).toISOString();
1576 if (stamp.slice(0, 10) === new Date().toISOString().slice(0, 10)) return stamp.slice(11, 16);
1577@@ -356,7 +357,10 @@
1578 header.append(member?.role ? " | " + member.role : "", small);
1579 const attachments = notice ? [] : roomAttachments(event);
1580 const body = notice ? el("p", notice) : chatMarkdown(el("div"), attachmentContent(event.content || "", attachments));
1581- node.append(avatar, header, body);
1582+ node.append(avatar, header);
1583+ const quote = notice ? "" : quoteID(event);
1584+ if (quote) { const block = el("blockquote"), link = el("a", "quoted event " + quote.slice(0, 12)); block.dataset.quote = ""; link.href = tiny.localPath("/open?target=" + encodeURIComponent("nostr:" + quote)); block.title = quote; block.append(link); node.append(block); }
1585+ node.append(body);
1586 const media = attachmentNodes(attachments); if (media) node.append(media);
1587 const footer = el("footer");
1588 const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]);
1589@@ -577,6 +581,20 @@
1590 tags.push(["e", root]);
1591 if (isHex64(author) && author !== this.getAttribute("pubkey")) tags.push(["p", author]);
1592 }
1593+ const quote = this.getAttribute("quote");
1594+ if (quote) {
1595+ if (!isHex64(quote)) throw Error("The quoted event is invalid.");
1596+ const relay = this.getAttribute("quote-relay");
1597+ const author = this.getAttribute("quote-pubkey");
1598+ // NIP-10 keeps relay and author positional. Preserve the empty relay
1599+ // slot when only the quoted author's key is known.
1600+ const tag = ["q", quote, relay || ""];
1601+ if (isHex64(author)) {
1602+ tag.push(author);
1603+ if (author !== this.getAttribute("pubkey") && !tags.some(item => item[0] === "p" && item[1] === author)) tags.push(["p", author]);
1604+ }
1605+ tags.push(tag);
1606+ }
1607 roomMentions(content).forEach(key => { if (!tags.some(tag => tag[0] === "p" && tag[1] === key)) tags.push(["p", key]); });
1608 const lines = [];
1609 attachments.forEach(file => {
1610diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go
1611index 26cc24a..9794e64 100644
1612--- a/internal/webui/rooms_test.go
1613+++ b/internal/webui/rooms_test.go
1614@@ -78,7 +78,7 @@ func (b *roomsBackend) Query(_ context.Context, method string, params []json.Raw
1615 roomEvent(roomLike, roomOwner, 7, 1757203600, "+", []string{"h", id}, []string{"e", roomChat}),
1616 reply,
1617 root,
1618- roomEvent(roomChat, roomOwner, 9, 1757202000, "@agent take a look <script>alert(1)</script>", []string{"h", id}, []string{"p", roomAgent}),
1619+ roomEvent(roomChat, roomOwner, 9, 1757202000, "@agent take a look <script>alert(1)</script>", []string{"h", id}, []string{"p", roomAgent}, []string{"q", roomThread, "", roomAgent}),
1620 roomEvent(roomHello, roomAgent, 9, 1757201000, "hello, see https://example.com/docs. and nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2", []string{"h", id}),
1621 }
1622 return map[string]any{"room": roomRecord(id, strings.ToUpper(id[:1])+id[1:], map[string]string{"general": "open", "build": "members"}[id], role), "members": members, "messages": messages, "next_cursor": "cursor-1"}, nil
1623@@ -114,8 +114,8 @@ func roomsPage(t *testing.T, app *App, path string) string {
1624
1625 func TestRoomsListRendersRoomsRailAndCreateForm(t *testing.T) {
1626 app, backend := roomsApp(t, roomOwner)
1627- body := roomsPage(t, app, "/rooms")
1628- for _, want := range []string{`<table id="rows">`, `<a href="/rooms/general">General</a>`, `<a href="/rooms/build">Build</a>`, `members only <small>Where Build happens.</small>`, `<room-create>`, `<a href="/rooms"><b>rooms</b></a>`, `<nav id="room-list"><a href="/rooms/general">General`, `<a href="/rooms#new-room">+ new room</a>`, `<p id="rail-tools"><a href="/">&larr; relay</a>`, `<h4>Rooms</h4>`, `<td>2</td>`} {
1629+ body := roomsPage(t, app, "/chat")
1630+ for _, want := range []string{`<table id="rows">`, `<a href="/rooms/general">General</a>`, `<a href="/rooms/build">Build</a>`, `members only <small>Where Build happens.</small>`, `<room-create>`, `<a href="/chat"><b>chat</b></a>`, `<nav id="room-list"><a href="/rooms/general">General`, `<a href="/chat#new-room">+ new room</a>`, `<p id="rail-tools"><a href="/">&larr; relay</a>`, `<h4>Rooms</h4>`, `<td>2</td>`} {
1631 if !strings.Contains(body, want) {
1632 t.Errorf("rooms list missing %q", want)
1633 }
1634@@ -124,15 +124,16 @@ func TestRoomsListRendersRoomsRailAndCreateForm(t *testing.T) {
1635 t.Fatalf("calls = %v", backend.calls)
1636 }
1637 guestApp, _ := roomsApp(t, "")
1638- body = roomsPage(t, guestApp, "/rooms")
1639- if strings.Contains(body, "<room-create>") || !strings.Contains(body, `<a href="/signin?next=%2Frooms">Sign in</a> to create a room.`) || strings.Contains(body, `href="/rooms/build"`) {
1640+ body = roomsPage(t, guestApp, "/chat")
1641+ if strings.Contains(body, "<room-create>") || !strings.Contains(body, `<a href="/signin?next=%2Fchat">Sign in</a> to create a room.`) || strings.Contains(body, `href="/rooms/build"`) {
1642 t.Fatalf("guest rooms list: %s", body[strings.Index(body, `id="content"`):])
1643 }
1644- // The relay rail lists rooms first in the conversation group.
1645+ // The relay rail lists chat in the conversation group and leaves the
1646+ // reliability event views out of the primary navigation.
1647 home := roomsPage(t, app, "/")
1648- rooms, inbox, search := strings.Index(home, `href="/rooms"`), strings.Index(home, `href="/inbox"`), strings.Index(home, `href="/search"`)
1649- if rooms < 0 || inbox < 0 || !(search < rooms && rooms < inbox) {
1650- t.Fatalf("relay rail order: search=%d rooms=%d inbox=%d", search, rooms, inbox)
1651+ chat, search := strings.Index(home, `href="/chat"`), strings.Index(home, `href="/search"`)
1652+ if chat < 0 || search < 0 || search > chat || strings.Contains(home, `href="/inbox"`) || strings.Contains(home, `href="/outbox"`) {
1653+ t.Fatalf("relay rail order or hidden reliability views: search=%d chat=%d", search, chat)
1654 }
1655 }
1656
1657@@ -149,6 +150,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
1658 `<section id="room" data-viewer="` + roomOwner + `">`,
1659 `<a href="https://example.com/docs" rel="noopener">https://example.com/docs</a>.`,
1660 `<a href="/open?target=nostr%3Anpub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2">nostr:npub1`,
1661+ `<blockquote data-quote><a href="/open?target=nostr%3A` + roomThread + `">quoted event ` + shortID(roomThread) + `</a></blockquote>`,
1662 `&lt;script&gt;alert(1)&lt;/script&gt;`,
1663 `<span>to <nostr-name pubkey="` + roomAgent + `"`,
1664 `<span data-reaction="&#43;1">&#43;1 1</span>`,
1665diff --git a/internal/webui/style.css b/internal/webui/style.css
1666index 9f945c8..54916e6 100644
1667--- a/internal/webui/style.css
1668+++ b/internal/webui/style.css
1669@@ -317,6 +317,7 @@ room-message[data-own]{margin-left:24px;padding:12px 14px 14px 56px;background:v
1670 room-message[data-own]>nostr-avatar{left:12px}
1671 room-message [data-own-label]{margin-left:6px;color:var(--accent);font-size:11px}
1672 room-message>p,room-message>div{min-width:0;margin:0;font:14px/1.55 var(--sans);overflow-wrap:anywhere}room-message>p{white-space:pre-wrap}
1673+room-message>[data-quote]{margin:0 0 8px;padding:6px 10px;border-left:3px solid var(--accent);background:var(--rail);font:12px/1.4 var(--mono);overflow-wrap:anywhere}
1674 room-message>p a,room-message>div a{color:var(--accent)}
1675 room-message>div p{margin:0 0 6px}room-message>div>:last-child{margin-bottom:0}room-message>div ul,room-message>div ol{margin:0 0 6px 20px;padding:0}
1676 room-message>div pre{margin:0 0 6px;padding:6px 8px;background:var(--rail);border:1px solid var(--line);overflow:auto;font:12px/1.4 var(--mono);white-space:pre}room-message>div code{font:12px var(--mono)}room-message>div h1,room-message>div h2,room-message>div h3,room-message>div h4{margin:4px 0 6px;font-size:14px;border:0}
1677@@ -458,3 +459,66 @@ social-compose input:not([type=file]),social-compose textarea{width:100%;box-siz
1678
1679 [data-reaction-emoji]{display:inline-block;width:20px;height:20px;object-fit:contain;vertical-align:middle}
1680 [data-post-body=note]>img,[data-comment-body]>img{display:block;width:auto;max-height:560px;margin:12px 0}
1681+
1682+/* Direct conversations share the room palette and keep their plaintext in the browser. */
1683+#chat,#direct-chat{max-width:860px;margin:0 auto;box-sizing:border-box}
1684+#chat>p,#direct-chat>header>p{color:var(--muted)}
1685+#direct-chat>header{padding-bottom:14px;border-bottom:1px solid var(--line)}
1686+#direct-chat h1{font-size:23px;overflow-wrap:anywhere}
1687+direct-start,direct-chats,direct-thread,direct-compose,direct-settings{display:block;min-width:0}
1688+direct-start{margin:12px 0 18px;padding:14px;background:var(--rail);border:1px solid var(--line);border-radius:10px}
1689+direct-start label{display:block;color:var(--muted);font-size:12px}
1690+direct-start input{display:block;width:100%;margin:6px 0 10px;min-height:42px;font-size:16px}
1691+direct-start button,direct-compose button{min-height:40px;padding:8px 16px;background:var(--accent);color:var(--paper);border:0;border-radius:7px}
1692+direct-chats{margin:8px 0 24px}
1693+direct-chats>a{display:block;position:relative;padding:14px 12px 14px 56px;border-bottom:1px solid var(--line);color:var(--ink);min-height:34px;overflow-wrap:anywhere}
1694+direct-chats>a:hover{background:var(--rail);text-decoration:none}
1695+direct-chats>a [data-author]{font-weight:600}
1696+direct-chats>a nostr-avatar{position:absolute;left:10px;top:15px;width:32px;height:32px}
1697+direct-chats>a time{display:block;color:var(--muted);font:11px/1.5 var(--mono);margin-top:4px}
1698+direct-chats>a p{margin:4px 0 0;color:var(--muted);overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
1699+direct-chats>p{color:var(--muted);padding:10px 0}
1700+#rail direct-chats{margin:0 0 10px;font-size:12px}
1701+#rail direct-chats>a{padding:10px 16px 10px 50px;border:0}
1702+#rail direct-chats>a nostr-avatar{left:20px;top:10px;width:24px;height:24px}
1703+#rail direct-chats time,#rail direct-chats>a p{display:none}
1704+#rail direct-chats>p{padding:0 20px;font-size:11px}
1705+#rail direct-chats>a[aria-current=page]{background:var(--soft);color:var(--accent)}
1706+direct-thread{min-height:160px;max-height:calc(100dvh - 340px);overflow:auto;overscroll-behavior:contain;padding:12px 0}
1707+direct-thread>article{position:relative;margin:0 18px 14px 0;padding:12px 14px 12px 54px;border-bottom:1px solid var(--line);overflow-wrap:anywhere}
1708+direct-thread>article[data-own=""]{margin:0 0 14px 24px;border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:12px;background:var(--soft)}
1709+direct-thread article nostr-avatar{position:absolute;left:12px;top:12px;width:30px;height:30px}
1710+direct-thread article header{font:12px/1.4 var(--mono);color:var(--muted);margin:0 0 7px}
1711+direct-thread article header [data-author]{color:var(--ink)}
1712+direct-thread article time{display:block;margin-top:3px;font-size:10px}
1713+direct-thread article>p{margin:0;font:15px/1.55 var(--sans);white-space:pre-wrap}
1714+direct-thread article img,direct-thread article video{display:block;max-width:100%;max-height:360px}
1715+direct-thread article audio{max-width:100%}
1716+direct-compose{position:sticky;bottom:0;background:var(--paper);padding:12px 0 6px;border-top:1px solid var(--line)}
1717+direct-compose label{display:block;color:var(--muted);font-size:12px;margin-bottom:5px}
1718+direct-compose textarea{display:block;resize:vertical;min-height:76px;max-height:180px;margin-bottom:8px;border-radius:8px;font:16px/1.5 var(--sans)}
1719+direct-compose output,direct-start output{display:block;margin-top:6px;font-size:12px;overflow-wrap:anywhere}
1720+direct-compose output[data-error],direct-start output[data-error]{color:var(--alert)}
1721+direct-settings button{width:100%;min-height:40px;white-space:normal;font-size:12px}
1722+@media(max-width:720px){
1723+#chat #rows,#chat #rows tbody,#chat #rows tr{display:block;width:auto}
1724+#chat #rows thead{display:none}
1725+#chat #rows tr{padding:10px 0;border-bottom:1px solid var(--line)}
1726+#chat #rows td{display:block;width:auto;padding:2px 0;border:0;height:auto}
1727+#chat #rows td:nth-child(3){display:none}
1728+#chat #rows td:last-child{width:auto;color:var(--muted);font-size:11px}
1729+direct-thread{max-height:calc(100dvh - 340px);min-height:160px}
1730+direct-thread>article{margin-right:0;padding-left:48px}
1731+direct-thread>article[data-own=""]{margin-left:10px}
1732+direct-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));padding-bottom:8px}
1733+}
1734+direct-thread article>blockquote{margin:4px 0 9px;padding:6px 10px;border-left:2px solid var(--muted);color:var(--muted);font:12px/1.5 var(--sans);white-space:pre-wrap}
1735+direct-thread article>footer{margin-top:8px;font:12px var(--mono);color:var(--muted)}
1736+direct-thread article>footer button{padding:3px 8px;margin-right:6px;border:0;border-radius:5px;font:12px var(--sans)}
1737+direct-thread article>footer output{display:block;margin-top:5px;color:var(--alert)}
1738+direct-chats>output,direct-thread>output{display:block;margin:8px 0;font-size:12px;color:var(--alert)}
1739+direct-file{display:block;margin-top:8px;overflow-wrap:anywhere}
1740+direct-file article{padding:0;margin:0;border:0}
1741+direct-file button{display:block;margin:8px 0;padding:8px 12px;min-height:40px}
1742+direct-file output,direct-settings output{display:block;font-size:12px;color:var(--muted);margin-top:6px}
1743+direct-file pre{max-height:360px;overflow:auto;padding:10px;background:var(--rail);font:13px/1.5 var(--mono)}
1744diff --git a/internal/webui/templates.go b/internal/webui/templates.go
1745index 2819e93..8188fa4 100644
1746--- a/internal/webui/templates.go
1747+++ b/internal/webui/templates.go
1748@@ -16,7 +16,7 @@ import (
1749 // templateFS holds every page template. page.html owns the shell and the
1750 // shared partials; the other files each define one group of tabs.
1751 //
1752-//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html rooms.html social.html
1753+//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html rooms.html social.html chat.html
1754 var templateFS embed.FS
1755
1756 // styleCSS is inlined into every page so the UI needs no extra request and
1757@@ -41,6 +41,15 @@ var componentsJS string
1758 //go:embed rooms.js
1759 var roomsJS string
1760
1761+//go:embed chat.js
1762+var chatJS string
1763+
1764+//go:embed chat-files.js
1765+var chatFilesJS string
1766+
1767+//go:embed direct-messages.js
1768+var directMessagesJS string
1769+
1770 // nostr-name.js is the vendored <nostr-name> element with the relay-local
1771 // profile loader; rebuild it with npm run build:nostr-name.
1772 //
1773@@ -104,20 +113,27 @@ var iconSVG []byte
1774 //go:embed icon-mono.svg
1775 var iconMonoSVG []byte
1776
1777-// navItem is one rail entry. Tab matches PageData.Tab for the active state.
1778-// navItem is one rail entry. Group orders entries by what they are for; the
1779-// rail leaves a small gap between groups.
1780+// navItem is one rail entry. Tab matches PageData.Tab for the active state;
1781+// Group separates related destinations with a small gap.
1782 type navItem struct {
1783 Label, Href, Tab string
1784 Group int
1785 }
1786
1787-// relayNav: entry points, then conversation, then collaborative artifacts,
1788-// then what the relay publishes and stores, then what is yours.
1789-var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "search", 0}, {"/rooms", "/rooms", "rooms", 1}, {"/inbox", "/inbox", "inbox", 1}, {"/approvals", "/approvals", "approvals", 1}, {"/repos", "/repos", "repos", 2}, {"/wiki", "/wiki", "wiki", 2}, {"/files", "/files", "files", 3}, {"/social", "/social", "social", 3}, {"/sites", "/sites", "sites", 3}, {"/outbox", "/outbox", "outbox", 4}, {"/manage", "/manage/people", "manage", 4}}
1790+// relayNav groups discovery, conversations, shared work and administration.
1791+var relayNav = []navItem{
1792+ {"/home", "/", "home", 0},
1793+ {"/search", "/search", "search", 0},
1794+ {"/social", "/social", "social", 1},
1795+ {"/chat", "/chat", "chat", 1},
1796+ {"/repos", "/repos", "repos", 2},
1797+ {"/wiki", "/wiki", "wiki", 2},
1798+ {"/files", "/files", "files", 2},
1799+ {"/sites", "/sites", "sites", 2},
1800+ {"/approvals", "/approvals", "approvals", 3},
1801+ {"/manage", "/manage/people", "manage", 3},
1802+}
1803
1804-// manageNav: who is here, what they may do, what the relay is, what it does
1805-// over time, and how it is doing.
1806 var manageNav = []navItem{{"/people", "/manage/people", "people", 0}, {"/agents", "/manage/agents", "agents", 0}, {"/moderation", "/manage/moderation", "moderation", 1}, {"/rules", "/manage/rules", "rules", 1}, {"/identity", "/manage/identity", "identity", 2}, {"/connect", "/manage/connect", "connect", 2}, {"/owner", "/manage/owner", "owner", 2}, {"/sync", "/manage/sync", "sync", 3}, {"/data", "/manage/data", "data", 3}, {"/views", "/manage/views", "views", 3}, {"/health", "/manage/health", "health", 4}}
1807
1808 // navGroups splits a rail list into its groups, in order.
1809@@ -138,7 +154,7 @@ func railKind(tab string) string {
1810 if tab == "repo" {
1811 return "repo"
1812 }
1813- if tab == "rooms" || tab == "room" || tab == "thread" {
1814+ if tab == "chat" || tab == "direct" || tab == "rooms" || tab == "room" || tab == "thread" {
1815 return "rooms"
1816 }
1817 for _, item := range manageNav {
1818@@ -225,7 +241,7 @@ func repoView(query url.Values) string {
1819 // scripts are served at /scripts/<name> and cached by the service worker.
1820 // One version stamp covers them all, so a change to any file refreshes every
1821 // cached copy together.
1822-var scripts = map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "rooms.js": roomsJS, "nostr-name.js": nostrNameJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS, "file-catalog.js": fileCatalogJS}
1823+var scripts = map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "rooms.js": roomsJS, "chat.js": chatJS, "chat-files.js": chatFilesJS, "direct-messages.js": directMessagesJS, "nostr-name.js": nostrNameJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS, "file-catalog.js": fileCatalogJS}
1824
1825 var scriptsVersion = func() string {
1826 names := make([]string, 0, len(scripts))
1827diff --git a/internal/webui/webui.go b/internal/webui/webui.go
1828index 31ddb9c..cab314d 100644
1829--- a/internal/webui/webui.go
1830+++ b/internal/webui/webui.go
1831@@ -176,7 +176,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
1832 if a.denyPrivateEndpoint(writer, request) {
1833 return
1834 }
1835- if a.handleControlRoute(writer, request) || a.handleWebMCPRoute(writer, request) || a.handleBrowseRoute(writer, request) {
1836+ if a.handleChatRoute(writer, request) || a.handleControlRoute(writer, request) || a.handleWebMCPRoute(writer, request) || a.handleBrowseRoute(writer, request) {
1837 return
1838 }
1839 if a.handleSocialRoute(writer, request) {
1840diff --git a/scripts/chat-files.test.mjs b/scripts/chat-files.test.mjs
1841new file mode 100644
1842index 0000000..0c86d72
1843--- /dev/null
1844+++ b/scripts/chat-files.test.mjs
1845@@ -0,0 +1,74 @@
1846+import assert from "node:assert/strict";
1847+import {webcrypto} from "node:crypto";
1848+import {readFile} from "node:fs/promises";
1849+import test from "node:test";
1850+import vm from "node:vm";
1851+
1852+const source = await readFile(new URL("../internal/webui/chat-files.js", import.meta.url), "utf8");
1853+const hex = value => Buffer.from(value).toString("hex");
1854+let DirectFile;
1855+const revoked = [];
1856+const sandbox = {
1857+ crypto: webcrypto, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, URL, Blob,
1858+ location: {origin: "https://relay.test"}, fetch: async () => { throw Error("unexpected fetch"); },
1859+ HTMLElement: class {}, customElements: {get: () => undefined, define(_name, ctor) { DirectFile = ctor; }},
1860+ document: {createElement: tag => ({localName: tag, append() {}, replaceChildren() {}, addEventListener() {}})}
1861+};
1862+sandbox.globalThis = sandbox;
1863+sandbox.tiny = {util: {
1864+ bytes: value => value instanceof Uint8Array ? value : new Uint8Array(value),
1865+ hex, sha256: async value => new Uint8Array(await webcrypto.subtle.digest("SHA-256", value))
1866+}};
1867+vm.runInNewContext(source, sandbox);
1868+const api = sandbox.tiny.chat.files;
1869+
1870+const makeFile = async (plain = new TextEncoder().encode("hello")) => {
1871+ const key = webcrypto.getRandomValues(new Uint8Array(32));
1872+ const nonce = webcrypto.getRandomValues(new Uint8Array(12));
1873+ const cryptoKey = await webcrypto.subtle.importKey("raw", key, {name: "AES-GCM"}, false, ["encrypt"]);
1874+ const encrypted = new Uint8Array(await webcrypto.subtle.encrypt({name: "AES-GCM", iv: nonce}, cryptoKey, plain));
1875+ const digest = value => webcrypto.subtle.digest("SHA-256", value).then(hex);
1876+ return {plain, encrypted, key, nonce, hash: await digest(encrypted), plaintextHash: await digest(plain)};
1877+};
1878+
1879+test("parses interoperable NIP-17 kind 15 tags without exposing key in the node", async () => {
1880+ const file = await makeFile();
1881+ const rumor = {kind: 15, content: "https://cdn.example/file.mp4", tags: [
1882+ ["p", "a".repeat(64)], ["file-type", "video/mp4"], ["encryption-algorithm", "aes-gcm"],
1883+ ["decryption-key", hex(file.key)], ["decryption-nonce", hex(file.nonce)], ["x", file.hash], ["ox", file.plaintextHash], ["size", String(file.encrypted.length)]
1884+ ]};
1885+ const parsed = api.parse(rumor);
1886+ assert.equal(parsed.mime, "video/mp4");
1887+ assert.equal(parsed.hash, file.hash);
1888+ assert.deepEqual(await api.decrypt(parsed, file.encrypted), file.plain);
1889+ const node = api.render(rumor);
1890+ assert.equal(node.rumor, rumor);
1891+});
1892+
1893+test("rejects credentialed, non-HTTP and malformed attachment URLs", () => {
1894+ const base = {kind: 15, tags: [["encryption-algorithm", "aes-gcm"], ["decryption-key", "a".repeat(64)], ["decryption-nonce", "b".repeat(24)], ["x", "c".repeat(64)]]};
1895+ for (const content of ["data:text/plain,secret", "javascript:alert(1)", "https://user:pass@example.test/a"]) {
1896+ assert.throws(() => api.parse({...base, content}), /URL/);
1897+ }
1898+ assert.throws(() => api.parse({...base, content: "https://example.test/a", tags: base.tags.map(tag => tag[0] === "x" ? ["x", "bad"] : tag)}), /hash/);
1899+});
1900+
1901+test("checks ciphertext and plaintext hashes and authenticated decryption", async () => {
1902+ const file = await makeFile();
1903+ const rumor = {kind: 15, content: "https://cdn.example/file.txt", tags: [["encryption-algorithm", "aes-gcm"], ["decryption-key", hex(file.key)], ["decryption-nonce", hex(file.nonce)], ["x", file.hash], ["ox", file.plaintextHash]]};
1904+ const parsed = api.parse(rumor);
1905+ const altered = new Uint8Array(file.encrypted); altered[0] ^= 1;
1906+ await assert.rejects(api.decrypt(parsed, altered), /hash/);
1907+ const wrongKey = {...parsed, key: new Uint8Array(32).fill(7)};
1908+ await assert.rejects(api.decrypt(wrongKey, file.encrypted), /decryption failed|plaintext hash mismatch/);
1909+});
1910+
1911+test("revokes attachment object URLs when its direct-file element disconnects", () => {
1912+ const oldRevoke = sandbox.URL.revokeObjectURL;
1913+ sandbox.URL.revokeObjectURL = value => revoked.push(value);
1914+ const node = new DirectFile();
1915+ node._attachmentURLs = new Set(["blob:attachment-1", "blob:attachment-2"]);
1916+ node.disconnectedCallback();
1917+ assert.deepEqual(revoked, ["blob:attachment-1", "blob:attachment-2"]);
1918+ sandbox.URL.revokeObjectURL = oldRevoke;
1919+});
1920diff --git a/scripts/chat.test.mjs b/scripts/chat.test.mjs
1921new file mode 100644
1922index 0000000..730e78f
1923--- /dev/null
1924+++ b/scripts/chat.test.mjs
1925@@ -0,0 +1,89 @@
1926+import test from "node:test";
1927+import assert from "node:assert/strict";
1928+import fs from "node:fs";
1929+import vm from "node:vm";
1930+import {getEventHash, verifyEvent} from "nostr-tools/pure";
1931+
1932+class FakeNode {
1933+ constructor(name = "node") { this.nodeName = name; this.childNodes = []; this.dataset = {}; this.attributes = {}; this.style = {}; this.isConnected = true; }
1934+ append(...children) { this.childNodes.push(...children.flat().filter(Boolean)); }
1935+ replaceChildren(...children) { this.childNodes = children; }
1936+ setAttribute(name, value) { this.attributes[name] = String(value); }
1937+ getAttribute(name) { return this.attributes[name] || null; }
1938+ addEventListener() {}
1939+ removeEventListener() {}
1940+ querySelector() { return null; }
1941+ querySelectorAll() { return []; }
1942+ closest() { return null; }
1943+}
1944+class FakeElement extends FakeNode {}
1945+const definitions = new Map();
1946+globalThis.HTMLElement = FakeElement;
1947+globalThis.document = {visibilityState: "visible", createElement: name => new FakeNode(name), createTextNode: text => Object.assign(new FakeNode("text"), {textContent: text}), addEventListener() {}, removeEventListener() {}};
1948+globalThis.customElements = {get: name => definitions.get(name), define: (name, value) => definitions.set(name, value)};
1949+globalThis.location = {href: "https://relay.test/chat", pathname: "/chat"};
1950+globalThis.setInterval = () => 0; globalThis.clearInterval = () => {};
1951+
1952+const key = char => char.repeat(64);
1953+const alice = key("a"), bob = key("b"), carol = key("c");
1954+const rumor = (id, sender, recipient, created_at, content, kind = 14, extra = []) => ({id, kind, pubkey: sender, created_at, content, tags: [["p", recipient], ...extra]});
1955+const wrap = (id, r) => ({id, kind: 1059, pubkey: bob, created_at: r.created_at, tags: [["p", alice]], rumor: r});
1956+const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; };
1957+const signer = {getPublicKey: async () => alice};
1958+const protocol = {
1959+ normalizePubkey: value => String(value).toLowerCase(),
1960+ decrypt: async (event, options) => { if (event.invalid) throw Error("invalid wrap"); if (event.recipient && event.recipient !== options.actor) throw Error("wrong recipient"); return {rumor: event.rumor}; },
1961+ relayList: value => value, queryRelayList: async () => ["wss://relay.test"],
1962+ build: async input => ({rumor: rumor("built", alice, input.recipient, 100, input.content), events: [{event: {id: "receiver-wrap"}}, {event: {id: "sender-wrap"}}]}), publishEvent: async () => true
1963+};
1964+let pages = [];
1965+globalThis.tiny = {chat: {protocol}, signer: () => signer, signedFetch: async () => ({ok: true, json: async () => pages.shift()}), localPath: value => value, util: {relayURL: value => value}, rooms: {linkify: value => Object.assign(new FakeNode("text"), {textContent: value})}, ui: {FormElement: FakeElement}, navigate: async () => {}, signing: {publish: async () => ({})}};
1966+globalThis.NostrSigner = {verifyEvent: () => true};
1967+vm.runInThisContext(fs.readFileSync("internal/webui/chat.js", "utf8"), {filename: "chat.js"});
1968+const ui = tiny.chat.ui;
1969+
1970+test("cache accepts files, rejects groups, deduplicates and folds reactions", async () => {
1971+ const value = ui.stateFor(alice), first = rumor(key("1"), alice, bob, 20, "one"), second = rumor("two", bob, alice, 10, "two");
1972+ const group = rumor("group", alice, bob, 30, "group", 14, [["p", carol]]), reaction = rumor("reaction", bob, alice, 31, "+", 7, [["e", first.id]]), outsiderReaction = rumor("outsider-reaction", carol, alice, 31, "+", 7, [["e", first.id]]), file = rumor("file", bob, alice, 32, "blossom://relay/file", 15, [["file-type", "video/mp4"]]);
1973+ await ui.cacheEvents(value, [wrap("one-wrap", first), wrap("one-duplicate", first), wrap("two-wrap", second), wrap("group-wrap", group), wrap("reaction-wrap", reaction), wrap("outsider-reaction-wrap", outsiderReaction), wrap("file-wrap", file)], signer);
1974+ assert.deepEqual(ui.messages(value).map(row => row.content), ["two", "one", "blossom://relay/file"]); assert.deepEqual([...ui.reactions(value, first)], [["♥", 1]]); assert.equal(value.events.size, 5); assert.equal(ui.participants(group).length, 3);
1975+});
1976+
1977+test("concurrent loads share one request and older pagination clears cursor", async () => {
1978+ ui.clear(); pages = [{events: [wrap("new", rumor("new", bob, alice, 100, "new"))], next_cursor: "older"}]; let calls = 0; const original = tiny.signedFetch; tiny.signedFetch = async (...args) => { calls++; return original(...args); };
1979+ await Promise.all([ui.load(alice), ui.load(alice)]); assert.equal(calls, 1); pages = [{events: [wrap("old", rumor("old", alice, bob, 1, "old"))], next_cursor: ""}]; await ui.load(alice, true); assert.equal(ui.stateFor(alice).cursor, ""); tiny.signedFetch = original;
1980+});
1981+
1982+test("refresh scans recent overlap without resetting an older cursor", async () => {
1983+ ui.clear(); pages = [{events: [wrap("seed", rumor("seed", bob, alice, 2000000000, "seed"))], next_cursor: "deep"}]; await ui.load(alice); assert.equal(ui.stateFor(alice).cursor, "deep"); pages = [{events: [wrap("fresh", rumor("fresh", bob, alice, 2000000100, "fresh"))], next_cursor: "overlap"}, {events: [wrap("overlap-old", rumor("overlap-old", alice, bob, 1999999999, "old"))], next_cursor: ""}]; const oldNow = Date.now; Date.now = () => 2000000200 * 1000; await ui.load(alice); Date.now = oldNow; assert.equal(ui.stateFor(alice).events.has("fresh"), true); assert.equal(ui.stateFor(alice).events.has("overlap-old"), true); assert.equal(ui.stateFor(alice).cursor, "deep");
1984+});
1985+
1986+test("partial delivery retries exact wraps and skips acknowledged copy", async () => {
1987+ ui.clear(); const value = ui.stateFor(alice); let buildCalls = 0, published = [], reportPending; protocol.build = async input => { buildCalls++; return {rumor: rumor("stable", alice, input.recipient, 200, input.content), events: [{event: {id: "wrap-a"}}, {event: {id: "wrap-b"}}]}; }; let attempt = 0; protocol.publishEvent = async event => { published.push(event.id); attempt++; return attempt === 1 || attempt === 3; };
1988+ const input = {recipient: bob, content: "retry"}; await assert.rejects(ui.deliver(value, input, null, pending => { reportPending = pending; }), /history copy failed/); assert.equal(buildCalls, 1); assert.deepEqual(published, ["wrap-a", "wrap-b"]); await ui.deliver(value, input, reportPending, pending => { reportPending = pending; }); assert.deepEqual(published, ["wrap-a", "wrap-b", "wrap-b"]); protocol.publishEvent = async () => true;
1989+});
1990+
1991+test("logout or signer replacement during pending decryption cannot cache plaintext", async () => {
1992+ ui.clear(); const value = ui.stateFor(alice), gate = deferred(); let activeActor = alice; const changingSigner = {getPublicKey: async () => activeActor}; protocol.decrypt = () => gate.promise; const pending = ui.cacheEvents(value, [wrap("pending", rumor("pending", bob, alice, 1, "secret"))], changingSigner); activeActor = bob; gate.resolve({rumor: rumor("pending", bob, alice, 1, "secret")}); await pending; assert.equal(value.events.size, 0); ui.clear(); protocol.decrypt = async event => ({rumor: event.rumor});
1993+});
1994+
1995+test("source parity fixture verifies and preserves its signed event hash", () => {
1996+ const source = JSON.parse(fs.readFileSync("scripts/fixtures/chat-client-parity.json", "utf8")).events[0]; assert.equal(verifyEvent(source), true); assert.equal(getEventHash({...source, id: undefined, sig: undefined}), source.id);
1997+});
1998+
1999+test("custom chat elements are registered", () => { for (const name of ["direct-chats", "direct-thread", "direct-compose", "direct-start", "direct-settings"]) assert.ok(customElements.get(name)); });
2000+
2001+test("wrapped retractions remove only their author's message or reaction", async () => {
2002+ ui.clear();
2003+ const value = ui.stateFor(alice);
2004+ const original = rumor(key("1"), alice, bob, 10, "message");
2005+ const like = rumor(key("2"), bob, alice, 11, "+", 7, [["e", original.id]]);
2006+ const malicious = rumor(key("3"), carol, alice, 12, "", 5, [["e", original.id]]);
2007+ await ui.cacheEvents(value, [wrap("orig", original), wrap("like", like), wrap("bad-delete", malicious)], signer);
2008+ assert.equal(ui.messages(value).length, 1);
2009+ assert.deepEqual([...ui.reactions(value, original)], [["♥", 1]]);
2010+ await ui.cacheEvents(value, [wrap("retract", rumor(key("4"), bob, alice, 13, "", 5, [["e", like.id]]))], signer);
2011+ assert.deepEqual([...ui.reactions(value, original)], []);
2012+ await ui.cacheEvents(value, [wrap("delete", rumor(key("5"), alice, bob, 14, "", 5, [["e", original.id]]))], signer);
2013+ assert.equal(ui.messages(value).length, 0);
2014+});
2015diff --git a/scripts/direct-messages.test.mjs b/scripts/direct-messages.test.mjs
2016new file mode 100644
2017index 0000000..423214d
2018--- /dev/null
2019+++ b/scripts/direct-messages.test.mjs
2020@@ -0,0 +1,165 @@
2021+import test from "node:test";
2022+import assert from "node:assert/strict";
2023+import fs from "node:fs";
2024+import vm from "node:vm";
2025+import { finalizeEvent, generateSecretKey, getEventHash, getPublicKey, nip19, nip44, verifyEvent } from "nostr-tools";
2026+
2027+globalThis.NostrSigner = {generateSecretKey, getPublicKey, getEventHash, finalizeEvent, verifyEvent, nip44, decodeNpub: value => nip19.decode(value).data};
2028+globalThis.tiny = {util: {relayURL: value => value}, files: {messages: {normalizePubkey: value => /^[0-9a-f]{64}$/i.test(value) ? value.toLowerCase() : nip19.decode(value).data}}};
2029+vm.runInThisContext(fs.readFileSync("internal/webui/direct-messages.js", "utf8"), {filename: "direct-messages.js"});
2030+
2031+const alice = generateSecretKey();
2032+const bob = generateSecretKey();
2033+const alicePub = getPublicKey(alice);
2034+const bobPub = getPublicKey(bob);
2035+const makeSigner = secret => ({
2036+ async getPublicKey() { return getPublicKey(secret); },
2037+ async signEvent(unsigned) { return finalizeEvent(unsigned, secret); },
2038+ async nip44Encrypt(peer, value) { return nip44.encrypt(value, nip44.getConversationKey(secret, peer)); },
2039+ async nip44Decrypt(peer, value) { return nip44.decrypt(value, nip44.getConversationKey(secret, peer)); },
2040+ nip44: {
2041+ getConversationKey(peer) { return nip44.getConversationKey(secret, peer); },
2042+ encrypt(first, second) {
2043+ if (/^[0-9a-f]{64}$/i.test(first)) return nip44.encrypt(second, nip44.getConversationKey(secret, first));
2044+ return nip44.encrypt(first, second);
2045+ },
2046+ decrypt(peer, value) { return nip44.decrypt(value, nip44.getConversationKey(secret, peer)); },
2047+ },
2048+});
2049+
2050+// Model a NIP-07 signer: the shared wrappers pass peer and payload to these
2051+// methods, while the signer retains the private key internally.
2052+globalThis.tiny.nip44Encrypt = (peer, value, signer) => signer.nip44.encrypt(peer, value);
2053+globalThis.tiny.nip44Decrypt = (peer, value, signer) => signer.nip44.decrypt(peer, value);
2054+const nip07Signer = secret => {
2055+ const base = makeSigner(secret);
2056+ return {...base, nip44Encrypt: undefined, nip44Decrypt: undefined};
2057+};
2058+
2059+test("build creates independently decryptable recipient and self-copy wraps", async () => {
2060+ const protocol = globalThis.tiny.chat.protocol;
2061+ const result = await protocol.build({recipient: bobPub, content: "hello", reply: {id: "a".repeat(64), pubkey: alicePub}}, {signer: makeSigner(alice), now: 100});
2062+ assert.equal(result.rumor.kind, 14);
2063+ assert.equal(result.rumor.id, getEventHash(result.rumor));
2064+ assert.equal(result.events.length, 2);
2065+ assert.deepEqual(result.events.map(item => item.event.tags), [[["p", bobPub]], [["p", alicePub]]]);
2066+ assert.deepEqual((await protocol.decrypt(result.events[0].event, {signer: makeSigner(bob)})).rumor, result.rumor);
2067+ assert.deepEqual((await protocol.decrypt(result.events[1].event, {signer: makeSigner(alice)})).rumor, result.rumor);
2068+});
2069+
2070+test("decrypt rejects tampering, spoofed recipient and third-party access", async () => {
2071+ const protocol = globalThis.tiny.chat.protocol;
2072+ const result = await protocol.build({recipient: bobPub, content: "private"}, {signer: makeSigner(alice), now: 100});
2073+ const tampered = {...result.events[0].event, content: result.events[0].event.content + "x"};
2074+ await assert.rejects(() => protocol.decrypt(tampered, {signer: makeSigner(bob)}), /invalid gift wrap|unable to decrypt/);
2075+ await assert.rejects(() => protocol.decrypt(result.events[0].event, {signer: makeSigner(alice)}), /not addressed/);
2076+ const carol = generateSecretKey();
2077+ await assert.rejects(() => protocol.decrypt(result.events[0].event, {signer: makeSigner(carol)}), /not addressed|unable to decrypt/);
2078+});
2079+
2080+test("normalizes npub and preserves relay transport wrappers", () => {
2081+ const protocol = globalThis.tiny.chat.protocol;
2082+ assert.equal(protocol.normalizePubkey(nip19.npubEncode(alicePub)), alicePub);
2083+ assert.equal(typeof protocol.relayList, "function");
2084+ assert.equal(typeof protocol.queryRelayList, "function");
2085+ assert.equal(typeof protocol.publishEvent, "function");
2086+});
2087+
2088+test("round trips a kind 15 file rumor through the NIP-07 path", async () => {
2089+ vm.runInThisContext(fs.readFileSync("internal/webui/file-messages.js", "utf8"), {filename: "file-messages.js"});
2090+ const file = await globalThis.tiny.files.messages.build({
2091+ recipient: bobPub, fileURL: "https://files.example/photo.jpg", ciphertextHash: "a".repeat(64),
2092+ mimeType: "image/jpeg", key: "b".repeat(64), nonce: "c".repeat(24)
2093+ }, {signer: nip07Signer(alice), now: 100});
2094+ const direct = globalThis.tiny.chat.protocol;
2095+ const opened = await direct.decrypt(file.events[0].event, {signer: nip07Signer(bob)});
2096+ assert.equal(opened.rumor.kind, 15);
2097+ assert.equal(opened.rumor.content, "https://files.example/photo.jpg");
2098+});
2099+
2100+test("rejects signed rumors, malformed timestamps and optional-tag abuse", async () => {
2101+ const protocol = globalThis.tiny.chat.protocol;
2102+ const result = await protocol.build({recipient: bobPub, content: "bounded"}, {signer: nip07Signer(alice), now: 100});
2103+ const opened = await protocol.decrypt(result.events[0].event, {signer: nip07Signer(bob)});
2104+ const makeWrap = rumor => {
2105+ const seal = finalizeEvent({kind: 13, created_at: 1, tags: [], content: nip44.encrypt(JSON.stringify(rumor), nip44.getConversationKey(alice, bobPub))}, alice);
2106+ const ephemeral = generateSecretKey();
2107+ const wrapped = nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(ephemeral, bobPub));
2108+ return finalizeEvent({kind: 1059, created_at: 1, tags: [["p", bobPub], ["expiration", String(Math.floor(Date.now() / 1000) + 3600)]], content: wrapped}, ephemeral);
2109+ };
2110+ const signed = {...opened.rumor, sig: "f".repeat(128)};
2111+ signed.id = getEventHash(signed);
2112+ await assert.rejects(() => protocol.decrypt(makeWrap(signed), {signer: nip07Signer(bob)}), /malformed direct message/);
2113+ const badTime = {...opened.rumor, created_at: 1.5};
2114+ badTime.id = getEventHash(badTime);
2115+ await assert.rejects(() => protocol.decrypt(makeWrap(badTime), {signer: nip07Signer(bob)}), /malformed direct message/);
2116+ const duplicateP = {...result.events[0].event, tags: [["p", bobPub], ["p", bobPub]]};
2117+ await assert.rejects(() => protocol.decrypt(duplicateP, {signer: nip07Signer(bob)}), /recipient/);
2118+});
2119+
2120+test("rejects a signer that mutates the requested seal", async () => {
2121+ const protocol = globalThis.tiny.chat.protocol;
2122+ const evil = nip07Signer(alice);
2123+ evil.signEvent = async unsigned => finalizeEvent({...unsigned, content: unsigned.content + " altered"}, alice);
2124+ await assert.rejects(() => protocol.build({recipient: bobPub, content: "do not alter"}, {signer: evil}), /invalid NIP-59 seal/);
2125+});
2126+
2127+test("round trips an encrypted NIP-25 reaction in both copies", async () => {
2128+ const protocol = globalThis.tiny.chat.protocol;
2129+ const target = {id: "d".repeat(64), pubkey: alicePub, kind: 14};
2130+ const result = await protocol.build({recipient: bobPub, reaction: {...target, content: "+"}}, {signer: nip07Signer(alice), now: 100});
2131+ assert.equal(result.rumor.kind, 7);
2132+ assert.deepEqual(result.rumor.tags.slice(1), [["e", target.id, "", "root", target.pubkey], ["p", target.pubkey], ["k", "14"]]);
2133+ assert.equal((await protocol.decrypt(result.events[0].event, {signer: nip07Signer(bob)})).rumor.content, "+");
2134+ assert.equal((await protocol.decrypt(result.events[1].event, {signer: nip07Signer(alice)})).rumor.kind, 7);
2135+});
2136+
2137+test("rejects reactions with malformed targets", async () => {
2138+ const protocol = globalThis.tiny.chat.protocol;
2139+ await assert.rejects(() => protocol.build({recipient: bobPub, reaction: {id: "nope", pubkey: alicePub, content: "+"}}, {signer: nip07Signer(alice)}), /reaction target/);
2140+});
2141+
2142+test("accepts an inbound Flotilla reaction with only the target-author p tag", async () => {
2143+ const protocol = globalThis.tiny.chat.protocol;
2144+ const target = {id: "e".repeat(64), pubkey: bobPub, kind: 14};
2145+ const signer = nip07Signer(alice);
2146+ const rumor = {pubkey: bobPub, created_at: 100, kind: 7, tags: [["e", target.id, "", "root"], ["p", bobPub], ["k", "14"]], content: "+"};
2147+ rumor.id = getEventHash(rumor);
2148+ const seal = finalizeEvent({kind: 13, created_at: 100, tags: [], content: nip44.encrypt(JSON.stringify(rumor), nip44.getConversationKey(bob, alicePub))}, bob);
2149+ const ephemeral = generateSecretKey();
2150+ const wrapped = nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(ephemeral, alicePub));
2151+ const gift = finalizeEvent({kind: 1059, created_at: 100, tags: [["p", alicePub]], content: wrapped}, ephemeral);
2152+ const opened = await protocol.decrypt(gift, {signer});
2153+ assert.equal(opened.rumor.pubkey, bobPub);
2154+ assert.equal(opened.rumor.kind, 7);
2155+});
2156+
2157+test("large escaped messages are rejected before asking the signer to encrypt", async () => {
2158+ let calls = 0;
2159+ const signer = nip07Signer(alice);
2160+ signer.nip44.encrypt = () => { calls++; throw Error("must not reach encryption"); };
2161+ await assert.rejects(() => tiny.chat.protocol.build({recipient: bobPub, content: "\n".repeat(24000)}, {signer}), /too long/);
2162+ assert.equal(calls, 0);
2163+});
2164+
2165+test("accepts an encrypted author retraction without a conversation p tag", async () => {
2166+ const rumor = {pubkey: bobPub, created_at: 100, kind: 5, tags: [["e", "e".repeat(64)]], content: ""};
2167+ rumor.id = getEventHash(rumor);
2168+ const seal = finalizeEvent({kind: 13, created_at: 100, tags: [], content: nip44.encrypt(JSON.stringify(rumor), nip44.getConversationKey(bob, alicePub))}, bob);
2169+ const secret = generateSecretKey();
2170+ const gift = finalizeEvent({kind: 1059, created_at: 100, tags: [["p", alicePub]], content: nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(secret, alicePub))}, secret);
2171+ assert.deepEqual((await tiny.chat.protocol.decrypt(gift, {signer: nip07Signer(alice)})).rumor, rumor);
2172+});
2173+
2174+test("decrypts independently generated Flotilla/Welshman gift wraps", async () => {
2175+ const fixture = JSON.parse(fs.readFileSync("scripts/fixtures/flotilla-gift-wraps.json", "utf8"));
2176+ const signer = nip07Signer(Uint8Array.from(Buffer.from(fixture.recipientSecret, "hex")));
2177+ const kinds = [];
2178+ for (const {gift, rumor} of fixture.vectors) {
2179+ assert.equal(verifyEvent(gift), true);
2180+ const opened = await tiny.chat.protocol.decrypt(gift, {signer});
2181+ assert.deepEqual(opened.rumor, rumor);
2182+ kinds.push(rumor.kind);
2183+ }
2184+ assert.deepEqual(kinds, [14, 14, 7, 15, 5]);
2185+});
2186diff --git a/scripts/fixtures/chat-client-parity.json b/scripts/fixtures/chat-client-parity.json
2187new file mode 100644
2188index 0000000..1afeda9
2189--- /dev/null
2190+++ b/scripts/fixtures/chat-client-parity.json
2191@@ -0,0 +1,41 @@
2192+{
2193+ "purpose": "Small signed event vectors for NIP-17 and NIP-29 client parity checks.",
2194+ "sources": {
2195+ "nip17": "https://github.com/nostr-protocol/nips/blob/master/17.md",
2196+ "nip29": "https://github.com/nostr-protocol/nips/blob/master/29.md",
2197+ "zero_x_chat_commit": "76675e7b832212c96c7a3061778bf9ac8971da9b",
2198+ "flotilla_commit": "19f549102471f0c79d33ce4a729deb0ceb205c33"
2199+ },
2200+ "identities": {
2201+ "alice": "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f",
2202+ "bob": "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766"
2203+ },
2204+ "events": [
2205+ {
2206+ "kind": 7,
2207+ "created_at": 1700000001,
2208+ "tags": [
2209+ ["e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "wss://relay.example", "root", "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"],
2210+ ["p", "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766"],
2211+ ["k", "14"]
2212+ ],
2213+ "content": "+",
2214+ "pubkey": "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f",
2215+ "id": "bdba9e9029393e1d82f96c8ad4ee6dc669429cecc769fdb64ecf91acb800f31c",
2216+ "sig": "a901c80a30549b689672842a734c025eeca91382ce7e67b0f09d5ec60b37fdc16262274f6b9177a56757ee4c7d3aa949404d4573eceafebe69e16a1a26f5967f"
2217+ },
2218+ {
2219+ "kind": 9,
2220+ "created_at": 1700000002,
2221+ "tags": [
2222+ ["h", "general"],
2223+ ["q", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://relay.example", "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766"],
2224+ ["p", "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766"]
2225+ ],
2226+ "content": "A quoted room reply",
2227+ "pubkey": "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f",
2228+ "id": "ad2d909d883d2e6eeecebe6a75cbd243cd40f3fc14b3872068e0fcf3980b162f",
2229+ "sig": "f5c73a690b68541cbfc4acab94fb3f6b52364c243a315ac819b85612b1819a414b931fa73e58447f5ba72ecac6c26655ac14a30de0e1ee8fac6ad14368601db7"
2230+ }
2231+ ]
2232+}
2233diff --git a/scripts/fixtures/flotilla-gift-wraps.json b/scripts/fixtures/flotilla-gift-wraps.json
2234new file mode 100644
2235index 0000000..0a46038
2236--- /dev/null
2237+++ b/scripts/fixtures/flotilla-gift-wraps.json
2238@@ -0,0 +1,191 @@
2239+{
2240+ "source": "https://gitea.coracle.social/coracle/flotilla",
2241+ "revision": "19f549102471f0c79d33ce4a729deb0ceb205c33",
2242+ "generator": "@welshman/signer 0.10.6 Nip59.wrap",
2243+ "recipientSecret": "0101010101010101010101010101010101010101010101010101010101010101",
2244+ "note": "Public disposable test key; these messages were generated locally, never published.",
2245+ "vectors": [
2246+ {
2247+ "gift": {
2248+ "kind": 1059,
2249+ "pubkey": "d9e11f1620f54da35a8ba679ac402dbc8a36ec5638007e605e9f74f452952b46",
2250+ "content": "AmZuujSZo3Ud0j8Vx3vUZ9LQSI8fXxjKvo3dO+feS7yeaZ3+jmLSpZoDAoJ586IaapbHuGC9engYhBqCtlOzQDdYb8xX8XF+x5aV/v6VlWpqjfPE8UZXKFz5MWJTPp1aB4WD8bWWltpkB7xe4PGat3LtjdyjT6jFVm49YTuT4kCdKl2Yw4LwEZoH0acmQGbY5o8sQVCAU4Frna5l9JHHaD8b92SwLdxe60herJ+i0ELKYyjDFNQsQ4hIxCK08GF/sG5HAGPfc1z/q8YOZ7WoKrTwwmMhiF9HXvEpjdjv8sJ7qC/VY7PYJCsBtMPJO4wTeC+WRtEIHRlHZB3r4RpwptvcMxoqE9HRyzP1tAylePwzMLI7oWc7+zGjbOx3yjedoCqBczhAzsni6ZKUN56z5sqquGEJL88NzLQSZU7L/0GSX4hUl2Arvs9xSEYyEeC+3QIL1kDt2XTCh7AO/VyHe2lw4Tk8FqvhrOafvuGbzMkrhetc+1FfJdiitAeoTvTbE1SpzCeRudhCaPcsQTg5os/4M0pmDVcW3/gKykJwkF3SjUx+2GTuicVmwM8/ah0/uXlz9veSP28VOVBPQVUGbhdGvj9G3fFnmA+1wcGZIqCQ50wY94DtuHCw+zvBe/cahGXTgEN+MyZA2mB0o0ED9ivdAr/oyPxvQPUh3SsT2u+89ZmVPzQtHcktp7mSvzshaO3q6a2ER/j8cVTPjCBDKbpyw8tlBbZbI6Wxh//bdS8qFqnHHDqjZ8f2vcZ4OmGbfP1Ojp7Vd2LZq8NNvvBhRv4B0zgZkP23ckh6HFitqTIx5LlOwtvDX9i2UuMqAvLmTYyI+7vX3C8gLcEsIj51hGEgwjJlHR2Bkcs4LEFj7ot8M1sKtlmL59cyL+1I2RB7nbShnj6qRDc8BZzRbpBQtB/7V3yENQmPoibj+y8lGsngQqd7jHRAvHFBaUv53JFPIVWSBQ+uMIszPLEkphv125kRhTzc9Yw+bKm8dKxdJUQE+28BHhpEMiCXToMS0f+kcBYKTa313r5NcY+17pakQQEYxpZ8j24DScKkw6HmDQgEIKy39zGpmCe7cKBgaBerUs++PHPHSsjeRfK9Tjlg1k27QSD50tXUMU70NuXRmhTL+eWwmv3gn0njbb1wnP1yjAhiIXEIinujyl+YKa8dyg9SX/OheJZOw7532oOKA0hRI19abyBHbF7rwTRC4O3JK3j9WfwNzwAqSmNJ6IMQvvkrDEkGByi8zqO0DDifW0BdcTwhL4eyGNFQP6NQ/EF1rVbyZae233JAUNdZAda+OairjIcZxrrmwQivryAtrC5GQ2hySUe/Z4JLvXVMz2+pcmugkinCSZz1FAobsXSW0XJfF7K1ws9K4ZdJU0uwCWRYbbv0zDzcQN6RG6KQ6aPPcrOxeW7bAIqzi/D2JNrxagI6LtjysjBRvPNKo+KjkE9Kyl4=",
2251+ "created_at": 1789059779,
2252+ "tags": [
2253+ [
2254+ "p",
2255+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"
2256+ ]
2257+ ],
2258+ "id": "c6e0bd5bbb7a70e4d7da5789a2e7a57bf20f7b8ecfcb12ae54bb10d50407bf82",
2259+ "sig": "f9b7e3fb6ea55dbed9f70b915ada17318b8c8f74f17694f057b0b4c24a323edf06e30e09f931118145f6ddaa0958f93724a41634074d279da340e87750a2a88b"
2260+ },
2261+ "rumor": {
2262+ "kind": 14,
2263+ "content": "Flotilla text with a link https://example.com",
2264+ "tags": [
2265+ [
2266+ "p",
2267+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"
2268+ ]
2269+ ],
2270+ "created_at": 1700000000,
2271+ "pubkey": "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766",
2272+ "id": "c10c8cd36ca311af47e918e39f0864430566b04ee8a1bfc538c512bc51f6ecf4"
2273+ }
2274+ },
2275+ {
2276+ "gift": {
2277+ "kind": 1059,
2278+ "pubkey": "53d82e8a3e5f694b17ccab9d7432a8341d758ed5c5d2a4aac4ea29d5388766a0",
2279+ "content": "As1gubdsM+GJ67dqVdoE3qmKrkHEKyrShUg0xuAI0TFYRTe4hr2N2pxs8wyAsPWktbYLp38O2yXklom4213j7qWxjJ1ADyXknDJWXQmCWPZNGyYMeYDtXR7R9C9qXMZDa1Qx9f6kNGDxqoUD6z4IeAFSW2optC5nyQsMxW5U+og2H3AxoDoIg+bFR9t5n5hiUFCAf4GGY2Pj0skFmRdJVIEhXLLG4lq4X0HkiQq7CqMiJ1FLpp86SbA569BGDlo3UzkEV6ojJbLW6lkYfUzWJhcGxKbqsSOQmfwtJ1G/W3vOLruGpyx/Uofc5GEiQJdDsHbLGHzli17tDohoLdM8EARc3lieigbrSp10qE1c+SNsKkgm9QrrI50z4ldYcgqNHEsus9ktqUrtR2MjZtMzhja7k/JHnEEly1SfGl+lnHD+Dd/k0alXufEuKhQAc9t/sM6GVMqw32W5AJpnrH2Sl3hqd1gHr4zKQnEbeM6RTDpNOXIxgShXR238hLCJrvzWaSw2vu23PtkmkW/UdoEesfJMW96z2B2Q7n3eACvGnbi09aj2K3UJT1p0C0t98SkZPYPp12QLDphDjYtLtBpMGrLPHiHe6HRjt2+1QBTXuX0AvB7/eHuU5KLSkEAhpSkBXKA1inTtADgae8WPFDtRgB7C9qyRXHCSfMYZA/alxUVJ8DGkXc10Lsf9Q8f+erZEvbxhhV0yYDNNAcWLv+LAp4CxI5aTkApMvXQt67CNagJURLito5WHxGisWX6eREnvsbtP/E/+Wi8E3ocgoX/dBEyoPNQojFIosEMVSZQ+tkXwG8FHsNCUTs6mwvvtGQX5x+Qpwm78XLKIBh9f4XOzs7plbraXo+Mjy9NTgF6tgIAjmQ5xu1dCmsXdRHr2QxyrBFPRuqxo6XTWVlyRdHC3vjeNAjxQo+WTDqWuK8M9WXk/o7CBKrLnaXIhFeI6Ix/ED/jvYDYXo0h/NSD5axT3sDoxpRTXPR67Bi3cILESOWGd1szLbwHpfPUF03Zoj77irkQ0t1Txjwe3EIT0CZCCoq4+1Z1DZ1oJhV8yY4XIiAkvYwy55ThdMExSHWKaBT+EqOWe2CLCCOYTRqOnhfmOObj+/8IXt66bkPJIL5LwoTHCcScWuRJLW9eTt0xxIcnZsvGAR01voIz2wQ4q3gznekPPCH0tk0X8WMeuaXoRqcLwhV5NDj35uvzwlRKwKH5Edpdi7Tmw7hwmuBEaYmFarSTeRIobmJIqJFqSxQNS4XhLppOKTe01n4Md2u5g1fzBDv/ATrioDn96aFbH3nFkJYWbWGBlBIwE7cOkYU06miVtaI7QdRwedZ0nWmOsHgPgNMiKXexdt/CE+6Z0aoXEBzY+zFLeozIMj2nEslqBkK4iqCfsT6Nb6CU6kE2scZwAd2TEX5MN1kXFsocClWLyvzaaA2evPxL907SEyTA4typX3+E=",
2280+ "created_at": 1789090030,
2281+ "tags": [
2282+ [
2283+ "p",
2284+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"
2285+ ]
2286+ ],
2287+ "id": "41e586499f525516477598cc01868a991218c1aa732a11f9f88152b8f2348728",
2288+ "sig": "24b54ba493d6e233ecbeb91f0c4080c14031aec79e39cd948643cf8c8f24c3c99865ed8c56dc48a78005431c3e47b1808545f17023ca5542bfcb2e61d9ff9b0a"
2289+ },
2290+ "rumor": {
2291+ "kind": 14,
2292+ "content": "Flotilla reply",
2293+ "tags": [
2294+ [
2295+ "p",
2296+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"
2297+ ],
2298+ [
2299+ "e",
2300+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2301+ "",
2302+ "reply"
2303+ ]
2304+ ],
2305+ "created_at": 1700000000,
2306+ "pubkey": "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766",
2307+ "id": "c3d6dd1b3c8928dcf999ae5f5b64b83edb19244a87df25f18038057d50c871b1"
2308+ }
2309+ },
2310+ {
2311+ "gift": {
2312+ "kind": 1059,
2313+ "pubkey": "a689c1629654c82d4ebc9f7d21facb419e828a26d85d577b7845a68ccec9de1e",
2314+ "content": "AlHFI2htGAFsm3b54zLCrFnrB8au5hEisytoxAV7NJhEhMc1i0HxU2Ei9SoPwPnAVJSFOqa+qkCS5f6hicEHmQsLf9iltvo2NtrSub6Wn82GaYZF0CECuNU+vGwLc0h5XhjKbllySkHJfVM/pmrrBFoVyaU52ENMUnScm4FHwbHeNpX4PQAqTpsn8nxj6Ivlun7NhiyJhhEr/3vFrEHlz3lRdRQ/6xOXsg7MxIHo/cP+qHH0Dsd3N+glo9brKyTZz1cypP+kXF7jD5upb8AOVJkWWzut6Qbeb5o4QRSeu7PQ6nb8F/sti9hVIo1AChVcy8NuMBoVgSvUj07yAjx9aJGVs+jE6nCdVZpTpV21JZG8u5cDnXae+21n0X7n6nhWnzDNBcDoYl2eirqfhLFvNp9bA8xeVzn3JuQR+ab7pOQTazkJaiTWX4A9FBSyBLSCDiha5vk0EHIFy6/0YaLuRM4My63VKwmVRElAmcUQUi2YItADtp9629Ch8dQ1ZVDQ2k2vHH3mANc40igOmqv49imbc8arzGGFty7U6xZDz6ZiM8Jr5sinTBwjZg0jFy3iqPNf1dPnZVARxMNfSjdekTaEMo9Z8E6hOMELJQuVYn43RqbthynGfufhV9TWigHT6SgNHxb4rBmHYL8BnFtzAT4++6TTQt7mPgBCVuMH6XvWvf/cg9yDzB64Lx3Wkj5vDlN1LX1kNKlEl/FvF3Aj8VCLZfZrEP9I9p9OzAZba4p0/6fnOUqfnAzlW9MkaJ6QMEgs7YKK/45nN8wTIyUeL+mP6FlU4rvoYzy+QPr7mEkaT+Cgk2HDz9TR7LCO5fn1/Pb39njXi7XXjbuGGeLXr9U6Q0Kh9GM1L6teLlvP37MXS23JIn2cXJfxB2A3pT7phaeN2enMV74xapECPmCgOunHbf0N3ZwxgGAyKlq/gW5dBZ3z8xAMUDTZPMOd5O3Qmlo4NmrKZkPhVuxtiVrfpMbk4S5ditOkorkoLFkcGpEN6+HnvPSbFbNHf3JRFP7jNZem+NOH4CCwov5eYUaMhc+Kc2ICKGifGRYM5T39U4TnDoVJ5KmO4E0LcaUuusJ1TeL04dWgmE7jZlZpm1oVmMeFEcLmZm0/q3jYtgVjX8MUWqmKC4ZstFXlbwcawyU700KGlZMtWDDkMdCYAAApxOqNj6+zgGgwdT1THZM69xTmbDylE/GIgNzpp1/8Yr06J9NNLlOabMpwEJYqEovST5wDIVSHI2mMNIdz1ECJsqRtw/xF1yIirzn1VZcmeiWlsEVSrOXQgbfrC9xYQh5i+DYI2+ZNhTB8H/nSCkdjSyBqzytiUSEJeRt55EPFJxbNVqhrFVj7hq/AtHTuSwbEGj88aMe1qYaHZ1t/5hTnoRxPWELn+uc4DibolGPLEHjzP3nvdIvnCaEC2iZjlzrd7bZMgPxCRayCzjrZnallC3bDYDGVdR55c7Fm6QIKoMOxK4uksqEiOkGVYIxHgls/o0n1bwrIuMS/7FtYBQ8UEgX8+bqFYcGYtZm7jAEvG0UAMufHTuxoGy0Y+LPLuiXAtUI++jd54gLEFVDrQFVA2FEAJEyaQvLrHzhHxOr885okCeDegwoWyCsOdMPGwWGkeyuXby0FCA3DfXQmRQwBBqNZf/Sf9KnJBkbMkK4iCiqspDU+toNmnL3QUP3dZtGvYTitxclBuUfgno0L2NkpTv6/J+zCqHPtlfSKu1pgzYVCtJAC6BZvHnf4EKKZcKCJKxinNR128s+N4wz/LE+DQE/aSBLjDK5p+WQstCaMtP2+6rmr",
2315+ "created_at": 1789010884,
2316+ "tags": [
2317+ [
2318+ "p",
2319+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"
2320+ ]
2321+ ],
2322+ "id": "c4029536987b822d7726e7ecbe471bc984391f9b28eaf220f5b60842a326a1aa",
2323+ "sig": "f78255a3ca586d881e9b76e82007bc048277ab5fc9e6b05da7a52e2ac9cfacfc868bcc779df06db6b94ada18fe973cfaf502cfabc52cdeb24f339e03912f522a"
2324+ },
2325+ "rumor": {
2326+ "kind": 7,
2327+ "content": "+",
2328+ "tags": [
2329+ [
2330+ "e",
2331+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
2332+ "",
2333+ "root",
2334+ "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766"
2335+ ],
2336+ [
2337+ "p",
2338+ "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766"
2339+ ],
2340+ [
2341+ "k",
2342+ "14"
2343+ ]
2344+ ],
2345+ "created_at": 1700000000,
2346+ "pubkey": "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766",
2347+ "id": "c4b777da575c7fe4329ca9dd51c3b2d5837e80d2957ed05395c0306477c1993c"
2348+ }
2349+ },
2350+ {
2351+ "gift": {
2352+ "kind": 1059,
2353+ "pubkey": "85b72b715afce7616f41143b2568854db5895db0a4181508e95b1414cb4cb542",
2354+ "content": "Ar3KGQjekIpLfV1GZkylrXUm1TJiivYY37hWsqW5F7h2Pe/rsNXBfFGrMEnJyrGnjvDlivkRMOBZhZx4ZC7xzwPu2omLqekT8ThiPzS+2oLwQl5BLNdnmSwqMkYsnNn++QBLn32AeAiex2UM5srcPx1HIJm3IcXgprAE1vvgj+81CwCPNJXGjf5Dk2u3HVu/FryzNfvymEalTl77Sw5XMvhYi0ARGwGJ12gpZtxjixEhmpnnY2ErN1sfgzWbvb1Ios7qvB0JT3ft39JOuJPH+1V9r8h6Z+JGuclXDa5UNNWWIMoSVzCbVHBr63LSrx4KDVKe55PMv/0KcvYqIPza4WK/tj15t6uDvgXx/9H9qbl7Qr8ymfAtCX1BwUpxQEHnf/lVQs+SLant+h/978sLuTZolICAwTNqv9c0jbR6jVZzqTP07wadSXjCCwaeod4PjlIJza8MPR3BD5J/NRA3OP2GmLZu2ltlDruwTSa9pT76hWAV/FenE6gsLEHST72rJOPDYf9DVUBQKN9ZWEV1tKJt98ZuXTdsjG5bY37M2YMh1ULFtcQUyKPo814rRH2BnrTnwN9USBUyWO0XAnaY+gNwOqQ068wnAoA5VPpbYDx8QUaHQ2l3zJUiOf0IzDHgryTFih2+0/LeZbBmxt4mcQ9TuG9W9eOcYpCbpv2nhwwukXV9UCCsVE8xAu5T44KalKsfxkE/XYCK5JXJrWoaVi4fIRa7eFiMMmrKOaFvwxpvGhK1j8imBaEArPBzV5AdTzNKCOfclrSEWg1yqyrQPvBhaqvSNT970wLlNS2voEY8a3Zo9FaCiS6zL0s4VezCE72YuSCBijBVpqCVURcbSLM/rf3ATyVZGuAhD+UUW31UedMDDRPkeoXpLdTXGvXQmz3WthpnXydJj7vUkkK5o5AhjrM3DCYpOju96RrFQT8S9WUhaR8G0yfO3pYM1KFWq+sjYhHsVgcY0rYEbgOLThwsxUo2oYzqUIveRrOCdbVg1X4RUx7pvSL5CF3A44axq4o29Svgvt03XGTsrl5vzE4aVb9EoKbUoL5ZP629s2XtCbg5wfLFILhw5Lel4boIwVTgz4ZB8o8OAdsJ+T3u8gdH8aijI/4HNYNmcS5f0KJyFaa5qB0f7Mo1cTKIyvF+G0kqKaSOenX13bFUjDN1ch0FCOrUiksm38/CHSKjVW6n2mAH2ATbnDuVc958ugjtwMH9POoB9IJWukBBgM4rFqbCj5hbZSDC78iJlcU5F3NXz/neTV7II8TpPGToKbNBW4av/3MQbKiwV3BsFAyOhHtCGow4F2MDlhfuuP5BA8aRfZ513oAojp7uHdRI0szhYugPphMKW9PSiO1pByfwtaqpZN5gwQr+tzR/5m4aELYn+ZxokxlPQSBwtB5qid9VCt/Ljeva49OkvI5T3iKSVdZKt7enO0Hpr38BtlWHL8s+d/culIpXtLEQiABMB613gGSiGWfHJiGrzsX6d624BVVnrHwwhW9bBfNUfD4RfqqWUIaO7PQElMDSQgDf3Jal9lEPCXjqffUicIK7fIPtHWUm6tnHmpUW0mUPU9XY2RckJ2UWiBSuoyGVejTBNRQht6mg3Mo1KF1+WvrNSiSLbhiiDsiYSpLdPIGJWIv95XXYd4qr/Bcrvn47Jt4q4RMuVAES3utZaeL+Cqn8tZjw7/AUQxfw7Wi5/y2pe9OyTJGlukJqc5Dd5B8iNUVmn3ccFBTdgb2mErOoCV9m06adc+hh/2l7qYFUqoBqiKZfL4i6j54V1NWKPVfuK8sE6WDL3XC2rVvFRPbpGHLDIBygRc4hRFtnSzslEIQanvOYKXs537+PVklbRt7zMlElsBmonKf7fSpLdwanQA4+dNInlMjZ+yRy/4IVW9fVwIJoGcjkzLgOd5kDVuvGAP+TP2yskeMrESHXHTLk16bsTFaoNZUi1a6FimTb55bPnlIBwNVVTBdBtOef645zEk+nlhhSbGI6buGr4WuVzdYFrM3XeVD2W7xjWv9Emlr9Fpvc6ec0Bl55qd5JeVxFOpy7q0x7zsJqzd3cj2UiXGyjMGC+21VicnmTywG/Dz/IKGHW3OXmo3SJedrYluWh06j3etgGaxkcJb1ziTdfqwwHCQGum7GS+w==",
2355+ "created_at": 1788997749,
2356+ "tags": [
2357+ [
2358+ "p",
2359+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"
2360+ ]
2361+ ],
2362+ "id": "e0496a2c8475ffe9bc1dcc012109f341e530bf63971060634e6ffa70026926b8",
2363+ "sig": "546bf70df810a977d7fd7139b7084448a4b96f6e9337bd24271153f2cd6981e0a35c14516bc4db7c80af2694daab3606f204621c80b750b4eb510eaec872c38f"
2364+ },
2365+ "rumor": {
2366+ "kind": 15,
2367+ "content": "https://files.example/attachment",
2368+ "tags": [
2369+ [
2370+ "p",
2371+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"
2372+ ],
2373+ [
2374+ "file-type",
2375+ "text/plain"
2376+ ],
2377+ [
2378+ "encryption-algorithm",
2379+ "aes-gcm"
2380+ ],
2381+ [
2382+ "decryption-key",
2383+ "0303030303030303030303030303030303030303030303030303030303030303"
2384+ ],
2385+ [
2386+ "decryption-nonce",
2387+ "040404040404040404040404"
2388+ ],
2389+ [
2390+ "x",
2391+ "0505050505050505050505050505050505050505050505050505050505050505"
2392+ ]
2393+ ],
2394+ "created_at": 1700000000,
2395+ "pubkey": "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766",
2396+ "id": "132437503620d7d49c34fdc84579500d26721fc874e302d3d10f0a84399623f0"
2397+ }
2398+ },
2399+ {
2400+ "gift": {
2401+ "kind": 1059,
2402+ "pubkey": "c8f2f4a6faddf1168ac0daeff1ef83fdda6f31cf5812308f6e9c603542d7afd7",
2403+ "content": "Aptz2VUuxV/pgBilSWnV42wolEWPYpTkmcXXkRkccKUjy9jUIwEvr+RsZD9Pi3RvDzTOnk2t0yV5jCH2e3TcalblLjvTIGOkDLvBVYc1H0z+a7Yv/+Is7GmhzPznJbBzIyG5SAS1PcIyul1t5srwNsdTAn9LmE8KEJbtncn3T5siz0YaoodYMNukGCkWoSKM7Iw1p2P4TlgSCgCByLDobKgPdN/f3R7Uq+oodKWhVj+ThUi5vZ6mjy3LpoMNwyBn5avqzUD73QgFlXWCQuZ7FLGNSOioBJDcVGadobODP3D/vojDtz1CMEQpYhUn5XyjtO6jVWVsSref2shGUDxI/eSJ5zQsqCRev+cKm/fETHZIyoifj3FOicgRlf8kR+WeZxT0g7zR3hX2AMLUbsShkptUqI0mGG+p8KmKmDBgmCTMXnpyHyMUR6MfuzkBFBftjfZk6iiWTTjGFnyzCu6CUQQa/X9GxRnbFbPFqJJoLp+fMG5umQjnE1blMJwGRbuMIkgWD61VQ7dgxppwwiRjhylz+uqVV4zOH810jcoUcdpR7ZOJZ09fmp/mcaFldXEFldhUeqzKMbcx9YYzshA/6ahxXppFzMv0b4E/b8tkc4jIjf0KIJVoZwKzRERqzfix61ZFT1qdlBxMCa7rLoq2Q/MpX+8Ak2mmNZKM9AbrUa2QZDJJU2zlokxTRn83io3PM5093gw/QmrOiMRMRr7i66TNySGsLsjKcs+ucc4xOzigAVSgRytAPORcSziauAObnUzJ6waYqNDc82Cd8cTUPuTfw/799MF0Yu3eaYmXbClZlxu20nEeK5JLk9szimzCRxCPc6Yj1wTIW5Xa4OqCDs/8kZnL8QdDy0KSaTMHpfg1aMgKwW2nPBqFOmI44Cq2aClrftoJm2W4c8qnUx8ZBkM85G2Fe0WK5FEnSZuaZY65P+xiec+5/bUp4fDq5KPEjwVM0uGWExIZlPCS5Yyn+1m2Yhzi8rB/YoHHWYBzx+NeYuIqFHx0r/+ZZaIJAr9n1b38GPAMa4Ip6l3RjivMm5AXGwlHz7zaQvdLJMFoESpWmoQO0Dh1ASKAV/3l5uProlA7jlWktuAujCx7Sf1LrDeeiyxKhCtwIqkBM/1iLxymg/UPoKx9kdGxB10RzbvqoYI5+jJrW/TptWGuYwMrhLFQaGatMnkOMwrghyLCqwXqb5zBst7crDHJv21CpgMswtZES566NmkMkm2BQEDwlOfCyb/DqN3GaNFi2E8ZDzU9Kg95WwBUNHfCkz4+DKM6j+Ai",
2404+ "created_at": 1789075782,
2405+ "tags": [
2406+ [
2407+ "p",
2408+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"
2409+ ]
2410+ ],
2411+ "id": "c7deb01de2998c9661701b8f2975e57432cd5d7bd2ddefb18364adc1a0242fe1",
2412+ "sig": "594e31b9343cfb0c365e5a39619f278db077f4d7c9a428788b8297531747d89837dfc67b729549bcb8ca01a916bc61a2e01a89d5a9799d608c4135c8b415e537"
2413+ },
2414+ "rumor": {
2415+ "kind": 5,
2416+ "content": "",
2417+ "tags": [
2418+ [
2419+ "e",
2420+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
2421+ ]
2422+ ],
2423+ "created_at": 1700000000,
2424+ "pubkey": "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766",
2425+ "id": "baff9bd04ceeb054107b4b96e609a7432d1fc53153ca113c14cc71b99768639c"
2426+ }
2427+ }
2428+ ]
2429+}
2430diff --git a/scripts/rooms.test.mjs b/scripts/rooms.test.mjs
2431index bf6b358..00fad86 100644
2432--- a/scripts/rooms.test.mjs
2433+++ b/scripts/rooms.test.mjs
2434@@ -156,6 +156,16 @@ test("room-compose replies in a thread with the root and its author, and refuses
2435 await assert.rejects(new refused.RoomCompose().submit(refused.form({content: "x"})), /members only/);
2436 });
2437
2438+test("room-compose emits the standard q tag for a quoted event", () => {
2439+ const id = "4".repeat(64), author = "5".repeat(64);
2440+ const s = setup({attributes: {room: "build", quote: id, "quote-relay": "wss://relay.example", "quote-pubkey": author}});
2441+ same(new s.RoomCompose().event("see this").tags, [["h", "build"], ["p", author], ["q", id, "wss://relay.example", author]]);
2442+ const withoutRelay = setup({attributes: {room: "build", quote: id, "quote-pubkey": author, pubkey: "6".repeat(64)}});
2443+ same(new withoutRelay.RoomCompose().event("see this").tags, [["h", "build"], ["p", author], ["q", id, "", author]]);
2444+ const invalid = setup({attributes: {room: "build", quote: "not-an-event"}});
2445+ assert.throws(() => new invalid.RoomCompose().event("x"), /quoted event/);
2446+});
2447+
2448 const selectedFile = (name = "chart.png", type = "image/png", text = "image bytes") => ({name, type, size: Buffer.byteLength(text), arrayBuffer: async () => Uint8Array.from(Buffer.from(text)).buffer});
2449 const descriptorFor = (file, bytes) => {
2450 const sha256 = createHash("sha256").update(bytes).digest("hex");

About

ownerc01503e8035f
visibilitypublic
headb5738be3195e

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs