| 1 | diff --git a/docs/rooms.md b/docs/rooms.md |
| 2 | index 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 | |
| 23 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 24 | index 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 |
| 67 | diff --git a/internal/webui/rooms.go b/internal/webui/rooms.go |
| 68 | index 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" |
| 140 | diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html |
| 141 | index 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}} |
| 155 | diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go |
| 156 | index 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 | `<script>alert(1)</script>`, |
| 170 | `<span>to <nostr-key hex="` + roomAgent + `"`, |
| 171 | `<span data-reaction="+1">+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>`, |
| 176 | diff --git a/scripts/rooms.test.mjs b/scripts/rooms.test.mjs |
| 177 | index 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"}); |