- commit
- cf6ae187df2a33a81ac7471621dca06aa0fcd4b5
- parent
- aad6cc915b4103587f8731820e51f4dbee8bb409
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 3 | + |
| M | internal/ | 2 | + |
| M | internal/ | 130 | ++++++++++++++--- |
| M | internal/ | 88 | ++++++++++++ |
| A | internal/ | 229 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 33 | ++++- |
| M | internal/ | 3 | +- |
| M | internal/ | 5 | +- |
@@ -14,4 +14,4 @@ The app registers for `web+nostr:` links. A link to an `npub`, `nprofile`, `note
1414 1515 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.1616 17-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.17+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.@@ -64,7 +64,7 @@ func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6464 t.sessionHTTP(w, r)6565 return6666 }67- if strings.HasPrefix(r.URL.Path, "/push/") {67+ if strings.HasPrefix(r.URL.Path, "/push/") || r.URL.Path == "/inbox/seen" {6868 t.pushHTTP(w, r)6969 return7070 }@@ -653,5 +653,8 @@ func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin repli
653653 opts.Intents = append(opts.Intents, storage.Intent{Kind: "view-publish", EventID: e.ID, Target: "articles", Payload: "{}"})654654 }655655 _, err := t.store.Save(ctx, e, opts)656+ if err == nil {657+ t.notifyDevices(ctx, e)658+ }656659 return err657660 }@@ -69,6 +69,7 @@ type Tenant struct {
6969 pushMu sync.Mutex7070 pushVAPID *webpush.Keys7171 pushClient *http.Client72+ pushRecent map[string]time.Time7273 }7374 7475 func newTenant(ctx context.Context, cfg tenantConfig) (*Tenant, error) {@@ -365,6 +366,7 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
365366 if err != nil {366367 return "", err367368 }369+ t.notifyDevices(ctx, e)368370 // Stage Git metadata before acknowledging the event. This closes the369371 // publish-ACK/receive-pack race: the signed pending refs and hook exist370372 // before a client can push objects for the state.@@ -15,6 +15,7 @@ import (
1515 "strings"1616 "time"1717 18+ "github.com/FelineStateMachine/tinyrelay/internal/relay"1819 "github.com/FelineStateMachine/tinyrelay/internal/replication"1920 "github.com/FelineStateMachine/tinyrelay/internal/storage"2021 "github.com/FelineStateMachine/tinyrelay/internal/webpush"@@ -30,14 +31,16 @@ const (
3031 pushSummaryMaxLength = 1603132 )3233 33-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);34+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 '');3435 CREATE INDEX IF NOT EXISTS web_push_pubkey ON web_push(pubkey);`3536 3637 type pushPayload struct {3738 Recipient string `json:"recipient"`38- Kind string `json:"kind"`39- Subject string `json:"subject"`40- Text string `json:"text"`39+ // Kind is a device category for event summaries or a relay notice kind.40+ Kind string `json:"kind"`41+ Subject string `json:"subject"`42+ Text string `json:"text"`43+ URL string `json:"url,omitempty"`4144 }4245 4346 // pushMessage is what the service worker receives after decryption.@@ -46,11 +49,25 @@ type pushMessage struct {
4649 Body string `json:"body"`4750 URL string `json:"url"`4851 Tag string `json:"tag"`52+ Badge int `json:"badge"`53+}54+55+// pushRegistration is the browser's subscription with its chosen categories.56+type pushRegistration struct {57+ Subscription webpush.Subscription `json:"subscription"`58+ Categories []string `json:"categories"`4959 }5060 5161 func (t *Tenant) initPush(ctx context.Context) error {52- _, err := t.store.DB().ExecContext(ctx, pushSchema)53- return err62+ if _, err := t.store.DB().ExecContext(ctx, pushSchema); err != nil {63+ return err64+ }65+ // Devices registered before categories existed receive everything.66+ _, err := t.store.DB().ExecContext(ctx, "ALTER TABLE web_push ADD COLUMN categories TEXT NOT NULL DEFAULT ''")67+ if err != nil && !strings.Contains(err.Error(), "duplicate column") {68+ return err69+ }70+ return nil5471 }5572 5673 // pushVAPIDKeys returns the tenant's VAPID pair, creating it on first use.@@ -101,6 +118,24 @@ func (t *Tenant) pushHTTP(w http.ResponseWriter, r *http.Request) {
101118 return102119 }103120 writeJSON(w, 200, map[string]string{"key": keys.PublicKey()})121+ case r.URL.Path == "/inbox/seen" && r.Method == http.MethodPost:122+ // Opening the inbox clears the badge. This is the person's own123+ // read marker, so the browser session may set it from this origin.124+ actor, err := t.resolveUIActor(r)125+ if err != nil || actor == "" {126+ if s, sessionErr := t.cookieReadSession(r, relay.Session{RelayURL: t.RelayURL()}); sessionErr == nil {127+ actor = first(s.PubKeys)128+ }129+ }130+ if actor == "" {131+ writeJSON(w, 401, map[string]string{"error": "auth-required: sign this request"})132+ return133+ }134+ if err := t.markInboxSeen(r.Context(), actor); err != nil {135+ writeJSON(w, 500, map[string]string{"error": "could not record inbox visit"})136+ return137+ }138+ writeJSON(w, 200, map[string]any{"ok": true})104139 case (r.URL.Path == "/push/subscribe" || r.URL.Path == "/push/unsubscribe") && r.Method == http.MethodPost:105140 // Registration is a signed mutation like every other change made106141 // from the browser; the session cookie alone is not enough.@@ -114,11 +149,12 @@ func (t *Tenant) pushHTTP(w http.ResponseWriter, r *http.Request) {
114149 writeJSON(w, 400, map[string]string{"error": err.Error()})115150 return116151 }117- var subscription webpush.Subscription118- if err := json.Unmarshal(body, &subscription); err != nil || subscription.Validate() != nil {152+ registration, err := parsePushRegistration(body)153+ if err != nil {119154 writeJSON(w, 400, map[string]string{"error": "invalid: push subscription"})120155 return121156 }157+ subscription := registration.Subscription122158 if r.URL.Path == "/push/unsubscribe" {123159 if _, err := t.store.DB().ExecContext(r.Context(), "DELETE FROM web_push WHERE endpoint=? AND pubkey=?", subscription.Endpoint, actor); err != nil {124160 writeJSON(w, 500, map[string]string{"error": "unsubscribe failed"})@@ -131,7 +167,7 @@ func (t *Tenant) pushHTTP(w http.ResponseWriter, r *http.Request) {
131167 writeJSON(w, 403, map[string]string{"error": "restricted: notifications are limited to relay members"})132168 return133169 }134- if err := t.savePushSubscription(r.Context(), actor, subscription); err != nil {170+ if err := t.savePushSubscription(r.Context(), actor, registration); err != nil {135171 writeJSON(w, statusForPush(err), map[string]string{"error": err.Error()})136172 return137173 }@@ -148,11 +184,43 @@ func statusForPush(err error) int {
148184 return 500149185 }150186 151-func (t *Tenant) savePushSubscription(ctx context.Context, actor string, subscription webpush.Subscription) error {187+// parsePushRegistration accepts the browser's registration, with or without188+// categories, and validates the subscription inside it.189+func parsePushRegistration(body []byte) (pushRegistration, error) {190+ var registration pushRegistration191+ if err := json.Unmarshal(body, ®istration); err != nil || registration.Subscription.Endpoint == "" {192+ registration = pushRegistration{}193+ if err := json.Unmarshal(body, ®istration.Subscription); err != nil {194+ return registration, err195+ }196+ }197+ if err := registration.Subscription.Validate(); err != nil {198+ return registration, err199+ }200+ var kept []string201+ for _, category := range registration.Categories {202+ if containsString(pushCategories, category) && !containsString(kept, category) {203+ kept = append(kept, category)204+ }205+ }206+ registration.Categories = kept207+ return registration, nil208+}209+210+func (t *Tenant) savePushSubscription(ctx context.Context, actor string, registration pushRegistration) error {211+ subscription := registration.Subscription152212 raw, err := json.Marshal(subscription)153213 if err != nil {154214 return err155215 }216+ categories := ""217+ if len(registration.Categories) > 0 {218+ encoded, err := json.Marshal(registration.Categories)219+ if err != nil {220+ return err221+ }222+ categories = string(encoded)223+ }156224 return t.store.WithTx(ctx, func(tx *sql.Tx) error {157225 var count int158226 if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM web_push WHERE pubkey=? AND endpoint<>?", actor, subscription.Endpoint).Scan(&count); err != nil {@@ -161,7 +229,7 @@ func (t *Tenant) savePushSubscription(ctx context.Context, actor string, subscri
161229 if count >= pushMaxPerPubkey {162230 return fmt.Errorf("invalid: at most %d devices per key", pushMaxPerPubkey)163231 }164- _, 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())232+ _, 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)165233 return err166234 })167235 }@@ -170,19 +238,25 @@ func (t *Tenant) savePushSubscription(ctx context.Context, actor string, subscri
170238 // suitable for records.Config.PushNotification and does nothing for171239 // recipients without registered devices.172240 func (t *Tenant) enqueuePush(ctx context.Context, recipient, kind, subject, text string) error {241+ return t.enqueuePushPayload(ctx, pushPayload{Recipient: recipient, Kind: kind, Subject: subject, Text: text})242+}243+244+func (t *Tenant) enqueuePushPayload(ctx context.Context, payload pushPayload) error {173245 var count int174- if err := t.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM web_push WHERE pubkey=?", recipient).Scan(&count); err != nil || count == 0 {246+ if err := t.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM web_push WHERE pubkey=?", payload.Recipient).Scan(&count); err != nil || count == 0 {175247 return err176248 }177- if len(text) > pushSummaryMaxLength {178- text = text[:pushSummaryMaxLength]249+ if len(payload.Text) > pushSummaryMaxLength {250+ payload.Text = payload.Text[:pushSummaryMaxLength]179251 }180- payload, err := json.Marshal(pushPayload{Recipient: recipient, Kind: kind, Subject: subject, Text: text})252+ recipient := payload.Recipient253+ encoded, err := json.Marshal(payload)181254 if err != nil {182255 return err183256 }257+ payloadJSON := string(encoded)184258 return t.store.WithTx(ctx, func(tx *sql.Tx) error {185- return storage.AddIntents(ctx, tx, []storage.Intent{{Kind: notificationPush, EventID: uuid.NewString(), Target: recipient, Payload: string(payload)}}, time.Now().Unix())259+ return storage.AddIntents(ctx, tx, []storage.Intent{{Kind: notificationPush, EventID: uuid.NewString(), Target: recipient, Payload: payloadJSON}}, time.Now().Unix())186260 })187261 }188262 @@ -191,7 +265,7 @@ func (t *Tenant) handleNotificationPush(ctx context.Context, intent work.Intent)
191265 if err := json.Unmarshal([]byte(intent.Payload), &payload); err != nil || payload.Recipient != intent.Target {192266 return errors.New("notification-push: invalid payload")193267 }194- rows, err := t.store.DB().QueryContext(ctx, "SELECT endpoint, subscription, failures FROM web_push WHERE pubkey=?", payload.Recipient)268+ rows, err := t.store.DB().QueryContext(ctx, "SELECT endpoint, subscription, failures, categories FROM web_push WHERE pubkey=?", payload.Recipient)195269 if err != nil {196270 return err197271 }@@ -200,14 +274,22 @@ func (t *Tenant) handleNotificationPush(ctx context.Context, intent work.Intent)
200274 subscription webpush.Subscription201275 failures int202276 }277+ category := payload.Kind278+ if !containsString(pushCategories, category) {279+ category = pushRelay280+ }203281 var devices []device204282 for rows.Next() {205283 var item device206- var raw string207- if err := rows.Scan(&item.endpoint, &raw, &item.failures); err != nil {284+ var raw, categories string285+ if err := rows.Scan(&item.endpoint, &raw, &item.failures, &categories); err != nil {208286 rows.Close()209287 return err210288 }289+ // A device that chose categories only wakes for those.290+ if allowed := decodeCategories(categories); allowed != nil && !allowed[category] {291+ continue292+ }211293 if json.Unmarshal([]byte(raw), &item.subscription) == nil {212294 devices = append(devices, item)213295 }@@ -220,7 +302,7 @@ func (t *Tenant) handleNotificationPush(ctx context.Context, intent work.Intent)
220302 if err != nil {221303 return err222304 }223- message, err := json.Marshal(t.pushMessage(payload))305+ message, err := json.Marshal(t.pushMessage(ctx, payload))224306 if err != nil {225307 return err226308 }@@ -245,7 +327,7 @@ func (t *Tenant) handleNotificationPush(ctx context.Context, intent work.Intent)
245327 }246328 247329 // pushMessage turns a notification summary into what the device shows.248-func (t *Tenant) pushMessage(payload pushPayload) pushMessage {330+func (t *Tenant) pushMessage(ctx context.Context, payload pushPayload) pushMessage {249331 title := t.Policy().Name250332 if title == "" {251333 title = t.meta.Name@@ -264,5 +346,9 @@ func (t *Tenant) pushMessage(payload pushPayload) pushMessage {
264346 if payload.Subject != "" && payload.Subject != "relay" && !strings.EqualFold(payload.Subject, payload.Kind) {265347 title = title + ": " + payload.Subject266348 }267- return pushMessage{Title: title, Body: body, URL: strings.TrimRight(t.publicURL, "/") + "/inbox", Tag: "tiny-" + payload.Kind}349+ url := payload.URL350+ if url == "" {351+ url = strings.TrimRight(t.publicURL, "/") + "/inbox"352+ }353+ return pushMessage{Title: title, Body: body, URL: url, Tag: "tiny-" + payload.Kind, Badge: t.inboxUnread(ctx, payload.Recipient)}268354 }@@ -11,6 +11,8 @@ import (
1111 "strings"1212 "testing"1313 14+ "github.com/FelineStateMachine/tinyrelay/internal/event"15+ "github.com/FelineStateMachine/tinyrelay/internal/storage"1416 "github.com/FelineStateMachine/tinyrelay/internal/webpush"1517 "github.com/FelineStateMachine/tinyrelay/internal/work"1618 )@@ -111,3 +113,89 @@ func countIntents(t *testing.T, tenant *Tenant, kind string) int {
111113 }112114 return count113115 }116+117+func TestDeviceNotificationsFollowCategoriesAndBadge(t *testing.T) {118+ a, tenant := testTenant(t)119+ ctx := context.Background()120+ owner := tenant.Policy().Owner121+ other := strings.Repeat("b", 64)122+ // Register one device that only wants replies and relay notices.123+ subscription := testSubscription(t, "https://push.example/send/device-2")124+ body, _ := json.Marshal(map[string]any{"subscription": subscription, "categories": []string{"replies", "relay", "bogus"}})125+ req := httptest.NewRequest("POST", "http://relay.test/push/subscribe", strings.NewReader(string(body)))126+ signRequest(t, req, string(body))127+ res := httptest.NewRecorder()128+ a.ServeHTTP(res, req)129+ if res.Code != 200 {130+ t.Fatalf("subscribe %d %s", res.Code, res.Body.String())131+ }132+ var categories string133+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT categories FROM web_push").Scan(&categories); err != nil || categories != `["replies","relay"]` {134+ t.Fatalf("categories %q %v", categories, err)135+ }136+137+ mention := event.Event{Kind: 1, PubKey: other, CreatedAt: 1, Tags: [][]string{{"p", owner}}, Content: "hello there\n friend"}138+ notices := tenant.pushNotices(ctx, mention)139+ if len(notices) != 1 || notices[0].category != pushMentions || notices[0].recipient != owner || notices[0].body != "Mentioned you: hello there friend" {140+ t.Fatalf("mention notices %+v", notices)141+ }142+ wrap := event.Event{Kind: 1059, PubKey: other, CreatedAt: 1, Tags: [][]string{{"p", owner}, {"p", other}}}143+ if notices := tenant.pushNotices(ctx, wrap); len(notices) != 1 || notices[0].category != pushMessages {144+ t.Fatalf("message notices %+v", notices)145+ }146+ issue := event.Event{Kind: 1621, PubKey: other, CreatedAt: 1, Tags: [][]string{{"a", "30617:" + owner + ":notes"}, {"subject", "Broken build"}}}147+ 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") {148+ t.Fatalf("issue notices %+v", notices)149+ }150+151+ // Coalescing: the second mention within the window is dropped, and a152+ // device limited to replies is skipped by the handler for mentions.153+ tenant.notifyDevices(ctx, mention)154+ tenant.notifyDevices(ctx, mention)155+ if got := countIntents(t, tenant, notificationPush); got != 1 {156+ t.Fatalf("coalesced intents = %d", got)157+ }158+ var payload string159+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT payload FROM work_intents WHERE kind=?", notificationPush).Scan(&payload); err != nil {160+ t.Fatal(err)161+ }162+ tenant.pushClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {163+ t.Fatal("mention reached a replies-only device")164+ return nil, nil165+ })}166+ if err := tenant.handleNotificationPush(ctx, work.Intent{Kind: notificationPush, Target: owner, Payload: payload}); err != nil {167+ t.Fatal(err)168+ }169+170+ // The badge counts inbox events since the last visit and the visit171+ // endpoint accepts the browser session from this origin.172+ if _, err := tenant.store.Save(ctx, mention, storageSave(tenant)); err != nil {173+ t.Fatal(err)174+ }175+ if got := tenant.inboxUnread(ctx, owner); got != 1 {176+ t.Fatalf("unread = %d", got)177+ }178+ login := httptest.NewRequest("POST", "http://relay.test/session", strings.NewReader(""))179+ signRequest(t, login, "")180+ loginRes := httptest.NewRecorder()181+ a.ServeHTTP(loginRes, login)182+ seen := httptest.NewRequest("POST", "http://relay.test/inbox/seen", nil)183+ seen.Header.Set("Origin", "http://relay.test")184+ seen.AddCookie(loginRes.Result().Cookies()[0])185+ seenRes := httptest.NewRecorder()186+ a.ServeHTTP(seenRes, seen)187+ if seenRes.Code != 200 {188+ t.Fatalf("inbox seen %d %s", seenRes.Code, seenRes.Body.String())189+ }190+ if got := tenant.inboxUnread(ctx, owner); got != 0 {191+ t.Fatalf("unread after visit = %d", got)192+ }193+}194+195+type roundTripFunc func(*http.Request) (*http.Response, error)196+197+func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }198+199+func storageSave(tenant *Tenant) storage.SaveOptions {200+ return storage.SaveOptions{Now: 2, SearchMode: tenant.Policy().Features.Search}201+}@@ -0,0 +1,229 @@
1+package daemon2+3+// Device notification triggers. Each stored event that addresses a member4+// with registered devices becomes a short summary in one of four categories,5+// which the person picks per device: private messages, replies to their own6+// posts and repositories, mentions, and relay notices.7+8+import (9+ "context"10+ "encoding/json"11+ "strconv"12+ "strings"13+ "time"14+15+ "github.com/FelineStateMachine/tinyrelay/internal/event"16+ "github.com/FelineStateMachine/tinyrelay/internal/storage"17+)18+19+const (20+ pushMessages = "messages"21+ pushReplies = "replies"22+ pushMentions = "mentions"23+ pushRelay = "relay"24+ pushCoalesce = 30 * time.Second25+ pushExcerptMax = 12026+)27+28+var pushCategories = []string{pushMessages, pushReplies, pushMentions, pushRelay}29+30+type pushNotice struct {31+ recipient, category, body, url string32+}33+34+// notifyDevices runs after an event is stored. It never fails the publish:35+// a device summary is best effort on top of the durable event.36+func (t *Tenant) notifyDevices(ctx context.Context, e event.Event) {37+ if !t.pushDevicesExist(ctx) {38+ return39+ }40+ for _, notice := range t.pushNotices(ctx, e) {41+ if !t.pushCoalesced(notice.recipient, notice.category) {42+ continue43+ }44+ if err := t.enqueuePushNotice(ctx, notice); err != nil {45+ t.app.telemetry.Logger().Debug("device notification not queued", "error", err)46+ }47+ }48+}49+50+func (t *Tenant) pushDevicesExist(ctx context.Context) bool {51+ var count int52+ return t.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM web_push").Scan(&count) == nil && count > 053+}54+55+// pushCoalesced allows one summary per recipient and category within a short56+// window, so a burst of replies produces a single notification.57+func (t *Tenant) pushCoalesced(recipient, category string) bool {58+ t.pushMu.Lock()59+ defer t.pushMu.Unlock()60+ if t.pushRecent == nil {61+ t.pushRecent = make(map[string]time.Time)62+ }63+ key := recipient + "\x00" + category64+ now := time.Now()65+ if last, ok := t.pushRecent[key]; ok && now.Sub(last) < pushCoalesce {66+ return false67+ }68+ for k, at := range t.pushRecent {69+ if now.Sub(at) > pushCoalesce {70+ delete(t.pushRecent, k)71+ }72+ }73+ t.pushRecent[key] = now74+ return true75+}76+77+func (t *Tenant) enqueuePushNotice(ctx context.Context, notice pushNotice) error {78+ return t.enqueuePushPayload(ctx, pushPayload{Recipient: notice.recipient, Kind: notice.category, Text: notice.body, URL: notice.url})79+}80+81+// pushNotices decides who an event should wake and why.82+func (t *Tenant) pushNotices(ctx context.Context, e event.Event) []pushNotice {83+ base := strings.TrimRight(t.publicURL, "/")84+ var notices []pushNotice85+ switch e.Kind {86+ case event.KIND_WRAP, 4:87+ for _, recipient := range pushRecipients(e) {88+ notices = append(notices, pushNotice{recipient: recipient, category: pushMessages, body: "New private message.", url: base + "/inbox"})89+ }90+ case event.KIND_GIT_ISSUE, event.KIND_GIT_PR:91+ owner, identifier, ok := repoCoordinate(event.Tag(e, "a"))92+ if !ok || owner == e.PubKey {93+ return nil94+ }95+ label := "New issue"96+ view := "issue"97+ if e.Kind == event.KIND_GIT_PR {98+ label, view = "New pull request", "pr"99+ }100+ body := label101+ if subject := strings.TrimSpace(event.Tag(e, "subject")); subject != "" {102+ body += ": " + excerpt(subject)103+ }104+ notices = append(notices, pushNotice{recipient: owner, category: pushReplies, body: body, url: base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=" + view + "&id=" + e.ID})105+ case 1, 1111, 30023:106+ authored := t.referencedAuthors(ctx, e)107+ for _, recipient := range pushRecipients(e) {108+ category, body := pushMentions, "Mentioned you: "109+ if authored[recipient] {110+ category, body = pushReplies, "Replied to you: "111+ }112+ notices = append(notices, pushNotice{recipient: recipient, category: category, body: body + excerpt(e.Content), url: base + "/e/" + e.ID})113+ }114+ }115+ return notices116+}117+118+// pushRecipients lists the addressed keys other than the author, bounded so119+// a broadcast to a large list cannot fan out to every device.120+func pushRecipients(e event.Event) []string {121+ seen := map[string]struct{}{e.PubKey: {}}122+ var recipients []string123+ for _, tag := range e.Tags {124+ if len(tag) < 2 || (tag[0] != "p" && tag[0] != "P") || len(tag[1]) != 64 {125+ continue126+ }127+ if _, ok := seen[tag[1]]; ok {128+ continue129+ }130+ seen[tag[1]] = struct{}{}131+ recipients = append(recipients, tag[1])132+ if len(recipients) == 16 {133+ break134+ }135+ }136+ return recipients137+}138+139+// referencedAuthors resolves the authors of events this one replies to, so a140+// reply to your note counts as a reply rather than a mention.141+func (t *Tenant) referencedAuthors(ctx context.Context, e event.Event) map[string]bool {142+ authors := map[string]bool{}143+ var ids []string144+ for _, name := range []string{"e", "E"} {145+ for _, id := range event.TagValues(e, name) {146+ if len(id) == 64 && len(ids) < 8 {147+ ids = append(ids, id)148+ }149+ }150+ }151+ if len(ids) == 0 {152+ return authors153+ }154+ 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)})155+ if err != nil {156+ return authors157+ }158+ for _, row := range rows.Events {159+ authors[row.PubKey] = true160+ }161+ return authors162+}163+164+func repoCoordinate(value string) (owner, identifier string, ok bool) {165+ parts := strings.SplitN(value, ":", 3)166+ if len(parts) != 3 || parts[0] != strconv.Itoa(30617) || len(parts[1]) != 64 || parts[2] == "" {167+ return "", "", false168+ }169+ return parts[1], parts[2], true170+}171+172+func excerpt(text string) string {173+ text = strings.Join(strings.Fields(text), " ")174+ if len(text) > pushExcerptMax {175+ cut := pushExcerptMax176+ for cut > 0 && cut < len(text) && text[cut]&0xC0 == 0x80 {177+ cut--178+ }179+ text = text[:cut] + "…"180+ }181+ return text182+}183+184+// inboxSeen records when the person last opened their inbox, which bounds185+// the unread count carried on the app badge.186+func (t *Tenant) inboxSeen(ctx context.Context, pubkey string) int64 {187+ var at int64188+ _ = t.store.GetSetting(ctx, "inbox.seen."+pubkey, &at)189+ return at190+}191+192+func (t *Tenant) markInboxSeen(ctx context.Context, pubkey string) error {193+ return t.store.PutSetting(ctx, "inbox.seen."+pubkey, time.Now().Unix())194+}195+196+// inboxUnread counts events addressed to the key since it last opened the197+// inbox. It feeds the app badge; the page clears it on the next visit.198+func (t *Tenant) inboxUnread(ctx context.Context, pubkey string) int {199+ since := t.inboxSeen(ctx, pubkey)200+ // Count conversation kinds only, so relay records addressed to the201+ // owner do not inflate the badge.202+ 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}}}203+ if since > 0 {204+ filter.Since = &since205+ }206+ count, err := t.store.Count(ctx, []event.Filter{filter}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: []string{pubkey}}})207+ if err != nil {208+ return 0209+ }210+ if count > 99 {211+ return 99212+ }213+ return int(count)214+}215+216+func decodeCategories(raw string) map[string]bool {217+ if raw == "" {218+ return nil219+ }220+ var list []string221+ if json.Unmarshal([]byte(raw), &list) != nil {222+ return nil223+ }224+ allowed := map[string]bool{}225+ for _, item := range list {226+ allowed[item] = true227+ }228+ return allowed229+}@@ -432,13 +432,18 @@
432432 // PushToggle opts this browser into relay notifications. Nothing happens433433 // until the button is pressed: permission is requested then, and the434434 // device subscription is registered with a signed request.435+ const pushCategories = [["messages", "private messages"], ["replies", "replies to you"], ["mentions", "mentions"], ["relay", "relay notices"]];435436 class PushToggle extends HTMLElement {436437 connectedCallback() {437438 if (this.bound) return;438439 this.bound = true;439- this.innerHTML = '<p><button type="button">Enable on this device</button></p><output role="status"></output>';440+ 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>';440441 this.button = this.querySelector("button");442+ this.form = this.querySelector("form");441443 this.output = this.querySelector("output");444+ const saved = (localStorage.getItem("tiny.push.categories") || "").split(",").filter(Boolean);445+ if (saved.length) this.form.querySelectorAll("input").forEach(input => { input.checked = saved.includes(input.name); });446+ this.form.addEventListener("change", () => this.register().catch(err => this.say("error: " + err.message, true)));442447 if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {443448 this.button.disabled = true;444449 this.say("This browser does not support notifications.");@@ -453,8 +458,21 @@
453458 const current = await this.subscription();454459 this.enabled = Boolean(current) && localStorage.getItem("tiny.push") === current.endpoint;455460 this.button.textContent = this.enabled ? "Disable on this device" : "Enable on this device";461+ this.form.hidden = !this.enabled;456462 if (!this.enabled && Notification.permission === "denied") this.say("Notifications are blocked in the browser settings.");457463 }464+ categories() { return [...this.form.querySelectorAll("input")].filter(input => input.checked).map(input => input.name); }465+ // register sends the subscription with the chosen categories; it runs on466+ // enable and again whenever a category changes.467+ async register(current) {468+ current = current || (await this.subscription());469+ if (!current) return;470+ const categories = this.categories();471+ await tiny.signedFetch("/push/subscribe", "POST", JSON.stringify({subscription: current.toJSON(), categories}), {contentType: "application/json"});472+ localStorage.setItem("tiny.push", current.endpoint);473+ localStorage.setItem("tiny.push.categories", categories.join(","));474+ this.say(categories.length ? "Notifications are on for this device." : "No categories chosen; nothing will arrive.");475+ }458476 async toggle() {459477 this.button.disabled = true;460478 try {@@ -471,9 +489,7 @@
471489 const {key} = await (await fetch(tiny.localPath("/push/key"), {credentials: "same-origin"})).json();472490 const registration = await navigator.serviceWorker.ready;473491 const current = (await registration.pushManager.getSubscription()) || (await registration.pushManager.subscribe({userVisibleOnly: true, applicationServerKey: tiny.util.fromB64url(key)}));474- await tiny.signedFetch("/push/subscribe", "POST", JSON.stringify(current.toJSON()), {contentType: "application/json"});475- localStorage.setItem("tiny.push", current.endpoint);476- this.say("Notifications are on for this device.");492+ await this.register(current);477493 }478494 await this.refresh();479495 } finally { this.button.disabled = false; }@@ -828,6 +844,15 @@
828844 // page whose definition has not arrived, such as after an in-place829845 // navigation to the Files or Connect pages.830846 const bundlesFor = {"file-upload": "files", "file-workspace-root": "files", "private-services": "private"};847+ // Opening the inbox records the visit for the badge and clears it.848+ const markInboxSeen = () => {849+ if (!/\/inbox$/.test(location.pathname) || !document.getElementById("session-logout")) return;850+ navigator.clearAppBadge?.().catch?.(() => {});851+ fetch(tiny.localPath("/inbox/seen"), {method: "POST", credentials: "same-origin"}).catch(() => {});852+ };853+ document.addEventListener("tiny:navigation", markInboxSeen);854+ markInboxSeen();855+831856 const ensureModules = () => {832857 if (!tiny.require || typeof customElements.get !== "function") return;833858 const wanted = new Set();@@ -117,7 +117,8 @@ rpc-form label,signed-form label,publish-list label,nostr-compose label,file-too
117117 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}118118 file-upload label[data-share]{display:block}file-upload input[data-share-url]{width:100%}119119 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}120-#panel file-upload [hidden],file-upload [hidden],file-tools [hidden]{display:none}120+#panel file-upload [hidden],file-upload [hidden],file-tools [hidden],#panel push-toggle [hidden]{display:none}121+#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}121122 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}122123 #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}123124 rpc-form label:has(textarea),publish-list label:has(textarea),nostr-compose label:has(textarea){display:block}@@ -47,9 +47,10 @@ self.addEventListener("push", event => {
4747 let data = {};4848 try { data = event.data?.json() || {}; } catch { data = {body: event.data?.text() || ""}; }4949 const base = new URL(self.registration.scope);50- event.waitUntil(self.registration.showNotification(data.title || "tiny", {50+ const badge = Number.isInteger(data.badge) && data.badge > 0 ? navigator.setAppBadge?.(data.badge) : navigator.clearAppBadge?.();51+ event.waitUntil(Promise.all([Promise.resolve(badge).catch(() => {}), self.registration.showNotification(data.title || "tiny", {5152 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}52- }));53+ })]));5354 });5455 self.addEventListener("notificationclick", event => {5556 event.notification.close();