Page room timelines by visible messages and retain complete folded interaction summaries. Resolve legacy reply ancestry in live chat, agent replies and unsigned MCP templates. Clear recovered presence failures and ignore stale background cleanup errors. Update chat guidance and add browser, protocol and pagination regressions.
- commit
- 5625d0b5f0230cb9d592d0e13baa9494a168f954
- parent
- 68dd278487c04ba3a334ed1ff452efade191328c
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 2 | +- |
| M | docs/ | 4 | +- |
| M | docs/ | 6 | +- |
| M | internal/ | 127 | +++++++++++++++++- |
| A | internal/ | 224 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 8 | + |
| M | internal/ | 2 | +- |
| M | internal/ | 18 | ++ |
| M | internal/ | 46 | ++++++- |
| M | internal/ | 9 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 85 | +++++++++++ |
| M | internal/ | 133 | +++++++++++++++++- |
| M | internal/ | 34 | ++++- |
| M | internal/ | 8 | +- |
| M | internal/ | 11 | + |
| M | internal/ | 34 | +++-- |
| M | internal/ | 24 | ++- |
| M | internal/ | 4 | +- |
| M | internal/ | 82 | ++++++++++- |
| M | internal/ | 17 | ++ |
| M | scripts/ | 73 | ++++++++++- |
| M | scripts/ | 43 | ++++++ |
@@ -219,7 +219,7 @@ A result, or feedback that reports `error` or `payment-required`, wakes the requ
219219 220220 ## Agent chat features221221 222-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`.222+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`.223223 224224 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.225225 @@ -109,7 +109,7 @@ Write tools, which publish through the same path as `POST /events`:
109109 | `set_status` | Mark an issue or pull request open, resolved, merged, closed or draft with a kind 1630 to 1633 event. |110110 | `post_message` | Post a kind 9 message in a room, with optional `mentions` and `attachments`. |111111 | `start_thread` | Start a kind 11 thread in a room with an optional title and `attachments`. |112-| `reply_in_thread` | Reply to a thread with a kind 12 event naming the root in its `e` tag and optional `attachments`. |112+| `reply_in_thread` | Reply with a kind 12 event and optional `attachments`. Unsigned templates resolve a reply ID to the original thread root. |113113 | `react` | Publish a kind 7 reaction to an event: `+`, `-` or one emoji, with `room` for a room message. |114114 | `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. |115115 | `propose_wiki_merge` | Ask a page's author to take in a version with a kind 818 merge request. |@@ -131,7 +131,7 @@ An agent whose grant holds `propose` on a repository publishes issues, pull requ
131131 132132 ### Asking a person133133 134-`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.134+`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.135135 136136 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.137137 @@ -65,15 +65,15 @@ One-to-one messages require a connected signer with NIP-44 support. The message
6565 6666 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.6767 68-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.68+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.6969 70-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.70+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.7171 7272 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.7373 7474 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.7575 76-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.76+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.7777 7878 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.7979 @@ -25,6 +25,12 @@ type RelayClient struct {
2525 conn *websocket.Conn2626 connection context.Context2727 pending map[string]chan error28+ queries map[string]chan queryResult29+}30+31+type queryResult struct {32+ event event.Event33+ err error2834 }2935 3036 func (c *RelayClient) Run(ctx context.Context) error {@@ -69,6 +75,7 @@ func (c *RelayClient) connect(ctx context.Context) error {
6975 c.conn = conn7076 c.connection = scope7177 c.pending = map[string]chan error{}78+ c.queries = make(map[string]chan queryResult)7279 c.mu.Unlock()7380 defer func() {7481 c.mu.Lock()@@ -144,7 +151,21 @@ func (c *RelayClient) connect(ctx context.Context) error {
144151 }145152 }146153 case "EOSE":147- if c.OnReady != nil {154+ var subscription string155+ if len(frame) > 1 {156+ _ = json.Unmarshal(frame[1], &subscription)157+ c.mu.Lock()158+ query := c.queries[subscription]159+ c.mu.Unlock()160+ if query != nil {161+ select {162+ case query <- queryResult{err: errors.New("thread parent not found")}:163+ default:164+ }165+ continue166+ }167+ }168+ if subscription == "tiny-agent" && c.OnReady != nil {148169 c.OnReady()149170 }150171 case "EVENT":@@ -155,10 +176,41 @@ func (c *RelayClient) connect(ctx context.Context) error {
155176 if err != nil {156177 continue157178 }158- if c.OnEvent != nil {179+ c.mu.Lock()180+ var subscription string181+ _ = json.Unmarshal(frame[1], &subscription)182+ query := c.queries[subscription]183+ c.mu.Unlock()184+ if query != nil {185+ select {186+ case query <- queryResult{event: e}:187+ default:188+ }189+ continue190+ }191+ if subscription == "tiny-agent" && c.OnEvent != nil {159192 c.OnEvent(e)160193 }161194 case "CLOSED":195+ var subscription string196+ _ = json.Unmarshal(frame[1], &subscription)197+ c.mu.Lock()198+ query := c.queries[subscription]199+ c.mu.Unlock()200+ if query != nil {201+ reason := "agent query closed"202+ if len(frame) > 2 {203+ _ = json.Unmarshal(frame[2], &reason)204+ }205+ select {206+ case query <- queryResult{err: errors.New(reason)}:207+ default:208+ }209+ continue210+ }211+ if subscription != "tiny-agent" {212+ continue213+ }162214 if len(frame) > 2 {163215 var reason string164216 json.Unmarshal(frame[2], &reason)@@ -169,6 +221,77 @@ func (c *RelayClient) connect(ctx context.Context) error {
169221 }170222 }171223 }224+225+func (c *RelayClient) ResolveRoot(ctx context.Context, room, id string) (string, error) {226+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)227+ defer cancel()228+ seen := map[string]bool{}229+ for len(seen) < 64 && !seen[id] {230+ seen[id] = true231+ e, err := c.queryEvent(ctx, room, id)232+ if err != nil {233+ return "", err234+ }235+ parent := event.RoomReplyRoot(e)236+ if parent == "" {237+ return e.ID, nil238+ }239+ id = parent240+ }241+ return "", errors.New("thread nesting limit exceeded")242+}243+244+func (c *RelayClient) queryEvent(ctx context.Context, room, id string) (event.Event, error) {245+ c.mu.Lock()246+ conn := c.conn247+ scope := c.connection248+ if conn == nil {249+ c.mu.Unlock()250+ return event.Event{}, errors.New("agent relay is disconnected")251+ }252+ sub := fmt.Sprintf("root-%d", time.Now().UnixNano())253+ wait := make(chan queryResult, 1)254+ c.queries[sub] = wait255+ c.mu.Unlock()256+ defer func() {257+ c.mu.Lock()258+ delete(c.queries, sub)259+ active := c.conn == conn && c.connection == scope260+ c.mu.Unlock()261+ if active {262+ closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)263+ defer cancel()264+ _ = writeRelay(closeCtx, conn, []any{"CLOSE", sub})265+ }266+ }()267+ if err := writeRelay(ctx, conn, []any{"REQ", sub, map[string]any{"ids": []string{id}, "#h": []string{room}, "limit": 1}}); err != nil {268+ return event.Event{}, err269+ }270+ select {271+ case result, ok := <-wait:272+ if !ok {273+ return event.Event{}, errors.New("thread parent not found")274+ }275+ if result.err != nil {276+ return event.Event{}, result.err277+ }278+ e := result.event279+ if e.ID != id {280+ return event.Event{}, errors.New("thread parent ID mismatch")281+ }282+ if event.Tag(e, "h") != room {283+ return event.Event{}, errors.New("thread parent is in another room")284+ }285+ if e.Kind != 9 && e.Kind != 11 && e.Kind != 12 && e.Kind != 40002 {286+ return event.Event{}, errors.New("thread parent has an invalid kind")287+ }288+ return e, nil289+ case <-ctx.Done():290+ return event.Event{}, ctx.Err()291+ case <-scope.Done():292+ return event.Event{}, errors.New("agent relay disconnected")293+ }294+}172295 func (c *RelayClient) subscribe(ctx context.Context, conn *websocket.Conn) error {173296 since := time.Now().Add(-5 * time.Minute).Unix()174297 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}})@@ -0,0 +1,224 @@
1+package agentrunner2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "net/http"8+ "net/http/httptest"9+ "strings"10+ "sync"11+ "testing"12+ "time"13+14+ "github.com/FelineStateMachine/tinyrelay/internal/event"15+ "github.com/coder/websocket"16+)17+18+const relayTestSecret = "0000000000000000000000000000000000000000000000000000000000000001"19+20+func TestResolveRootQueriesUnseenParentsWithoutDispatchingThem(t *testing.T) {21+ room := "room"22+ root := relayTestEvent(t, 11, room, "")23+ middle := relayTestEvent(t, 12, room, root.ID)24+ leaf := relayTestEvent(t, 12, room, middle.ID)25+ events := map[string]event.Event{root.ID: root, middle.ID: middle, leaf.ID: leaf}26+ var mu sync.Mutex27+ var closed []string28+ ready := make(chan struct{}, 1)29+ server := relayTestServer(t, events, &mu, &closed, ready)30+ defer server.Close()31+32+ client := RelayClient{URL: "ws" + strings.TrimPrefix(server.URL, "http"), Secret: relayTestSecret, Rooms: []string{room}}33+ var dispatched int34+ client.OnEvent = func(event.Event) { dispatched++ }35+ ctx, cancel := context.WithCancel(context.Background())36+ defer cancel()37+ done := make(chan error, 1)38+ go func() { done <- client.Run(ctx) }()39+ select {40+ case <-ready:41+ case <-time.After(time.Second):42+ t.Fatal("relay did not finish its initial subscription")43+ }44+45+ queryCtx, queryCancel := context.WithTimeout(context.Background(), time.Second)46+ defer queryCancel()47+ got, err := client.ResolveRoot(queryCtx, room, leaf.ID)48+ if err != nil {49+ t.Fatal(err)50+ }51+ if got != root.ID {52+ t.Fatalf("root=%s, want %s", got, root.ID)53+ }54+ if dispatched != 0 {55+ t.Fatalf("query events dispatched to agent callback: %d", dispatched)56+ }57+ mu.Lock()58+ closeCount := len(closed)59+ mu.Unlock()60+ deadline := time.Now().Add(time.Second)61+ for closeCount < 3 && time.Now().Before(deadline) {62+ time.Sleep(time.Millisecond)63+ mu.Lock()64+ closeCount = len(closed)65+ mu.Unlock()66+ }67+ if closeCount != 3 {68+ t.Fatalf("CLOSE frames=%d, want 3", closeCount)69+ }70+ cancel()71+ select {72+ case <-done:73+ case <-time.After(time.Second):74+ t.Fatal("relay did not stop")75+ }76+}77+78+func TestResolveRootRejectsInvalidQueriedEvents(t *testing.T) {79+ tests := []struct {80+ name string81+ make func(t *testing.T) (string, event.Event)82+ want string83+ }{84+ {"wrong room", func(t *testing.T) (string, event.Event) {85+ e := relayTestEvent(t, 11, "other", "")86+ return "room", e87+ }, "another room"},88+ {"invalid kind", func(t *testing.T) (string, event.Event) {89+ e := relayTestEvent(t, 1, "room", "")90+ return "room", e91+ }, "invalid kind"},92+ }93+ for _, tt := range tests {94+ t.Run(tt.name, func(t *testing.T) {95+ room, e := tt.make(t)96+ server := relayTestServer(t, map[string]event.Event{e.ID: e}, nil, nil, make(chan struct{}, 1))97+ defer server.Close()98+ client, stop := runningTestRelay(t, server, room)99+ defer stop()100+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)101+ defer cancel()102+ _, err := client.ResolveRoot(ctx, room, e.ID)103+ if err == nil || !strings.Contains(err.Error(), tt.want) {104+ t.Fatalf("error=%v, want substring %q", err, tt.want)105+ }106+ })107+ }108+}109+110+func TestResolveRootTimesOutAndClosesQuery(t *testing.T) {111+ root := relayTestEvent(t, 11, "room", "")112+ server := relayTestServer(t, map[string]event.Event{}, nil, nil, make(chan struct{}, 1))113+ defer server.Close()114+ client, stop := runningTestRelay(t, server, "room")115+ defer stop()116+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)117+ defer cancel()118+ _, err := client.ResolveRoot(ctx, "room", root.ID)119+ if !errors.Is(err, context.DeadlineExceeded) {120+ t.Fatalf("error=%v, want deadline exceeded", err)121+ }122+}123+124+func relayTestEvent(t *testing.T, kind int, room, parent string) event.Event {125+ t.Helper()126+ tags := [][]string{{"h", room}}127+ if parent != "" {128+ tags = append(tags, []string{"e", parent, "", "root"})129+ }130+ e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: "test"}131+ if err := event.Sign(&e, relayTestSecret); err != nil {132+ t.Fatal(err)133+ }134+ return e135+}136+137+func runningTestRelay(t *testing.T, server *httptest.Server, room string) (*RelayClient, func()) {138+ t.Helper()139+ client := &RelayClient{URL: "ws" + strings.TrimPrefix(server.URL, "http"), Secret: relayTestSecret, Rooms: []string{room}}140+ ctx, cancel := context.WithCancel(context.Background())141+ done := make(chan error, 1)142+ go func() { done <- client.Run(ctx) }()143+ deadline := time.Now().Add(time.Second)144+ for {145+ client.mu.Lock()146+ connected := client.conn != nil147+ client.mu.Unlock()148+ if connected {149+ break150+ }151+ if time.Now().After(deadline) {152+ t.Fatal("relay did not connect")153+ }154+ time.Sleep(time.Millisecond)155+ }156+ return client, func() {157+ cancel()158+ select {159+ case <-done:160+ case <-time.After(time.Second):161+ t.Fatal("relay did not stop")162+ }163+ }164+}165+166+func relayTestServer(t *testing.T, events map[string]event.Event, mu *sync.Mutex, closed *[]string, ready chan struct{}) *httptest.Server {167+ t.Helper()168+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {169+ conn, err := websocket.Accept(w, r, nil)170+ if err != nil {171+ t.Error(err)172+ return173+ }174+ defer conn.CloseNow()175+ for {176+ _, raw, err := conn.Read(r.Context())177+ if err != nil {178+ return179+ }180+ var frame []json.RawMessage181+ if json.Unmarshal(raw, &frame) != nil || len(frame) < 2 {182+ continue183+ }184+ var kind, sub string185+ _ = json.Unmarshal(frame[0], &kind)186+ _ = json.Unmarshal(frame[1], &sub)187+ switch kind {188+ case "REQ":189+ if sub == "tiny-agent" {190+ _ = conn.Write(r.Context(), websocket.MessageText, []byte(`["EOSE","tiny-agent"]`))191+ select {192+ case ready <- struct{}{}:193+ default:194+ }195+ continue196+ }197+ var filter map[string]any198+ if len(frame) > 2 {199+ _ = json.Unmarshal(frame[2], &filter)200+ }201+ var id string202+ if value, ok := filter["ids"].([]any); ok && len(value) > 0 {203+ id, _ = value[0].(string)204+ }205+ if e, ok := events[id]; ok {206+ payload, _ := json.Marshal([]any{"EVENT", sub, e})207+ _ = conn.Write(r.Context(), websocket.MessageText, payload)208+ }209+ if _, found := events[id]; found {210+ payload, _ := json.Marshal([]any{"EOSE", sub})211+ _ = conn.Write(r.Context(), websocket.MessageText, payload)212+ }213+ case "CLOSE":214+ payload, _ := json.Marshal([]any{"CLOSED", sub, "subscription closed"})215+ _ = conn.Write(r.Context(), websocket.MessageText, payload)216+ if mu != nil && closed != nil {217+ mu.Lock()218+ *closed = append(*closed, sub)219+ mu.Unlock()220+ }221+ }222+ }223+ }))224+}@@ -83,6 +83,14 @@ func (s *service) handle(parent context.Context, batch []Mention) error {
8383 ctx, cancel := context.WithTimeout(parent, s.options.Timeout)8484 defer cancel()8585 first := batch[0]86+ root, err := s.relay.ResolveRoot(ctx, first.Room, first.Root)87+ if err != nil {88+ return err89+ }90+ first.Root = root91+ // Keep the canonical root on the batch so permission requests emitted92+ // while this task runs stay attached to the same top-level conversation.93+ batch[0].Root = root8694 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)}}8795 var prompt strings.Builder8896 prompt.WriteString("These are messages addressed to you in a Nostr chat. Reply to the users' request. Room: " + first.Room + "\n\n")@@ -109,7 +109,7 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
109109 "",110110 "## Chat and Social",111111 "",112- "- 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.",112+ "- 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.",113113 "- 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.",114114 "- 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.",115115 "- 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.",@@ -454,6 +454,18 @@ func TestMCPRoomWikiAndAgentTools(t *testing.T) {
454454 if isError || len(replies) != 1 || structured(result)["root"].(map[string]any)["id"] != messageID {455455 t.Fatalf("read_thread: %s", text(result))456456 }457+ nestedReply := mcpSigned(t, testOwnerSecret, 12, [][]string{{"h", "general"}, {"e", messageID}, {"p", member}}, "follow-up")458+ nestedResult, nestedError := call("reply_in_thread", map[string]any{"event": nestedReply}, testOwnerSecret)459+ nestedID, _ := structured(nestedResult)["event_id"].(string)460+ if nestedError || nestedID == "" {461+ t.Fatalf("nested reply publish: %s", text(nestedResult))462+ }463+ result, isError = call("reply_in_thread", map[string]any{"room": "general", "root": nestedID, "root_pubkey": member, "content": "canonical follow-up"}, "")464+ unsigned, _ = structured(result)["unsigned"].(map[string]any)465+ tags, _ = json.Marshal(unsigned["tags"])466+ if isError || !strings.Contains(string(tags), `"e","`+messageID+`"`) || strings.Contains(string(tags), nestedID) {467+ t.Fatalf("reply_in_thread did not flatten nested root: %s", text(result))468+ }457469 458470 // react: content is +, - or one emoji, whether templated or signed.459471 result, isError = call("react", map[string]any{"target": messageID, "target_pubkey": member, "content": "great", "room": "general"}, "")@@ -560,6 +572,12 @@ func TestMCPRoomWikiAndAgentTools(t *testing.T) {
560572 if isError || unsigned["kind"] != float64(9) || string(tags) != `[["h","general"],["request","approve"],["p","`+member+`"],["expiration","`+strconv.FormatInt(now+3600, 10)+`"],["subject","Release"]]` {561573 t.Fatalf("request_decision room template: %s", text(result))562574 }575+ result, isError = call("request_decision", map[string]any{"pubkey": member, "request": "approve", "content": "Approve this thread?", "room": "general", "root": messageID}, "")576+ unsigned, _ = structured(result)["unsigned"].(map[string]any)577+ tags, _ = json.Marshal(unsigned["tags"])578+ if isError || unsigned["kind"] != float64(9) || !strings.Contains(string(tags), `"e","`+messageID+`","","root"`) {579+ t.Fatalf("request_decision room thread template: %s", text(result))580+ }563581 result, isError = call("request_decision", map[string]any{"pubkey": member, "request": "question", "content": "Which name?", "root": pageID, "root_kind": 30818, "root_pubkey": owner}, "")564582 unsigned, _ = structured(result)["unsigned"].(map[string]any)565583 tags, _ = json.Marshal(unsigned["tags"])@@ -70,7 +70,7 @@ const (
7070 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.`7171 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.`7272 mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].`73- 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.`73+ 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.`7474 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.`7575 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.`7676 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.`@@ -223,13 +223,13 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
223223 attachments := map[string]any{"type": "array", "items": mcpAttachment, "maxItems": 8}224224 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))225225 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))226- 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))226+ 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))227227 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))228228 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))229229 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))230230 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))231231 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))232- 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))232+ 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))233233 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))234234 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))235235 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))@@ -293,10 +293,48 @@ func (t *Tenant) mcpWrite(build func(mcp.Call) (mcpUnsigned, error), check func(
293293 if err != nil {294294 return mcp.Failure(err.Error()+"\n"+shape, map[string]any{"expected": shape}), nil295295 }296+ unsigned, err = t.canonicalizeRoomThreadUnsigned(ctx, call.Actor, unsigned)297+ if err != nil {298+ return mcp.Failure(err.Error()+"\n"+shape, map[string]any{"expected": shape}), nil299+ }296300 return mcp.Value(map[string]any{"unsigned": unsigned, "next": mcpSignNext}), nil297301 }298302 }299303 304+// canonicalizeRoomThreadUnsigned keeps room replies at one visible level.305+// The event is still unsigned, so its parent reference can be normalized306+// before the client signs it. Signed events are intentionally checked as307+// submitted because changing them would invalidate their signatures.308+func (t *Tenant) canonicalizeRoomThreadUnsigned(ctx context.Context, actor string, unsigned mcpUnsigned) (mcpUnsigned, error) {309+ if unsigned.Kind != event.KIND_THREAD_REPLY && unsigned.Kind != event.KIND_CHAT {310+ return unsigned, nil311+ }312+ room := event.Tag(event.Event{Tags: unsigned.Tags}, "h")313+ rootID := event.Tag(event.Event{Tags: unsigned.Tags}, "e")314+ if room == "" || rootID == "" {315+ return unsigned, nil316+ }317+ root, err := t.roomThreadRoot(ctx, actor, room, rootID)318+ if err != nil {319+ return mcpUnsigned{}, err320+ }321+ for _, tag := range unsigned.Tags {322+ if len(tag) >= 2 && tag[0] == "e" {323+ tag[1] = root.ID324+ break325+ }326+ }327+ if unsigned.Kind == event.KIND_THREAD_REPLY {328+ for _, tag := range unsigned.Tags {329+ if len(tag) >= 2 && tag[0] == "p" {330+ tag[1] = root.PubKey331+ break332+ }333+ }334+ }335+ return unsigned, nil336+}337+300338 func (t *Tenant) mcpPublish(ctx context.Context, call mcp.Call, e event.Event) mcp.Result {301339 s := relay.Session{RelayURL: t.RelayURL(), PubKeys: []string{call.Actor}}302340 if call.Request != nil {@@ -730,7 +768,7 @@ func mcpBuildRequest(call mcp.Call) (mcpUnsigned, error) {
730768 var tags [][]string731769 switch {732770 case room != "" && root != "":733- return mcpUnsigned{}, errors.New("pass either room or root, not both")771+ kind, tags = event.KIND_CHAT, [][]string{{"h", room}, {"e", root, "", "root"}}734772 case room != "":735773 kind, tags = event.KIND_CHAT, [][]string{{"h", room}}736774 case root != "":@@ -8,6 +8,11 @@ import (
88 "github.com/FelineStateMachine/tinyrelay/internal/storage"99 )1010 11+var (12+ errRoomThreadSizeLimit = errors.New("restricted: thread exceeds the browser limit")13+ errRoomThreadDepthLimit = errors.New("restricted: thread exceeds the nesting limit")14+)15+1116 func (t *Tenant) roomThreadRoot(ctx context.Context, actor, room, id string) (event.Event, error) {1217 seen := map[string]bool{}1318 for len(seen) < 64 && !seen[id] {@@ -57,7 +62,7 @@ func (t *Tenant) roomThreadReplies(ctx context.Context, actor, room, root string
5762 replies = append(replies, row)5863 nextLevel = append(nextLevel, row.ID)5964 if len(replies) > 5000 {60- return nil, errors.New("restricted: thread exceeds the browser limit")65+ return nil, errRoomThreadSizeLimit6166 }6267 }6368 if next == "" {@@ -72,7 +77,7 @@ func (t *Tenant) roomThreadReplies(ctx context.Context, actor, room, root string
7277 frontier = nextLevel7378 }7479 if len(frontier) > 0 {75- return nil, errors.New("restricted: thread exceeds the nesting limit")80+ return nil, errRoomThreadDepthLimit7681 }7782 sortEventsNewestFirst(replies)7883 return replies, nil@@ -123,7 +123,9 @@ func wireRoomPage(page webui.RoomPage, thread bool) map[string]any {
123123 }124124 125125 func (t *Tenant) browseRoom(ctx context.Context, actor string, q roomBrowseRequest) (any, error) {126- page, err := t.readRoomCore(ctx, actor, q.ID, q.Cursor, q.Limit)126+ // The MCP browser contract exposes raw room events for agents. The typed127+ // web reader folds replies into top-level pages for the UI.128+ page, err := t.readRoomCore(ctx, actor, q.ID, q.Cursor, q.Limit, false)127129 if err != nil {128130 return nil, err129131 }@@ -4,6 +4,7 @@ import (
44 "bufio"55 "context"66 "encoding/json"7+ "fmt"78 "net/http"89 "net/http/httptest"910 "strings"@@ -320,6 +321,90 @@ func TestRoomThreadRejectsCrossRoomRootsAndPaginatesNestedReplies(t *testing.T)
320321 _ = second321322 }322323 324+func TestReadRoomPagesTopLevelRootsPastFoldedReplies(t *testing.T) {325+ h := newRoomHarness(t)326+ older := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "older root")327+ root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "root before reply burst")328+ for i := 0; i < 105; i++ {329+ h.must("bob", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", root.ID, "", "reply"}}, fmt.Sprintf("folded reply %d", i))330+ }331+ h.must("alice", 7, [][]string{{"h", "main"}, {"e", root.ID}}, "+")332+ olderReply := h.must("bob", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", older.ID, "", "reply"}}, "reply to older root")333+ h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", olderReply.ID, "", "reply"}}, "nested reply to older root")334+ h.must("bob", 7, [][]string{{"h", "main"}, {"e", older.ID}}, "heart")335+ seen := map[string]bool{}336+ cursor := ""337+ for pages := 0; ; pages++ {338+ page, err := h.tenant.ReadRoom(h.ctx, h.keys["bob"], "main", cursor, 1)339+ if err != nil {340+ t.Fatal(err)341+ }342+ if len(page.Messages) > 1 || pages > 20 {343+ t.Fatalf("unbounded timeline page: %d messages on page %d", len(page.Messages), pages)344+ }345+ for _, row := range page.Messages {346+ if seen[row.ID] {347+ t.Fatalf("repeated message across tied timestamp pages: %s", row.ID)348+ }349+ seen[row.ID] = true350+ switch row.ID {351+ case root.ID:352+ if page.ReplyCounts[root.ID] != 105 || page.ReactionCounts[root.ID]["+1"] != 1 {353+ t.Fatalf("burst summaries missing: %+v", page)354+ }355+ case older.ID:356+ if page.ReplyCounts[older.ID] != 2 || page.ReactionCounts[older.ID]["heart"] != 1 {357+ t.Fatalf("older root summaries missing: %+v", page)358+ }359+ default:360+ if row.Kind != event.KIND_ROOM_MEMBER_ADDED && row.Kind != event.KIND_ROOM_MEMBER_REMOVED {361+ t.Fatalf("unfolded event on timeline: %d %s", row.Kind, row.ID)362+ }363+ }364+ }365+ if page.NextCursor == "" {366+ break367+ }368+ if page.NextCursor == cursor || len(page.Messages) == 0 {369+ t.Fatal("pagination failed to advance through visible messages")370+ }371+ cursor = page.NextCursor372+ }373+ if !seen[root.ID] || !seen[older.ID] {374+ t.Fatalf("timeline skipped roots after folded reply burst: %v", seen)375+ }376+ thread, err := h.tenant.ReadThread(h.ctx, h.keys["bob"], "main", root.ID, "", 1)377+ if err != nil {378+ t.Fatal(err)379+ }380+ if thread.ReplyCounts[root.ID] != 105 || len(thread.Replies) != 1 {381+ t.Fatalf("thread folded count = %d replies=%d", thread.ReplyCounts[root.ID], len(thread.Replies))382+ }383+}384+385+func TestReadRoomRemainsReadableWhenThreadExceedsTraversalLimit(t *testing.T) {386+ h := newRoomHarness(t)387+ root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "root")388+ parent := root.ID389+ for i := 0; i < 65; i++ {390+ row := h.must("bob", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", parent, "", "reply"}}, "deep reply")391+ parent = row.ID392+ }393+ page, err := h.tenant.ReadRoom(h.ctx, h.keys["alice"], "main", "", 50)394+ if err != nil {395+ t.Fatalf("one bounded thread blocked the room: %v", err)396+ }397+ for _, row := range page.Messages {398+ if row.ID == root.ID {399+ if _, found := page.ReplyCounts[root.ID]; found {400+ t.Fatal("a partial reply count was presented as complete")401+ }402+ return403+ }404+ }405+ t.Fatal("root missing from room")406+}407+323408 func TestTypedRoomReaderPreservesSignedEvents(t *testing.T) {324409 h := newRoomHarness(t)325410 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")@@ -3,9 +3,11 @@ package daemon
33 import (44 "context"55 "errors"6+ "strings"67 78 "github.com/FelineStateMachine/tinyrelay/internal/community"89 "github.com/FelineStateMachine/tinyrelay/internal/event"10+ "github.com/FelineStateMachine/tinyrelay/internal/storage"911 "github.com/FelineStateMachine/tinyrelay/internal/webui"1012 )1113 @@ -72,10 +74,10 @@ func (t *Tenant) ReadRoom(ctx context.Context, actor, roomID, cursor string, lim
7274 return webui.RoomPage{}, err7375 }7476 defer done()75- return t.readRoomCore(ctx, actor, roomID, cursor, limit)77+ return t.readRoomCore(ctx, actor, roomID, cursor, limit, true)7678 }7779 78-func (t *Tenant) readRoomCore(ctx context.Context, actor, roomID, cursor string, limit int) (webui.RoomPage, error) {80+func (t *Tenant) readRoomCore(ctx context.Context, actor, roomID, cursor string, limit int, foldReplies bool) (webui.RoomPage, error) {7981 if err := t.browseRead(ctx, actor); err != nil {8082 return webui.RoomPage{}, err8183 }@@ -95,7 +97,12 @@ func (t *Tenant) readRoomCore(ctx context.Context, actor, roomID, cursor string,
9597 if err != nil {9698 return webui.RoomPage{}, err9799 }98- messages, next, err := t.roomMessages(ctx, actor, event.Filter{Kinds: roomMessageKinds, Tags: map[string][]string{"h": {room.ID}}}, position, normalizeRoomLimit(limit))100+ readMessages := t.roomMessages101+ if foldReplies {102+ readMessages = t.roomTimelineMessages103+ }104+ messageFilter := event.Filter{Kinds: roomMessageKinds, Tags: map[string][]string{"h": {room.ID}}}105+ messages, next, err := readMessages(ctx, actor, messageFilter, position, normalizeRoomLimit(limit))99106 if err != nil {100107 return webui.RoomPage{}, err101108 }@@ -103,7 +110,123 @@ func (t *Tenant) readRoomCore(ctx context.Context, actor, roomID, cursor string,
103110 if err != nil {104111 return webui.RoomPage{}, err105112 }106- return webui.RoomPage{Room: webuiRoomSummary(summary), Members: members, Messages: messages, Edits: edits, NextCursor: next}, nil113+ page := webui.RoomPage{Room: webuiRoomSummary(summary), Members: members, Messages: messages, Edits: edits, NextCursor: next}114+ if foldReplies {115+ page.ReplyCounts, page.ReactionCounts, err = t.roomInteractionSummaries(ctx, actor, room.ID, messages)116+ if err != nil {117+ return webui.RoomPage{}, err118+ }119+ }120+ return page, nil121+}122+123+// roomTimelineMessages pages by visible top-level messages rather than raw124+// events. Folded replies and reactions are summarized separately so a burst125+// of replies cannot make the page empty or consume unbounded response memory.126+func (t *Tenant) roomTimelineMessages(ctx context.Context, actor string, filter event.Filter, cursor *storage.EventCursor, limit int) ([]event.Event, string, error) {127+ if limit <= 0 {128+ limit = 100129+ }130+ position := cursor131+ rows := make([]event.Event, 0, limit)132+ for {133+ page, next, err := t.roomMessages(ctx, actor, filter, position, 100)134+ if err != nil {135+ return nil, "", err136+ }137+ for i, row := range page {138+ if roomTimelineTopLevel(row) || (row.Kind == event.KIND_ROOM_MEMBER_ADDED || row.Kind == event.KIND_ROOM_MEMBER_REMOVED) && event.Tag(row, "p") != "" {139+ rows = append(rows, row)140+ }141+ if len(rows) < limit {142+ continue143+ }144+ if next == "" && i == len(page)-1 {145+ return rows, "", nil146+ }147+ return rows, collaborationCursor(collaborationItemFrom(row)), nil148+ }149+ if next == "" {150+ return rows, "", nil151+ }152+ position, err = parseCollaborationCursor(next)153+ if err != nil {154+ return nil, "", err155+ }156+ }157+}158+159+func (t *Tenant) roomInteractionSummaries(ctx context.Context, actor, roomID string, roots []event.Event) (map[string]int, map[string]map[string]int, error) {160+ counts := make(map[string]int)161+ reactions := make(map[string]map[string]int)162+ for _, root := range roots {163+ if !roomTimelineTopLevel(root) {164+ continue165+ }166+ replies, err := t.roomThreadReplies(ctx, actor, roomID, root.ID)167+ if errors.Is(err, errRoomThreadSizeLimit) || errors.Is(err, errRoomThreadDepthLimit) {168+ // A bounded thread view must not prevent reading the rest of the169+ // room. Omit its count instead of presenting a partial total.170+ continue171+ }172+ if err != nil {173+ return nil, nil, err174+ }175+ counts[root.ID] = len(replies)176+ }177+ ids := make([]string, 0, len(roots))178+ selected := make(map[string]bool, len(roots))179+ for _, root := range roots {180+ if roomTimelineTopLevel(root) {181+ ids = append(ids, root.ID)182+ selected[root.ID] = true183+ }184+ }185+ if len(ids) == 0 {186+ return counts, reactions, nil187+ }188+ filter := event.Filter{Kinds: []int{7}, Tags: map[string][]string{"h": {roomID}, "e": ids}}189+ var cursor *storage.EventCursor190+ for {191+ rows, next, err := t.roomMessages(ctx, actor, filter, cursor, 100)192+ if err != nil {193+ return nil, nil, err194+ }195+ for _, row := range rows {196+ targets := event.TagValues(row, "e")197+ if len(targets) == 0 {198+ continue199+ }200+ target := targets[len(targets)-1]201+ if !selected[target] {202+ continue203+ }204+ content := strings.TrimSpace(row.Content)205+ if content == "" || content == "+" {206+ content = "+1"207+ }208+ if reactions[target] == nil {209+ reactions[target] = make(map[string]int)210+ }211+ reactions[target][content]++212+ }213+ if next == "" {214+ return counts, reactions, nil215+ }216+ cursor, err = parseCollaborationCursor(next)217+ if err != nil {218+ return nil, nil, err219+ }220+ }221+}222+223+func roomTimelineTopLevel(row event.Event) bool {224+ switch row.Kind {225+ case event.KIND_CHAT, event.KIND_THREAD, event.KIND_RICH_CONTENT:226+ return event.RoomReplyRoot(row) == ""227+ default:228+ return false229+ }107230 }108231 109232 func (t *Tenant) ReadThread(ctx context.Context, actor, roomID, rootID, cursor string, limit int) (webui.RoomPage, error) {@@ -148,7 +271,7 @@ func (t *Tenant) readThreadCore(ctx context.Context, actor, roomID, rootID, curs
148271 if err != nil {149272 return webui.RoomPage{}, err150273 }151- return webui.RoomPage{Room: webuiRoomSummaryFromRoom(room), Members: members, Root: &root, Replies: replies, Edits: edits, NextCursor: next}, nil274+ return webui.RoomPage{Room: webuiRoomSummaryFromRoom(room), Members: members, Root: &root, Replies: replies, ReplyCounts: map[string]int{root.ID: len(all)}, Edits: edits, NextCursor: next}, nil152275 }153276 154277 func (t *Tenant) webuiRoomMembers(ctx context.Context, roomID, role string) ([]webui.RoomMember, error) {@@ -64,17 +64,30 @@
6464 if (!/^[a-z0-9_-]{1,64}$/.test(this.room || "")) return;6565 this.values = new Map(); this.entries = new Map(); this.last = {}; this.pending = new Set(); this.generation = 0;6666 this.status = this.querySelector("output"); this.controller = new AbortController();67- this.enabled = false;67+ this.enabled = false; this.signerReadyVersion = 0; this.preferenceReload = false;6868 this.onEvent = event => { if (event.detail?.room === this.room) this.receive(event.detail.event); };6969 this.onInput = event => { if (event.target.matches('room-compose textarea')) { this.inputAt = seconds(); this.publish(TYPING, event.target.value ? "typing" : "stopped"); } };7070 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(); } };7171 this.onIdentity = () => { this.enabled = false; this.generation++; this.values.clear(); this.paint(); };72- this.onSigner = () => { this.onIdentity(); this.failed = false; this.loadPreference(); };72+ this.clearFailure = () => {73+ this.failed = false;74+ if (this.status?.textContent.includes("Presence could not be shared")) {75+ this.status.textContent = "";76+ this.paint();77+ }78+ };79+ this.onSigner = () => { this.onIdentity(); this.signerReadyVersion++; this.clearFailure(); this.loadPreference(); };80+ this.onSignerStatus = event => {81+ if (event.detail?.status !== "ready") return;82+ this.signerReadyVersion++; this.clearFailure();83+ this.loadPreference();84+ };7385 this.onPreference = event => { if (event.detail?.actor === this.actor) this.applyPreference(event.detail.share_presence === true); };7486 document.addEventListener("tiny:room-event", this.onEvent);7587 document.addEventListener("input", this.onInput);7688 document.addEventListener("visibilitychange", this.onVisibility);7789 document.addEventListener("tiny:signer", this.onSigner);90+ document.addEventListener("tiny:signer-status", this.onSignerStatus);7891 document.addEventListener("tiny:logout", this.onIdentity);7992 document.addEventListener("tiny:chat-preferences",this.onPreference);8093 this.loadPreference();@@ -84,7 +97,7 @@
8497 this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true);8598 this.enabled = false; this.generation++; this.controller?.abort(); clearInterval(this.timer);8699 document.removeEventListener("tiny:room-event",this.onEvent); document.removeEventListener("input",this.onInput);87- document.removeEventListener("visibilitychange",this.onVisibility); document.removeEventListener("tiny:signer",this.onSigner); document.removeEventListener("tiny:logout",this.onIdentity);100+ document.removeEventListener("visibilitychange",this.onVisibility); document.removeEventListener("tiny:signer",this.onSigner); document.removeEventListener("tiny:signer-status",this.onSignerStatus); document.removeEventListener("tiny:logout",this.onIdentity);88101 document.removeEventListener("tiny:chat-preferences",this.onPreference); this.values?.clear();89102 }90103 applyPreference(enabled) {@@ -94,13 +107,20 @@
94107 else { this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true); }95108 }96109 async loadPreference() {97- if (!hex(this.actor) || this.loadingPreference || this.failed || this.controller.signal.aborted) return;110+ if (!hex(this.actor) || this.failed || this.controller.signal.aborted) return;111+ if (this.loadingPreference) { this.preferenceReload = true; return; }98112 this.loadingPreference = true; const generation = this.generation;99113 try {100114 const enabled = await readPreference(this.controller.signal);101115 if (this.isConnected && !this.controller.signal.aborted && generation === this.generation) this.applyPreference(enabled);102116 } catch { this.applyPreference(false); }103- finally { this.loadingPreference = false; this.preferenceCheckedAt = seconds(); }117+ finally {118+ this.loadingPreference = false; this.preferenceCheckedAt = seconds();119+ if (this.preferenceReload && this.isConnected && !this.controller.signal.aborted) {120+ this.preferenceReload = false;121+ this.loadPreference();122+ }123+ }104124 }105125 receive(event) {106126 const value = parse(event,this.room); if (!value || value.pubkey === this.actor) return;@@ -121,7 +141,7 @@
121141 const at = seconds(), interval = kind === PRESENCE ? 60 : 3;122142 if (!leaving && at-(this.last[kind] || 0) < interval) return;123143 const signer = tiny.signer?.(); if (!signer?.signEvent) return;124- const generation = this.generation;144+ const generation = this.generation, readyVersion = this.signerReadyVersion;125145 this.pending.add(kind);126146 try {127147 if (await signer.getPublicKey() !== this.actor || tiny.signer() !== signer) return;@@ -129,6 +149,8 @@
129149 await tiny.signing.publish({kind, created_at:at, tags:[["h",this.room]], content});130150 this.last[kind] = at;131151 } catch {152+ const stale = generation !== this.generation || readyVersion !== this.signerReadyVersion || signer !== tiny.signer?.();153+ if (stale || leaving || !this.isConnected) return;132154 this.enabled = false; this.failed = true;133155 if (this.status) this.status.textContent = "Presence could not be shared. Reconnect your signer to try again.";134156 } finally { this.pending.delete(kind); }@@ -72,8 +72,12 @@ func roomHeader(data PageData, route roomPath, thread bool) mobileHeaderData {
7272 if result.Subtitle == "" {7373 result.Subtitle = "Group chat"7474 }75- if eventIDPattern.MatchString(route.event) {76- result.BackURL = "/rooms/" + route.id + "#msg-" + route.event75+ root := plainString(valueMap(valueMap(data.Event)["root"])["id"])76+ if !eventIDPattern.MatchString(root) {77+ root = route.event78+ }79+ if eventIDPattern.MatchString(root) {80+ result.BackURL = "/rooms/" + route.id + "#msg-" + root7781 }7882 result.BackLabel = "Back to room"7983 return result@@ -46,6 +46,17 @@ func TestMobileHeaderRoomAndThread(t *testing.T) {
4646 }4747 }4848 49+func TestThreadHeaderReturnsToCanonicalRoot(t *testing.T) {50+ root, reply := strings.Repeat("a", 64), strings.Repeat("b", 64)51+ header := mobileHeader(PageData{Path: "/rooms/general/thread/" + reply, Tab: "thread", Event: map[string]any{52+ "room": map[string]any{"id": "general", "name": "General"},53+ "root": map[string]any{"id": root},54+ }})55+ if header.BackURL != "/rooms/general#msg-"+root {56+ t.Fatalf("thread back link points to a folded reply: %+v", header)57+ }58+}59+4960 func TestMobileHeaderDirectAndHiddenData(t *testing.T) {5061 peer := strings.Repeat("b", 64)5162 got := mobileHeader(PageData{Path: "/chat/dm/" + peer, Tab: "direct", View: peer})@@ -68,13 +68,17 @@ type RoomMessage = event.Event
6868 // Members and Messages; a thread read fills Root and Replies. Edits contains6969 // the newest visible edit for the returned targets.7070 type RoomPage struct {71- Room RoomSummary `json:"room"`72- Members []RoomMember `json:"members,omitempty"`73- Messages []RoomMessage `json:"messages,omitempty"`74- Replies []RoomMessage `json:"replies,omitempty"`75- Edits []RoomMessage `json:"edits,omitempty"`76- Root *RoomMessage `json:"root,omitempty"`77- NextCursor string `json:"next_cursor,omitempty"`71+ Room RoomSummary `json:"room"`72+ Members []RoomMember `json:"members,omitempty"`73+ Messages []RoomMessage `json:"messages,omitempty"`74+ // ReplyCounts and ReactionCounts carry folded interaction summaries for75+ // the roots in Messages without expanding every reply into the timeline.76+ ReplyCounts map[string]int `json:"reply_counts,omitempty"`77+ ReactionCounts map[string]map[string]int `json:"reaction_counts,omitempty"`78+ Replies []RoomMessage `json:"replies,omitempty"`79+ Edits []RoomMessage `json:"edits,omitempty"`80+ Root *RoomMessage `json:"root,omitempty"`81+ NextCursor string `json:"next_cursor,omitempty"`7882 }7983 8084 // rowsReader converts method-and-JSON query results into the row slices used@@ -104,13 +108,15 @@ func roomListValue(list RoomList) any {
104108 105109 func roomPageValueFromContract(page RoomPage) any {106110 return map[string]any{107- "room": page.Room,108- "members": page.Members,109- "messages": page.Messages,110- "replies": page.Replies,111- "edits": page.Edits,112- "root": page.Root,113- "next_cursor": page.NextCursor,111+ "room": page.Room,112+ "members": page.Members,113+ "messages": page.Messages,114+ "reply_counts": page.ReplyCounts,115+ "reaction_counts": page.ReactionCounts,116+ "replies": page.Replies,117+ "edits": page.Edits,118+ "root": page.Root,119+ "next_cursor": page.NextCursor,114120 }115121 }116122 @@ -70,10 +70,12 @@ func (a *App) roomList(ctx context.Context, actor string) []any {
7070 // legacyRoomPage is the compatibility decoder for Backend.Query results.7171 // Typed production adapters use RoomPage from read_contracts.go instead.7272 type legacyRoomPage struct {73- Members []any `json:"members"`74- Messages []any `json:"messages"`75- Replies []any `json:"replies"`76- Edits []any `json:"edits"`73+ Members []any `json:"members"`74+ Messages []any `json:"messages"`75+ Replies []any `json:"replies"`76+ Edits []any `json:"edits"`77+ ReplyCounts map[string]int `json:"reply_counts"`78+ ReactionCounts map[string]map[string]int `json:"reaction_counts"`7779 }7880 7981 func legacyRoomPageValue(value any) (legacyRoomPage, bool) {@@ -122,8 +124,11 @@ func memberIndex(value any) map[string]map[string]any {
122124 return index123125 }124126 125-// replyCounts counts the thread replies on this page by their root id.127+// replyCounts prefers complete thread summaries over the legacy page counts.126128 func replyCounts(value any) map[string]int {129+ if page, ok := legacyRoomPageValue(value); ok && page.ReplyCounts != nil {130+ return page.ReplyCounts131+ }127132 counts := map[string]int{}128133 for _, row := range roomSlice(value, "messages") {129134 if root := webRoomReplyRoot(row); root != "" {@@ -196,6 +201,9 @@ func reactionSummary(value any) map[string][]reaction {
196201 }197202 counts[target][content]++198203 }204+ if page, ok := legacyRoomPageValue(value); ok && page.ReactionCounts != nil {205+ counts = page.ReactionCounts206+ }199207 summary := map[string][]reaction{}200208 for target, byContent := range counts {201209 for content, count := range byContent {@@ -328,7 +336,7 @@ func roomItems(value any, field string, actors ...string) []roomItem {
328336 return items329337 }330338 331-// roomRoot is the thread page's root message, with the page's replies counted.339+// roomRoot is the thread page's root message with its complete reply count.332340 func roomRoot(value any, actors ...string) roomItem {333341 data := valueMap(value)334342 item, _ := newRoomItem(data["root"], plainString(valueMap(data["room"])["id"]), memberIndex(value))@@ -337,6 +345,10 @@ func roomRoot(value any, actors ...string) roomItem {
337345 item.UpdatedAt = edit.CreatedAt338346 }339347 item.Replies = len(roomSlice(value, "replies"))348+ if count, ok := replyCounts(value)[item.ID]; ok {349+ item.Replies = count350+ }351+ item.Reactions = reactionSummary(value)[item.ID]340352 item.InThread = true341353 actor := firstString(actors)342354 item.Own = actor != "" && item.PubKey == actor && item.Notice == ""@@ -68,10 +68,10 @@ establishes the initial state. */}}
6868 {{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}}6969 7070 {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}71-{{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>71+{{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>7272 <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>7373 {{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}}74-{{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}}74+{{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}}7575 </room-message>{{end}}7676 7777 {{define "chatPresence"}}<chat-presence room="{{.View}}" actor="{{.Actor}}"><output aria-live="polite"></output></chat-presence>{{end}}@@ -383,8 +383,8 @@
383383 const footer = el("footer");384384 const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]);385385 if (mentions.length) { const span = el("span", "to "); mentions.forEach(key => span.append(nameNode(key), " ")); footer.append(span); }386- if (!inThread && room && [9, 11, 40002].includes(event.kind)) { const link = el("a", "thread"); link.href = roomPath(room, "/thread/" + event.id); footer.append(link); }387386 const root = replyRoot(event);387+ 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); }388388 if (!inThread && room && isHex64(root)) { const link = el("a", "in thread"); link.href = roomPath(room, "/thread/" + root); footer.append(link); }389389 if (footer.childNodes.length) node.append(footer);390390 return node;@@ -403,14 +403,14 @@
403403 // roomAppend adds one message to the timeline unless it is already there,404404 // keeps the list bounded and follows the newest message when the viewer405405 // is already reading the end of it.406- const roomAppend = (event, {room = "", inThread = false, own = false} = {}) => {406+ const roomAppend = (event, {room = "", inThread = false, own = false, canonicalRoot = ""} = {}) => {407407 const list = document.getElementById("messages");408408 if (!list || !isHex64(event?.id) || document.getElementById("msg-" + event.id)) return null;409409 list.roomSeen ||= new Set();410410 if (list.roomSeen.has(event.id)) return null;411411 list.roomSeen.add(event.id);412412 while (list.roomSeen.size > 2000) list.roomSeen.delete(list.roomSeen.values().next().value);413- const reference = replyRoot(event);413+ const reference = canonicalRoot || replyRoot(event);414414 if (reference && !inThread) {415415 const link = document.querySelector("#msg-" + reference + " > footer > a");416416 if (link) { const count = Number((link.textContent.match(/(\d+) repl/) || [])[1] || 0) + 1; link.textContent = "thread | " + count + (count === 1 ? " reply" : " replies"); }@@ -762,13 +762,15 @@
762762 const room = this.getAttribute("room");763763 if (!room || this.source || typeof EventSource !== "function") return;764764 this.delay = this.delay || 1000;765+ const generation = (this.generation || 0) + 1;766+ this.generation = generation;765767 const source = new EventSource(roomPath(room, "/stream"), {withCredentials: true});766768 this.source = source;767769 source.addEventListener("open", () => { this.delay = 1000; this.textContent = ""; });768770 source.addEventListener("message", event => {769771 let parsed;770772 try { parsed = JSON.parse(event.data); } catch { return; }771- this.receive(parsed);773+ this.receive(parsed, source, generation);772774 });773775 source.addEventListener("error", () => {774776 if (this.source !== source) return;@@ -780,14 +782,57 @@
780782 }781783 782784 close() {785+ this.generation = (this.generation || 0) + 1;786+ this.lookupController?.abort();787+ this.lookupController = null;788+ this.roots?.clear();789+ this.rootQueries?.clear();783790 this.source?.close();784791 this.source = null;785792 clearTimeout(this.timer);786793 this.timer = null;787794 }788795 789- receive(event) {796+ async canonicalRoot(room, reference, source, generation, eventID = "") {797+ this.roots ||= new Map();798+ if (this.roots.has(reference)) { const root = this.roots.get(reference); this.rememberRoot(eventID, root); return root; }799+ this.rootQueries ||= new Map();800+ if (!this.rootQueries.has(reference)) {801+ const path = tiny.localPath("/webmcp/query");802+ const params = encodeURIComponent(JSON.stringify([{id: room, event: reference, limit: 1}]));803+ this.lookupController ||= new AbortController();804+ const query = this.rootQueries.get(reference) || (async () => {805+ if (typeof fetch !== "function") return "";806+ const response = await fetch(path + "?method=browsethread¶ms=" + params, {credentials: "same-origin", cache: "no-store", signal: this.lookupController?.signal});807+ if (!response.ok) throw Error("thread lookup failed");808+ const value = await response.json();809+ const root = value?.root || value?.result?.root;810+ const id = root?.id;811+ if (!isHex64(id) || ![9, 11, 40002].includes(Number(root?.kind)) || tagValue(root, "h") !== room || replyRoot(root)) return "";812+ return id;813+ })();814+ this.rootQueries.set(reference, query);815+ }816+ const query = this.rootQueries.get(reference);817+ try {818+ const root = await query;819+ if (source && (this.source !== source || this.generation !== generation)) return "";820+ if (root) { this.rememberRoot(reference, root); this.rememberRoot(eventID, root); }821+ return root;822+ } catch { return ""; }823+ finally { if (this.rootQueries.get(reference) === query) this.rootQueries.delete(reference); }824+ }825+826+ rememberRoot(eventID, root) {827+ if (!isHex64(eventID) || !isHex64(root)) return;828+ this.roots ||= new Map();829+ this.roots.set(eventID, root);830+ while (this.roots.size > 256) this.roots.delete(this.roots.keys().next().value);831+ }832+833+ async receive(event, source = null, generation = this.generation) {790834 if (!event || typeof event !== "object") return;835+ if (source && (this.source !== source || this.generation !== generation)) return;791836 const room = this.getAttribute("room");792837 if (tagValue(event, "h") !== room && !(event.kind === 20001 && !tagValue(event, "h"))) return;793838 if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:room-event", {detail: {room, event}}));@@ -795,11 +840,30 @@
795840 if (event.kind === 40003) { roomEdit(event); return; }796841 if (!roomKinds.includes(event.kind)) return;797842 const root = this.getAttribute("root");798- if (root) {799- const reference = replyRoot(event);800- if (reference !== root && !document.getElementById("msg-" + reference)) return;843+ const reference = replyRoot(event);844+ if (reference) {845+ // The common direct-reply path is already canonical and can update the846+ // UI synchronously. Unknown parents are resolved below so nested or847+ // out-of-order events still use the original thread root.848+ if (root && reference === root) {849+ this.rememberRoot(event.id, root);850+ roomAppend(event, {room, inThread: true, canonicalRoot: root});851+ return;852+ }853+ if (!root && document.getElementById("msg-" + reference)) {854+ this.rememberRoot(event.id, reference);855+ roomAppend(event, {room, inThread: false, canonicalRoot: reference});856+ return;857+ }858+ const canonical = await this.canonicalRoot(room, reference, source, generation, event.id);859+ if (source && (this.source !== source || this.generation !== generation)) return;860+ if (root && canonical !== root) return;861+ if (!canonical) return;862+ roomAppend(event, {room, inThread: Boolean(root), canonicalRoot: canonical});863+ return;801864 }802- roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(root)});865+ if (root) return;866+ roomAppend(event, {room, inThread: false});803867 }804868 }805869 @@ -38,6 +38,23 @@ func roomRecord(id, name, access, role string) map[string]any {
3838 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}3939 }4040 41+func TestRoomInteractionSummariesSurvivePagination(t *testing.T) {42+ root := RoomMessage{ID: roomThread, PubKey: roomAgent, Kind: 11, Content: "Root"}43+ page := RoomPage{44+ Room: RoomSummary{ID: "general"}, Messages: []RoomMessage{root}, Root: &root,45+ ReplyCounts: map[string]int{roomThread: 105},46+ ReactionCounts: map[string]map[string]int{roomThread: {"+1": 2, "wave": 1}},47+ }48+ value := roomPageValueFromContract(page)49+ items := roomItems(value, "messages")50+ if len(items) != 1 || items[0].Replies != 105 || len(items[0].Reactions) != 2 || items[0].Reactions[0] != (reaction{Content: "+1", Count: 2}) {51+ t.Fatalf("folded summaries missing: %#v", items)52+ }53+ if item := roomRoot(value); item.Replies != 105 {54+ t.Fatalf("thread total counts only the current page: %d", item.Replies)55+ }56+}57+4158 func (b *roomsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {4259 b.calls = append(b.calls, method+":"+actor)4360 q := map[string]any{}@@ -33,7 +33,7 @@ function setup(options={}) {
3333 document.removeEventListener = name => delete document.listeners?.[name];3434 const definitions = {}, saved = [], dispatched = [];3535 document.dispatchEvent = event => dispatched.push(event);36- 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)}};36+ 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); }}};3737 const published = [];3838 const signer = {getPublicKey: async () => key, signEvent: async () => ({})};3939 class HTMLElement extends Node {}@@ -122,6 +122,77 @@ test("saved account preference enables chat without a chat control, and respects
122122 assert.equal(published.length,0); otherAccount.disconnectedCallback();123123 });124124 125+test("signer recovery clears a transient presence failure without a reload", async () => {126+ const options = {share:true, publishError:"Signer connection changed. Retry when connected."};127+ const {definitions, document, published} = setup(options);128+ const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key);129+ host.append(new Node("output")); host.parent = document; host.connectedCallback();130+ await new Promise(resolve => setTimeout(resolve, 0));131+ assert.match(host.status.textContent, /Presence could not be shared/);132+ assert.equal(host.failed, true);133+ document.listeners["tiny:signer-status"]({detail:{status:"ready"}});134+ await new Promise(resolve => setTimeout(resolve, 0));135+ assert.equal(host.failed, false);136+ assert.equal(host.status.textContent, "");137+ await host.publish(20001, "online");138+ assert.equal(published.length, 1);139+ host.disconnectedCallback();140+});141+142+test("a presence failure from before signer recovery cannot restore the warning", async () => {143+ let resolve, reject;144+ const options = {share:false, publishGate:{promise:new Promise((done, fail) => { resolve = done; reject = fail; })}};145+ const {definitions, document} = setup(options);146+ const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key);147+ host.append(new Node("output")); host.parent = document; host.connectedCallback(); host.enabled = true;148+ const pending = host.publish(20001, "online");149+ document.listeners["tiny:signer-status"]({detail:{status:"ready"}});150+ reject(Error("Signer connection changed. Retry when connected."));151+ await pending;152+ assert.notEqual(host.failed, true);153+ assert.equal(host.status.textContent, "");154+ resolve();155+ host.disconnectedCallback();156+});157+158+test("a failed hidden cleanup announcement does not create a signer warning", async () => {159+ const {definitions, document} = setup({publishError:"Signer connection changed. Retry when connected."});160+ const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key);161+ host.append(new Node("output")); host.parent = document; host.connectedCallback();162+ host.last[20001] = 1; document.hidden = true;163+ await host.publish(20001, "offline", true);164+ assert.notEqual(host.failed, true);165+ assert.equal(host.status.textContent, "");166+ host.disconnectedCallback();167+});168+169+test("a ready signer event queues preference reload after an in-flight request", async () => {170+ let resolveFirst, calls = 0;171+ const first = new Promise(resolve => { resolveFirst = resolve; });172+ const {definitions, document} = setup({fetch: async () => ++calls === 1 ? first : {ok:true,json:async()=>({share_presence:true})}});173+ const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key);174+ host.append(new Node("output")); host.parent = document; host.connectedCallback();175+ document.listeners["tiny:signer-status"]({detail:{status:"ready"}});176+ document.listeners["tiny:signer"]({detail:{}});177+ assert.equal(host.preferenceReload, true);178+ resolveFirst({ok:true,json:async()=>({share_presence:true})});179+ await new Promise(resolve => setTimeout(resolve, 0));180+ await new Promise(resolve => setTimeout(resolve, 0));181+ assert.equal(calls, 2);182+ assert.equal(host.enabled, true);183+ host.disconnectedCallback();184+});185+186+test("a failed visible opt-out announcement does not create a signer warning", async () => {187+ const {definitions, document} = setup({publishError:"Signer connection changed. Retry when connected."});188+ const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key);189+ host.append(new Node("output")); host.parent = document; host.connectedCallback();190+ host.last[20001] = 1; await host.publish(20001, "offline", true);191+ assert.notEqual(host.failed, true);192+ assert.equal(host.status.textContent, "");193+ host.disconnectedCallback();194+});195+125196 test("account preference saves with a signed JSON request and restores checkbox after failure", async () => {126197 const options={}; const {definitions,document,saved,dispatched}=setup(options);127198 const host=new definitions["chat-preferences"]();host.setAttribute("actor",key);host.parent=document;@@ -407,6 +407,49 @@ test("room-live on a thread page keeps only replies to its root", () => {
407407 assert.equal(source.closed, true);408408 });409409 410+test("room-live resolves an out-of-order nested reply to the canonical root", async () => {411+ const root = "a".repeat(64), parent = "b".repeat(64);412+ const s = setup({attributes: {room: "build", root}});413+ const queries = [];414+ s.sandbox.fetch = async (url) => {415+ queries.push(url);416+ return {ok: true, async json() { return {root: {id: root, kind: 11, tags: [["h", "build"]]}}; }};417+ };418+ const live = new s.RoomLive();419+ live.connectedCallback();420+ const source = s.sandbox.sources[0];421+ const secret = generateSecretKey();422+ const nested = finalizeEvent({kind: 12, created_at: 3, tags: [["h", "build"], ["e", parent]], content: "nested"}, secret);423+ source.emit("message", {data: JSON.stringify(nested)});424+ await new Promise(resolve => setTimeout(resolve, 0));425+ assert.equal(s.list.children.length, 1);426+ assert.equal(s.list.children[0].id, "msg-" + nested.id);427+ assert.equal(queries.length, 1);428+ assert.match(queries[0], /method=browsethread/);429+ assert.match(decodeURIComponent(queries[0]), /"event":"bbbb/);430+ const followup = finalizeEvent({kind: 12, created_at: 4, tags: [["h", "build"], ["e", nested.id]], content: "followup"}, secret);431+ source.emit("message", {data: JSON.stringify(followup)});432+ await new Promise(resolve => setTimeout(resolve, 0));433+ assert.equal(s.list.children.length, 2);434+ assert.equal(queries.length, 1);435+});436+437+test("room-live drops a root lookup that finishes after the stream closes", async () => {438+ const root = "a".repeat(64), parent = "b".repeat(64);439+ const s = setup({attributes: {room: "build", root}});440+ let finish;441+ s.sandbox.fetch = async () => new Promise(resolve => { finish = resolve; });442+ const live = new s.RoomLive();443+ live.connectedCallback();444+ const source = s.sandbox.sources[0], secret = generateSecretKey();445+ const nested = finalizeEvent({kind: 12, created_at: 3, tags: [["h", "build"], ["e", parent]], content: "nested"}, secret);446+ source.emit("message", {data: JSON.stringify(nested)});447+ live.close();448+ finish({ok: true, async json() { return {root: {id: root, kind: 11, tags: [["h", "build"]]}}; }});449+ await new Promise(resolve => setTimeout(resolve, 0));450+ assert.equal(s.list.children.length, 0);451+});452+410453 test("chat markdown renders the shared subset without ever parsing markup", () => {411454 const s = setup();412455 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```");