clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

wake devices for messages, replies and mentions with per-device categories and an app badge

Dami committed cf6ae18
commit
cf6ae187df2a33a81ac7471621dca06aa0fcd4b5
parent
aad6cc915b4103587f8731820e51f4dbee8bb409
author
Dami <dami.etoile@gmail.com>
10 files changed, +466 -31
Mdocs/app.md2+-
Minternal/daemon/http.go2+-
Minternal/daemon/services.go3+
Minternal/daemon/tenant.go2+
Minternal/daemon/webpush.go130++++++++++++++---
Minternal/daemon/webpush_test.go88++++++++++++
Ainternal/daemon/webpush_triggers.go229++++++++++++++++++++++++++++++
Minternal/webui/components.js33++++-
Minternal/webui/style.css3+-
Minternal/webui/sw.js5+-
M docs/app.md +1 -1
M internal/daemon/http.go +1 -1
M internal/daemon/services.go +3 -0
M internal/daemon/tenant.go +2 -0
M internal/daemon/webpush.go +108 -22
@@ -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, &registration); err != nil || registration.Subscription.Endpoint == "" {192+ registration = pushRegistration{}193+ if err := json.Unmarshal(body, &registration.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 }
M internal/daemon/webpush_test.go +88 -0
@@ -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+}
A internal/daemon/webpush_triggers.go +229 -0
@@ -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+}
M internal/webui/components.js +29 -4
@@ -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();
M internal/webui/style.css +2 -1
@@ -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}
M internal/webui/sw.js +3 -2