Group Social and Chat together, keep shared work together, and move approvals beside management while removing inbox and outbox from the main rail. Add recipient-scoped encrypted message reads, NIP-17 conversations, replies, reactions and retractions, retry-safe delivery, and safe encrypted attachment previews. Preserve existing room links and render NIP-29 quote references. Validate with Go tests and vet, 232 JavaScript tests, recipient-isolation race tests, mobile browser checks, and Flotilla UI and independent gift-wrap replay checks.
- commit
- b5738be3195ee9eff8da3bf2ed658d81079a6baa
- parent
- 1944aa061621d532bb0d447d2c2f65da3d3e2370
- author
- Dami <dami.etoile@gmail.com>
@@ -10,7 +10,7 @@ flowchart LR
1010 api --> tenant1111 subgraph tenant [one tenant]1212 policy[owner and policy] --> store[(durable store)]13- store --> rooms[rooms]13+ store --> rooms[chat rooms]1414 store --> wiki[wiki]1515 store --> repos[repositories]1616 store --> files[files and sites]@@ -59,7 +59,7 @@ Run `tiny templates` to see available templates. Templates that read from an ups
5959 6060 ## Configure and operate6161 62-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.62+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**.6363 6464 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.6565 @@ -69,7 +69,7 @@ See [Git collaboration](docs/git-collaboration.md) for issues, pull requests, co
6969 7070 See [Membership](docs/membership.md) for invites, joining with NIP-43 and reviewing access requests.7171 72-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.72+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.7373 7474 See [Social](docs/social.md) for notes, long-form articles, NIP-10 and NIP-22 conversations, reactions, Markdown, media and mixed feeds.7575 @@ -16,9 +16,9 @@ The app registers for `web+nostr:` links. A link to an `npub`, `nprofile`, `note
1616 1717 ## Notifications1818 19-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.19+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.2020 21-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.21+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.2222 2323 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.2424 @@ -37,4 +37,4 @@ Rebuild the bundle after changing `scripts/nostr-name-setup.mjs` or updating the
3737 ## Copying a page address3838 3939 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.40-\n\ No newline at end of file40+\n@@ -1,6 +1,6 @@
11 # Files and private repositories22 3-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.3+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.44 55 ## Uploading66 @@ -1,6 +1,6 @@
1-# Rooms1+# Chat22 3-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.3+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.44 55 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.66 @@ -49,7 +49,7 @@ The relay accepts these kinds inside a room:
4949 | 9005 | Delete messages: `e` tags naming messages in the room |5050 | 9008 | Delete the room |5151 52-Private messages between members travel as [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) gift wraps outside any room.52+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>`.5353 5454 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.5555 @@ -57,7 +57,11 @@ Room events reach device notifications and relay push callbacks the same way as
5757 5858 ## In the web UI5959 60-**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.60+**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.61+62+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.63+64+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.6165 6266 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.6367 @@ -69,11 +73,11 @@ Room uploads follow the room's current access rule. A members-only room's files
6973 7074 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).7175 72-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.76+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.7377 7478 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.7579 76-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.80+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.7781 7882 ## Browser tools7983 @@ -2,7 +2,7 @@
22 33 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.44 5-## Rooms5+## Chat rooms66 77 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.88 @@ -34,7 +34,7 @@ type clientBrowseRequest struct {
3434 }3535 3636 func clientBrowseMethod(method string) bool {37- return wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || socialBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent", "browseprofile"}, method)37+ 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)3838 }3939 4040 func (t *Tenant) browseRead(ctx context.Context, actor string) error {@@ -70,6 +70,9 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params
7070 if socialBrowseMethod(method) {7171 return t.executeSocial(ctx, actor, method, params)7272 }73+ if directMessagesBrowseMethod(method) {74+ return t.executeDirectMessages(ctx, actor, params)75+ }7376 q := clientBrowseRequest{}7477 if len(params) > 0 {7578 if err := json.Unmarshal(params[0], &q); err != nil {@@ -0,0 +1,76 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "time"9+10+ "github.com/FelineStateMachine/tinyrelay/internal/event"11+ "github.com/FelineStateMachine/tinyrelay/internal/relay"12+ "github.com/FelineStateMachine/tinyrelay/internal/storage"13+)14+15+type directMessagesRequest struct {16+ Cursor string `json:"cursor"`17+ Limit int `json:"limit"`18+}19+20+func directMessagesBrowseMethod(method string) bool { return method == "browsedirectmessages" }21+22+func (t *Tenant) executeDirectMessages(ctx context.Context, actor string, params []json.RawMessage) (any, error) {23+ if actor == "" || !hex64(actor) {24+ return nil, errors.New("auth-required: signed actor")25+ }26+ request := directMessagesRequest{Limit: 50}27+ if len(params) > 0 {28+ if err := json.Unmarshal(params[0], &request); err != nil {29+ return nil, fmt.Errorf("invalid: direct message parameters: %w", err)30+ }31+ }32+ if request.Limit <= 0 || request.Limit > 100 {33+ request.Limit = 5034+ }35+ cursor, err := parseCollaborationCursor(request.Cursor)36+ if err != nil {37+ return nil, errors.New("invalid: direct message cursor")38+ }39+ return t.browseDirectMessages(ctx, actor, cursor, request.Limit)40+}41+42+func (t *Tenant) browseDirectMessages(ctx context.Context, actor string, cursor *storage.EventCursor, limit int) (any, error) {43+ filter := event.Filter{Kinds: []int{event.KIND_WRAP}, Tags: map[string][]string{"p": {actor}}}44+ session := relay.Session{PubKeys: []string{actor}, RelayURL: t.RelayURL()}45+ if _, err := t.gate.Read(ctx, []event.Filter{filter}, session); err != nil {46+ return nil, err47+ }48+ rows := make([]event.Event, 0, limit)49+ next := ""50+ for scans := 0; scans < 100; scans++ {51+ page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: []string{actor}}, Limit: 100, Before: cursor})52+ if err != nil {53+ return nil, err54+ }55+ for index, row := range page.Events {56+ cursor = &storage.EventCursor{CreatedAt: row.CreatedAt, ID: row.ID}57+ if !t.gate.CanSee(ctx, row, session, &filter) {58+ continue59+ }60+ rows = append(rows, row)61+ if len(rows) == limit {62+ if page.More || index+1 < len(page.Events) {63+ next = collaborationCursor(collaborationItem{CreatedAt: row.CreatedAt, ID: row.ID})64+ }65+ return map[string]any{"events": rows, "next_cursor": next}, nil66+ }67+ }68+ if !page.More || len(page.Events) == 0 {69+ return map[string]any{"events": rows, "next_cursor": ""}, nil70+ }71+ }72+ if cursor != nil {73+ next = collaborationCursor(collaborationItem{CreatedAt: cursor.CreatedAt, ID: cursor.ID})74+ }75+ return map[string]any{"events": rows, "next_cursor": next}, nil76+}@@ -0,0 +1,115 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/event"10+ "github.com/FelineStateMachine/tinyrelay/internal/storage"11+)12+13+func TestBrowseDirectMessagesRequiresRecipientAndPaginates(t *testing.T) {14+ _, tenant := testTenant(t)15+ actorSecret := strings.Repeat("a", 63) + "1"16+ otherSecret := strings.Repeat("b", 63) + "2"17+ actor := signedEvent(t, actorSecret, event.KIND_WRAP, 100, [][]string{{"p", strings.Repeat("c", 64)}}, "ciphertext").Tags[0][1]18+ recipient := actor19+ first := signedEvent(t, otherSecret, event.KIND_WRAP, 200, [][]string{{"p", recipient}}, "first")20+ second := signedEvent(t, otherSecret, event.KIND_WRAP, 200, [][]string{{"p", recipient}}, "second")21+ third := signedEvent(t, otherSecret, event.KIND_WRAP, 199, [][]string{{"p", recipient}}, "third")22+ for _, row := range []event.Event{first, second, third} {23+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {24+ t.Fatal(err)25+ }26+ }27+ value, err := tenant.Execute(context.Background(), recipient, "browsedirectmessages", []json.RawMessage{rawJSON(map[string]any{"limit": 2})})28+ if err != nil {29+ t.Fatal(err)30+ }31+ page := value.(map[string]any)32+ events := page["events"].([]event.Event)33+ if len(events) != 2 || events[0].ID != first.ID || events[1].ID != second.ID {34+ t.Fatalf("first page = %#v", events)35+ }36+ next := page["next_cursor"].(string)37+ value, err = tenant.Execute(context.Background(), recipient, "browsedirectmessages", []json.RawMessage{rawJSON(map[string]any{"limit": 2, "cursor": next})})38+ if err != nil {39+ t.Fatal(err)40+ }41+ page = value.(map[string]any)42+ events = page["events"].([]event.Event)43+ if len(events) != 1 || events[0].ID != third.ID || page["next_cursor"].(string) != "" {44+ t.Fatalf("second page = %#v", page)45+ }46+ if events[0].Content != "third" {47+ t.Fatalf("message content changed: %q", events[0].Content)48+ }49+}50+51+func TestBrowseDirectMessagesDoesNotUseOwnerBypass(t *testing.T) {52+ _, tenant := testTenant(t)53+ owner := tenant.Policy().Owner54+ other := strings.Repeat("d", 64)55+ row := signedEvent(t, strings.Repeat("e", 63)+"f", event.KIND_WRAP, 100, [][]string{{"p", other}}, "secret")56+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {57+ t.Fatal(err)58+ }59+ if _, err := tenant.Execute(context.Background(), owner, "browsedirectmessages", []json.RawMessage{rawJSON(map[string]any{"limit": 10})}); err != nil {60+ t.Fatal(err)61+ }62+ page := mustDirectMessagePage(t, tenant, owner)63+ if len(page["events"].([]event.Event)) != 0 {64+ t.Fatal("owner saw a message addressed to another user")65+ }66+}67+68+func TestBrowseDirectMessagesRequiresValidActor(t *testing.T) {69+ _, tenant := testTenant(t)70+ for _, actor := range []string{"", "not-a-pubkey"} {71+ if _, err := tenant.Execute(context.Background(), actor, "browsedirectmessages", nil); err == nil || !strings.HasPrefix(err.Error(), "auth-required:") {72+ t.Fatalf("actor %q error = %v", actor, err)73+ }74+ }75+}76+77+func TestBrowseDirectMessagesFiltersRecipientAndKind(t *testing.T) {78+ _, tenant := testTenant(t)79+ actorSecret := strings.Repeat("1", 63) + "2"80+ actor := signedEvent(t, actorSecret, event.KIND_WRAP, 99, nil, "").PubKey81+ actorMessage := signedEvent(t, actorSecret, event.KIND_WRAP, 100, [][]string{{"p", actor}}, "recipient")82+ otherRecipient := signedEvent(t, strings.Repeat("3", 63)+"4", event.KIND_WRAP, 101, [][]string{{"p", strings.Repeat("f", 64)}}, "other")83+ wrongKind := signedEvent(t, strings.Repeat("5", 63)+"6", event.KIND_DM, 102, [][]string{{"p", actor}}, "legacy")84+ for _, row := range []event.Event{actorMessage, otherRecipient, wrongKind} {85+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {86+ t.Fatal(err)87+ }88+ }89+ value, err := tenant.Execute(context.Background(), actor, "browsedirectmessages", nil)90+ if err != nil {91+ t.Fatal(err)92+ }93+ events := value.(map[string]any)["events"].([]event.Event)94+ if len(events) != 1 || events[0].ID != actorMessage.ID {95+ t.Fatalf("filtered events = %#v", events)96+ }97+}98+99+func TestBrowseDirectMessagesRejectsInvalidCursor(t *testing.T) {100+ _, tenant := testTenant(t)101+ actor := strings.Repeat("a", 64)102+ _, err := tenant.Execute(context.Background(), actor, "browsedirectmessages", []json.RawMessage{rawJSON(map[string]any{"cursor": "broken"})})103+ if err == nil || !strings.HasPrefix(err.Error(), "invalid: direct message cursor") {104+ t.Fatalf("cursor error = %v", err)105+ }106+}107+108+func mustDirectMessagePage(t *testing.T, tenant *Tenant, actor string) map[string]any {109+ t.Helper()110+ value, err := tenant.Execute(context.Background(), actor, "browsedirectmessages", nil)111+ if err != nil {112+ t.Fatal(err)113+ }114+ return value.(map[string]any)115+}@@ -382,7 +382,7 @@ func (t *Tenant) pushMessage(ctx context.Context, payload pushPayload) pushMessa
382382 }383383 url := payload.URL384384 if url == "" {385- url = strings.TrimRight(t.publicURL, "/") + "/inbox"385+ url = strings.TrimRight(t.publicURL, "/") + "/chat"386386 }387387 return pushMessage{Title: title, Body: body, URL: url, Tag: "tiny-" + payload.Kind, Badge: t.inboxUnread(ctx, payload.Recipient), Actions: payload.Actions}388388 }@@ -87,7 +87,7 @@ func TestApprovalsPageRendersRequestsForTheOwner(t *testing.T) {
8787 t.Fatal("page carries class attributes")8888 }8989 rail := body[strings.Index(body, `id="railbox"`):strings.Index(body, `id="rail-tools"`)]90- if strings.Index(rail, "/inbox") > strings.Index(rail, "/approvals") || strings.Index(rail, "/approvals") > strings.Index(rail, "/repos") {90+ if strings.Contains(rail, "/inbox") || strings.Contains(rail, "/outbox") || strings.Index(rail, "/approvals") < strings.Index(rail, "/repos") {9191 t.Fatalf("rail order:\n%s", rail)9292 }9393 @@ -242,6 +242,7 @@
242242 const response = await fetch(localPath("/session/logout"), {method: "POST", credentials: "same-origin"});243243 if (!response.ok) throw Error(await response.text());244244 forgetRemote();245+ if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:logout"));245246 say("Signed out.");246247 location.reload();247248 } catch (err) {@@ -0,0 +1,209 @@
1+/* NIP-17 kind 15 attachments. File keys stay in the decrypted rumor and are2+ never copied into markup, URLs, local storage, or the clipboard. */3+(() => {4+ "use strict";5+6+ const MAX_BYTES = 256 * 1024 * 1024;7+ const textDecoder = new TextDecoder();8+ const sizeLabel = size => {9+ if (size === null) return "";10+ if (size < 1024) return size + " B";11+ if (size < 1024 * 1024) return (size / 1024).toFixed(1) + " KiB";12+ if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + " MiB";13+ return (size / (1024 * 1024 * 1024)).toFixed(1) + " GiB";14+ };15+ const bytes = value => globalThis.tiny.util.bytes(value);16+ const hex = value => globalThis.tiny.util.hex(value);17+ const fromHex = (value, message) => {18+ if (typeof value !== "string" || !/^[0-9a-f]+$/i.test(value) || value.length % 2)19+ throw Error(message);20+ return Uint8Array.from(value.match(/../g), pair => parseInt(pair, 16));21+ };22+ const tagValue = (tags, name) => {23+ const tag = (Array.isArray(tags) ? tags : []).find(item => item?.[0] === name);24+ return typeof tag?.[1] === "string" ? tag[1] : "";25+ };26+27+ function parse(rumor) {28+ if (!rumor || rumor.kind !== 15 || typeof rumor.content !== "string")29+ throw Error("expected a NIP-17 file message");30+ let url;31+ try { url = new URL(rumor.content); } catch { throw Error("attachment URL is invalid"); }32+ if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password)33+ throw Error("attachment URL must be HTTP(S) without credentials");34+ const algorithm = tagValue(rumor.tags, "encryption-algorithm").toLowerCase();35+ if (algorithm !== "aes-gcm") throw Error("unsupported attachment encryption");36+ const keyText = tagValue(rumor.tags, "decryption-key");37+ const nonceText = tagValue(rumor.tags, "decryption-nonce");38+ const hash = tagValue(rumor.tags, "x").toLowerCase();39+ const key = fromHex(keyText, "attachment key is invalid");40+ const nonce = fromHex(nonceText, "attachment nonce is invalid");41+ if (key.length !== 32) throw Error("attachment key is invalid");42+ if (nonce.length < 8 || nonce.length > 32) throw Error("attachment nonce is invalid");43+ if (!/^[0-9a-f]{64}$/.test(hash)) throw Error("attachment hash is invalid");44+ const plaintextHash = tagValue(rumor.tags, "ox").toLowerCase();45+ if (plaintextHash && !/^[0-9a-f]{64}$/.test(plaintextHash)) throw Error("plaintext hash is invalid");46+ const sizeText = tagValue(rumor.tags, "size");47+ const size = sizeText ? Number(sizeText) : null;48+ if (size !== null && (!Number.isSafeInteger(size) || size < 0 || size > MAX_BYTES))49+ throw Error("attachment is too large");50+ return Object.freeze({51+ url: url.href,52+ mime: tagValue(rumor.tags, "file-type") || "application/octet-stream",53+ name: tagValue(rumor.tags, "filename") || tagValue(rumor.tags, "name") || "attachment",54+ hash,55+ plaintextHash,56+ size,57+ key,58+ nonce59+ });60+ }61+62+ async function responseBytes(response, signal) {63+ const declared = Number(response.headers?.get?.("content-length"));64+ if (Number.isFinite(declared) && declared > MAX_BYTES) throw Error("attachment is too large");65+ if (response.body?.getReader) {66+ const reader = response.body.getReader();67+ const chunks = [];68+ let total = 0;69+ try {70+ for (;;) {71+ if (signal?.aborted) throw Error("attachment opening canceled");72+ const part = await reader.read();73+ if (part.done) break;74+ const chunk = bytes(part.value);75+ total += chunk.length;76+ if (total > MAX_BYTES) {77+ await reader.cancel();78+ throw Error("attachment is too large");79+ }80+ chunks.push(chunk);81+ }82+ } finally { reader.releaseLock?.(); }83+ const output = new Uint8Array(total);84+ let offset = 0;85+ chunks.forEach(chunk => { output.set(chunk, offset); offset += chunk.length; });86+ return output;87+ }88+ const output = bytes(await response.arrayBuffer());89+ if (output.length > MAX_BYTES) throw Error("attachment is too large");90+ return output;91+ }92+93+ async function fetchCiphertext(file, signal) {94+ const init = {method: "GET", credentials: "omit", referrerPolicy: "no-referrer", signal};95+ let response = await fetch(file.url, init);96+ if (!response.ok && new URL(file.url).origin === globalThis.location.origin && response.status === 401 && globalThis.tiny.signedFetch) {97+ const local = new URL(file.url).pathname + new URL(file.url).search;98+ response = await globalThis.tiny.signedFetch(local, "GET", undefined, {signal});99+ }100+ if (!response.ok) throw Error("attachment could not be fetched");101+ return responseBytes(response, signal);102+ }103+104+ async function decrypt(file, ciphertext) {105+ const encrypted = bytes(ciphertext);106+ if (hex(await globalThis.tiny.util.sha256(encrypted)) !== file.hash) throw Error("attachment hash mismatch");107+ let plain;108+ try {109+ const cryptoKey = await crypto.subtle.importKey("raw", file.key, {name: "AES-GCM"}, false, ["decrypt"]);110+ plain = new Uint8Array(await crypto.subtle.decrypt({name: "AES-GCM", iv: file.nonce}, cryptoKey, encrypted));111+ } catch { throw Error("attachment decryption failed"); }112+ if (file.plaintextHash && hex(await globalThis.tiny.util.sha256(plain)) !== file.plaintextHash)113+ throw Error("attachment plaintext hash mismatch");114+ return plain;115+ }116+117+ function downloadLink(owner, file, plain) {118+ const url = URL.createObjectURL(new Blob([plain], {type: file.mime}));119+ owner._attachmentURLs ||= new Set();120+ owner._attachmentURLs.add(url);121+ const link = document.createElement("a");122+ link.textContent = "Download";123+ link.download = file.name;124+ link.rel = "noopener";125+ link.href = url;126+ return link;127+ }128+129+ function preview(node, plain, file, owner = node) {130+ const mime = file.mime.toLowerCase().split(";", 1)[0];131+ if (mime === "text/plain" || mime === "text/markdown" || mime === "application/json") {132+ const output = document.createElement("pre");133+ output.textContent = textDecoder.decode(plain.slice(0, 1024 * 1024));134+ const footer = document.createElement("p");135+ footer.append(downloadLink(owner, file, plain));136+ if (plain.length > 1024 * 1024) footer.append(" (preview truncated)");137+ node.replaceChildren(output, footer);138+ return;139+ }140+ if (mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/")) {141+ const media = document.createElement(mime.startsWith("image/") ? "img" : mime.startsWith("video/") ? "video" : "audio");142+ media.controls = mime.startsWith("video/") || mime.startsWith("audio/");143+ if (media.localName === "img") media.alt = file.name;144+ if (media.localName === "video") media.preload = "metadata";145+ const url = URL.createObjectURL(new Blob([plain], {type: file.mime}));146+ media.src = url;147+ owner._attachmentURLs ||= new Set();148+ owner._attachmentURLs.add(url);149+ const footer = document.createElement("p");150+ footer.append(downloadLink(owner, file, plain));151+ node.replaceChildren(media, footer);152+ return;153+ }154+ const output = document.createElement("p");155+ output.append("Decrypted attachment is ready: ", downloadLink(owner, file, plain));156+ node.replaceChildren(output);157+ }158+159+ class DirectFile extends HTMLElement {160+ connectedCallback() {161+ this.revokeAttachments();162+ this.generation = (this.generation || 0) + 1;163+ const generation = this.generation;164+ let file;165+ try { file = parse(this.rumor); } catch (error) { this.textContent = error.message; return; }166+ this.file = file;167+ this.innerHTML = "";168+ const article = document.createElement("article");169+ const label = document.createElement("span");170+ label.textContent = file.name + (file.size === null ? "" : " (" + sizeLabel(file.size) + ")");171+ const button = document.createElement("button");172+ button.type = "button";173+ button.textContent = "Open attachment";174+ const status = document.createElement("output");175+ button.addEventListener("click", async () => {176+ button.disabled = true;177+ status.textContent = "Opening attachment...";178+ this.abort?.abort();179+ this.abort = new AbortController();180+ try {181+ const plain = await decrypt(file, await fetchCiphertext(file, this.abort.signal));182+ if (this.generation !== generation || !this.isConnected) return;183+ preview(article, plain, file, this);184+ } catch (error) { if (this.generation === generation) status.textContent = error.message; }185+ finally { if (this.generation === generation) button.disabled = false; }186+ });187+ article.append(label, button, status);188+ this.append(article);189+ }190+ revokeAttachments() {191+ for (const url of this._attachmentURLs || []) URL.revokeObjectURL(url);192+ this._attachmentURLs?.clear();193+ }194+ disconnectedCallback() {195+ this.generation = (this.generation || 0) + 1;196+ this.abort?.abort();197+ this.revokeAttachments();198+ }199+ }200+201+ globalThis.tiny = globalThis.tiny || {};202+ globalThis.tiny.chat = globalThis.tiny.chat || {};203+ globalThis.tiny.chat.files = Object.freeze({parse, decrypt, render: rumor => {204+ const node = document.createElement("direct-file");205+ node.rumor = rumor;206+ return node;207+ }, MAX_BYTES});208+ if (globalThis.customElements && !customElements.get("direct-file")) customElements.define("direct-file", DirectFile);209+})();@@ -0,0 +1,24 @@
1+{{define "chat"}}<section id="chat">2+<h1>Chat</h1>3+<p>Direct conversations and group rooms.</p>4+<h2 id="new-chat">Direct chats</h2>5+{{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>6+<direct-chats actor="{{.Actor}}" relay="{{wsURL .URL}}"><p>Connect your signer to open your encrypted conversations.</p></direct-chats>7+<noscript><p>Direct chats need JavaScript and a signer that supports NIP-44 encryption. Group rooms are available below.</p></noscript>8+{{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to open your direct chats.</p>{{end}}9+<h2>Rooms</h2>10+{{template "roomList" .}}11+</section>{{end}}12+13+{{define "direct"}}<section id="direct-chat">14+<header><h1>{{template "nostrName" .View}}</h1><p>Private conversation. Only you and this person can read these messages.</p></header>15+{{if .Actor}}<direct-thread actor="{{.Actor}}" peer="{{.View}}" relay="{{wsURL .URL}}"><p>Connect your signer to decrypt this conversation.</p></direct-thread>16+<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>17+<noscript><p>Reading and sending direct messages needs JavaScript and a signer that supports NIP-44 encryption.</p></noscript>18+{{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to open this private conversation.</p>{{end}}19+</section>{{end}}20+21+{{define "panel-chat"}}<section><h4>Direct messages</h4><p>Encrypted for you and the recipient. Your signer opens messages delivered to this relay.</p>22+{{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>23+{{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}}24+<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}}@@ -0,0 +1,330 @@
1+/* Direct conversations use verified NIP-17 rumors. Plaintext lives only in this page's memory. */2+(() => {3+ "use strict";4+ const tiny = globalThis.tiny;5+ const protocol = () => tiny.chat.protocol;6+ const hex = value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);7+ const node = (name, text) => {8+ const result = document.createElement(name);9+ if (text !== undefined) result.textContent = text;10+ return result;11+ };12+ const key = value => { try { return protocol().normalizePubkey(String(value || "").trim()); } catch { return ""; } };13+ const participants = rumor => [...new Set([rumor.pubkey, ...rumor.tags.filter(tag => tag[0] === "p" && hex(tag[1])).map(tag => tag[1])])];14+ const states = new Map();15+ const listeners = new Set();16+ let generation = 0;17+ const stateFor = actor => {18+ if (!states.has(actor)) states.set(actor, {actor, generation, events: new Map(), wraps: new Map(), cursor: "", loaded: false, pending: null, version: 0});19+ return states.get(actor);20+ };21+ const current = value => value.generation === generation && states.get(value.actor) === value;22+ const changed = value => { if (current(value)) { value.version++; for (const host of listeners) if (host.actor === value.actor) host.render(); } };23+ const clear = () => {24+ generation++;25+ for (const value of states.values()) { value.events.clear(); value.wraps.clear(); }26+ states.clear();27+ for (const host of listeners) { host.renderVersion = ""; host.replaceChildren(node("p", "Connect your signer to open direct chats.")); }28+ };29+ const activeSigner = async actor => {30+ const signer = tiny.signer?.();31+ if (!signer || key(await signer.getPublicKey()) !== actor) throw Error("Connect the signer for this account to open direct chats.");32+ return signer;33+ };34+ const checkSession = async (value, signer) => {35+ if (!current(value) || tiny.signer() !== signer || key(await signer.getPublicKey()) !== value.actor) throw Error("The signer changed. Reopen this conversation.");36+ if (!current(value)) throw Error("The session ended.");37+ };38+ const cacheEvents = async (value, rows, signer) => {39+ for (const packet of rows) {40+ if (!current(value)) return;41+ let pending = value.wraps.get(packet.id);42+ if (!pending) {43+ pending = protocol().decrypt(packet, {actor: value.actor, signer});44+ value.wraps.set(packet.id, pending);45+ }46+ try {47+ const {rumor} = await pending;48+ await checkSession(value, signer);49+ if (value.events.has(rumor.id)) continue;50+ const members = participants(rumor);51+ if ([14, 15].includes(rumor.kind) && (members.length !== 2 || !members.includes(value.actor))) continue;52+ if (![5, 7, 14, 15].includes(rumor.kind)) continue;53+ value.events.set(rumor.id, rumor);54+ } catch (error) {55+ if (!current(value)) return;56+ // A signer refusal can be retried. Structurally invalid events stay cached.57+ if (/cancel|reject|denied|support|connect|unable to decrypt/i.test(error.message)) {58+ value.wraps.delete(packet.id);59+ throw error;60+ }61+ }62+ }63+ };64+ const fetchPage = async (value, cursor, signer) => {65+ const response = await tiny.signedFetch("/chat/events" + (cursor ? "?cursor=" + encodeURIComponent(cursor) : ""), "GET", new Uint8Array());66+ const page = await response.json();67+ if (!Array.isArray(page?.events) || typeof page.next_cursor !== "string") throw Error("Could not read the chat history.");68+ await checkSession(value, signer);69+ await cacheEvents(value, page.events, signer);70+ return page;71+ };72+ const load = async (actor, older = false) => {73+ const value = stateFor(actor);74+ if (value.pending) return value.pending;75+ if (older && !value.cursor) return value;76+ value.pending = (async () => {77+ const signer = await activeSigner(actor);78+ await checkSession(value, signer);79+ const wasLoaded = value.loaded, count = value.events.size;80+ let page = await fetchPage(value, older ? value.cursor : "", signer);81+ if (!current(value)) return value;82+ if (older || !wasLoaded) value.cursor = page.next_cursor;83+ // Gift wraps deliberately use older dates. Refresh the entire two-day84+ // overlap so a new message cannot hide behind the first page of history.85+ if (wasLoaded && !older) {86+ const cutoff = Math.floor(Date.now() / 1000) - 172860;87+ const seen = new Set();88+ while (page.next_cursor && page.events.length && page.events.at(-1).created_at >= cutoff && !seen.has(page.next_cursor)) {89+ seen.add(page.next_cursor);90+ page = await fetchPage(value, page.next_cursor, signer);91+ if (!current(value)) return value;92+ }93+ }94+ value.loaded = true;95+ if (count !== value.events.size || !wasLoaded || older) changed(value);96+ return value;97+ })().finally(() => { value.pending = null; });98+ return value.pending;99+ };100+ const chronological = value => [...value.events.values()].sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id));101+ const peerFor = (rumor, actor) => participants(rumor).find(member => member !== actor);102+ const deleted = (value, rumor) => {103+ if (value.deletedVersion !== value.events.size) {104+ value.deletedIDs = new Set();105+ for (const deletion of value.events.values()) {106+ if (deletion.kind !== 5) continue;107+ for (const tag of deletion.tags) {108+ const target = tag[0] === "e" && value.events.get(tag[1]);109+ if (target && target.pubkey === deletion.pubkey) value.deletedIDs.add(target.id);110+ }111+ }112+ value.deletedVersion = value.events.size;113+ }114+ return value.deletedIDs.has(rumor.id);115+ };116+ const messages = (value, peer) => chronological(value).filter(rumor => [14, 15].includes(rumor.kind) && !deleted(value, rumor) && (!peer || peerFor(rumor, value.actor) === peer));117+ const profile = pubkey => {118+ const author = node("span"); author.setAttribute("data-author", "");119+ const avatar = node("nostr-avatar", pubkey.slice(0, 2)); avatar.setAttribute("pubkey", pubkey); avatar.setAttribute("aria-hidden", "true");120+ const name = node("nostr-name", pubkey.slice(0, 8)); name.setAttribute("pubkey", pubkey);121+ author.append(avatar, name); return author;122+ };123+ const time = timestamp => {124+ const date = new Date(timestamp * 1000);125+ const result = node("time", date.toLocaleString()); result.dateTime = date.toISOString(); return result;126+ };127+ const textBody = content => {128+ const body = node("p");129+ if (tiny.rooms?.linkify) tiny.rooms.linkify(body, content); else body.textContent = content;130+ return body;131+ };132+ const targetID = rumor => rumor.tags.filter(tag => ["e", "q"].includes(tag[0]) && hex(tag[1])).at(-1)?.[1];133+ const reactions = (value, target) => {134+ if (value.reactionVersion !== value.events.size) {135+ const grouped = new Map();136+ for (const rumor of value.events.values()) {137+ if (rumor.kind !== 7 || deleted(value, rumor)) continue;138+ const target = value.events.get(targetID(rumor));139+ if (!target || ![14, 15].includes(target.kind)) continue;140+ const allowed = new Set(participants(target));141+ if (!allowed.has(rumor.pubkey) || participants(rumor).some(pubkey => !allowed.has(pubkey))) continue;142+ const label = !rumor.content || rumor.content === "+" ? "♥" : rumor.content;143+ if (!grouped.has(target.id)) grouped.set(target.id, new Map());144+ grouped.get(target.id).set(rumor.pubkey + ":" + label, label);145+ }146+ value.reactionIndex = new Map();147+ for (const [id, unique] of grouped) {148+ const counts = new Map();149+ for (const label of unique.values()) counts.set(label, (counts.get(label) || 0) + 1);150+ value.reactionIndex.set(id, counts);151+ }152+ value.reactionVersion = value.events.size;153+ }154+ return value.reactionIndex.get(target.id) || new Map();155+ };156+ const accepted = values => Array.isArray(values) ? values.some(value => value === true) : values === true;157+ const deliver = async (value, input, previous, report) => {158+ const signer = await activeSigner(value.actor);159+ await checkSession(value, signer);160+ const pending = previous || {built: await protocol().build(input, {signer}), delivered: [false, false]};161+ await checkSession(value, signer);162+ if (pending.built.rumor.pubkey !== value.actor) throw Error("The signer changed.");163+ if (!pending.relays) {164+ const lists = await Promise.all([protocol().queryRelayList(input.recipient), protocol().queryRelayList(value.actor)]);165+ pending.relays = lists.map((list, index) => protocol().relayList(list, index ? value.actor : input.recipient));166+ }167+ await checkSession(value, signer);168+ // Retain the exact gift wraps before network publication, including failure.169+ report(pending);170+ for (let index = 0; index < 2; index++) {171+ if (pending.delivered[index]) continue;172+ const result = await protocol().publishEvent(pending.built.events[index].event, pending.relays[index], {signer});173+ await checkSession(value, signer);174+ 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.");175+ pending.delivered[index] = true;176+ if (index === 0) { value.events.set(pending.built.rumor.id, pending.built.rumor); changed(value); }177+ }178+ return pending;179+ };180+ class ConversationView extends HTMLElement {181+ connectedCallback() {182+ this.actor = key(this.getAttribute("actor")); this.peer = key(this.getAttribute("peer"));183+ this.renderVersion = ""; listeners.add(this); this.refresh();184+ this.timer = setInterval(() => { if (document.visibilityState !== "hidden") this.refresh(); }, 10000);185+ }186+ disconnectedCallback() { listeners.delete(this); clearInterval(this.timer); }187+ async refresh(older = false) {188+ if (!this.actor || !tiny.signer?.()) return;189+ const epoch = generation;190+ try { await load(this.actor, older); if (epoch === generation && this.isConnected) this.render(); }191+ 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; } }192+ }193+ unchanged(value) {194+ const version = [value.version, value.cursor, this.peer].join(":");195+ if (this.renderVersion === version) return true;196+ this.renderVersion = version; return false;197+ }198+ older(value) {199+ if (!value.cursor) return;200+ const button = node("button", "Load older conversations"); button.type = "button";201+ button.addEventListener("click", async () => { button.disabled = true; try { await this.refresh(true); } finally { button.disabled = false; } });202+ this.append(button);203+ }204+ }205+ class DirectChats extends ConversationView {206+ render() {207+ const value = stateFor(this.actor); if (this.unchanged(value)) return;208+ const groups = new Map(); for (const rumor of messages(value)) groups.set(peerFor(rumor, this.actor), rumor);209+ this.replaceChildren();210+ for (const [peer, latest] of [...groups].sort((a, b) => b[1].created_at - a[1].created_at || a[0].localeCompare(b[0]))) {211+ const link = node("a"); link.href = tiny.localPath("/chat/dm/" + peer);212+ if (location.pathname.endsWith("/chat/dm/" + peer)) link.setAttribute("aria-current", "page");213+ link.append(profile(peer), node("p", latest.kind === 15 ? "Encrypted attachment" : latest.content.slice(0, 120)), time(latest.created_at)); this.append(link);214+ }215+ if (!groups.size) this.append(node("p", "No direct conversations yet."));216+ if (!this.hasAttribute("compact")) this.older(value);217+ }218+ }219+ class DirectThread extends ConversationView {220+ render() {221+ const value = stateFor(this.actor); if (this.unchanged(value)) return;222+ const near = !this.childElementCount || this.scrollHeight - this.scrollTop - this.clientHeight < 100;223+ const top = this.scrollTop, height = this.scrollHeight;224+ const rows = messages(value, this.peer), existing = new Map([...this.querySelectorAll(":scope > article")].map(article => [article.id, article]));225+ const result = [];226+ if (value.cursor) {227+ const button = node("button", "Load older messages"); button.type = "button"; button.addEventListener("click", () => this.refresh(true)); result.push(button);228+ }229+ for (const rumor of rows) {230+ let article = existing.get("chat-message-" + rumor.id);231+ if (!article) {232+ article = node("article"); article.id = "chat-message-" + rumor.id;233+ if (rumor.pubkey === this.actor) article.dataset.own = "";234+ const header = node("header"); header.append(profile(rumor.pubkey), time(rumor.created_at)); article.append(header);235+ const parent = targetID(rumor);236+ if (parent) { const quote = node("blockquote"); quote.dataset.quote = parent; article.append(quote); }237+ if (rumor.kind === 15 && tiny.chat.files) article.append(tiny.chat.files.render(rumor)); else article.append(textBody(rumor.content));238+ const footer = node("footer");239+ const reply = node("button", "Reply"); reply.type = "button";240+ reply.addEventListener("click", () => document.querySelector(`direct-compose[peer="${this.peer}"]`)?.replyTo(rumor)); footer.append(reply);241+ const react = node("button", "♥"); react.type = "button"; react.title = "Like this message";242+ react.addEventListener("click", async () => {243+ react.disabled = true;244+ 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; }245+ catch (error) { let status = footer.querySelector("output"); if (!status) { status = node("output"); footer.append(status); } status.textContent = error.message; }246+ finally { react.disabled = false; }247+ }); footer.append(react, node("span")); article.append(footer);248+ }249+ const quote = article.querySelector("blockquote[data-quote]");250+ if (quote) {251+ const parent = value.events.get(quote.dataset.quote);252+ quote.textContent = parent && [14, 15].includes(parent.kind) && peerFor(parent, this.actor) === this.peer253+ ? deleted(value, parent) ? "Message deleted" : parent.content.slice(0, 220)254+ : "Reply to an earlier message";255+ }256+ const summary = article.querySelector("footer > span"); summary.textContent = [...reactions(value, rumor)].map(([label, count]) => `${label} ${count}`).join(" | ");257+ result.push(article);258+ }259+ if (!rows.length) result.push(node("p", "Start the conversation with a private message."));260+ // Move existing message nodes instead of rebuilding media players.261+ let before = this.firstChild;262+ for (const child of result) { if (before === child) before = before.nextSibling; else this.insertBefore(child, before); }263+ while (before) { const next = before.nextSibling; before.remove(); before = next; }264+ if (near) this.scrollTop = this.scrollHeight;265+ else this.scrollTop = top + Math.max(0, this.scrollHeight - height);266+ }267+ }268+ class DirectCompose extends tiny.ui.FormElement {269+ connectedCallback() {270+ if (this.form) return;271+ super.connectedCallback();272+ this.querySelector("textarea")?.addEventListener("keydown", event => {273+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); if (!this.submitting) this.form.requestSubmit(); }274+ });275+ }276+ replyTo(rumor) {277+ this.reply = {id: rumor.id, pubkey: rumor.pubkey};278+ this.report("Replying to: " + rumor.content.slice(0, 100));279+ let cancel = this.querySelector("[data-cancel-reply]");280+ 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); }281+ this.querySelector("textarea")?.focus();282+ }283+ async submit(form) {284+ const actor = key(this.getAttribute("actor")), peer = key(this.getAttribute("peer")), content = String(form.elements.content.value || "");285+ if (!actor || !peer || actor === peer || !content.trim()) throw Error("Choose another person and write a message.");286+ const value = stateFor(actor), pendingKey = JSON.stringify([actor, peer, content, this.reply?.id]);287+ if (this.pendingKey !== pendingKey) { this.pending = null; this.pendingKey = pendingKey; }288+ this.report(this.pending ? "Retrying delivery…" : "Encrypting and sending…");289+ await deliver(value, {recipient: peer, content, reply: this.reply}, this.pending, pending => { this.pending = pending; });290+ if (!current(value) || !this.isConnected) return;291+ this.pending = null;292+ if (form.elements.content.value === content && pendingKey === JSON.stringify([actor, peer, content, this.reply?.id])) {293+ this.reply = null; this.querySelector("[data-cancel-reply]")?.remove(); form.reset();294+ }295+ this.report("Sent.");296+ }297+ }298+ class DirectStart extends tiny.ui.FormElement {299+ async submit(form) {300+ const peer = key(form.elements.peer.value); if (!peer) throw Error("Enter an npub or a 64-character public key.");301+ await tiny.navigate(tiny.localPath("/chat/dm/" + peer));302+ }303+ }304+ class DirectSettings extends HTMLElement {305+ connectedCallback() {306+ const button = node("button", "Receive chats here"); button.type = "button";307+ const status = node("output"); status.setAttribute("role", "status"); this.replaceChildren(button, status);308+ button.addEventListener("click", async () => {309+ button.disabled = true;310+ try {311+ const actor = key(this.getAttribute("actor")), value = stateFor(actor), signer = await activeSigner(actor);312+ const url = tiny.util.relayURL(this.getAttribute("relay")); if (!url) throw Error("This relay's address is unavailable.");313+ const rows = await protocol().queryRelayList(actor);314+ const events = Array.isArray(rows) ? rows : rows?.events || [];315+ 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];316+ const tags = (prior?.tags || []).filter(tag => tag[0] !== "relay" || tiny.util.relayURL(tag[1]));317+ if (!tags.some(tag => tag[0] === "relay" && tiny.util.relayURL(tag[1]) === url)) tags.push(["relay", url]);318+ await checkSession(value, signer);319+ await tiny.signing.publish({kind: 10050, created_at: Math.max(Math.floor(Date.now() / 1000), (prior?.created_at || 0) + 1), tags, content: prior?.content || ""});320+ status.textContent = "This relay now receives your direct messages.";321+ } catch (error) { status.textContent = error.message; } finally { button.disabled = false; }322+ });323+ }324+ }325+ document.addEventListener("tiny:signer", () => { clear(); for (const host of listeners) host.refresh(); });326+ document.addEventListener("tiny:logout", clear);327+ document.addEventListener("visibilitychange", () => { if (document.visibilityState !== "hidden") for (const host of listeners) host.refresh(); });328+ for (const [name, element] of [["direct-chats", DirectChats], ["direct-thread", DirectThread], ["direct-compose", DirectCompose], ["direct-start", DirectStart], ["direct-settings", DirectSettings]]) customElements.define(name, element);329+ tiny.chat.ui = Object.freeze({state: states, stateFor, clear, cacheEvents, participants, reactions, load, deliver, messages});330+})();@@ -0,0 +1,73 @@
1+package webui2+3+import (4+ "encoding/json"5+ "net/http"6+ "strings"7+)8+9+func (a *App) handleChatRoute(w http.ResponseWriter, r *http.Request) bool {10+ path := r.URL.Path11+ if path != "/rooms" && path != "/chat" && !strings.HasPrefix(path, "/chat/") {12+ return false13+ }14+ if r.Method != http.MethodGet && r.Method != http.MethodHead {15+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)16+ return true17+ }18+ if path == "/rooms" {19+ target := requestPrefix(r) + "/chat"20+ if r.URL.RawQuery != "" {21+ target += "?" + r.URL.RawQuery22+ }23+ http.Redirect(w, r, target, http.StatusMovedPermanently)24+ return true25+ }26+ if path == "/chat/events" {27+ a.directMessagesJSON(w, r)28+ return true29+ }30+ actor, _ := a.resolveActor(r)31+ data := PageData{Tab: "chat", Title: "Chat | " + a.backend.Slug(), Query: r.URL.Query()}32+ if path != "/chat" {33+ peer, ok := strings.CutPrefix(path, "/chat/dm/")34+ if !ok || !eventIDPattern.MatchString(peer) {35+ http.NotFound(w, r)36+ return true37+ }38+ data.Tab, data.View, data.Title = "direct", peer, "Direct chat | "+a.backend.Slug()39+ }40+ if data.Tab == "chat" {41+ result, err, typed := a.typedRoomResult(r.Context(), roomPath{tab: "rooms"}, r.URL.Query(), actor)42+ if !typed {43+ params, _ := json.Marshal(map[string]any{"limit": 100, "cursor": r.URL.Query().Get("cursor")})44+ result, err = a.backend.Query(r.Context(), "browserooms", []json.RawMessage{params}, actor)45+ }46+ data.Event, data.Feed = result, browseRows(result)47+ data.Rooms = data.Feed48+ if err != nil {49+ data.Error = err.Error()50+ }51+ } else {52+ data.Rooms = a.roomList(r.Context(), actor)53+ }54+ a.render(w, r, data)55+ return true56+}57+58+func (a *App) directMessagesJSON(w http.ResponseWriter, r *http.Request) {59+ w.Header().Set("Cache-Control", "private, no-store")60+ actor, err := a.resolveActor(r)61+ if err != nil || actor == "" {62+ http.Error(w, "Sign in to open direct chats.", http.StatusUnauthorized)63+ return64+ }65+ params, _ := json.Marshal(map[string]any{"limit": 50, "cursor": r.URL.Query().Get("cursor")})66+ result, err := a.backend.Query(r.Context(), "browsedirectmessages", []json.RawMessage{params}, actor)67+ if err != nil {68+ http.Error(w, err.Error(), publicErrorStatus(err))69+ return70+ }71+ w.Header().Set("Content-Type", "application/json; charset=utf-8")72+ _ = json.NewEncoder(w).Encode(result)73+}@@ -0,0 +1,131 @@
1+package webui2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/policy"12+)13+14+type chatBackend struct {15+ fakeBackend16+ methods []string17+ actors []string18+ params []json.RawMessage19+}20+21+func (b *chatBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {22+ b.methods = append(b.methods, method)23+ b.actors = append(b.actors, actor)24+ if len(params) != 0 {25+ b.params = append(b.params, params[0])26+ }27+ switch method {28+ case "browserooms":29+ return map[string]any{"items": []any{roomRecord("general", "General", "open", "")}, "next_cursor": ""}, nil30+ case "browsedirectmessages":31+ return map[string]any{"events": []any{}, "next_cursor": "next"}, nil32+ default:33+ return nil, nil34+ }35+}36+37+func chatApp(t *testing.T, actor string) (*App, *chatBackend) {38+ t.Helper()39+ backend := &chatBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}40+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return actor, nil }})41+ if err != nil {42+ t.Fatal(err)43+ }44+ return app, backend45+}46+47+func TestRoomsRedirectToChatPreservesPrefixAndQuery(t *testing.T) {48+ app, _ := chatApp(t, roomOwner)49+ req := httptest.NewRequest(http.MethodGet, "/rooms?cursor=older", nil)50+ // Tenant routing normally strips the /r/<slug> prefix before webui sees51+ // the URL. Keep the original request URI here to cover the redirect helper.52+ req.RequestURI = "/r/demo/rooms?cursor=older"53+ res := httptest.NewRecorder()54+ app.ServeHTTP(res, req)55+ if res.Code != http.StatusMovedPermanently || res.Header().Get("Location") != "/r/demo/chat?cursor=older" {56+ t.Fatalf("redirect = %d %q", res.Code, res.Header().Get("Location"))57+ }58+}59+60+func TestChatPageRendersGroupsAndGuestSignin(t *testing.T) {61+ app, _ := chatApp(t, roomOwner)62+ res := httptest.NewRecorder()63+ app.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/chat", nil))64+ body := res.Body.String()65+ if res.Code != http.StatusOK || !strings.Contains(body, "<h1>Chat</h1>") || !strings.Contains(body, "Direct chats") || !strings.Contains(body, `href="/rooms/general"`) {66+ t.Fatalf("signed chat page: %d %s", res.Code, body)67+ }68+ guest, _ := chatApp(t, "")69+ res = httptest.NewRecorder()70+ guest.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/chat", nil))71+ body = res.Body.String()72+ 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") {73+ t.Fatalf("guest chat page: %d %s", res.Code, body)74+ }75+}76+77+func TestDirectChatPageDoesNotRenderCiphertextForGuest(t *testing.T) {78+ app, backend := chatApp(t, "")79+ peer := strings.Repeat("d", 64)80+ res := httptest.NewRecorder()81+ app.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/chat/dm/"+peer, nil))82+ body := res.Body.String()83+ 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") {84+ t.Fatalf("guest direct chat: %d %s", res.Code, body)85+ }86+ for _, method := range backend.methods {87+ if method == "browsedirectmessages" {88+ t.Fatal("guest direct chat queried encrypted messages")89+ }90+ }91+}92+93+func TestDirectMessagesEndpointRequiresActorAndForwardsCursor(t *testing.T) {94+ guest, _ := chatApp(t, "")95+ res := httptest.NewRecorder()96+ guest.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/chat/events?cursor=abc", nil))97+ if res.Code != http.StatusUnauthorized || res.Header().Get("Cache-Control") != "private, no-store" {98+ t.Fatalf("guest events endpoint: %d %q", res.Code, res.Header().Get("Cache-Control"))99+ }100+ app, backend := chatApp(t, roomOwner)101+ res = httptest.NewRecorder()102+ app.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/chat/events?cursor=abc", nil))103+ if res.Code != http.StatusOK || res.Header().Get("Cache-Control") != "private, no-store" || res.Header().Get("Content-Type") != "application/json; charset=utf-8" {104+ t.Fatalf("events endpoint: %d headers=%v body=%s", res.Code, res.Header(), res.Body.String())105+ }106+ if len(backend.methods) != 1 || backend.methods[0] != "browsedirectmessages" || backend.actors[0] != roomOwner {107+ t.Fatalf("backend calls = %#v %#v", backend.methods, backend.actors)108+ }109+ var query map[string]any110+ if err := json.Unmarshal(backend.params[0], &query); err != nil || query["cursor"] != "abc" || query["limit"] != float64(50) {111+ t.Fatalf("query = %#v", query)112+ }113+}114+115+func TestChatRouteRejectsUnsupportedMethodsAndPaths(t *testing.T) {116+ app, _ := chatApp(t, roomOwner)117+ for _, tc := range []struct {118+ method, path string119+ status int120+ }{121+ {http.MethodPost, "/chat/events", http.StatusMethodNotAllowed},122+ {http.MethodGet, "/chat/events/extra", http.StatusNotFound},123+ {http.MethodGet, "/chat/dm/not-a-pubkey", http.StatusNotFound},124+ } {125+ res := httptest.NewRecorder()126+ app.ServeHTTP(res, httptest.NewRequest(tc.method, tc.path, nil))127+ if res.Code != tc.status {128+ t.Errorf("%s %s = %d, want %d", tc.method, tc.path, res.Code, tc.status)129+ }130+ }131+}@@ -1598,9 +1598,9 @@
15981598 // page whose definition has not arrived, such as after an in-place15991599 // navigation to the Files or Connect pages.16001600 const bundlesFor = {"file-upload": "files", "file-workspace-root": "files", "private-services": "private"};1601- // Opening the inbox records the visit for the badge and clears it.1601+ // Opening Chat records the visit for the notification badge.16021602 const markInboxSeen = () => {1603- if (!/\/inbox$/.test(location.pathname) || !document.getElementById("session-logout")) return;1603+ if (!/\/(?:inbox|chat(?:\/dm\/[0-9a-f]{64})?)$/.test(location.pathname) || !document.getElementById("session-logout")) return;16041604 navigator.clearAppBadge?.().catch?.(() => {});16051605 fetch(tiny.localPath("/inbox/seen"), {method: "POST", credentials: "same-origin"}).catch(() => {});16061606 };@@ -1667,7 +1667,7 @@
16671667 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;16681668 return root ? url.pathname.slice(root.length) || "/" : url.pathname;16691669 };1670- 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 || "");1670+ 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 || "");16711671 let navigationSerial = 0, activeAbort;16721672 const streams = new Set();16731673 const closeStreams = () => {@@ -0,0 +1,131 @@
1+/* NIP-17 direct messages: kind 14 rumors, NIP-59 seals and gift wraps. */2+(() => {3+ "use strict";4+5+ // Leave room for the rumor JSON envelope under NIP-44's 65535-byte limit.6+ const MAX_CONTENT = 60 * 1024;7+ const MAX_TAGS = 32;8+ const contentBytes = value => typeof TextEncoder === "function" ? new TextEncoder().encode(value).length : unescape(encodeURIComponent(value)).length;9+ const hex64 = value => typeof value === "string" && /^[0-9a-f]{64}$/i.test(value);10+ const signerAPI = () => {11+ const value = globalThis.NostrSigner;12+ if (!value?.finalizeEvent || !value.generateSecretKey || !value.verifyEvent || !value.getEventHash)13+ throw Error("Nostr signing support is unavailable");14+ return value;15+ };16+ const signerPublicKey = async signer => {17+ const key = await signer.getPublicKey();18+ if (!hex64(key)) throw Error("invalid signer public key");19+ return key.toLowerCase();20+ };21+ const normalizePubkey = value => {22+ if (globalThis.tiny?.files?.messages?.normalizePubkey)23+ return globalThis.tiny.files.messages.normalizePubkey(value);24+ if (hex64(value)) return value.toLowerCase();25+ const decoded = signerAPI().decodeNpub?.(String(value || ""));26+ if (hex64(decoded)) return decoded.toLowerCase();27+ throw Error("public key must be a 64 character hex key or npub");28+ };29+ const nip44Encrypt = async (signer, pubkey, text) => {30+ if (typeof globalThis.tiny?.nip44Encrypt === "function") return globalThis.tiny.nip44Encrypt(pubkey, text, signer);31+ if (typeof signer.nip44Encrypt === "function") return signer.nip44Encrypt(pubkey, text);32+ throw Error("NIP-44 encryption is unavailable");33+ };34+ const nip44Decrypt = async (signer, pubkey, text) => {35+ if (typeof globalThis.tiny?.nip44Decrypt === "function") return globalThis.tiny.nip44Decrypt(pubkey, text, signer);36+ if (typeof signer.nip44Decrypt === "function") return signer.nip44Decrypt(pubkey, text);37+ throw Error("NIP-44 decryption is unavailable");38+ };39+ const eventHash = (api, event) => api.getEventHash(event);40+ const past = now => Math.max(0, Math.floor(now) - Math.floor(Math.random() * 172801));41+42+ async function build(input = {}, options = {}) {43+ const signer = options.signer || globalThis.tiny?.signer?.();44+ if (!signer?.signEvent || !signer.getPublicKey) throw Error("a NIP-44 signer is required");45+ const recipient = normalizePubkey(input.recipient);46+ const sender = await signerPublicKey(signer);47+ const content = String(input.content ?? "");48+ if (contentBytes(content) > MAX_CONTENT) throw Error("message content is empty or too large");49+ const tags = [["p", recipient]];50+ if (input.reply) {51+ if (!hex64(input.reply.id) || !hex64(input.reply.pubkey)) throw Error("reply must include valid id and pubkey");52+ tags.push(["e", input.reply.id.toLowerCase(), input.reply.relay || "", "reply", input.reply.pubkey.toLowerCase()]);53+ }54+ const now = options.now ?? Date.now() / 1000;55+ let kind = 14;56+ if (input.reaction) {57+ const target = input.reaction;58+ if (!hex64(target.id) || !hex64(target.pubkey) || (target.kind != null && (!Number.isSafeInteger(target.kind) || target.kind < 0)))59+ throw Error("reaction target must include valid id and pubkey");60+ kind = 7;61+ tags.push(["e", target.id.toLowerCase(), target.relay || "", "root", target.pubkey.toLowerCase()]);62+ tags.push(["p", target.pubkey.toLowerCase()]);63+ if (target.kind != null) tags.push(["k", String(target.kind)]);64+ }65+ const rumorContent = input.reaction ? String(input.reaction.content ?? content) : content;66+ if (!rumorContent || contentBytes(rumorContent) > MAX_CONTENT) throw Error("message content is empty or too large");67+ const rumor = {pubkey: sender, created_at: Math.floor(now), kind, tags, content: rumorContent};68+ rumor.id = eventHash(signerAPI(), rumor);69+ // Keep both encryption layers within the original NIP-44 payload limit.70+ if (contentBytes(JSON.stringify(rumor)) > 40 * 1024) throw Error("Message is too long to send. Split it into shorter messages.");71+ const sealFor = async target => {72+ const encrypted = await nip44Encrypt(signer, target, JSON.stringify(rumor));73+ const sealInput = {kind: 13, created_at: past(now), tags: [], content: encrypted};74+ const seal = await signer.signEvent(sealInput);75+ 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");76+ return seal;77+ };78+ const wrap = async (target, seal) => {79+ const api = signerAPI();80+ const secret = api.generateSecretKey();81+ const ephemeral = api.getPublicKey(secret);82+ const key = api.nip44.getConversationKey(secret, target);83+ const encrypted = api.nip44.encrypt(JSON.stringify(seal), key);84+ const event = api.finalizeEvent({kind: 1059, created_at: past(now), tags: [["p", target]], content: encrypted}, secret);85+ if (!api.verifyEvent(event)) throw Error("invalid NIP-59 gift wrap");86+ return {recipient: target, event};87+ };88+ const [receiverSeal, senderSeal] = await Promise.all([sealFor(recipient), sealFor(sender)]);89+ return {rumor, events: [await wrap(recipient, receiverSeal), await wrap(sender, senderSeal)]};90+ }91+92+ async function decrypt(wrap, options = {}) {93+ const signer = options.signer || globalThis.tiny?.signer?.();94+ 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")))95+ throw Error("malformed gift wrap");96+ const actor = await signerPublicKey(signer);97+ const recipients = wrap.tags.filter(tag => tag[0] === "p");98+ if (recipients.length !== 1 || !hex64(recipients[0][1])) throw Error("malformed gift wrap recipient");99+ const recipient = normalizePubkey(recipients[0][1]);100+ if (recipient !== actor || (options.actor && normalizePubkey(options.actor) !== actor)) throw Error("gift wrap is not addressed to this signer");101+ if (typeof wrap.content !== "string" || wrap.content.length > 256 * 1024) throw Error("gift wrap is too large");102+ if (!signerAPI().verifyEvent(wrap) || wrap.id !== eventHash(signerAPI(), {...wrap, id: undefined})) throw Error("invalid gift wrap signature");103+ let seal;104+ try { seal = JSON.parse(await nip44Decrypt(signer, wrap.pubkey, wrap.content)); } catch { throw Error("unable to decrypt gift wrap"); }105+ 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");106+ const expirations = seal.tags.filter(tag => tag[0] === "expiration");107+ 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");108+ let rumor;109+ try { rumor = JSON.parse(await nip44Decrypt(signer, seal.pubkey, seal.content)); } catch { throw Error("unable to decrypt seal"); }110+ 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)111+ throw Error("malformed direct message rumor");112+ const addressed = rumor.tags.filter(tag => Array.isArray(tag) && tag[0] === "p" && hex64(tag[1])).map(tag => tag[1].toLowerCase());113+ const selfCopy = rumor.pubkey === actor;114+ const interactionTarget = [5, 7].includes(rumor.kind) && rumor.tags.some(tag => (tag[0] === "e" || tag[0] === "q") && hex64(tag[1]));115+ if (![5, 7].includes(rumor.kind) && (!addressed.includes(actor) && !selfCopy || options.actor && !addressed.includes(normalizePubkey(options.actor)) && !selfCopy)) throw Error("rumor recipient mismatch");116+ if ([5, 7].includes(rumor.kind) && !interactionTarget) throw Error("interaction target is missing");117+ return {rumor, seal, wrap, sender: rumor.pubkey};118+ }119+120+ const files = () => globalThis.tiny?.files?.messages;121+ const protocol = Object.freeze({122+ normalizePubkey,123+ build,124+ decrypt,125+ relayList: (...args) => files()?.relayList(...args),126+ queryRelayList: (...args) => files()?.queryRelayList(...args),127+ publishEvent: (...args) => files()?.publishEvent(...args)128+ });129+ globalThis.tiny = globalThis.tiny || {};130+ globalThis.tiny.chat = {...globalThis.tiny.chat, protocol};131+})();@@ -90,7 +90,7 @@
9090 return response.json();9191 }9292 93- function publishEvent(event, relays, options) {93+ function publishEvent(event, relays, options = {}) {9494 const signer = options.signer || activeSigner();9595 const WebSocketImpl = options.WebSocket || globalThis.WebSocket;9696 if (typeof WebSocketImpl !== "function") throw Error("WebSocket support is unavailable");@@ -321,6 +321,7 @@
321321 const apiObject = Object.freeze({322322 build,323323 share,324+ normalizePubkey,324325 relayList,325326 queryRelayList,326327 publishEvent@@ -37,6 +37,7 @@ then feature modules are loaded for the current page. */}}
3737 {{else if eq .Tab "social-thread"}}{{template "social-thread" .}}3838 {{else if eq .Tab "private"}}{{template "private" .}}3939 {{else if eq .Tab "chat"}}{{template "chat" .}}40+{{else if eq .Tab "direct"}}{{template "direct" .}}4041 {{else if eq .Tab "media"}}{{template "media" .}}4142 {{else if eq .Tab "sites"}}{{template "sites" .}}4243 {{else if eq .Tab "marmot"}}{{template "marmot" .}}@@ -79,11 +80,14 @@ then feature modules are loaded for the current page. */}}
7980 <script src="{{scriptURL "file-catalog.js"}}"></script>8081 <script src="{{scriptURL "blossom-manifests.js"}}"></script>8182 <script src="{{scriptURL "blossom-upload.js"}}"></script>82-<script src="{{scriptURL "file-messages.js"}}"></script>8383 <script src="{{scriptURL "file-workspace.js"}}"></script>8484 {{end}}<script src="{{scriptURL "nostr-name.js"}}"></script>8585 <script src="{{scriptURL "components.js"}}"></script>8686 <script src="{{scriptURL "rooms.js"}}"></script>87+<script src="{{scriptURL "file-messages.js"}}"></script>88+<script src="{{scriptURL "direct-messages.js"}}"></script>89+<script src="{{scriptURL "chat-files.js"}}"></script>90+<script src="{{scriptURL "chat.js"}}"></script>8791 {{if eq .Tab "connect"}}<script src="{{scriptURL "private-services.js"}}"></script>8892 {{end}}</body>8993 </html>@@ -97,10 +101,13 @@ then feature modules are loaded for the current page. */}}
97101 <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>98102 {{else if eq $kind "manage"}}<header><span><a href="/">{{.Slug}}</a>/<b>manage</b></span></header>99103 {{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}}100-{{else if eq $kind "rooms"}}<header><span><a href="/">{{.Slug}}</a>/<a href="/rooms"><b>rooms</b></a></span></header>104+{{else if eq $kind "rooms"}}<header><span><a href="/">{{.Slug}}</a>/<a href="/chat"><b>chat</b></a></span></header>105+<nav aria-label="Chat"><a href="/chat"{{if eq .Tab "chat"}} aria-current="page"{{end}}>All conversations</a></nav>106+{{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>107+<p><a href="/chat#new-chat">+ new chat</a></p>{{end}}101108 <h4>Rooms</h4>102-<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>103-<p><a href="/rooms#new-room">+ new room</a></p>109+<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>110+<p><a href="/chat#new-room">+ new room</a></p>104111 {{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header>105112 {{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}}106113 {{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}}@@ -110,6 +117,7 @@ then feature modules are loaded for the current page. */}}
110117 {{else if eq .Tab "repo"}}{{template "panel-repo" .}}111118 {{else if eq .Tab "agents"}}{{template "panel-agents" .}}112119 {{else if eq $kind "manage"}}{{template "panel-manage" .}}120+{{else if or (eq .Tab "chat") (eq .Tab "direct")}}{{template "panel-chat" .}}113121 {{else if eq $kind "rooms"}}{{template "panel-rooms" .}}114122 {{else if or (eq .Tab "social") (eq .Tab "social-thread")}}{{template "panel-social" .}}115123 {{else if or (eq .Tab "inbox") (eq .Tab "outbox") (eq .Tab "search") (eq .Tab "articles")}}{{template "panel-feed" .}}@@ -126,7 +134,7 @@ then feature modules are loaded for the current page. */}}
126134 <section><h4>Relay</h4><table><tr><th>url</th><td><code>{{.URL}}</code></td></tr></table></section>{{end}}127135 128136 {{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>129-<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}}137+<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}}130138 131139 {{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>132140 <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}}@@ -135,7 +143,7 @@ then feature modules are loaded for the current page. */}}
135143 {{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}}136144 137145 {{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>138-<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}}146+<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}}139147 140148 {{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>141149 {{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}@@ -156,7 +164,7 @@ then feature modules are loaded for the current page. */}}
156164 <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>157165 <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}}158166 {{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>159-<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>167+<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>160168 {{else if .Error}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{.View}}</code></td></tr></table></section>161169 {{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>162170 {{if eq .Tab "thread"}}<section><h4>Actions</h4><ul><li><a href="/rooms/{{$id}}">open room</a></li></ul></section>@@ -29,7 +29,7 @@
2929 <h1>{{if .Actor}}Your account{{else}}Sign in{{end}}</h1>3030 {{if .Notice}}<p role="status">{{.Notice}}</p>{{end}}3131 {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p>32-<p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a> | <a href="/inbox">Inbox</a></p>32+<p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a> | <a href="/chat">Chat</a></p>3333 <p>Use the sign out control in the footer to end this session.</p>3434 <details id="relay-setup"><summary>Your relay lists (optional)</summary>3535 <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>@@ -138,12 +138,6 @@
138138 </section>139139 {{end}}140140 141-{{define "chat"}}<section>142-<h1>Chat</h1>143-<p>Group and direct conversations are available through NIP-17 and NIP-29 clients.</p>144-</section>145-{{end}}146-147141 {{define "media"}}<section>148142 <h1>Media</h1>149143 <p>Blossom uploads and NIP-96 metadata are available from compatible clients.</p>@@ -35,7 +35,7 @@ func TestTypedRoomsReaderAvoidsLegacyQuery(t *testing.T) {
3535 if err != nil {3636 t.Fatal(err)3737 }38- for _, path := range []string{"/rooms", "/rooms/build"} {38+ for _, path := range []string{"/rooms/build"} {3939 response := httptest.NewRecorder()4040 app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))4141 if response.Code != http.StatusOK {@@ -289,13 +289,13 @@ func clock(value any) string {
289289 // roomItem is one message as the page shows it: the event's fields, the290290 // author's markers, the thread state and the reactions it has received.291291 type roomItem struct {292- ID, PubKey, Avatar, Kind, Content, Room, Root, Role, Notice string293- CreatedAt, UpdatedAt int64294- Agent, InThread, Edited, Own bool295- Mentions []string296- Replies int297- Reactions []reaction298- Attachments []roomAttachment292+ ID, PubKey, Avatar, Kind, Content, Room, Root, Quote, Role, Notice string293+ CreatedAt, UpdatedAt int64294+ Agent, InThread, Edited, Own bool295+ Mentions []string296+ Replies int297+ Reactions []reaction298+ Attachments []roomAttachment299299 }300300 301301 // roomItems turns a browse result's messages or replies into view items,@@ -356,6 +356,11 @@ func newRoomItem(row any, room string, members map[string]map[string]any) (roomI
356356 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"])}357357 item.UpdatedAt = item.CreatedAt358358 item.Attachments = roomAttachments(row)359+ // Flotilla and other NIP-29 clients use q tags for quoted events. Keep360+ // only a valid event id so malformed references never become links.361+ if quotes := tagValues(row, "q"); len(quotes) > 0 && eventIDPattern.MatchString(quotes[0]) {362+ item.Quote = quotes[0]363+ }359364 switch item.Kind {360365 case "7", "40003":361366 return roomItem{}, false@@ -3,6 +3,8 @@ thread. room-live receives the room stream after the server-rendered page
33 establishes the initial state. */}}44 {{define "rooms"}}<section id="rooms">55 <h1>Rooms</h1>6+{{template "roomList" .}}</section>{{end}}7+{{define "roomList"}}68 {{if .Error}}{{template "alert" .Error}}79 {{else if .Feed}}<table id="rows">810 <thead><tr><th>room</th><th>access</th><th>members</th><th>last message</th></tr></thead>@@ -11,12 +13,12 @@ establishes the initial state. */}}
1113 {{end}}</tbody>1214 </table>1315 {{else}}<p>No rooms yet.</p>14-{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/rooms" $.Query "cursor" .}}">next page</a></p>16+{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/chat" $.Query "cursor" .}}">next page</a></p>1517 {{end}}{{if .Actor}}<h2 id="new-room">New room</h2>1618 <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>1719 <noscript><p>Creating a room needs JavaScript and a connected signer.</p></noscript>1820 {{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to create a room.</p>19-{{end}}</section>21+{{end}}2022 {{end}}2123 2224 {{define "room"}}{{if .Error}}<section id="room">@@ -64,6 +66,6 @@ establishes the initial state. */}}
6466 {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}6567 {{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>6668 <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>67-{{if .Notice}}<p>{{.Notice}}</p>{{else}}<div>{{chatMarkdown (roomAttachmentContent .Content .Attachments)}}</div>{{roomAttachmentMarkup .Attachments}}{{end}}69+{{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}}6870 {{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}}6971 </room-message>{{end}}@@ -35,6 +35,7 @@
3535 // roomID derives a room id from a name: lowercase letters, digits, hyphen and underscore.3636 const roomID = name => String(name || "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);3737 const tagValue = (event, name) => (event.tags || []).find(tag => tag[0] === name)?.[1] || "";38+ const quoteID = event => { const id = tagValue(event, "q"); return isHex64(id) ? id : ""; };3839 const clock = seconds => {3940 const stamp = new Date(seconds * 1000).toISOString();4041 if (stamp.slice(0, 10) === new Date().toISOString().slice(0, 10)) return stamp.slice(11, 16);@@ -356,7 +357,10 @@
356357 header.append(member?.role ? " | " + member.role : "", small);357358 const attachments = notice ? [] : roomAttachments(event);358359 const body = notice ? el("p", notice) : chatMarkdown(el("div"), attachmentContent(event.content || "", attachments));359- node.append(avatar, header, body);360+ node.append(avatar, header);361+ const quote = notice ? "" : quoteID(event);362+ 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); }363+ node.append(body);360364 const media = attachmentNodes(attachments); if (media) node.append(media);361365 const footer = el("footer");362366 const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]);@@ -577,6 +581,20 @@
577581 tags.push(["e", root]);578582 if (isHex64(author) && author !== this.getAttribute("pubkey")) tags.push(["p", author]);579583 }584+ const quote = this.getAttribute("quote");585+ if (quote) {586+ if (!isHex64(quote)) throw Error("The quoted event is invalid.");587+ const relay = this.getAttribute("quote-relay");588+ const author = this.getAttribute("quote-pubkey");589+ // NIP-10 keeps relay and author positional. Preserve the empty relay590+ // slot when only the quoted author's key is known.591+ const tag = ["q", quote, relay || ""];592+ if (isHex64(author)) {593+ tag.push(author);594+ if (author !== this.getAttribute("pubkey") && !tags.some(item => item[0] === "p" && item[1] === author)) tags.push(["p", author]);595+ }596+ tags.push(tag);597+ }580598 roomMentions(content).forEach(key => { if (!tags.some(tag => tag[0] === "p" && tag[1] === key)) tags.push(["p", key]); });581599 const lines = [];582600 attachments.forEach(file => {@@ -78,7 +78,7 @@ func (b *roomsBackend) Query(_ context.Context, method string, params []json.Raw
7878 roomEvent(roomLike, roomOwner, 7, 1757203600, "+", []string{"h", id}, []string{"e", roomChat}),7979 reply,8080 root,81- roomEvent(roomChat, roomOwner, 9, 1757202000, "@agent take a look <script>alert(1)</script>", []string{"h", id}, []string{"p", roomAgent}),81+ roomEvent(roomChat, roomOwner, 9, 1757202000, "@agent take a look <script>alert(1)</script>", []string{"h", id}, []string{"p", roomAgent}, []string{"q", roomThread, "", roomAgent}),8282 roomEvent(roomHello, roomAgent, 9, 1757201000, "hello, see https://example.com/docs. and nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2", []string{"h", id}),8383 }8484 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@@ -114,8 +114,8 @@ func roomsPage(t *testing.T, app *App, path string) string {
114114 115115 func TestRoomsListRendersRoomsRailAndCreateForm(t *testing.T) {116116 app, backend := roomsApp(t, roomOwner)117- body := roomsPage(t, app, "/rooms")118- 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>`} {117+ body := roomsPage(t, app, "/chat")118+ 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>`} {119119 if !strings.Contains(body, want) {120120 t.Errorf("rooms list missing %q", want)121121 }@@ -124,15 +124,16 @@ func TestRoomsListRendersRoomsRailAndCreateForm(t *testing.T) {
124124 t.Fatalf("calls = %v", backend.calls)125125 }126126 guestApp, _ := roomsApp(t, "")127- body = roomsPage(t, guestApp, "/rooms")128- 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"`) {127+ body = roomsPage(t, guestApp, "/chat")128+ 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"`) {129129 t.Fatalf("guest rooms list: %s", body[strings.Index(body, `id="content"`):])130130 }131- // The relay rail lists rooms first in the conversation group.131+ // The relay rail lists chat in the conversation group and leaves the132+ // reliability event views out of the primary navigation.132133 home := roomsPage(t, app, "/")133- rooms, inbox, search := strings.Index(home, `href="/rooms"`), strings.Index(home, `href="/inbox"`), strings.Index(home, `href="/search"`)134- if rooms < 0 || inbox < 0 || !(search < rooms && rooms < inbox) {135- t.Fatalf("relay rail order: search=%d rooms=%d inbox=%d", search, rooms, inbox)134+ chat, search := strings.Index(home, `href="/chat"`), strings.Index(home, `href="/search"`)135+ if chat < 0 || search < 0 || search > chat || strings.Contains(home, `href="/inbox"`) || strings.Contains(home, `href="/outbox"`) {136+ t.Fatalf("relay rail order or hidden reliability views: search=%d chat=%d", search, chat)136137 }137138 }138139 @@ -149,6 +150,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
149150 `<section id="room" data-viewer="` + roomOwner + `">`,150151 `<a href="https://example.com/docs" rel="noopener">https://example.com/docs</a>.`,151152 `<a href="/open?target=nostr%3Anpub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2">nostr:npub1`,153+ `<blockquote data-quote><a href="/open?target=nostr%3A` + roomThread + `">quoted event ` + shortID(roomThread) + `</a></blockquote>`,152154 `<script>alert(1)</script>`,153155 `<span>to <nostr-name pubkey="` + roomAgent + `"`,154156 `<span data-reaction="+1">+1 1</span>`,@@ -317,6 +317,7 @@ room-message[data-own]{margin-left:24px;padding:12px 14px 14px 56px;background:v
317317 room-message[data-own]>nostr-avatar{left:12px}318318 room-message [data-own-label]{margin-left:6px;color:var(--accent);font-size:11px}319319 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}320+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}320321 room-message>p a,room-message>div a{color:var(--accent)}321322 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}322323 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}@@ -458,3 +459,66 @@ social-compose input:not([type=file]),social-compose textarea{width:100%;box-siz
458459 459460 [data-reaction-emoji]{display:inline-block;width:20px;height:20px;object-fit:contain;vertical-align:middle}460461 [data-post-body=note]>img,[data-comment-body]>img{display:block;width:auto;max-height:560px;margin:12px 0}462+463+/* Direct conversations share the room palette and keep their plaintext in the browser. */464+#chat,#direct-chat{max-width:860px;margin:0 auto;box-sizing:border-box}465+#chat>p,#direct-chat>header>p{color:var(--muted)}466+#direct-chat>header{padding-bottom:14px;border-bottom:1px solid var(--line)}467+#direct-chat h1{font-size:23px;overflow-wrap:anywhere}468+direct-start,direct-chats,direct-thread,direct-compose,direct-settings{display:block;min-width:0}469+direct-start{margin:12px 0 18px;padding:14px;background:var(--rail);border:1px solid var(--line);border-radius:10px}470+direct-start label{display:block;color:var(--muted);font-size:12px}471+direct-start input{display:block;width:100%;margin:6px 0 10px;min-height:42px;font-size:16px}472+direct-start button,direct-compose button{min-height:40px;padding:8px 16px;background:var(--accent);color:var(--paper);border:0;border-radius:7px}473+direct-chats{margin:8px 0 24px}474+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}475+direct-chats>a:hover{background:var(--rail);text-decoration:none}476+direct-chats>a [data-author]{font-weight:600}477+direct-chats>a nostr-avatar{position:absolute;left:10px;top:15px;width:32px;height:32px}478+direct-chats>a time{display:block;color:var(--muted);font:11px/1.5 var(--mono);margin-top:4px}479+direct-chats>a p{margin:4px 0 0;color:var(--muted);overflow:hidden;white-space:nowrap;text-overflow:ellipsis}480+direct-chats>p{color:var(--muted);padding:10px 0}481+#rail direct-chats{margin:0 0 10px;font-size:12px}482+#rail direct-chats>a{padding:10px 16px 10px 50px;border:0}483+#rail direct-chats>a nostr-avatar{left:20px;top:10px;width:24px;height:24px}484+#rail direct-chats time,#rail direct-chats>a p{display:none}485+#rail direct-chats>p{padding:0 20px;font-size:11px}486+#rail direct-chats>a[aria-current=page]{background:var(--soft);color:var(--accent)}487+direct-thread{min-height:160px;max-height:calc(100dvh - 340px);overflow:auto;overscroll-behavior:contain;padding:12px 0}488+direct-thread>article{position:relative;margin:0 18px 14px 0;padding:12px 14px 12px 54px;border-bottom:1px solid var(--line);overflow-wrap:anywhere}489+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)}490+direct-thread article nostr-avatar{position:absolute;left:12px;top:12px;width:30px;height:30px}491+direct-thread article header{font:12px/1.4 var(--mono);color:var(--muted);margin:0 0 7px}492+direct-thread article header [data-author]{color:var(--ink)}493+direct-thread article time{display:block;margin-top:3px;font-size:10px}494+direct-thread article>p{margin:0;font:15px/1.55 var(--sans);white-space:pre-wrap}495+direct-thread article img,direct-thread article video{display:block;max-width:100%;max-height:360px}496+direct-thread article audio{max-width:100%}497+direct-compose{position:sticky;bottom:0;background:var(--paper);padding:12px 0 6px;border-top:1px solid var(--line)}498+direct-compose label{display:block;color:var(--muted);font-size:12px;margin-bottom:5px}499+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)}500+direct-compose output,direct-start output{display:block;margin-top:6px;font-size:12px;overflow-wrap:anywhere}501+direct-compose output[data-error],direct-start output[data-error]{color:var(--alert)}502+direct-settings button{width:100%;min-height:40px;white-space:normal;font-size:12px}503+@media(max-width:720px){504+#chat #rows,#chat #rows tbody,#chat #rows tr{display:block;width:auto}505+#chat #rows thead{display:none}506+#chat #rows tr{padding:10px 0;border-bottom:1px solid var(--line)}507+#chat #rows td{display:block;width:auto;padding:2px 0;border:0;height:auto}508+#chat #rows td:nth-child(3){display:none}509+#chat #rows td:last-child{width:auto;color:var(--muted);font-size:11px}510+direct-thread{max-height:calc(100dvh - 340px);min-height:160px}511+direct-thread>article{margin-right:0;padding-left:48px}512+direct-thread>article[data-own=""]{margin-left:10px}513+direct-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));padding-bottom:8px}514+}515+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}516+direct-thread article>footer{margin-top:8px;font:12px var(--mono);color:var(--muted)}517+direct-thread article>footer button{padding:3px 8px;margin-right:6px;border:0;border-radius:5px;font:12px var(--sans)}518+direct-thread article>footer output{display:block;margin-top:5px;color:var(--alert)}519+direct-chats>output,direct-thread>output{display:block;margin:8px 0;font-size:12px;color:var(--alert)}520+direct-file{display:block;margin-top:8px;overflow-wrap:anywhere}521+direct-file article{padding:0;margin:0;border:0}522+direct-file button{display:block;margin:8px 0;padding:8px 12px;min-height:40px}523+direct-file output,direct-settings output{display:block;font-size:12px;color:var(--muted);margin-top:6px}524+direct-file pre{max-height:360px;overflow:auto;padding:10px;background:var(--rail);font:13px/1.5 var(--mono)}@@ -16,7 +16,7 @@ import (
1616 // templateFS holds every page template. page.html owns the shell and the1717 // shared partials; the other files each define one group of tabs.1818 //19-//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html rooms.html social.html19+//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html rooms.html social.html chat.html2020 var templateFS embed.FS2121 2222 // styleCSS is inlined into every page so the UI needs no extra request and@@ -41,6 +41,15 @@ var componentsJS string
4141 //go:embed rooms.js4242 var roomsJS string4343 44+//go:embed chat.js45+var chatJS string46+47+//go:embed chat-files.js48+var chatFilesJS string49+50+//go:embed direct-messages.js51+var directMessagesJS string52+4453 // nostr-name.js is the vendored <nostr-name> element with the relay-local4554 // profile loader; rebuild it with npm run build:nostr-name.4655 //@@ -104,20 +113,27 @@ var iconSVG []byte
104113 //go:embed icon-mono.svg105114 var iconMonoSVG []byte106115 107-// navItem is one rail entry. Tab matches PageData.Tab for the active state.108-// navItem is one rail entry. Group orders entries by what they are for; the109-// rail leaves a small gap between groups.116+// navItem is one rail entry. Tab matches PageData.Tab for the active state;117+// Group separates related destinations with a small gap.110118 type navItem struct {111119 Label, Href, Tab string112120 Group int113121 }114122 115-// relayNav: entry points, then conversation, then collaborative artifacts,116-// then what the relay publishes and stores, then what is yours.117-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}}123+// relayNav groups discovery, conversations, shared work and administration.124+var relayNav = []navItem{125+ {"/home", "/", "home", 0},126+ {"/search", "/search", "search", 0},127+ {"/social", "/social", "social", 1},128+ {"/chat", "/chat", "chat", 1},129+ {"/repos", "/repos", "repos", 2},130+ {"/wiki", "/wiki", "wiki", 2},131+ {"/files", "/files", "files", 2},132+ {"/sites", "/sites", "sites", 2},133+ {"/approvals", "/approvals", "approvals", 3},134+ {"/manage", "/manage/people", "manage", 3},135+}118136 119-// manageNav: who is here, what they may do, what the relay is, what it does120-// over time, and how it is doing.121137 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}}122138 123139 // navGroups splits a rail list into its groups, in order.@@ -138,7 +154,7 @@ func railKind(tab string) string {
138154 if tab == "repo" {139155 return "repo"140156 }141- if tab == "rooms" || tab == "room" || tab == "thread" {157+ if tab == "chat" || tab == "direct" || tab == "rooms" || tab == "room" || tab == "thread" {142158 return "rooms"143159 }144160 for _, item := range manageNav {@@ -225,7 +241,7 @@ func repoView(query url.Values) string {
225241 // scripts are served at /scripts/<name> and cached by the service worker.226242 // One version stamp covers them all, so a change to any file refreshes every227243 // cached copy together.228-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}244+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}229245 230246 var scriptsVersion = func() string {231247 names := make([]string, 0, len(scripts))@@ -176,7 +176,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
176176 if a.denyPrivateEndpoint(writer, request) {177177 return178178 }179- if a.handleControlRoute(writer, request) || a.handleWebMCPRoute(writer, request) || a.handleBrowseRoute(writer, request) {179+ if a.handleChatRoute(writer, request) || a.handleControlRoute(writer, request) || a.handleWebMCPRoute(writer, request) || a.handleBrowseRoute(writer, request) {180180 return181181 }182182 if a.handleSocialRoute(writer, request) {@@ -0,0 +1,74 @@
1+import assert from "node:assert/strict";2+import {webcrypto} from "node:crypto";3+import {readFile} from "node:fs/promises";4+import test from "node:test";5+import vm from "node:vm";6+7+const source = await readFile(new URL("../internal/webui/chat-files.js", import.meta.url), "utf8");8+const hex = value => Buffer.from(value).toString("hex");9+let DirectFile;10+const revoked = [];11+const sandbox = {12+ crypto: webcrypto, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, URL, Blob,13+ location: {origin: "https://relay.test"}, fetch: async () => { throw Error("unexpected fetch"); },14+ HTMLElement: class {}, customElements: {get: () => undefined, define(_name, ctor) { DirectFile = ctor; }},15+ document: {createElement: tag => ({localName: tag, append() {}, replaceChildren() {}, addEventListener() {}})}16+};17+sandbox.globalThis = sandbox;18+sandbox.tiny = {util: {19+ bytes: value => value instanceof Uint8Array ? value : new Uint8Array(value),20+ hex, sha256: async value => new Uint8Array(await webcrypto.subtle.digest("SHA-256", value))21+}};22+vm.runInNewContext(source, sandbox);23+const api = sandbox.tiny.chat.files;24+25+const makeFile = async (plain = new TextEncoder().encode("hello")) => {26+ const key = webcrypto.getRandomValues(new Uint8Array(32));27+ const nonce = webcrypto.getRandomValues(new Uint8Array(12));28+ const cryptoKey = await webcrypto.subtle.importKey("raw", key, {name: "AES-GCM"}, false, ["encrypt"]);29+ const encrypted = new Uint8Array(await webcrypto.subtle.encrypt({name: "AES-GCM", iv: nonce}, cryptoKey, plain));30+ const digest = value => webcrypto.subtle.digest("SHA-256", value).then(hex);31+ return {plain, encrypted, key, nonce, hash: await digest(encrypted), plaintextHash: await digest(plain)};32+};33+34+test("parses interoperable NIP-17 kind 15 tags without exposing key in the node", async () => {35+ const file = await makeFile();36+ const rumor = {kind: 15, content: "https://cdn.example/file.mp4", tags: [37+ ["p", "a".repeat(64)], ["file-type", "video/mp4"], ["encryption-algorithm", "aes-gcm"],38+ ["decryption-key", hex(file.key)], ["decryption-nonce", hex(file.nonce)], ["x", file.hash], ["ox", file.plaintextHash], ["size", String(file.encrypted.length)]39+ ]};40+ const parsed = api.parse(rumor);41+ assert.equal(parsed.mime, "video/mp4");42+ assert.equal(parsed.hash, file.hash);43+ assert.deepEqual(await api.decrypt(parsed, file.encrypted), file.plain);44+ const node = api.render(rumor);45+ assert.equal(node.rumor, rumor);46+});47+48+test("rejects credentialed, non-HTTP and malformed attachment URLs", () => {49+ const base = {kind: 15, tags: [["encryption-algorithm", "aes-gcm"], ["decryption-key", "a".repeat(64)], ["decryption-nonce", "b".repeat(24)], ["x", "c".repeat(64)]]};50+ for (const content of ["data:text/plain,secret", "javascript:alert(1)", "https://user:pass@example.test/a"]) {51+ assert.throws(() => api.parse({...base, content}), /URL/);52+ }53+ assert.throws(() => api.parse({...base, content: "https://example.test/a", tags: base.tags.map(tag => tag[0] === "x" ? ["x", "bad"] : tag)}), /hash/);54+});55+56+test("checks ciphertext and plaintext hashes and authenticated decryption", async () => {57+ const file = await makeFile();58+ 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]]};59+ const parsed = api.parse(rumor);60+ const altered = new Uint8Array(file.encrypted); altered[0] ^= 1;61+ await assert.rejects(api.decrypt(parsed, altered), /hash/);62+ const wrongKey = {...parsed, key: new Uint8Array(32).fill(7)};63+ await assert.rejects(api.decrypt(wrongKey, file.encrypted), /decryption failed|plaintext hash mismatch/);64+});65+66+test("revokes attachment object URLs when its direct-file element disconnects", () => {67+ const oldRevoke = sandbox.URL.revokeObjectURL;68+ sandbox.URL.revokeObjectURL = value => revoked.push(value);69+ const node = new DirectFile();70+ node._attachmentURLs = new Set(["blob:attachment-1", "blob:attachment-2"]);71+ node.disconnectedCallback();72+ assert.deepEqual(revoked, ["blob:attachment-1", "blob:attachment-2"]);73+ sandbox.URL.revokeObjectURL = oldRevoke;74+});@@ -0,0 +1,89 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import fs from "node:fs";4+import vm from "node:vm";5+import {getEventHash, verifyEvent} from "nostr-tools/pure";6+7+class FakeNode {8+ constructor(name = "node") { this.nodeName = name; this.childNodes = []; this.dataset = {}; this.attributes = {}; this.style = {}; this.isConnected = true; }9+ append(...children) { this.childNodes.push(...children.flat().filter(Boolean)); }10+ replaceChildren(...children) { this.childNodes = children; }11+ setAttribute(name, value) { this.attributes[name] = String(value); }12+ getAttribute(name) { return this.attributes[name] || null; }13+ addEventListener() {}14+ removeEventListener() {}15+ querySelector() { return null; }16+ querySelectorAll() { return []; }17+ closest() { return null; }18+}19+class FakeElement extends FakeNode {}20+const definitions = new Map();21+globalThis.HTMLElement = FakeElement;22+globalThis.document = {visibilityState: "visible", createElement: name => new FakeNode(name), createTextNode: text => Object.assign(new FakeNode("text"), {textContent: text}), addEventListener() {}, removeEventListener() {}};23+globalThis.customElements = {get: name => definitions.get(name), define: (name, value) => definitions.set(name, value)};24+globalThis.location = {href: "https://relay.test/chat", pathname: "/chat"};25+globalThis.setInterval = () => 0; globalThis.clearInterval = () => {};26+27+const key = char => char.repeat(64);28+const alice = key("a"), bob = key("b"), carol = key("c");29+const rumor = (id, sender, recipient, created_at, content, kind = 14, extra = []) => ({id, kind, pubkey: sender, created_at, content, tags: [["p", recipient], ...extra]});30+const wrap = (id, r) => ({id, kind: 1059, pubkey: bob, created_at: r.created_at, tags: [["p", alice]], rumor: r});31+const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; };32+const signer = {getPublicKey: async () => alice};33+const protocol = {34+ normalizePubkey: value => String(value).toLowerCase(),35+ 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}; },36+ relayList: value => value, queryRelayList: async () => ["wss://relay.test"],37+ build: async input => ({rumor: rumor("built", alice, input.recipient, 100, input.content), events: [{event: {id: "receiver-wrap"}}, {event: {id: "sender-wrap"}}]}), publishEvent: async () => true38+};39+let pages = [];40+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 () => ({})}};41+globalThis.NostrSigner = {verifyEvent: () => true};42+vm.runInThisContext(fs.readFileSync("internal/webui/chat.js", "utf8"), {filename: "chat.js"});43+const ui = tiny.chat.ui;44+45+test("cache accepts files, rejects groups, deduplicates and folds reactions", async () => {46+ const value = ui.stateFor(alice), first = rumor(key("1"), alice, bob, 20, "one"), second = rumor("two", bob, alice, 10, "two");47+ 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"]]);48+ 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);49+ 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);50+});51+52+test("concurrent loads share one request and older pagination clears cursor", async () => {53+ 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); };54+ 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;55+});56+57+test("refresh scans recent overlap without resetting an older cursor", async () => {58+ 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");59+});60+61+test("partial delivery retries exact wraps and skips acknowledged copy", async () => {62+ 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; };63+ 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;64+});65+66+test("logout or signer replacement during pending decryption cannot cache plaintext", async () => {67+ 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});68+});69+70+test("source parity fixture verifies and preserves its signed event hash", () => {71+ 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);72+});73+74+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)); });75+76+test("wrapped retractions remove only their author's message or reaction", async () => {77+ ui.clear();78+ const value = ui.stateFor(alice);79+ const original = rumor(key("1"), alice, bob, 10, "message");80+ const like = rumor(key("2"), bob, alice, 11, "+", 7, [["e", original.id]]);81+ const malicious = rumor(key("3"), carol, alice, 12, "", 5, [["e", original.id]]);82+ await ui.cacheEvents(value, [wrap("orig", original), wrap("like", like), wrap("bad-delete", malicious)], signer);83+ assert.equal(ui.messages(value).length, 1);84+ assert.deepEqual([...ui.reactions(value, original)], [["♥", 1]]);85+ await ui.cacheEvents(value, [wrap("retract", rumor(key("4"), bob, alice, 13, "", 5, [["e", like.id]]))], signer);86+ assert.deepEqual([...ui.reactions(value, original)], []);87+ await ui.cacheEvents(value, [wrap("delete", rumor(key("5"), alice, bob, 14, "", 5, [["e", original.id]]))], signer);88+ assert.equal(ui.messages(value).length, 0);89+});@@ -0,0 +1,165 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import fs from "node:fs";4+import vm from "node:vm";5+import { finalizeEvent, generateSecretKey, getEventHash, getPublicKey, nip19, nip44, verifyEvent } from "nostr-tools";6+7+globalThis.NostrSigner = {generateSecretKey, getPublicKey, getEventHash, finalizeEvent, verifyEvent, nip44, decodeNpub: value => nip19.decode(value).data};8+globalThis.tiny = {util: {relayURL: value => value}, files: {messages: {normalizePubkey: value => /^[0-9a-f]{64}$/i.test(value) ? value.toLowerCase() : nip19.decode(value).data}}};9+vm.runInThisContext(fs.readFileSync("internal/webui/direct-messages.js", "utf8"), {filename: "direct-messages.js"});10+11+const alice = generateSecretKey();12+const bob = generateSecretKey();13+const alicePub = getPublicKey(alice);14+const bobPub = getPublicKey(bob);15+const makeSigner = secret => ({16+ async getPublicKey() { return getPublicKey(secret); },17+ async signEvent(unsigned) { return finalizeEvent(unsigned, secret); },18+ async nip44Encrypt(peer, value) { return nip44.encrypt(value, nip44.getConversationKey(secret, peer)); },19+ async nip44Decrypt(peer, value) { return nip44.decrypt(value, nip44.getConversationKey(secret, peer)); },20+ nip44: {21+ getConversationKey(peer) { return nip44.getConversationKey(secret, peer); },22+ encrypt(first, second) {23+ if (/^[0-9a-f]{64}$/i.test(first)) return nip44.encrypt(second, nip44.getConversationKey(secret, first));24+ return nip44.encrypt(first, second);25+ },26+ decrypt(peer, value) { return nip44.decrypt(value, nip44.getConversationKey(secret, peer)); },27+ },28+});29+30+// Model a NIP-07 signer: the shared wrappers pass peer and payload to these31+// methods, while the signer retains the private key internally.32+globalThis.tiny.nip44Encrypt = (peer, value, signer) => signer.nip44.encrypt(peer, value);33+globalThis.tiny.nip44Decrypt = (peer, value, signer) => signer.nip44.decrypt(peer, value);34+const nip07Signer = secret => {35+ const base = makeSigner(secret);36+ return {...base, nip44Encrypt: undefined, nip44Decrypt: undefined};37+};38+39+test("build creates independently decryptable recipient and self-copy wraps", async () => {40+ const protocol = globalThis.tiny.chat.protocol;41+ const result = await protocol.build({recipient: bobPub, content: "hello", reply: {id: "a".repeat(64), pubkey: alicePub}}, {signer: makeSigner(alice), now: 100});42+ assert.equal(result.rumor.kind, 14);43+ assert.equal(result.rumor.id, getEventHash(result.rumor));44+ assert.equal(result.events.length, 2);45+ assert.deepEqual(result.events.map(item => item.event.tags), [[["p", bobPub]], [["p", alicePub]]]);46+ assert.deepEqual((await protocol.decrypt(result.events[0].event, {signer: makeSigner(bob)})).rumor, result.rumor);47+ assert.deepEqual((await protocol.decrypt(result.events[1].event, {signer: makeSigner(alice)})).rumor, result.rumor);48+});49+50+test("decrypt rejects tampering, spoofed recipient and third-party access", async () => {51+ const protocol = globalThis.tiny.chat.protocol;52+ const result = await protocol.build({recipient: bobPub, content: "private"}, {signer: makeSigner(alice), now: 100});53+ const tampered = {...result.events[0].event, content: result.events[0].event.content + "x"};54+ await assert.rejects(() => protocol.decrypt(tampered, {signer: makeSigner(bob)}), /invalid gift wrap|unable to decrypt/);55+ await assert.rejects(() => protocol.decrypt(result.events[0].event, {signer: makeSigner(alice)}), /not addressed/);56+ const carol = generateSecretKey();57+ await assert.rejects(() => protocol.decrypt(result.events[0].event, {signer: makeSigner(carol)}), /not addressed|unable to decrypt/);58+});59+60+test("normalizes npub and preserves relay transport wrappers", () => {61+ const protocol = globalThis.tiny.chat.protocol;62+ assert.equal(protocol.normalizePubkey(nip19.npubEncode(alicePub)), alicePub);63+ assert.equal(typeof protocol.relayList, "function");64+ assert.equal(typeof protocol.queryRelayList, "function");65+ assert.equal(typeof protocol.publishEvent, "function");66+});67+68+test("round trips a kind 15 file rumor through the NIP-07 path", async () => {69+ vm.runInThisContext(fs.readFileSync("internal/webui/file-messages.js", "utf8"), {filename: "file-messages.js"});70+ const file = await globalThis.tiny.files.messages.build({71+ recipient: bobPub, fileURL: "https://files.example/photo.jpg", ciphertextHash: "a".repeat(64),72+ mimeType: "image/jpeg", key: "b".repeat(64), nonce: "c".repeat(24)73+ }, {signer: nip07Signer(alice), now: 100});74+ const direct = globalThis.tiny.chat.protocol;75+ const opened = await direct.decrypt(file.events[0].event, {signer: nip07Signer(bob)});76+ assert.equal(opened.rumor.kind, 15);77+ assert.equal(opened.rumor.content, "https://files.example/photo.jpg");78+});79+80+test("rejects signed rumors, malformed timestamps and optional-tag abuse", async () => {81+ const protocol = globalThis.tiny.chat.protocol;82+ const result = await protocol.build({recipient: bobPub, content: "bounded"}, {signer: nip07Signer(alice), now: 100});83+ const opened = await protocol.decrypt(result.events[0].event, {signer: nip07Signer(bob)});84+ const makeWrap = rumor => {85+ const seal = finalizeEvent({kind: 13, created_at: 1, tags: [], content: nip44.encrypt(JSON.stringify(rumor), nip44.getConversationKey(alice, bobPub))}, alice);86+ const ephemeral = generateSecretKey();87+ const wrapped = nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(ephemeral, bobPub));88+ return finalizeEvent({kind: 1059, created_at: 1, tags: [["p", bobPub], ["expiration", String(Math.floor(Date.now() / 1000) + 3600)]], content: wrapped}, ephemeral);89+ };90+ const signed = {...opened.rumor, sig: "f".repeat(128)};91+ signed.id = getEventHash(signed);92+ await assert.rejects(() => protocol.decrypt(makeWrap(signed), {signer: nip07Signer(bob)}), /malformed direct message/);93+ const badTime = {...opened.rumor, created_at: 1.5};94+ badTime.id = getEventHash(badTime);95+ await assert.rejects(() => protocol.decrypt(makeWrap(badTime), {signer: nip07Signer(bob)}), /malformed direct message/);96+ const duplicateP = {...result.events[0].event, tags: [["p", bobPub], ["p", bobPub]]};97+ await assert.rejects(() => protocol.decrypt(duplicateP, {signer: nip07Signer(bob)}), /recipient/);98+});99+100+test("rejects a signer that mutates the requested seal", async () => {101+ const protocol = globalThis.tiny.chat.protocol;102+ const evil = nip07Signer(alice);103+ evil.signEvent = async unsigned => finalizeEvent({...unsigned, content: unsigned.content + " altered"}, alice);104+ await assert.rejects(() => protocol.build({recipient: bobPub, content: "do not alter"}, {signer: evil}), /invalid NIP-59 seal/);105+});106+107+test("round trips an encrypted NIP-25 reaction in both copies", async () => {108+ const protocol = globalThis.tiny.chat.protocol;109+ const target = {id: "d".repeat(64), pubkey: alicePub, kind: 14};110+ const result = await protocol.build({recipient: bobPub, reaction: {...target, content: "+"}}, {signer: nip07Signer(alice), now: 100});111+ assert.equal(result.rumor.kind, 7);112+ assert.deepEqual(result.rumor.tags.slice(1), [["e", target.id, "", "root", target.pubkey], ["p", target.pubkey], ["k", "14"]]);113+ assert.equal((await protocol.decrypt(result.events[0].event, {signer: nip07Signer(bob)})).rumor.content, "+");114+ assert.equal((await protocol.decrypt(result.events[1].event, {signer: nip07Signer(alice)})).rumor.kind, 7);115+});116+117+test("rejects reactions with malformed targets", async () => {118+ const protocol = globalThis.tiny.chat.protocol;119+ await assert.rejects(() => protocol.build({recipient: bobPub, reaction: {id: "nope", pubkey: alicePub, content: "+"}}, {signer: nip07Signer(alice)}), /reaction target/);120+});121+122+test("accepts an inbound Flotilla reaction with only the target-author p tag", async () => {123+ const protocol = globalThis.tiny.chat.protocol;124+ const target = {id: "e".repeat(64), pubkey: bobPub, kind: 14};125+ const signer = nip07Signer(alice);126+ const rumor = {pubkey: bobPub, created_at: 100, kind: 7, tags: [["e", target.id, "", "root"], ["p", bobPub], ["k", "14"]], content: "+"};127+ rumor.id = getEventHash(rumor);128+ const seal = finalizeEvent({kind: 13, created_at: 100, tags: [], content: nip44.encrypt(JSON.stringify(rumor), nip44.getConversationKey(bob, alicePub))}, bob);129+ const ephemeral = generateSecretKey();130+ const wrapped = nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(ephemeral, alicePub));131+ const gift = finalizeEvent({kind: 1059, created_at: 100, tags: [["p", alicePub]], content: wrapped}, ephemeral);132+ const opened = await protocol.decrypt(gift, {signer});133+ assert.equal(opened.rumor.pubkey, bobPub);134+ assert.equal(opened.rumor.kind, 7);135+});136+137+test("large escaped messages are rejected before asking the signer to encrypt", async () => {138+ let calls = 0;139+ const signer = nip07Signer(alice);140+ signer.nip44.encrypt = () => { calls++; throw Error("must not reach encryption"); };141+ await assert.rejects(() => tiny.chat.protocol.build({recipient: bobPub, content: "\n".repeat(24000)}, {signer}), /too long/);142+ assert.equal(calls, 0);143+});144+145+test("accepts an encrypted author retraction without a conversation p tag", async () => {146+ const rumor = {pubkey: bobPub, created_at: 100, kind: 5, tags: [["e", "e".repeat(64)]], content: ""};147+ rumor.id = getEventHash(rumor);148+ const seal = finalizeEvent({kind: 13, created_at: 100, tags: [], content: nip44.encrypt(JSON.stringify(rumor), nip44.getConversationKey(bob, alicePub))}, bob);149+ const secret = generateSecretKey();150+ const gift = finalizeEvent({kind: 1059, created_at: 100, tags: [["p", alicePub]], content: nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(secret, alicePub))}, secret);151+ assert.deepEqual((await tiny.chat.protocol.decrypt(gift, {signer: nip07Signer(alice)})).rumor, rumor);152+});153+154+test("decrypts independently generated Flotilla/Welshman gift wraps", async () => {155+ const fixture = JSON.parse(fs.readFileSync("scripts/fixtures/flotilla-gift-wraps.json", "utf8"));156+ const signer = nip07Signer(Uint8Array.from(Buffer.from(fixture.recipientSecret, "hex")));157+ const kinds = [];158+ for (const {gift, rumor} of fixture.vectors) {159+ assert.equal(verifyEvent(gift), true);160+ const opened = await tiny.chat.protocol.decrypt(gift, {signer});161+ assert.deepEqual(opened.rumor, rumor);162+ kinds.push(rumor.kind);163+ }164+ assert.deepEqual(kinds, [14, 14, 7, 15, 5]);165+});@@ -0,0 +1,41 @@
1+{2+ "purpose": "Small signed event vectors for NIP-17 and NIP-29 client parity checks.",3+ "sources": {4+ "nip17": "https://github.com/nostr-protocol/nips/blob/master/17.md",5+ "nip29": "https://github.com/nostr-protocol/nips/blob/master/29.md",6+ "zero_x_chat_commit": "76675e7b832212c96c7a3061778bf9ac8971da9b",7+ "flotilla_commit": "19f549102471f0c79d33ce4a729deb0ceb205c33"8+ },9+ "identities": {10+ "alice": "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f",11+ "bob": "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766"12+ },13+ "events": [14+ {15+ "kind": 7,16+ "created_at": 1700000001,17+ "tags": [18+ ["e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "wss://relay.example", "root", "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"],19+ ["p", "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766"],20+ ["k", "14"]21+ ],22+ "content": "+",23+ "pubkey": "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f",24+ "id": "bdba9e9029393e1d82f96c8ad4ee6dc669429cecc769fdb64ecf91acb800f31c",25+ "sig": "a901c80a30549b689672842a734c025eeca91382ce7e67b0f09d5ec60b37fdc16262274f6b9177a56757ee4c7d3aa949404d4573eceafebe69e16a1a26f5967f"26+ },27+ {28+ "kind": 9,29+ "created_at": 1700000002,30+ "tags": [31+ ["h", "general"],32+ ["q", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://relay.example", "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766"],33+ ["p", "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766"]34+ ],35+ "content": "A quoted room reply",36+ "pubkey": "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f",37+ "id": "ad2d909d883d2e6eeecebe6a75cbd243cd40f3fc14b3872068e0fcf3980b162f",38+ "sig": "f5c73a690b68541cbfc4acab94fb3f6b52364c243a315ac819b85612b1819a414b931fa73e58447f5ba72ecac6c26655ac14a30de0e1ee8fac6ad14368601db7"39+ }40+ ]41+}@@ -0,0 +1,191 @@
1+{2+ "source": "https://gitea.coracle.social/coracle/flotilla",3+ "revision": "19f549102471f0c79d33ce4a729deb0ceb205c33",4+ "generator": "@welshman/signer 0.10.6 Nip59.wrap",5+ "recipientSecret": "0101010101010101010101010101010101010101010101010101010101010101",6+ "note": "Public disposable test key; these messages were generated locally, never published.",7+ "vectors": [8+ {9+ "gift": {10+ "kind": 1059,11+ "pubkey": "d9e11f1620f54da35a8ba679ac402dbc8a36ec5638007e605e9f74f452952b46",12+ "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=",13+ "created_at": 1789059779,14+ "tags": [15+ [16+ "p",17+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"18+ ]19+ ],20+ "id": "c6e0bd5bbb7a70e4d7da5789a2e7a57bf20f7b8ecfcb12ae54bb10d50407bf82",21+ "sig": "f9b7e3fb6ea55dbed9f70b915ada17318b8c8f74f17694f057b0b4c24a323edf06e30e09f931118145f6ddaa0958f93724a41634074d279da340e87750a2a88b"22+ },23+ "rumor": {24+ "kind": 14,25+ "content": "Flotilla text with a link https://example.com",26+ "tags": [27+ [28+ "p",29+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"30+ ]31+ ],32+ "created_at": 1700000000,33+ "pubkey": "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766",34+ "id": "c10c8cd36ca311af47e918e39f0864430566b04ee8a1bfc538c512bc51f6ecf4"35+ }36+ },37+ {38+ "gift": {39+ "kind": 1059,40+ "pubkey": "53d82e8a3e5f694b17ccab9d7432a8341d758ed5c5d2a4aac4ea29d5388766a0",41+ "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=",42+ "created_at": 1789090030,43+ "tags": [44+ [45+ "p",46+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"47+ ]48+ ],49+ "id": "41e586499f525516477598cc01868a991218c1aa732a11f9f88152b8f2348728",50+ "sig": "24b54ba493d6e233ecbeb91f0c4080c14031aec79e39cd948643cf8c8f24c3c99865ed8c56dc48a78005431c3e47b1808545f17023ca5542bfcb2e61d9ff9b0a"51+ },52+ "rumor": {53+ "kind": 14,54+ "content": "Flotilla reply",55+ "tags": [56+ [57+ "p",58+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"59+ ],60+ [61+ "e",62+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",63+ "",64+ "reply"65+ ]66+ ],67+ "created_at": 1700000000,68+ "pubkey": "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766",69+ "id": "c3d6dd1b3c8928dcf999ae5f5b64b83edb19244a87df25f18038057d50c871b1"70+ }71+ },72+ {73+ "gift": {74+ "kind": 1059,75+ "pubkey": "a689c1629654c82d4ebc9f7d21facb419e828a26d85d577b7845a68ccec9de1e",76+ "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",77+ "created_at": 1789010884,78+ "tags": [79+ [80+ "p",81+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"82+ ]83+ ],84+ "id": "c4029536987b822d7726e7ecbe471bc984391f9b28eaf220f5b60842a326a1aa",85+ "sig": "f78255a3ca586d881e9b76e82007bc048277ab5fc9e6b05da7a52e2ac9cfacfc868bcc779df06db6b94ada18fe973cfaf502cfabc52cdeb24f339e03912f522a"86+ },87+ "rumor": {88+ "kind": 7,89+ "content": "+",90+ "tags": [91+ [92+ "e",93+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",94+ "",95+ "root",96+ "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766"97+ ],98+ [99+ "p",100+ "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766"101+ ],102+ [103+ "k",104+ "14"105+ ]106+ ],107+ "created_at": 1700000000,108+ "pubkey": "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766",109+ "id": "c4b777da575c7fe4329ca9dd51c3b2d5837e80d2957ed05395c0306477c1993c"110+ }111+ },112+ {113+ "gift": {114+ "kind": 1059,115+ "pubkey": "85b72b715afce7616f41143b2568854db5895db0a4181508e95b1414cb4cb542",116+ "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==",117+ "created_at": 1788997749,118+ "tags": [119+ [120+ "p",121+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"122+ ]123+ ],124+ "id": "e0496a2c8475ffe9bc1dcc012109f341e530bf63971060634e6ffa70026926b8",125+ "sig": "546bf70df810a977d7fd7139b7084448a4b96f6e9337bd24271153f2cd6981e0a35c14516bc4db7c80af2694daab3606f204621c80b750b4eb510eaec872c38f"126+ },127+ "rumor": {128+ "kind": 15,129+ "content": "https://files.example/attachment",130+ "tags": [131+ [132+ "p",133+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"134+ ],135+ [136+ "file-type",137+ "text/plain"138+ ],139+ [140+ "encryption-algorithm",141+ "aes-gcm"142+ ],143+ [144+ "decryption-key",145+ "0303030303030303030303030303030303030303030303030303030303030303"146+ ],147+ [148+ "decryption-nonce",149+ "040404040404040404040404"150+ ],151+ [152+ "x",153+ "0505050505050505050505050505050505050505050505050505050505050505"154+ ]155+ ],156+ "created_at": 1700000000,157+ "pubkey": "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766",158+ "id": "132437503620d7d49c34fdc84579500d26721fc874e302d3d10f0a84399623f0"159+ }160+ },161+ {162+ "gift": {163+ "kind": 1059,164+ "pubkey": "c8f2f4a6faddf1168ac0daeff1ef83fdda6f31cf5812308f6e9c603542d7afd7",165+ "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",166+ "created_at": 1789075782,167+ "tags": [168+ [169+ "p",170+ "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"171+ ]172+ ],173+ "id": "c7deb01de2998c9661701b8f2975e57432cd5d7bd2ddefb18364adc1a0242fe1",174+ "sig": "594e31b9343cfb0c365e5a39619f278db077f4d7c9a428788b8297531747d89837dfc67b729549bcb8ca01a916bc61a2e01a89d5a9799d608c4135c8b415e537"175+ },176+ "rumor": {177+ "kind": 5,178+ "content": "",179+ "tags": [180+ [181+ "e",182+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"183+ ]184+ ],185+ "created_at": 1700000000,186+ "pubkey": "4d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766",187+ "id": "baff9bd04ceeb054107b4b96e609a7432d1fc53153ca113c14cc71b99768639c"188+ }189+ }190+ ]191+}@@ -156,6 +156,16 @@ test("room-compose replies in a thread with the root and its author, and refuses
156156 await assert.rejects(new refused.RoomCompose().submit(refused.form({content: "x"})), /members only/);157157 });158158 159+test("room-compose emits the standard q tag for a quoted event", () => {160+ const id = "4".repeat(64), author = "5".repeat(64);161+ const s = setup({attributes: {room: "build", quote: id, "quote-relay": "wss://relay.example", "quote-pubkey": author}});162+ same(new s.RoomCompose().event("see this").tags, [["h", "build"], ["p", author], ["q", id, "wss://relay.example", author]]);163+ const withoutRelay = setup({attributes: {room: "build", quote: id, "quote-pubkey": author, pubkey: "6".repeat(64)}});164+ same(new withoutRelay.RoomCompose().event("see this").tags, [["h", "build"], ["p", author], ["q", id, "", author]]);165+ const invalid = setup({attributes: {room: "build", quote: "not-an-event"}});166+ assert.throws(() => new invalid.RoomCompose().event("x"), /quoted event/);167+});168+159169 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});160170 const descriptorFor = (file, bytes) => {161171 const sha256 = createHash("sha256").update(bytes).digest("hex");