diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d211c99985e3..9cf451626d70 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -2,6 +2,193 @@ "sourceLanguage": "en", "version": "1.0", "strings": { + "contextMenu.muteWorkspaceNotifications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mute Workspace Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース通知をミュート" + } + } + } + }, + "contextMenu.muteWorkspacesNotifications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mute Workspaces Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース通知をミュート" + } + } + } + }, + "contextMenu.unmuteWorkspaceNotifications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unmute Workspace Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース通知のミュートを解除" + } + } + } + }, + "contextMenu.unmuteWorkspacesNotifications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unmute Workspaces Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース通知のミュートを解除" + } + } + } + }, + "notificationMute.duration.eightHours": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "8 Hours" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "8時間" + } + } + } + }, + "notificationMute.duration.fifteenMinutes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "15 Minutes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "15分" + } + } + } + }, + "notificationMute.duration.fourHours": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "4 Hours" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "4時間" + } + } + } + }, + "notificationMute.duration.oneHour": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1 Hour" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1時間" + } + } + } + }, + "notificationMute.duration.untilUnmuted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Until Unmuted" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "解除するまで" + } + } + } + }, + "terminalContextMenu.muteTabNotifications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mute Tab Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブ通知をミュート" + } + } + } + }, + "terminalContextMenu.unmuteTabNotifications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unmute Tab Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブ通知のミュートを解除" + } + } + } + }, "command.auth.signIn.title": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index b72416ab9da3..cab49ccfd011 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -192,96 +192,6 @@ struct CmuxResolvedWorkspaceGroupConfig: Sendable, Equatable { let newWorkspacePlacement: WorkspaceGroupNewPlacement? } -enum CmuxNotificationHooksMode: String, Codable, Sendable, Hashable { - case append - case replace -} - -struct CmuxNotificationConfigDefinition: Codable, Sendable, Hashable { - var hooks: [CmuxNotificationHookDefinition]? - var hooksMode: CmuxNotificationHooksMode? - - private enum CodingKeys: String, CodingKey { - case hooks - case hooksMode - } -} - -struct CmuxNotificationHookDefinition: Codable, Sendable, Hashable { - static let defaultTimeoutSeconds: TimeInterval = 20 - - var id: String - var command: String - var timeoutSeconds: TimeInterval? - var enabled: Bool - - private enum CodingKeys: String, CodingKey { - case id - case command - case timeoutSeconds - case enabled - } - - init( - id: String, - command: String, - timeoutSeconds: TimeInterval? = nil, - enabled: Bool = true - ) { - self.id = id - self.command = command - self.timeoutSeconds = timeoutSeconds - self.enabled = enabled - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let decodedID = try Self.requiredTrimmedString(forKey: .id, in: container) - let decodedCommand = try Self.requiredTrimmedString(forKey: .command, in: container) - let decodedTimeout = try container.decodeIfPresent(TimeInterval.self, forKey: .timeoutSeconds) - if let decodedTimeout, !decodedTimeout.isFinite || decodedTimeout <= 0 { - throw DecodingError.dataCorruptedError( - forKey: .timeoutSeconds, - in: container, - debugDescription: "timeoutSeconds must be greater than 0" - ) - } - - id = decodedID - command = decodedCommand - timeoutSeconds = decodedTimeout - enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(command, forKey: .command) - try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) - try container.encode(enabled, forKey: .enabled) - } - - var resolvedTimeoutSeconds: TimeInterval { - timeoutSeconds ?? Self.defaultTimeoutSeconds - } - - private static func requiredTrimmedString( - forKey key: CodingKeys, - in container: KeyedDecodingContainer - ) throws -> String { - let value = try container.decode(String.self, forKey: key) - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty else { - throw DecodingError.dataCorruptedError( - forKey: key, - in: container, - debugDescription: "\(key.stringValue) must not be blank" - ) - } - return value - } -} - struct CmuxResolvedNotificationHook: Sendable, Hashable { let id: String let command: String @@ -1896,6 +1806,7 @@ final class CmuxConfigStore: ObservableObject { @Published private(set) var workspaceGroupConfigs: [CmuxResolvedWorkspaceGroupConfig] = [] @Published private(set) var surfaceTabBarButtons: [CmuxSurfaceTabBarButton] = CmuxSurfaceTabBarButton.defaults @Published private(set) var notificationHooks: [CmuxResolvedNotificationHook] = [] + @Published private(set) var notificationMuteMenuOptions: [NotificationMuteMenuOption] = NotificationMuteMenuOption.defaultOptions @Published private(set) var configurationIssues: [CmuxConfigIssue] = [] @Published private(set) var configRevision: UInt64 = 0 @@ -2293,6 +2204,10 @@ final class CmuxConfigStore: ObservableObject { entry.result.config.map { (path: entry.path, config: $0) } } ) + let resolvedNotificationMuteMenuOptions = resolveNotificationMuteMenuOptions( + localConfig: localConfig, + globalConfig: globalConfig + ) loadedCommands = commands loadedActions = resolvedActions @@ -2318,6 +2233,7 @@ final class CmuxConfigStore: ObservableObject { surfaceTabBarWorkspaceCommands = resolvedWorkspaceButtons.workspaceCommands surfaceTabBarButtons = resolvedWorkspaceButtons.buttons notificationHooks = resolvedNotificationHooks + notificationMuteMenuOptions = resolvedNotificationMuteMenuOptions resolvedNewWorkspaceActionCache = resolvedNewWorkspaceAction.action resolvedNewWorkspaceCommandCache = resolvedNewWorkspaceAction.command if let issue = resolvedNewWorkspaceAction.issue { @@ -2346,6 +2262,16 @@ final class CmuxConfigStore: ObservableObject { return fallbackLocalPath.map { [$0] } ?? [] } + private func resolveNotificationMuteMenuOptions( + localConfig: CmuxConfigFile?, + globalConfig: CmuxConfigFile? + ) -> [NotificationMuteMenuOption] { + NotificationMuteMenuOption.options( + configuredDurations: localConfig?.notifications?.muteDurations ?? + globalConfig?.notifications?.muteDurations + ) + } + private func resolveNotificationHooks( globalConfig: CmuxConfigFile?, localConfigs: [(path: String, config: CmuxConfigFile)] diff --git a/Sources/CmuxNotificationConfigDefinitions.swift b/Sources/CmuxNotificationConfigDefinitions.swift new file mode 100644 index 000000000000..19dddd7cd2d9 --- /dev/null +++ b/Sources/CmuxNotificationConfigDefinitions.swift @@ -0,0 +1,159 @@ +import Foundation + +enum CmuxNotificationHooksMode: String, Codable, Sendable, Hashable { + case append + case replace +} + +struct CmuxNotificationConfigDefinition: Codable, Sendable, Hashable { + var hooks: [CmuxNotificationHookDefinition]? + var hooksMode: CmuxNotificationHooksMode? + var muteDurations: [CmuxNotificationMuteDurationDefinition]? + + private enum CodingKeys: String, CodingKey { + case hooks + case hooksMode + case muteDurations + } +} + +struct CmuxNotificationMuteDurationDefinition: Codable, Sendable, Hashable { + var label: String + var interval: TimeInterval + + private enum CodingKeys: String, CodingKey { + case label + case seconds + case minutes + case hours + } + + init(label: String, interval: TimeInterval) { + self.label = label + self.interval = interval + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedLabel = try Self.requiredTrimmedString(forKey: .label, in: container) + let seconds = try container.decodeIfPresent(TimeInterval.self, forKey: .seconds) + let minutes = try container.decodeIfPresent(TimeInterval.self, forKey: .minutes) + let hours = try container.decodeIfPresent(TimeInterval.self, forKey: .hours) + for (key, value) in [(CodingKeys.seconds, seconds), (.minutes, minutes), (.hours, hours)] { + if let value, (!value.isFinite || value <= 0) { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must be greater than 0" + ) + } + } + let decodedInterval = (seconds ?? 0) + ((minutes ?? 0) * 60) + ((hours ?? 0) * 60 * 60) + if decodedInterval <= 0 { + throw DecodingError.dataCorruptedError( + forKey: .seconds, + in: container, + debugDescription: "mute duration must be greater than 0 seconds" + ) + } + + label = decodedLabel + interval = decodedInterval + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(label, forKey: .label) + try container.encode(interval, forKey: .seconds) + } + + private static func requiredTrimmedString( + forKey key: Key, + in container: KeyedDecodingContainer + ) throws -> String { + let value = try container.decode(String.self, forKey: key).trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must not be blank" + ) + } + return value + } +} + +struct CmuxNotificationHookDefinition: Codable, Sendable, Hashable { + static let defaultTimeoutSeconds: TimeInterval = 20 + + var id: String + var command: String + var timeoutSeconds: TimeInterval? + var enabled: Bool + + private enum CodingKeys: String, CodingKey { + case id + case command + case timeoutSeconds + case enabled + } + + init( + id: String, + command: String, + timeoutSeconds: TimeInterval? = nil, + enabled: Bool = true + ) { + self.id = id + self.command = command + self.timeoutSeconds = timeoutSeconds + self.enabled = enabled + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedID = try Self.requiredTrimmedString(forKey: .id, in: container) + let decodedCommand = try Self.requiredTrimmedString(forKey: .command, in: container) + let decodedTimeout = try container.decodeIfPresent(TimeInterval.self, forKey: .timeoutSeconds) + if let decodedTimeout, !decodedTimeout.isFinite || decodedTimeout <= 0 { + throw DecodingError.dataCorruptedError( + forKey: .timeoutSeconds, + in: container, + debugDescription: "timeoutSeconds must be greater than 0" + ) + } + + id = decodedID + command = decodedCommand + timeoutSeconds = decodedTimeout + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(command, forKey: .command) + try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) + try container.encode(enabled, forKey: .enabled) + } + + var resolvedTimeoutSeconds: TimeInterval { + timeoutSeconds ?? Self.defaultTimeoutSeconds + } + + private static func requiredTrimmedString( + forKey key: CodingKeys, + in container: KeyedDecodingContainer + ) throws -> String { + let value = try container.decode(String.self, forKey: key) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must not be blank" + ) + } + return value + } +} diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 85925e7464ef..87c29d8cfc70 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -366,6 +366,7 @@ extension CmuxSettingsFileStore { "notifications.command", "notifications.hooks", "notifications.hooksMode", + "notifications.muteDurations", "sidebar.hideAllDetails", "sidebar.wrapWorkspaceTitles", "sidebar.showWorkspaceDescription", diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index bb6d778dd9f8..a312ca5caf9c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -27,7 +27,6 @@ import SwiftUI import ObjectiveC import UniformTypeIdentifiers import WebKit - var fileDropOverlayKey: UInt8 = 0 private var commandPaletteWindowOverlayKey: UInt8 = 0 private var tmuxWorkspacePaneWindowOverlayKey: UInt8 = 0 @@ -10388,6 +10387,7 @@ struct VerticalTabsSidebar: View { let workspaceGroups: [WorkspaceGroup] let workspaceGroupById: [UUID: WorkspaceGroup] let workspaceGroupMenuSnapshot: WorkspaceGroupMenuSnapshot + let notificationMuteMenuOptions: [NotificationMuteMenuOption] let workspaceRenderItems: [SidebarWorkspaceRenderItem] let visibleWorkspaceRowIds: [UUID] @@ -10450,6 +10450,7 @@ struct VerticalTabsSidebar: View { workspaceGroups: workspaceGroups, workspaceGroupById: workspaceGroupById, workspaceGroupMenuSnapshot: workspaceGroupMenuSnapshot, + notificationMuteMenuOptions: cmuxConfigStore.notificationMuteMenuOptions, workspaceRenderItems: workspaceRenderItems, visibleWorkspaceRowIds: visibleWorkspaceRowIds ) @@ -12082,6 +12083,9 @@ struct VerticalTabsSidebar: View { in: tabManager, target: contextMenuPinTarget ) + let contextMenuWorkspaceMuteActive = notificationStore.hasActiveWorkspaceNotificationMute( + forTabIds: contextMenuWorkspaceIds + ) let liveUnreadCount = sidebarUnread.unreadCount(forWorkspaceId: tab.id) let liveLatestNotificationText: String? = showsSidebarNotificationMessage ? sidebarUnread.latestNotificationText(forWorkspaceId: tab.id) @@ -12169,6 +12173,8 @@ struct VerticalTabsSidebar: View { allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, contextMenuPinState: contextMenuPinState, + contextMenuWorkspaceMuteActive: contextMenuWorkspaceMuteActive, + notificationMuteMenuOptions: renderContext.notificationMuteMenuOptions, workspaceGroupMenuSnapshot: renderContext.workspaceGroupMenuSnapshot, settings: renderContext.tabItemSettings, onContextMenuAppear: onContextMenuAppear, @@ -12959,6 +12965,8 @@ struct TabItemView: View, Equatable { lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && lhs.contextMenuPinState == rhs.contextMenuPinState && + lhs.contextMenuWorkspaceMuteActive == rhs.contextMenuWorkspaceMuteActive && + lhs.notificationMuteMenuOptions == rhs.notificationMuteMenuOptions && lhs.workspaceGroupMenuSnapshot == rhs.workspaceGroupMenuSnapshot && lhs.isBeingDragged == rhs.isBeingDragged && lhs.topDropIndicatorVisible == rhs.topDropIndicatorVisible && @@ -13003,6 +13011,8 @@ struct TabItemView: View, Equatable { let allRemoteContextMenuTargetsConnecting: Bool let allRemoteContextMenuTargetsDisconnected: Bool let contextMenuPinState: WorkspaceActionDispatcher.PinState? + let contextMenuWorkspaceMuteActive: Bool + let notificationMuteMenuOptions: [NotificationMuteMenuOption] let workspaceGroupMenuSnapshot: WorkspaceGroupMenuSnapshot let settings: SidebarTabItemSettingsSnapshot /// Called from this row's contextMenu.onAppear so the parent can freeze @@ -13881,17 +13891,6 @@ struct TabItemView: View, Equatable { contextMenuState.pendingWorkspaceSnapshot = nil } - private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { - isMulti ? multi : single - } - - private func remoteContextMenuWorkspaces() -> [Workspace] { - guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] } - return remoteContextMenuWorkspaceIds.compactMap { workspaceId in - tabManager.tabs.first(where: { $0.id == workspaceId }) - } - } - @ViewBuilder private var workspaceContextMenu: some View { let workspaceSnapshot = self.workspaceSnapshot @@ -14136,11 +14135,12 @@ struct TabItemView: View, Equatable { } .disabled(!notificationStore.canMarkWorkspaceUnread(forTabIds: targetIds)) + workspaceNotificationMuteContextMenuItems(targetIds: targetIds, isMulti: isMulti) + Button(clearLatestNotificationLabel) { clearLatestNotifications(targetIds) } .disabled(!hasLatestNotifications(in: targetIds)) - Divider() Button(copyWorkspaceIDLabel) { diff --git a/Sources/GhosttyNSView+MoveTabToNewWorkspace.swift b/Sources/GhosttyNSView+MoveTabToNewWorkspace.swift index f03f4aaf44ff..b95a4e7577d5 100644 --- a/Sources/GhosttyNSView+MoveTabToNewWorkspace.swift +++ b/Sources/GhosttyNSView+MoveTabToNewWorkspace.swift @@ -1,6 +1,64 @@ import AppKit +private final class NotificationMuteMenuOptionBox: NSObject { + let option: NotificationMuteMenuOption + + init(option: NotificationMuteMenuOption) { + self.option = option + } +} + extension GhosttyNSView { + func appendCurrentSurfaceNotificationMuteMenuItems(to menu: NSMenu) { + guard tabId != nil, terminalSurface?.id != nil else { return } + + let muteItem = NSMenuItem( + title: String(localized: "terminalContextMenu.muteTabNotifications", defaultValue: "Mute Tab Notifications"), + action: nil, + keyEquivalent: "" + ) + muteItem.image = NSImage(systemSymbolName: "bell.slash", accessibilityDescription: nil) + let submenu = NSMenu() + for option in currentNotificationMuteMenuOptions() { + let item = NSMenuItem( + title: option.title, + action: #selector(muteCurrentSurfaceNotifications(_:)), + keyEquivalent: "" + ) + item.target = self + item.representedObject = NotificationMuteMenuOptionBox(option: option) + submenu.addItem(item) + } + muteItem.submenu = submenu + + if menu.items.last?.isSeparatorItem == false { + menu.addItem(.separator()) + } + menu.addItem(muteItem) + + if let surfaceId = terminalSurface?.id, + TerminalNotificationStore.shared.activeSurfaceNotificationMuteExpiration(forSurfaceId: surfaceId) != nil { + let unmuteItem = NSMenuItem( + title: String(localized: "terminalContextMenu.unmuteTabNotifications", defaultValue: "Unmute Tab Notifications"), + action: #selector(unmuteCurrentSurfaceNotifications(_:)), + keyEquivalent: "" + ) + unmuteItem.target = self + unmuteItem.image = NSImage(systemSymbolName: "bell", accessibilityDescription: nil) + menu.addItem(unmuteItem) + } + } + + private func currentNotificationMuteMenuOptions() -> [NotificationMuteMenuOption] { + guard let tabId else { + return NotificationMuteMenuOption.defaultOptions + } + return AppDelegate.shared? + .contextContainingTabId(tabId)? + .cmuxConfigStore? + .notificationMuteMenuOptions ?? NotificationMuteMenuOption.defaultOptions + } + func appendMoveCurrentSurfaceMoveMenuItems(to menu: NSMenu) { let canMoveToNewWorkspace = canMoveCurrentSurfaceToNewWorkspace() let workspaceTargets = currentSurfaceWorkspaceMoveTargets() @@ -97,4 +155,27 @@ extension GhosttyNSView { return } } + + @objc func muteCurrentSurfaceNotifications(_ sender: Any?) { + guard let tabId, + let surfaceId = terminalSurface?.id, + let item = sender as? NSMenuItem, + let box = item.representedObject as? NotificationMuteMenuOptionBox else { + NSSound.beep() + return + } + TerminalNotificationStore.shared.muteNotifications( + forTabId: tabId, + surfaceId: surfaceId, + until: box.option.expiration() + ) + } + + @objc func unmuteCurrentSurfaceNotifications(_ sender: Any?) { + guard let surfaceId = terminalSurface?.id else { + NSSound.beep() + return + } + TerminalNotificationStore.shared.unmuteNotifications(forSurfaceId: surfaceId) + } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 8c8224ffbd40..deb4fa3167bb 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -22,7 +22,6 @@ import CMUXAgentLaunch import CMUXMobileCore import IOSurface import UniformTypeIdentifiers - enum GhosttyStartupAppearancePreviewProfile: String, CaseIterable, Identifiable { case realUserConfig case freshInstall @@ -31,7 +30,6 @@ enum GhosttyStartupAppearancePreviewProfile: String, CaseIterable, Identifiable case userExplicitColors var id: String { rawValue } - var displayName: String { switch self { case .realUserConfig: @@ -7223,7 +7221,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { systemSymbolName: "rectangle.righthalf.inset.filled", accessibilityDescription: nil ) - appendMoveCurrentSurfaceMoveMenuItems(to: menu); menu.addItem(.separator()) + appendMoveCurrentSurfaceMoveMenuItems(to: menu) + appendCurrentSurfaceNotificationMuteMenuItems(to: menu) + menu.addItem(.separator()) let resetTerminalItem = menu.addItem( withTitle: String(localized: "terminalContextMenu.resetTerminal", defaultValue: "Reset Terminal"), action: #selector(resetTerminal(_:)), diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 8be440770be7..8d297035b688 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -120,6 +120,24 @@ extension CmuxSettingsFileStore { "command": NotificationSoundSettings.defaultCustomCommand, "hooksMode": "append", "hooks": [], + "muteDurations": [ + [ + "label": "15 Minutes", + "minutes": 15, + ], + [ + "label": "1 Hour", + "hours": 1, + ], + [ + "label": "4 Hours", + "hours": 4, + ], + [ + "label": "8 Hours", + "hours": 8, + ], + ], ], ], [ diff --git a/Sources/NotificationDisplaySettings.swift b/Sources/NotificationDisplaySettings.swift new file mode 100644 index 000000000000..9f9c78c40d7d --- /dev/null +++ b/Sources/NotificationDisplaySettings.swift @@ -0,0 +1,30 @@ +import Foundation + +enum NotificationBadgeSettings { + static let dockBadgeEnabledKey = "notificationDockBadgeEnabled" + static let defaultDockBadgeEnabled = true + + static func isDockBadgeEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: dockBadgeEnabledKey) == nil { + return defaultDockBadgeEnabled + } + return defaults.bool(forKey: dockBadgeEnabledKey) + } +} + +enum NotificationPaneRingSettings { + static let enabledKey = "notificationPaneRingEnabled" + static let defaultEnabled = true +} + +enum NotificationPaneFlashSettings { + static let enabledKey = "notificationPaneFlashEnabled" + static let defaultEnabled = true + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: enabledKey) == nil { + return defaultEnabled + } + return defaults.bool(forKey: enabledKey) + } +} diff --git a/Sources/NotificationMuteMenuOption.swift b/Sources/NotificationMuteMenuOption.swift new file mode 100644 index 000000000000..4f9dc57f5e0c --- /dev/null +++ b/Sources/NotificationMuteMenuOption.swift @@ -0,0 +1,76 @@ +import Foundation + +struct NotificationMuteMenuOption: Hashable, Identifiable { + enum Kind: Hashable { + case untilUnmuted + case duration(TimeInterval) + } + + let id: String + let title: String + let kind: Kind + + static var untilUnmuted: NotificationMuteMenuOption { + NotificationMuteMenuOption( + id: "until-unmuted", + title: String(localized: "notificationMute.duration.untilUnmuted", defaultValue: "Until Unmuted"), + kind: .untilUnmuted + ) + } + + static var defaultTimedOptions: [NotificationMuteMenuOption] { + [ + .timed( + id: "15m", + title: String(localized: "notificationMute.duration.fifteenMinutes", defaultValue: "15 Minutes"), + interval: 15 * 60 + ), + .timed( + id: "1h", + title: String(localized: "notificationMute.duration.oneHour", defaultValue: "1 Hour"), + interval: 60 * 60 + ), + .timed( + id: "4h", + title: String(localized: "notificationMute.duration.fourHours", defaultValue: "4 Hours"), + interval: 4 * 60 * 60 + ), + .timed( + id: "8h", + title: String(localized: "notificationMute.duration.eightHours", defaultValue: "8 Hours"), + interval: 8 * 60 * 60 + ), + ] + } + + static var defaultOptions: [NotificationMuteMenuOption] { + [untilUnmuted] + defaultTimedOptions + } + + static func options(configuredDurations: [CmuxNotificationMuteDurationDefinition]?) -> [NotificationMuteMenuOption] { + guard let configuredDurations else { + return defaultOptions + } + let timedOptions = configuredDurations.enumerated().map { index, definition in + NotificationMuteMenuOption.timed( + id: "configured-\(index)-\(definition.label)-\(definition.interval)", + title: definition.label, + interval: definition.interval + ) + } + return [untilUnmuted] + timedOptions + } + + static func timed(id: String, title: String, interval: TimeInterval) -> NotificationMuteMenuOption { + NotificationMuteMenuOption(id: id, title: title, kind: .duration(interval)) + } + + func expiration(from date: Date = Date()) -> Date { + switch kind { + case .untilUnmuted: + return .distantFuture + case .duration(let interval): + return date.addingTimeInterval(interval) + } + } +} diff --git a/Sources/TabItemView+NotificationMuteContextMenu.swift b/Sources/TabItemView+NotificationMuteContextMenu.swift new file mode 100644 index 000000000000..e503e9ef06eb --- /dev/null +++ b/Sources/TabItemView+NotificationMuteContextMenu.swift @@ -0,0 +1,48 @@ +import SwiftUI + +extension TabItemView { + func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { + isMulti ? multi : single + } + + func remoteContextMenuWorkspaces() -> [Workspace] { + guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] } + return remoteContextMenuWorkspaceIds.compactMap { workspaceId in + tabManager.tabs.first(where: { $0.id == workspaceId }) + } + } + + @ViewBuilder + func workspaceNotificationMuteContextMenuItems(targetIds: [UUID], isMulti: Bool) -> some View { + Menu(workspaceNotificationMuteLabel(isMulti: isMulti)) { + ForEach(notificationMuteMenuOptions) { option in + Button(option.title) { + notificationStore.muteNotifications( + forTabIds: targetIds, + until: option.expiration() + ) + } + } + } + .disabled(targetIds.isEmpty) + + if contextMenuWorkspaceMuteActive { + Button(workspaceNotificationUnmuteLabel(isMulti: isMulti)) { + notificationStore.unmuteNotifications(forTabIds: targetIds) + } + .disabled(targetIds.isEmpty) + } + } + + private func workspaceNotificationMuteLabel(isMulti: Bool) -> String { + isMulti + ? String(localized: "contextMenu.muteWorkspacesNotifications", defaultValue: "Mute Workspaces Notifications") + : String(localized: "contextMenu.muteWorkspaceNotifications", defaultValue: "Mute Workspace Notifications") + } + + private func workspaceNotificationUnmuteLabel(isMulti: Bool) -> String { + isMulti + ? String(localized: "contextMenu.unmuteWorkspacesNotifications", defaultValue: "Unmute Workspaces Notifications") + : String(localized: "contextMenu.unmuteWorkspaceNotifications", defaultValue: "Unmute Workspace Notifications") + } +} diff --git a/Sources/TerminalNotificationStore+Mute.swift b/Sources/TerminalNotificationStore+Mute.swift new file mode 100644 index 000000000000..eea5a5a360b5 --- /dev/null +++ b/Sources/TerminalNotificationStore+Mute.swift @@ -0,0 +1,90 @@ +import Foundation + +extension TerminalNotificationStore { + func activeWorkspaceNotificationMuteExpiration(forTabId tabId: UUID, now: Date = Date()) -> Date? { + activeNotificationMuteExpiration(notificationMuteExpirationsByWorkspaceId[tabId], now: now) + } + + func activeSurfaceNotificationMuteExpiration(forSurfaceId surfaceId: UUID, now: Date = Date()) -> Date? { + activeNotificationMuteExpiration(notificationMuteExpirationsBySurfaceId[surfaceId], now: now) + } + + func activeNotificationMuteExpiration(forTabId tabId: UUID, surfaceId: UUID?, now: Date = Date()) -> Date? { + let workspaceExpiration = activeWorkspaceNotificationMuteExpiration(forTabId: tabId, now: now) + let surfaceExpiration = surfaceId.flatMap { + activeSurfaceNotificationMuteExpiration(forSurfaceId: $0, now: now) + } + switch (workspaceExpiration, surfaceExpiration) { + case (.some(let workspace), .some(let surface)): + return max(workspace, surface) + case (.some(let workspace), .none): + return workspace + case (.none, .some(let surface)): + return surface + case (.none, .none): + return nil + } + } + + func hasActiveWorkspaceNotificationMute(forTabIds tabIds: [UUID], now: Date = Date()) -> Bool { + tabIds.contains { activeWorkspaceNotificationMuteExpiration(forTabId: $0, now: now) != nil } + } + + @discardableResult + func muteNotifications(forTabIds tabIds: [UUID], until expiration: Date) -> Bool { + let validIds = Set(tabIds) + guard !validIds.isEmpty else { return false } + var next = notificationMuteExpirationsByWorkspaceId + for tabId in validIds { + next[tabId] = expiration + } + guard next != notificationMuteExpirationsByWorkspaceId else { return false } + notificationMuteExpirationsByWorkspaceId = next + return true + } + + @discardableResult + func unmuteNotifications(forTabIds tabIds: [UUID]) -> Bool { + let validIds = Set(tabIds) + guard !validIds.isEmpty else { return false } + var next = notificationMuteExpirationsByWorkspaceId + for tabId in validIds { + next.removeValue(forKey: tabId) + } + guard next != notificationMuteExpirationsByWorkspaceId else { return false } + notificationMuteExpirationsByWorkspaceId = next + return true + } + + @discardableResult + func muteNotifications(forTabId tabId: UUID, surfaceId: UUID, until expiration: Date) -> Bool { + var next = notificationMuteExpirationsBySurfaceId + next[surfaceId] = expiration + guard next != notificationMuteExpirationsBySurfaceId else { return false } + notificationMuteExpirationsBySurfaceId = next + return true + } + + @discardableResult + func unmuteNotifications(forSurfaceId surfaceId: UUID) -> Bool { + var next = notificationMuteExpirationsBySurfaceId + next.removeValue(forKey: surfaceId) + guard next != notificationMuteExpirationsBySurfaceId else { return false } + notificationMuteExpirationsBySurfaceId = next + return true + } + + private func activeNotificationMuteExpiration(_ expiration: Date?, now: Date) -> Date? { + guard let expiration, expiration > now else { return nil } + return expiration + } +} + +#if DEBUG +extension TerminalNotificationStore { + func clearNotificationMutesForTesting() { + notificationMuteExpirationsByWorkspaceId = [:] + notificationMuteExpirationsBySurfaceId = [:] + } +} +#endif diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index bba05a9cf572..36389b42c516 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -37,35 +37,6 @@ extension UNUserNotificationCenter { } } -enum NotificationBadgeSettings { - static let dockBadgeEnabledKey = "notificationDockBadgeEnabled" - static let defaultDockBadgeEnabled = true - - static func isDockBadgeEnabled(defaults: UserDefaults = .standard) -> Bool { - if defaults.object(forKey: dockBadgeEnabledKey) == nil { - return defaultDockBadgeEnabled - } - return defaults.bool(forKey: dockBadgeEnabledKey) - } -} - -enum NotificationPaneRingSettings { - static let enabledKey = "notificationPaneRingEnabled" - static let defaultEnabled = true -} - -enum NotificationPaneFlashSettings { - static let enabledKey = "notificationPaneFlashEnabled" - static let defaultEnabled = true - - static func isEnabled(defaults: UserDefaults = .standard) -> Bool { - if defaults.object(forKey: enabledKey) == nil { - return defaultEnabled - } - return defaults.bool(forKey: enabledKey) - } -} - enum TaggedRunBadgeSettings { static let environmentKey = "CMUX_TAG" private static let maxTagLength = 10 @@ -463,6 +434,8 @@ final class TerminalNotificationStore: ObservableObject { refreshUnreadPresentation() } } + @Published var notificationMuteExpirationsByWorkspaceId: [UUID: Date] = [:] + @Published var notificationMuteExpirationsBySurfaceId: [UUID: Date] = [:] @Published private(set) var authorizationState: NotificationAuthorizationState = .unknown private var suppressNotificationDiffPublishing = false @@ -1093,10 +1066,15 @@ final class TerminalNotificationStore: ObservableObject { cooldownReservation: NotificationCooldownReservation?, clickAction: TerminalNotificationClickAction? ) { + let shouldMuteNotificationSideEffects = activeNotificationMuteExpiration( + forTabId: request.tabId, + surfaceId: request.surfaceId, + now: now + ) != nil let shouldSuppressExternalDelivery = shouldSuppressExternalDelivery( tabId: request.tabId, surfaceId: request.surfaceId - ) + ) || shouldMuteNotificationSideEffects let notification = TerminalNotification( id: UUID(), tabId: request.tabId, @@ -1115,6 +1093,7 @@ final class TerminalNotificationStore: ObservableObject { recordNotification( notification, shouldSuppressExternalDelivery: shouldSuppressExternalDelivery, + shouldMuteNotificationSideEffects: shouldMuteNotificationSideEffects, effects: effects, now: now, cooldownReservation: cooldownReservation @@ -1128,6 +1107,7 @@ final class TerminalNotificationStore: ObservableObject { ) #endif if effects.reorderWorkspace, + !shouldMuteNotificationSideEffects, UserDefaultsSettingsClient(defaults: .standard).value(for: SettingCatalog().app.reorderOnNotification) { AppDelegate.shared?.tabManagerFor(tabId: notification.tabId)? .moveTabToTopForNotification(notification.tabId) @@ -1137,6 +1117,14 @@ final class TerminalNotificationStore: ObservableObject { } else { restoreCooldownReservation(cooldownReservation) } + guard !shouldMuteNotificationSideEffects else { +#if DEBUG + cmuxDebugLog( + "notification.store.sideEffects.skip workspace=\(notification.tabId.uuidString.prefix(8)) surface=\(notification.surfaceId?.uuidString.prefix(8) ?? "nil") reason=muted" + ) +#endif + return + } deliverNotificationSideEffects( notification, shouldSuppressExternalDelivery: shouldSuppressExternalDelivery, @@ -1147,6 +1135,7 @@ final class TerminalNotificationStore: ObservableObject { private func recordNotification( _ notification: TerminalNotification, shouldSuppressExternalDelivery: Bool, + shouldMuteNotificationSideEffects: Bool, effects: TerminalNotificationPolicyEffects, now: Date, cooldownReservation: NotificationCooldownReservation? @@ -1164,11 +1153,12 @@ final class TerminalNotificationStore: ObservableObject { focusedReadIndicatorByTabId.removeValue(forKey: notification.tabId) } - if shouldSuppressExternalDelivery, effects.markUnread { + if shouldSuppressExternalDelivery, !shouldMuteNotificationSideEffects, effects.markUnread { setFocusedReadIndicator(forTabId: notification.tabId, surfaceId: notification.surfaceId) } if effects.reorderWorkspace, + !shouldMuteNotificationSideEffects, UserDefaultsSettingsClient(defaults: .standard).value(for: SettingCatalog().app.reorderOnNotification) { AppDelegate.shared?.tabManagerFor(tabId: notification.tabId)? .moveTabToTopForNotification(notification.tabId) @@ -1229,6 +1219,14 @@ final class TerminalNotificationStore: ObservableObject { ) } } + guard !shouldMuteNotificationSideEffects else { +#if DEBUG + cmuxDebugLog( + "notification.store.sideEffects.skip workspace=\(notification.tabId.uuidString.prefix(8)) surface=\(notification.surfaceId?.uuidString.prefix(8) ?? "nil") reason=muted" + ) +#endif + return + } deliverNotificationSideEffects( notification, shouldSuppressExternalDelivery: shouldSuppressExternalDelivery, @@ -2133,6 +2131,7 @@ final class TerminalNotificationStore: ObservableObject { clearWorkspaceRestoredUnread() focusedReadIndicatorByTabId.removeAll() } + #endif private func refreshDockBadge() { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index b78af9532481..1be074abb913 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -276,6 +276,8 @@ 799F1BEF876006EEB8CD1035 /* CMUXMobileCore in Frameworks */ = {isa = PBXBuildFile; productRef = EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */; }; EFB18E3C0000000000000001 /* CMUXMobileCore in Frameworks */ = {isa = PBXBuildFile; productRef = EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */; }; CA1F0A01CA1F0A01CA1F0A01 /* CmuxModalAlertPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA1F0A02CA1F0A02CA1F0A02 /* CmuxModalAlertPresentation.swift */; }; + D7A665800000000000000001 /* CmuxNotificationConfigDefinitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A665800000000000000002 /* CmuxNotificationConfigDefinitions.swift */; }; + D7A66580000000000000000D /* CmuxNotificationMuteConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A66580000000000000000E /* CmuxNotificationMuteConfigTests.swift */; }; E3B7A30000000000000000D3 /* CmuxNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = E3B7A30000000000000000D2 /* CmuxNotifications */; }; C0DE31390000000000000101 /* CMUXOpenCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */; }; E3B7A30000000000000000F3 /* CmuxPanes in Frameworks */ = {isa = PBXBuildFile; productRef = E3B7A30000000000000000F2 /* CmuxPanes */; }; @@ -593,6 +595,8 @@ 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */; }; C06552010000000000000003 /* NotificationBurstCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06552010000000000000004 /* NotificationBurstCoalescer.swift */; }; D7AB00000000000000B021 /* NotificationDismissSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B020 /* NotificationDismissSyncTests.swift */; }; + D7A665800000000000000007 /* NotificationDisplaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A665800000000000000008 /* NotificationDisplaySettings.swift */; }; + D7A665800000000000000003 /* NotificationMuteMenuOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A665800000000000000004 /* NotificationMuteMenuOption.swift */; }; B7F00002 /* NotificationSoundSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F00001 /* NotificationSoundSettings.swift */; }; 4E5F60720000000000000001 /* NotificationSoundSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */; }; A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; }; @@ -855,6 +859,7 @@ F6572000A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572001A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; FBE660ABA1854983B84368C6 /* SystemWideHotkeyShortcutPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65154DD251384EE0A2C8923A /* SystemWideHotkeyShortcutPolicyTests.swift */; }; + D7A665800000000000000009 /* TabItemView+NotificationMuteContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A66580000000000000000A /* TabItemView+NotificationMuteContextMenu.swift */; }; C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */; }; D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */; }; E3309A03 /* TabManager+EqualizeSplits.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A04 /* TabManager+EqualizeSplits.swift */; }; @@ -921,10 +926,12 @@ A5C41103A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C41104A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift */; }; A5A5A507A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5A508A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift */; }; A5A5A505A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5A506A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift */; }; + D7A66580000000000000000B /* TerminalNotificationMuteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A66580000000000000000C /* TerminalNotificationMuteTests.swift */; }; A5001096 /* TerminalNotificationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001097 /* TerminalNotificationPolicy.swift */; }; A5A5A501A1B2C3D4E5F60718 /* TerminalNotificationQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5A502A1B2C3D4E5F60718 /* TerminalNotificationQueue.swift */; }; A5A5A503A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5A504A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift */; }; A5E380700000000000000001 /* TerminalNotificationSocketActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E380700000000000000002 /* TerminalNotificationSocketActionTests.swift */; }; + D7A665800000000000000005 /* TerminalNotificationStore+Mute.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A665800000000000000006 /* TerminalNotificationStore+Mute.swift */; }; 596100000000000000000007 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 596100000000000000000006 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift */; }; A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */; }; @@ -1373,6 +1380,8 @@ 2F0C07000000000000000001 /* CmuxMainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxMainWindow.swift; sourceTree = ""; }; D36090010000000000000006 /* CmuxMainWindowConstrainFrameTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxMainWindowConstrainFrameTests.swift; sourceTree = ""; }; CA1F0A02CA1F0A02CA1F0A02 /* CmuxModalAlertPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxModalAlertPresentation.swift; sourceTree = ""; }; + D7A665800000000000000002 /* CmuxNotificationConfigDefinitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxNotificationConfigDefinitions.swift; sourceTree = ""; }; + D7A66580000000000000000E /* CmuxNotificationMuteConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxNotificationMuteConfigTests.swift; sourceTree = ""; }; C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXOpenCommandTests.swift; sourceTree = ""; }; A9F100000000000000000005 /* CmuxRuntimeDebugCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxRuntimeDebugCapture.swift; sourceTree = ""; }; A9F100000000000000000006 /* CmuxRuntimeDebugCaptureConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxRuntimeDebugCaptureConfiguration.swift; sourceTree = ""; }; @@ -1662,6 +1671,8 @@ D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAndMenuBarTests.swift; sourceTree = ""; }; C06552010000000000000004 /* NotificationBurstCoalescer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationBurstCoalescer.swift; sourceTree = ""; }; D7AB00000000000000B020 /* NotificationDismissSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationDismissSyncTests.swift"; sourceTree = ""; }; + D7A665800000000000000008 /* NotificationDisplaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDisplaySettings.swift; sourceTree = ""; }; + D7A665800000000000000004 /* NotificationMuteMenuOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationMuteMenuOption.swift; sourceTree = ""; }; B7F00001 /* NotificationSoundSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundSettings.swift; sourceTree = ""; }; 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundSettingsTests.swift; sourceTree = ""; }; A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = ""; }; @@ -1911,6 +1922,7 @@ F6572001A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift"; sourceTree = ""; }; A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = ""; }; 65154DD251384EE0A2C8923A /* SystemWideHotkeyShortcutPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemWideHotkeyShortcutPolicyTests.swift; sourceTree = ""; }; + D7A66580000000000000000A /* TabItemView+NotificationMuteContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabItemView+NotificationMuteContextMenu.swift"; sourceTree = ""; }; C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabItemView+WorkspaceGroups.swift"; sourceTree = ""; }; D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+DetachedWorkspace.swift"; sourceTree = ""; }; E3309A04 /* TabManager+EqualizeSplits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+EqualizeSplits.swift"; sourceTree = ""; }; @@ -1977,10 +1989,12 @@ A5C41104A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationCallerTests.swift; sourceTree = ""; }; A5A5A508A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationClearAllTests.swift; sourceTree = ""; }; A5A5A506A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationDirectInteractionTests.swift; sourceTree = ""; }; + D7A66580000000000000000C /* TerminalNotificationMuteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationMuteTests.swift; sourceTree = ""; }; A5001097 /* TerminalNotificationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationPolicy.swift; sourceTree = ""; }; A5A5A502A1B2C3D4E5F60718 /* TerminalNotificationQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationQueue.swift; sourceTree = ""; }; A5A5A504A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationQueueTests.swift; sourceTree = ""; }; A5E380700000000000000002 /* TerminalNotificationSocketActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationSocketActionTests.swift; sourceTree = ""; }; + D7A665800000000000000006 /* TerminalNotificationStore+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationStore+Mute.swift"; sourceTree = ""; }; 596100000000000000000006 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationStore+NativeNotificationDeliveryTesting.swift"; sourceTree = ""; }; A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = ""; }; D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneDropTargetView.swift; sourceTree = ""; }; @@ -2448,6 +2462,7 @@ D5037010000000000000002 /* RenderableSystemSymbol.swift */, C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */, C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */, + D7A66580000000000000000A /* TabItemView+NotificationMuteContextMenu.swift */, C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */, C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */, C9A57204C9A57204C9A57204 /* WorkspaceGroupMenuSnapshot.swift */, @@ -2688,10 +2703,13 @@ B37A00000000000000000006 /* MainWindowFocusTypes.swift */, 2F0C05000000000000000001 /* MainWindowFocusController.swift */, A5001091 /* NotificationsPage.swift */, + D7A665800000000000000008 /* NotificationDisplaySettings.swift */, + D7A665800000000000000004 /* NotificationMuteMenuOption.swift */, D1F0A00200000000000000B2 /* MacPresenceMonitor.swift */, D1F0A00500000000000000E2 /* MacPresenceDecisionCache.swift */, D1F0A00600000000000000F2 /* ScreenLockObserver.swift */, B7F00001 /* NotificationSoundSettings.swift */, + D7A665800000000000000006 /* TerminalNotificationStore+Mute.swift */, A5001092 /* TerminalNotificationStore.swift */, 596100000000000000000004 /* NativeNotificationDeliveryHooks.swift */, D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */, @@ -2879,6 +2897,7 @@ A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */, A5001641 /* RemoteRelayZshBootstrap.swift */, A5001651 /* CmuxConfig.swift */, + D7A665800000000000000002 /* CmuxNotificationConfigDefinitions.swift */, C10D00040000000000000004 /* CmuxSurfaceTabBarBuiltInAction.swift */, C57B00030000000000000002 /* Workspace+CustomSidebarPane.swift */, C0DEF0A10000000000000002 /* CmuxConfigUI.swift */, @@ -3254,6 +3273,7 @@ E7E000000000000000000004 /* CmuxEventBusTests.swift */, A5A5A504A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift */, A5A5A508A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift */, + D7A66580000000000000000C /* TerminalNotificationMuteTests.swift */, A5E380700000000000000002 /* TerminalNotificationSocketActionTests.swift */, A5A5A506A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift */, A5C41104A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift */, @@ -3279,6 +3299,7 @@ A5E01204A1B2C3D4E5F60718 /* OpenCodeHookRegressionTests.swift */, 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */, C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */, + D7A66580000000000000000E /* CmuxNotificationMuteConfigTests.swift */, C0DEF0A40000000000000002 /* CmuxConfigContextMenuTests.swift */, E30750000000000000000005 /* CmuxConfigNamedColorTests.swift */, FE002001 /* FileExplorerRootResolverTests.swift */, @@ -3840,6 +3861,7 @@ E7E00000000000000000000B /* CmuxLifecycleEventPublishing.swift in Sources */, 2F0C07000000000000000002 /* CmuxMainWindow.swift in Sources */, CA1F0A01CA1F0A01CA1F0A01 /* CmuxModalAlertPresentation.swift in Sources */, + D7A665800000000000000001 /* CmuxNotificationConfigDefinitions.swift in Sources */, A9F200000000000000000005 /* CmuxRuntimeDebugCapture.swift in Sources */, A9F200000000000000000006 /* CmuxRuntimeDebugCaptureConfiguration.swift in Sources */, A9F20000000000000000001A /* CmuxRuntimeDebugCaptureSender.swift in Sources */, @@ -4027,6 +4049,8 @@ 00C3AA443F6DA8D94E28CE17 /* MobileWorkspaceListObserver.swift in Sources */, 596100000000000000000003 /* NativeNotificationDeliveryHooks.swift in Sources */, C06552010000000000000003 /* NotificationBurstCoalescer.swift in Sources */, + D7A665800000000000000007 /* NotificationDisplaySettings.swift in Sources */, + D7A665800000000000000003 /* NotificationMuteMenuOption.swift in Sources */, B7F00002 /* NotificationSoundSettings.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */, D36090020000000000000001 /* NSWindow+CmuxPeerWindow.swift in Sources */, @@ -4180,6 +4204,7 @@ D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */, F6572000A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift in Sources */, A5001303 /* SurfaceSearchOverlay.swift in Sources */, + D7A665800000000000000009 /* TabItemView+NotificationMuteContextMenu.swift in Sources */, C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */, D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */, E3309A03 /* TabManager+EqualizeSplits.swift in Sources */, @@ -4232,6 +4257,7 @@ A5C41101A1B2C3D4E5F60718 /* TerminalNotificationCallerResolver.swift in Sources */, A5001096 /* TerminalNotificationPolicy.swift in Sources */, A5A5A501A1B2C3D4E5F60718 /* TerminalNotificationQueue.swift in Sources */, + D7A665800000000000000005 /* TerminalNotificationStore+Mute.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */, D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */, A5001401 /* TerminalPanel.swift in Sources */, @@ -4505,6 +4531,7 @@ C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */, E7E000000000000000000003 /* CmuxEventBusTests.swift in Sources */, D36090010000000000000005 /* CmuxMainWindowConstrainFrameTests.swift in Sources */, + D7A66580000000000000000D /* CmuxNotificationMuteConfigTests.swift in Sources */, C0DE31390000000000000101 /* CMUXOpenCommandTests.swift in Sources */, C3677001000000000000001 /* CmuxSSHURLRequestTests.swift in Sources */, C7A50C000000000000000002 /* CmuxTopProcessCPUTests.swift in Sources */, @@ -4680,6 +4707,7 @@ A5C41103A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift in Sources */, A5A5A507A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift in Sources */, A5A5A505A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift in Sources */, + D7A66580000000000000000B /* TerminalNotificationMuteTests.swift in Sources */, A5A5A503A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift in Sources */, A5E380700000000000000001 /* TerminalNotificationSocketActionTests.swift in Sources */, 596100000000000000000007 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift in Sources */, diff --git a/cmuxTests/CmuxNotificationMuteConfigTests.swift b/cmuxTests/CmuxNotificationMuteConfigTests.swift new file mode 100644 index 000000000000..4f77faa32203 --- /dev/null +++ b/cmuxTests/CmuxNotificationMuteConfigTests.swift @@ -0,0 +1,58 @@ +import Foundation +@_implementationOnly import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class CmuxNotificationMuteConfigTests: XCTestCase { + private func decode(_ json: String) throws -> CmuxConfigFile { + let data = json.data(using: .utf8)! + return try JSONDecoder().decode(CmuxConfigFile.self, from: data) + } + + func testDecodeNotificationMuteDurations() throws { + let json = """ + { + "notifications": { + "muteDurations": [ + { "label": "30 Minutes", "minutes": 30 }, + { "label": "2.5 Hours", "hours": 2, "minutes": 30 } + ] + } + } + """ + let config = try decode(json) + let durations = try XCTUnwrap(config.notifications?.muteDurations) + XCTAssertEqual(durations.map(\.label), ["30 Minutes", "2.5 Hours"]) + XCTAssertEqual(durations.map(\.interval), [1_800.0, 9_000.0]) + } + + func testDecodeNotificationMuteDurationsRejectBlankLabel() { + let json = """ + { + "notifications": { + "muteDurations": [ + { "label": " ", "minutes": 30 } + ] + } + } + """ + XCTAssertThrowsError(try decode(json)) + } + + func testDecodeNotificationMuteDurationsRejectZeroDuration() { + let json = """ + { + "notifications": { + "muteDurations": [ + { "label": "Zero", "minutes": 0 } + ] + } + } + """ + XCTAssertThrowsError(try decode(json)) + } +} diff --git a/cmuxTests/TerminalNotificationMuteTests.swift b/cmuxTests/TerminalNotificationMuteTests.swift new file mode 100644 index 000000000000..ef41a4803526 --- /dev/null +++ b/cmuxTests/TerminalNotificationMuteTests.swift @@ -0,0 +1,158 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TerminalNotificationMuteTests: XCTestCase { + func testMutedWorkspaceRecordsUnreadNotificationWithoutSideEffects() throws { + let store = TerminalNotificationStore.shared + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + var deliveredCount = 0 + var suppressedFeedbackCount = 0 + store.replaceNotificationsForTesting([]) + store.clearNotificationMutesForTesting() + store.configureNotificationDeliveryHandlerForTesting { _, _ in + deliveredCount += 1 + } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in + suppressedFeedbackCount += 1 + } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = false + + let workspace = manager.addWorkspace(select: true) + defer { + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + store.replaceNotificationsForTesting([]) + store.clearNotificationMutesForTesting() + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + let focusedPanelId = try XCTUnwrap(workspace.focusedPanelId) + store.muteNotifications( + forTabIds: [workspace.id], + until: Date().addingTimeInterval(60) + ) + + store.addNotification( + tabId: workspace.id, + surfaceId: focusedPanelId, + title: "Muted", + subtitle: "Workspace", + body: "Body" + ) + + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: focusedPanelId)) + XCTAssertEqual(deliveredCount, 0) + XCTAssertEqual(suppressedFeedbackCount, 0) + } + + func testWorkspaceMuteUntilUnmutedStaysActivePastTimedDurations() { + let store = TerminalNotificationStore.shared + let workspaceId = UUID() + let now = Date() + store.clearNotificationMutesForTesting() + defer { store.clearNotificationMutesForTesting() } + + store.muteNotifications( + forTabIds: [workspaceId], + until: NotificationMuteMenuOption.untilUnmuted.expiration(from: now) + ) + + XCTAssertNotNil( + store.activeWorkspaceNotificationMuteExpiration( + forTabId: workspaceId, + now: now.addingTimeInterval(365 * 24 * 60 * 60) + ) + ) + + store.unmuteNotifications(forTabIds: [workspaceId]) + + XCTAssertNil(store.activeWorkspaceNotificationMuteExpiration(forTabId: workspaceId, now: now)) + } + + func testMutedSurfaceDoesNotMuteSiblingSurface() throws { + let store = TerminalNotificationStore.shared + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + var deliveredSurfaceIds: [UUID?] = [] + var suppressedFeedbackCount = 0 + store.replaceNotificationsForTesting([]) + store.clearNotificationMutesForTesting() + store.configureNotificationDeliveryHandlerForTesting { _, notification in + deliveredSurfaceIds.append(notification.surfaceId) + } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in + suppressedFeedbackCount += 1 + } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = false + + let workspace = manager.addWorkspace(select: true) + defer { + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + store.replaceNotificationsForTesting([]) + store.clearNotificationMutesForTesting() + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) + let secondPanel = try XCTUnwrap( + workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal) + ) + store.muteNotifications( + forTabId: workspace.id, + surfaceId: firstPanelId, + until: Date().addingTimeInterval(60) + ) + + store.addNotification( + tabId: workspace.id, + surfaceId: firstPanelId, + title: "Muted", + subtitle: "Surface", + body: "Body" + ) + store.addNotification( + tabId: workspace.id, + surfaceId: secondPanel.id, + title: "Delivered", + subtitle: "Surface", + body: "Body" + ) + + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: firstPanelId)) + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: secondPanel.id)) + XCTAssertEqual(deliveredSurfaceIds, [secondPanel.id]) + XCTAssertEqual(suppressedFeedbackCount, 0) + } +} diff --git a/docs/notifications.md b/docs/notifications.md index 8240448e2976..8bfda444a66a 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -109,6 +109,26 @@ Hook input and output use this shape: Global hooks from `~/.config/cmux/cmux.json` run first. Project hooks from parent directories to the current workspace append after that. Project hooks use the same trust prompt as other project `cmux.json` commands before they run. Feed approval banners also pass through these hooks; disabling `desktop` suppresses the native banner while keeping the Feed item available in cmux. Set `"hooksMode": "replace"` in a project `notifications` section to ignore inherited hooks. If any hook fails, times out, or returns invalid JSON, cmux uses the default notification behavior and posts a hook failure alert. +## Notification Mute Durations + +Right-click a workspace or terminal tab to mute notification side effects. Muted notifications still appear as unread in cmux, but do not play sounds, run commands, forward to mobile, show native banners, flash panes, or reorder workspaces. The permanent `Until Unmuted` option is always shown. + +Customize the timed choices in `cmux.json`: + +```json +{ + "notifications": { + "muteDurations": [ + { "label": "30 Minutes", "minutes": 30 }, + { "label": "2 Hours", "hours": 2 }, + { "label": "Workday", "hours": 8 } + ] + } +} +``` + +Each item needs a non-empty `label` and at least one positive `seconds`, `minutes`, or `hours` value. Project `cmux.json` values replace the global timed choices for that project. + ## Integration Examples ### Claude Code diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index cfbe5d697038..dfe2d1f50162 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -630,6 +630,67 @@ "default": "", "description": "Optional shell command to run alongside notification delivery." }, + "muteDurations": { + "type": "array", + "default": [ + { + "label": "15 Minutes", + "minutes": 15 + }, + { + "label": "1 Hour", + "hours": 1 + }, + { + "label": "4 Hours", + "hours": 4 + }, + { + "label": "8 Hours", + "hours": 8 + } + ], + "description": "Timed choices shown in right-click notification mute menus. The Until Unmuted option is always shown.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["label"], + "anyOf": [ + { + "required": ["seconds"] + }, + { + "required": ["minutes"] + }, + { + "required": ["hours"] + } + ], + "properties": { + "label": { + "type": "string", + "minLength": 1, + "pattern": ".*\\S.*", + "description": "Menu label for this timed mute choice." + }, + "seconds": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Seconds to mute notifications." + }, + "minutes": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Minutes to mute notifications." + }, + "hours": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Hours to mute notifications." + } + } + } + }, "hooksMode": { "type": "string", "enum": ["append", "replace"],