1package tinyclient23import (4 "context"5 "encoding/json"6 "html/template"7 "regexp"8 "sort"9 "strconv"10 "strings"11 "time"1213 "github.com/FelineStateMachine/tinyrelay/internal/event"14)1516// roomPath is one rooms page: the list, one room, or one thread in a room.17type roomPath struct {18 tab string19 id string20 event string21}2223var (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)2829// roomRoute maps /rooms, /rooms/<id> and /rooms/<id>/thread/<event> to a30// 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}5152// roomList reads the rooms the viewer may see for the rail. A failure53// 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 nil59 }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 nil66 }67 return rows68}6970// 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}8081func legacyRoomPageValue(value any) (legacyRoomPage, bool) {82 encoded, err := json.Marshal(value)83 if err != nil {84 return legacyRoomPage{}, false85 }86 var page legacyRoomPage87 if err := json.Unmarshal(encoded, &page); err != nil {88 return legacyRoomPage{}, false89 }90 return page, true91}9293// roomSlice normalizes legacy and JSON-shaped backend results into the room94// page contract. The fallback keeps compatibility with in-process adapters95// 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.Members101 case "messages":102 return page.Messages103 case "replies":104 return page.Replies105 case "edits":106 return page.Edits107 }108 }109 return collaborationSlice(value, field)110}111112func roomMembers(value any) []any { return roomSlice(value, "members") }113114// memberIndex maps member pubkeys to their rows so a message can carry the115// 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] = member122 }123 }124 return index125}126127// 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.ReplyCounts131 }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 counts139}140141// reaction is one line of a message's reaction summary.142type reaction struct {143 Content string144 Count int145}146147// 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 string151 CreatedAt int64152 Tags [][]string153}154155// editSummary maps message ids to the newest edit (kind 40003, Buzz's156// in-place message edit) per editing key found in the page's messages and157// 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 continue165 }166 targets := tagValues(row, "e")167 if len(targets) == 0 {168 continue169 }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] = edit177 }178 }179 }180 return edits181}182183func 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 continue189 }190 targets := tagValues(row, "e")191 if len(targets) == 0 {192 continue193 }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.ReactionCounts206 }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].Count215 }216 return summary[target][i].Content < summary[target][j].Content217 })218 }219 return summary220}221222// tagValues lists the second field of every tag with the given name.223func tagValues(value any, name string) []string {224 var out []string225 var tags [][]string226 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 out235}236237// roomContent renders message text as escaped HTML with http(s) URLs and238// nostr links turned into anchors. Nostr links open through /open so the239// relay resolves them to a page.240func roomContent(text string) template.HTML {241 var out strings.Builder242 last := 0243 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 := trimmed249 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}262263// 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}281282// clock renders a message time: the UTC clock time today, otherwise the day283// 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}295296// roomItem is one message as the page shows it: the event's fields, the297// 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 string300 CreatedAt, UpdatedAt int64301 Agent, InThread, Edited, Own bool302 Mentions []string303 Replies int304 Reactions []reaction305 Attachments []roomAttachment306}307308// 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 continue324 }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}), true328 item.UpdatedAt = edit.CreatedAt329 }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 items337}338339// 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}), true345 item.UpdatedAt = edit.CreatedAt346 }347 item.Replies = len(roomSlice(value, "replies"))348 if count, ok := replyCounts(value)[item.ID]; ok {349 item.Replies = count350 }351 item.Reactions = reactionSummary(value)[item.ID]352 item.InThread = true353 actor := firstString(actors)354 item.Own = actor != "" && item.PubKey == actor && item.Notice == ""355 return item356}357358func firstString(values []string) string {359 if len(values) == 0 {360 return ""361 }362 return values[0]363}364365func 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.CreatedAt369 item.Attachments = roomAttachments(row)370 // Flotilla and other NIP-29 clients use q tags for quoted events. Keep371 // 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{}, false378 case "44100", "44101":379 who := tagValues(row, "p")380 if len(who) == 0 {381 return roomItem{}, false382 }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}405406// 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}411412func 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}