aboutsummaryrefslogtreecommitdiffstats
path: root/internal/notify
diff options
context:
space:
mode:
Diffstat (limited to 'internal/notify')
-rw-r--r--internal/notify/store.go226
-rw-r--r--internal/notify/store_test.go126
2 files changed, 352 insertions, 0 deletions
diff --git a/internal/notify/store.go b/internal/notify/store.go
new file mode 100644
index 0000000..f1e9fe4
--- /dev/null
+++ b/internal/notify/store.go
@@ -0,0 +1,226 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+package notify
+
+import "sync"
+
+// Popup is one notification as the renderers see it. The JSON tags are the
+// file contract in the spec.
+type Popup struct {
+ ID uint32 `json:"id"`
+ App string `json:"app"`
+ Summary string `json:"summary"`
+ Body string `json:"body"`
+ Urgency Urgency `json:"urgency"`
+ Icon string `json:"icon"`
+ Actions [][2]string `json:"actions"`
+ Created int64 `json:"created"`
+ Expires int64 `json:"expires"`
+}
+
+// live rounds out a Popup with the state the renderers do not need: the stack
+// tag it replaces on, and whether the D-Bus client has already been closed.
+type live struct {
+ Popup
+ Stack string
+ Closed bool
+}
+
+const (
+ liveCap = 20
+ historyCap = 20
+)
+
+// Store holds the live queue and the history ring. Time, signals and file
+// writes are injected, so the whole thing is tested without a bus or a clock.
+type Store struct {
+ mu sync.Mutex
+ nextID uint32
+ order []uint32
+ entries map[uint32]*live
+ history []*live
+ emit func(id, reason uint32)
+ publish func(live, history []Popup)
+}
+
+func NewStore(emit func(id, reason uint32), publish func(live, history []Popup)) *Store {
+ return &Store{
+ nextID: 1,
+ entries: map[uint32]*live{},
+ emit: emit,
+ publish: publish,
+ }
+}
+
+// Add inserts n, replacing the entry named by replacesID or stack when one
+// matches. A replace reuses the id and emits nothing: the old client is told
+// nothing because a new client owns the id now.
+func (s *Store) Add(n *Popup, stack string, replacesID uint32) (uint32, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ var old *live
+ if replacesID != 0 {
+ old = s.entries[replacesID]
+ }
+ if old == nil && stack != "" {
+ for _, id := range s.order {
+ if s.entries[id].Stack == stack {
+ old = s.entries[id]
+ break
+ }
+ }
+ }
+ add := &live{Popup: *n, Stack: stack}
+ if old != nil {
+ add.ID = old.ID
+ s.entries[add.ID] = add
+ s.moveToFrontLocked(add.ID)
+ s.evictLocked()
+ s.publishLocked()
+ return add.ID, true
+ }
+ add.ID = s.nextID
+ s.nextID++
+ s.entries[add.ID] = add
+ s.order = append([]uint32{add.ID}, s.order...)
+ s.evictLocked()
+ s.publishLocked()
+ return add.ID, false
+}
+
+// Expire is the balloon timeout: tell the client, keep the entry as inert.
+func (s *Store) Expire(id uint32) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ n, ok := s.entries[id]
+ if !ok || n.Closed {
+ return
+ }
+ n.Closed = true
+ s.emit(id, 1)
+}
+
+// Dismiss is an explicit close from either renderer. The client is told only
+// if expiry has not already told it, then the entry is filed.
+func (s *Store) Dismiss(id, reason uint32) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ n, ok := s.entries[id]
+ if !ok {
+ return
+ }
+ s.removeLocked(id)
+ s.fileLocked(n, reason)
+ s.publishLocked()
+}
+
+// DismissAll dismisses every live entry.
+func (s *Store) DismissAll() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, id := range append([]uint32(nil), s.order...) {
+ n := s.entries[id]
+ if n == nil {
+ continue
+ }
+ s.removeLocked(id)
+ s.fileLocked(n, 2)
+ }
+ s.publishLocked()
+}
+
+// ClearHistory empties the history ring.
+func (s *Store) ClearHistory() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.history = nil
+ s.publishLocked()
+}
+
+// Actionable reports whether a client is still listening on the id.
+func (s *Store) Actionable(id uint32) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ n, ok := s.entries[id]
+ return ok && !n.Closed
+}
+
+// Reset makes the state empty, which is what a start needs: nothing from a
+// previous run is resurrected.
+func (s *Store) Reset() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.order = nil
+ s.entries = map[uint32]*live{}
+ s.history = nil
+ s.publishLocked()
+}
+
+// historySnapshot is for tests.
+func (s *Store) historySnapshot() []*live {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return append([]*live(nil), s.history...)
+}
+
+func (s *Store) removeLocked(id uint32) {
+ delete(s.entries, id)
+ for i, v := range s.order {
+ if v == id {
+ s.order = append(s.order[:i], s.order[i+1:]...)
+ return
+ }
+ }
+}
+
+func (s *Store) moveToFrontLocked(id uint32) {
+ for i, v := range s.order {
+ if v == id {
+ s.order = append(s.order[:i], s.order[i+1:]...)
+ break
+ }
+ }
+ s.order = append([]uint32{id}, s.order...)
+}
+
+func (s *Store) fileLocked(n *live, reason uint32) {
+ if !n.Closed {
+ n.Closed = true
+ s.emit(n.ID, reason)
+ }
+ s.history = append([]*live{n}, s.history...)
+ if len(s.history) > historyCap {
+ s.history = s.history[:historyCap]
+ }
+}
+
+func (s *Store) evictLocked() {
+ for len(s.order) > liveCap {
+ id := s.order[len(s.order)-1]
+ n := s.entries[id]
+ s.removeLocked(id)
+ s.fileLocked(n, 1)
+ }
+}
+
+func (s *Store) publishLocked() {
+ liveList := make([]Popup, 0, len(s.order))
+ for _, id := range s.order {
+ liveList = append(liveList, s.entries[id].Popup)
+ }
+ historyList := make([]Popup, 0, len(s.history))
+ for _, n := range s.history {
+ historyList = append(historyList, n.Popup)
+ }
+ s.publish(liveList, historyList)
+}
diff --git a/internal/notify/store_test.go b/internal/notify/store_test.go
new file mode 100644
index 0000000..4e4dd79
--- /dev/null
+++ b/internal/notify/store_test.go
@@ -0,0 +1,126 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+package notify
+
+import "testing"
+
+type event struct {
+ id uint32
+ reason uint32
+}
+
+func newTestStore() (*Store, *[]event) {
+ emitted := &[]event{}
+ s := NewStore(
+ func(id, reason uint32) { *emitted = append(*emitted, event{id, reason}) },
+ func(live, history []Popup) {},
+ )
+ return s, emitted
+}
+
+func popup(app string) *Popup {
+ return &Popup{App: app, Urgency: Normal, Created: 1}
+}
+
+func TestAddAssignsIdsFromOne(t *testing.T) {
+ s, _ := newTestStore()
+ a, replaced := s.Add(popup("a"), "", 0)
+ b, _ := s.Add(popup("b"), "", 0)
+ if replaced {
+ t.Fatal("first add reported replaced")
+ }
+ if a != 1 || b != 2 {
+ t.Errorf("ids = %d, %d, want 1, 2", a, b)
+ }
+}
+
+func TestReplaceByIDReusesTheIDAndEmitsNothing(t *testing.T) {
+ s, emitted := newTestStore()
+ id, _ := s.Add(popup("a"), "", 0)
+ *emitted = nil
+ again, replaced := s.Add(popup("a2"), "", id)
+ if !replaced {
+ t.Fatal("replace by id not reported")
+ }
+ if again != id {
+ t.Errorf("replace id = %d, want %d", again, id)
+ }
+ if len(*emitted) != 0 {
+ t.Errorf("replace emitted %v, want none", *emitted)
+ }
+}
+
+func TestReplaceByStackTag(t *testing.T) {
+ s, _ := newTestStore()
+ first, _ := s.Add(popup("mail"), "mail-account", 0)
+ second, replaced := s.Add(popup("mail"), "mail-account", 0)
+ if !replaced || second != first {
+ t.Errorf("stack replace = id %d replaced %v, want id %d replaced true", second, replaced, first)
+ }
+}
+
+func TestExpireClosesOnceAndKeepsTheEntry(t *testing.T) {
+ s, emitted := newTestStore()
+ id, _ := s.Add(popup("a"), "", 0)
+ s.Expire(id)
+ s.Expire(id)
+ if len(*emitted) != 1 || (*emitted)[0].reason != 1 {
+ t.Fatalf("emitted = %v, want one reason 1", *emitted)
+ }
+ if s.Actionable(id) {
+ t.Error("expired entry still actionable")
+ }
+}
+
+func TestDismissFilesHistoryAndEmitsDismissed(t *testing.T) {
+ s, emitted := newTestStore()
+ id, _ := s.Add(popup("a"), "", 0)
+ s.Dismiss(id, 2)
+ if len(*emitted) != 1 || (*emitted)[0].reason != 2 {
+ t.Fatalf("emitted = %v, want one reason 2", *emitted)
+ }
+ if s.Actionable(id) {
+ t.Error("dismissed entry still live")
+ }
+}
+
+func TestDismissAfterExpireEmitsNothingMore(t *testing.T) {
+ s, emitted := newTestStore()
+ id, _ := s.Add(popup("a"), "", 0)
+ s.Expire(id)
+ *emitted = nil
+ s.Dismiss(id, 2)
+ if len(*emitted) != 0 {
+ t.Errorf("dismiss after expire emitted %v, want none", *emitted)
+ }
+}
+
+func TestQueueCapEvictsToHistory(t *testing.T) {
+ s, emitted := newTestStore()
+ for i := 0; i < 21; i++ {
+ s.Add(popup("a"), "", 0)
+ }
+ if len(*emitted) == 0 {
+ t.Fatal("eviction emitted nothing")
+ }
+}
+
+func TestHistoryRingCapsAtTwenty(t *testing.T) {
+ s, _ := newTestStore()
+ for i := 0; i < 30; i++ {
+ id, _ := s.Add(popup("a"), "", 0)
+ s.Dismiss(id, 2)
+ }
+ if got := len(s.historySnapshot()); got != 20 {
+ t.Errorf("history length = %d, want 20", got)
+ }
+}