Pārlūkot izejas kodu

Fix meetings list hitting Zoom rate limits after initial load.

Coalesce refresh requests, lengthen polling cooldowns, preserve cached meetings on transient errors, and skip API calls while rate limited.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 mēneši atpakaļ
vecāks
revīzija
5aa9ec947f
1 mainītis faili ar 113 papildinājumiem un 34 dzēšanām
  1. 113 34
      zoom_app/ViewController.swift

+ 113 - 34
zoom_app/ViewController.swift

@@ -521,16 +521,20 @@ class ViewController: NSViewController {
     private var isSigningIn = false
     private var isSigningIn = false
     private var isPromptingZoomCredentials = false
     private var isPromptingZoomCredentials = false
     private var isLoadingMeetings = false
     private var isLoadingMeetings = false
+    private var meetingsLoadTask: Task<Void, Never>?
+    private var hasBuiltInitialHomeView = false
     private var meetingsScrollObserver: NSObjectProtocol?
     private var meetingsScrollObserver: NSObjectProtocol?
     private var meetingsPageScrollObserver: NSObjectProtocol?
     private var meetingsPageScrollObserver: NSObjectProtocol?
     private var lastMeetingsRefreshAt = Date.distantPast
     private var lastMeetingsRefreshAt = Date.distantPast
     private var lastScrollEdgeRefreshAt = Date.distantPast
     private var lastScrollEdgeRefreshAt = Date.distantPast
     private var lastMeetingsPageAutoLoadAt = Date.distantPast
     private var lastMeetingsPageAutoLoadAt = Date.distantPast
     // Keep this conservative to avoid Zoom API rate limits.
     // Keep this conservative to avoid Zoom API rate limits.
-    private let meetingsRefreshInterval: TimeInterval = 60
-    private let scrollRefreshCooldown: TimeInterval = 3
+    private let meetingsRefreshInterval: TimeInterval = 120
+    private let scrollRefreshCooldown: TimeInterval = 30
     private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
     private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
+    private let maxMeetingListPages = 5
     private var meetingsRateLimitedUntil: Date?
     private var meetingsRateLimitedUntil: Date?
+    private static let zoomAccountTimeZoneDefaultsKey = "zoomAccountTimeZoneIdentifier"
     private var zoomAccountTimeZone: TimeZone?
     private var zoomAccountTimeZone: TimeZone?
     private var meetingTimeZoneOverrides: [String: String] = [:]
     private var meetingTimeZoneOverrides: [String: String] = [:]
     private var meetingsPageFilteredMeetings: [ScheduledMeeting] = []
     private var meetingsPageFilteredMeetings: [ScheduledMeeting] = []
@@ -785,6 +789,10 @@ class ViewController: NSViewController {
         }
         }
         setupUI()
         setupUI()
         setupWidgetBridge()
         setupWidgetBridge()
+        if let tzId = UserDefaults.standard.string(forKey: Self.zoomAccountTimeZoneDefaultsKey),
+           let tz = TimeZone(identifier: tzId) {
+            zoomAccountTimeZone = tz
+        }
         startStoreKit()
         startStoreKit()
     }
     }
 
 
@@ -803,7 +811,12 @@ class ViewController: NSViewController {
             installWindowFullScreenObservers(for: window)
             installWindowFullScreenObservers(for: window)
         }
         }
         alignNativeTrafficLights()
         alignNativeTrafficLights()
-        showHomeView(profile: nil)
+        if hasBuiltInitialHomeView == false {
+            hasBuiltInitialHomeView = true
+            showHomeView(profile: nil, refreshMeetings: true)
+        } else if isUserLoggedIn(), storeKitCoordinator.hasPremiumAccess {
+            triggerMeetingsRefresh(force: false)
+        }
         syncBrandPositionForCurrentWindowState(animated: false)
         syncBrandPositionForCurrentWindowState(animated: false)
         scheduleLaunchPaywallIfNeeded()
         scheduleLaunchPaywallIfNeeded()
         if storeKitCoordinator.hasPremiumAccess {
         if storeKitCoordinator.hasPremiumAccess {
@@ -880,7 +893,7 @@ class ViewController: NSViewController {
         }
         }
     }
     }
 
 
-    private func showHomeView(profile: GoogleUserProfile?) {
+    private func showHomeView(profile: GoogleUserProfile?, refreshMeetings: Bool = true) {
         clearMeetingsScrollObserver()
         clearMeetingsScrollObserver()
         clearMeetingsPageScrollObserver()
         clearMeetingsPageScrollObserver()
         removeSearchFieldObserver()
         removeSearchFieldObserver()
@@ -928,7 +941,7 @@ class ViewController: NSViewController {
         if let homeView { attachToRoot(homeView) }
         if let homeView { attachToRoot(homeView) }
         installSearchShortcutMonitor()
         installSearchShortcutMonitor()
         startClock()
         startClock()
-        applyAuthenticationUIState(triggerInitialRefresh: true)
+        applyAuthenticationUIState(triggerInitialRefresh: refreshMeetings)
         updateHomeSidebarHighlight()
         updateHomeSidebarHighlight()
         updateSelectedHomeSectionUI()
         updateSelectedHomeSectionUI()
     }
     }
@@ -1003,6 +1016,9 @@ class ViewController: NSViewController {
                 await MainActor.run {
                 await MainActor.run {
                     self.resetLoginSigningInState()
                     self.resetLoginSigningInState()
                     self.zoomAccountTimeZone = profileTimeZone
                     self.zoomAccountTimeZone = profileTimeZone
+                    if let profileTimeZone {
+                        UserDefaults.standard.set(profileTimeZone.identifier, forKey: Self.zoomAccountTimeZoneDefaultsKey)
+                    }
                     self.completeLoginAndRestart(profile: profile)
                     self.completeLoginAndRestart(profile: profile)
                 }
                 }
             } catch {
             } catch {
@@ -2454,6 +2470,10 @@ class ViewController: NSViewController {
     @objc private func logoutTapped() {
     @objc private func logoutTapped() {
         meetingsRefreshTimer?.invalidate()
         meetingsRefreshTimer?.invalidate()
         meetingsRefreshTimer = nil
         meetingsRefreshTimer = nil
+        meetingsLoadTask?.cancel()
+        meetingsLoadTask = nil
+        isLoadingMeetings = false
+        meetingsRateLimitedUntil = nil
         clearMeetingsScrollObserver()
         clearMeetingsScrollObserver()
         googleOAuth.clearSavedTokens()
         googleOAuth.clearSavedTokens()
         zoomOAuth.clearSavedTokens()
         zoomOAuth.clearSavedTokens()
@@ -2631,23 +2651,29 @@ class ViewController: NSViewController {
         meetingsRefreshTimer?.invalidate()
         meetingsRefreshTimer?.invalidate()
         // Poll Zoom meetings periodically so newly scheduled meetings appear automatically.
         // Poll Zoom meetings periodically so newly scheduled meetings appear automatically.
         meetingsRefreshTimer = Timer.scheduledTimer(withTimeInterval: meetingsRefreshInterval, repeats: true) { [weak self] _ in
         meetingsRefreshTimer = Timer.scheduledTimer(withTimeInterval: meetingsRefreshInterval, repeats: true) { [weak self] _ in
-            guard let self else { return }
-            self.triggerMeetingsRefresh()
+            Task { @MainActor [weak self] in
+                self?.triggerMeetingsRefresh()
+            }
         }
         }
     }
     }
 
 
+    @MainActor
     private func triggerMeetingsRefresh(force: Bool = false) {
     private func triggerMeetingsRefresh(force: Bool = false) {
         guard isUserLoggedIn() else { return }
         guard isUserLoggedIn() else { return }
         guard storeKitCoordinator.hasPremiumAccess else { return }
         guard storeKitCoordinator.hasPremiumAccess else { return }
         let now = Date()
         let now = Date()
         if let until = meetingsRateLimitedUntil, now < until {
         if let until = meetingsRateLimitedUntil, now < until {
+            applyMeetingsRateLimitStatusLabels()
             return
             return
         }
         }
         if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
         if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
             return
             return
         }
         }
+        if isLoadingMeetings { return }
         lastMeetingsRefreshAt = now
         lastMeetingsRefreshAt = now
-        Task { await self.loadScheduledMeetings() }
+        isLoadingMeetings = true
+        meetingsLoadTask?.cancel()
+        meetingsLoadTask = Task { await self.loadScheduledMeetings() }
     }
     }
 
 
     private func clearMeetingsScrollObserver() {
     private func clearMeetingsScrollObserver() {
@@ -3057,12 +3083,14 @@ class ViewController: NSViewController {
 
 
         meetingsRefreshTimer?.invalidate()
         meetingsRefreshTimer?.invalidate()
         meetingsRefreshTimer = nil
         meetingsRefreshTimer = nil
-        meetingsRateLimitedUntil = nil
+        if loggedIn == false {
+            meetingsRateLimitedUntil = nil
+        }
         if loggedIn {
         if loggedIn {
             if storeKitCoordinator.hasPremiumAccess {
             if storeKitCoordinator.hasPremiumAccess {
                 startMeetingsAutoRefresh()
                 startMeetingsAutoRefresh()
                 if triggerInitialRefresh {
                 if triggerInitialRefresh {
-                    triggerMeetingsRefresh(force: true)
+                    triggerMeetingsRefresh(force: false)
                 }
                 }
             } else {
             } else {
                 applyMeetings([])
                 applyMeetings([])
@@ -3073,6 +3101,25 @@ class ViewController: NSViewController {
         }
         }
     }
     }
 
 
+    @MainActor
+    private func applyMeetingsRateLimitStatusLabels() {
+        guard let until = meetingsRateLimitedUntil, until > Date() else { return }
+        let minutes = max(1, Int(ceil(until.timeIntervalSinceNow / 60.0)))
+        let message = "Zoom rate limit reached. Please try again in \(minutes) min."
+        meetingsStatusLabel?.stringValue = message
+        meetingsPageStatusLabel?.stringValue = message
+    }
+
+    @MainActor
+    private func persistZoomAccountTimeZone(_ timeZone: TimeZone?) {
+        zoomAccountTimeZone = timeZone
+        if let timeZone {
+            UserDefaults.standard.set(timeZone.identifier, forKey: Self.zoomAccountTimeZoneDefaultsKey)
+        } else {
+            UserDefaults.standard.removeObject(forKey: Self.zoomAccountTimeZoneDefaultsKey)
+        }
+    }
+
     /// Returns false if the user cancelled or left credentials empty.
     /// Returns false if the user cancelled or left credentials empty.
     @MainActor
     @MainActor
     private func ensureZoomOAuthClientConfigured() -> Bool {
     private func ensureZoomOAuthClientConfigured() -> Bool {
@@ -3164,6 +3211,10 @@ class ViewController: NSViewController {
             return
             return
         }
         }
 
 
+        if let until = meetingsRateLimitedUntil, until > Date() {
+            applyMeetingsRateLimitStatusLabels()
+        }
+
         let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
         let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
         let calendar = Calendar.current
         let calendar = Calendar.current
         let dayStart = selectedMeetingsDayStart
         let dayStart = selectedMeetingsDayStart
@@ -3185,12 +3236,15 @@ class ViewController: NSViewController {
         let ordered = filtered.sorted(by: { $0.start < $1.start })
         let ordered = filtered.sorted(by: { $0.start < $1.start })
         if ordered.isEmpty {
         if ordered.isEmpty {
             emptyMeetingLabel?.isHidden = showingHomeSection == false
             emptyMeetingLabel?.isHidden = showingHomeSection == false
-            if source.isEmpty {
-                meetingsStatusLabel?.stringValue = "Upcoming meetings"
-                emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
-            } else {
-                meetingsStatusLabel?.stringValue = "Upcoming meetings"
-                emptyMeetingLabel?.stringValue = "No meetings match your search."
+            let isRateLimited = meetingsRateLimitedUntil.map { $0 > Date() } ?? false
+            if isRateLimited == false {
+                if source.isEmpty {
+                    meetingsStatusLabel?.stringValue = "Upcoming meetings"
+                    emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
+                } else {
+                    meetingsStatusLabel?.stringValue = "Upcoming meetings"
+                    emptyMeetingLabel?.stringValue = "No meetings match your search."
+                }
             }
             }
             applySchedulerMeetingsForSelectedDate()
             applySchedulerMeetingsForSelectedDate()
             renderSchedulerCalendarGrid()
             renderSchedulerCalendarGrid()
@@ -3665,48 +3719,64 @@ class ViewController: NSViewController {
     private func loadScheduledMeetings() async {
     private func loadScheduledMeetings() async {
         guard isUserLoggedIn() else {
         guard isUserLoggedIn() else {
             await MainActor.run {
             await MainActor.run {
+                self.isLoadingMeetings = false
+                self.setMeetingsLoadingUI(false)
                 self.applyMeetings([])
                 self.applyMeetings([])
                 self.applyGuestLoginMessages()
                 self.applyGuestLoginMessages()
             }
             }
             return
             return
         }
         }
-        guard storeKitCoordinator.hasPremiumAccess else {
+        let hasPremium = await MainActor.run { self.storeKitCoordinator.hasPremiumAccess }
+        guard hasPremium else {
             await MainActor.run {
             await MainActor.run {
+                self.isLoadingMeetings = false
+                self.setMeetingsLoadingUI(false)
                 self.applyMeetings([])
                 self.applyMeetings([])
                 self.applyPremiumLockMessages()
                 self.applyPremiumLockMessages()
             }
             }
             return
             return
         }
         }
-        if isLoadingMeetings { return }
-        isLoadingMeetings = true
+        let shouldSkipForRateLimit = await MainActor.run { () -> Bool in
+            if let until = self.meetingsRateLimitedUntil, until > Date() {
+                self.applyMeetingsRateLimitStatusLabels()
+                return true
+            }
+            self.setMeetingsLoadingUI(true)
+            return false
+        }
+        if shouldSkipForRateLimit {
+            await MainActor.run {
+                self.isLoadingMeetings = false
+                self.setMeetingsLoadingUI(false)
+            }
+            return
+        }
         defer {
         defer {
-            isLoadingMeetings = false
             Task { @MainActor in
             Task { @MainActor in
+                self.isLoadingMeetings = false
                 self.setMeetingsLoadingUI(false)
                 self.setMeetingsLoadingUI(false)
             }
             }
         }
         }
-        await MainActor.run {
-            self.setMeetingsLoadingUI(true)
-        }
         do {
         do {
             let zoomToken = try await zoomOAuth.validAccessToken(
             let zoomToken = try await zoomOAuth.validAccessToken(
                 presentingWindow: view.window,
                 presentingWindow: view.window,
                 requiringAnyOf: ["meeting:read:list_meetings"]
                 requiringAnyOf: ["meeting:read:list_meetings"]
             )
             )
-            if zoomAccountTimeZone == nil {
+            let needsProfileTimeZone = await MainActor.run { self.zoomAccountTimeZone == nil }
+            if needsProfileTimeZone {
                 if let profile = try? await fetchZoomUserProfile(accessToken: zoomToken) {
                 if let profile = try? await fetchZoomUserProfile(accessToken: zoomToken) {
                     let profileTZ = parseZoomTimeZone(profile.timezone)
                     let profileTZ = parseZoomTimeZone(profile.timezone)
-                    await MainActor.run { self.zoomAccountTimeZone = profileTZ }
+                    await MainActor.run { self.persistZoomAccountTimeZone(profileTZ) }
                 }
                 }
             }
             }
-            let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken, fallbackTimeZone: zoomAccountTimeZone)
+            let fallbackTZ = await MainActor.run { self.zoomAccountTimeZone }
+            let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken, fallbackTimeZone: fallbackTZ)
             await MainActor.run {
             await MainActor.run {
                 self.meetingsRateLimitedUntil = nil
                 self.meetingsRateLimitedUntil = nil
                 self.applyMeetings(zoomMeetings)
                 self.applyMeetings(zoomMeetings)
             }
             }
         } catch {
         } catch {
             await MainActor.run {
             await MainActor.run {
-                self.applyMeetings([])
                 if case ZoomOAuthError.missingClientId = error {
                 if case ZoomOAuthError.missingClientId = error {
                     self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
                     self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
                     self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
                     self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
@@ -3722,13 +3792,20 @@ class ViewController: NSViewController {
                 } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
                 } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
                     let seconds = max(retryAfterSeconds ?? 300, 30)
                     let seconds = max(retryAfterSeconds ?? 300, 30)
                     self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
                     self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
-                    let minutes = Int(ceil(Double(seconds) / 60.0))
-                    self.meetingsStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
-                    self.meetingsPageStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
+                    self.applyMeetingsRateLimitStatusLabels()
                 } else {
                 } else {
                     self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
                     self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
                     self.meetingsPageStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
                     self.meetingsPageStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
                 }
                 }
+                if case ZoomOAuthError.missingClientId = error {
+                    self.applyMeetings([])
+                } else if case ZoomOAuthError.missingClientSecret = error {
+                    self.applyMeetings([])
+                } else if case ZoomOAuthError.missingRequiredScope = error {
+                    self.applyMeetings([])
+                }
+                self.applyFilteredMeetings()
+                self.applyMeetingsPageFilters(resetVisible: false)
             }
             }
         }
         }
     }
     }
@@ -3784,7 +3861,7 @@ class ViewController: NSViewController {
 
 
         if presentZoomOAuthCredentialPrompt() {
         if presentZoomOAuthCredentialPrompt() {
             meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
             meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
-            Task { await self.loadScheduledMeetings() }
+            triggerMeetingsRefresh(force: true)
         }
         }
     }
     }
 
 
@@ -3976,7 +4053,9 @@ class ViewController: NSViewController {
 
 
         var allMeetings: [ZoomMeeting] = []
         var allMeetings: [ZoomMeeting] = []
         var nextPageToken: String?
         var nextPageToken: String?
+        var pageCount = 0
         repeat {
         repeat {
+            pageCount += 1
             var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
             var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
             var items: [URLQueryItem] = [
             var items: [URLQueryItem] = [
                 URLQueryItem(name: "type", value: "scheduled"),
                 URLQueryItem(name: "type", value: "scheduled"),
@@ -4008,7 +4087,7 @@ class ViewController: NSViewController {
             allMeetings.append(contentsOf: decoded.meetings)
             allMeetings.append(contentsOf: decoded.meetings)
             let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
             let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
             nextPageToken = (token?.isEmpty == false) ? token : nil
             nextPageToken = (token?.isEmpty == false) ? token : nil
-        } while nextPageToken != nil
+        } while nextPageToken != nil && pageCount < maxMeetingListPages
 
 
         return mapMeetings(allMeetings)
         return mapMeetings(allMeetings)
     }
     }
@@ -4899,7 +4978,7 @@ class ViewController: NSViewController {
                 self.reconcileDesktopWidgetsForPremiumAccess()
                 self.reconcileDesktopWidgetsForPremiumAccess()
                 if (wasPremium == false) && newIsPremium {
                 if (wasPremium == false) && newIsPremium {
                     self.startMeetingsAutoRefresh()
                     self.startMeetingsAutoRefresh()
-                    self.triggerMeetingsRefresh(force: true)
+                    self.triggerMeetingsRefresh(force: false)
                     self.schedulePremiumRatingPromptIfNeeded()
                     self.schedulePremiumRatingPromptIfNeeded()
                 }
                 }
                 if wasPremium && !newIsPremium {
                 if wasPremium && !newIsPremium {
@@ -5173,7 +5252,7 @@ class ViewController: NSViewController {
 
 
         let keepSelected = selectedHomeSidebarItem
         let keepSelected = selectedHomeSidebarItem
         selectedHomeSidebarItem = keepSelected
         selectedHomeSidebarItem = keepSelected
-        showHomeView(profile: nil)
+        showHomeView(profile: nil, refreshMeetings: false)
         updatePremiumLockedSections()
         updatePremiumLockedSections()
     }
     }