| 1 | diff --git a/docs/app.md b/docs/app.md |
| 2 | index d023056..a0e00af 100644 |
| 3 | --- a/docs/app.md |
| 4 | +++ b/docs/app.md |
| 5 | @@ -14,4 +14,4 @@ The app registers for `web+nostr:` links. A link to an `npub`, `nprofile`, `note |
| 6 | |
| 7 | Notifications are off until you turn them on, and each device is enabled separately. Open **Inbox** and choose **Enable on this device**. The browser asks for permission at that moment, and the device is registered with a signed request. Choose **Disable on this device** to stop. The owner can choose **Send a test** to check delivery. |
| 8 | |
| 9 | -A notification carries the relay's short notice: a report awaiting moderation, a finished job, a succession change or a digest, following the settings under **Manage > Identity**. The text is encrypted to the device, so the push service never reads it. Devices that stop accepting messages are removed. |
| 10 | +Each device chooses what wakes it: private messages, replies to your posts, issues and pull requests, mentions, and relay notices such as a report awaiting moderation or a finished job. A burst of activity produces one notification per category. The text is encrypted to the device, so the push service never reads it. The app icon shows how many conversation events arrived since you last opened Inbox, and opening Inbox clears it. Devices that stop accepting messages are removed. |
| 11 | diff --git a/internal/daemon/http.go b/internal/daemon/http.go |
| 12 | index a4b1f0c..d09873a 100644 |
| 13 | --- a/internal/daemon/http.go |
| 14 | +++ b/internal/daemon/http.go |
| 15 | @@ -64,7 +64,7 @@ func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 16 | t.sessionHTTP(w, r) |
| 17 | return |
| 18 | } |
| 19 | - if strings.HasPrefix(r.URL.Path, "/push/") { |
| 20 | + if strings.HasPrefix(r.URL.Path, "/push/") || r.URL.Path == "/inbox/seen" { |
| 21 | t.pushHTTP(w, r) |
| 22 | return |
| 23 | } |
| 24 | diff --git a/internal/daemon/services.go b/internal/daemon/services.go |
| 25 | index 9c37113..dd44e54 100644 |
| 26 | --- a/internal/daemon/services.go |
| 27 | +++ b/internal/daemon/services.go |
| 28 | @@ -653,5 +653,8 @@ func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin repli |
| 29 | opts.Intents = append(opts.Intents, storage.Intent{Kind: "view-publish", EventID: e.ID, Target: "articles", Payload: "{}"}) |
| 30 | } |
| 31 | _, err := t.store.Save(ctx, e, opts) |
| 32 | + if err == nil { |
| 33 | + t.notifyDevices(ctx, e) |
| 34 | + } |
| 35 | return err |
| 36 | } |
| 37 | diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go |
| 38 | index d0160a8..e3c58e2 100644 |
| 39 | --- a/internal/daemon/tenant.go |
| 40 | +++ b/internal/daemon/tenant.go |
| 41 | @@ -69,6 +69,7 @@ type Tenant struct { |
| 42 | pushMu sync.Mutex |
| 43 | pushVAPID *webpush.Keys |
| 44 | pushClient *http.Client |
| 45 | + pushRecent map[string]time.Time |
| 46 | } |
| 47 | |
| 48 | func newTenant(ctx context.Context, cfg tenantConfig) (*Tenant, error) { |
| 49 | @@ -365,6 +366,7 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s |
| 50 | if err != nil { |
| 51 | return "", err |
| 52 | } |
| 53 | + t.notifyDevices(ctx, e) |
| 54 | // Stage Git metadata before acknowledging the event. This closes the |
| 55 | // publish-ACK/receive-pack race: the signed pending refs and hook exist |
| 56 | // before a client can push objects for the state. |
| 57 | diff --git a/internal/daemon/webpush.go b/internal/daemon/webpush.go |
| 58 | index 9381479..e32c859 100644 |
| 59 | --- a/internal/daemon/webpush.go |
| 60 | +++ b/internal/daemon/webpush.go |
| 61 | @@ -15,6 +15,7 @@ import ( |
| 62 | "strings" |
| 63 | "time" |
| 64 | |
| 65 | + "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 66 | "github.com/FelineStateMachine/tinyrelay/internal/replication" |
| 67 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 68 | "github.com/FelineStateMachine/tinyrelay/internal/webpush" |
| 69 | @@ -30,14 +31,16 @@ const ( |
| 70 | pushSummaryMaxLength = 160 |
| 71 | ) |
| 72 | |
| 73 | -const pushSchema = `CREATE TABLE IF NOT EXISTS web_push(endpoint TEXT PRIMARY KEY, pubkey TEXT NOT NULL, subscription TEXT NOT NULL, created_at INTEGER NOT NULL, failures INTEGER NOT NULL DEFAULT 0); |
| 74 | +const pushSchema = `CREATE TABLE IF NOT EXISTS web_push(endpoint TEXT PRIMARY KEY, pubkey TEXT NOT NULL, subscription TEXT NOT NULL, created_at INTEGER NOT NULL, failures INTEGER NOT NULL DEFAULT 0, categories TEXT NOT NULL DEFAULT ''); |
| 75 | CREATE INDEX IF NOT EXISTS web_push_pubkey ON web_push(pubkey);` |
| 76 | |
| 77 | type pushPayload struct { |
| 78 | Recipient string `json:"recipient"` |
| 79 | - Kind string `json:"kind"` |
| 80 | - Subject string `json:"subject"` |
| 81 | - Text string `json:"text"` |
| 82 | + // Kind is a device category for event summaries or a relay notice kind. |
| 83 | + Kind string `json:"kind"` |
| 84 | + Subject string `json:"subject"` |
| 85 | + Text string `json:"text"` |
| 86 | + URL string `json:"url,omitempty"` |
| 87 | } |
| 88 | |
| 89 | // pushMessage is what the service worker receives after decryption. |
| 90 | @@ -46,11 +49,25 @@ type pushMessage struct { |
| 91 | Body string `json:"body"` |
| 92 | URL string `json:"url"` |
| 93 | Tag string `json:"tag"` |
| 94 | + Badge int `json:"badge"` |
| 95 | +} |
| 96 | + |
| 97 | +// pushRegistration is the browser's subscription with its chosen categories. |
| 98 | +type pushRegistration struct { |
| 99 | + Subscription webpush.Subscription `json:"subscription"` |
| 100 | + Categories []string `json:"categories"` |
| 101 | } |
| 102 | |
| 103 | func (t *Tenant) initPush(ctx context.Context) error { |
| 104 | - _, err := t.store.DB().ExecContext(ctx, pushSchema) |
| 105 | - return err |
| 106 | + if _, err := t.store.DB().ExecContext(ctx, pushSchema); err != nil { |
| 107 | + return err |
| 108 | + } |
| 109 | + // Devices registered before categories existed receive everything. |
| 110 | + _, err := t.store.DB().ExecContext(ctx, "ALTER TABLE web_push ADD COLUMN categories TEXT NOT NULL DEFAULT ''") |
| 111 | + if err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| 112 | + return err |
| 113 | + } |
| 114 | + return nil |
| 115 | } |
| 116 | |
| 117 | // pushVAPIDKeys returns the tenant's VAPID pair, creating it on first use. |
| 118 | @@ -101,6 +118,24 @@ func (t *Tenant) pushHTTP(w http.ResponseWriter, r *http.Request) { |
| 119 | return |
| 120 | } |
| 121 | writeJSON(w, 200, map[string]string{"key": keys.PublicKey()}) |
| 122 | + case r.URL.Path == "/inbox/seen" && r.Method == http.MethodPost: |
| 123 | + // Opening the inbox clears the badge. This is the person's own |
| 124 | + // read marker, so the browser session may set it from this origin. |
| 125 | + actor, err := t.resolveUIActor(r) |
| 126 | + if err != nil || actor == "" { |
| 127 | + if s, sessionErr := t.cookieReadSession(r, relay.Session{RelayURL: t.RelayURL()}); sessionErr == nil { |
| 128 | + actor = first(s.PubKeys) |
| 129 | + } |
| 130 | + } |
| 131 | + if actor == "" { |
| 132 | + writeJSON(w, 401, map[string]string{"error": "auth-required: sign this request"}) |
| 133 | + return |
| 134 | + } |
| 135 | + if err := t.markInboxSeen(r.Context(), actor); err != nil { |
| 136 | + writeJSON(w, 500, map[string]string{"error": "could not record inbox visit"}) |
| 137 | + return |
| 138 | + } |
| 139 | + writeJSON(w, 200, map[string]any{"ok": true}) |
| 140 | case (r.URL.Path == "/push/subscribe" || r.URL.Path == "/push/unsubscribe") && r.Method == http.MethodPost: |
| 141 | // Registration is a signed mutation like every other change made |
| 142 | // from the browser; the session cookie alone is not enough. |
| 143 | @@ -114,11 +149,12 @@ func (t *Tenant) pushHTTP(w http.ResponseWriter, r *http.Request) { |
| 144 | writeJSON(w, 400, map[string]string{"error": err.Error()}) |
| 145 | return |
| 146 | } |
| 147 | - var subscription webpush.Subscription |
| 148 | - if err := json.Unmarshal(body, &subscription); err != nil || subscription.Validate() != nil { |
| 149 | + registration, err := parsePushRegistration(body) |
| 150 | + if err != nil { |
| 151 | writeJSON(w, 400, map[string]string{"error": "invalid: push subscription"}) |
| 152 | return |
| 153 | } |
| 154 | + subscription := registration.Subscription |
| 155 | if r.URL.Path == "/push/unsubscribe" { |
| 156 | if _, err := t.store.DB().ExecContext(r.Context(), "DELETE FROM web_push WHERE endpoint=? AND pubkey=?", subscription.Endpoint, actor); err != nil { |
| 157 | writeJSON(w, 500, map[string]string{"error": "unsubscribe failed"}) |
| 158 | @@ -131,7 +167,7 @@ func (t *Tenant) pushHTTP(w http.ResponseWriter, r *http.Request) { |
| 159 | writeJSON(w, 403, map[string]string{"error": "restricted: notifications are limited to relay members"}) |
| 160 | return |
| 161 | } |
| 162 | - if err := t.savePushSubscription(r.Context(), actor, subscription); err != nil { |
| 163 | + if err := t.savePushSubscription(r.Context(), actor, registration); err != nil { |
| 164 | writeJSON(w, statusForPush(err), map[string]string{"error": err.Error()}) |
| 165 | return |
| 166 | } |
| 167 | @@ -148,11 +184,43 @@ func statusForPush(err error) int { |
| 168 | return 500 |
| 169 | } |
| 170 | |
| 171 | -func (t *Tenant) savePushSubscription(ctx context.Context, actor string, subscription webpush.Subscription) error { |
| 172 | +// parsePushRegistration accepts the browser's registration, with or without |
| 173 | +// categories, and validates the subscription inside it. |
| 174 | +func parsePushRegistration(body []byte) (pushRegistration, error) { |
| 175 | + var registration pushRegistration |
| 176 | + if err := json.Unmarshal(body, ®istration); err != nil || registration.Subscription.Endpoint == "" { |
| 177 | + registration = pushRegistration{} |
| 178 | + if err := json.Unmarshal(body, ®istration.Subscription); err != nil { |
| 179 | + return registration, err |
| 180 | + } |
| 181 | + } |
| 182 | + if err := registration.Subscription.Validate(); err != nil { |
| 183 | + return registration, err |
| 184 | + } |
| 185 | + var kept []string |
| 186 | + for _, category := range registration.Categories { |
| 187 | + if containsString(pushCategories, category) && !containsString(kept, category) { |
| 188 | + kept = append(kept, category) |
| 189 | + } |
| 190 | + } |
| 191 | + registration.Categories = kept |
| 192 | + return registration, nil |
| 193 | +} |
| 194 | + |
| 195 | +func (t *Tenant) savePushSubscription(ctx context.Context, actor string, registration pushRegistration) error { |
| 196 | + subscription := registration.Subscription |
| 197 | raw, err := json.Marshal(subscription) |
| 198 | if err != nil { |
| 199 | return err |
| 200 | } |
| 201 | + categories := "" |
| 202 | + if len(registration.Categories) > 0 { |
| 203 | + encoded, err := json.Marshal(registration.Categories) |
| 204 | + if err != nil { |
| 205 | + return err |
| 206 | + } |
| 207 | + categories = string(encoded) |
| 208 | + } |
| 209 | return t.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 210 | var count int |
| 211 | if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM web_push WHERE pubkey=? AND endpoint<>?", actor, subscription.Endpoint).Scan(&count); err != nil { |
| 212 | @@ -161,7 +229,7 @@ func (t *Tenant) savePushSubscription(ctx context.Context, actor string, subscri |
| 213 | if count >= pushMaxPerPubkey { |
| 214 | return fmt.Errorf("invalid: at most %d devices per key", pushMaxPerPubkey) |
| 215 | } |
| 216 | - _, err := tx.ExecContext(ctx, "INSERT INTO web_push(endpoint,pubkey,subscription,created_at,failures) VALUES(?,?,?,?,0) ON CONFLICT(endpoint) DO UPDATE SET pubkey=excluded.pubkey, subscription=excluded.subscription, failures=0", subscription.Endpoint, actor, string(raw), time.Now().Unix()) |
| 217 | + _, err := tx.ExecContext(ctx, "INSERT INTO web_push(endpoint,pubkey,subscription,created_at,failures,categories) VALUES(?,?,?,?,0,?) ON CONFLICT(endpoint) DO UPDATE SET pubkey=excluded.pubkey, subscription=excluded.subscription, failures=0, categories=excluded.categories", subscription.Endpoint, actor, string(raw), time.Now().Unix(), categories) |
| 218 | return err |
| 219 | }) |
| 220 | } |
| 221 | @@ -170,19 +238,25 @@ func (t *Tenant) savePushSubscription(ctx context.Context, actor string, subscri |
| 222 | // suitable for records.Config.PushNotification and does nothing for |
| 223 | // recipients without registered devices. |
| 224 | func (t *Tenant) enqueuePush(ctx context.Context, recipient, kind, subject, text string) error { |
| 225 | + return t.enqueuePushPayload(ctx, pushPayload{Recipient: recipient, Kind: kind, Subject: subject, Text: text}) |
| 226 | +} |
| 227 | + |
| 228 | +func (t *Tenant) enqueuePushPayload(ctx context.Context, payload pushPayload) error { |
| 229 | var count int |
| 230 | - if err := t.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM web_push WHERE pubkey=?", recipient).Scan(&count); err != nil || count == 0 { |
| 231 | + if err := t.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM web_push WHERE pubkey=?", payload.Recipient).Scan(&count); err != nil || count == 0 { |
| 232 | return err |
| 233 | } |
| 234 | - if len(text) > pushSummaryMaxLength { |
| 235 | - text = text[:pushSummaryMaxLength] |
| 236 | + if len(payload.Text) > pushSummaryMaxLength { |
| 237 | + payload.Text = payload.Text[:pushSummaryMaxLength] |
| 238 | } |
| 239 | - payload, err := json.Marshal(pushPayload{Recipient: recipient, Kind: kind, Subject: subject, Text: text}) |
| 240 | + recipient := payload.Recipient |
| 241 | + encoded, err := json.Marshal(payload) |
| 242 | if err != nil { |
| 243 | return err |
| 244 | } |
| 245 | + payloadJSON := string(encoded) |
| 246 | return t.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 247 | - return storage.AddIntents(ctx, tx, []storage.Intent{{Kind: notificationPush, EventID: uuid.NewString(), Target: recipient, Payload: string(payload)}}, time.Now().Unix()) |
| 248 | + return storage.AddIntents(ctx, tx, []storage.Intent{{Kind: notificationPush, EventID: uuid.NewString(), Target: recipient, Payload: payloadJSON}}, time.Now().Unix()) |
| 249 | }) |
| 250 | } |
| 251 | |
| 252 | @@ -191,7 +265,7 @@ func (t *Tenant) handleNotificationPush(ctx context.Context, intent work.Intent) |
| 253 | if err := json.Unmarshal([]byte(intent.Payload), &payload); err != nil || payload.Recipient != intent.Target { |
| 254 | return errors.New("notification-push: invalid payload") |
| 255 | } |
| 256 | - rows, err := t.store.DB().QueryContext(ctx, "SELECT endpoint, subscription, failures FROM web_push WHERE pubkey=?", payload.Recipient) |
| 257 | + rows, err := t.store.DB().QueryContext(ctx, "SELECT endpoint, subscription, failures, categories FROM web_push WHERE pubkey=?", payload.Recipient) |
| 258 | if err != nil { |
| 259 | return err |
| 260 | } |
| 261 | @@ -200,14 +274,22 @@ func (t *Tenant) handleNotificationPush(ctx context.Context, intent work.Intent) |
| 262 | subscription webpush.Subscription |
| 263 | failures int |
| 264 | } |
| 265 | + category := payload.Kind |
| 266 | + if !containsString(pushCategories, category) { |
| 267 | + category = pushRelay |
| 268 | + } |
| 269 | var devices []device |
| 270 | for rows.Next() { |
| 271 | var item device |
| 272 | - var raw string |
| 273 | - if err := rows.Scan(&item.endpoint, &raw, &item.failures); err != nil { |
| 274 | + var raw, categories string |
| 275 | + if err := rows.Scan(&item.endpoint, &raw, &item.failures, &categories); err != nil { |
| 276 | rows.Close() |
| 277 | return err |
| 278 | } |
| 279 | + // A device that chose categories only wakes for those. |
| 280 | + if allowed := decodeCategories(categories); allowed != nil && !allowed[category] { |
| 281 | + continue |
| 282 | + } |
| 283 | if json.Unmarshal([]byte(raw), &item.subscription) == nil { |
| 284 | devices = append(devices, item) |
| 285 | } |
| 286 | @@ -220,7 +302,7 @@ func (t *Tenant) handleNotificationPush(ctx context.Context, intent work.Intent) |
| 287 | if err != nil { |
| 288 | return err |
| 289 | } |
| 290 | - message, err := json.Marshal(t.pushMessage(payload)) |
| 291 | + message, err := json.Marshal(t.pushMessage(ctx, payload)) |
| 292 | if err != nil { |
| 293 | return err |
| 294 | } |
| 295 | @@ -245,7 +327,7 @@ func (t *Tenant) handleNotificationPush(ctx context.Context, intent work.Intent) |
| 296 | } |
| 297 | |
| 298 | // pushMessage turns a notification summary into what the device shows. |
| 299 | -func (t *Tenant) pushMessage(payload pushPayload) pushMessage { |
| 300 | +func (t *Tenant) pushMessage(ctx context.Context, payload pushPayload) pushMessage { |
| 301 | title := t.Policy().Name |
| 302 | if title == "" { |
| 303 | title = t.meta.Name |
| 304 | @@ -264,5 +346,9 @@ func (t *Tenant) pushMessage(payload pushPayload) pushMessage { |
| 305 | if payload.Subject != "" && payload.Subject != "relay" && !strings.EqualFold(payload.Subject, payload.Kind) { |
| 306 | title = title + ": " + payload.Subject |
| 307 | } |
| 308 | - return pushMessage{Title: title, Body: body, URL: strings.TrimRight(t.publicURL, "/") + "/inbox", Tag: "tiny-" + payload.Kind} |
| 309 | + url := payload.URL |
| 310 | + if url == "" { |
| 311 | + url = strings.TrimRight(t.publicURL, "/") + "/inbox" |
| 312 | + } |
| 313 | + return pushMessage{Title: title, Body: body, URL: url, Tag: "tiny-" + payload.Kind, Badge: t.inboxUnread(ctx, payload.Recipient)} |
| 314 | } |
| 315 | diff --git a/internal/daemon/webpush_test.go b/internal/daemon/webpush_test.go |
| 316 | index af9b52f..888fd71 100644 |
| 317 | --- a/internal/daemon/webpush_test.go |
| 318 | +++ b/internal/daemon/webpush_test.go |
| 319 | @@ -11,6 +11,8 @@ import ( |
| 320 | "strings" |
| 321 | "testing" |
| 322 | |
| 323 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 324 | + "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 325 | "github.com/FelineStateMachine/tinyrelay/internal/webpush" |
| 326 | "github.com/FelineStateMachine/tinyrelay/internal/work" |
| 327 | ) |
| 328 | @@ -111,3 +113,89 @@ func countIntents(t *testing.T, tenant *Tenant, kind string) int { |
| 329 | } |
| 330 | return count |
| 331 | } |
| 332 | + |
| 333 | +func TestDeviceNotificationsFollowCategoriesAndBadge(t *testing.T) { |
| 334 | + a, tenant := testTenant(t) |
| 335 | + ctx := context.Background() |
| 336 | + owner := tenant.Policy().Owner |
| 337 | + other := strings.Repeat("b", 64) |
| 338 | + // Register one device that only wants replies and relay notices. |
| 339 | + subscription := testSubscription(t, "https://push.example/send/device-2") |
| 340 | + body, _ := json.Marshal(map[string]any{"subscription": subscription, "categories": []string{"replies", "relay", "bogus"}}) |
| 341 | + req := httptest.NewRequest("POST", "http://relay.test/push/subscribe", strings.NewReader(string(body))) |
| 342 | + signRequest(t, req, string(body)) |
| 343 | + res := httptest.NewRecorder() |
| 344 | + a.ServeHTTP(res, req) |
| 345 | + if res.Code != 200 { |
| 346 | + t.Fatalf("subscribe %d %s", res.Code, res.Body.String()) |
| 347 | + } |
| 348 | + var categories string |
| 349 | + if err := tenant.store.DB().QueryRowContext(ctx, "SELECT categories FROM web_push").Scan(&categories); err != nil || categories != `["replies","relay"]` { |
| 350 | + t.Fatalf("categories %q %v", categories, err) |
| 351 | + } |
| 352 | + |
| 353 | + mention := event.Event{Kind: 1, PubKey: other, CreatedAt: 1, Tags: [][]string{{"p", owner}}, Content: "hello there\n friend"} |
| 354 | + notices := tenant.pushNotices(ctx, mention) |
| 355 | + if len(notices) != 1 || notices[0].category != pushMentions || notices[0].recipient != owner || notices[0].body != "Mentioned you: hello there friend" { |
| 356 | + t.Fatalf("mention notices %+v", notices) |
| 357 | + } |
| 358 | + wrap := event.Event{Kind: 1059, PubKey: other, CreatedAt: 1, Tags: [][]string{{"p", owner}, {"p", other}}} |
| 359 | + if notices := tenant.pushNotices(ctx, wrap); len(notices) != 1 || notices[0].category != pushMessages { |
| 360 | + t.Fatalf("message notices %+v", notices) |
| 361 | + } |
| 362 | + issue := event.Event{Kind: 1621, PubKey: other, CreatedAt: 1, Tags: [][]string{{"a", "30617:" + owner + ":notes"}, {"subject", "Broken build"}}} |
| 363 | + if notices := tenant.pushNotices(ctx, issue); len(notices) != 1 || notices[0].category != pushReplies || notices[0].body != "New issue: Broken build" || !strings.Contains(notices[0].url, "view=issue") { |
| 364 | + t.Fatalf("issue notices %+v", notices) |
| 365 | + } |
| 366 | + |
| 367 | + // Coalescing: the second mention within the window is dropped, and a |
| 368 | + // device limited to replies is skipped by the handler for mentions. |
| 369 | + tenant.notifyDevices(ctx, mention) |
| 370 | + tenant.notifyDevices(ctx, mention) |
| 371 | + if got := countIntents(t, tenant, notificationPush); got != 1 { |
| 372 | + t.Fatalf("coalesced intents = %d", got) |
| 373 | + } |
| 374 | + var payload string |
| 375 | + if err := tenant.store.DB().QueryRowContext(ctx, "SELECT payload FROM work_intents WHERE kind=?", notificationPush).Scan(&payload); err != nil { |
| 376 | + t.Fatal(err) |
| 377 | + } |
| 378 | + tenant.pushClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { |
| 379 | + t.Fatal("mention reached a replies-only device") |
| 380 | + return nil, nil |
| 381 | + })} |
| 382 | + if err := tenant.handleNotificationPush(ctx, work.Intent{Kind: notificationPush, Target: owner, Payload: payload}); err != nil { |
| 383 | + t.Fatal(err) |
| 384 | + } |
| 385 | + |
| 386 | + // The badge counts inbox events since the last visit and the visit |
| 387 | + // endpoint accepts the browser session from this origin. |
| 388 | + if _, err := tenant.store.Save(ctx, mention, storageSave(tenant)); err != nil { |
| 389 | + t.Fatal(err) |
| 390 | + } |
| 391 | + if got := tenant.inboxUnread(ctx, owner); got != 1 { |
| 392 | + t.Fatalf("unread = %d", got) |
| 393 | + } |
| 394 | + login := httptest.NewRequest("POST", "http://relay.test/session", strings.NewReader("")) |
| 395 | + signRequest(t, login, "") |
| 396 | + loginRes := httptest.NewRecorder() |
| 397 | + a.ServeHTTP(loginRes, login) |
| 398 | + seen := httptest.NewRequest("POST", "http://relay.test/inbox/seen", nil) |
| 399 | + seen.Header.Set("Origin", "http://relay.test") |
| 400 | + seen.AddCookie(loginRes.Result().Cookies()[0]) |
| 401 | + seenRes := httptest.NewRecorder() |
| 402 | + a.ServeHTTP(seenRes, seen) |
| 403 | + if seenRes.Code != 200 { |
| 404 | + t.Fatalf("inbox seen %d %s", seenRes.Code, seenRes.Body.String()) |
| 405 | + } |
| 406 | + if got := tenant.inboxUnread(ctx, owner); got != 0 { |
| 407 | + t.Fatalf("unread after visit = %d", got) |
| 408 | + } |
| 409 | +} |
| 410 | + |
| 411 | +type roundTripFunc func(*http.Request) (*http.Response, error) |
| 412 | + |
| 413 | +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
| 414 | + |
| 415 | +func storageSave(tenant *Tenant) storage.SaveOptions { |
| 416 | + return storage.SaveOptions{Now: 2, SearchMode: tenant.Policy().Features.Search} |
| 417 | +} |
| 418 | diff --git a/internal/daemon/webpush_triggers.go b/internal/daemon/webpush_triggers.go |
| 419 | new file mode 100644 |
| 420 | index 0000000..1c77cb7 |
| 421 | --- /dev/null |
| 422 | +++ b/internal/daemon/webpush_triggers.go |
| 423 | @@ -0,0 +1,229 @@ |
| 424 | +package daemon |
| 425 | + |
| 426 | +// Device notification triggers. Each stored event that addresses a member |
| 427 | +// with registered devices becomes a short summary in one of four categories, |
| 428 | +// which the person picks per device: private messages, replies to their own |
| 429 | +// posts and repositories, mentions, and relay notices. |
| 430 | + |
| 431 | +import ( |
| 432 | + "context" |
| 433 | + "encoding/json" |
| 434 | + "strconv" |
| 435 | + "strings" |
| 436 | + "time" |
| 437 | + |
| 438 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 439 | + "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 440 | +) |
| 441 | + |
| 442 | +const ( |
| 443 | + pushMessages = "messages" |
| 444 | + pushReplies = "replies" |
| 445 | + pushMentions = "mentions" |
| 446 | + pushRelay = "relay" |
| 447 | + pushCoalesce = 30 * time.Second |
| 448 | + pushExcerptMax = 120 |
| 449 | +) |
| 450 | + |
| 451 | +var pushCategories = []string{pushMessages, pushReplies, pushMentions, pushRelay} |
| 452 | + |
| 453 | +type pushNotice struct { |
| 454 | + recipient, category, body, url string |
| 455 | +} |
| 456 | + |
| 457 | +// notifyDevices runs after an event is stored. It never fails the publish: |
| 458 | +// a device summary is best effort on top of the durable event. |
| 459 | +func (t *Tenant) notifyDevices(ctx context.Context, e event.Event) { |
| 460 | + if !t.pushDevicesExist(ctx) { |
| 461 | + return |
| 462 | + } |
| 463 | + for _, notice := range t.pushNotices(ctx, e) { |
| 464 | + if !t.pushCoalesced(notice.recipient, notice.category) { |
| 465 | + continue |
| 466 | + } |
| 467 | + if err := t.enqueuePushNotice(ctx, notice); err != nil { |
| 468 | + t.app.telemetry.Logger().Debug("device notification not queued", "error", err) |
| 469 | + } |
| 470 | + } |
| 471 | +} |
| 472 | + |
| 473 | +func (t *Tenant) pushDevicesExist(ctx context.Context) bool { |
| 474 | + var count int |
| 475 | + return t.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM web_push").Scan(&count) == nil && count > 0 |
| 476 | +} |
| 477 | + |
| 478 | +// pushCoalesced allows one summary per recipient and category within a short |
| 479 | +// window, so a burst of replies produces a single notification. |
| 480 | +func (t *Tenant) pushCoalesced(recipient, category string) bool { |
| 481 | + t.pushMu.Lock() |
| 482 | + defer t.pushMu.Unlock() |
| 483 | + if t.pushRecent == nil { |
| 484 | + t.pushRecent = make(map[string]time.Time) |
| 485 | + } |
| 486 | + key := recipient + "\x00" + category |
| 487 | + now := time.Now() |
| 488 | + if last, ok := t.pushRecent[key]; ok && now.Sub(last) < pushCoalesce { |
| 489 | + return false |
| 490 | + } |
| 491 | + for k, at := range t.pushRecent { |
| 492 | + if now.Sub(at) > pushCoalesce { |
| 493 | + delete(t.pushRecent, k) |
| 494 | + } |
| 495 | + } |
| 496 | + t.pushRecent[key] = now |
| 497 | + return true |
| 498 | +} |
| 499 | + |
| 500 | +func (t *Tenant) enqueuePushNotice(ctx context.Context, notice pushNotice) error { |
| 501 | + return t.enqueuePushPayload(ctx, pushPayload{Recipient: notice.recipient, Kind: notice.category, Text: notice.body, URL: notice.url}) |
| 502 | +} |
| 503 | + |
| 504 | +// pushNotices decides who an event should wake and why. |
| 505 | +func (t *Tenant) pushNotices(ctx context.Context, e event.Event) []pushNotice { |
| 506 | + base := strings.TrimRight(t.publicURL, "/") |
| 507 | + var notices []pushNotice |
| 508 | + switch e.Kind { |
| 509 | + case event.KIND_WRAP, 4: |
| 510 | + for _, recipient := range pushRecipients(e) { |
| 511 | + notices = append(notices, pushNotice{recipient: recipient, category: pushMessages, body: "New private message.", url: base + "/inbox"}) |
| 512 | + } |
| 513 | + case event.KIND_GIT_ISSUE, event.KIND_GIT_PR: |
| 514 | + owner, identifier, ok := repoCoordinate(event.Tag(e, "a")) |
| 515 | + if !ok || owner == e.PubKey { |
| 516 | + return nil |
| 517 | + } |
| 518 | + label := "New issue" |
| 519 | + view := "issue" |
| 520 | + if e.Kind == event.KIND_GIT_PR { |
| 521 | + label, view = "New pull request", "pr" |
| 522 | + } |
| 523 | + body := label |
| 524 | + if subject := strings.TrimSpace(event.Tag(e, "subject")); subject != "" { |
| 525 | + body += ": " + excerpt(subject) |
| 526 | + } |
| 527 | + notices = append(notices, pushNotice{recipient: owner, category: pushReplies, body: body, url: base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=" + view + "&id=" + e.ID}) |
| 528 | + case 1, 1111, 30023: |
| 529 | + authored := t.referencedAuthors(ctx, e) |
| 530 | + for _, recipient := range pushRecipients(e) { |
| 531 | + category, body := pushMentions, "Mentioned you: " |
| 532 | + if authored[recipient] { |
| 533 | + category, body = pushReplies, "Replied to you: " |
| 534 | + } |
| 535 | + notices = append(notices, pushNotice{recipient: recipient, category: category, body: body + excerpt(e.Content), url: base + "/e/" + e.ID}) |
| 536 | + } |
| 537 | + } |
| 538 | + return notices |
| 539 | +} |
| 540 | + |
| 541 | +// pushRecipients lists the addressed keys other than the author, bounded so |
| 542 | +// a broadcast to a large list cannot fan out to every device. |
| 543 | +func pushRecipients(e event.Event) []string { |
| 544 | + seen := map[string]struct{}{e.PubKey: {}} |
| 545 | + var recipients []string |
| 546 | + for _, tag := range e.Tags { |
| 547 | + if len(tag) < 2 || (tag[0] != "p" && tag[0] != "P") || len(tag[1]) != 64 { |
| 548 | + continue |
| 549 | + } |
| 550 | + if _, ok := seen[tag[1]]; ok { |
| 551 | + continue |
| 552 | + } |
| 553 | + seen[tag[1]] = struct{}{} |
| 554 | + recipients = append(recipients, tag[1]) |
| 555 | + if len(recipients) == 16 { |
| 556 | + break |
| 557 | + } |
| 558 | + } |
| 559 | + return recipients |
| 560 | +} |
| 561 | + |
| 562 | +// referencedAuthors resolves the authors of events this one replies to, so a |
| 563 | +// reply to your note counts as a reply rather than a mention. |
| 564 | +func (t *Tenant) referencedAuthors(ctx context.Context, e event.Event) map[string]bool { |
| 565 | + authors := map[string]bool{} |
| 566 | + var ids []string |
| 567 | + for _, name := range []string{"e", "E"} { |
| 568 | + for _, id := range event.TagValues(e, name) { |
| 569 | + if len(id) == 64 && len(ids) < 8 { |
| 570 | + ids = append(ids, id) |
| 571 | + } |
| 572 | + } |
| 573 | + } |
| 574 | + if len(ids) == 0 { |
| 575 | + return authors |
| 576 | + } |
| 577 | + rows, err := t.store.Query(ctx, event.Filter{IDs: ids, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: len(ids)}) |
| 578 | + if err != nil { |
| 579 | + return authors |
| 580 | + } |
| 581 | + for _, row := range rows.Events { |
| 582 | + authors[row.PubKey] = true |
| 583 | + } |
| 584 | + return authors |
| 585 | +} |
| 586 | + |
| 587 | +func repoCoordinate(value string) (owner, identifier string, ok bool) { |
| 588 | + parts := strings.SplitN(value, ":", 3) |
| 589 | + if len(parts) != 3 || parts[0] != strconv.Itoa(30617) || len(parts[1]) != 64 || parts[2] == "" { |
| 590 | + return "", "", false |
| 591 | + } |
| 592 | + return parts[1], parts[2], true |
| 593 | +} |
| 594 | + |
| 595 | +func excerpt(text string) string { |
| 596 | + text = strings.Join(strings.Fields(text), " ") |
| 597 | + if len(text) > pushExcerptMax { |
| 598 | + cut := pushExcerptMax |
| 599 | + for cut > 0 && cut < len(text) && text[cut]&0xC0 == 0x80 { |
| 600 | + cut-- |
| 601 | + } |
| 602 | + text = text[:cut] + "…" |
| 603 | + } |
| 604 | + return text |
| 605 | +} |
| 606 | + |
| 607 | +// inboxSeen records when the person last opened their inbox, which bounds |
| 608 | +// the unread count carried on the app badge. |
| 609 | +func (t *Tenant) inboxSeen(ctx context.Context, pubkey string) int64 { |
| 610 | + var at int64 |
| 611 | + _ = t.store.GetSetting(ctx, "inbox.seen."+pubkey, &at) |
| 612 | + return at |
| 613 | +} |
| 614 | + |
| 615 | +func (t *Tenant) markInboxSeen(ctx context.Context, pubkey string) error { |
| 616 | + return t.store.PutSetting(ctx, "inbox.seen."+pubkey, time.Now().Unix()) |
| 617 | +} |
| 618 | + |
| 619 | +// inboxUnread counts events addressed to the key since it last opened the |
| 620 | +// inbox. It feeds the app badge; the page clears it on the next visit. |
| 621 | +func (t *Tenant) inboxUnread(ctx context.Context, pubkey string) int { |
| 622 | + since := t.inboxSeen(ctx, pubkey) |
| 623 | + // Count conversation kinds only, so relay records addressed to the |
| 624 | + // owner do not inflate the badge. |
| 625 | + filter := event.Filter{Kinds: []int{1, 4, 1111, event.KIND_WRAP, event.KIND_GIT_ISSUE, event.KIND_GIT_PR, 30023}, Tags: map[string][]string{"p": {pubkey}}} |
| 626 | + if since > 0 { |
| 627 | + filter.Since = &since |
| 628 | + } |
| 629 | + count, err := t.store.Count(ctx, []event.Filter{filter}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: []string{pubkey}}}) |
| 630 | + if err != nil { |
| 631 | + return 0 |
| 632 | + } |
| 633 | + if count > 99 { |
| 634 | + return 99 |
| 635 | + } |
| 636 | + return int(count) |
| 637 | +} |
| 638 | + |
| 639 | +func decodeCategories(raw string) map[string]bool { |
| 640 | + if raw == "" { |
| 641 | + return nil |
| 642 | + } |
| 643 | + var list []string |
| 644 | + if json.Unmarshal([]byte(raw), &list) != nil { |
| 645 | + return nil |
| 646 | + } |
| 647 | + allowed := map[string]bool{} |
| 648 | + for _, item := range list { |
| 649 | + allowed[item] = true |
| 650 | + } |
| 651 | + return allowed |
| 652 | +} |
| 653 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 654 | index 609dab7..933661f 100644 |
| 655 | --- a/internal/webui/components.js |
| 656 | +++ b/internal/webui/components.js |
| 657 | @@ -432,13 +432,18 @@ |
| 658 | // PushToggle opts this browser into relay notifications. Nothing happens |
| 659 | // until the button is pressed: permission is requested then, and the |
| 660 | // device subscription is registered with a signed request. |
| 661 | + const pushCategories = [["messages", "private messages"], ["replies", "replies to you"], ["mentions", "mentions"], ["relay", "relay notices"]]; |
| 662 | class PushToggle extends HTMLElement { |
| 663 | connectedCallback() { |
| 664 | if (this.bound) return; |
| 665 | this.bound = true; |
| 666 | - this.innerHTML = '<p><button type="button">Enable on this device</button></p><output role="status"></output>'; |
| 667 | + this.innerHTML = '<p><button type="button">Enable on this device</button></p><form hidden>' + pushCategories.map(([name, label]) => '<label><input type="checkbox" name="' + name + '" checked> ' + label + '</label>').join("") + '</form><output role="status"></output>'; |
| 668 | this.button = this.querySelector("button"); |
| 669 | + this.form = this.querySelector("form"); |
| 670 | this.output = this.querySelector("output"); |
| 671 | + const saved = (localStorage.getItem("tiny.push.categories") || "").split(",").filter(Boolean); |
| 672 | + if (saved.length) this.form.querySelectorAll("input").forEach(input => { input.checked = saved.includes(input.name); }); |
| 673 | + this.form.addEventListener("change", () => this.register().catch(err => this.say("error: " + err.message, true))); |
| 674 | if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) { |
| 675 | this.button.disabled = true; |
| 676 | this.say("This browser does not support notifications."); |
| 677 | @@ -453,8 +458,21 @@ |
| 678 | const current = await this.subscription(); |
| 679 | this.enabled = Boolean(current) && localStorage.getItem("tiny.push") === current.endpoint; |
| 680 | this.button.textContent = this.enabled ? "Disable on this device" : "Enable on this device"; |
| 681 | + this.form.hidden = !this.enabled; |
| 682 | if (!this.enabled && Notification.permission === "denied") this.say("Notifications are blocked in the browser settings."); |
| 683 | } |
| 684 | + categories() { return [...this.form.querySelectorAll("input")].filter(input => input.checked).map(input => input.name); } |
| 685 | + // register sends the subscription with the chosen categories; it runs on |
| 686 | + // enable and again whenever a category changes. |
| 687 | + async register(current) { |
| 688 | + current = current || (await this.subscription()); |
| 689 | + if (!current) return; |
| 690 | + const categories = this.categories(); |
| 691 | + await tiny.signedFetch("/push/subscribe", "POST", JSON.stringify({subscription: current.toJSON(), categories}), {contentType: "application/json"}); |
| 692 | + localStorage.setItem("tiny.push", current.endpoint); |
| 693 | + localStorage.setItem("tiny.push.categories", categories.join(",")); |
| 694 | + this.say(categories.length ? "Notifications are on for this device." : "No categories chosen; nothing will arrive."); |
| 695 | + } |
| 696 | async toggle() { |
| 697 | this.button.disabled = true; |
| 698 | try { |
| 699 | @@ -471,9 +489,7 @@ |
| 700 | const {key} = await (await fetch(tiny.localPath("/push/key"), {credentials: "same-origin"})).json(); |
| 701 | const registration = await navigator.serviceWorker.ready; |
| 702 | const current = (await registration.pushManager.getSubscription()) || (await registration.pushManager.subscribe({userVisibleOnly: true, applicationServerKey: tiny.util.fromB64url(key)})); |
| 703 | - await tiny.signedFetch("/push/subscribe", "POST", JSON.stringify(current.toJSON()), {contentType: "application/json"}); |
| 704 | - localStorage.setItem("tiny.push", current.endpoint); |
| 705 | - this.say("Notifications are on for this device."); |
| 706 | + await this.register(current); |
| 707 | } |
| 708 | await this.refresh(); |
| 709 | } finally { this.button.disabled = false; } |
| 710 | @@ -828,6 +844,15 @@ |
| 711 | // page whose definition has not arrived, such as after an in-place |
| 712 | // navigation to the Files or Connect pages. |
| 713 | const bundlesFor = {"file-upload": "files", "file-workspace-root": "files", "private-services": "private"}; |
| 714 | + // Opening the inbox records the visit for the badge and clears it. |
| 715 | + const markInboxSeen = () => { |
| 716 | + if (!/\/inbox$/.test(location.pathname) || !document.getElementById("session-logout")) return; |
| 717 | + navigator.clearAppBadge?.().catch?.(() => {}); |
| 718 | + fetch(tiny.localPath("/inbox/seen"), {method: "POST", credentials: "same-origin"}).catch(() => {}); |
| 719 | + }; |
| 720 | + document.addEventListener("tiny:navigation", markInboxSeen); |
| 721 | + markInboxSeen(); |
| 722 | + |
| 723 | const ensureModules = () => { |
| 724 | if (!tiny.require || typeof customElements.get !== "function") return; |
| 725 | const wanted = new Set(); |
| 726 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 727 | index 2043fe7..c28602d 100644 |
| 728 | --- a/internal/webui/style.css |
| 729 | +++ b/internal/webui/style.css |
| 730 | @@ -117,7 +117,8 @@ rpc-form label,signed-form label,publish-list label,nostr-compose label,file-too |
| 731 | rpc-form label input,rpc-form label select,signed-form label input,nostr-compose label input,nostr-compose label select,file-tools label input,file-tools label select,file-upload label input,#collaboration-filter label input,#collaboration-filter label select,#file-search label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px} |
| 732 | file-upload label[data-share]{display:block}file-upload input[data-share-url]{width:100%} |
| 733 | file-upload label:has([type=checkbox]),#panel file-upload label:has([type=checkbox]){display:inline-block;margin:8px 14px 8px 0}file-upload label:has([type=checkbox]) input,#panel file-upload label:has([type=checkbox]) input{display:inline-block;width:auto;min-width:0;margin:0 4px 0 0;vertical-align:middle} |
| 734 | -#panel file-upload [hidden],file-upload [hidden],file-tools [hidden]{display:none} |
| 735 | +#panel file-upload [hidden],file-upload [hidden],file-tools [hidden],#panel push-toggle [hidden]{display:none} |
| 736 | +#panel push-toggle label{display:inline-block;margin:6px 12px 0 0}#panel push-toggle label input{display:inline-block;width:auto;min-width:0;margin:0 4px 0 0;vertical-align:middle}#panel push-toggle form{margin-top:4px} |
| 737 | file-upload p[data-drop]{margin:6px 0;color:var(--muted);font-size:12px}file-upload[data-over]{outline:1px dashed var(--accent);outline-offset:4px} |
| 738 | #panel file-upload,#panel file-mirror{border:0;padding:0;max-width:none}#panel details{margin-top:8px}#panel details>summary{cursor:pointer;color:var(--muted);font-size:12px} |
| 739 | rpc-form label:has(textarea),publish-list label:has(textarea),nostr-compose label:has(textarea){display:block} |
| 740 | diff --git a/internal/webui/sw.js b/internal/webui/sw.js |
| 741 | index 8621ba6..538dc82 100644 |
| 742 | --- a/internal/webui/sw.js |
| 743 | +++ b/internal/webui/sw.js |
| 744 | @@ -47,9 +47,10 @@ self.addEventListener("push", event => { |
| 745 | let data = {}; |
| 746 | try { data = event.data?.json() || {}; } catch { data = {body: event.data?.text() || ""}; } |
| 747 | const base = new URL(self.registration.scope); |
| 748 | - event.waitUntil(self.registration.showNotification(data.title || "tiny", { |
| 749 | + const badge = Number.isInteger(data.badge) && data.badge > 0 ? navigator.setAppBadge?.(data.badge) : navigator.clearAppBadge?.(); |
| 750 | + event.waitUntil(Promise.all([Promise.resolve(badge).catch(() => {}), self.registration.showNotification(data.title || "tiny", { |
| 751 | body: data.body || "", tag: data.tag || "tiny", icon: new URL("icon-192.png", base).href, badge: new URL("badge-96.png", base).href, data: {url: data.url || base.href} |
| 752 | - })); |
| 753 | + })])); |
| 754 | }); |
| 755 | self.addEventListener("notificationclick", event => { |
| 756 | event.notification.close(); |