krz/octosentry

macOS menu bar app to monitor GitHub security alerts

clone: git clone https://gitbay.org/krz/octosentry.git

b4dbbd307fab4edf0958d35032dbaa0f6450785b

signed_unknown_key

author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T23:06:41Z
committer: <noreply@github.com>

Render seen alerts as seen instead of dropping them (#46)

Marking an alert seen removed the row for the session, then the next
poll put it back rendered identically, so the action looked like it had
done nothing.

The row now stays and recedes: dimmed, with a filled checkmark. Hiding
an alert is what dismiss and snooze are for. The control is a toggle, so
acknowledging isn't a one-way door.

markSeen becomes setSeen(_:_:).
 octosentry/SecurityEventListView.swift |  2 +-
 octosentry/SecurityEventRow.swift      | 15 +++++++++------
 octosentry/SecurityEventStore.swift    | 17 +++++++++++++----
 3 files changed, 23 insertions(+), 11 deletions(-)

diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift
index 21e13a2..16af4d2 100644
--- a/octosentry/SecurityEventListView.swift
+++ b/octosentry/SecurityEventListView.swift
@@ -313,7 +313,7 @@ struct SecurityEventListView: View {
                                 : nil,
                             isHidden: store.triage.isHidden(event.id, now: .now),
                             snoozedUntil: store.triage.snoozedUntil(event.id, now: .now),
-                            onMarkSeen: { Task { await store.markSeen(event.id) } },
+                            onToggleSeen: { Task { await store.setSeen(event.id, !event.seenLocally) } },
                             onDismiss: { Task { await store.dismiss(event.id) } },
                             onSnooze: { duration in
                                 Task { await store.snooze(event.id, until: duration.date(from: .now)) }
diff --git a/octosentry/SecurityEventRow.swift b/octosentry/SecurityEventRow.swift
index 846905a..3305d61 100644
--- a/octosentry/SecurityEventRow.swift
+++ b/octosentry/SecurityEventRow.swift
@@ -13,7 +13,7 @@ struct SecurityEventRow: View {
     var attribution: String?
     var isHidden = false
     var snoozedUntil: Date?
-    var onMarkSeen: () -> Void
+    var onToggleSeen: () -> Void
     var onDismiss: () -> Void = {}
     var onSnooze: (SnoozeDuration) -> Void = { _ in }
     var onRestore: () -> Void = {}
@@ -80,6 +80,9 @@ struct SecurityEventRow: View {
                 }
                 .padding(10)
                 .contentShape(Rectangle())
+                // Seen alerts stay in the feed but recede, so the
+                // acknowledgement is visible rather than silently discarded.
+                .opacity(event.seenLocally ? 0.5 : 1)
             }
             .buttonStyle(.plain)
 
@@ -93,12 +96,12 @@ struct SecurityEventRow: View {
                 .padding(.top, 12)
                 .padding(.trailing, 10)
             } else {
-                Button(action: onMarkSeen) {
-                    Image(systemName: "checkmark.circle")
+                Button(action: onToggleSeen) {
+                    Image(systemName: event.seenLocally ? "checkmark.circle.fill" : "checkmark.circle")
                 }
                 .buttonStyle(.plain)
-                .foregroundStyle(.secondary)
-                .help("Mark as seen")
+                .foregroundStyle(event.seenLocally ? Color.accentColor : .secondary)
+                .help(event.seenLocally ? "Mark as unseen" : "Mark as seen")
                 .padding(.top, 12)
                 .padding(.trailing, 6)
 
@@ -144,6 +147,6 @@ struct SecurityEventRow: View {
             updatedAt: .now,
             seenLocally: false
         ),
-        onMarkSeen: {}
+        onToggleSeen: {}
     )
 }
diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift
index e2f5db4..cc74b7e 100644
--- a/octosentry/SecurityEventStore.swift
+++ b/octosentry/SecurityEventStore.swift
@@ -266,13 +266,22 @@ final class SecurityEventStore {
 
     /// Local-only triage state (spec §11) — no API write, no scope beyond
     /// read needed. Removes the event from the active stream.
-    func markSeen(_ eventID: String) async {
+    /// Acknowledges an alert without hiding it — the row stays, rendered as
+    /// seen. Dropping it from the feed only lasted until the next poll put it
+    /// back looking untouched; hiding an alert is what dismiss and snooze are
+    /// for. Toggleable, so acknowledging isn't a one-way door.
+    func setSeen(_ eventID: String, _ seen: Bool) async {
         var state = await persistenceStore.load()
-        state.seenEventIDs.insert(eventID)
+        if seen {
+            state.seenEventIDs.insert(eventID)
+        } else {
+            state.seenEventIDs.remove(eventID)
+        }
         await persistenceStore.save(state)
 
-        rawEvents.removeAll { $0.id == eventID }
-        totalFetchedCount = rawEvents.count
+        if let index = rawEvents.firstIndex(where: { $0.id == eventID }) {
+            rawEvents[index].seenLocally = seen
+        }
         applyFilters()
     }