1package daemon23import (4 "context"5 "errors"6 "strings"78 "github.com/FelineStateMachine/tinyrelay/internal/community"9 "github.com/FelineStateMachine/tinyrelay/internal/event"10 "github.com/FelineStateMachine/tinyrelay/internal/storage"11 webui "github.com/FelineStateMachine/tinyrelay/tinyclient"12)1314var _ webui.RoomsReader = (*Tenant)(nil)15var _ webui.RoomsReader = backend{}1617func (b backend) ListRooms(ctx context.Context, actor, cursor string, limit int) (webui.RoomList, error) {18 return b.tenant.ListRooms(ctx, actor, cursor, limit)19}2021func (b backend) ReadRoom(ctx context.Context, actor, roomID, cursor string, limit int) (webui.RoomPage, error) {22 return b.tenant.ReadRoom(ctx, actor, roomID, cursor, limit)23}2425func (b backend) ReadThread(ctx context.Context, actor, roomID, rootID, cursor string, limit int) (webui.RoomPage, error) {26 return b.tenant.ReadThread(ctx, actor, roomID, rootID, cursor, limit)27}2829func (t *Tenant) ListRooms(ctx context.Context, actor, cursor string, limit int) (webui.RoomList, error) {30 ctx, done, err := t.beginOperation(ctx)31 if err != nil {32 return webui.RoomList{}, err33 }34 defer done()35 return t.listRoomsCore(ctx, actor, cursor, limit)36}3738func (t *Tenant) listRoomsCore(ctx context.Context, actor, cursor string, limit int) (webui.RoomList, error) {39 if err := t.browseRead(ctx, actor); err != nil {40 return webui.RoomList{}, err41 }42 if limit <= 0 || limit > 100 {43 limit = 10044 }45 rooms, err := t.community.Rooms(ctx)46 if err != nil {47 return webui.RoomList{}, err48 }49 result := webui.RoomList{}50 for _, room := range rooms {51 if room.ID <= cursor {52 continue53 }54 _, role, roleErr := t.roomFor(ctx, actor, room.ID)55 if roleErr != nil {56 continue57 }58 if len(result.Rooms) == limit {59 result.NextCursor = result.Rooms[len(result.Rooms)-1].ID60 break61 }62 summary, summaryErr := t.roomSummary(ctx, actor, room, role)63 if summaryErr != nil {64 return webui.RoomList{}, summaryErr65 }66 result.Rooms = append(result.Rooms, webuiRoomSummary(summary))67 }68 return result, nil69}7071func (t *Tenant) ReadRoom(ctx context.Context, actor, roomID, cursor string, limit int) (webui.RoomPage, error) {72 ctx, done, err := t.beginOperation(ctx)73 if err != nil {74 return webui.RoomPage{}, err75 }76 defer done()77 return t.readRoomCore(ctx, actor, roomID, cursor, limit, true)78}7980func (t *Tenant) readRoomCore(ctx context.Context, actor, roomID, cursor string, limit int, foldReplies bool) (webui.RoomPage, error) {81 if err := t.browseRead(ctx, actor); err != nil {82 return webui.RoomPage{}, err83 }84 room, role, err := t.roomFor(ctx, actor, roomID)85 if err != nil {86 return webui.RoomPage{}, err87 }88 position, err := parseCollaborationCursor(cursor)89 if err != nil {90 return webui.RoomPage{}, errors.New("invalid: room cursor")91 }92 summary, err := t.roomSummary(ctx, actor, room, role)93 if err != nil {94 return webui.RoomPage{}, err95 }96 members, err := t.webuiRoomMembers(ctx, room.ID, role)97 if err != nil {98 return webui.RoomPage{}, err99 }100 readMessages := t.roomMessages101 if foldReplies {102 readMessages = t.roomTimelineMessages103 }104 messageFilter := event.Filter{Kinds: roomMessageKinds, Tags: map[string][]string{"h": {room.ID}}}105 messages, next, err := readMessages(ctx, actor, messageFilter, position, normalizeRoomLimit(limit))106 if err != nil {107 return webui.RoomPage{}, err108 }109 edits, err := t.roomEdits(ctx, actor, room.ID, messages)110 if err != nil {111 return webui.RoomPage{}, err112 }113 page := webui.RoomPage{Room: webuiRoomSummary(summary), Members: members, Messages: messages, Edits: edits, NextCursor: next}114 if foldReplies {115 page.ReplyCounts, page.ReactionCounts, err = t.roomInteractionSummaries(ctx, actor, room.ID, messages)116 if err != nil {117 return webui.RoomPage{}, err118 }119 }120 return page, nil121}122123// roomTimelineMessages pages by visible top-level messages rather than raw124// events. Folded replies and reactions are summarized separately so a burst125// of replies cannot make the page empty or consume unbounded response memory.126func (t *Tenant) roomTimelineMessages(ctx context.Context, actor string, filter event.Filter, cursor *storage.EventCursor, limit int) ([]event.Event, string, error) {127 if limit <= 0 {128 limit = 100129 }130 position := cursor131 rows := make([]event.Event, 0, limit)132 for {133 page, next, err := t.roomMessages(ctx, actor, filter, position, 100)134 if err != nil {135 return nil, "", err136 }137 for i, row := range page {138 if roomTimelineTopLevel(row) || (row.Kind == event.KIND_ROOM_MEMBER_ADDED || row.Kind == event.KIND_ROOM_MEMBER_REMOVED) && event.Tag(row, "p") != "" {139 rows = append(rows, row)140 }141 if len(rows) < limit {142 continue143 }144 if next == "" && i == len(page)-1 {145 return rows, "", nil146 }147 return rows, collaborationCursor(collaborationItemFrom(row)), nil148 }149 if next == "" {150 return rows, "", nil151 }152 position, err = parseCollaborationCursor(next)153 if err != nil {154 return nil, "", err155 }156 }157}158159func (t *Tenant) roomInteractionSummaries(ctx context.Context, actor, roomID string, roots []event.Event) (map[string]int, map[string]map[string]int, error) {160 counts := make(map[string]int)161 reactions := make(map[string]map[string]int)162 for _, root := range roots {163 if !roomTimelineTopLevel(root) {164 continue165 }166 replies, err := t.roomThreadReplies(ctx, actor, roomID, root.ID)167 if errors.Is(err, errRoomThreadSizeLimit) || errors.Is(err, errRoomThreadDepthLimit) {168 // A bounded thread view must not prevent reading the rest of the169 // room. Omit its count instead of presenting a partial total.170 continue171 }172 if err != nil {173 return nil, nil, err174 }175 counts[root.ID] = len(replies)176 }177 ids := make([]string, 0, len(roots))178 selected := make(map[string]bool, len(roots))179 for _, root := range roots {180 if roomTimelineTopLevel(root) {181 ids = append(ids, root.ID)182 selected[root.ID] = true183 }184 }185 if len(ids) == 0 {186 return counts, reactions, nil187 }188 filter := event.Filter{Kinds: []int{7}, Tags: map[string][]string{"h": {roomID}, "e": ids}}189 var cursor *storage.EventCursor190 for {191 rows, next, err := t.roomMessages(ctx, actor, filter, cursor, 100)192 if err != nil {193 return nil, nil, err194 }195 for _, row := range rows {196 targets := event.TagValues(row, "e")197 if len(targets) == 0 {198 continue199 }200 target := targets[len(targets)-1]201 if !selected[target] {202 continue203 }204 content := strings.TrimSpace(row.Content)205 if content == "" || content == "+" {206 content = "+1"207 }208 if reactions[target] == nil {209 reactions[target] = make(map[string]int)210 }211 reactions[target][content]++212 }213 if next == "" {214 return counts, reactions, nil215 }216 cursor, err = parseCollaborationCursor(next)217 if err != nil {218 return nil, nil, err219 }220 }221}222223func roomTimelineTopLevel(row event.Event) bool {224 switch row.Kind {225 case event.KIND_CHAT, event.KIND_THREAD, event.KIND_RICH_CONTENT:226 return event.RoomReplyRoot(row) == ""227 default:228 return false229 }230}231232func (t *Tenant) ReadThread(ctx context.Context, actor, roomID, rootID, cursor string, limit int) (webui.RoomPage, error) {233 ctx, done, err := t.beginOperation(ctx)234 if err != nil {235 return webui.RoomPage{}, err236 }237 defer done()238 return t.readThreadCore(ctx, actor, roomID, rootID, cursor, limit)239}240241func (t *Tenant) readThreadCore(ctx context.Context, actor, roomID, rootID, cursor string, limit int) (webui.RoomPage, error) {242 if err := t.browseRead(ctx, actor); err != nil {243 return webui.RoomPage{}, err244 }245 room, role, err := t.roomFor(ctx, actor, roomID)246 if err != nil {247 return webui.RoomPage{}, err248 }249 if len(rootID) != 64 || !hexLower(rootID) {250 return webui.RoomPage{}, errors.New("invalid: thread root")251 }252 position, err := parseCollaborationCursor(cursor)253 if err != nil {254 return webui.RoomPage{}, errors.New("invalid: thread cursor")255 }256 root, err := t.roomThreadRoot(ctx, actor, room.ID, rootID)257 if err != nil {258 return webui.RoomPage{}, err259 }260 all, err := t.roomThreadReplies(ctx, actor, room.ID, root.ID)261 if err != nil {262 return webui.RoomPage{}, err263 }264 replies, next := roomThreadPage(all, position, normalizeRoomLimit(limit))265 targets := append([]event.Event{root}, replies...)266 edits, err := t.roomEdits(ctx, actor, room.ID, targets)267 if err != nil {268 return webui.RoomPage{}, err269 }270 members, err := t.webuiRoomMembers(ctx, room.ID, role)271 if err != nil {272 return webui.RoomPage{}, err273 }274 return webui.RoomPage{Room: webuiRoomSummaryFromRoom(room), Members: members, Root: &root, Replies: replies, ReplyCounts: map[string]int{root.ID: len(all)}, Edits: edits, NextCursor: next}, nil275}276277func (t *Tenant) webuiRoomMembers(ctx context.Context, roomID, role string) ([]webui.RoomMember, error) {278 if role == "" && !t.Policy().DirectoryPublic {279 return nil, nil280 }281 rows, err := t.community.RoomMembers(ctx, roomID)282 if err != nil {283 return nil, err284 }285 members := make([]webui.RoomMember, 0, len(rows))286 for _, row := range rows {287 tenantRole, err := t.community.Role(ctx, row.PubKey)288 if err != nil {289 return nil, err290 }291 member := webui.RoomMember{PubKey: row.PubKey, Role: row.Role, AddedAt: row.AddedAt, Agent: tenantRole == "agent"}292 if member.Agent {293 grant, found, grantErr := t.community.AgentGrant(ctx, row.PubKey)294 if grantErr != nil {295 return nil, grantErr296 }297 if found {298 member.Operator = grant.Owner299 }300 }301 members = append(members, member)302 }303 return members, nil304}305306func webuiRoomSummary(summary roomSummary) webui.RoomSummary {307 room := webuiRoomSummaryFromRoom(summary.Room)308 room.Members, room.LastMessageAt, room.Role = summary.Members, summary.LastMessageAt, summary.Role309 return room310}311312func webuiRoomSummaryFromRoom(room community.Room) webui.RoomSummary {313 return webui.RoomSummary{ID: room.ID, Name: room.Name, About: room.About, Picture: room.Picture, Access: room.Access, CreatedBy: room.CreatedBy, CreatedAt: room.CreatedAt, EventID: room.EventID, DeletedAt: room.DeletedAt}314}315316func normalizeRoomLimit(limit int) int {317 if limit <= 0 || limit > 100 {318 return 100319 }320 return limit321}