| 1 | diff --git a/docs/agents.md b/docs/agents.md |
| 2 | index 6d5cabd..0342d70 100644 |
| 3 | --- a/docs/agents.md |
| 4 | +++ b/docs/agents.md |
| 5 | @@ -219,7 +219,7 @@ A result, or feedback that reports `error` or `payment-required`, wakes the requ |
| 6 | |
| 7 | ## Agent chat features |
| 8 | |
| 9 | -Agents can participate in room conversations using the same Nostr events as people. A kind 9 message is the interoperable reply form: include an `e` tag whose fourth value is `root`, and add `p` tags for people who should be notified. Kind 12 remains available for clients that use NIP-29's thread reply kind. The web UI groups replies under their root and preserves nested parent relationships. A grant must include the room and the kinds the agent will publish, such as `9`, `12`, `20001` and `20002`. |
| 10 | +Agents can participate in room conversations using the same Nostr events as people. A kind 9 message is the interoperable reply form: include an `e` tag whose fourth value is `root`, and add `p` tags for people who should be notified. Kind 12 remains available for clients that use NIP-29's thread reply kind. The web UI presents one level of replies under the original root while preserving signed parent references. Use the original root for follow-ups. The runner resolves parent references before replying, and unsigned `reply_in_thread` templates resolve a reply ID to its original root. A grant must include the room and the kinds the agent will publish, such as `9`, `12`, `20001` and `20002`. |
| 11 | |
| 12 | An agent may publish ephemeral kind 20001 presence and kind 20002 typing events when its grant includes those kinds. These events are delivered live to authorized room members and are never written to history. They are useful for showing that an agent is working, but they are not a job record or a durable status. See Buzz's [Nostr event conventions](https://github.com/block/buzz/blob/main/NOSTR.md) for a compatible client reference. |
| 13 | |
| 14 | diff --git a/docs/mcp.md b/docs/mcp.md |
| 15 | index 1521ed2..24b9390 100644 |
| 16 | --- a/docs/mcp.md |
| 17 | +++ b/docs/mcp.md |
| 18 | @@ -109,7 +109,7 @@ Write tools, which publish through the same path as `POST /events`: |
| 19 | | `set_status` | Mark an issue or pull request open, resolved, merged, closed or draft with a kind 1630 to 1633 event. | |
| 20 | | `post_message` | Post a kind 9 message in a room, with optional `mentions` and `attachments`. | |
| 21 | | `start_thread` | Start a kind 11 thread in a room with an optional title and `attachments`. | |
| 22 | -| `reply_in_thread` | Reply to a thread with a kind 12 event naming the root in its `e` tag and optional `attachments`. | |
| 23 | +| `reply_in_thread` | Reply with a kind 12 event and optional `attachments`. Unsigned templates resolve a reply ID to the original thread root. | |
| 24 | | `react` | Publish a kind 7 reaction to an event: `+`, `-` or one emoji, with `room` for a room message. | |
| 25 | | `publish_wiki_page` | Publish or replace the key's version of a kind 30818 wiki page, with `fork_author` and `fork_event` to record a fork. | |
| 26 | | `propose_wiki_merge` | Ask a page's author to take in a version with a kind 818 merge request. | |
| 27 | @@ -131,7 +131,7 @@ An agent whose grant holds `propose` on a repository publishes issues, pull requ |
| 28 | |
| 29 | ### Asking a person |
| 30 | |
| 31 | -`request_decision` builds an event addressed to one person. Pass `pubkey`, `request` (`approve`, `decide` or `question`) and `content`, then either `room` for a kind 9 message in that room or `root`, `root_kind` and `root_pubkey` for a kind 1111 comment under an issue, pull request or other event. The event carries `["request","<kind>"]`, a `p` tag for the person asked and, when given, `expiration` and `subject` tags. |
| 32 | +`request_decision` builds an event addressed to one person. Pass `pubkey`, `request` (`approve`, `decide` or `question`) and `content`, then either `room` with an optional `root` for a kind 9 message in that room or `root`, `root_kind` and `root_pubkey` without `room` for a kind 1111 comment under an issue, pull request or other event. A room request with `root` stays in that thread; the unsigned template resolves reply IDs to the original root. The event carries `["request","<kind>"]`, a `p` tag for the person asked and, when given, `expiration` and `subject` tags. |
| 33 | |
| 34 | The person answers with a kind 7 reaction to the published event from the asked key: `+` approves and `-` declines. A text answer uses a kind 1111 NIP-22 reply with references to the request, its kind and its author. Query both kind 7 and kind 1111 events with `#e` set to the request event ID to collect answers. Only answers from the asked key count. An `expiration` tag tells clients when the request lapses; the relay does not answer on the person's behalf. |
| 35 | |
| 36 | diff --git a/docs/rooms.md b/docs/rooms.md |
| 37 | index b3e31ec..062923d 100644 |
| 38 | --- a/docs/rooms.md |
| 39 | +++ b/docs/rooms.md |
| 40 | @@ -65,15 +65,15 @@ One-to-one messages require a connected signer with NIP-44 support. The message |
| 41 | |
| 42 | Open a room to read its messages, oldest first. Each message shows the author's profile picture when available, or a deterministic Seedmark avatar, along with their name, room role and time. Room lists and headers use the room picture when one is set, with a stable generated avatar otherwise. Agent messages identify the operator when the grant names one, using the form `<operator>'s agent`; an agent without an operator is labeled `agent`. Avatars are decorative; use the displayed public key when identity matters. |
| 43 | |
| 44 | -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. |
| 45 | +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. Room pages count visible messages, so a burst of replies stays folded under its conversation without leaving the timeline empty. Reply totals include the whole thread, including replies outside the current page. |
| 46 | |
| 47 | -For broad client compatibility, replies can use a kind 9 message with NIP-10 `e` tags: the root reference has marker `root` in the fourth tag value, and a nested reply can also identify its immediate parent. Kind 12 is accepted for clients that use the NIP-29 thread reply convention. Both forms are grouped into the same thread by the web UI. |
| 48 | +For broad client compatibility, replies can use a kind 9 message with NIP-10 `e` tags: the root reference has marker `root` in the fourth tag value, and a nested reply can also identify its immediate parent. Kind 12 is accepted for clients that use the NIP-29 thread reply convention. Both forms appear at one level under the original root in the web UI. A reply to another reply stays in that same thread; existing signed parent references remain intact. |
| 49 | |
| 50 | Choose **Attach files**, paste files or drop them into the compose bar. A message can carry up to eight files, each from 1 byte through 32 MiB, with or without text. Files upload when you send. If sending fails, retrying reuses completed uploads. Relay quotas and agent grants also apply. |
| 51 | |
| 52 | Messages with NIP-92 `imeta` attachment tags and matching content references display images, video and audio inline, with download links for every file. Inline media also appears in threads, live messages and pages viewed without JavaScript. An edit replaces the message's attachment metadata along with its text. |
| 53 | |
| 54 | -Presence and typing are optional. A client publishes kind 20001 with an `h` tag and a short status such as `online`, `away` or `offline`; kind 20002 reports that typing started or stopped. Both are temporary live events and are not stored in room history. The web UI shares them only after you enable **Share presence and typing** on your Account page. This preference is saved for your account on the relay and applies across your browsers. Open Account by selecting your name in the footer; your relay lists and profile link are there too. Clients should treat them as hints that expire rather than as durable member state. |
| 55 | +Presence and typing are optional. A client publishes kind 20001 with an `h` tag and a short status such as `online`, `away` or `offline`; kind 20002 reports that typing started or stopped. Both are temporary live events and are not stored in room history. The web UI shares them only after you enable **Share presence and typing** on your Account page. This preference is saved for your account on the relay and applies across your browsers. Open Account by selecting your name in the footer; your relay lists and profile link are there too. Clients should treat them as hints that expire rather than as durable member state. Background cleanup failures do not require reconnecting, and a recovered signer clears a temporary presence warning automatically. |
| 56 | |
| 57 | Room uploads follow the room's current access rule. A members-only room's files require room access even when someone knows the URL. Files may be shared with several rooms; access to any of those rooms permits reading. Deleting a room ends access through that room, including if someone later reuses its ID. Room attachments use access controls and are not end-to-end encrypted. |
| 58 | |
| 59 | diff --git a/internal/agentrunner/relay.go b/internal/agentrunner/relay.go |
| 60 | index c36d7c1..aff2b80 100644 |
| 61 | --- a/internal/agentrunner/relay.go |
| 62 | +++ b/internal/agentrunner/relay.go |
| 63 | @@ -25,6 +25,12 @@ type RelayClient struct { |
| 64 | conn *websocket.Conn |
| 65 | connection context.Context |
| 66 | pending map[string]chan error |
| 67 | + queries map[string]chan queryResult |
| 68 | +} |
| 69 | + |
| 70 | +type queryResult struct { |
| 71 | + event event.Event |
| 72 | + err error |
| 73 | } |
| 74 | |
| 75 | func (c *RelayClient) Run(ctx context.Context) error { |
| 76 | @@ -69,6 +75,7 @@ func (c *RelayClient) connect(ctx context.Context) error { |
| 77 | c.conn = conn |
| 78 | c.connection = scope |
| 79 | c.pending = map[string]chan error{} |
| 80 | + c.queries = make(map[string]chan queryResult) |
| 81 | c.mu.Unlock() |
| 82 | defer func() { |
| 83 | c.mu.Lock() |
| 84 | @@ -144,7 +151,21 @@ func (c *RelayClient) connect(ctx context.Context) error { |
| 85 | } |
| 86 | } |
| 87 | case "EOSE": |
| 88 | - if c.OnReady != nil { |
| 89 | + var subscription string |
| 90 | + if len(frame) > 1 { |
| 91 | + _ = json.Unmarshal(frame[1], &subscription) |
| 92 | + c.mu.Lock() |
| 93 | + query := c.queries[subscription] |
| 94 | + c.mu.Unlock() |
| 95 | + if query != nil { |
| 96 | + select { |
| 97 | + case query <- queryResult{err: errors.New("thread parent not found")}: |
| 98 | + default: |
| 99 | + } |
| 100 | + continue |
| 101 | + } |
| 102 | + } |
| 103 | + if subscription == "tiny-agent" && c.OnReady != nil { |
| 104 | c.OnReady() |
| 105 | } |
| 106 | case "EVENT": |
| 107 | @@ -155,10 +176,41 @@ func (c *RelayClient) connect(ctx context.Context) error { |
| 108 | if err != nil { |
| 109 | continue |
| 110 | } |
| 111 | - if c.OnEvent != nil { |
| 112 | + c.mu.Lock() |
| 113 | + var subscription string |
| 114 | + _ = json.Unmarshal(frame[1], &subscription) |
| 115 | + query := c.queries[subscription] |
| 116 | + c.mu.Unlock() |
| 117 | + if query != nil { |
| 118 | + select { |
| 119 | + case query <- queryResult{event: e}: |
| 120 | + default: |
| 121 | + } |
| 122 | + continue |
| 123 | + } |
| 124 | + if subscription == "tiny-agent" && c.OnEvent != nil { |
| 125 | c.OnEvent(e) |
| 126 | } |
| 127 | case "CLOSED": |
| 128 | + var subscription string |
| 129 | + _ = json.Unmarshal(frame[1], &subscription) |
| 130 | + c.mu.Lock() |
| 131 | + query := c.queries[subscription] |
| 132 | + c.mu.Unlock() |
| 133 | + if query != nil { |
| 134 | + reason := "agent query closed" |
| 135 | + if len(frame) > 2 { |
| 136 | + _ = json.Unmarshal(frame[2], &reason) |
| 137 | + } |
| 138 | + select { |
| 139 | + case query <- queryResult{err: errors.New(reason)}: |
| 140 | + default: |
| 141 | + } |
| 142 | + continue |
| 143 | + } |
| 144 | + if subscription != "tiny-agent" { |
| 145 | + continue |
| 146 | + } |
| 147 | if len(frame) > 2 { |
| 148 | var reason string |
| 149 | json.Unmarshal(frame[2], &reason) |
| 150 | @@ -169,6 +221,77 @@ func (c *RelayClient) connect(ctx context.Context) error { |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | + |
| 155 | +func (c *RelayClient) ResolveRoot(ctx context.Context, room, id string) (string, error) { |
| 156 | + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) |
| 157 | + defer cancel() |
| 158 | + seen := map[string]bool{} |
| 159 | + for len(seen) < 64 && !seen[id] { |
| 160 | + seen[id] = true |
| 161 | + e, err := c.queryEvent(ctx, room, id) |
| 162 | + if err != nil { |
| 163 | + return "", err |
| 164 | + } |
| 165 | + parent := event.RoomReplyRoot(e) |
| 166 | + if parent == "" { |
| 167 | + return e.ID, nil |
| 168 | + } |
| 169 | + id = parent |
| 170 | + } |
| 171 | + return "", errors.New("thread nesting limit exceeded") |
| 172 | +} |
| 173 | + |
| 174 | +func (c *RelayClient) queryEvent(ctx context.Context, room, id string) (event.Event, error) { |
| 175 | + c.mu.Lock() |
| 176 | + conn := c.conn |
| 177 | + scope := c.connection |
| 178 | + if conn == nil { |
| 179 | + c.mu.Unlock() |
| 180 | + return event.Event{}, errors.New("agent relay is disconnected") |
| 181 | + } |
| 182 | + sub := fmt.Sprintf("root-%d", time.Now().UnixNano()) |
| 183 | + wait := make(chan queryResult, 1) |
| 184 | + c.queries[sub] = wait |
| 185 | + c.mu.Unlock() |
| 186 | + defer func() { |
| 187 | + c.mu.Lock() |
| 188 | + delete(c.queries, sub) |
| 189 | + active := c.conn == conn && c.connection == scope |
| 190 | + c.mu.Unlock() |
| 191 | + if active { |
| 192 | + closeCtx, cancel := context.WithTimeout(context.Background(), time.Second) |
| 193 | + defer cancel() |
| 194 | + _ = writeRelay(closeCtx, conn, []any{"CLOSE", sub}) |
| 195 | + } |
| 196 | + }() |
| 197 | + if err := writeRelay(ctx, conn, []any{"REQ", sub, map[string]any{"ids": []string{id}, "#h": []string{room}, "limit": 1}}); err != nil { |
| 198 | + return event.Event{}, err |
| 199 | + } |
| 200 | + select { |
| 201 | + case result, ok := <-wait: |
| 202 | + if !ok { |
| 203 | + return event.Event{}, errors.New("thread parent not found") |
| 204 | + } |
| 205 | + if result.err != nil { |
| 206 | + return event.Event{}, result.err |
| 207 | + } |
| 208 | + e := result.event |
| 209 | + if e.ID != id { |
| 210 | + return event.Event{}, errors.New("thread parent ID mismatch") |
| 211 | + } |
| 212 | + if event.Tag(e, "h") != room { |
| 213 | + return event.Event{}, errors.New("thread parent is in another room") |
| 214 | + } |
| 215 | + if e.Kind != 9 && e.Kind != 11 && e.Kind != 12 && e.Kind != 40002 { |
| 216 | + return event.Event{}, errors.New("thread parent has an invalid kind") |
| 217 | + } |
| 218 | + return e, nil |
| 219 | + case <-ctx.Done(): |
| 220 | + return event.Event{}, ctx.Err() |
| 221 | + case <-scope.Done(): |
| 222 | + return event.Event{}, errors.New("agent relay disconnected") |
| 223 | + } |
| 224 | +} |
| 225 | func (c *RelayClient) subscribe(ctx context.Context, conn *websocket.Conn) error { |
| 226 | since := time.Now().Add(-5 * time.Minute).Unix() |
| 227 | return writeRelay(ctx, conn, []any{"REQ", "tiny-agent", map[string]any{"kinds": []int{9, 11, 12}, "#h": c.Rooms, "#p": []string{c.PubKey}, "since": since}, map[string]any{"kinds": []int{7, 1111}, "#p": []string{c.PubKey}, "since": since}}) |
| 228 | diff --git a/internal/agentrunner/relay_test.go b/internal/agentrunner/relay_test.go |
| 229 | new file mode 100644 |
| 230 | index 0000000..a3b25b2 |
| 231 | --- /dev/null |
| 232 | +++ b/internal/agentrunner/relay_test.go |
| 233 | @@ -0,0 +1,224 @@ |
| 234 | +package agentrunner |
| 235 | + |
| 236 | +import ( |
| 237 | + "context" |
| 238 | + "encoding/json" |
| 239 | + "errors" |
| 240 | + "net/http" |
| 241 | + "net/http/httptest" |
| 242 | + "strings" |
| 243 | + "sync" |
| 244 | + "testing" |
| 245 | + "time" |
| 246 | + |
| 247 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 248 | + "github.com/coder/websocket" |
| 249 | +) |
| 250 | + |
| 251 | +const relayTestSecret = "0000000000000000000000000000000000000000000000000000000000000001" |
| 252 | + |
| 253 | +func TestResolveRootQueriesUnseenParentsWithoutDispatchingThem(t *testing.T) { |
| 254 | + room := "room" |
| 255 | + root := relayTestEvent(t, 11, room, "") |
| 256 | + middle := relayTestEvent(t, 12, room, root.ID) |
| 257 | + leaf := relayTestEvent(t, 12, room, middle.ID) |
| 258 | + events := map[string]event.Event{root.ID: root, middle.ID: middle, leaf.ID: leaf} |
| 259 | + var mu sync.Mutex |
| 260 | + var closed []string |
| 261 | + ready := make(chan struct{}, 1) |
| 262 | + server := relayTestServer(t, events, &mu, &closed, ready) |
| 263 | + defer server.Close() |
| 264 | + |
| 265 | + client := RelayClient{URL: "ws" + strings.TrimPrefix(server.URL, "http"), Secret: relayTestSecret, Rooms: []string{room}} |
| 266 | + var dispatched int |
| 267 | + client.OnEvent = func(event.Event) { dispatched++ } |
| 268 | + ctx, cancel := context.WithCancel(context.Background()) |
| 269 | + defer cancel() |
| 270 | + done := make(chan error, 1) |
| 271 | + go func() { done <- client.Run(ctx) }() |
| 272 | + select { |
| 273 | + case <-ready: |
| 274 | + case <-time.After(time.Second): |
| 275 | + t.Fatal("relay did not finish its initial subscription") |
| 276 | + } |
| 277 | + |
| 278 | + queryCtx, queryCancel := context.WithTimeout(context.Background(), time.Second) |
| 279 | + defer queryCancel() |
| 280 | + got, err := client.ResolveRoot(queryCtx, room, leaf.ID) |
| 281 | + if err != nil { |
| 282 | + t.Fatal(err) |
| 283 | + } |
| 284 | + if got != root.ID { |
| 285 | + t.Fatalf("root=%s, want %s", got, root.ID) |
| 286 | + } |
| 287 | + if dispatched != 0 { |
| 288 | + t.Fatalf("query events dispatched to agent callback: %d", dispatched) |
| 289 | + } |
| 290 | + mu.Lock() |
| 291 | + closeCount := len(closed) |
| 292 | + mu.Unlock() |
| 293 | + deadline := time.Now().Add(time.Second) |
| 294 | + for closeCount < 3 && time.Now().Before(deadline) { |
| 295 | + time.Sleep(time.Millisecond) |
| 296 | + mu.Lock() |
| 297 | + closeCount = len(closed) |
| 298 | + mu.Unlock() |
| 299 | + } |
| 300 | + if closeCount != 3 { |
| 301 | + t.Fatalf("CLOSE frames=%d, want 3", closeCount) |
| 302 | + } |
| 303 | + cancel() |
| 304 | + select { |
| 305 | + case <-done: |
| 306 | + case <-time.After(time.Second): |
| 307 | + t.Fatal("relay did not stop") |
| 308 | + } |
| 309 | +} |
| 310 | + |
| 311 | +func TestResolveRootRejectsInvalidQueriedEvents(t *testing.T) { |
| 312 | + tests := []struct { |
| 313 | + name string |
| 314 | + make func(t *testing.T) (string, event.Event) |
| 315 | + want string |
| 316 | + }{ |
| 317 | + {"wrong room", func(t *testing.T) (string, event.Event) { |
| 318 | + e := relayTestEvent(t, 11, "other", "") |
| 319 | + return "room", e |
| 320 | + }, "another room"}, |
| 321 | + {"invalid kind", func(t *testing.T) (string, event.Event) { |
| 322 | + e := relayTestEvent(t, 1, "room", "") |
| 323 | + return "room", e |
| 324 | + }, "invalid kind"}, |
| 325 | + } |
| 326 | + for _, tt := range tests { |
| 327 | + t.Run(tt.name, func(t *testing.T) { |
| 328 | + room, e := tt.make(t) |
| 329 | + server := relayTestServer(t, map[string]event.Event{e.ID: e}, nil, nil, make(chan struct{}, 1)) |
| 330 | + defer server.Close() |
| 331 | + client, stop := runningTestRelay(t, server, room) |
| 332 | + defer stop() |
| 333 | + ctx, cancel := context.WithTimeout(context.Background(), time.Second) |
| 334 | + defer cancel() |
| 335 | + _, err := client.ResolveRoot(ctx, room, e.ID) |
| 336 | + if err == nil || !strings.Contains(err.Error(), tt.want) { |
| 337 | + t.Fatalf("error=%v, want substring %q", err, tt.want) |
| 338 | + } |
| 339 | + }) |
| 340 | + } |
| 341 | +} |
| 342 | + |
| 343 | +func TestResolveRootTimesOutAndClosesQuery(t *testing.T) { |
| 344 | + root := relayTestEvent(t, 11, "room", "") |
| 345 | + server := relayTestServer(t, map[string]event.Event{}, nil, nil, make(chan struct{}, 1)) |
| 346 | + defer server.Close() |
| 347 | + client, stop := runningTestRelay(t, server, "room") |
| 348 | + defer stop() |
| 349 | + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) |
| 350 | + defer cancel() |
| 351 | + _, err := client.ResolveRoot(ctx, "room", root.ID) |
| 352 | + if !errors.Is(err, context.DeadlineExceeded) { |
| 353 | + t.Fatalf("error=%v, want deadline exceeded", err) |
| 354 | + } |
| 355 | +} |
| 356 | + |
| 357 | +func relayTestEvent(t *testing.T, kind int, room, parent string) event.Event { |
| 358 | + t.Helper() |
| 359 | + tags := [][]string{{"h", room}} |
| 360 | + if parent != "" { |
| 361 | + tags = append(tags, []string{"e", parent, "", "root"}) |
| 362 | + } |
| 363 | + e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: "test"} |
| 364 | + if err := event.Sign(&e, relayTestSecret); err != nil { |
| 365 | + t.Fatal(err) |
| 366 | + } |
| 367 | + return e |
| 368 | +} |
| 369 | + |
| 370 | +func runningTestRelay(t *testing.T, server *httptest.Server, room string) (*RelayClient, func()) { |
| 371 | + t.Helper() |
| 372 | + client := &RelayClient{URL: "ws" + strings.TrimPrefix(server.URL, "http"), Secret: relayTestSecret, Rooms: []string{room}} |
| 373 | + ctx, cancel := context.WithCancel(context.Background()) |
| 374 | + done := make(chan error, 1) |
| 375 | + go func() { done <- client.Run(ctx) }() |
| 376 | + deadline := time.Now().Add(time.Second) |
| 377 | + for { |
| 378 | + client.mu.Lock() |
| 379 | + connected := client.conn != nil |
| 380 | + client.mu.Unlock() |
| 381 | + if connected { |
| 382 | + break |
| 383 | + } |
| 384 | + if time.Now().After(deadline) { |
| 385 | + t.Fatal("relay did not connect") |
| 386 | + } |
| 387 | + time.Sleep(time.Millisecond) |
| 388 | + } |
| 389 | + return client, func() { |
| 390 | + cancel() |
| 391 | + select { |
| 392 | + case <-done: |
| 393 | + case <-time.After(time.Second): |
| 394 | + t.Fatal("relay did not stop") |
| 395 | + } |
| 396 | + } |
| 397 | +} |
| 398 | + |
| 399 | +func relayTestServer(t *testing.T, events map[string]event.Event, mu *sync.Mutex, closed *[]string, ready chan struct{}) *httptest.Server { |
| 400 | + t.Helper() |
| 401 | + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 402 | + conn, err := websocket.Accept(w, r, nil) |
| 403 | + if err != nil { |
| 404 | + t.Error(err) |
| 405 | + return |
| 406 | + } |
| 407 | + defer conn.CloseNow() |
| 408 | + for { |
| 409 | + _, raw, err := conn.Read(r.Context()) |
| 410 | + if err != nil { |
| 411 | + return |
| 412 | + } |
| 413 | + var frame []json.RawMessage |
| 414 | + if json.Unmarshal(raw, &frame) != nil || len(frame) < 2 { |
| 415 | + continue |
| 416 | + } |
| 417 | + var kind, sub string |
| 418 | + _ = json.Unmarshal(frame[0], &kind) |
| 419 | + _ = json.Unmarshal(frame[1], &sub) |
| 420 | + switch kind { |
| 421 | + case "REQ": |
| 422 | + if sub == "tiny-agent" { |
| 423 | + _ = conn.Write(r.Context(), websocket.MessageText, []byte(`["EOSE","tiny-agent"]`)) |
| 424 | + select { |
| 425 | + case ready <- struct{}{}: |
| 426 | + default: |
| 427 | + } |
| 428 | + continue |
| 429 | + } |
| 430 | + var filter map[string]any |
| 431 | + if len(frame) > 2 { |
| 432 | + _ = json.Unmarshal(frame[2], &filter) |
| 433 | + } |
| 434 | + var id string |
| 435 | + if value, ok := filter["ids"].([]any); ok && len(value) > 0 { |
| 436 | + id, _ = value[0].(string) |
| 437 | + } |
| 438 | + if e, ok := events[id]; ok { |
| 439 | + payload, _ := json.Marshal([]any{"EVENT", sub, e}) |
| 440 | + _ = conn.Write(r.Context(), websocket.MessageText, payload) |
| 441 | + } |
| 442 | + if _, found := events[id]; found { |
| 443 | + payload, _ := json.Marshal([]any{"EOSE", sub}) |
| 444 | + _ = conn.Write(r.Context(), websocket.MessageText, payload) |
| 445 | + } |
| 446 | + case "CLOSE": |
| 447 | + payload, _ := json.Marshal([]any{"CLOSED", sub, "subscription closed"}) |
| 448 | + _ = conn.Write(r.Context(), websocket.MessageText, payload) |
| 449 | + if mu != nil && closed != nil { |
| 450 | + mu.Lock() |
| 451 | + *closed = append(*closed, sub) |
| 452 | + mu.Unlock() |
| 453 | + } |
| 454 | + } |
| 455 | + } |
| 456 | + })) |
| 457 | +} |
| 458 | diff --git a/internal/agentrunner/service.go b/internal/agentrunner/service.go |
| 459 | index 5252fa0..20f37d1 100644 |
| 460 | --- a/internal/agentrunner/service.go |
| 461 | +++ b/internal/agentrunner/service.go |
| 462 | @@ -83,6 +83,14 @@ func (s *service) handle(parent context.Context, batch []Mention) error { |
| 463 | ctx, cancel := context.WithTimeout(parent, s.options.Timeout) |
| 464 | defer cancel() |
| 465 | first := batch[0] |
| 466 | + root, err := s.relay.ResolveRoot(ctx, first.Room, first.Root) |
| 467 | + if err != nil { |
| 468 | + return err |
| 469 | + } |
| 470 | + first.Root = root |
| 471 | + // Keep the canonical root on the batch so permission requests emitted |
| 472 | + // while this task runs stay attached to the same top-level conversation. |
| 473 | + batch[0].Root = root |
| 474 | tags := [][]string{{"h", first.Room}, {"e", first.Root}, {"p", s.relay.PubKey}, {"output", "text/plain"}, {"subject", shortText(first.Prompt, 100)}, {"expiration", strconv.FormatInt(time.Now().Add(s.options.Timeout).Unix(), 10)}} |
| 475 | var prompt strings.Builder |
| 476 | prompt.WriteString("These are messages addressed to you in a Nostr chat. Reply to the users' request. Room: " + first.Room + "\n\n") |
| 477 | diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go |
| 478 | index 48222e6..dc1e51c 100644 |
| 479 | --- a/internal/daemon/mcp_http.go |
| 480 | +++ b/internal/daemon/mcp_http.go |
| 481 | @@ -109,7 +109,7 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) { |
| 482 | "", |
| 483 | "## Chat and Social", |
| 484 | "", |
| 485 | - "- Chat: "+base+"/chat combines NIP-29 group rooms and NIP-17 one-to-one messages. Existing /rooms URLs and room MCP tool names remain supported. Group messages use kind 9; kind 11 threads and kind 12 replies remain supported. Kind 9 replies use NIP-10 root and reply markers. Room attachments use NIP-92 metadata. The rooms guide documents 0xchat, Flotilla and Buzz compatibility boundaries.", |
| 486 | + "- Chat: "+base+"/chat combines NIP-29 group rooms and NIP-17 one-to-one messages. Existing /rooms URLs and room MCP tool names remain supported. Group messages use kind 9; kind 11 threads and kind 12 replies remain supported. Kind 9 replies use NIP-10 root and reply markers. The web UI shows one level of replies under the original root; follow-ups stay in that thread. Unsigned reply_in_thread templates resolve reply IDs to the original root. Room attachments use NIP-92 metadata. The rooms guide documents 0xchat, Flotilla and Buzz compatibility boundaries.", |
| 487 | "- Direct messages use NIP-44 encryption and NIP-59 gift wraps with kind 10050 inbox relay lists. Decryption and recipient delivery happen in the client; room MCP tools do not read or send decrypted direct messages.", |
| 488 | "- Presence (kind 20001) and typing (kind 20002) are ephemeral. People opt in through "+base+"/account, which also holds personal relay lists and a profile link. Account preferences are separate from relay management. Agent labels identify the grant operator.", |
| 489 | "- Social: "+base+"/social is one chronological feed of kind 1 notes and kind 30023 articles. Note replies use NIP-10; article comments use NIP-22 kind 1111; reactions use NIP-25 kind 7, including NIP-30 emoji. Articles render Markdown; notes and comments keep plain text with links and media embeds. Media uses NIP-92. Use publish_event for signed Social events; the Social guide covers formats and Jumble/Primal interoperability.", |
| 490 | diff --git a/internal/daemon/mcp_test.go b/internal/daemon/mcp_test.go |
| 491 | index 6e2e5eb..2904b7e 100644 |
| 492 | --- a/internal/daemon/mcp_test.go |
| 493 | +++ b/internal/daemon/mcp_test.go |
| 494 | @@ -454,6 +454,18 @@ func TestMCPRoomWikiAndAgentTools(t *testing.T) { |
| 495 | if isError || len(replies) != 1 || structured(result)["root"].(map[string]any)["id"] != messageID { |
| 496 | t.Fatalf("read_thread: %s", text(result)) |
| 497 | } |
| 498 | + nestedReply := mcpSigned(t, testOwnerSecret, 12, [][]string{{"h", "general"}, {"e", messageID}, {"p", member}}, "follow-up") |
| 499 | + nestedResult, nestedError := call("reply_in_thread", map[string]any{"event": nestedReply}, testOwnerSecret) |
| 500 | + nestedID, _ := structured(nestedResult)["event_id"].(string) |
| 501 | + if nestedError || nestedID == "" { |
| 502 | + t.Fatalf("nested reply publish: %s", text(nestedResult)) |
| 503 | + } |
| 504 | + result, isError = call("reply_in_thread", map[string]any{"room": "general", "root": nestedID, "root_pubkey": member, "content": "canonical follow-up"}, "") |
| 505 | + unsigned, _ = structured(result)["unsigned"].(map[string]any) |
| 506 | + tags, _ = json.Marshal(unsigned["tags"]) |
| 507 | + if isError || !strings.Contains(string(tags), `"e","`+messageID+`"`) || strings.Contains(string(tags), nestedID) { |
| 508 | + t.Fatalf("reply_in_thread did not flatten nested root: %s", text(result)) |
| 509 | + } |
| 510 | |
| 511 | // react: content is +, - or one emoji, whether templated or signed. |
| 512 | result, isError = call("react", map[string]any{"target": messageID, "target_pubkey": member, "content": "great", "room": "general"}, "") |
| 513 | @@ -560,6 +572,12 @@ func TestMCPRoomWikiAndAgentTools(t *testing.T) { |
| 514 | if isError || unsigned["kind"] != float64(9) || string(tags) != `[["h","general"],["request","approve"],["p","`+member+`"],["expiration","`+strconv.FormatInt(now+3600, 10)+`"],["subject","Release"]]` { |
| 515 | t.Fatalf("request_decision room template: %s", text(result)) |
| 516 | } |
| 517 | + result, isError = call("request_decision", map[string]any{"pubkey": member, "request": "approve", "content": "Approve this thread?", "room": "general", "root": messageID}, "") |
| 518 | + unsigned, _ = structured(result)["unsigned"].(map[string]any) |
| 519 | + tags, _ = json.Marshal(unsigned["tags"]) |
| 520 | + if isError || unsigned["kind"] != float64(9) || !strings.Contains(string(tags), `"e","`+messageID+`","","root"`) { |
| 521 | + t.Fatalf("request_decision room thread template: %s", text(result)) |
| 522 | + } |
| 523 | result, isError = call("request_decision", map[string]any{"pubkey": member, "request": "question", "content": "Which name?", "root": pageID, "root_kind": 30818, "root_pubkey": owner}, "") |
| 524 | unsigned, _ = structured(result)["unsigned"].(map[string]any) |
| 525 | tags, _ = json.Marshal(unsigned["tags"]) |
| 526 | diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go |
| 527 | index 74fa753..85d0378 100644 |
| 528 | --- a/internal/daemon/mcp_tools.go |
| 529 | +++ b/internal/daemon/mcp_tools.go |
| 530 | @@ -70,7 +70,7 @@ const ( |
| 531 | mcpWikiShape = `Expected a signed kind 30818 event with tags ["d","<normalized page name>"], ["title","<title>"], optional ["summary","<summary>"] and, for a fork, ["a","30818:<author>:<page name>","","fork"] and ["e","<version id>","","fork"], with the Djot article in content.` |
| 532 | mcpMergeShape = `Expected a signed kind 818 event with tags ["a","30818:<destination>:<page name>"], ["p","<destination>"], ["e","<proposed version id>","","source"] and optional ["e","<base version id>"], with the explanation in content.` |
| 533 | mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].` |
| 534 | - mcpRequestShape = `Expected a signed kind 9 event with ["h","<room id>"], or a signed kind 1111 event with NIP-22 tags ["E","<root id>","","<root pubkey>"], ["K","<root kind>"], ["P","<root pubkey>"], ["e","<root id>","","<root pubkey>"] and ["k","<root kind>"], carrying ["request","approve", "decide" or "question"], ["p","<asked pubkey>"] and optional ["expiration","<unix time>"] and ["subject","<subject>"], with the question in content.` |
| 535 | + mcpRequestShape = `Expected a signed kind 9 event with ["h","<room id>"] and optional ["e","<thread root id>","","root"], or a signed kind 1111 event with NIP-22 tags ["E","<root id>","","<root pubkey>"], ["K","<root kind>"], ["P","<root pubkey>"], ["e","<root id>","","<root pubkey>"] and ["k","<root kind>"], carrying ["request","approve", "decide" or "question"], ["p","<asked pubkey>"] and optional ["expiration","<unix time>"] and ["subject","<subject>"], with the question in content.` |
| 536 | mcpJobRequestShape = `Expected a signed event of kind 5000 to 5127 or 5129 to 5999 with optional ["h","<room id>"], ["i","<data>","url" or "event" or "job" or "text","<relay>","<marker>"], ["output","<mime type>"], ["param","<key>","<value>"], ["bid","<millisats>"], ["relays","wss://..."], ["p","<provider pubkey>"] and ["expiration","<unix time>"], with content empty or the encrypted inputs.` |
| 537 | mcpJobFeedbackShape = `Expected a signed kind 7000 event with tags ["status","payment-required" or "processing" or "error" or "success" or "partial","<info>"], ["e","<request id>"], ["p","<requester pubkey>"], optional ["h","<room id>"] and ["amount","<millisats>","<bolt11>"], with content empty or a partial result.` |
| 538 | mcpJobResultShape = `Expected a signed event of the request kind plus 1000 (6000 to 6999) with tags ["e","<request id>"], ["p","<requester pubkey>"], optional ["h","<room id>"], ["request","<request event JSON>"], the request's ["i",...] tags and optional ["amount","<millisats>","<bolt11>"], with the output in content.` |
| 539 | @@ -223,13 +223,13 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) { |
| 540 | attachments := map[string]any{"type": "array", "items": mcpAttachment, "maxItems": 8} |
| 541 | add("post_message", "Post a kind 9 chat message in a room. Pass room and content or attachments, plus optional mentions, to receive an unsigned event. Sign it, then call again with event. Posting in an open room joins it.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "content": mcpText, "mentions": map[string]any{"type": "array", "items": mcpPubKey}, "attachments": attachments}), mcpPublishes, t.mcpWrite(mcpBuildMessage, mcpCheckMessage, mcpMessageShape)) |
| 542 | add("start_thread", "Start a kind 11 thread in a room. Pass room and content or attachments, plus an optional title, to receive an unsigned event. Sign it, then call again with event.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "title": mcpText, "content": mcpText, "attachments": attachments}), mcpPublishes, t.mcpWrite(mcpBuildThread, mcpCheckThread, mcpThreadShape)) |
| 543 | - add("reply_in_thread", "Reply with a kind 12 event. Pass room, root and content or attachments, plus optional root_pubkey, to receive an unsigned event. Sign it, then call again with event.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "root": mcpHash, "root_pubkey": mcpPubKey, "content": mcpText, "attachments": attachments}), mcpPublishes, t.mcpWrite(mcpBuildReply, mcpCheckReply, mcpReplyShape)) |
| 544 | + add("reply_in_thread", "Reply with a kind 12 event. Pass room, root and content or attachments, plus optional root_pubkey, to receive an unsigned event. A nested root is resolved to the thread root before signing. Sign it, then call again with event.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "root": mcpHash, "root_pubkey": mcpPubKey, "content": mcpText, "attachments": attachments}), mcpPublishes, t.mcpWrite(mcpBuildReply, mcpCheckReply, mcpReplyShape)) |
| 545 | add("react", "React to an event with a kind 7 reaction: + to like or approve, - to dislike or decline, or one emoji. Pass target, target_pubkey and content (plus room for a room message) to receive the unsigned event, sign it, then call again with the signed event. A + or - from a wiki merge request's destination author answers the request.", mcp.Object(map[string]any{"event": mcpEvent, "target": mcpHash, "target_pubkey": mcpPubKey, "content": map[string]any{"type": "string", "minLength": 1, "description": "+, - or one emoji."}, "room": mcpRoomID}), mcpPublishes, t.mcpWrite(mcpBuildReact, mcpCheckReact, mcpReactShape)) |
| 546 | add("publish_wiki_page", "Publish or replace your version of a kind 30818 wiki page in Djot markup. Pass d (the page name), title and content, plus optional summary and, to fork another author's version, fork_author and fork_event, to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "d": mcpPageName, "title": mcpText, "summary": mcpText, "content": mcpText, "fork_author": mcpPubKey, "fork_event": mcpHash}), mcpPublishes, t.mcpWrite(mcpBuildWikiPage, mcpCheckWikiPage, mcpWikiShape)) |
| 547 | add("propose_wiki_merge", "Ask a wiki author to take in changes from another version with a kind 818 merge request. Pass d, destination (the author asked), source (the proposed version's event id) and content, plus an optional base version id, to receive the unsigned event, sign it, then call again with the signed event. The destination author answers with a + or - reaction.", mcp.Object(map[string]any{"event": mcpEvent, "d": mcpPageName, "destination": mcpPubKey, "source": mcpHash, "base": mcpHash, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildWikiMerge, mcpCheckWikiMerge, mcpMergeShape)) |
| 548 | add("publish_site", "Publish a static site manifest (NIP-5A). Upload the files to the blob store first, then pass paths as [path, sha256] pairs, an optional label (your npub for your own site, the default, or a named site label under your key) and an optional expiration, to receive the unsigned kind 15128 or 35128 event, sign it, then call again with the signed event. An agent needs a sites grant that covers the label; a grant with a ttl requires the expiration.", mcp.Object(map[string]any{"event": mcpEvent, "label": map[string]any{"type": "string", "minLength": 1, "description": "Site label: your npub, or a named site label under your key. Defaults to your own site."}, "paths": map[string]any{"type": "array", "items": map[string]any{"type": "array", "items": mcpText}, "description": "One [path, sha256] pair per file, such as [\"/index.html\", \"<sha256>\"]."}, "expiration": mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildSite, mcpCheckSite, mcpSiteShape)) |
| 549 | add("create_room", "Create a chat room with a kind 9007 event. Relay members may do this. Pass room (the new id), name and optional about and visibility (open or members) to receive the unsigned event, sign it, then call again with the signed event. The signer becomes the room owner.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "name": mcpText, "about": mcpText, "visibility": map[string]any{"type": "string", "enum": []string{"open", "members"}}}), mcpPublishes, t.mcpWrite(mcpBuildRoom, mcpCheckRoom, mcpRoomShape)) |
| 550 | - add("request_decision", "Ask a person for an approval, a decision or an answer. Pass pubkey (the person asked), request (approve, decide or question) and content, plus room for a kind 9 room message or root, root_kind and root_pubkey for a kind 1111 comment under an issue, pull request or other event, and optional expiration and subject, to receive the unsigned event carrying a request tag, sign it, then call again with the signed event."+mcpAnswerNote, mcp.Object(map[string]any{"event": mcpEvent, "pubkey": mcpPubKey, "request": map[string]any{"type": "string", "enum": mcpRequestKinds}, "content": mcpText, "room": mcpRoomID, "root": mcpHash, "root_kind": map[string]any{"type": "integer", "minimum": 0}, "root_pubkey": mcpPubKey, "expiration": mcpUnixTime, "subject": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildRequest, mcpCheckRequest, mcpRequestShape)) |
| 551 | + add("request_decision", "Ask a person for an approval, a decision or an answer. Pass pubkey, request and content, plus room and optional root for a kind 9 room message, or root, root_kind and root_pubkey for a kind 1111 comment under another event. A room root is resolved to the thread root before signing."+mcpAnswerNote, mcp.Object(map[string]any{"event": mcpEvent, "pubkey": mcpPubKey, "request": map[string]any{"type": "string", "enum": mcpRequestKinds}, "content": mcpText, "room": mcpRoomID, "root": mcpHash, "root_kind": map[string]any{"type": "integer", "minimum": 0}, "root_pubkey": mcpPubKey, "expiration": mcpUnixTime, "subject": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildRequest, mcpCheckRequest, mcpRequestShape)) |
| 552 | add("request_job", "Ask for a long task with a NIP-90 job request. Pass kind, inputs and optional room, output, params, bid in millisats, relays and expiration. A room-scoped job keeps its h tag on feedback and results; read them with read_job.", mcp.Object(map[string]any{"event": mcpEvent, "kind": mcpJobKind, "room": mcpRoomID, "inputs": mcpJobInputs, "output": map[string]any{"type": "string", "description": "Expected output MIME type."}, "params": map[string]any{"type": "object", "description": "Job parameters as key and string value, each becoming a param tag."}, "bid": mcpMsats, "relays": map[string]any{"type": "array", "items": mcpText}, "expiration": mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildJobRequest, mcpCheckJobRequest, mcpJobRequestShape)) |
| 553 | add("job_feedback", "Report progress on a long task with a kind 7000 job feedback event. Pass e, p, status and optional room, info, amount in millisats, invoice and content. For a room-scoped request, pass the same room so private feedback stays private.", mcp.Object(map[string]any{"event": mcpEvent, "e": mcpHash, "p": mcpPubKey, "room": mcpRoomID, "status": mcpJobStatus, "info": mcpText, "amount": mcpMsats, "invoice": mcpText, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobFeedback, mcpCheckJobFeedback, mcpJobFeedbackShape)) |
| 554 | add("job_result", "Deliver a long task's output with a NIP-90 job result. Pass request (the job request event) or kind, e and p, plus content and optional room, amount and invoice. When request is supplied, its room and inputs are copied into the result.", mcp.Object(map[string]any{"event": mcpEvent, "request": map[string]any{"type": "object", "description": "The job request event being answered."}, "room": mcpRoomID, "kind": map[string]any{"type": "integer", "minimum": event.KIND_JOB_RESULT_MIN, "maximum": event.KIND_JOB_RESULT_MAX}, "e": mcpHash, "p": mcpPubKey, "content": mcpText, "amount": mcpMsats, "invoice": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobResult, mcpCheckJobResult, mcpJobResultShape)) |
| 555 | @@ -293,10 +293,48 @@ func (t *Tenant) mcpWrite(build func(mcp.Call) (mcpUnsigned, error), check func( |
| 556 | if err != nil { |
| 557 | return mcp.Failure(err.Error()+"\n"+shape, map[string]any{"expected": shape}), nil |
| 558 | } |
| 559 | + unsigned, err = t.canonicalizeRoomThreadUnsigned(ctx, call.Actor, unsigned) |
| 560 | + if err != nil { |
| 561 | + return mcp.Failure(err.Error()+"\n"+shape, map[string]any{"expected": shape}), nil |
| 562 | + } |
| 563 | return mcp.Value(map[string]any{"unsigned": unsigned, "next": mcpSignNext}), nil |
| 564 | } |
| 565 | } |
| 566 | |
| 567 | +// canonicalizeRoomThreadUnsigned keeps room replies at one visible level. |
| 568 | +// The event is still unsigned, so its parent reference can be normalized |
| 569 | +// before the client signs it. Signed events are intentionally checked as |
| 570 | +// submitted because changing them would invalidate their signatures. |
| 571 | +func (t *Tenant) canonicalizeRoomThreadUnsigned(ctx context.Context, actor string, unsigned mcpUnsigned) (mcpUnsigned, error) { |
| 572 | + if unsigned.Kind != event.KIND_THREAD_REPLY && unsigned.Kind != event.KIND_CHAT { |
| 573 | + return unsigned, nil |
| 574 | + } |
| 575 | + room := event.Tag(event.Event{Tags: unsigned.Tags}, "h") |
| 576 | + rootID := event.Tag(event.Event{Tags: unsigned.Tags}, "e") |
| 577 | + if room == "" || rootID == "" { |
| 578 | + return unsigned, nil |
| 579 | + } |
| 580 | + root, err := t.roomThreadRoot(ctx, actor, room, rootID) |
| 581 | + if err != nil { |
| 582 | + return mcpUnsigned{}, err |
| 583 | + } |
| 584 | + for _, tag := range unsigned.Tags { |
| 585 | + if len(tag) >= 2 && tag[0] == "e" { |
| 586 | + tag[1] = root.ID |
| 587 | + break |
| 588 | + } |
| 589 | + } |
| 590 | + if unsigned.Kind == event.KIND_THREAD_REPLY { |
| 591 | + for _, tag := range unsigned.Tags { |
| 592 | + if len(tag) >= 2 && tag[0] == "p" { |
| 593 | + tag[1] = root.PubKey |
| 594 | + break |
| 595 | + } |
| 596 | + } |
| 597 | + } |
| 598 | + return unsigned, nil |
| 599 | +} |
| 600 | + |
| 601 | func (t *Tenant) mcpPublish(ctx context.Context, call mcp.Call, e event.Event) mcp.Result { |
| 602 | s := relay.Session{RelayURL: t.RelayURL(), PubKeys: []string{call.Actor}} |
| 603 | if call.Request != nil { |
| 604 | @@ -730,7 +768,7 @@ func mcpBuildRequest(call mcp.Call) (mcpUnsigned, error) { |
| 605 | var tags [][]string |
| 606 | switch { |
| 607 | case room != "" && root != "": |
| 608 | - return mcpUnsigned{}, errors.New("pass either room or root, not both") |
| 609 | + kind, tags = event.KIND_CHAT, [][]string{{"h", room}, {"e", root, "", "root"}} |
| 610 | case room != "": |
| 611 | kind, tags = event.KIND_CHAT, [][]string{{"h", room}} |
| 612 | case root != "": |
| 613 | diff --git a/internal/daemon/room_threads.go b/internal/daemon/room_threads.go |
| 614 | index d0ba38b..f6a8fee 100644 |
| 615 | --- a/internal/daemon/room_threads.go |
| 616 | +++ b/internal/daemon/room_threads.go |
| 617 | @@ -8,6 +8,11 @@ import ( |
| 618 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 619 | ) |
| 620 | |
| 621 | +var ( |
| 622 | + errRoomThreadSizeLimit = errors.New("restricted: thread exceeds the browser limit") |
| 623 | + errRoomThreadDepthLimit = errors.New("restricted: thread exceeds the nesting limit") |
| 624 | +) |
| 625 | + |
| 626 | func (t *Tenant) roomThreadRoot(ctx context.Context, actor, room, id string) (event.Event, error) { |
| 627 | seen := map[string]bool{} |
| 628 | for len(seen) < 64 && !seen[id] { |
| 629 | @@ -57,7 +62,7 @@ func (t *Tenant) roomThreadReplies(ctx context.Context, actor, room, root string |
| 630 | replies = append(replies, row) |
| 631 | nextLevel = append(nextLevel, row.ID) |
| 632 | if len(replies) > 5000 { |
| 633 | - return nil, errors.New("restricted: thread exceeds the browser limit") |
| 634 | + return nil, errRoomThreadSizeLimit |
| 635 | } |
| 636 | } |
| 637 | if next == "" { |
| 638 | @@ -72,7 +77,7 @@ func (t *Tenant) roomThreadReplies(ctx context.Context, actor, room, root string |
| 639 | frontier = nextLevel |
| 640 | } |
| 641 | if len(frontier) > 0 { |
| 642 | - return nil, errors.New("restricted: thread exceeds the nesting limit") |
| 643 | + return nil, errRoomThreadDepthLimit |
| 644 | } |
| 645 | sortEventsNewestFirst(replies) |
| 646 | return replies, nil |
| 647 | diff --git a/internal/daemon/rooms_browse.go b/internal/daemon/rooms_browse.go |
| 648 | index e2cc206..4e7e74c 100644 |
| 649 | --- a/internal/daemon/rooms_browse.go |
| 650 | +++ b/internal/daemon/rooms_browse.go |
| 651 | @@ -123,7 +123,9 @@ func wireRoomPage(page webui.RoomPage, thread bool) map[string]any { |
| 652 | } |
| 653 | |
| 654 | func (t *Tenant) browseRoom(ctx context.Context, actor string, q roomBrowseRequest) (any, error) { |
| 655 | - page, err := t.readRoomCore(ctx, actor, q.ID, q.Cursor, q.Limit) |
| 656 | + // The MCP browser contract exposes raw room events for agents. The typed |
| 657 | + // web reader folds replies into top-level pages for the UI. |
| 658 | + page, err := t.readRoomCore(ctx, actor, q.ID, q.Cursor, q.Limit, false) |
| 659 | if err != nil { |
| 660 | return nil, err |
| 661 | } |
| 662 | diff --git a/internal/daemon/rooms_test.go b/internal/daemon/rooms_test.go |
| 663 | index db36a5c..77081bd 100644 |
| 664 | --- a/internal/daemon/rooms_test.go |
| 665 | +++ b/internal/daemon/rooms_test.go |
| 666 | @@ -4,6 +4,7 @@ import ( |
| 667 | "bufio" |
| 668 | "context" |
| 669 | "encoding/json" |
| 670 | + "fmt" |
| 671 | "net/http" |
| 672 | "net/http/httptest" |
| 673 | "strings" |
| 674 | @@ -320,6 +321,90 @@ func TestRoomThreadRejectsCrossRoomRootsAndPaginatesNestedReplies(t *testing.T) |
| 675 | _ = second |
| 676 | } |
| 677 | |
| 678 | +func TestReadRoomPagesTopLevelRootsPastFoldedReplies(t *testing.T) { |
| 679 | + h := newRoomHarness(t) |
| 680 | + older := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "older root") |
| 681 | + root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "root before reply burst") |
| 682 | + for i := 0; i < 105; i++ { |
| 683 | + h.must("bob", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", root.ID, "", "reply"}}, fmt.Sprintf("folded reply %d", i)) |
| 684 | + } |
| 685 | + h.must("alice", 7, [][]string{{"h", "main"}, {"e", root.ID}}, "+") |
| 686 | + olderReply := h.must("bob", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", older.ID, "", "reply"}}, "reply to older root") |
| 687 | + h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", olderReply.ID, "", "reply"}}, "nested reply to older root") |
| 688 | + h.must("bob", 7, [][]string{{"h", "main"}, {"e", older.ID}}, "heart") |
| 689 | + seen := map[string]bool{} |
| 690 | + cursor := "" |
| 691 | + for pages := 0; ; pages++ { |
| 692 | + page, err := h.tenant.ReadRoom(h.ctx, h.keys["bob"], "main", cursor, 1) |
| 693 | + if err != nil { |
| 694 | + t.Fatal(err) |
| 695 | + } |
| 696 | + if len(page.Messages) > 1 || pages > 20 { |
| 697 | + t.Fatalf("unbounded timeline page: %d messages on page %d", len(page.Messages), pages) |
| 698 | + } |
| 699 | + for _, row := range page.Messages { |
| 700 | + if seen[row.ID] { |
| 701 | + t.Fatalf("repeated message across tied timestamp pages: %s", row.ID) |
| 702 | + } |
| 703 | + seen[row.ID] = true |
| 704 | + switch row.ID { |
| 705 | + case root.ID: |
| 706 | + if page.ReplyCounts[root.ID] != 105 || page.ReactionCounts[root.ID]["+1"] != 1 { |
| 707 | + t.Fatalf("burst summaries missing: %+v", page) |
| 708 | + } |
| 709 | + case older.ID: |
| 710 | + if page.ReplyCounts[older.ID] != 2 || page.ReactionCounts[older.ID]["heart"] != 1 { |
| 711 | + t.Fatalf("older root summaries missing: %+v", page) |
| 712 | + } |
| 713 | + default: |
| 714 | + if row.Kind != event.KIND_ROOM_MEMBER_ADDED && row.Kind != event.KIND_ROOM_MEMBER_REMOVED { |
| 715 | + t.Fatalf("unfolded event on timeline: %d %s", row.Kind, row.ID) |
| 716 | + } |
| 717 | + } |
| 718 | + } |
| 719 | + if page.NextCursor == "" { |
| 720 | + break |
| 721 | + } |
| 722 | + if page.NextCursor == cursor || len(page.Messages) == 0 { |
| 723 | + t.Fatal("pagination failed to advance through visible messages") |
| 724 | + } |
| 725 | + cursor = page.NextCursor |
| 726 | + } |
| 727 | + if !seen[root.ID] || !seen[older.ID] { |
| 728 | + t.Fatalf("timeline skipped roots after folded reply burst: %v", seen) |
| 729 | + } |
| 730 | + thread, err := h.tenant.ReadThread(h.ctx, h.keys["bob"], "main", root.ID, "", 1) |
| 731 | + if err != nil { |
| 732 | + t.Fatal(err) |
| 733 | + } |
| 734 | + if thread.ReplyCounts[root.ID] != 105 || len(thread.Replies) != 1 { |
| 735 | + t.Fatalf("thread folded count = %d replies=%d", thread.ReplyCounts[root.ID], len(thread.Replies)) |
| 736 | + } |
| 737 | +} |
| 738 | + |
| 739 | +func TestReadRoomRemainsReadableWhenThreadExceedsTraversalLimit(t *testing.T) { |
| 740 | + h := newRoomHarness(t) |
| 741 | + root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "root") |
| 742 | + parent := root.ID |
| 743 | + for i := 0; i < 65; i++ { |
| 744 | + row := h.must("bob", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", parent, "", "reply"}}, "deep reply") |
| 745 | + parent = row.ID |
| 746 | + } |
| 747 | + page, err := h.tenant.ReadRoom(h.ctx, h.keys["alice"], "main", "", 50) |
| 748 | + if err != nil { |
| 749 | + t.Fatalf("one bounded thread blocked the room: %v", err) |
| 750 | + } |
| 751 | + for _, row := range page.Messages { |
| 752 | + if row.ID == root.ID { |
| 753 | + if _, found := page.ReplyCounts[root.ID]; found { |
| 754 | + t.Fatal("a partial reply count was presented as complete") |
| 755 | + } |
| 756 | + return |
| 757 | + } |
| 758 | + } |
| 759 | + t.Fatal("root missing from room") |
| 760 | +} |
| 761 | + |
| 762 | func TestTypedRoomReaderPreservesSignedEvents(t *testing.T) { |
| 763 | h := newRoomHarness(t) |
| 764 | message := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}, {"imeta", "url https://files.example/report.pdf", "m application/pdf", "filename report.pdf"}}, "typed room read") |
| 765 | diff --git a/internal/daemon/webui_rooms.go b/internal/daemon/webui_rooms.go |
| 766 | index 8d4199f..dab78e1 100644 |
| 767 | --- a/internal/daemon/webui_rooms.go |
| 768 | +++ b/internal/daemon/webui_rooms.go |
| 769 | @@ -3,9 +3,11 @@ package daemon |
| 770 | import ( |
| 771 | "context" |
| 772 | "errors" |
| 773 | + "strings" |
| 774 | |
| 775 | "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 776 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 777 | + "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 778 | "github.com/FelineStateMachine/tinyrelay/internal/webui" |
| 779 | ) |
| 780 | |
| 781 | @@ -72,10 +74,10 @@ func (t *Tenant) ReadRoom(ctx context.Context, actor, roomID, cursor string, lim |
| 782 | return webui.RoomPage{}, err |
| 783 | } |
| 784 | defer done() |
| 785 | - return t.readRoomCore(ctx, actor, roomID, cursor, limit) |
| 786 | + return t.readRoomCore(ctx, actor, roomID, cursor, limit, true) |
| 787 | } |
| 788 | |
| 789 | -func (t *Tenant) readRoomCore(ctx context.Context, actor, roomID, cursor string, limit int) (webui.RoomPage, error) { |
| 790 | +func (t *Tenant) readRoomCore(ctx context.Context, actor, roomID, cursor string, limit int, foldReplies bool) (webui.RoomPage, error) { |
| 791 | if err := t.browseRead(ctx, actor); err != nil { |
| 792 | return webui.RoomPage{}, err |
| 793 | } |
| 794 | @@ -95,7 +97,12 @@ func (t *Tenant) readRoomCore(ctx context.Context, actor, roomID, cursor string, |
| 795 | if err != nil { |
| 796 | return webui.RoomPage{}, err |
| 797 | } |
| 798 | - messages, next, err := t.roomMessages(ctx, actor, event.Filter{Kinds: roomMessageKinds, Tags: map[string][]string{"h": {room.ID}}}, position, normalizeRoomLimit(limit)) |
| 799 | + readMessages := t.roomMessages |
| 800 | + if foldReplies { |
| 801 | + readMessages = t.roomTimelineMessages |
| 802 | + } |
| 803 | + messageFilter := event.Filter{Kinds: roomMessageKinds, Tags: map[string][]string{"h": {room.ID}}} |
| 804 | + messages, next, err := readMessages(ctx, actor, messageFilter, position, normalizeRoomLimit(limit)) |
| 805 | if err != nil { |
| 806 | return webui.RoomPage{}, err |
| 807 | } |
| 808 | @@ -103,7 +110,123 @@ func (t *Tenant) readRoomCore(ctx context.Context, actor, roomID, cursor string, |
| 809 | if err != nil { |
| 810 | return webui.RoomPage{}, err |
| 811 | } |
| 812 | - return webui.RoomPage{Room: webuiRoomSummary(summary), Members: members, Messages: messages, Edits: edits, NextCursor: next}, nil |
| 813 | + page := webui.RoomPage{Room: webuiRoomSummary(summary), Members: members, Messages: messages, Edits: edits, NextCursor: next} |
| 814 | + if foldReplies { |
| 815 | + page.ReplyCounts, page.ReactionCounts, err = t.roomInteractionSummaries(ctx, actor, room.ID, messages) |
| 816 | + if err != nil { |
| 817 | + return webui.RoomPage{}, err |
| 818 | + } |
| 819 | + } |
| 820 | + return page, nil |
| 821 | +} |
| 822 | + |
| 823 | +// roomTimelineMessages pages by visible top-level messages rather than raw |
| 824 | +// events. Folded replies and reactions are summarized separately so a burst |
| 825 | +// of replies cannot make the page empty or consume unbounded response memory. |
| 826 | +func (t *Tenant) roomTimelineMessages(ctx context.Context, actor string, filter event.Filter, cursor *storage.EventCursor, limit int) ([]event.Event, string, error) { |
| 827 | + if limit <= 0 { |
| 828 | + limit = 100 |
| 829 | + } |
| 830 | + position := cursor |
| 831 | + rows := make([]event.Event, 0, limit) |
| 832 | + for { |
| 833 | + page, next, err := t.roomMessages(ctx, actor, filter, position, 100) |
| 834 | + if err != nil { |
| 835 | + return nil, "", err |
| 836 | + } |
| 837 | + for i, row := range page { |
| 838 | + if roomTimelineTopLevel(row) || (row.Kind == event.KIND_ROOM_MEMBER_ADDED || row.Kind == event.KIND_ROOM_MEMBER_REMOVED) && event.Tag(row, "p") != "" { |
| 839 | + rows = append(rows, row) |
| 840 | + } |
| 841 | + if len(rows) < limit { |
| 842 | + continue |
| 843 | + } |
| 844 | + if next == "" && i == len(page)-1 { |
| 845 | + return rows, "", nil |
| 846 | + } |
| 847 | + return rows, collaborationCursor(collaborationItemFrom(row)), nil |
| 848 | + } |
| 849 | + if next == "" { |
| 850 | + return rows, "", nil |
| 851 | + } |
| 852 | + position, err = parseCollaborationCursor(next) |
| 853 | + if err != nil { |
| 854 | + return nil, "", err |
| 855 | + } |
| 856 | + } |
| 857 | +} |
| 858 | + |
| 859 | +func (t *Tenant) roomInteractionSummaries(ctx context.Context, actor, roomID string, roots []event.Event) (map[string]int, map[string]map[string]int, error) { |
| 860 | + counts := make(map[string]int) |
| 861 | + reactions := make(map[string]map[string]int) |
| 862 | + for _, root := range roots { |
| 863 | + if !roomTimelineTopLevel(root) { |
| 864 | + continue |
| 865 | + } |
| 866 | + replies, err := t.roomThreadReplies(ctx, actor, roomID, root.ID) |
| 867 | + if errors.Is(err, errRoomThreadSizeLimit) || errors.Is(err, errRoomThreadDepthLimit) { |
| 868 | + // A bounded thread view must not prevent reading the rest of the |
| 869 | + // room. Omit its count instead of presenting a partial total. |
| 870 | + continue |
| 871 | + } |
| 872 | + if err != nil { |
| 873 | + return nil, nil, err |
| 874 | + } |
| 875 | + counts[root.ID] = len(replies) |
| 876 | + } |
| 877 | + ids := make([]string, 0, len(roots)) |
| 878 | + selected := make(map[string]bool, len(roots)) |
| 879 | + for _, root := range roots { |
| 880 | + if roomTimelineTopLevel(root) { |
| 881 | + ids = append(ids, root.ID) |
| 882 | + selected[root.ID] = true |
| 883 | + } |
| 884 | + } |
| 885 | + if len(ids) == 0 { |
| 886 | + return counts, reactions, nil |
| 887 | + } |
| 888 | + filter := event.Filter{Kinds: []int{7}, Tags: map[string][]string{"h": {roomID}, "e": ids}} |
| 889 | + var cursor *storage.EventCursor |
| 890 | + for { |
| 891 | + rows, next, err := t.roomMessages(ctx, actor, filter, cursor, 100) |
| 892 | + if err != nil { |
| 893 | + return nil, nil, err |
| 894 | + } |
| 895 | + for _, row := range rows { |
| 896 | + targets := event.TagValues(row, "e") |
| 897 | + if len(targets) == 0 { |
| 898 | + continue |
| 899 | + } |
| 900 | + target := targets[len(targets)-1] |
| 901 | + if !selected[target] { |
| 902 | + continue |
| 903 | + } |
| 904 | + content := strings.TrimSpace(row.Content) |
| 905 | + if content == "" || content == "+" { |
| 906 | + content = "+1" |
| 907 | + } |
| 908 | + if reactions[target] == nil { |
| 909 | + reactions[target] = make(map[string]int) |
| 910 | + } |
| 911 | + reactions[target][content]++ |
| 912 | + } |
| 913 | + if next == "" { |
| 914 | + return counts, reactions, nil |
| 915 | + } |
| 916 | + cursor, err = parseCollaborationCursor(next) |
| 917 | + if err != nil { |
| 918 | + return nil, nil, err |
| 919 | + } |
| 920 | + } |
| 921 | +} |
| 922 | + |
| 923 | +func roomTimelineTopLevel(row event.Event) bool { |
| 924 | + switch row.Kind { |
| 925 | + case event.KIND_CHAT, event.KIND_THREAD, event.KIND_RICH_CONTENT: |
| 926 | + return event.RoomReplyRoot(row) == "" |
| 927 | + default: |
| 928 | + return false |
| 929 | + } |
| 930 | } |
| 931 | |
| 932 | func (t *Tenant) ReadThread(ctx context.Context, actor, roomID, rootID, cursor string, limit int) (webui.RoomPage, error) { |
| 933 | @@ -148,7 +271,7 @@ func (t *Tenant) readThreadCore(ctx context.Context, actor, roomID, rootID, curs |
| 934 | if err != nil { |
| 935 | return webui.RoomPage{}, err |
| 936 | } |
| 937 | - return webui.RoomPage{Room: webuiRoomSummaryFromRoom(room), Members: members, Root: &root, Replies: replies, Edits: edits, NextCursor: next}, nil |
| 938 | + return webui.RoomPage{Room: webuiRoomSummaryFromRoom(room), Members: members, Root: &root, Replies: replies, ReplyCounts: map[string]int{root.ID: len(all)}, Edits: edits, NextCursor: next}, nil |
| 939 | } |
| 940 | |
| 941 | func (t *Tenant) webuiRoomMembers(ctx context.Context, roomID, role string) ([]webui.RoomMember, error) { |
| 942 | diff --git a/internal/webui/chat-presence.js b/internal/webui/chat-presence.js |
| 943 | index cff7ab6..8ca22f4 100644 |
| 944 | --- a/internal/webui/chat-presence.js |
| 945 | +++ b/internal/webui/chat-presence.js |
| 946 | @@ -64,17 +64,30 @@ |
| 947 | if (!/^[a-z0-9_-]{1,64}$/.test(this.room || "")) return; |
| 948 | this.values = new Map(); this.entries = new Map(); this.last = {}; this.pending = new Set(); this.generation = 0; |
| 949 | this.status = this.querySelector("output"); this.controller = new AbortController(); |
| 950 | - this.enabled = false; |
| 951 | + this.enabled = false; this.signerReadyVersion = 0; this.preferenceReload = false; |
| 952 | this.onEvent = event => { if (event.detail?.room === this.room) this.receive(event.detail.event); }; |
| 953 | this.onInput = event => { if (event.target.matches('room-compose textarea')) { this.inputAt = seconds(); this.publish(TYPING, event.target.value ? "typing" : "stopped"); } }; |
| 954 | this.onVisibility = () => { this.generation++; this.values.clear(); this.paint(); if (document.hidden) { this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true); } else { this.loadPreference(); this.tick(); } }; |
| 955 | this.onIdentity = () => { this.enabled = false; this.generation++; this.values.clear(); this.paint(); }; |
| 956 | - this.onSigner = () => { this.onIdentity(); this.failed = false; this.loadPreference(); }; |
| 957 | + this.clearFailure = () => { |
| 958 | + this.failed = false; |
| 959 | + if (this.status?.textContent.includes("Presence could not be shared")) { |
| 960 | + this.status.textContent = ""; |
| 961 | + this.paint(); |
| 962 | + } |
| 963 | + }; |
| 964 | + this.onSigner = () => { this.onIdentity(); this.signerReadyVersion++; this.clearFailure(); this.loadPreference(); }; |
| 965 | + this.onSignerStatus = event => { |
| 966 | + if (event.detail?.status !== "ready") return; |
| 967 | + this.signerReadyVersion++; this.clearFailure(); |
| 968 | + this.loadPreference(); |
| 969 | + }; |
| 970 | this.onPreference = event => { if (event.detail?.actor === this.actor) this.applyPreference(event.detail.share_presence === true); }; |
| 971 | document.addEventListener("tiny:room-event", this.onEvent); |
| 972 | document.addEventListener("input", this.onInput); |
| 973 | document.addEventListener("visibilitychange", this.onVisibility); |
| 974 | document.addEventListener("tiny:signer", this.onSigner); |
| 975 | + document.addEventListener("tiny:signer-status", this.onSignerStatus); |
| 976 | document.addEventListener("tiny:logout", this.onIdentity); |
| 977 | document.addEventListener("tiny:chat-preferences",this.onPreference); |
| 978 | this.loadPreference(); |
| 979 | @@ -84,7 +97,7 @@ |
| 980 | this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true); |
| 981 | this.enabled = false; this.generation++; this.controller?.abort(); clearInterval(this.timer); |
| 982 | document.removeEventListener("tiny:room-event",this.onEvent); document.removeEventListener("input",this.onInput); |
| 983 | - document.removeEventListener("visibilitychange",this.onVisibility); document.removeEventListener("tiny:signer",this.onSigner); document.removeEventListener("tiny:logout",this.onIdentity); |
| 984 | + document.removeEventListener("visibilitychange",this.onVisibility); document.removeEventListener("tiny:signer",this.onSigner); document.removeEventListener("tiny:signer-status",this.onSignerStatus); document.removeEventListener("tiny:logout",this.onIdentity); |
| 985 | document.removeEventListener("tiny:chat-preferences",this.onPreference); this.values?.clear(); |
| 986 | } |
| 987 | applyPreference(enabled) { |
| 988 | @@ -94,13 +107,20 @@ |
| 989 | else { this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true); } |
| 990 | } |
| 991 | async loadPreference() { |
| 992 | - if (!hex(this.actor) || this.loadingPreference || this.failed || this.controller.signal.aborted) return; |
| 993 | + if (!hex(this.actor) || this.failed || this.controller.signal.aborted) return; |
| 994 | + if (this.loadingPreference) { this.preferenceReload = true; return; } |
| 995 | this.loadingPreference = true; const generation = this.generation; |
| 996 | try { |
| 997 | const enabled = await readPreference(this.controller.signal); |
| 998 | if (this.isConnected && !this.controller.signal.aborted && generation === this.generation) this.applyPreference(enabled); |
| 999 | } catch { this.applyPreference(false); } |
| 1000 | - finally { this.loadingPreference = false; this.preferenceCheckedAt = seconds(); } |
| 1001 | + finally { |
| 1002 | + this.loadingPreference = false; this.preferenceCheckedAt = seconds(); |
| 1003 | + if (this.preferenceReload && this.isConnected && !this.controller.signal.aborted) { |
| 1004 | + this.preferenceReload = false; |
| 1005 | + this.loadPreference(); |
| 1006 | + } |
| 1007 | + } |
| 1008 | } |
| 1009 | receive(event) { |
| 1010 | const value = parse(event,this.room); if (!value || value.pubkey === this.actor) return; |
| 1011 | @@ -121,7 +141,7 @@ |
| 1012 | const at = seconds(), interval = kind === PRESENCE ? 60 : 3; |
| 1013 | if (!leaving && at-(this.last[kind] || 0) < interval) return; |
| 1014 | const signer = tiny.signer?.(); if (!signer?.signEvent) return; |
| 1015 | - const generation = this.generation; |
| 1016 | + const generation = this.generation, readyVersion = this.signerReadyVersion; |
| 1017 | this.pending.add(kind); |
| 1018 | try { |
| 1019 | if (await signer.getPublicKey() !== this.actor || tiny.signer() !== signer) return; |
| 1020 | @@ -129,6 +149,8 @@ |
| 1021 | await tiny.signing.publish({kind, created_at:at, tags:[["h",this.room]], content}); |
| 1022 | this.last[kind] = at; |
| 1023 | } catch { |
| 1024 | + const stale = generation !== this.generation || readyVersion !== this.signerReadyVersion || signer !== tiny.signer?.(); |
| 1025 | + if (stale || leaving || !this.isConnected) return; |
| 1026 | this.enabled = false; this.failed = true; |
| 1027 | if (this.status) this.status.textContent = "Presence could not be shared. Reconnect your signer to try again."; |
| 1028 | } finally { this.pending.delete(kind); } |
| 1029 | diff --git a/internal/webui/mobile_header.go b/internal/webui/mobile_header.go |
| 1030 | index a759ec8..4d0013e 100644 |
| 1031 | --- a/internal/webui/mobile_header.go |
| 1032 | +++ b/internal/webui/mobile_header.go |
| 1033 | @@ -72,8 +72,12 @@ func roomHeader(data PageData, route roomPath, thread bool) mobileHeaderData { |
| 1034 | if result.Subtitle == "" { |
| 1035 | result.Subtitle = "Group chat" |
| 1036 | } |
| 1037 | - if eventIDPattern.MatchString(route.event) { |
| 1038 | - result.BackURL = "/rooms/" + route.id + "#msg-" + route.event |
| 1039 | + root := plainString(valueMap(valueMap(data.Event)["root"])["id"]) |
| 1040 | + if !eventIDPattern.MatchString(root) { |
| 1041 | + root = route.event |
| 1042 | + } |
| 1043 | + if eventIDPattern.MatchString(root) { |
| 1044 | + result.BackURL = "/rooms/" + route.id + "#msg-" + root |
| 1045 | } |
| 1046 | result.BackLabel = "Back to room" |
| 1047 | return result |
| 1048 | diff --git a/internal/webui/mobile_header_test.go b/internal/webui/mobile_header_test.go |
| 1049 | index d32d606..d4ba7d2 100644 |
| 1050 | --- a/internal/webui/mobile_header_test.go |
| 1051 | +++ b/internal/webui/mobile_header_test.go |
| 1052 | @@ -46,6 +46,17 @@ func TestMobileHeaderRoomAndThread(t *testing.T) { |
| 1053 | } |
| 1054 | } |
| 1055 | |
| 1056 | +func TestThreadHeaderReturnsToCanonicalRoot(t *testing.T) { |
| 1057 | + root, reply := strings.Repeat("a", 64), strings.Repeat("b", 64) |
| 1058 | + header := mobileHeader(PageData{Path: "/rooms/general/thread/" + reply, Tab: "thread", Event: map[string]any{ |
| 1059 | + "room": map[string]any{"id": "general", "name": "General"}, |
| 1060 | + "root": map[string]any{"id": root}, |
| 1061 | + }}) |
| 1062 | + if header.BackURL != "/rooms/general#msg-"+root { |
| 1063 | + t.Fatalf("thread back link points to a folded reply: %+v", header) |
| 1064 | + } |
| 1065 | +} |
| 1066 | + |
| 1067 | func TestMobileHeaderDirectAndHiddenData(t *testing.T) { |
| 1068 | peer := strings.Repeat("b", 64) |
| 1069 | got := mobileHeader(PageData{Path: "/chat/dm/" + peer, Tab: "direct", View: peer}) |
| 1070 | diff --git a/internal/webui/read_contracts.go b/internal/webui/read_contracts.go |
| 1071 | index 58b4a3e..78ec08d 100644 |
| 1072 | --- a/internal/webui/read_contracts.go |
| 1073 | +++ b/internal/webui/read_contracts.go |
| 1074 | @@ -68,13 +68,17 @@ type RoomMessage = event.Event |
| 1075 | // Members and Messages; a thread read fills Root and Replies. Edits contains |
| 1076 | // the newest visible edit for the returned targets. |
| 1077 | type RoomPage struct { |
| 1078 | - Room RoomSummary `json:"room"` |
| 1079 | - Members []RoomMember `json:"members,omitempty"` |
| 1080 | - Messages []RoomMessage `json:"messages,omitempty"` |
| 1081 | - Replies []RoomMessage `json:"replies,omitempty"` |
| 1082 | - Edits []RoomMessage `json:"edits,omitempty"` |
| 1083 | - Root *RoomMessage `json:"root,omitempty"` |
| 1084 | - NextCursor string `json:"next_cursor,omitempty"` |
| 1085 | + Room RoomSummary `json:"room"` |
| 1086 | + Members []RoomMember `json:"members,omitempty"` |
| 1087 | + Messages []RoomMessage `json:"messages,omitempty"` |
| 1088 | + // ReplyCounts and ReactionCounts carry folded interaction summaries for |
| 1089 | + // the roots in Messages without expanding every reply into the timeline. |
| 1090 | + ReplyCounts map[string]int `json:"reply_counts,omitempty"` |
| 1091 | + ReactionCounts map[string]map[string]int `json:"reaction_counts,omitempty"` |
| 1092 | + Replies []RoomMessage `json:"replies,omitempty"` |
| 1093 | + Edits []RoomMessage `json:"edits,omitempty"` |
| 1094 | + Root *RoomMessage `json:"root,omitempty"` |
| 1095 | + NextCursor string `json:"next_cursor,omitempty"` |
| 1096 | } |
| 1097 | |
| 1098 | // rowsReader converts method-and-JSON query results into the row slices used |
| 1099 | @@ -104,13 +108,15 @@ func roomListValue(list RoomList) any { |
| 1100 | |
| 1101 | func roomPageValueFromContract(page RoomPage) any { |
| 1102 | return map[string]any{ |
| 1103 | - "room": page.Room, |
| 1104 | - "members": page.Members, |
| 1105 | - "messages": page.Messages, |
| 1106 | - "replies": page.Replies, |
| 1107 | - "edits": page.Edits, |
| 1108 | - "root": page.Root, |
| 1109 | - "next_cursor": page.NextCursor, |
| 1110 | + "room": page.Room, |
| 1111 | + "members": page.Members, |
| 1112 | + "messages": page.Messages, |
| 1113 | + "reply_counts": page.ReplyCounts, |
| 1114 | + "reaction_counts": page.ReactionCounts, |
| 1115 | + "replies": page.Replies, |
| 1116 | + "edits": page.Edits, |
| 1117 | + "root": page.Root, |
| 1118 | + "next_cursor": page.NextCursor, |
| 1119 | } |
| 1120 | } |
| 1121 | |
| 1122 | diff --git a/internal/webui/rooms.go b/internal/webui/rooms.go |
| 1123 | index 3feac73..d8bb8f5 100644 |
| 1124 | --- a/internal/webui/rooms.go |
| 1125 | +++ b/internal/webui/rooms.go |
| 1126 | @@ -70,10 +70,12 @@ func (a *App) roomList(ctx context.Context, actor string) []any { |
| 1127 | // legacyRoomPage is the compatibility decoder for Backend.Query results. |
| 1128 | // Typed production adapters use RoomPage from read_contracts.go instead. |
| 1129 | type legacyRoomPage struct { |
| 1130 | - Members []any `json:"members"` |
| 1131 | - Messages []any `json:"messages"` |
| 1132 | - Replies []any `json:"replies"` |
| 1133 | - Edits []any `json:"edits"` |
| 1134 | + Members []any `json:"members"` |
| 1135 | + Messages []any `json:"messages"` |
| 1136 | + Replies []any `json:"replies"` |
| 1137 | + Edits []any `json:"edits"` |
| 1138 | + ReplyCounts map[string]int `json:"reply_counts"` |
| 1139 | + ReactionCounts map[string]map[string]int `json:"reaction_counts"` |
| 1140 | } |
| 1141 | |
| 1142 | func legacyRoomPageValue(value any) (legacyRoomPage, bool) { |
| 1143 | @@ -122,8 +124,11 @@ func memberIndex(value any) map[string]map[string]any { |
| 1144 | return index |
| 1145 | } |
| 1146 | |
| 1147 | -// replyCounts counts the thread replies on this page by their root id. |
| 1148 | +// replyCounts prefers complete thread summaries over the legacy page counts. |
| 1149 | func replyCounts(value any) map[string]int { |
| 1150 | + if page, ok := legacyRoomPageValue(value); ok && page.ReplyCounts != nil { |
| 1151 | + return page.ReplyCounts |
| 1152 | + } |
| 1153 | counts := map[string]int{} |
| 1154 | for _, row := range roomSlice(value, "messages") { |
| 1155 | if root := webRoomReplyRoot(row); root != "" { |
| 1156 | @@ -196,6 +201,9 @@ func reactionSummary(value any) map[string][]reaction { |
| 1157 | } |
| 1158 | counts[target][content]++ |
| 1159 | } |
| 1160 | + if page, ok := legacyRoomPageValue(value); ok && page.ReactionCounts != nil { |
| 1161 | + counts = page.ReactionCounts |
| 1162 | + } |
| 1163 | summary := map[string][]reaction{} |
| 1164 | for target, byContent := range counts { |
| 1165 | for content, count := range byContent { |
| 1166 | @@ -328,7 +336,7 @@ func roomItems(value any, field string, actors ...string) []roomItem { |
| 1167 | return items |
| 1168 | } |
| 1169 | |
| 1170 | -// roomRoot is the thread page's root message, with the page's replies counted. |
| 1171 | +// roomRoot is the thread page's root message with its complete reply count. |
| 1172 | func roomRoot(value any, actors ...string) roomItem { |
| 1173 | data := valueMap(value) |
| 1174 | item, _ := newRoomItem(data["root"], plainString(valueMap(data["room"])["id"]), memberIndex(value)) |
| 1175 | @@ -337,6 +345,10 @@ func roomRoot(value any, actors ...string) roomItem { |
| 1176 | item.UpdatedAt = edit.CreatedAt |
| 1177 | } |
| 1178 | item.Replies = len(roomSlice(value, "replies")) |
| 1179 | + if count, ok := replyCounts(value)[item.ID]; ok { |
| 1180 | + item.Replies = count |
| 1181 | + } |
| 1182 | + item.Reactions = reactionSummary(value)[item.ID] |
| 1183 | item.InThread = true |
| 1184 | actor := firstString(actors) |
| 1185 | item.Own = actor != "" && item.PubKey == actor && item.Notice == "" |
| 1186 | diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html |
| 1187 | index 96ffe9a..72f17c9 100644 |
| 1188 | --- a/internal/webui/rooms.html |
| 1189 | +++ b/internal/webui/rooms.html |
| 1190 | @@ -68,10 +68,10 @@ establishes the initial state. */}} |
| 1191 | {{define "roomSendIcon"}}<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5m-7 7 7-7 7 7"/></svg>{{end}} |
| 1192 | |
| 1193 | {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}} |
| 1194 | -{{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"><img src="{{avatarURL .PubKey}}" alt="" width="32" height="32" loading="lazy"></nostr-avatar> |
| 1195 | +{{define "roomMessage"}}<room-message id="msg-{{.ID}}" data-id="{{.ID}}" data-kind="{{.Kind}}"{{if .Root}} data-root="{{.Root}}"{{end}} 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"><img src="{{avatarURL .PubKey}}" alt="" width="32" height="32" loading="lazy"></nostr-avatar> |
| 1196 | <header><b>{{template "nostrName" .PubKey}}</b>{{if .Own}}<span data-own-label>you</span>{{end}}{{if .Agent}} | {{if .Operator}}{{template "nostrName" .Operator}}'s agent{{else}}agent{{end}}{{else if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header> |
| 1197 | {{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}} |
| 1198 | -{{if or .Mentions (and (not .InThread) (or (eq .Kind "11") (eq .Kind "9") (eq .Kind "40002") .Root)) .Reactions .Edited}}<footer>{{if .Mentions}}<span>to {{range .Mentions}}{{template "nostrName" .}} {{end}}</span>{{end}}{{if not .InThread}}{{if or (eq .Kind "11") (eq .Kind "9") (eq .Kind "40002")}}<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}} |
| 1199 | +{{if or .Mentions (and (not .InThread) (or (eq .Kind "11") (eq .Kind "9") (eq .Kind "40002") .Root)) .Reactions .Edited}}<footer>{{if .Mentions}}<span>to {{range .Mentions}}{{template "nostrName" .}} {{end}}</span>{{end}}{{if not .InThread}}{{if .Root}}<a href="/rooms/{{.Room}}/thread/{{.Root}}">in thread</a>{{else if or (eq .Kind "11") (eq .Kind "9") (eq .Kind "40002")}}<a href="/rooms/{{.Room}}/thread/{{.ID}}">thread{{if .Replies}} | {{.Replies}} {{if eq .Replies 1}}reply{{else}}replies{{end}}{{end}}</a>{{end}}{{end}}{{range .Reactions}}<span data-reaction="{{.Content}}">{{.Content}} {{.Count}}</span>{{end}}{{if .Edited}}<span data-edited>edited</span>{{end}}</footer>{{end}} |
| 1200 | </room-message>{{end}} |
| 1201 | |
| 1202 | {{define "chatPresence"}}<chat-presence room="{{.View}}" actor="{{.Actor}}"><output aria-live="polite"></output></chat-presence>{{end}} |
| 1203 | diff --git a/internal/webui/rooms.js b/internal/webui/rooms.js |
| 1204 | index efffcf6..867900c 100644 |
| 1205 | --- a/internal/webui/rooms.js |
| 1206 | +++ b/internal/webui/rooms.js |
| 1207 | @@ -383,8 +383,8 @@ |
| 1208 | const footer = el("footer"); |
| 1209 | const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]); |
| 1210 | if (mentions.length) { const span = el("span", "to "); mentions.forEach(key => span.append(nameNode(key), " ")); footer.append(span); } |
| 1211 | - if (!inThread && room && [9, 11, 40002].includes(event.kind)) { const link = el("a", "thread"); link.href = roomPath(room, "/thread/" + event.id); footer.append(link); } |
| 1212 | const root = replyRoot(event); |
| 1213 | + if (!inThread && room && !root && [9, 11, 40002].includes(event.kind)) { const link = el("a", "thread"); link.href = roomPath(room, "/thread/" + event.id); footer.append(link); } |
| 1214 | if (!inThread && room && isHex64(root)) { const link = el("a", "in thread"); link.href = roomPath(room, "/thread/" + root); footer.append(link); } |
| 1215 | if (footer.childNodes.length) node.append(footer); |
| 1216 | return node; |
| 1217 | @@ -403,14 +403,14 @@ |
| 1218 | // roomAppend adds one message to the timeline unless it is already there, |
| 1219 | // keeps the list bounded and follows the newest message when the viewer |
| 1220 | // is already reading the end of it. |
| 1221 | - const roomAppend = (event, {room = "", inThread = false, own = false} = {}) => { |
| 1222 | + const roomAppend = (event, {room = "", inThread = false, own = false, canonicalRoot = ""} = {}) => { |
| 1223 | const list = document.getElementById("messages"); |
| 1224 | if (!list || !isHex64(event?.id) || document.getElementById("msg-" + event.id)) return null; |
| 1225 | list.roomSeen ||= new Set(); |
| 1226 | if (list.roomSeen.has(event.id)) return null; |
| 1227 | list.roomSeen.add(event.id); |
| 1228 | while (list.roomSeen.size > 2000) list.roomSeen.delete(list.roomSeen.values().next().value); |
| 1229 | - const reference = replyRoot(event); |
| 1230 | + const reference = canonicalRoot || replyRoot(event); |
| 1231 | if (reference && !inThread) { |
| 1232 | const link = document.querySelector("#msg-" + reference + " > footer > a"); |
| 1233 | if (link) { const count = Number((link.textContent.match(/(\d+) repl/) || [])[1] || 0) + 1; link.textContent = "thread | " + count + (count === 1 ? " reply" : " replies"); } |
| 1234 | @@ -762,13 +762,15 @@ |
| 1235 | const room = this.getAttribute("room"); |
| 1236 | if (!room || this.source || typeof EventSource !== "function") return; |
| 1237 | this.delay = this.delay || 1000; |
| 1238 | + const generation = (this.generation || 0) + 1; |
| 1239 | + this.generation = generation; |
| 1240 | const source = new EventSource(roomPath(room, "/stream"), {withCredentials: true}); |
| 1241 | this.source = source; |
| 1242 | source.addEventListener("open", () => { this.delay = 1000; this.textContent = ""; }); |
| 1243 | source.addEventListener("message", event => { |
| 1244 | let parsed; |
| 1245 | try { parsed = JSON.parse(event.data); } catch { return; } |
| 1246 | - this.receive(parsed); |
| 1247 | + this.receive(parsed, source, generation); |
| 1248 | }); |
| 1249 | source.addEventListener("error", () => { |
| 1250 | if (this.source !== source) return; |
| 1251 | @@ -780,14 +782,57 @@ |
| 1252 | } |
| 1253 | |
| 1254 | close() { |
| 1255 | + this.generation = (this.generation || 0) + 1; |
| 1256 | + this.lookupController?.abort(); |
| 1257 | + this.lookupController = null; |
| 1258 | + this.roots?.clear(); |
| 1259 | + this.rootQueries?.clear(); |
| 1260 | this.source?.close(); |
| 1261 | this.source = null; |
| 1262 | clearTimeout(this.timer); |
| 1263 | this.timer = null; |
| 1264 | } |
| 1265 | |
| 1266 | - receive(event) { |
| 1267 | + async canonicalRoot(room, reference, source, generation, eventID = "") { |
| 1268 | + this.roots ||= new Map(); |
| 1269 | + if (this.roots.has(reference)) { const root = this.roots.get(reference); this.rememberRoot(eventID, root); return root; } |
| 1270 | + this.rootQueries ||= new Map(); |
| 1271 | + if (!this.rootQueries.has(reference)) { |
| 1272 | + const path = tiny.localPath("/webmcp/query"); |
| 1273 | + const params = encodeURIComponent(JSON.stringify([{id: room, event: reference, limit: 1}])); |
| 1274 | + this.lookupController ||= new AbortController(); |
| 1275 | + const query = this.rootQueries.get(reference) || (async () => { |
| 1276 | + if (typeof fetch !== "function") return ""; |
| 1277 | + const response = await fetch(path + "?method=browsethread¶ms=" + params, {credentials: "same-origin", cache: "no-store", signal: this.lookupController?.signal}); |
| 1278 | + if (!response.ok) throw Error("thread lookup failed"); |
| 1279 | + const value = await response.json(); |
| 1280 | + const root = value?.root || value?.result?.root; |
| 1281 | + const id = root?.id; |
| 1282 | + if (!isHex64(id) || ![9, 11, 40002].includes(Number(root?.kind)) || tagValue(root, "h") !== room || replyRoot(root)) return ""; |
| 1283 | + return id; |
| 1284 | + })(); |
| 1285 | + this.rootQueries.set(reference, query); |
| 1286 | + } |
| 1287 | + const query = this.rootQueries.get(reference); |
| 1288 | + try { |
| 1289 | + const root = await query; |
| 1290 | + if (source && (this.source !== source || this.generation !== generation)) return ""; |
| 1291 | + if (root) { this.rememberRoot(reference, root); this.rememberRoot(eventID, root); } |
| 1292 | + return root; |
| 1293 | + } catch { return ""; } |
| 1294 | + finally { if (this.rootQueries.get(reference) === query) this.rootQueries.delete(reference); } |
| 1295 | + } |
| 1296 | + |
| 1297 | + rememberRoot(eventID, root) { |
| 1298 | + if (!isHex64(eventID) || !isHex64(root)) return; |
| 1299 | + this.roots ||= new Map(); |
| 1300 | + this.roots.set(eventID, root); |
| 1301 | + while (this.roots.size > 256) this.roots.delete(this.roots.keys().next().value); |
| 1302 | + } |
| 1303 | + |
| 1304 | + async receive(event, source = null, generation = this.generation) { |
| 1305 | if (!event || typeof event !== "object") return; |
| 1306 | + if (source && (this.source !== source || this.generation !== generation)) return; |
| 1307 | const room = this.getAttribute("room"); |
| 1308 | if (tagValue(event, "h") !== room && !(event.kind === 20001 && !tagValue(event, "h"))) return; |
| 1309 | if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:room-event", {detail: {room, event}})); |
| 1310 | @@ -795,11 +840,30 @@ |
| 1311 | if (event.kind === 40003) { roomEdit(event); return; } |
| 1312 | if (!roomKinds.includes(event.kind)) return; |
| 1313 | const root = this.getAttribute("root"); |
| 1314 | - if (root) { |
| 1315 | - const reference = replyRoot(event); |
| 1316 | - if (reference !== root && !document.getElementById("msg-" + reference)) return; |
| 1317 | + const reference = replyRoot(event); |
| 1318 | + if (reference) { |
| 1319 | + // The common direct-reply path is already canonical and can update the |
| 1320 | + // UI synchronously. Unknown parents are resolved below so nested or |
| 1321 | + // out-of-order events still use the original thread root. |
| 1322 | + if (root && reference === root) { |
| 1323 | + this.rememberRoot(event.id, root); |
| 1324 | + roomAppend(event, {room, inThread: true, canonicalRoot: root}); |
| 1325 | + return; |
| 1326 | + } |
| 1327 | + if (!root && document.getElementById("msg-" + reference)) { |
| 1328 | + this.rememberRoot(event.id, reference); |
| 1329 | + roomAppend(event, {room, inThread: false, canonicalRoot: reference}); |
| 1330 | + return; |
| 1331 | + } |
| 1332 | + const canonical = await this.canonicalRoot(room, reference, source, generation, event.id); |
| 1333 | + if (source && (this.source !== source || this.generation !== generation)) return; |
| 1334 | + if (root && canonical !== root) return; |
| 1335 | + if (!canonical) return; |
| 1336 | + roomAppend(event, {room, inThread: Boolean(root), canonicalRoot: canonical}); |
| 1337 | + return; |
| 1338 | } |
| 1339 | - roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(root)}); |
| 1340 | + if (root) return; |
| 1341 | + roomAppend(event, {room, inThread: false}); |
| 1342 | } |
| 1343 | } |
| 1344 | |
| 1345 | diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go |
| 1346 | index f738285..ac4d0d7 100644 |
| 1347 | --- a/internal/webui/rooms_test.go |
| 1348 | +++ b/internal/webui/rooms_test.go |
| 1349 | @@ -38,6 +38,23 @@ func roomRecord(id, name, access, role string) map[string]any { |
| 1350 | return map[string]any{"id": id, "name": name, "about": "Where " + name + " happens.", "picture": "", "access": access, "created_by": roomOwner, "created_at": 1757200000, "event_id": "", "members": 2, "last_message_at": 1757203600, "role": role} |
| 1351 | } |
| 1352 | |
| 1353 | +func TestRoomInteractionSummariesSurvivePagination(t *testing.T) { |
| 1354 | + root := RoomMessage{ID: roomThread, PubKey: roomAgent, Kind: 11, Content: "Root"} |
| 1355 | + page := RoomPage{ |
| 1356 | + Room: RoomSummary{ID: "general"}, Messages: []RoomMessage{root}, Root: &root, |
| 1357 | + ReplyCounts: map[string]int{roomThread: 105}, |
| 1358 | + ReactionCounts: map[string]map[string]int{roomThread: {"+1": 2, "wave": 1}}, |
| 1359 | + } |
| 1360 | + value := roomPageValueFromContract(page) |
| 1361 | + items := roomItems(value, "messages") |
| 1362 | + if len(items) != 1 || items[0].Replies != 105 || len(items[0].Reactions) != 2 || items[0].Reactions[0] != (reaction{Content: "+1", Count: 2}) { |
| 1363 | + t.Fatalf("folded summaries missing: %#v", items) |
| 1364 | + } |
| 1365 | + if item := roomRoot(value); item.Replies != 105 { |
| 1366 | + t.Fatalf("thread total counts only the current page: %d", item.Replies) |
| 1367 | + } |
| 1368 | +} |
| 1369 | + |
| 1370 | func (b *roomsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) { |
| 1371 | b.calls = append(b.calls, method+":"+actor) |
| 1372 | q := map[string]any{} |
| 1373 | diff --git a/scripts/chat-presence.test.mjs b/scripts/chat-presence.test.mjs |
| 1374 | index 09c427d..f5d6084 100644 |
| 1375 | --- a/scripts/chat-presence.test.mjs |
| 1376 | +++ b/scripts/chat-presence.test.mjs |
| 1377 | @@ -33,7 +33,7 @@ function setup(options={}) { |
| 1378 | document.removeEventListener = name => delete document.listeners?.[name]; |
| 1379 | const definitions = {}, saved = [], dispatched = []; |
| 1380 | document.dispatchEvent = event => dispatched.push(event); |
| 1381 | - const tiny = {signedFetch: async (path,method,body,settings) => {saved.push({path,method,body,settings}); if(options.saveError) throw Error(options.saveError); return {json:async()=>JSON.parse(body)};},localPath:path=>path,signer: () => signer, signing: {publish: async event => published.push(event)}}; |
| 1382 | + const tiny = {signedFetch: async (path,method,body,settings) => {saved.push({path,method,body,settings}); if(options.saveError) throw Error(options.saveError); return {json:async()=>JSON.parse(body)};},localPath:path=>path,signer: () => signer, signing: {publish: async event => { if (options.publishGate) await options.publishGate.promise; if (options.publishError) { const error = options.publishError; options.publishError = null; throw Error(error); } published.push(event); }}}; |
| 1383 | const published = []; |
| 1384 | const signer = {getPublicKey: async () => key, signEvent: async () => ({})}; |
| 1385 | class HTMLElement extends Node {} |
| 1386 | @@ -122,6 +122,77 @@ test("saved account preference enables chat without a chat control, and respects |
| 1387 | assert.equal(published.length,0); otherAccount.disconnectedCallback(); |
| 1388 | }); |
| 1389 | |
| 1390 | +test("signer recovery clears a transient presence failure without a reload", async () => { |
| 1391 | + const options = {share:true, publishError:"Signer connection changed. Retry when connected."}; |
| 1392 | + const {definitions, document, published} = setup(options); |
| 1393 | + const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key); |
| 1394 | + host.append(new Node("output")); host.parent = document; host.connectedCallback(); |
| 1395 | + await new Promise(resolve => setTimeout(resolve, 0)); |
| 1396 | + assert.match(host.status.textContent, /Presence could not be shared/); |
| 1397 | + assert.equal(host.failed, true); |
| 1398 | + document.listeners["tiny:signer-status"]({detail:{status:"ready"}}); |
| 1399 | + await new Promise(resolve => setTimeout(resolve, 0)); |
| 1400 | + assert.equal(host.failed, false); |
| 1401 | + assert.equal(host.status.textContent, ""); |
| 1402 | + await host.publish(20001, "online"); |
| 1403 | + assert.equal(published.length, 1); |
| 1404 | + host.disconnectedCallback(); |
| 1405 | +}); |
| 1406 | + |
| 1407 | +test("a presence failure from before signer recovery cannot restore the warning", async () => { |
| 1408 | + let resolve, reject; |
| 1409 | + const options = {share:false, publishGate:{promise:new Promise((done, fail) => { resolve = done; reject = fail; })}}; |
| 1410 | + const {definitions, document} = setup(options); |
| 1411 | + const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key); |
| 1412 | + host.append(new Node("output")); host.parent = document; host.connectedCallback(); host.enabled = true; |
| 1413 | + const pending = host.publish(20001, "online"); |
| 1414 | + document.listeners["tiny:signer-status"]({detail:{status:"ready"}}); |
| 1415 | + reject(Error("Signer connection changed. Retry when connected.")); |
| 1416 | + await pending; |
| 1417 | + assert.notEqual(host.failed, true); |
| 1418 | + assert.equal(host.status.textContent, ""); |
| 1419 | + resolve(); |
| 1420 | + host.disconnectedCallback(); |
| 1421 | +}); |
| 1422 | + |
| 1423 | +test("a failed hidden cleanup announcement does not create a signer warning", async () => { |
| 1424 | + const {definitions, document} = setup({publishError:"Signer connection changed. Retry when connected."}); |
| 1425 | + const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key); |
| 1426 | + host.append(new Node("output")); host.parent = document; host.connectedCallback(); |
| 1427 | + host.last[20001] = 1; document.hidden = true; |
| 1428 | + await host.publish(20001, "offline", true); |
| 1429 | + assert.notEqual(host.failed, true); |
| 1430 | + assert.equal(host.status.textContent, ""); |
| 1431 | + host.disconnectedCallback(); |
| 1432 | +}); |
| 1433 | + |
| 1434 | +test("a ready signer event queues preference reload after an in-flight request", async () => { |
| 1435 | + let resolveFirst, calls = 0; |
| 1436 | + const first = new Promise(resolve => { resolveFirst = resolve; }); |
| 1437 | + const {definitions, document} = setup({fetch: async () => ++calls === 1 ? first : {ok:true,json:async()=>({share_presence:true})}}); |
| 1438 | + const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key); |
| 1439 | + host.append(new Node("output")); host.parent = document; host.connectedCallback(); |
| 1440 | + document.listeners["tiny:signer-status"]({detail:{status:"ready"}}); |
| 1441 | + document.listeners["tiny:signer"]({detail:{}}); |
| 1442 | + assert.equal(host.preferenceReload, true); |
| 1443 | + resolveFirst({ok:true,json:async()=>({share_presence:true})}); |
| 1444 | + await new Promise(resolve => setTimeout(resolve, 0)); |
| 1445 | + await new Promise(resolve => setTimeout(resolve, 0)); |
| 1446 | + assert.equal(calls, 2); |
| 1447 | + assert.equal(host.enabled, true); |
| 1448 | + host.disconnectedCallback(); |
| 1449 | +}); |
| 1450 | + |
| 1451 | +test("a failed visible opt-out announcement does not create a signer warning", async () => { |
| 1452 | + const {definitions, document} = setup({publishError:"Signer connection changed. Retry when connected."}); |
| 1453 | + const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key); |
| 1454 | + host.append(new Node("output")); host.parent = document; host.connectedCallback(); |
| 1455 | + host.last[20001] = 1; await host.publish(20001, "offline", true); |
| 1456 | + assert.notEqual(host.failed, true); |
| 1457 | + assert.equal(host.status.textContent, ""); |
| 1458 | + host.disconnectedCallback(); |
| 1459 | +}); |
| 1460 | + |
| 1461 | test("account preference saves with a signed JSON request and restores checkbox after failure", async () => { |
| 1462 | const options={}; const {definitions,document,saved,dispatched}=setup(options); |
| 1463 | const host=new definitions["chat-preferences"]();host.setAttribute("actor",key);host.parent=document; |
| 1464 | diff --git a/scripts/rooms.test.mjs b/scripts/rooms.test.mjs |
| 1465 | index 33cdff5..b821f47 100644 |
| 1466 | --- a/scripts/rooms.test.mjs |
| 1467 | +++ b/scripts/rooms.test.mjs |
| 1468 | @@ -407,6 +407,49 @@ test("room-live on a thread page keeps only replies to its root", () => { |
| 1469 | assert.equal(source.closed, true); |
| 1470 | }); |
| 1471 | |
| 1472 | +test("room-live resolves an out-of-order nested reply to the canonical root", async () => { |
| 1473 | + const root = "a".repeat(64), parent = "b".repeat(64); |
| 1474 | + const s = setup({attributes: {room: "build", root}}); |
| 1475 | + const queries = []; |
| 1476 | + s.sandbox.fetch = async (url) => { |
| 1477 | + queries.push(url); |
| 1478 | + return {ok: true, async json() { return {root: {id: root, kind: 11, tags: [["h", "build"]]}}; }}; |
| 1479 | + }; |
| 1480 | + const live = new s.RoomLive(); |
| 1481 | + live.connectedCallback(); |
| 1482 | + const source = s.sandbox.sources[0]; |
| 1483 | + const secret = generateSecretKey(); |
| 1484 | + const nested = finalizeEvent({kind: 12, created_at: 3, tags: [["h", "build"], ["e", parent]], content: "nested"}, secret); |
| 1485 | + source.emit("message", {data: JSON.stringify(nested)}); |
| 1486 | + await new Promise(resolve => setTimeout(resolve, 0)); |
| 1487 | + assert.equal(s.list.children.length, 1); |
| 1488 | + assert.equal(s.list.children[0].id, "msg-" + nested.id); |
| 1489 | + assert.equal(queries.length, 1); |
| 1490 | + assert.match(queries[0], /method=browsethread/); |
| 1491 | + assert.match(decodeURIComponent(queries[0]), /"event":"bbbb/); |
| 1492 | + const followup = finalizeEvent({kind: 12, created_at: 4, tags: [["h", "build"], ["e", nested.id]], content: "followup"}, secret); |
| 1493 | + source.emit("message", {data: JSON.stringify(followup)}); |
| 1494 | + await new Promise(resolve => setTimeout(resolve, 0)); |
| 1495 | + assert.equal(s.list.children.length, 2); |
| 1496 | + assert.equal(queries.length, 1); |
| 1497 | +}); |
| 1498 | + |
| 1499 | +test("room-live drops a root lookup that finishes after the stream closes", async () => { |
| 1500 | + const root = "a".repeat(64), parent = "b".repeat(64); |
| 1501 | + const s = setup({attributes: {room: "build", root}}); |
| 1502 | + let finish; |
| 1503 | + s.sandbox.fetch = async () => new Promise(resolve => { finish = resolve; }); |
| 1504 | + const live = new s.RoomLive(); |
| 1505 | + live.connectedCallback(); |
| 1506 | + const source = s.sandbox.sources[0], secret = generateSecretKey(); |
| 1507 | + const nested = finalizeEvent({kind: 12, created_at: 3, tags: [["h", "build"], ["e", parent]], content: "nested"}, secret); |
| 1508 | + source.emit("message", {data: JSON.stringify(nested)}); |
| 1509 | + live.close(); |
| 1510 | + finish({ok: true, async json() { return {root: {id: root, kind: 11, tags: [["h", "build"]]}}; }}); |
| 1511 | + await new Promise(resolve => setTimeout(resolve, 0)); |
| 1512 | + assert.equal(s.list.children.length, 0); |
| 1513 | +}); |
| 1514 | + |
| 1515 | test("chat markdown renders the shared subset without ever parsing markup", () => { |
| 1516 | const s = setup(); |
| 1517 | const body = s.rooms.chatMarkdown(new FakeNode("div"), "It works: **[the page](https://012.run/wiki/agents)**.\nsee https://example.com/x, plus <b>not markup</b>\n\n- `code https://not.a.link`\n- [x](javascript:alert(1))\n\n```\nhttps://in.code\n```"); |