tinyrelaycommit

Commit 5625d0b5f023

browse files at this commit | history

1diff --git a/docs/agents.md b/docs/agents.md
2index 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
14diff --git a/docs/mcp.md b/docs/mcp.md
15index 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
36diff --git a/docs/rooms.md b/docs/rooms.md
37index 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
59diff --git a/internal/agentrunner/relay.go b/internal/agentrunner/relay.go
60index 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}})
228diff --git a/internal/agentrunner/relay_test.go b/internal/agentrunner/relay_test.go
229new file mode 100644
230index 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+}
458diff --git a/internal/agentrunner/service.go b/internal/agentrunner/service.go
459index 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")
477diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go
478index 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.",
490diff --git a/internal/daemon/mcp_test.go b/internal/daemon/mcp_test.go
491index 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"])
526diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
527index 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 != "":
613diff --git a/internal/daemon/room_threads.go b/internal/daemon/room_threads.go
614index 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
647diff --git a/internal/daemon/rooms_browse.go b/internal/daemon/rooms_browse.go
648index 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 }
662diff --git a/internal/daemon/rooms_test.go b/internal/daemon/rooms_test.go
663index 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")
765diff --git a/internal/daemon/webui_rooms.go b/internal/daemon/webui_rooms.go
766index 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) {
942diff --git a/internal/webui/chat-presence.js b/internal/webui/chat-presence.js
943index 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); }
1029diff --git a/internal/webui/mobile_header.go b/internal/webui/mobile_header.go
1030index 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
1048diff --git a/internal/webui/mobile_header_test.go b/internal/webui/mobile_header_test.go
1049index 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})
1070diff --git a/internal/webui/read_contracts.go b/internal/webui/read_contracts.go
1071index 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
1122diff --git a/internal/webui/rooms.go b/internal/webui/rooms.go
1123index 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 == ""
1186diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html
1187index 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}}
1203diff --git a/internal/webui/rooms.js b/internal/webui/rooms.js
1204index 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&params=" + 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
1345diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go
1346index 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{}
1373diff --git a/scripts/chat-presence.test.mjs b/scripts/chat-presence.test.mjs
1374index 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;
1464diff --git a/scripts/rooms.test.mjs b/scripts/rooms.test.mjs
1465index 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```");

About

ownerc01503e8035f
visibilitypublic
head5625d0b5f023

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs