Browse Source

Add configurable meeting reminder notifications.

Adds a reminders section in Settings with 1d/12h/1h toggles, persists preferences, and schedules/cancels local notifications based on the current meetings list.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 months ago
parent
commit
fdb5398ff4
2 changed files with 358 additions and 0 deletions
  1. 41 0
      zoom_app/AppDelegate.swift
  2. 317 0
      zoom_app/ViewController.swift

+ 41 - 0
zoom_app/AppDelegate.swift

@@ -6,10 +6,13 @@
 //
 
 import Cocoa
+import UserNotifications
 
 @main
 class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
     private let darkModeDefaultsKey = "settings.darkModeEnabled"
+    private let legacyNotificationsEnabledDefaultsKey = "settings.notificationsEnabled"
+    private let remindersEnabledDefaultsKey = "reminders.enabled"
     private let loginStateKey = "zoom_app.isLoggedIn"
     private var statusItem: NSStatusItem?
     private var statusMenu: NSMenu?
@@ -46,6 +49,24 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
         let darkEnabled = systemPrefersDarkMode()
         UserDefaults.standard.set(darkEnabled, forKey: darkModeDefaultsKey)
         NSApp.appearance = NSAppearance(named: darkEnabled ? .darkAqua : .aqua)
+        UNUserNotificationCenter.current().delegate = self
+        let defaults = UserDefaults.standard
+        let remindersEnabled: Bool = {
+            if defaults.object(forKey: remindersEnabledDefaultsKey) != nil {
+                return defaults.bool(forKey: remindersEnabledDefaultsKey)
+            }
+            if defaults.object(forKey: legacyNotificationsEnabledDefaultsKey) != nil {
+                return defaults.bool(forKey: legacyNotificationsEnabledDefaultsKey)
+            }
+            return true
+        }()
+        if remindersEnabled {
+            UNUserNotificationCenter.current().getNotificationSettings { settings in
+                if settings.authorizationStatus == .notDetermined {
+                    UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
+                }
+            }
+        }
         configureStatusItem()
 
         showSplashThenMainWindow()
@@ -270,6 +291,26 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
 
 }
 
+extension AppDelegate: UNUserNotificationCenterDelegate {
+    func userNotificationCenter(
+        _ center: UNUserNotificationCenter,
+        didReceive response: UNNotificationResponse,
+        withCompletionHandler completionHandler: @escaping () -> Void
+    ) {
+        NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
+        NSApp.activate(ignoringOtherApps: true)
+        completionHandler()
+    }
+
+    func userNotificationCenter(
+        _ center: UNUserNotificationCenter,
+        willPresent notification: UNNotification,
+        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+    ) {
+        completionHandler([.banner, .sound])
+    }
+}
+
 private final class SplashViewController: NSViewController {
     private struct Theme {
         let isDark: Bool

+ 317 - 0
zoom_app/ViewController.swift

@@ -10,6 +10,7 @@ import Combine
 import CryptoKit
 import StoreKit
 import SwiftUI
+import UserNotifications
 import WebKit
 
 /// Default main window **content** size; OAuth sign-in uses the same dimensions and aligns to this content area.
@@ -57,6 +58,11 @@ class ViewController: NSViewController {
     private let zoomOAuth = ZoomOAuthService.shared
     private let loginStateKey = "zoom_app.isLoggedIn"
     private let darkModeDefaultsKey = "settings.darkModeEnabled"
+    private let legacyNotificationsEnabledDefaultsKey = "settings.notificationsEnabled"
+    private let remindersEnabledDefaultsKey = "reminders.enabled"
+    private let remind1DayDefaultsKey = "reminders.1day"
+    private let remind12HoursDefaultsKey = "reminders.12hours"
+    private let remind1HourDefaultsKey = "reminders.1hour"
     private let ratingPromptShownKey = "zoom_app.ratingPromptShown"
     private let ratingCompletedKey = "zoom_app.ratingCompleted"
     private let usageSessionStartedAtKey = "zoom_app.usageSessionStartedAt"
@@ -210,6 +216,47 @@ class ViewController: NSViewController {
         }
         set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
     }
+    private var remindersEnabled: Bool {
+        get {
+            let defaults = UserDefaults.standard
+            if defaults.object(forKey: remindersEnabledDefaultsKey) != nil {
+                return defaults.bool(forKey: remindersEnabledDefaultsKey)
+            }
+            // Migration: if the old single-toggle key exists, treat it as master reminders toggle.
+            if defaults.object(forKey: legacyNotificationsEnabledDefaultsKey) != nil {
+                return defaults.bool(forKey: legacyNotificationsEnabledDefaultsKey)
+            }
+            return true
+        }
+        set { UserDefaults.standard.set(newValue, forKey: remindersEnabledDefaultsKey) }
+    }
+
+    private var remind1Day: Bool {
+        get {
+            let defaults = UserDefaults.standard
+            let stored = defaults.object(forKey: remind1DayDefaultsKey)
+            return stored != nil ? defaults.bool(forKey: remind1DayDefaultsKey) : true
+        }
+        set { UserDefaults.standard.set(newValue, forKey: remind1DayDefaultsKey) }
+    }
+
+    private var remind12Hours: Bool {
+        get {
+            let defaults = UserDefaults.standard
+            let stored = defaults.object(forKey: remind12HoursDefaultsKey)
+            return stored != nil ? defaults.bool(forKey: remind12HoursDefaultsKey) : true
+        }
+        set { UserDefaults.standard.set(newValue, forKey: remind12HoursDefaultsKey) }
+    }
+
+    private var remind1Hour: Bool {
+        get {
+            let defaults = UserDefaults.standard
+            let stored = defaults.object(forKey: remind1HourDefaultsKey)
+            return stored != nil ? defaults.bool(forKey: remind1HourDefaultsKey) : true
+        }
+        set { UserDefaults.standard.set(newValue, forKey: remind1HourDefaultsKey) }
+    }
 
     private let sidebarWidth: CGFloat = 78
     private var appBackground: NSColor { palette.appBackground }
@@ -302,6 +349,13 @@ class ViewController: NSViewController {
     private var schedulerDateByButtonTag: [Int: Date] = [:]
     private var nextSchedulerButtonTag: Int = 1
     private weak var settingsDarkModeSwitch: NSSwitch?
+    private weak var settingsRemindersMasterSwitch: NSSwitch?
+    private weak var settingsReminders1DaySwitch: NSSwitch?
+    private weak var settingsReminders12HoursSwitch: NSSwitch?
+    private weak var settingsReminders1HourSwitch: NSSwitch?
+    private weak var settingsReminders1DayRow: NSView?
+    private weak var settingsReminders12HoursRow: NSView?
+    private weak var settingsReminders1HourRow: NSView?
     private weak var settingsUpgradeButton: NSButton?
     private weak var settingsUpgradeTitleLabel: NSTextField?
     private weak var settingsGoogleActionButton: NSButton?
@@ -2401,6 +2455,7 @@ class ViewController: NSViewController {
         applyFilteredMeetings()
         applyMeetingsPageFilters(resetVisible: true)
         updateWidgetMeetingStoreFromCurrentMeetings()
+        rescheduleMeetingRemindersIfNeeded()
     }
 
     @MainActor
@@ -3338,6 +3393,13 @@ class ViewController: NSViewController {
 
         stack.setCustomSpacing(24, after: upgradeButton)
 
+        let notificationsTitle = textLabel("Notifications", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
+        stack.addArrangedSubview(notificationsTitle)
+        let remindersSection = makeSettingsRemindersSection()
+        stack.addArrangedSubview(remindersSection)
+        remindersSection.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+        stack.setCustomSpacing(24, after: remindersSection)
+
         let legalTitle = textLabel("Help & Legal", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
         stack.addArrangedSubview(legalTitle)
         let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
@@ -3417,6 +3479,115 @@ class ViewController: NSViewController {
         return row
     }
 
+    private func makeSettingsRemindersSection() -> NSView {
+        let container = NSStackView()
+        container.translatesAutoresizingMaskIntoConstraints = false
+        container.orientation = .vertical
+        container.spacing = 1
+        container.alignment = .leading
+
+        let masterRow = makeReminderToggleRow(
+            icon: "🔔",
+            title: "Reminders",
+            state: remindersEnabled,
+            action: #selector(settingsReminderMasterToggled(_:)),
+            isSubRow: false
+        )
+        container.addArrangedSubview(masterRow)
+        masterRow.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
+
+        let subAlpha: CGFloat = remindersEnabled ? 1.0 : 0.4
+
+        let day1Row = makeReminderToggleRow(
+            icon: "📅",
+            title: "1 Day Before",
+            state: remind1Day,
+            action: #selector(settingsReminder1DayToggled(_:)),
+            isSubRow: true
+        )
+        day1Row.alphaValue = subAlpha
+        container.addArrangedSubview(day1Row)
+        day1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
+
+        let hours12Row = makeReminderToggleRow(
+            icon: "🕛",
+            title: "12 Hours Before",
+            state: remind12Hours,
+            action: #selector(settingsReminder12HoursToggled(_:)),
+            isSubRow: true
+        )
+        hours12Row.alphaValue = subAlpha
+        container.addArrangedSubview(hours12Row)
+        hours12Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
+
+        let hour1Row = makeReminderToggleRow(
+            icon: "⏰",
+            title: "1 Hour Before",
+            state: remind1Hour,
+            action: #selector(settingsReminder1HourToggled(_:)),
+            isSubRow: true
+        )
+        hour1Row.alphaValue = subAlpha
+        container.addArrangedSubview(hour1Row)
+        hour1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
+
+        // Keep references so toggles can update UI without rebuilding the whole settings view.
+        if let masterSwitch = (masterRow.subviews.compactMap { $0 as? NSSwitch }.first) {
+            settingsRemindersMasterSwitch = masterSwitch
+        }
+        if let day1Switch = (day1Row.subviews.compactMap { $0 as? NSSwitch }.first) {
+            settingsReminders1DaySwitch = day1Switch
+        }
+        if let hours12Switch = (hours12Row.subviews.compactMap { $0 as? NSSwitch }.first) {
+            settingsReminders12HoursSwitch = hours12Switch
+        }
+        if let hour1Switch = (hour1Row.subviews.compactMap { $0 as? NSSwitch }.first) {
+            settingsReminders1HourSwitch = hour1Switch
+        }
+        settingsReminders1DayRow = day1Row
+        settingsReminders12HoursRow = hours12Row
+        settingsReminders1HourRow = hour1Row
+
+        return container
+    }
+
+    private func makeReminderToggleRow(
+        icon: String,
+        title: String,
+        state: Bool,
+        action: Selector,
+        isSubRow: Bool
+    ) -> NSView {
+        let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
+        row.translatesAutoresizingMaskIntoConstraints = false
+        row.heightAnchor.constraint(equalToConstant: 52).isActive = true
+        styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+
+        let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 18, weight: .medium), color: primaryText)
+        let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: isSubRow ? .medium : .semibold), color: primaryText)
+
+        let toggle = NSSwitch()
+        toggle.translatesAutoresizingMaskIntoConstraints = false
+        toggle.state = state ? .on : .off
+        toggle.target = self
+        toggle.action = action
+
+        row.addSubview(iconLabel)
+        row.addSubview(titleLabel)
+        row.addSubview(toggle)
+
+        let leading: CGFloat = isSubRow ? 26 : 14
+        NSLayoutConstraint.activate([
+            iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: leading),
+            iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
+            titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
+            titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
+            toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
+            toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
+        ])
+        return row
+    }
+
     private func makeSettingsGoogleAccountRow() -> NSView {
         let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
         row.translatesAutoresizingMaskIntoConstraints = false
@@ -3691,6 +3862,152 @@ class ViewController: NSViewController {
         setDarkMode(sender.state == .on)
     }
 
+    @objc private func settingsReminderMasterToggled(_ sender: NSSwitch) {
+        let enabled = sender.state == .on
+        remindersEnabled = enabled
+        updateRemindersSubRowState()
+
+        if enabled {
+            requestNotificationPermissionIfNeeded { [weak self] granted in
+                guard let self else { return }
+                if granted {
+                    self.rescheduleMeetingRemindersIfNeeded()
+                } else {
+                    self.remindersEnabled = false
+                    self.settingsRemindersMasterSwitch?.state = .off
+                    self.updateRemindersSubRowState()
+                    self.cancelAllMeetingReminders()
+                    self.showSimpleAlert(
+                        title: "Notifications disabled",
+                        message: "To enable reminders, allow notifications for this app in System Settings → Notifications."
+                    )
+                }
+            }
+        } else {
+            cancelAllMeetingReminders()
+        }
+    }
+
+    @objc private func settingsReminder1DayToggled(_ sender: NSSwitch) {
+        remind1Day = sender.state == .on
+        rescheduleMeetingRemindersIfNeeded()
+    }
+
+    @objc private func settingsReminder12HoursToggled(_ sender: NSSwitch) {
+        remind12Hours = sender.state == .on
+        rescheduleMeetingRemindersIfNeeded()
+    }
+
+    @objc private func settingsReminder1HourToggled(_ sender: NSSwitch) {
+        remind1Hour = sender.state == .on
+        rescheduleMeetingRemindersIfNeeded()
+    }
+
+    private func updateRemindersSubRowState() {
+        let subAlpha: CGFloat = remindersEnabled ? 1.0 : 0.4
+        settingsReminders1DayRow?.alphaValue = subAlpha
+        settingsReminders12HoursRow?.alphaValue = subAlpha
+        settingsReminders1HourRow?.alphaValue = subAlpha
+
+        let enabled = remindersEnabled
+        settingsReminders1DaySwitch?.isEnabled = enabled
+        settingsReminders12HoursSwitch?.isEnabled = enabled
+        settingsReminders1HourSwitch?.isEnabled = enabled
+    }
+
+    private func requestNotificationPermissionIfNeeded(completion: @escaping (Bool) -> Void) {
+        let center = UNUserNotificationCenter.current()
+        center.getNotificationSettings { settings in
+            switch settings.authorizationStatus {
+            case .notDetermined:
+                center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
+                    DispatchQueue.main.async { completion(granted) }
+                }
+            case .authorized, .provisional, .ephemeral:
+                DispatchQueue.main.async { completion(true) }
+            default:
+                DispatchQueue.main.async { completion(false) }
+            }
+        }
+    }
+
+    private func rescheduleMeetingRemindersIfNeeded() {
+        guard remindersEnabled else {
+            cancelAllMeetingReminders()
+            return
+        }
+        // If nothing is selected, don't schedule anything (but keep master toggle on).
+        if remind1Day == false, remind12Hours == false, remind1Hour == false {
+            cancelAllMeetingReminders()
+            return
+        }
+        scheduleMeetingReminders(for: allScheduledMeetings)
+    }
+
+    private func cancelAllMeetingReminders() {
+        UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
+    }
+
+    private func scheduleMeetingReminders(for meetings: [ScheduledMeeting]) {
+        let center = UNUserNotificationCenter.current()
+        let now = Date()
+        struct Interval {
+            let offset: TimeInterval
+            let label: String
+            let suffix: String
+            let isEnabled: () -> Bool
+        }
+        let intervals: [Interval] = [
+            Interval(offset: 60 * 60 * 24, label: "Starts in 1 day", suffix: "1d", isEnabled: { self.remind1Day }),
+            Interval(offset: 60 * 60 * 12, label: "Starts in 12 hours", suffix: "12h", isEnabled: { self.remind12Hours }),
+            Interval(offset: 60 * 60, label: "Starts in 1 hour", suffix: "1h", isEnabled: { self.remind1Hour })
+        ]
+
+        var requests: [UNNotificationRequest] = []
+        for meeting in meetings {
+            let title = meeting.title.trimmingCharacters(in: .whitespacesAndNewlines)
+            guard title.isEmpty == false else { continue }
+
+            for interval in intervals where interval.isEnabled() {
+                let triggerDate = meeting.start.addingTimeInterval(-interval.offset)
+                guard triggerDate > now else { continue }
+
+                let content = UNMutableNotificationContent()
+                content.title = "📅 \(title)"
+                let subtitle = meeting.host.trimmingCharacters(in: .whitespacesAndNewlines)
+                content.subtitle = subtitle.isEmpty ? meeting.source : subtitle
+                content.body = interval.label
+                content.sound = .default
+
+                let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: triggerDate)
+                let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
+                let id = "zoom.meeting.reminder.\(stableReminderKey(for: meeting)).\(interval.suffix)"
+                requests.append(UNNotificationRequest(identifier: id, content: content, trigger: trigger))
+            }
+        }
+
+        center.removeAllPendingNotificationRequests()
+        for request in requests {
+            center.add(request, withCompletionHandler: nil)
+        }
+    }
+
+    private func stableReminderKey(for meeting: ScheduledMeeting) -> String {
+        if let id = meeting.meetingID?.trimmingCharacters(in: .whitespacesAndNewlines), id.isEmpty == false {
+            return id
+        }
+        if let url = meeting.webURL?.absoluteString, url.isEmpty == false {
+            return sha256Prefix(url)
+        }
+        return sha256Prefix("\(meeting.title)|\(meeting.start.timeIntervalSince1970)")
+    }
+
+    private func sha256Prefix(_ input: String) -> String {
+        let digest = SHA256.hash(data: Data(input.utf8))
+        let hex = digest.compactMap { String(format: "%02x", $0) }.joined()
+        return String(hex.prefix(24))
+    }
+
     private func setDarkMode(_ enabled: Bool) {
         darkModeEnabled = enabled
         palette = Palette(isDarkMode: enabled)