tinyrelaycommit

Commit cf6ae187df2a

browse files at this commit | history

1diff --git a/docs/app.md b/docs/app.md
2index 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.
11diff --git a/internal/daemon/http.go b/internal/daemon/http.go
12index 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 }
24diff --git a/internal/daemon/services.go b/internal/daemon/services.go
25index 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 }
37diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go
38index 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.
57diff --git a/internal/daemon/webpush.go b/internal/daemon/webpush.go
58index 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, &registration); err != nil || registration.Subscription.Endpoint == "" {
177+ registration = pushRegistration{}
178+ if err := json.Unmarshal(body, &registration.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 }
315diff --git a/internal/daemon/webpush_test.go b/internal/daemon/webpush_test.go
316index 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+}
418diff --git a/internal/daemon/webpush_triggers.go b/internal/daemon/webpush_triggers.go
419new file mode 100644
420index 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+}
653diff --git a/internal/webui/components.js b/internal/webui/components.js
654index 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();
726diff --git a/internal/webui/style.css b/internal/webui/style.css
727index 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}
740diff --git a/internal/webui/sw.js b/internal/webui/sw.js
741index 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();

About

ownerc01503e8035f
visibilitypublic
headcf6ae187df2a

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs