| 1 | diff --git a/internal/daemon/approvals.go b/internal/daemon/approvals.go |
| 2 | new file mode 100644 |
| 3 | index 0000000..0b4cf80 |
| 4 | --- /dev/null |
| 5 | +++ b/internal/daemon/approvals.go |
| 6 | @@ -0,0 +1,295 @@ |
| 7 | +package daemon |
| 8 | + |
| 9 | +// Approvals: a request for a decision is an ordinary comment or room message |
| 10 | +// that carries a request tag and names the people asked with p tags. The |
| 11 | +// asked person answers with a signed reaction (+ approves, - denies) or a |
| 12 | +// reply. Nothing here introduces a kind; the asker learns the answer through |
| 13 | +// the events it already watches. |
| 14 | + |
| 15 | +import ( |
| 16 | + "context" |
| 17 | + "strconv" |
| 18 | + "strings" |
| 19 | + "time" |
| 20 | + |
| 21 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 22 | + "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 23 | +) |
| 24 | + |
| 25 | +const ( |
| 26 | + kindComment = 1111 |
| 27 | + kindChatMessage = 9 |
| 28 | + kindThreadRoot = 11 |
| 29 | + // approvalScan bounds how many stored events one listing walks. |
| 30 | + approvalScan = 1000 |
| 31 | +) |
| 32 | + |
| 33 | +// approvalTypes is the bounded vocabulary of the request tag. |
| 34 | +var approvalTypes = []string{"approve", "decide", "question"} |
| 35 | +var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot} |
| 36 | + |
| 37 | +// approvalItem is one request as the asked person sees it. |
| 38 | +type approvalItem struct { |
| 39 | + ID string `json:"id"` |
| 40 | + Kind int `json:"kind"` |
| 41 | + Type string `json:"type"` |
| 42 | + Asker string `json:"asker"` |
| 43 | + Asked []string `json:"asked"` |
| 44 | + Subject string `json:"subject,omitempty"` |
| 45 | + Content string `json:"content"` |
| 46 | + CreatedAt int64 `json:"created_at"` |
| 47 | + Expires int64 `json:"expires,omitempty"` |
| 48 | + State string `json:"state"` |
| 49 | + Room string `json:"room,omitempty"` |
| 50 | + About *approvalAbout `json:"about,omitempty"` |
| 51 | + Answer *approvalAnswer `json:"answer,omitempty"` |
| 52 | + Event event.Event `json:"event"` |
| 53 | +} |
| 54 | + |
| 55 | +// approvalAbout is what the request refers to: a repository coordinate, an |
| 56 | +// event, or both, with the page that opens it. |
| 57 | +type approvalAbout struct { |
| 58 | + Coordinate string `json:"coordinate,omitempty"` |
| 59 | + Event string `json:"event,omitempty"` |
| 60 | + Kind string `json:"kind,omitempty"` |
| 61 | + URL string `json:"url"` |
| 62 | +} |
| 63 | + |
| 64 | +// approvalAnswer is the answer that counts: the newest reaction from an asked |
| 65 | +// key, or the newest reply when nobody reacted. |
| 66 | +type approvalAnswer struct { |
| 67 | + ID string `json:"id"` |
| 68 | + Kind int `json:"kind"` |
| 69 | + Author string `json:"author"` |
| 70 | + Decision string `json:"decision"` |
| 71 | + Content string `json:"content"` |
| 72 | + CreatedAt int64 `json:"created_at"` |
| 73 | +} |
| 74 | + |
| 75 | +// approvalRequest reports whether the event asks anyone for a decision and |
| 76 | +// which type it carries. |
| 77 | +func approvalRequest(e event.Event) (string, bool) { |
| 78 | + if !containsInt(approvalKinds, e.Kind) { |
| 79 | + return "", false |
| 80 | + } |
| 81 | + kind := strings.ToLower(strings.TrimSpace(event.Tag(e, "request"))) |
| 82 | + if !containsString(approvalTypes, kind) || len(approvalAsked(e)) == 0 { |
| 83 | + return "", false |
| 84 | + } |
| 85 | + return kind, true |
| 86 | +} |
| 87 | + |
| 88 | +// approvalAsked lists the keys asked: every p tag other than the asker. |
| 89 | +func approvalAsked(e event.Event) []string { |
| 90 | + return pushRecipients(e) |
| 91 | +} |
| 92 | + |
| 93 | +func approvalItemFrom(e event.Event, kind string, base string) approvalItem { |
| 94 | + item := approvalItem{ID: e.ID, Kind: e.Kind, Type: kind, Asker: e.PubKey, Asked: approvalAsked(e), Subject: strings.TrimSpace(event.Tag(e, "subject")), Content: e.Content, CreatedAt: e.CreatedAt, Expires: event.Expiration(e), State: "open", Room: event.Tag(e, "h"), Event: e} |
| 95 | + item.About = approvalAboutFrom(e, base) |
| 96 | + return item |
| 97 | +} |
| 98 | + |
| 99 | +// approvalAboutFrom resolves the request's a or e reference to a page. A |
| 100 | +// repository coordinate opens the repository, or its issue or pull request |
| 101 | +// when the root is one; any other event opens the event page. |
| 102 | +func approvalAboutFrom(e event.Event, base string) *approvalAbout { |
| 103 | + coordinate := "" |
| 104 | + for _, name := range []string{"a", "A"} { |
| 105 | + if value := event.Tag(e, name); value != "" { |
| 106 | + coordinate = value |
| 107 | + break |
| 108 | + } |
| 109 | + } |
| 110 | + referenced, kind := "", "" |
| 111 | + for _, ref := range [][2]string{{"E", "K"}, {"e", "k"}} { |
| 112 | + if value := event.Tag(e, ref[0]); len(value) == 64 { |
| 113 | + referenced, kind = value, event.Tag(e, ref[1]) |
| 114 | + break |
| 115 | + } |
| 116 | + } |
| 117 | + if coordinate == "" && referenced == "" { |
| 118 | + return nil |
| 119 | + } |
| 120 | + about := &approvalAbout{Coordinate: coordinate, Event: referenced, Kind: kind} |
| 121 | + if owner, identifier, ok := repoCoordinate(coordinate); ok { |
| 122 | + about.URL = base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=home" |
| 123 | + if view := map[string]string{"1621": "issue", "1618": "pr"}[kind]; view != "" && referenced != "" { |
| 124 | + about.URL = base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=" + view + "&id=" + referenced |
| 125 | + } |
| 126 | + } else if referenced != "" { |
| 127 | + about.URL = base + "/e/" + referenced |
| 128 | + } else { |
| 129 | + about.URL = base + "/a/" + coordinate |
| 130 | + } |
| 131 | + return about |
| 132 | +} |
| 133 | + |
| 134 | +// approvalDecision maps a reaction to the answer it records. An empty |
| 135 | +// reaction counts as approval, as NIP-25 reads it. |
| 136 | +func approvalDecision(content string) string { |
| 137 | + switch strings.TrimSpace(content) { |
| 138 | + case "+", "": |
| 139 | + return "approved" |
| 140 | + case "-": |
| 141 | + return "denied" |
| 142 | + } |
| 143 | + return "" |
| 144 | +} |
| 145 | + |
| 146 | +// approvalAnswers finds the answers to the given requests in one query and |
| 147 | +// settles each request's state. The newest reaction from an asked key wins; |
| 148 | +// a reply from an asked key answers a request that has no reaction. |
| 149 | +func (t *Tenant) approvalAnswers(ctx context.Context, items []approvalItem, now int64) (map[string][]event.Event, error) { |
| 150 | + answers := make(map[string][]event.Event, len(items)) |
| 151 | + if len(items) == 0 { |
| 152 | + return answers, nil |
| 153 | + } |
| 154 | + ids := make([]string, 0, len(items)) |
| 155 | + authors := []string{} |
| 156 | + asked := make(map[string]map[string]bool, len(items)) |
| 157 | + for _, item := range items { |
| 158 | + ids = append(ids, item.ID) |
| 159 | + authors = append(authors, item.Asked...) |
| 160 | + asked[item.ID] = map[string]bool{} |
| 161 | + for _, key := range item.Asked { |
| 162 | + asked[item.ID][key] = true |
| 163 | + } |
| 164 | + } |
| 165 | + rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction, kindComment}, Authors: uniqueStrings(authors), Tags: map[string][]string{"e": ids}}, storage.QueryOptions{Now: now, Access: storage.Access{All: true}, Limit: approvalScan}) |
| 166 | + if err != nil { |
| 167 | + return nil, err |
| 168 | + } |
| 169 | + sortEventsNewestFirst(rows.Events) |
| 170 | + for _, row := range rows.Events { |
| 171 | + if _, isRequest := approvalRequest(row); isRequest { |
| 172 | + continue |
| 173 | + } |
| 174 | + for _, id := range event.TagValues(row, "e") { |
| 175 | + if asked[id] != nil && asked[id][row.PubKey] { |
| 176 | + answers[id] = append(answers[id], row) |
| 177 | + } |
| 178 | + } |
| 179 | + } |
| 180 | + return answers, nil |
| 181 | +} |
| 182 | + |
| 183 | +// approvalSettle fills the answer and state of one request from its answers, |
| 184 | +// which arrive newest first. |
| 185 | +func approvalSettle(item *approvalItem, rows []event.Event, now int64) { |
| 186 | + var reply *event.Event |
| 187 | + for i := range rows { |
| 188 | + row := rows[i] |
| 189 | + if row.Kind == kindReaction { |
| 190 | + if decision := approvalDecision(row.Content); decision != "" { |
| 191 | + item.Answer = &approvalAnswer{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Decision: decision, Content: row.Content, CreatedAt: row.CreatedAt} |
| 192 | + item.State = "answered" |
| 193 | + return |
| 194 | + } |
| 195 | + } else if reply == nil { |
| 196 | + reply = &row |
| 197 | + } |
| 198 | + } |
| 199 | + if reply != nil { |
| 200 | + item.Answer = &approvalAnswer{ID: reply.ID, Kind: reply.Kind, Author: reply.PubKey, Decision: "replied", Content: reply.Content, CreatedAt: reply.CreatedAt} |
| 201 | + item.State = "answered" |
| 202 | + return |
| 203 | + } |
| 204 | + if item.Expires > 0 && item.Expires <= now { |
| 205 | + item.State = "expired" |
| 206 | + } |
| 207 | +} |
| 208 | + |
| 209 | +// approvalsFor lists every request addressed to the key, newest first, |
| 210 | +// settled against its answers. Expired requests are kept so the person sees |
| 211 | +// what lapsed until maintenance removes them. |
| 212 | +func (t *Tenant) approvalsFor(ctx context.Context, pubkey string, before *storage.EventCursor, limit int) ([]approvalItem, bool, error) { |
| 213 | + now := time.Now().Unix() |
| 214 | + base := strings.TrimRight(t.publicURL, "/") |
| 215 | + page, err := t.store.Query(ctx, event.Filter{Kinds: approvalKinds, Tags: map[string][]string{"p": {pubkey}}}, storage.QueryOptions{Access: storage.Access{PubKeys: []string{pubkey}}, Limit: limit, Before: before}) |
| 216 | + if err != nil { |
| 217 | + return nil, false, err |
| 218 | + } |
| 219 | + items := make([]approvalItem, 0, len(page.Events)) |
| 220 | + for _, row := range page.Events { |
| 221 | + kind, ok := approvalRequest(row) |
| 222 | + if !ok || row.PubKey == pubkey { |
| 223 | + continue |
| 224 | + } |
| 225 | + items = append(items, approvalItemFrom(row, kind, base)) |
| 226 | + } |
| 227 | + answers, err := t.approvalAnswers(ctx, items, now) |
| 228 | + if err != nil { |
| 229 | + return nil, false, err |
| 230 | + } |
| 231 | + for i := range items { |
| 232 | + approvalSettle(&items[i], answers[items[i].ID], now) |
| 233 | + } |
| 234 | + return items, page.More, nil |
| 235 | +} |
| 236 | + |
| 237 | +// openApprovals lists the unanswered, unexpired requests addressed to the |
| 238 | +// key, which the badge and the panel count. |
| 239 | +func (t *Tenant) openApprovals(ctx context.Context, pubkey string) []approvalItem { |
| 240 | + var open []approvalItem |
| 241 | + var cursor *storage.EventCursor |
| 242 | + for scanned := 0; scanned < approvalScan; scanned += 100 { |
| 243 | + items, more, err := t.approvalsFor(ctx, pubkey, cursor, 100) |
| 244 | + if err != nil { |
| 245 | + return open |
| 246 | + } |
| 247 | + for _, item := range items { |
| 248 | + if item.State == "open" { |
| 249 | + open = append(open, item) |
| 250 | + } |
| 251 | + } |
| 252 | + if !more || len(items) == 0 { |
| 253 | + break |
| 254 | + } |
| 255 | + last := items[len(items)-1] |
| 256 | + cursor = &storage.EventCursor{CreatedAt: last.CreatedAt, ID: last.ID} |
| 257 | + } |
| 258 | + return open |
| 259 | +} |
| 260 | + |
| 261 | +// approvalNotices wakes each asked person in the approvals category. The |
| 262 | +// body names the asker and the subject, and the payload carries the actions |
| 263 | +// the device can answer with: approve and deny for decisions, reply for |
| 264 | +// every type. |
| 265 | +func (t *Tenant) approvalNotices(e event.Event, kind string) []pushNotice { |
| 266 | + base := strings.TrimRight(t.publicURL, "/") |
| 267 | + subject := strings.TrimSpace(event.Tag(e, "subject")) |
| 268 | + if subject == "" { |
| 269 | + subject = e.Content |
| 270 | + } |
| 271 | + body := shortKey(e.PubKey) + " asks: " + excerpt(subject) |
| 272 | + actions := []pushAction{{Action: "reply", Title: "Reply"}} |
| 273 | + if kind != "question" { |
| 274 | + actions = []pushAction{{Action: "approve", Title: "Approve"}, {Action: "deny", Title: "Deny"}, {Action: "reply", Title: "Reply"}} |
| 275 | + } |
| 276 | + var notices []pushNotice |
| 277 | + for _, recipient := range approvalAsked(e) { |
| 278 | + notices = append(notices, pushNotice{recipient: recipient, category: pushApprovals, body: body, url: base + "/approvals?id=" + e.ID, actions: actions}) |
| 279 | + } |
| 280 | + return notices |
| 281 | +} |
| 282 | + |
| 283 | +func shortKey(pubkey string) string { |
| 284 | + if len(pubkey) > 12 { |
| 285 | + return pubkey[:12] |
| 286 | + } |
| 287 | + return pubkey |
| 288 | +} |
| 289 | + |
| 290 | +func containsInt(values []int, wanted int) bool { |
| 291 | + for _, value := range values { |
| 292 | + if value == wanted { |
| 293 | + return true |
| 294 | + } |
| 295 | + } |
| 296 | + return false |
| 297 | +} |
| 298 | + |
| 299 | +func approvalCursor(item approvalItem) string { |
| 300 | + return strconv.FormatInt(item.CreatedAt, 10) + ":" + item.ID |
| 301 | +} |
| 302 | diff --git a/internal/daemon/approvals_browse.go b/internal/daemon/approvals_browse.go |
| 303 | new file mode 100644 |
| 304 | index 0000000..d83659d |
| 305 | --- /dev/null |
| 306 | +++ b/internal/daemon/approvals_browse.go |
| 307 | @@ -0,0 +1,182 @@ |
| 308 | +package daemon |
| 309 | + |
| 310 | +// Approval browsing: the requests addressed to the caller with their state |
| 311 | +// and counts, and one request with every answer it received. Both read |
| 312 | +// through the tenant gate with the caller's session, like the other browse |
| 313 | +// methods. |
| 314 | + |
| 315 | +import ( |
| 316 | + "context" |
| 317 | + "encoding/json" |
| 318 | + "errors" |
| 319 | + "fmt" |
| 320 | + "strings" |
| 321 | + "time" |
| 322 | + |
| 323 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 324 | + "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 325 | +) |
| 326 | + |
| 327 | +type approvalBrowseRequest struct { |
| 328 | + Limit int `json:"limit"` |
| 329 | + Cursor string `json:"cursor"` |
| 330 | + State string `json:"state"` |
| 331 | + ID string `json:"id"` |
| 332 | + Event string `json:"event"` |
| 333 | +} |
| 334 | + |
| 335 | +// approvalDevice is one of the caller's registered devices, as a fact for |
| 336 | +// the panel: when it was registered and which categories it wakes for. |
| 337 | +type approvalDevice struct { |
| 338 | + CreatedAt int64 `json:"created_at"` |
| 339 | + Categories []string `json:"categories"` |
| 340 | +} |
| 341 | + |
| 342 | +func approvalBrowseMethod(method string) bool { |
| 343 | + return method == "browseapprovals" || method == "browseapproval" |
| 344 | +} |
| 345 | + |
| 346 | +func (t *Tenant) executeApprovals(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) { |
| 347 | + if actor == "" { |
| 348 | + return nil, errors.New("auth-required: sign in to see requests addressed to you") |
| 349 | + } |
| 350 | + q := approvalBrowseRequest{} |
| 351 | + if len(params) > 0 { |
| 352 | + if err := json.Unmarshal(params[0], &q); err != nil { |
| 353 | + return nil, fmt.Errorf("invalid: approval parameters: %w", err) |
| 354 | + } |
| 355 | + } |
| 356 | + if q.Limit <= 0 || q.Limit > 100 { |
| 357 | + q.Limit = 50 |
| 358 | + } |
| 359 | + switch method { |
| 360 | + case "browseapprovals": |
| 361 | + return t.browseApprovals(ctx, actor, q) |
| 362 | + case "browseapproval": |
| 363 | + if q.ID == "" { |
| 364 | + q.ID = q.Event |
| 365 | + } |
| 366 | + return t.browseApproval(ctx, actor, q.ID) |
| 367 | + } |
| 368 | + return nil, errors.New("unsupported: browse operation") |
| 369 | +} |
| 370 | + |
| 371 | +// browseApprovals lists the requests addressed to the caller. The counts |
| 372 | +// cover every request the walk saw, so the panel can say how many wait even |
| 373 | +// when the page shows only one state. |
| 374 | +func (t *Tenant) browseApprovals(ctx context.Context, actor string, q approvalBrowseRequest) (any, error) { |
| 375 | + if q.State == "" { |
| 376 | + q.State = "all" |
| 377 | + } |
| 378 | + if !containsString([]string{"open", "answered", "expired", "all"}, q.State) { |
| 379 | + return nil, errors.New("invalid: approval state") |
| 380 | + } |
| 381 | + cursor, err := parseCollaborationCursor(q.Cursor) |
| 382 | + if err != nil { |
| 383 | + return nil, errors.New("invalid: approval cursor") |
| 384 | + } |
| 385 | + session := browseSession(t, actor) |
| 386 | + items := make([]approvalItem, 0, q.Limit) |
| 387 | + counts := map[string]int{"open": 0, "answered": 0, "expired": 0} |
| 388 | + var oldest int64 |
| 389 | + next := "" |
| 390 | + for scanned := 0; scanned < approvalScan; scanned += 100 { |
| 391 | + page, more, err := t.approvalsFor(ctx, actor, cursor, 100) |
| 392 | + if err != nil { |
| 393 | + return nil, err |
| 394 | + } |
| 395 | + for _, item := range page { |
| 396 | + cursor = &storage.EventCursor{CreatedAt: item.CreatedAt, ID: item.ID} |
| 397 | + if !t.gate.CanSee(ctx, item.Event, session, nil) { |
| 398 | + continue |
| 399 | + } |
| 400 | + counts[item.State]++ |
| 401 | + if item.State == "open" && (oldest == 0 || item.CreatedAt < oldest) { |
| 402 | + oldest = item.CreatedAt |
| 403 | + } |
| 404 | + if q.State != "all" && q.State != item.State { |
| 405 | + continue |
| 406 | + } |
| 407 | + if len(items) < q.Limit { |
| 408 | + items = append(items, item) |
| 409 | + } else if next == "" { |
| 410 | + next = approvalCursor(items[len(items)-1]) |
| 411 | + } |
| 412 | + } |
| 413 | + if !more || len(page) == 0 { |
| 414 | + break |
| 415 | + } |
| 416 | + if next == "" && len(items) == q.Limit { |
| 417 | + next = approvalCursor(items[len(items)-1]) |
| 418 | + } |
| 419 | + } |
| 420 | + devices, err := t.approvalDevices(ctx, actor) |
| 421 | + if err != nil { |
| 422 | + return nil, err |
| 423 | + } |
| 424 | + return map[string]any{"items": items, "next_cursor": next, "counts": counts, "oldest_open": oldest, "devices": devices}, nil |
| 425 | +} |
| 426 | + |
| 427 | +// browseApproval returns one request with every reaction and reply from the |
| 428 | +// asked keys, newest first. The asked people and the asker may read it. |
| 429 | +func (t *Tenant) browseApproval(ctx context.Context, actor, id string) (any, error) { |
| 430 | + if len(id) != 64 { |
| 431 | + return nil, errors.New("invalid: approval id") |
| 432 | + } |
| 433 | + session := browseSession(t, actor) |
| 434 | + rows, err := t.Query(ctx, []event.Filter{{IDs: []string{id}, Kinds: approvalKinds, Limit: intPtr(1)}}, session) |
| 435 | + if err != nil { |
| 436 | + return nil, err |
| 437 | + } |
| 438 | + if len(rows) != 1 { |
| 439 | + return nil, errors.New("not found: request") |
| 440 | + } |
| 441 | + kind, ok := approvalRequest(rows[0]) |
| 442 | + if !ok { |
| 443 | + return nil, errors.New("not found: request") |
| 444 | + } |
| 445 | + item := approvalItemFrom(rows[0], kind, strings.TrimRight(t.publicURL, "/")) |
| 446 | + if item.Asker != actor && !containsString(item.Asked, actor) { |
| 447 | + return nil, errors.New("restricted: this request is not addressed to you") |
| 448 | + } |
| 449 | + now := time.Now().Unix() |
| 450 | + answers, err := t.approvalAnswers(ctx, []approvalItem{item}, now) |
| 451 | + if err != nil { |
| 452 | + return nil, err |
| 453 | + } |
| 454 | + approvalSettle(&item, answers[item.ID], now) |
| 455 | + history := answers[item.ID] |
| 456 | + if history == nil { |
| 457 | + history = []event.Event{} |
| 458 | + } |
| 459 | + return map[string]any{"item": item, "answers": history}, nil |
| 460 | +} |
| 461 | + |
| 462 | +// approvalDevices lists the caller's registered devices with the categories |
| 463 | +// each one chose. Endpoints and keys stay out of the result. |
| 464 | +func (t *Tenant) approvalDevices(ctx context.Context, pubkey string) ([]approvalDevice, error) { |
| 465 | + rows, err := t.store.DB().QueryContext(ctx, "SELECT created_at, categories FROM web_push WHERE pubkey=? ORDER BY created_at", pubkey) |
| 466 | + if err != nil { |
| 467 | + return nil, err |
| 468 | + } |
| 469 | + defer rows.Close() |
| 470 | + devices := []approvalDevice{} |
| 471 | + for rows.Next() { |
| 472 | + var device approvalDevice |
| 473 | + var raw string |
| 474 | + if err := rows.Scan(&device.CreatedAt, &raw); err != nil { |
| 475 | + return nil, err |
| 476 | + } |
| 477 | + device.Categories = pushCategories |
| 478 | + if allowed := decodeCategories(raw); allowed != nil { |
| 479 | + device.Categories = []string{} |
| 480 | + for _, category := range pushCategories { |
| 481 | + if allowed[category] { |
| 482 | + device.Categories = append(device.Categories, category) |
| 483 | + } |
| 484 | + } |
| 485 | + } |
| 486 | + devices = append(devices, device) |
| 487 | + } |
| 488 | + return devices, rows.Err() |
| 489 | +} |
| 490 | diff --git a/internal/daemon/approvals_test.go b/internal/daemon/approvals_test.go |
| 491 | new file mode 100644 |
| 492 | index 0000000..ce6d633 |
| 493 | --- /dev/null |
| 494 | +++ b/internal/daemon/approvals_test.go |
| 495 | @@ -0,0 +1,242 @@ |
| 496 | +package daemon |
| 497 | + |
| 498 | +import ( |
| 499 | + "context" |
| 500 | + "encoding/json" |
| 501 | + "strconv" |
| 502 | + "strings" |
| 503 | + "testing" |
| 504 | + "time" |
| 505 | + |
| 506 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 507 | +) |
| 508 | + |
| 509 | +const ( |
| 510 | + approvalAskerSecret = "5555555555555555555555555555555555555555555555555555555555555555" |
| 511 | + approvalOtherSecret = "6666666666666666666666666666666666666666666666666666666666666666" |
| 512 | +) |
| 513 | + |
| 514 | +func approvalCall(t *testing.T, tenant *Tenant, actor, method string, params map[string]any) (map[string]any, error) { |
| 515 | + t.Helper() |
| 516 | + raw, err := json.Marshal(params) |
| 517 | + if err != nil { |
| 518 | + t.Fatal(err) |
| 519 | + } |
| 520 | + value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{raw}) |
| 521 | + if err != nil { |
| 522 | + return nil, err |
| 523 | + } |
| 524 | + encoded, err := json.Marshal(value) |
| 525 | + if err != nil { |
| 526 | + t.Fatal(err) |
| 527 | + } |
| 528 | + var result map[string]any |
| 529 | + if err := json.Unmarshal(encoded, &result); err != nil { |
| 530 | + t.Fatal(err) |
| 531 | + } |
| 532 | + return result, nil |
| 533 | +} |
| 534 | + |
| 535 | +func approvalByID(items []map[string]any, id string) map[string]any { |
| 536 | + for _, item := range items { |
| 537 | + if item["id"] == id { |
| 538 | + return item |
| 539 | + } |
| 540 | + } |
| 541 | + return nil |
| 542 | +} |
| 543 | + |
| 544 | +func TestBrowseApprovalsStatesAndAccess(t *testing.T) { |
| 545 | + _, tenant := testTenant(t) |
| 546 | + ctx := context.Background() |
| 547 | + owner := tenant.Policy().Owner |
| 548 | + asker, other := wikiKey(t, approvalAskerSecret), wikiKey(t, approvalOtherSecret) |
| 549 | + now := time.Now().Unix() |
| 550 | + |
| 551 | + open := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-400, "Publish release notes 1.4 as drafted?", []string{"request", "approve"}, []string{"p", owner}, []string{"subject", "Publish release notes 1.4"}, []string{"a", "30617:" + owner + ":notes"}, []string{"expiration", strconv.FormatInt(now+3600, 10)}) |
| 552 | + decided := wikiPublish(t, tenant, approvalAskerSecret, kindThreadRoot, now-300, "Mark pull request 23 merged?", []string{"request", "decide"}, []string{"p", owner}, []string{"h", tenant.meta.Name}) |
| 553 | + // An older denial, a newer approval and a stranger's reaction: the |
| 554 | + // newest reaction from an asked key wins and the stranger is ignored. |
| 555 | + wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-250, "-", []string{"e", decided.ID}, []string{"p", asker}) |
| 556 | + approval := wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-200, "+", []string{"e", decided.ID}, []string{"p", asker}) |
| 557 | + wikiPublish(t, tenant, approvalOtherSecret, kindReaction, now-100, "-", []string{"e", decided.ID}, []string{"p", asker}) |
| 558 | + question := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-200, "Should the edge be mentioned?", []string{"request", "question"}, []string{"p", owner}, []string{"E", decided.ID, "", asker}, []string{"K", "11"}, []string{"P", asker}) |
| 559 | + reply := wikiPublish(t, tenant, wikiOwnerSecret, kindComment, now-150, "Leave it out.", []string{"E", question.ID, "", asker}, []string{"K", "1111"}, []string{"P", asker}, []string{"e", question.ID, "", asker}, []string{"k", "1111"}, []string{"p", asker}) |
| 560 | + // An expired request is saved directly, as it would have been before it |
| 561 | + // lapsed, and shows as expired until maintenance removes it. |
| 562 | + expired := event.Event{Kind: kindComment, CreatedAt: now - 7200, Content: "Rotate the key tonight?", Tags: [][]string{{"request", "approve"}, {"p", owner}, {"expiration", strconv.FormatInt(now-3600, 10)}}} |
| 563 | + if err := event.Sign(&expired, approvalAskerSecret); err != nil { |
| 564 | + t.Fatal(err) |
| 565 | + } |
| 566 | + if _, err := tenant.store.Save(ctx, expired, storageSave(tenant)); err != nil { |
| 567 | + t.Fatal(err) |
| 568 | + } |
| 569 | + elsewhere := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-50, "Only for someone else", []string{"request", "approve"}, []string{"p", other}) |
| 570 | + // Ordinary comments and unknown request types are not requests. |
| 571 | + wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-40, "Plain mention", []string{"p", owner}) |
| 572 | + wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-30, "Unknown type", []string{"request", "veto"}, []string{"p", owner}) |
| 573 | + |
| 574 | + result, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{}) |
| 575 | + if err != nil { |
| 576 | + t.Fatal(err) |
| 577 | + } |
| 578 | + items := wikiItems(result, "items") |
| 579 | + if len(items) != 4 || approvalByID(items, elsewhere.ID) != nil { |
| 580 | + t.Fatalf("owner sees %d items: %v", len(items), items) |
| 581 | + } |
| 582 | + counts := result["counts"].(map[string]any) |
| 583 | + if counts["open"] != 1.0 || counts["answered"] != 2.0 || counts["expired"] != 1.0 { |
| 584 | + t.Fatalf("counts %v", counts) |
| 585 | + } |
| 586 | + if result["oldest_open"] != float64(open.CreatedAt) { |
| 587 | + t.Fatalf("oldest open %v", result["oldest_open"]) |
| 588 | + } |
| 589 | + item := approvalByID(items, open.ID) |
| 590 | + if item["state"] != "open" || item["type"] != "approve" || item["subject"] != "Publish release notes 1.4" || item["asker"] != asker || item["expires"] != float64(now+3600) || item["answer"] != nil { |
| 591 | + t.Fatalf("open item %v", item) |
| 592 | + } |
| 593 | + if about := item["about"].(map[string]any); about["coordinate"] != "30617:"+owner+":notes" || !strings.HasSuffix(about["url"].(string), "/repo?owner="+owner+"&repo=notes&view=home") { |
| 594 | + t.Fatalf("about %v", about) |
| 595 | + } |
| 596 | + item = approvalByID(items, decided.ID) |
| 597 | + answer, _ := item["answer"].(map[string]any) |
| 598 | + if item["state"] != "answered" || item["room"] != tenant.meta.Name || answer == nil || answer["decision"] != "approved" || answer["id"] != approval.ID { |
| 599 | + t.Fatalf("decided item %v", item) |
| 600 | + } |
| 601 | + item = approvalByID(items, question.ID) |
| 602 | + answer, _ = item["answer"].(map[string]any) |
| 603 | + if item["state"] != "answered" || answer == nil || answer["decision"] != "replied" || answer["id"] != reply.ID || answer["content"] != "Leave it out." { |
| 604 | + t.Fatalf("question item %v", item) |
| 605 | + } |
| 606 | + if about := item["about"].(map[string]any); about["event"] != decided.ID || about["kind"] != "11" || !strings.HasSuffix(about["url"].(string), "/e/"+decided.ID) { |
| 607 | + t.Fatalf("question about %v", about) |
| 608 | + } |
| 609 | + if item = approvalByID(items, expired.ID); item["state"] != "expired" { |
| 610 | + t.Fatalf("expired item %v", item) |
| 611 | + } |
| 612 | + |
| 613 | + filtered, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"state": "open"}) |
| 614 | + if err != nil { |
| 615 | + t.Fatal(err) |
| 616 | + } |
| 617 | + if items := wikiItems(filtered, "items"); len(items) != 1 || items[0]["id"] != open.ID { |
| 618 | + t.Fatalf("open filter %v", items) |
| 619 | + } |
| 620 | + if _, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"state": "bogus"}); err == nil || !strings.HasPrefix(err.Error(), "invalid:") { |
| 621 | + t.Fatalf("bogus state %v", err) |
| 622 | + } |
| 623 | + paged, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"limit": 2}) |
| 624 | + if err != nil { |
| 625 | + t.Fatal(err) |
| 626 | + } |
| 627 | + if items := wikiItems(paged, "items"); len(items) != 2 || paged["next_cursor"] == "" { |
| 628 | + t.Fatalf("paged %v", paged) |
| 629 | + } |
| 630 | + rest, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"limit": 2, "cursor": paged["next_cursor"]}) |
| 631 | + if err != nil { |
| 632 | + t.Fatal(err) |
| 633 | + } |
| 634 | + if items := wikiItems(rest, "items"); len(items) != 2 || items[0]["id"] == wikiItems(paged, "items")[0]["id"] { |
| 635 | + t.Fatalf("second page %v", rest) |
| 636 | + } |
| 637 | + |
| 638 | + // The other key sees only its own request; guests see nothing. |
| 639 | + theirs, err := approvalCall(t, tenant, other, "browseapprovals", map[string]any{}) |
| 640 | + if err != nil { |
| 641 | + t.Fatal(err) |
| 642 | + } |
| 643 | + if items := wikiItems(theirs, "items"); len(items) != 1 || items[0]["id"] != elsewhere.ID { |
| 644 | + t.Fatalf("other sees %v", items) |
| 645 | + } |
| 646 | + if _, err := approvalCall(t, tenant, "", "browseapprovals", map[string]any{}); err == nil || !strings.HasPrefix(err.Error(), "auth-required:") { |
| 647 | + t.Fatalf("guest %v", err) |
| 648 | + } |
| 649 | + |
| 650 | + // One request with its history: the asked person and the asker may |
| 651 | + // read it, nobody else. |
| 652 | + detail, err := approvalCall(t, tenant, owner, "browseapproval", map[string]any{"id": decided.ID}) |
| 653 | + if err != nil { |
| 654 | + t.Fatal(err) |
| 655 | + } |
| 656 | + if got := detail["item"].(map[string]any); got["state"] != "answered" || got["answer"].(map[string]any)["decision"] != "approved" { |
| 657 | + t.Fatalf("detail %v", detail) |
| 658 | + } |
| 659 | + if answers := detail["answers"].([]any); len(answers) != 2 || answers[0].(map[string]any)["id"] != approval.ID { |
| 660 | + t.Fatalf("answers %v", answers) |
| 661 | + } |
| 662 | + if _, err := approvalCall(t, tenant, asker, "browseapproval", map[string]any{"id": decided.ID}); err != nil { |
| 663 | + t.Fatalf("asker detail %v", err) |
| 664 | + } |
| 665 | + if _, err := approvalCall(t, tenant, other, "browseapproval", map[string]any{"id": decided.ID}); err == nil || !strings.HasPrefix(err.Error(), "restricted:") { |
| 666 | + t.Fatalf("stranger detail %v", err) |
| 667 | + } |
| 668 | + if _, err := approvalCall(t, tenant, owner, "browseapproval", map[string]any{"id": reply.ID}); err == nil || !strings.HasPrefix(err.Error(), "not found:") { |
| 669 | + t.Fatalf("reply as request %v", err) |
| 670 | + } |
| 671 | +} |
| 672 | + |
| 673 | +func TestApprovalRequestsWakeDevicesWithActionsAndCountOnBadge(t *testing.T) { |
| 674 | + _, tenant := testTenant(t) |
| 675 | + ctx := context.Background() |
| 676 | + owner := tenant.Policy().Owner |
| 677 | + asker := wikiKey(t, approvalAskerSecret) |
| 678 | + now := time.Now().Unix() |
| 679 | + |
| 680 | + request := event.Event{Kind: kindComment, PubKey: asker, CreatedAt: now, Tags: [][]string{{"request", "decide"}, {"p", owner}, {"subject", "Publish release notes"}, {"expiration", strconv.FormatInt(now+3600, 10)}}, Content: "Long body"} |
| 681 | + notices := tenant.pushNotices(ctx, request) |
| 682 | + if len(notices) != 1 || notices[0].category != pushApprovals || notices[0].recipient != owner || notices[0].body != asker[:12]+" asks: Publish release notes" || !strings.HasSuffix(notices[0].url, "/approvals?id="+request.ID) { |
| 683 | + t.Fatalf("decision notices %+v", notices) |
| 684 | + } |
| 685 | + if actions := notices[0].actions; len(actions) != 3 || actions[0].Action != "approve" || actions[1].Action != "deny" || actions[2].Action != "reply" || actions[0].Title != "Approve" { |
| 686 | + t.Fatalf("decision actions %+v", notices[0].actions) |
| 687 | + } |
| 688 | + question := event.Event{Kind: kindChatMessage, PubKey: asker, CreatedAt: now, Tags: [][]string{{"request", "question"}, {"p", owner}, {"h", "build"}}, Content: "Mention the edge?"} |
| 689 | + notices = tenant.pushNotices(ctx, question) |
| 690 | + if len(notices) != 1 || notices[0].body != asker[:12]+" asks: Mention the edge?" || len(notices[0].actions) != 1 || notices[0].actions[0].Action != "reply" { |
| 691 | + t.Fatalf("question notices %+v", notices) |
| 692 | + } |
| 693 | + // A comment that asks nobody keeps its mention notice. |
| 694 | + plain := event.Event{Kind: kindComment, PubKey: asker, CreatedAt: now, Tags: [][]string{{"p", owner}}, Content: "Hello"} |
| 695 | + if notices := tenant.pushNotices(ctx, plain); len(notices) != 1 || notices[0].category != pushMentions { |
| 696 | + t.Fatalf("plain notices %+v", notices) |
| 697 | + } |
| 698 | + if !containsString(pushCategories, pushApprovals) { |
| 699 | + t.Fatal("approvals is not a device category") |
| 700 | + } |
| 701 | + |
| 702 | + // The actions travel through the payload to the device message. |
| 703 | + message := tenant.pushMessage(ctx, pushPayload{Recipient: owner, Kind: pushApprovals, Text: notices[0].body, URL: notices[0].url, Actions: notices[0].actions}) |
| 704 | + encoded, err := json.Marshal(message) |
| 705 | + if err != nil { |
| 706 | + t.Fatal(err) |
| 707 | + } |
| 708 | + if !strings.Contains(string(encoded), `"actions":[{"action":"reply","title":"Reply"}]`) || !strings.Contains(string(encoded), `"tag":"tiny-approvals"`) { |
| 709 | + t.Fatalf("message %s", encoded) |
| 710 | + } |
| 711 | + if encoded, _ := json.Marshal(tenant.pushMessage(ctx, pushPayload{Recipient: owner, Kind: pushMentions, Text: "hi"})); strings.Contains(string(encoded), "actions") { |
| 712 | + t.Fatalf("mention message carries actions: %s", encoded) |
| 713 | + } |
| 714 | + |
| 715 | + // The badge counts open requests once, whether they arrived before or |
| 716 | + // after the last inbox visit, and drops them once answered. |
| 717 | + if err := tenant.markInboxSeen(ctx, owner); err != nil { |
| 718 | + t.Fatal(err) |
| 719 | + } |
| 720 | + seen := tenant.inboxSeen(ctx, owner) |
| 721 | + older := wikiPublish(t, tenant, approvalAskerSecret, kindComment, seen-100, "Older request", []string{"request", "approve"}, []string{"p", owner}) |
| 722 | + if got := tenant.inboxUnread(ctx, owner); got != 1 { |
| 723 | + t.Fatalf("unread with an older open request = %d", got) |
| 724 | + } |
| 725 | + newer := wikiPublish(t, tenant, approvalAskerSecret, kindComment, seen+100, "Newer request", []string{"request", "approve"}, []string{"p", owner}) |
| 726 | + wikiPublish(t, tenant, approvalAskerSecret, kindChatMessage, seen+100, "Room request", []string{"request", "question"}, []string{"p", owner}, []string{"h", tenant.meta.Name}) |
| 727 | + if got := tenant.inboxUnread(ctx, owner); got != 3 { |
| 728 | + t.Fatalf("unread with three open requests = %d", got) |
| 729 | + } |
| 730 | + wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, seen+200, "+", []string{"e", older.ID}, []string{"p", asker}) |
| 731 | + wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, seen+200, "-", []string{"e", newer.ID}, []string{"p", asker}) |
| 732 | + // The newer request still counts as a conversation event since the |
| 733 | + // visit; the older one is answered and gone from the badge. |
| 734 | + if got := tenant.inboxUnread(ctx, owner); got != 2 { |
| 735 | + t.Fatalf("unread after answering = %d", got) |
| 736 | + } |
| 737 | +} |
| 738 | diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go |
| 739 | index fc2e26a..d67a87a 100644 |
| 740 | --- a/internal/daemon/client_browse.go |
| 741 | +++ b/internal/daemon/client_browse.go |
| 742 | @@ -34,7 +34,7 @@ type clientBrowseRequest struct { |
| 743 | } |
| 744 | |
| 745 | func clientBrowseMethod(method string) bool { |
| 746 | - return wikiBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent"}, method) |
| 747 | + return wikiBrowseMethod(method) || approvalBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent"}, method) |
| 748 | } |
| 749 | |
| 750 | func (t *Tenant) browseRead(ctx context.Context, actor string) error { |
| 751 | @@ -61,6 +61,9 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params |
| 752 | if wikiBrowseMethod(method) { |
| 753 | return t.executeWiki(ctx, actor, method, params) |
| 754 | } |
| 755 | + if approvalBrowseMethod(method) { |
| 756 | + return t.executeApprovals(ctx, actor, method, params) |
| 757 | + } |
| 758 | q := clientBrowseRequest{} |
| 759 | if len(params) > 0 { |
| 760 | if err := json.Unmarshal(params[0], &q); err != nil { |
| 761 | diff --git a/internal/daemon/webpush.go b/internal/daemon/webpush.go |
| 762 | index 93fe7a0..30d41be 100644 |
| 763 | --- a/internal/daemon/webpush.go |
| 764 | +++ b/internal/daemon/webpush.go |
| 765 | @@ -41,15 +41,25 @@ type pushPayload struct { |
| 766 | Subject string `json:"subject"` |
| 767 | Text string `json:"text"` |
| 768 | URL string `json:"url,omitempty"` |
| 769 | + // Actions are the answers the device offers on the notification. |
| 770 | + Actions []pushAction `json:"actions,omitempty"` |
| 771 | +} |
| 772 | + |
| 773 | +// pushAction is one button on a notification. The service worker opens the |
| 774 | +// notification's url with answer=<action> when it is pressed. |
| 775 | +type pushAction struct { |
| 776 | + Action string `json:"action"` |
| 777 | + Title string `json:"title"` |
| 778 | } |
| 779 | |
| 780 | // pushMessage is what the service worker receives after decryption. |
| 781 | type pushMessage struct { |
| 782 | - Title string `json:"title"` |
| 783 | - Body string `json:"body"` |
| 784 | - URL string `json:"url"` |
| 785 | - Tag string `json:"tag"` |
| 786 | - Badge int `json:"badge"` |
| 787 | + Title string `json:"title"` |
| 788 | + Body string `json:"body"` |
| 789 | + URL string `json:"url"` |
| 790 | + Tag string `json:"tag"` |
| 791 | + Badge int `json:"badge"` |
| 792 | + Actions []pushAction `json:"actions,omitempty"` |
| 793 | } |
| 794 | |
| 795 | // pushRegistration is the browser's subscription with its chosen categories. |
| 796 | @@ -374,5 +384,5 @@ func (t *Tenant) pushMessage(ctx context.Context, payload pushPayload) pushMessa |
| 797 | if url == "" { |
| 798 | url = strings.TrimRight(t.publicURL, "/") + "/inbox" |
| 799 | } |
| 800 | - return pushMessage{Title: title, Body: body, URL: url, Tag: "tiny-" + payload.Kind, Badge: t.inboxUnread(ctx, payload.Recipient)} |
| 801 | + return pushMessage{Title: title, Body: body, URL: url, Tag: "tiny-" + payload.Kind, Badge: t.inboxUnread(ctx, payload.Recipient), Actions: payload.Actions} |
| 802 | } |
| 803 | diff --git a/internal/daemon/webpush_triggers.go b/internal/daemon/webpush_triggers.go |
| 804 | index cf99037..db8dec2 100644 |
| 805 | --- a/internal/daemon/webpush_triggers.go |
| 806 | +++ b/internal/daemon/webpush_triggers.go |
| 807 | @@ -1,9 +1,10 @@ |
| 808 | package daemon |
| 809 | |
| 810 | // Device notification triggers. Each stored event that addresses a member |
| 811 | -// with registered devices becomes a short summary in one of four categories, |
| 812 | +// with registered devices becomes a short summary in one of five categories, |
| 813 | // which the person picks per device: private messages, replies to their own |
| 814 | -// posts and repositories, mentions, and relay notices. |
| 815 | +// posts and repositories, mentions, requests for a decision, and relay |
| 816 | +// notices. |
| 817 | |
| 818 | import ( |
| 819 | "context" |
| 820 | @@ -20,15 +21,18 @@ const ( |
| 821 | pushMessages = "messages" |
| 822 | pushReplies = "replies" |
| 823 | pushMentions = "mentions" |
| 824 | + pushApprovals = "approvals" |
| 825 | pushRelay = "relay" |
| 826 | pushCoalesce = 30 * time.Second |
| 827 | pushExcerptMax = 120 |
| 828 | ) |
| 829 | |
| 830 | -var pushCategories = []string{pushMessages, pushReplies, pushMentions, pushRelay} |
| 831 | +var pushCategories = []string{pushMessages, pushReplies, pushMentions, pushApprovals, pushRelay} |
| 832 | |
| 833 | type pushNotice struct { |
| 834 | recipient, category, body, url string |
| 835 | + // actions are the answers a device can give from the notification. |
| 836 | + actions []pushAction |
| 837 | } |
| 838 | |
| 839 | // notifyDevices runs after an event is stored. It never fails the publish: |
| 840 | @@ -75,13 +79,18 @@ func (t *Tenant) pushCoalesced(recipient, category string) bool { |
| 841 | } |
| 842 | |
| 843 | func (t *Tenant) enqueuePushNotice(ctx context.Context, notice pushNotice) error { |
| 844 | - return t.enqueuePushPayload(ctx, pushPayload{Recipient: notice.recipient, Kind: notice.category, Text: notice.body, URL: notice.url}) |
| 845 | + return t.enqueuePushPayload(ctx, pushPayload{Recipient: notice.recipient, Kind: notice.category, Text: notice.body, URL: notice.url, Actions: notice.actions}) |
| 846 | } |
| 847 | |
| 848 | // pushNotices decides who an event should wake and why. |
| 849 | func (t *Tenant) pushNotices(ctx context.Context, e event.Event) []pushNotice { |
| 850 | base := strings.TrimRight(t.publicURL, "/") |
| 851 | var notices []pushNotice |
| 852 | + // A request for a decision wakes the people asked in its own category |
| 853 | + // and nowhere else, so one event yields one notification per device. |
| 854 | + if kind, ok := approvalRequest(e); ok { |
| 855 | + return t.approvalNotices(e, kind) |
| 856 | + } |
| 857 | switch e.Kind { |
| 858 | case event.KIND_WRAP, 4: |
| 859 | for _, recipient := range pushRecipients(e) { |
| 860 | @@ -216,7 +225,9 @@ func (t *Tenant) markInboxSeen(ctx context.Context, pubkey string) error { |
| 861 | } |
| 862 | |
| 863 | // inboxUnread counts events addressed to the key since it last opened the |
| 864 | -// inbox. It feeds the app badge; the page clears it on the next visit. |
| 865 | +// inbox, plus every request that still waits for the key's decision. It |
| 866 | +// feeds the app badge; the page clears the conversation part on the next |
| 867 | +// visit, and an answer clears the request. |
| 868 | func (t *Tenant) inboxUnread(ctx context.Context, pubkey string) int { |
| 869 | since := t.inboxSeen(ctx, pubkey) |
| 870 | // Count conversation kinds only, so relay records addressed to the |
| 871 | @@ -229,6 +240,13 @@ func (t *Tenant) inboxUnread(ctx context.Context, pubkey string) int { |
| 872 | if err != nil { |
| 873 | return 0 |
| 874 | } |
| 875 | + // An open request counts once: those newer than the last visit are |
| 876 | + // already in the conversation count. |
| 877 | + for _, item := range t.openApprovals(ctx, pubkey) { |
| 878 | + if item.CreatedAt < since || !containsInt(filter.Kinds, item.Kind) { |
| 879 | + count++ |
| 880 | + } |
| 881 | + } |
| 882 | if count > 99 { |
| 883 | return 99 |
| 884 | } |