tinyrelayfile
tinyrelay/tinyclient/rooms.go/

tinyclient/rooms.go raw
1package tinyclient
2
3import (
4 "context"
5 "encoding/json"
6 "html/template"
7 "regexp"
8 "sort"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/FelineStateMachine/tinyrelay/internal/event"
14)
15
16// roomPath is one rooms page: the list, one room, or one thread in a room.
17type roomPath struct {
18 tab string
19 id string
20 event string
21}
22
23var (
24 roomIDPattern = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`)
25 eventIDPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
26 roomLinkPattern = regexp.MustCompile(`https?://[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+`)
27)
28
29// roomRoute maps /rooms, /rooms/<id> and /rooms/<id>/thread/<event> to a
30// page. The stream path belongs to the daemon and is not a page.
31func roomRoute(path string) roomPath {
32 if path == "/rooms" {
33 return roomPath{tab: "rooms"}
34 }
35 rest, ok := strings.CutPrefix(path, "/rooms/")
36 if !ok {
37 return roomPath{}
38 }
39 parts := strings.Split(rest, "/")
40 if !roomIDPattern.MatchString(parts[0]) {
41 return roomPath{}
42 }
43 switch {
44 case len(parts) == 1:
45 return roomPath{tab: "room", id: parts[0]}
46 case len(parts) == 3 && parts[1] == "thread" && eventIDPattern.MatchString(parts[2]):
47 return roomPath{tab: "thread", id: parts[0], event: parts[2]}
48 }
49 return roomPath{}
50}
51
52// roomList reads the rooms the viewer may see for the rail. A failure
53// leaves the rail empty rather than failing the page.
54func (a *App) roomList(ctx context.Context, actor string) []any {
55 if reader := a.roomsReader(); reader != nil {
56 list, err := reader.ListRooms(ctx, actor, "", 100)
57 if err != nil {
58 return nil
59 }
60 return browseRows(roomListValue(list))
61 }
62 raw, _ := json.Marshal(map[string]any{"limit": 100})
63 rows, err := a.readRows(ctx, "browserooms", []json.RawMessage{raw}, actor)
64 if err != nil {
65 return nil
66 }
67 return rows
68}
69
70// legacyRoomPage is the compatibility decoder for Backend.Query results.
71// Typed production adapters use RoomPage from read_contracts.go instead.
72type legacyRoomPage struct {
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"`
79}
80
81func legacyRoomPageValue(value any) (legacyRoomPage, bool) {
82 encoded, err := json.Marshal(value)
83 if err != nil {
84 return legacyRoomPage{}, false
85 }
86 var page legacyRoomPage
87 if err := json.Unmarshal(encoded, &page); err != nil {
88 return legacyRoomPage{}, false
89 }
90 return page, true
91}
92
93// roomSlice normalizes legacy and JSON-shaped backend results into the room
94// page contract. The fallback keeps compatibility with in-process adapters
95// that expose a field through their older collaboration helper.
96func roomSlice(value any, field string) []any {
97 if page, ok := legacyRoomPageValue(value); ok {
98 switch field {
99 case "members":
100 return page.Members
101 case "messages":
102 return page.Messages
103 case "replies":
104 return page.Replies
105 case "edits":
106 return page.Edits
107 }
108 }
109 return collaborationSlice(value, field)
110}
111
112func roomMembers(value any) []any { return roomSlice(value, "members") }
113
114// memberIndex maps member pubkeys to their rows so a message can carry the
115// author's role and agent marker.
116func memberIndex(value any) map[string]map[string]any {
117 index := map[string]map[string]any{}
118 for _, row := range roomMembers(value) {
119 member := valueMap(row)
120 if pubkey := plainString(member["pubkey"]); pubkey != "" {
121 index[pubkey] = member
122 }
123 }
124 return index
125}
126
127// replyCounts prefers complete thread summaries over the legacy page counts.
128func replyCounts(value any) map[string]int {
129 if page, ok := legacyRoomPageValue(value); ok && page.ReplyCounts != nil {
130 return page.ReplyCounts
131 }
132 counts := map[string]int{}
133 for _, row := range roomSlice(value, "messages") {
134 if root := webRoomReplyRoot(row); root != "" {
135 counts[root]++
136 }
137 }
138 return counts
139}
140
141// reaction is one line of a message's reaction summary.
142type reaction struct {
143 Content string
144 Count int
145}
146
147// reactionSummary groups the page's reactions by the message they answer.
148// roomEdit is the newest kind 40003 edit aimed at one message.
149type roomEdit struct {
150 PubKey, Content string
151 CreatedAt int64
152 Tags [][]string
153}
154
155// editSummary maps message ids to the newest edit (kind 40003, Buzz's
156// in-place message edit) per editing key found in the page's messages and
157// replies, so a stranger's edit never shadows the author's own.
158func editSummary(value any) map[string]map[string]roomEdit {
159 edits := map[string]map[string]roomEdit{}
160 for _, field := range []string{"messages", "replies", "edits"} {
161 for _, row := range roomSlice(value, field) {
162 e := valueMap(row)
163 if plainString(e["kind"]) != "40003" {
164 continue
165 }
166 targets := tagValues(row, "e")
167 if len(targets) == 0 {
168 continue
169 }
170 target := targets[len(targets)-1]
171 edit := roomEdit{PubKey: plainString(e["pubkey"]), Content: plainString(e["content"]), CreatedAt: unixSeconds(e["created_at"]), Tags: roomTags(row)}
172 if edits[target] == nil {
173 edits[target] = map[string]roomEdit{}
174 }
175 if prior, found := edits[target][edit.PubKey]; !found || edit.CreatedAt > prior.CreatedAt {
176 edits[target][edit.PubKey] = edit
177 }
178 }
179 }
180 return edits
181}
182
183func reactionSummary(value any) map[string][]reaction {
184 counts := map[string]map[string]int{}
185 for _, row := range roomSlice(value, "messages") {
186 e := valueMap(row)
187 if plainString(e["kind"]) != "7" {
188 continue
189 }
190 targets := tagValues(row, "e")
191 if len(targets) == 0 {
192 continue
193 }
194 target := targets[len(targets)-1]
195 content := strings.TrimSpace(plainString(e["content"]))
196 if content == "" || content == "+" {
197 content = "+1"
198 }
199 if counts[target] == nil {
200 counts[target] = map[string]int{}
201 }
202 counts[target][content]++
203 }
204 if page, ok := legacyRoomPageValue(value); ok && page.ReactionCounts != nil {
205 counts = page.ReactionCounts
206 }
207 summary := map[string][]reaction{}
208 for target, byContent := range counts {
209 for content, count := range byContent {
210 summary[target] = append(summary[target], reaction{Content: content, Count: count})
211 }
212 sort.Slice(summary[target], func(i, j int) bool {
213 if summary[target][i].Count != summary[target][j].Count {
214 return summary[target][i].Count > summary[target][j].Count
215 }
216 return summary[target][i].Content < summary[target][j].Content
217 })
218 }
219 return summary
220}
221
222// tagValues lists the second field of every tag with the given name.
223func tagValues(value any, name string) []string {
224 var out []string
225 var tags [][]string
226 if encoded, err := json.Marshal(valueMap(value)["tags"]); err == nil {
227 _ = json.Unmarshal(encoded, &tags)
228 }
229 for _, tag := range tags {
230 if len(tag) > 1 && tag[0] == name {
231 out = append(out, tag[1])
232 }
233 }
234 return out
235}
236
237// roomContent renders message text as escaped HTML with http(s) URLs and
238// nostr links turned into anchors. Nostr links open through /open so the
239// relay resolves them to a page.
240func roomContent(text string) template.HTML {
241 var out strings.Builder
242 last := 0
243 for _, match := range roomLinkPattern.FindAllStringIndex(text, -1) {
244 out.WriteString(template.HTMLEscapeString(text[last:match[0]]))
245 link := text[match[0]:match[1]]
246 trimmed := strings.TrimRight(link, ".,;:!?)")
247 rest := link[len(trimmed):]
248 href := trimmed
249 if !strings.HasPrefix(trimmed, "http") {
250 href = "/open?target=" + template.URLQueryEscaper(trimmed)
251 }
252 out.WriteString(`<a href="` + template.HTMLEscapeString(href) + `"`)
253 if strings.HasPrefix(trimmed, "http") {
254 out.WriteString(` rel="noopener"`)
255 }
256 out.WriteString(`>` + template.HTMLEscapeString(trimmed) + `</a>` + template.HTMLEscapeString(rest))
257 last = match[1]
258 }
259 out.WriteString(template.HTMLEscapeString(text[last:]))
260 return template.HTML(out.String())
261}
262
263// age renders how long ago a Unix timestamp was, compactly, for the rail.
264func age(value any) string {
265 seconds := unixSeconds(value)
266 if seconds <= 0 {
267 return ""
268 }
269 elapsed := time.Since(time.Unix(seconds, 0))
270 switch {
271 case elapsed < time.Minute:
272 return "now"
273 case elapsed < time.Hour:
274 return plainString(int(elapsed.Minutes())) + "m"
275 case elapsed < 24*time.Hour:
276 return plainString(int(elapsed.Hours())) + "h"
277 default:
278 return plainString(int(elapsed.Hours()/24)) + "d"
279 }
280}
281
282// clock renders a message time: the UTC clock time today, otherwise the day
283// and time.
284func clock(value any) string {
285 seconds := unixSeconds(value)
286 if seconds <= 0 {
287 return ""
288 }
289 stamp := time.Unix(seconds, 0).UTC()
290 if stamp.Format("2006-01-02") == time.Now().UTC().Format("2006-01-02") {
291 return stamp.Format("15:04")
292 }
293 return stamp.Format("Jan 2 15:04")
294}
295
296// roomItem is one message as the page shows it: the event's fields, the
297// author's markers, the thread state and the reactions it has received.
298type roomItem struct {
299 ID, PubKey, Kind, Content, Room, Root, Quote, Role, Operator, Notice string
300 CreatedAt, UpdatedAt int64
301 Agent, InThread, Edited, Own bool
302 Mentions []string
303 Replies int
304 Reactions []reaction
305 Attachments []roomAttachment
306}
307
308// roomItems turns a browse result's messages or replies into view items,
309// oldest first. Reactions and edits become summaries on their targets.
310func roomItems(value any, field string, actors ...string) []roomItem {
311 data := valueMap(value)
312 actor := firstString(actors)
313 room := plainString(valueMap(data["room"])["id"])
314 members := memberIndex(value)
315 replies := replyCounts(value)
316 reactions := reactionSummary(value)
317 edits := editSummary(value)
318 rows := roomSlice(value, field)
319 items := make([]roomItem, 0, len(rows))
320 for i := len(rows) - 1; i >= 0; i-- {
321 item, ok := newRoomItem(rows[i], room, members)
322 if !ok || field == "messages" && item.Root != "" {
323 continue
324 }
325 // Only the author edits their own message, and only forward in time.
326 if edit, found := edits[item.ID][item.PubKey]; found && edit.CreatedAt >= item.CreatedAt {
327 item.Content, item.Attachments, item.Edited = edit.Content, roomAttachments(map[string]any{"content": edit.Content, "tags": edit.Tags}), true
328 item.UpdatedAt = edit.CreatedAt
329 }
330 item.Replies = replies[item.ID]
331 item.Reactions = reactions[item.ID]
332 item.InThread = field == "replies"
333 item.Own = actor != "" && item.PubKey == actor && item.Notice == ""
334 items = append(items, item)
335 }
336 return items
337}
338
339// roomRoot is the thread page's root message with its complete reply count.
340func roomRoot(value any, actors ...string) roomItem {
341 data := valueMap(value)
342 item, _ := newRoomItem(data["root"], plainString(valueMap(data["room"])["id"]), memberIndex(value))
343 if edit, found := editSummary(value)[item.ID][item.PubKey]; found && edit.CreatedAt >= item.CreatedAt {
344 item.Content, item.Attachments, item.Edited = edit.Content, roomAttachments(map[string]any{"content": edit.Content, "tags": edit.Tags}), true
345 item.UpdatedAt = edit.CreatedAt
346 }
347 item.Replies = len(roomSlice(value, "replies"))
348 if count, ok := replyCounts(value)[item.ID]; ok {
349 item.Replies = count
350 }
351 item.Reactions = reactionSummary(value)[item.ID]
352 item.InThread = true
353 actor := firstString(actors)
354 item.Own = actor != "" && item.PubKey == actor && item.Notice == ""
355 return item
356}
357
358func firstString(values []string) string {
359 if len(values) == 0 {
360 return ""
361 }
362 return values[0]
363}
364
365func newRoomItem(row any, room string, members map[string]map[string]any) (roomItem, bool) {
366 e := valueMap(row)
367 item := roomItem{ID: plainString(e["id"]), PubKey: plainString(e["pubkey"]), Kind: plainString(e["kind"]), Content: plainString(e["content"]), Room: room, CreatedAt: unixSeconds(e["created_at"])}
368 item.UpdatedAt = item.CreatedAt
369 item.Attachments = roomAttachments(row)
370 // Flotilla and other NIP-29 clients use q tags for quoted events. Keep
371 // only a valid event id so malformed references never become links.
372 if quotes := tagValues(row, "q"); len(quotes) > 0 && eventIDPattern.MatchString(quotes[0]) {
373 item.Quote = quotes[0]
374 }
375 switch item.Kind {
376 case "7", "40003":
377 return roomItem{}, false
378 case "44100", "44101":
379 who := tagValues(row, "p")
380 if len(who) == 0 {
381 return roomItem{}, false
382 }
383 item.PubKey = who[0]
384 item.Notice = "joined the room"
385 if item.Kind == "44101" {
386 item.Notice = "left the room"
387 }
388 case "9", "12":
389 item.Root = webRoomReplyRoot(row)
390 }
391 if member := members[item.PubKey]; member != nil {
392 item.Role = plainString(member["role"])
393 item.Agent, _ = member["agent"].(bool)
394 item.Operator = plainString(member["operator"])
395 }
396 if item.Notice == "" {
397 for _, mention := range tagValues(row, "p") {
398 if eventIDPattern.MatchString(mention) && mention != item.PubKey {
399 item.Mentions = append(item.Mentions, mention)
400 }
401 }
402 }
403 return item, item.ID != ""
404}
405
406// roomAdmin reports whether a room role may administer the room.
407func roomAdmin(role any) bool {
408 value := plainString(role)
409 return value == "owner" || value == "admin"
410}
411
412func webRoomReplyRoot(row any) string {
413 kind, err := strconv.Atoi(plainString(valueMap(row)["kind"]))
414 if err != nil {
415 return ""
416 }
417 return event.RoomReplyRoot(event.Event{Kind: kind, Tags: roomTags(row)})
418}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs