1package daemon23import (4 "context"5 "encoding/json"6 "errors"7 "time"89 "github.com/FelineStateMachine/tinyrelay/internal/event"10 "github.com/FelineStateMachine/tinyrelay/internal/storage"11)1213func (t *Tenant) browseChatActivity(ctx context.Context, actor string, params []json.RawMessage) (any, error) {14 var q struct {15 Room string `json:"room"`16 Root string `json:"root"`17 }18 if len(params) != 1 || json.Unmarshal(params[0], &q) != nil || q.Room == "" {19 return nil, errors.New("invalid: chat activity room")20 }21 if _, _, err := t.roomFor(ctx, actor, q.Room); err != nil {22 return nil, err23 }24 if q.Root != "" {25 root, err := t.roomThreadRoot(ctx, actor, q.Room, q.Root)26 if err != nil {27 return nil, err28 }29 q.Root = root.ID30 }31 rows := []event.Event{}32 for _, filter := range []event.Filter{33 {Kinds: approvalKinds, Tags: map[string][]string{"h": {q.Room}}},34 {Kinds: jobRequestKinds, Tags: map[string][]string{"h": {q.Room}}},35 } {36 page, err := t.store.Query(ctx, filter, storage.QueryOptions{Access: storage.Access{PubKeys: []string{actor}}, Limit: 1000})37 if err != nil {38 return nil, err39 }40 rows = append(rows, page.Events...)41 }42 session := browseSession(t, actor)43 jobs, approvals := []jobItem{}, []approvalItem{}44 now := time.Now().Unix()45 roots := map[string]string{}46 for _, row := range rows {47 _, isApproval := approvalRequest(row)48 if !event.IsJobRequest(row.Kind) && !isApproval {49 continue50 }51 if !t.gate.CanSee(ctx, row, session, nil) {52 continue53 }54 if q.Root != "" && !t.activityInThread(ctx, actor, q.Root, row, roots) {55 continue56 }57 if event.IsJobRequest(row.Kind) && t.Policy().Features.Jobs && (event.Expiration(row) == 0 || event.Expiration(row) > now) {58 jobs = append(jobs, jobItemFrom(row))59 } else if kind, ok := approvalRequest(row); ok && (row.PubKey == actor || containsString(approvalAsked(row), actor)) {60 approvals = append(approvals, approvalItemFrom(row, kind, t.publicURL))61 }62 }63 // Keep the inline panel bounded even when a busy room has accumulated a64 // large amount of historical task and approval traffic. The store returns65 // each filter newest first, so retaining the first 50 preserves the useful66 // recent context while the dedicated views remain available for history.67 if len(jobs) > 50 {68 jobs = jobs[:50]69 }70 if len(approvals) > 50 {71 approvals = approvals[:50]72 }73 answers, err := t.jobAnswers(ctx, jobs, session, now)74 if err != nil {75 return nil, err76 }77 for i := range jobs {78 jobSettle(&jobs[i], answers[jobs[i].ID])79 }80 decisions, err := t.approvalAnswers(ctx, approvals, now)81 if err != nil {82 return nil, err83 }84 for i := range approvals {85 approvalSettle(&approvals[i], decisions[approvals[i].ID], now)86 }87 return map[string]any{"jobs": jobs, "approvals": approvals}, nil88}8990func (b backend) ReadChatActivity(ctx context.Context, actor, room, root string) (any, error) {91 raw, err := json.Marshal(map[string]string{"room": room, "root": root})92 if err != nil {93 return nil, err94 }95 return b.tenant.Execute(ctx, actor, "browsechatactivity", []json.RawMessage{raw})96}9798// Resolve parent-only references once per parent while keeping unrelated tasks99// out of the thread. roomThreadRoot also enforces room and reader access.100func (t *Tenant) activityInThread(ctx context.Context, actor, root string, row event.Event, roots map[string]string) bool {101 if row.ID == root {102 return true103 }104 reference := event.RoomReplyRoot(row)105 if reference == "" && event.IsJobRequest(row.Kind) {106 reference = event.Tag(row, "e")107 }108 if reference == root {109 return true110 }111 if reference == "" {112 return false113 }114 canonical, known := roots[reference]115 if !known {116 parent, err := t.roomThreadRoot(ctx, actor, event.Tag(row, "h"), reference)117 if err == nil {118 canonical = parent.ID119 }120 roots[reference] = canonical121 }122 return canonical == root123}