| 1 | diff --git a/README.md b/README.md |
| 2 | index 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 | |
| 32 | diff --git a/docs/app.md b/docs/app.md |
| 33 | index 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 |
| 55 | diff --git a/docs/files-and-private-repositories.md b/docs/files-and-private-repositories.md |
| 56 | index 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 | |
| 67 | diff --git a/docs/rooms.md b/docs/rooms.md |
| 68 | index 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 | |
| 116 | diff --git a/docs/webmcp.md b/docs/webmcp.md |
| 117 | index 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 | |
| 129 | diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go |
| 130 | index 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 { |
| 152 | diff --git a/internal/daemon/direct_messages.go b/internal/daemon/direct_messages.go |
| 153 | new file mode 100644 |
| 154 | index 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 | +} |
| 234 | diff --git a/internal/daemon/direct_messages_test.go b/internal/daemon/direct_messages_test.go |
| 235 | new file mode 100644 |
| 236 | index 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 | +} |
| 355 | diff --git a/internal/daemon/webpush.go b/internal/daemon/webpush.go |
| 356 | index 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 | } |
| 368 | diff --git a/internal/webui/approvals_test.go b/internal/webui/approvals_test.go |
| 369 | index 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 | |
| 381 | diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js |
| 382 | index 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) { |
| 393 | diff --git a/internal/webui/chat-files.js b/internal/webui/chat-files.js |
| 394 | new file mode 100644 |
| 395 | index 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 | +})(); |
| 608 | diff --git a/internal/webui/chat.html b/internal/webui/chat.html |
| 609 | new file mode 100644 |
| 610 | index 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}} |
| 638 | diff --git a/internal/webui/chat.js b/internal/webui/chat.js |
| 639 | new file mode 100644 |
| 640 | index 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 | +})(); |
| 974 | diff --git a/internal/webui/chat_pages.go b/internal/webui/chat_pages.go |
| 975 | new file mode 100644 |
| 976 | index 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 | +} |
| 1053 | diff --git a/internal/webui/chat_pages_test.go b/internal/webui/chat_pages_test.go |
| 1054 | new file mode 100644 |
| 1055 | index 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 | +} |
| 1190 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 1191 | index 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 = () => { |
| 1215 | diff --git a/internal/webui/direct-messages.js b/internal/webui/direct-messages.js |
| 1216 | new file mode 100644 |
| 1217 | index 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 | +})(); |
| 1352 | diff --git a/internal/webui/file-messages.js b/internal/webui/file-messages.js |
| 1353 | index 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 |
| 1373 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 1374 | index 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">← all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms")}}<a href="/">← 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> |
| 1453 | diff --git a/internal/webui/public.html b/internal/webui/public.html |
| 1454 | index 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> |
| 1479 | diff --git a/internal/webui/read_contracts_test.go b/internal/webui/read_contracts_test.go |
| 1480 | index 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 { |
| 1492 | diff --git a/internal/webui/rooms.go b/internal/webui/rooms.go |
| 1493 | index 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 |
| 1529 | diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html |
| 1530 | index 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}} |
| 1565 | diff --git a/internal/webui/rooms.js b/internal/webui/rooms.js |
| 1566 | index 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 => { |
| 1610 | diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go |
| 1611 | index 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="/">← 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="/">← 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 | `<script>alert(1)</script>`, |
| 1663 | `<span>to <nostr-name pubkey="` + roomAgent + `"`, |
| 1664 | `<span data-reaction="+1">+1 1</span>`, |
| 1665 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 1666 | index 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)} |
| 1744 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 1745 | index 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)) |
| 1827 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 1828 | index 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) { |
| 1840 | diff --git a/scripts/chat-files.test.mjs b/scripts/chat-files.test.mjs |
| 1841 | new file mode 100644 |
| 1842 | index 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 | +}); |
| 1920 | diff --git a/scripts/chat.test.mjs b/scripts/chat.test.mjs |
| 1921 | new file mode 100644 |
| 1922 | index 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 | +}); |
| 2015 | diff --git a/scripts/direct-messages.test.mjs b/scripts/direct-messages.test.mjs |
| 2016 | new file mode 100644 |
| 2017 | index 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 | +}); |
| 2186 | diff --git a/scripts/fixtures/chat-client-parity.json b/scripts/fixtures/chat-client-parity.json |
| 2187 | new file mode 100644 |
| 2188 | index 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 | +} |
| 2233 | diff --git a/scripts/fixtures/flotilla-gift-wraps.json b/scripts/fixtures/flotilla-gift-wraps.json |
| 2234 | new file mode 100644 |
| 2235 | index 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 | +} |
| 2430 | diff --git a/scripts/rooms.test.mjs b/scripts/rooms.test.mjs |
| 2431 | index 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"); |