tinyrelayfile
tinyrelay/internal/daemon/webui_rooms.go/

internal/daemon/webui_rooms.go raw
1package daemon
2
3import (
4 "context"
5 "errors"
6 "strings"
7
8 "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)
13
14var _ webui.RoomsReader = (*Tenant)(nil)
15var _ webui.RoomsReader = backend{}
16
17func (b backend) ListRooms(ctx context.Context, actor, cursor string, limit int) (webui.RoomList, error) {
18 return b.tenant.ListRooms(ctx, actor, cursor, limit)
19}
20
21func (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}
24
25func (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}
28
29func (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{}, err
33 }
34 defer done()
35 return t.listRoomsCore(ctx, actor, cursor, limit)
36}
37
38func (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{}, err
41 }
42 if limit <= 0 || limit > 100 {
43 limit = 100
44 }
45 rooms, err := t.community.Rooms(ctx)
46 if err != nil {
47 return webui.RoomList{}, err
48 }
49 result := webui.RoomList{}
50 for _, room := range rooms {
51 if room.ID <= cursor {
52 continue
53 }
54 _, role, roleErr := t.roomFor(ctx, actor, room.ID)
55 if roleErr != nil {
56 continue
57 }
58 if len(result.Rooms) == limit {
59 result.NextCursor = result.Rooms[len(result.Rooms)-1].ID
60 break
61 }
62 summary, summaryErr := t.roomSummary(ctx, actor, room, role)
63 if summaryErr != nil {
64 return webui.RoomList{}, summaryErr
65 }
66 result.Rooms = append(result.Rooms, webuiRoomSummary(summary))
67 }
68 return result, nil
69}
70
71func (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{}, err
75 }
76 defer done()
77 return t.readRoomCore(ctx, actor, roomID, cursor, limit, true)
78}
79
80func (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{}, err
83 }
84 room, role, err := t.roomFor(ctx, actor, roomID)
85 if err != nil {
86 return webui.RoomPage{}, err
87 }
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{}, err
95 }
96 members, err := t.webuiRoomMembers(ctx, room.ID, role)
97 if err != nil {
98 return webui.RoomPage{}, err
99 }
100 readMessages := t.roomMessages
101 if foldReplies {
102 readMessages = t.roomTimelineMessages
103 }
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{}, err
108 }
109 edits, err := t.roomEdits(ctx, actor, room.ID, messages)
110 if err != nil {
111 return webui.RoomPage{}, err
112 }
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{}, err
118 }
119 }
120 return page, nil
121}
122
123// roomTimelineMessages pages by visible top-level messages rather than raw
124// events. Folded replies and reactions are summarized separately so a burst
125// 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 = 100
129 }
130 position := cursor
131 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, "", err
136 }
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 continue
143 }
144 if next == "" && i == len(page)-1 {
145 return rows, "", nil
146 }
147 return rows, collaborationCursor(collaborationItemFrom(row)), nil
148 }
149 if next == "" {
150 return rows, "", nil
151 }
152 position, err = parseCollaborationCursor(next)
153 if err != nil {
154 return nil, "", err
155 }
156 }
157}
158
159func (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 continue
165 }
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 the
169 // room. Omit its count instead of presenting a partial total.
170 continue
171 }
172 if err != nil {
173 return nil, nil, err
174 }
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] = true
183 }
184 }
185 if len(ids) == 0 {
186 return counts, reactions, nil
187 }
188 filter := event.Filter{Kinds: []int{7}, Tags: map[string][]string{"h": {roomID}, "e": ids}}
189 var cursor *storage.EventCursor
190 for {
191 rows, next, err := t.roomMessages(ctx, actor, filter, cursor, 100)
192 if err != nil {
193 return nil, nil, err
194 }
195 for _, row := range rows {
196 targets := event.TagValues(row, "e")
197 if len(targets) == 0 {
198 continue
199 }
200 target := targets[len(targets)-1]
201 if !selected[target] {
202 continue
203 }
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, nil
215 }
216 cursor, err = parseCollaborationCursor(next)
217 if err != nil {
218 return nil, nil, err
219 }
220 }
221}
222
223func 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 false
229 }
230}
231
232func (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{}, err
236 }
237 defer done()
238 return t.readThreadCore(ctx, actor, roomID, rootID, cursor, limit)
239}
240
241func (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{}, err
244 }
245 room, role, err := t.roomFor(ctx, actor, roomID)
246 if err != nil {
247 return webui.RoomPage{}, err
248 }
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{}, err
259 }
260 all, err := t.roomThreadReplies(ctx, actor, room.ID, root.ID)
261 if err != nil {
262 return webui.RoomPage{}, err
263 }
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{}, err
269 }
270 members, err := t.webuiRoomMembers(ctx, room.ID, role)
271 if err != nil {
272 return webui.RoomPage{}, err
273 }
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}, nil
275}
276
277func (t *Tenant) webuiRoomMembers(ctx context.Context, roomID, role string) ([]webui.RoomMember, error) {
278 if role == "" && !t.Policy().DirectoryPublic {
279 return nil, nil
280 }
281 rows, err := t.community.RoomMembers(ctx, roomID)
282 if err != nil {
283 return nil, err
284 }
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, err
290 }
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, grantErr
296 }
297 if found {
298 member.Operator = grant.Owner
299 }
300 }
301 members = append(members, member)
302 }
303 return members, nil
304}
305
306func webuiRoomSummary(summary roomSummary) webui.RoomSummary {
307 room := webuiRoomSummaryFromRoom(summary.Room)
308 room.Members, room.LastMessageAt, room.Role = summary.Members, summary.LastMessageAt, summary.Role
309 return room
310}
311
312func 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}
315
316func normalizeRoomLimit(limit int) int {
317 if limit <= 0 || limit > 100 {
318 return 100
319 }
320 return limit
321}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs