@@ -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 " ) " +
0 commit comments