clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

show the newest text of an edited room message

Dami committed d78d443
commit
d78d443c4d8102417ad6dd958aa150e0dd439e8e
parent
3c304a3aaca8e36e4d6fc2734ee73e963a824be3
author
Dami <dami.etoile@gmail.com>
6 files changed, +77 -6
Mdocs/rooms.md4+-
Minternal/webui/components.js20++++++++++++++-
Minternal/webui/rooms.go41+++++++++++++++++++++++++++++-
Minternal/webui/rooms.html4+-
Minternal/webui/rooms_test.go3++
Mscripts/rooms.test.mjs11++++++++
M docs/rooms.md +2 -2
@@ -37,7 +37,7 @@ The relay accepts these kinds inside a room:
3737 | 12 | Thread reply, with an `e` tag naming the thread |3838 | 7 | Reaction |3939 | 40002 | Rich content |40-| 40003 | Edit |40+| 40003 | Edit: `e` names the message and the content replaces it; only the author's edits count |4141 | 20001 | Presence, delivered live and never stored |4242 | 20002 | Typing, delivered live and never stored |4343 | 9007 | Create room: `h` is the new id; `name`, `about`, `picture` and `visibility` (`open` or `members`) or `channel_type` describe it |
@@ -59,7 +59,7 @@ Room events reach device notifications and relay push callbacks the same way as
5959 6060 **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.6161 62-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.62+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.6363 6464 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.6565
M internal/webui/components.js +19 -1
@@ -696,6 +696,23 @@
696696 if (span) span.textContent = content + " " + (Number(span.textContent.slice(content.length)) + 1);697697 else { span = el("span", content + " 1"); span.dataset.reaction = content; footer.append(span); }698698 };699+ // roomEdit applies a kind 40003 edit to the message it names when the700+ // editor is that message's author, replacing the text and marking it.701+ const roomEdit = event => {702+ const targets = (event.tags || []).filter(tag => tag[0] === "e");703+ const target = targets.length && document.getElementById("msg-" + targets[targets.length - 1][1]);704+ if (!target || target.dataset.pubkey !== event.pubkey || target.dataset.notice !== undefined) return;705+ const body = target.querySelector(":scope > p");706+ if (!body) return;707+ body.replaceWith(linkify(el("p"), event.content || ""));708+ if (target.dataset.edited !== undefined) return;709+ target.dataset.edited = "";710+ let footer = target.querySelector(":scope > footer");711+ if (!footer) { footer = el("footer"); target.append(footer); }712+ const mark = el("span", "edited");713+ mark.dataset.edited = "";714+ footer.append(mark);715+ };699716 // signAndPublish signs one room event, checks it came back unchanged and700717 // valid, and publishes it once.701718 const signAndPublish = async unsigned => {
@@ -857,13 +874,14 @@
857874 receive(event) {858875 if (!event || typeof event !== "object") return;859876 if (event.kind === 7) { roomReact(event); return; }877+ if (event.kind === 40003) { roomEdit(event); return; }860878 if (!roomKinds.includes(event.kind)) return;861879 const root = this.getAttribute("root");862880 if (root && (event.kind !== 12 || tagValue(event, "e") !== root)) return;863881 roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(root)});864882 }865883 }866- tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, linkify});884+ tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify});867885 868886 // JsonView renders any JSON value. Arrays of objects become tables, objects869887 // become definition lists, scalar arrays become lists, and deep nesting
M internal/webui/rooms.go +40 -1
@@ -100,6 +100,40 @@ type reaction struct {
100100 }101101 102102 // reactionSummary groups the page's reactions by the message they answer.103+// roomEdit is the newest kind 40003 edit aimed at one message.104+type roomEdit struct {105+ PubKey, Content string106+ CreatedAt int64107+}108+109+// editSummary maps message ids to the newest edit (kind 40003, Buzz's110+// in-place message edit) per editing key found in the page's messages and111+// replies, so a stranger's edit never shadows the author's own.112+func editSummary(value any) map[string]map[string]roomEdit {113+ edits := map[string]map[string]roomEdit{}114+ for _, field := range []string{"messages", "replies"} {115+ for _, row := range roomSlice(value, field) {116+ e := valueMap(row)117+ if plainString(e["kind"]) != "40003" {118+ continue119+ }120+ targets := tagValues(row, "e")121+ if len(targets) == 0 {122+ continue123+ }124+ target := targets[len(targets)-1]125+ edit := roomEdit{PubKey: plainString(e["pubkey"]), Content: plainString(e["content"]), CreatedAt: unixSeconds(e["created_at"])}126+ if edits[target] == nil {127+ edits[target] = map[string]roomEdit{}128+ }129+ if prior, found := edits[target][edit.PubKey]; !found || edit.CreatedAt > prior.CreatedAt {130+ edits[target][edit.PubKey] = edit131+ }132+ }133+ }134+ return edits135+}136+103137 func reactionSummary(value any) map[string][]reaction {104138 counts := map[string]map[string]int{}105139 for _, row := range roomSlice(value, "messages") {
@@ -215,7 +249,7 @@ func clock(value any) string {
215249 type roomItem struct {216250 ID, PubKey, Kind, Content, Room, Root, Role, Notice string217251 CreatedAt int64218- Agent, InThread bool252+ Agent, InThread, Edited bool219253 Mentions []string220254 Replies int221255 Reactions []reaction
@@ -229,6 +263,7 @@ func roomItems(value any, field string) []roomItem {
229263 members := memberIndex(value)230264 replies := replyCounts(value)231265 reactions := reactionSummary(value)266+ edits := editSummary(value)232267 rows := roomSlice(value, field)233268 items := make([]roomItem, 0, len(rows))234269 for i := len(rows) - 1; i >= 0; i-- {
@@ -236,6 +271,10 @@ func roomItems(value any, field string) []roomItem {
236271 if !ok {237272 continue238273 }274+ // Only the author edits their own message, and only forward in time.275+ if edit, found := edits[item.ID][item.PubKey]; found && edit.CreatedAt >= item.CreatedAt {276+ item.Content, item.Edited = edit.Content, true277+ }239278 item.Replies = replies[item.ID]240279 item.Reactions = reactions[item.ID]241280 item.InThread = field == "replies"
M internal/webui/rooms.html +2 -2
@@ -57,8 +57,8 @@
5757 {{end}}{{end}}5858 5959 {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}60-{{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}}>60+{{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}}>6161 <header><b>{{template "nostrKey" .PubKey}}</b>{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header>6262 <p>{{if .Notice}}{{.Notice}}{{else}}{{roomContent .Content}}{{end}}</p>63-{{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}}63+{{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}}6464 </room-message>{{end}}
M internal/webui/rooms_test.go +3 -0
M scripts/rooms.test.mjs +11 -0
@@ -17,6 +17,7 @@ class FakeNode {
1717 get isConnected() { return Boolean(this.parent) && (this.parent === document || this.parent.isConnected); }1818 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)); } }1919 remove() { if (this.parent) { this.parent.childNodes = this.parent.childNodes.filter(node => node !== this); this.parent = null; } }20+ 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; }2021 get textContent() { return this.childNodes.map(node => typeof node === "string" ? node : node.textContent).join(""); }2122 set textContent(value) { this.childNodes = value ? [String(value)] : []; }2223 setAttribute(name, value) { if (name === "id") this.id = value; else this.attributes[name] = String(value); }
@@ -196,6 +197,16 @@ test("room-live appends streamed messages once, summarizes reactions and reconne
196197 const summary = node.querySelector("footer span[data-reaction]");197198 assert.equal(summary.dataset.reaction, "+1");198199 assert.equal(summary.textContent, "+1 2");200+ const edit = finalizeEvent({kind: 40003, created_at: 1757203750, tags: [["h", "build"], ["e", message.id]], content: "see https://example.com/y instead"}, secret);201+ source.emit("message", {data: JSON.stringify(edit)});202+ assert.equal(node.querySelector("p").textContent, "see https://example.com/y instead");203+ assert.equal(node.querySelector("p a").href, "https://example.com/y");204+ assert.equal(node.dataset.edited, "");205+ assert.equal(node.querySelectorAll("footer span[data-edited]").length, 1);206+ const stranger = finalizeEvent({kind: 40003, created_at: 1757203760, tags: [["h", "build"], ["e", message.id]], content: "hijacked"}, generateSecretKey());207+ source.emit("message", {data: JSON.stringify(stranger)});208+ assert.equal(node.querySelector("p").textContent, "see https://example.com/y instead");209+ assert.equal(s.list.children.length, 1);199210 // An agent's message carries the marker from the members list.200211 const agent = finalizeEvent({kind: 11, created_at: 1757203800, tags: [["h", "build"]], content: "draft"}, secret);201212 s.rooms.roomAppend({...agent, pubkey: "b".repeat(64)}, {room: "build"});