| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 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. |
| 17 | type roomPath struct { |
| 18 | tab string |
| 19 | id string |
| 20 | event string |
| 21 | } |
| 22 | |
| 23 | var ( |
| 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. |
| 31 | func 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. |
| 54 | func (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. |
| 72 | type 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 | |
| 81 | func 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. |
| 96 | func 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 | |
| 112 | func 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. |
| 116 | func 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. |
| 128 | func 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. |
| 142 | type 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. |
| 149 | type 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. |
| 158 | func 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 | |
| 183 | func 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. |
| 223 | func 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. |
| 240 | func 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. |
| 264 | func 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. |
| 284 | func 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. |
| 298 | type 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. |
| 310 | func 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. |
| 340 | func 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 | |
| 358 | func firstString(values []string) string { |
| 359 | if len(values) == 0 { |
| 360 | return "" |
| 361 | } |
| 362 | return values[0] |
| 363 | } |
| 364 | |
| 365 | func 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. |
| 407 | func roomAdmin(role any) bool { |
| 408 | value := plainString(role) |
| 409 | return value == "owner" || value == "admin" |
| 410 | } |
| 411 | |
| 412 | func 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 | } |