krz/octosentry

macOS menu bar app to monitor GitHub security alerts github macos menubar security

Commit 0dca8dc832

0dca8dc832adedf57c8a6e9516fb258b0f681638

parent: 91f87b483b

Unsigned

cmc <hello@cleberg.net> · 2026-08-22 23:06 UTC
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(_:_:).

Layout: unified · split

octosentry/SecurityEventListView.swift +1 −1
@@ -313,7 +313,7 @@ struct SecurityEventListView: View {
313 : nil, 313 : nil,
314 isHidden: store.triage.isHidden(event.id, now: .now), 314 isHidden: store.triage.isHidden(event.id, now: .now),
315 snoozedUntil: store.triage.snoozedUntil(event.id, now: .now), 315 snoozedUntil: store.triage.snoozedUntil(event.id, now: .now),
316 onMarkSeen: { Task { await store.markSeen(event.id) } }, 316 onToggleSeen: { Task { await store.setSeen(event.id, !event.seenLocally) } },
317 onDismiss: { Task { await store.dismiss(event.id) } }, 317 onDismiss: { Task { await store.dismiss(event.id) } },
318 onSnooze: { duration in 318 onSnooze: { duration in
319 Task { await store.snooze(event.id, until: duration.date(from: .now)) } 319 Task { await store.snooze(event.id, until: duration.date(from: .now)) }
octosentry/SecurityEventRow.swift +9 −6
@@ -13,7 +13,7 @@ struct SecurityEventRow: View {
13 var attribution: String? 13 var attribution: String?
14 var isHidden = false 14 var isHidden = false
15 var snoozedUntil: Date? 15 var snoozedUntil: Date?
16 var onMarkSeen: () -> Void 16 var onToggleSeen: () -> Void
17 var onDismiss: () -> Void = {} 17 var onDismiss: () -> Void = {}
18 var onSnooze: (SnoozeDuration) -> Void = { _ in } 18 var onSnooze: (SnoozeDuration) -> Void = { _ in }
19 var onRestore: () -> Void = {} 19 var onRestore: () -> Void = {}
@@ -80,6 +80,9 @@ struct SecurityEventRow: View {
80 } 80 }
81 .padding(10) 81 .padding(10)
82 .contentShape(Rectangle()) 82 .contentShape(Rectangle())
83 // Seen alerts stay in the feed but recede, so the
84 // acknowledgement is visible rather than silently discarded.
85 .opacity(event.seenLocally ? 0.5 : 1)
83 } 86 }
84 .buttonStyle(.plain) 87 .buttonStyle(.plain)
85 88
@@ -93,12 +96,12 @@ struct SecurityEventRow: View {
93 .padding(.top, 12) 96 .padding(.top, 12)
94 .padding(.trailing, 10) 97 .padding(.trailing, 10)
95 } else { 98 } else {
96 Button(action: onMarkSeen) { 99 Button(action: onToggleSeen) {
97 Image(systemName: "checkmark.circle") 100 Image(systemName: event.seenLocally ? "checkmark.circle.fill" : "checkmark.circle")
98 } 101 }
99 .buttonStyle(.plain) 102 .buttonStyle(.plain)
100 .foregroundStyle(.secondary) 103 .foregroundStyle(event.seenLocally ? Color.accentColor : .secondary)
101 .help("Mark as seen") 104 .help(event.seenLocally ? "Mark as unseen" : "Mark as seen")
102 .padding(.top, 12) 105 .padding(.top, 12)
103 .padding(.trailing, 6) 106 .padding(.trailing, 6)
104 107
@@ -144,6 +147,6 @@ struct SecurityEventRow: View {
144 updatedAt: .now, 147 updatedAt: .now,
145 seenLocally: false 148 seenLocally: false
146 ), 149 ),
147 onMarkSeen: {} 150 onToggleSeen: {}
148 ) 151 )
149} 152}
octosentry/SecurityEventStore.swift +13 −4
@@ -266,13 +266,22 @@ final class SecurityEventStore {
266 266
267 /// Local-only triage state (spec §11) — no API write, no scope beyond 267 /// Local-only triage state (spec §11) — no API write, no scope beyond
268 /// read needed. Removes the event from the active stream. 268 /// read needed. Removes the event from the active stream.
269 func markSeen(_ eventID: String) async { 269 /// Acknowledges an alert without hiding it — the row stays, rendered as
270 /// seen. Dropping it from the feed only lasted until the next poll put it
271 /// back looking untouched; hiding an alert is what dismiss and snooze are
272 /// for. Toggleable, so acknowledging isn't a one-way door.
273 func setSeen(_ eventID: String, _ seen: Bool) async {
270 var state = await persistenceStore.load() 274 var state = await persistenceStore.load()
271 state.seenEventIDs.insert(eventID) 275 if seen {
276 state.seenEventIDs.insert(eventID)
277 } else {
278 state.seenEventIDs.remove(eventID)
279 }
272 await persistenceStore.save(state) 280 await persistenceStore.save(state)
273 281
274 rawEvents.removeAll { $0.id == eventID } 282 if let index = rawEvents.firstIndex(where: { $0.id == eventID }) {
275 totalFetchedCount = rawEvents.count 283 rawEvents[index].seenLocally = seen
284 }
276 applyFilters() 285 applyFilters()
277 } 286 }
278 287