tinyrelaycommit

Commit d78d443c4d81

browse files at this commit | history

1diff --git a/docs/rooms.md b/docs/rooms.md
2index a074b77..8a5cce6 100644
3--- a/docs/rooms.md
4+++ b/docs/rooms.md
5@@ -37,7 +37,7 @@ The relay accepts these kinds inside a room:
6 | 12 | Thread reply, with an `e` tag naming the thread |
7 | 7 | Reaction |
8 | 40002 | Rich content |
9-| 40003 | Edit |
10+| 40003 | Edit: `e` names the message and the content replaces it; only the author's edits count |
11 | 20001 | Presence, delivered live and never stored |
12 | 20002 | Typing, delivered live and never stored |
13 | 9007 | Create room: `h` is the new id; `name`, `about`, `picture` and `visibility` (`open` or `members`) or `channel_type` describe it |
14@@ -59,7 +59,7 @@ Room events reach device notifications and relay push callbacks the same way as
15
16 **Rooms** in the relay navigation lists the rooms you can see with their access rule, member count and last message. Signed-in members create a room at the bottom of the list: the name becomes the room id, lowercased with punctuation replaced by hyphens, unless the **Id** field names one, and the room opens once the relay accepts it. Give the id yourself when a client expects a particular shape, such as a Buzz gateway that wants a UUID.
17
18-Open a room to read its messages, oldest first. Each message shows the author's key, their room role and the time; messages from agents carry an agent marker. 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. Choose **load earlier** for older messages.
19+Open a room to read its messages, oldest first. Each message shows the author's key, their room role and the time; messages from agents carry an agent marker. 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.
20
21 The compose bar sits at the bottom of the column. Enter sends and Shift+Enter starts a new line. Mention a person with `@npub...` or `@<hex key>`; the relay notifies them. Sending, creating a room and every room action need JavaScript and a connected signer; without JavaScript the page still shows the newest messages.
22
23diff --git a/internal/webui/components.js b/internal/webui/components.js
24index b2191c9..2a6fc53 100644
25--- a/internal/webui/components.js
26+++ b/internal/webui/components.js
27@@ -696,6 +696,23 @@
28 if (span) span.textContent = content + " " + (Number(span.textContent.slice(content.length)) + 1);
29 else { span = el("span", content + " 1"); span.dataset.reaction = content; footer.append(span); }
30 };
31+ // roomEdit applies a kind 40003 edit to the message it names when the
32+ // editor is that message's author, replacing the text and marking it.
33+ const roomEdit = event => {
34+ const targets = (event.tags || []).filter(tag => tag[0] === "e");
35+ const target = targets.length && document.getElementById("msg-" + targets[targets.length - 1][1]);
36+ if (!target || target.dataset.pubkey !== event.pubkey || target.dataset.notice !== undefined) return;
37+ const body = target.querySelector(":scope > p");
38+ if (!body) return;
39+ body.replaceWith(linkify(el("p"), event.content || ""));
40+ if (target.dataset.edited !== undefined) return;
41+ target.dataset.edited = "";
42+ let footer = target.querySelector(":scope > footer");
43+ if (!footer) { footer = el("footer"); target.append(footer); }
44+ const mark = el("span", "edited");
45+ mark.dataset.edited = "";
46+ footer.append(mark);
47+ };
48 // signAndPublish signs one room event, checks it came back unchanged and
49 // valid, and publishes it once.
50 const signAndPublish = async unsigned => {
51@@ -857,13 +874,14 @@
52 receive(event) {
53 if (!event || typeof event !== "object") return;
54 if (event.kind === 7) { roomReact(event); return; }
55+ if (event.kind === 40003) { roomEdit(event); return; }
56 if (!roomKinds.includes(event.kind)) return;
57 const root = this.getAttribute("root");
58 if (root && (event.kind !== 12 || tagValue(event, "e") !== root)) return;
59 roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(root)});
60 }
61 }
62- tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, linkify});
63+ tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify});
64
65 // JsonView renders any JSON value. Arrays of objects become tables, objects
66 // become definition lists, scalar arrays become lists, and deep nesting
67diff --git a/internal/webui/rooms.go b/internal/webui/rooms.go
68index 621ef0d..ce6e198 100644
69--- a/internal/webui/rooms.go
70+++ b/internal/webui/rooms.go
71@@ -100,6 +100,40 @@ type reaction struct {
72 }
73
74 // reactionSummary groups the page's reactions by the message they answer.
75+// roomEdit is the newest kind 40003 edit aimed at one message.
76+type roomEdit struct {
77+ PubKey, Content string
78+ CreatedAt int64
79+}
80+
81+// editSummary maps message ids to the newest edit (kind 40003, Buzz's
82+// in-place message edit) per editing key found in the page's messages and
83+// replies, so a stranger's edit never shadows the author's own.
84+func editSummary(value any) map[string]map[string]roomEdit {
85+ edits := map[string]map[string]roomEdit{}
86+ for _, field := range []string{"messages", "replies"} {
87+ for _, row := range roomSlice(value, field) {
88+ e := valueMap(row)
89+ if plainString(e["kind"]) != "40003" {
90+ continue
91+ }
92+ targets := tagValues(row, "e")
93+ if len(targets) == 0 {
94+ continue
95+ }
96+ target := targets[len(targets)-1]
97+ edit := roomEdit{PubKey: plainString(e["pubkey"]), Content: plainString(e["content"]), CreatedAt: unixSeconds(e["created_at"])}
98+ if edits[target] == nil {
99+ edits[target] = map[string]roomEdit{}
100+ }
101+ if prior, found := edits[target][edit.PubKey]; !found || edit.CreatedAt > prior.CreatedAt {
102+ edits[target][edit.PubKey] = edit
103+ }
104+ }
105+ }
106+ return edits
107+}
108+
109 func reactionSummary(value any) map[string][]reaction {
110 counts := map[string]map[string]int{}
111 for _, row := range roomSlice(value, "messages") {
112@@ -215,7 +249,7 @@ func clock(value any) string {
113 type roomItem struct {
114 ID, PubKey, Kind, Content, Room, Root, Role, Notice string
115 CreatedAt int64
116- Agent, InThread bool
117+ Agent, InThread, Edited bool
118 Mentions []string
119 Replies int
120 Reactions []reaction
121@@ -229,6 +263,7 @@ func roomItems(value any, field string) []roomItem {
122 members := memberIndex(value)
123 replies := replyCounts(value)
124 reactions := reactionSummary(value)
125+ edits := editSummary(value)
126 rows := roomSlice(value, field)
127 items := make([]roomItem, 0, len(rows))
128 for i := len(rows) - 1; i >= 0; i-- {
129@@ -236,6 +271,10 @@ func roomItems(value any, field string) []roomItem {
130 if !ok {
131 continue
132 }
133+ // Only the author edits their own message, and only forward in time.
134+ if edit, found := edits[item.ID][item.PubKey]; found && edit.CreatedAt >= item.CreatedAt {
135+ item.Content, item.Edited = edit.Content, true
136+ }
137 item.Replies = replies[item.ID]
138 item.Reactions = reactions[item.ID]
139 item.InThread = field == "replies"
140diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html
141index cd63f5e..2f3ea09 100644
142--- a/internal/webui/rooms.html
143+++ b/internal/webui/rooms.html
144@@ -57,8 +57,8 @@
145 {{end}}{{end}}
146
147 {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}
148-{{define "roomMessage"}}<room-message id="msg-{{.ID}}" data-id="{{.ID}}" data-kind="{{.Kind}}" data-pubkey="{{.PubKey}}"{{if .Agent}} data-agent{{end}}{{if .Notice}} data-notice{{end}}>
149+{{define "roomMessage"}}<room-message id="msg-{{.ID}}" data-id="{{.ID}}" data-kind="{{.Kind}}" data-pubkey="{{.PubKey}}"{{if .Agent}} data-agent{{end}}{{if .Notice}} data-notice{{end}}{{if .Edited}} data-edited{{end}}>
150 <header><b>{{template "nostrKey" .PubKey}}</b>{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header>
151 <p>{{if .Notice}}{{.Notice}}{{else}}{{roomContent .Content}}{{end}}</p>
152-{{if or .Mentions (and (not .InThread) (or (eq .Kind "11") .Root)) .Reactions}}<footer>{{if .Mentions}}<span>to {{range .Mentions}}{{template "nostrKey" .}} {{end}}</span>{{end}}{{if not .InThread}}{{if eq .Kind "11"}}<a href="/rooms/{{.Room}}/thread/{{.ID}}">thread{{if .Replies}} | {{.Replies}} {{if eq .Replies 1}}reply{{else}}replies{{end}}{{end}}</a>{{else if .Root}}<a href="/rooms/{{.Room}}/thread/{{.Root}}">in thread</a>{{end}}{{end}}{{range .Reactions}}<span data-reaction="{{.Content}}">{{.Content}} {{.Count}}</span>{{end}}</footer>{{end}}
153+{{if or .Mentions (and (not .InThread) (or (eq .Kind "11") .Root)) .Reactions .Edited}}<footer>{{if .Mentions}}<span>to {{range .Mentions}}{{template "nostrKey" .}} {{end}}</span>{{end}}{{if not .InThread}}{{if eq .Kind "11"}}<a href="/rooms/{{.Room}}/thread/{{.ID}}">thread{{if .Replies}} | {{.Replies}} {{if eq .Replies 1}}reply{{else}}replies{{end}}{{end}}</a>{{else if .Root}}<a href="/rooms/{{.Room}}/thread/{{.Root}}">in thread</a>{{end}}{{end}}{{range .Reactions}}<span data-reaction="{{.Content}}">{{.Content}} {{.Count}}</span>{{end}}{{if .Edited}}<span data-edited>edited</span>{{end}}</footer>{{end}}
154 </room-message>{{end}}
155diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go
156index 8cb074a..8319239 100644
157--- a/internal/webui/rooms_test.go
158+++ b/internal/webui/rooms_test.go
159@@ -73,6 +73,8 @@ func (b *roomsBackend) Query(_ context.Context, method string, params []json.Raw
160 return map[string]any{"room": roomRecord(id, "General", "open", ""), "root": root, "replies": []any{reply}, "next_cursor": ""}, nil
161 }
162 messages := []any{
163+ roomEvent(strings.Repeat("a", 64), roomAgent, 40003, 1757203650, "hello again, edited", []string{"h", id}, []string{"e", roomHello}),
164+ roomEvent(strings.Repeat("b", 64), roomOwner, 40003, 1757203660, "not my message", []string{"h", id}, []string{"e", roomHello}),
165 roomEvent(roomLike, roomOwner, 7, 1757203600, "+", []string{"h", id}, []string{"e", roomChat}),
166 reply,
167 root,
168@@ -146,6 +148,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
169 `&lt;script&gt;alert(1)&lt;/script&gt;`,
170 `<span>to <nostr-key hex="` + roomAgent + `"`,
171 `<span data-reaction="&#43;1">&#43;1 1</span>`,
172+ `data-pubkey="` + roomAgent + `" data-agent data-edited>`, `<p>hello again, edited</p>`, `<span data-edited>edited</span>`,
173 `<a href="/rooms/general/thread/` + roomThread + `">thread | 1 reply</a>`,
174 `<a href="/rooms/general/thread/` + roomThread + `">in thread</a>`,
175 `<room-live room="general"></room-live>`,
176diff --git a/scripts/rooms.test.mjs b/scripts/rooms.test.mjs
177index 67ae444..9949e71 100644
178--- a/scripts/rooms.test.mjs
179+++ b/scripts/rooms.test.mjs
180@@ -17,6 +17,7 @@ class FakeNode {
181 get isConnected() { return Boolean(this.parent) && (this.parent === document || this.parent.isConnected); }
182 append(...nodes) { for (const node of nodes) { if (node instanceof FakeNode) { node.remove(); node.parent = this; this.childNodes.push(node); } else if (String(node)) this.childNodes.push(String(node)); } }
183 remove() { if (this.parent) { this.parent.childNodes = this.parent.childNodes.filter(node => node !== this); this.parent = null; } }
184+ replaceWith(node) { if (!this.parent) return; const index = this.parent.childNodes.indexOf(this); node.remove(); node.parent = this.parent; this.parent.childNodes[index] = node; this.parent = null; }
185 get textContent() { return this.childNodes.map(node => typeof node === "string" ? node : node.textContent).join(""); }
186 set textContent(value) { this.childNodes = value ? [String(value)] : []; }
187 setAttribute(name, value) { if (name === "id") this.id = value; else this.attributes[name] = String(value); }
188@@ -196,6 +197,16 @@ test("room-live appends streamed messages once, summarizes reactions and reconne
189 const summary = node.querySelector("footer span[data-reaction]");
190 assert.equal(summary.dataset.reaction, "+1");
191 assert.equal(summary.textContent, "+1 2");
192+ const edit = finalizeEvent({kind: 40003, created_at: 1757203750, tags: [["h", "build"], ["e", message.id]], content: "see https://example.com/y instead"}, secret);
193+ source.emit("message", {data: JSON.stringify(edit)});
194+ assert.equal(node.querySelector("p").textContent, "see https://example.com/y instead");
195+ assert.equal(node.querySelector("p a").href, "https://example.com/y");
196+ assert.equal(node.dataset.edited, "");
197+ assert.equal(node.querySelectorAll("footer span[data-edited]").length, 1);
198+ const stranger = finalizeEvent({kind: 40003, created_at: 1757203760, tags: [["h", "build"], ["e", message.id]], content: "hijacked"}, generateSecretKey());
199+ source.emit("message", {data: JSON.stringify(stranger)});
200+ assert.equal(node.querySelector("p").textContent, "see https://example.com/y instead");
201+ assert.equal(s.list.children.length, 1);
202 // An agent's message carries the marker from the members list.
203 const agent = finalizeEvent({kind: 11, created_at: 1757203800, tags: [["h", "build"]], content: "draft"}, secret);
204 s.rooms.roomAppend({...agent, pubkey: "b".repeat(64)}, {room: "build"});

About

ownerc01503e8035f
visibilitypublic
headd78d443c4d81

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs