Skip to content

Commit b84c431

Browse files
authored
Merge pull request #26 from jasonlong/advisor/008-handle-same-thread-and-mixed-bulk-actions
Apply every checked activity in bulk triage
2 parents 8fcdc7f + 70cd4c3 commit b84c431

3 files changed

Lines changed: 364 additions & 75 deletions

File tree

Octodot/App/AppState.swift

Lines changed: 83 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ final class AppState {
2020
private static let inboxModeStorageKey = "AppState.inboxMode.v1"
2121
private static let groupByRepoStorageKey = "AppState.groupByRepo.v1"
2222
private static let visibleSubjectStateBatchSize = 20
23+
private static let unsupportedSecurityAlertActionMessage =
24+
"Security alerts can only be opened or marked done"
2325
static let pageJumpCount = 8
2426
static let halfPageJumpCount = 4
2527

@@ -459,27 +461,39 @@ final class AppState {
459461
if let batch = checkedNotificationsBatch() {
460462
let originalVisibleOrder = filteredNotifications
461463
let originalSelectionID = selectedNotificationID
464+
var acceptedItems: [GitHubNotification] = []
462465
clearChecked()
463-
for notification in batch {
464-
if notification.source == .dependabotAlert {
465-
dismissSecurityAlert(notification, updatesSelection: false)
466-
} else {
467-
startThreadAction(.done, target: notification, updatesSelection: false)
466+
467+
for notification in batch where notification.source == .dependabotAlert {
468+
dismissSecurityAlert(notification, updatesSelection: false)
469+
acceptedItems.append(notification)
470+
}
471+
for group in groupedThreadNotifications(from: batch) {
472+
guard let representative = group.first else { continue }
473+
if startThreadAction(
474+
.done,
475+
target: representative,
476+
activityIdentities: group.map(\.activityIdentity),
477+
updatesSelection: false
478+
) {
479+
acceptedItems.append(contentsOf: group)
468480
}
469481
}
482+
470483
restoreSelectionAfterBulkMutation(
471484
originalSelectionID: originalSelectionID,
472485
originalVisibleOrder: originalVisibleOrder
473486
)
474-
presentActionToast(verb: .done, items: batch)
475-
return
476-
}
477-
if dismissSelectedSecurityAlertIfNeeded() {
487+
presentActionToast(verb: .done, items: acceptedItems)
478488
return
479489
}
480490
guard let target = selectedNotification else { return }
481-
startThreadAction(.done)
482-
presentActionToast(verb: .done, items: [target])
491+
if target.source == .dependabotAlert {
492+
dismissSecurityAlert(target)
493+
presentActionToast(verb: .done, items: [target])
494+
} else if startThreadAction(.done) {
495+
presentActionToast(verb: .done, items: [target])
496+
}
483497
}
484498

485499
func markRead() {
@@ -490,29 +504,44 @@ final class AppState {
490504
if let batch = checkedNotificationsBatch() {
491505
let originalVisibleOrder = filteredNotifications
492506
let originalSelectionID = selectedNotificationID
507+
let hasUnsupportedAlerts = batch.contains { $0.source == .dependabotAlert }
508+
var acceptedItems: [GitHubNotification] = []
493509
clearChecked()
494-
for notification in batch {
495-
if notification.source == .dependabotAlert {
496-
dismissSecurityAlert(notification, updatesSelection: false)
497-
} else {
498-
inboxStore.muteThread(notification.threadId)
499-
startThreadAction(.unsubscribe, target: notification, updatesSelection: false)
510+
511+
for group in groupedThreadNotifications(from: batch) {
512+
guard let representative = group.first else { continue }
513+
if startThreadAction(
514+
.unsubscribe,
515+
target: representative,
516+
activityIdentities: group.map(\.activityIdentity),
517+
updatesSelection: false
518+
) {
519+
inboxStore.muteThread(representative.threadId)
520+
clampSelection()
521+
acceptedItems.append(contentsOf: group)
500522
}
501523
}
524+
502525
restoreSelectionAfterBulkMutation(
503526
originalSelectionID: originalSelectionID,
504527
originalVisibleOrder: originalVisibleOrder
505528
)
506-
presentActionToast(verb: .unsub, items: batch)
529+
presentActionToast(verb: .unsub, items: acceptedItems)
530+
if hasUnsupportedAlerts {
531+
errorMessage = Self.unsupportedSecurityAlertActionMessage
532+
}
507533
return
508534
}
509-
if dismissSelectedSecurityAlertIfNeeded() {
535+
guard let notification = selectedNotification else { return }
536+
guard notification.source == .thread else {
537+
errorMessage = Self.unsupportedSecurityAlertActionMessage
510538
return
511539
}
512-
guard let notification = selectedNotification else { return }
513-
inboxStore.muteThread(notification.threadId)
514-
startThreadAction(.unsubscribe)
515-
presentActionToast(verb: .unsub, items: [notification])
540+
if startThreadAction(.unsubscribe) {
541+
inboxStore.muteThread(notification.threadId)
542+
clampSelection()
543+
presentActionToast(verb: .unsub, items: [notification])
544+
}
516545
}
517546

518547
func copyURL() {
@@ -625,6 +654,24 @@ final class AppState {
625654
return ordered.isEmpty ? nil : ordered
626655
}
627656

657+
private func groupedThreadNotifications(
658+
from notifications: [GitHubNotification]
659+
) -> [[GitHubNotification]] {
660+
var groups: [[GitHubNotification]] = []
661+
var groupIndexByThreadID: [String: Int] = [:]
662+
663+
for notification in notifications where notification.source == .thread {
664+
if let groupIndex = groupIndexByThreadID[notification.threadId] {
665+
groups[groupIndex].append(notification)
666+
} else {
667+
groupIndexByThreadID[notification.threadId] = groups.count
668+
groups.append([notification])
669+
}
670+
}
671+
672+
return groups
673+
}
674+
628675
func refresh(force: Bool = false) {
629676
refresh(using: .uniform(force: force))
630677
}
@@ -875,20 +922,22 @@ final class AppState {
875922
selectedThreadID = nil
876923
}
877924

925+
@discardableResult
878926
private func startThreadAction(
879927
_ kind: ThreadActionStore.ActionKind,
880928
target explicitTarget: GitHubNotification? = nil,
929+
activityIdentities: [String]? = nil,
881930
delayNanosecondsOverride: UInt64? = nil,
882931
updatesSelection: Bool = true
883-
) {
884-
guard let client = apiClient else { return }
885-
guard let target = explicitTarget ?? selectedNotification else { return }
932+
) -> Bool {
933+
guard let client = apiClient else { return false }
934+
guard let target = explicitTarget ?? selectedNotification else { return false }
886935
guard target.source == .thread else {
887-
errorMessage = "Security alerts can only be opened or marked done"
888-
return
936+
errorMessage = Self.unsupportedSecurityAlertActionMessage
937+
return false
889938
}
890-
guard !threadActions.hasPendingAction(for: target.threadId) else { return }
891-
if kind == .markRead && !target.isUnread { return }
939+
guard !threadActions.hasPendingAction(for: target.threadId) else { return false }
940+
if kind == .markRead && !target.isUnread { return false }
892941

893942
DebugTrace.log(
894943
"start action kind=\(kind.rawValue) target.id=\(target.id) target.thread=\(target.threadId) " +
@@ -901,6 +950,7 @@ final class AppState {
901950
let pending = threadActions.start(
902951
kind,
903952
notification: target,
953+
activityIdentities: activityIdentities,
904954
originalServerIndex: originalServerIndex
905955
)
906956

@@ -923,15 +973,6 @@ final class AppState {
923973
} else {
924974
scheduleBatchDispatch(client: client, delayNanoseconds: delayNanoseconds)
925975
}
926-
}
927-
928-
@discardableResult
929-
private func dismissSelectedSecurityAlertIfNeeded() -> Bool {
930-
guard let target = selectedNotification,
931-
target.source == .dependabotAlert else {
932-
return false
933-
}
934-
dismissSecurityAlert(target)
935976
return true
936977
}
937978

@@ -1060,7 +1101,9 @@ final class AppState {
10601101
"target.thread=\(pending.notification.threadId) server=\(serverNotifications.map(\.id).joined(separator: ","))"
10611102
)
10621103

1063-
errorMessage = nil
1104+
if errorMessage != Self.unsupportedSecurityAlertActionMessage {
1105+
errorMessage = nil
1106+
}
10641107
rebuildDerivedState()
10651108
DebugTrace.log(
10661109
"after rebuild success kind=\(pending.kind.rawValue) selected=\(selectedNotificationID ?? "nil") " +

Octodot/App/ThreadActionStore.swift

Lines changed: 43 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ struct ThreadActionStore {
3838
let requestID: UUID
3939
let kind: ActionKind
4040
let notification: GitHubNotification
41+
let activityIdentities: [String]
4142
let originalServerIndex: Int
4243
var phase: PendingActionPhase
4344
}
@@ -46,14 +47,15 @@ struct ThreadActionStore {
4647
let kind: ActionKind
4748
let threadId: String
4849
let updatedAt: Date
49-
let activityIdentity: String?
50+
let activityIdentities: [String]
5051
}
5152

5253
private struct PersistedCommittedAction: Codable {
5354
let kind: ActionKind
5455
let threadId: String
5556
let updatedAt: Date
5657
let activityIdentity: String?
58+
let activityIdentities: [String]?
5759
}
5860

5961
private let userDefaults: UserDefaults
@@ -89,12 +91,14 @@ struct ThreadActionStore {
8991
mutating func start(
9092
_ kind: ActionKind,
9193
notification: GitHubNotification,
94+
activityIdentities: [String]? = nil,
9295
originalServerIndex: Int
9396
) -> PendingAction {
9497
let pending = PendingAction(
9598
requestID: UUID(),
9699
kind: kind,
97100
notification: notification,
101+
activityIdentities: activityIdentities ?? [notification.activityIdentity],
98102
originalServerIndex: originalServerIndex,
99103
phase: .queued
100104
)
@@ -122,27 +126,27 @@ struct ThreadActionStore {
122126
kind: .markRead,
123127
threadId: pending.notification.threadId,
124128
updatedAt: pending.notification.updatedAt,
125-
activityIdentity: pending.notification.activityIdentity
129+
activityIdentities: pending.activityIdentities
126130
)
127131
persistCommittedActions()
128132

129133
case .done:
130-
serverNotifications.removeAll { $0.matchesActivity(as: pending.notification) }
134+
removeActivities(pending.activityIdentities, from: &serverNotifications)
131135
committedActions[pending.notification.threadId] = CommittedAction(
132136
kind: .done,
133137
threadId: pending.notification.threadId,
134138
updatedAt: pending.notification.updatedAt,
135-
activityIdentity: pending.notification.activityIdentity
139+
activityIdentities: pending.activityIdentities
136140
)
137141
persistCommittedActions()
138142

139143
case .unsubscribe:
140-
serverNotifications.removeAll { $0.matchesActivity(as: pending.notification) }
144+
removeActivities(pending.activityIdentities, from: &serverNotifications)
141145
committedActions[pending.notification.threadId] = CommittedAction(
142146
kind: .unsubscribe,
143147
threadId: pending.notification.threadId,
144148
updatedAt: pending.notification.updatedAt,
145-
activityIdentity: pending.notification.activityIdentity
149+
activityIdentities: pending.activityIdentities
146150
)
147151
persistCommittedActions()
148152
}
@@ -164,11 +168,11 @@ struct ThreadActionStore {
164168
projected[index].isUnread = false
165169
}
166170
case .done, .unsubscribe:
167-
hideDismissedActivity(
168-
activityIdentity: committed.activityIdentity,
171+
hideDismissedActivities(
172+
activityIdentities: committed.activityIdentities,
169173
threadId: committed.threadId,
170174
updatedAt: committed.updatedAt,
171-
useLegacyThreadFallback: committed.activityIdentity == nil,
175+
useLegacyThreadFallback: committed.activityIdentities.isEmpty,
172176
in: &projected
173177
)
174178
}
@@ -182,8 +186,8 @@ struct ThreadActionStore {
182186
}
183187

184188
case .done, .unsubscribe:
185-
hideDismissedActivity(
186-
activityIdentity: pending.notification.activityIdentity,
189+
hideDismissedActivities(
190+
activityIdentities: pending.activityIdentities,
187191
threadId: pending.notification.threadId,
188192
updatedAt: pending.notification.updatedAt,
189193
useLegacyThreadFallback: false,
@@ -212,8 +216,9 @@ struct ThreadActionStore {
212216
guard !fetchedSnapshots.isEmpty else {
213217
return true
214218
}
215-
if let activityIdentity = committed.activityIdentity {
216-
if fetchedSnapshots.contains(where: { $0.activityIdentity == activityIdentity }) {
219+
if !committed.activityIdentities.isEmpty {
220+
let representedIdentities = Set(committed.activityIdentities)
221+
if fetchedSnapshots.contains(where: { representedIdentities.contains($0.activityIdentity) }) {
217222
return true
218223
}
219224
if fetchedSnapshots.count == 1,
@@ -237,38 +242,40 @@ struct ThreadActionStore {
237242
pendingActions.removeAll()
238243
}
239244

240-
private func hideDismissedActivity(
241-
activityIdentity: String?,
245+
private func hideDismissedActivities(
246+
activityIdentities: [String],
242247
threadId: String,
243248
updatedAt: Date,
244249
useLegacyThreadFallback: Bool,
245250
in notifications: inout [GitHubNotification]
246251
) {
247-
if let activityIdentity,
248-
let exactIndex = notifications.firstIndex(where: { $0.activityIdentity == activityIdentity }) {
249-
notifications.remove(at: exactIndex)
250-
return
251-
}
252-
253-
let threadSnapshotIndices = notifications.indices.filter {
254-
notifications[$0].threadId == threadId
255-
}
252+
if !activityIdentities.isEmpty {
253+
let representedIdentities = Set(activityIdentities)
254+
let originalCount = notifications.count
255+
notifications.removeAll { representedIdentities.contains($0.activityIdentity) }
256+
if notifications.count < originalCount {
257+
return
258+
}
256259

257-
guard threadSnapshotIndices.count == 1 else {
258-
if useLegacyThreadFallback {
259-
notifications.removeAll {
260-
$0.threadId == threadId && $0.updatedAt <= updatedAt
261-
}
260+
let threadSnapshots = notifications.filter { $0.threadId == threadId }
261+
if threadSnapshots.count == 1, threadSnapshots[0].updatedAt <= updatedAt {
262+
notifications.removeAll { $0.id == threadSnapshots[0].id }
262263
}
263264
return
264265
}
265266

266-
let snapshotIndex = threadSnapshotIndices[0]
267-
guard notifications[snapshotIndex].updatedAt <= updatedAt else {
268-
return
267+
guard useLegacyThreadFallback else { return }
268+
notifications.removeAll {
269+
$0.threadId == threadId && $0.updatedAt <= updatedAt
269270
}
271+
}
270272

271-
notifications.remove(at: snapshotIndex)
273+
private func removeActivities(
274+
_ activityIdentities: [String],
275+
from notifications: inout [GitHubNotification]
276+
) {
277+
let representedIdentities = Set(activityIdentities)
278+
notifications.removeAll { representedIdentities.contains($0.activityIdentity) }
272279
}
273280

274281
private static func loadCommittedActions(from userDefaults: UserDefaults) -> [String: CommittedAction] {
@@ -291,7 +298,7 @@ struct ThreadActionStore {
291298
kind: $0.kind,
292299
threadId: $0.threadId,
293300
updatedAt: $0.updatedAt,
294-
activityIdentity: $0.activityIdentity
301+
activityIdentities: $0.activityIdentities ?? $0.activityIdentity.map { [$0] } ?? []
295302
)
296303
)
297304
}
@@ -309,7 +316,8 @@ struct ThreadActionStore {
309316
kind: $0.kind,
310317
threadId: $0.threadId,
311318
updatedAt: $0.updatedAt,
312-
activityIdentity: $0.activityIdentity
319+
activityIdentity: $0.activityIdentities.first,
320+
activityIdentities: $0.activityIdentities
313321
)
314322
}
315323

0 commit comments

Comments
 (0)