瀏覽代碼

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 月之前
父節點
當前提交
5aa9ec947f
共有 1 個文件被更改,包括 113 次插入34 次删除
  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 isPromptingZoomCredentials = false
     private var isLoadingMeetings = false
+    private var meetingsLoadTask: Task<Void, Never>?
+    private var hasBuiltInitialHomeView = false
     private var meetingsScrollObserver: NSObjectProtocol?
     private var meetingsPageScrollObserver: NSObjectProtocol?
     private var lastMeetingsRefreshAt = Date.distantPast
     private var lastScrollEdgeRefreshAt = Date.distantPast
     private var lastMeetingsPageAutoLoadAt = Date.distantPast
     // 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 maxMeetingListPages = 5
     private var meetingsRateLimitedUntil: Date?
+    private static let zoomAccountTimeZoneDefaultsKey = "zoomAccountTimeZoneIdentifier"
     private var zoomAccountTimeZone: TimeZone?
     private var meetingTimeZoneOverrides: [String: String] = [:]
     private var meetingsPageFilteredMeetings: [ScheduledMeeting] = []
@@ -785,6 +789,10 @@ class ViewController: NSViewController {
         }
         setupUI()
         setupWidgetBridge()
+        if let tzId = UserDefaults.standard.string(forKey: Self.zoomAccountTimeZoneDefaultsKey),
+           let tz = TimeZone(identifier: tzId) {
+            zoomAccountTimeZone = tz
+        }
         startStoreKit()
     }
 
@@ -803,7 +811,12 @@ class ViewController: NSViewController {
             installWindowFullScreenObservers(for: window)
         }
         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)
         scheduleLaunchPaywallIfNeeded()
         if storeKitCoordinator.hasPremiumAccess {
@@ -880,7 +893,7 @@ class ViewController: NSViewController {
         }
     }
 
-    private func showHomeView(profile: GoogleUserProfile?) {
+    private func showHomeView(profile: GoogleUserProfile?, refreshMeetings: Bool = true) {
         clearMeetingsScrollObserver()
         clearMeetingsPageScrollObserver()
         removeSearchFieldObserver()
@@ -928,7 +941,7 @@ class ViewController: NSViewController {
         if let homeView { attachToRoot(homeView) }
         installSearchShortcutMonitor()
         startClock()
-        applyAuthenticationUIState(triggerInitialRefresh: true)
+        applyAuthenticationUIState(triggerInitialRefresh: refreshMeetings)
         updateHomeSidebarHighlight()
         updateSelectedHomeSectionUI()
     }
@@ -1003,6 +1016,9 @@ class ViewController: NSViewController {
                 await MainActor.run {
                     self.resetLoginSigningInState()
                     self.zoomAccountTimeZone = profileTimeZone
+                    if let profileTimeZone {
+                        UserDefaults.standard.set(profileTimeZone.identifier, forKey: Self.zoomAccountTimeZoneDefaultsKey)
+                    }
                     self.completeLoginAndRestart(profile: profile)
                 }
             } catch {
@@ -2454,6 +2470,10 @@ class ViewController: NSViewController {
     @objc private func logoutTapped() {
         meetingsRefreshTimer?.invalidate()
         meetingsRefreshTimer = nil
+        meetingsLoadTask?.cancel()
+        meetingsLoadTask = nil
+        isLoadingMeetings = false
+        meetingsRateLimitedUntil = nil
         clearMeetingsScrollObserver()
         googleOAuth.clearSavedTokens()
         zoomOAuth.clearSavedTokens()
@@ -2631,23 +2651,29 @@ class ViewController: NSViewController {
         meetingsRefreshTimer?.invalidate()
         // Poll Zoom meetings periodically so newly scheduled meetings appear automatically.
         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) {
         guard isUserLoggedIn() else { return }
         guard storeKitCoordinator.hasPremiumAccess else { return }
         let now = Date()
         if let until = meetingsRateLimitedUntil, now < until {
+            applyMeetingsRateLimitStatusLabels()
             return
         }
         if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
             return
         }
+        if isLoadingMeetings { return }
         lastMeetingsRefreshAt = now
-        Task { await self.loadScheduledMeetings() }
+        isLoadingMeetings = true
+        meetingsLoadTask?.cancel()
+        meetingsLoadTask = Task { await self.loadScheduledMeetings() }
     }
 
     private func clearMeetingsScrollObserver() {
@@ -3057,12 +3083,14 @@ class ViewController: NSViewController {
 
         meetingsRefreshTimer?.invalidate()
         meetingsRefreshTimer = nil
-        meetingsRateLimitedUntil = nil
+        if loggedIn == false {
+            meetingsRateLimitedUntil = nil
+        }
         if loggedIn {
             if storeKitCoordinator.hasPremiumAccess {
                 startMeetingsAutoRefresh()
                 if triggerInitialRefresh {
-                    triggerMeetingsRefresh(force: true)
+                    triggerMeetingsRefresh(force: false)
                 }
             } else {
                 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.
     @MainActor
     private func ensureZoomOAuthClientConfigured() -> Bool {
@@ -3164,6 +3211,10 @@ class ViewController: NSViewController {
             return
         }
 
+        if let until = meetingsRateLimitedUntil, until > Date() {
+            applyMeetingsRateLimitStatusLabels()
+        }
+
         let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
         let calendar = Calendar.current
         let dayStart = selectedMeetingsDayStart
@@ -3185,12 +3236,15 @@ class ViewController: NSViewController {
         let ordered = filtered.sorted(by: { $0.start < $1.start })
         if ordered.isEmpty {
             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()
             renderSchedulerCalendarGrid()
@@ -3665,48 +3719,64 @@ class ViewController: NSViewController {
     private func loadScheduledMeetings() async {
         guard isUserLoggedIn() else {
             await MainActor.run {
+                self.isLoadingMeetings = false
+                self.setMeetingsLoadingUI(false)
                 self.applyMeetings([])
                 self.applyGuestLoginMessages()
             }
             return
         }
-        guard storeKitCoordinator.hasPremiumAccess else {
+        let hasPremium = await MainActor.run { self.storeKitCoordinator.hasPremiumAccess }
+        guard hasPremium else {
             await MainActor.run {
+                self.isLoadingMeetings = false
+                self.setMeetingsLoadingUI(false)
                 self.applyMeetings([])
                 self.applyPremiumLockMessages()
             }
             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 {
-            isLoadingMeetings = false
             Task { @MainActor in
+                self.isLoadingMeetings = false
                 self.setMeetingsLoadingUI(false)
             }
         }
-        await MainActor.run {
-            self.setMeetingsLoadingUI(true)
-        }
         do {
             let zoomToken = try await zoomOAuth.validAccessToken(
                 presentingWindow: view.window,
                 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) {
                     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 {
                 self.meetingsRateLimitedUntil = nil
                 self.applyMeetings(zoomMeetings)
             }
         } catch {
             await MainActor.run {
-                self.applyMeetings([])
                 if case ZoomOAuthError.missingClientId = error {
                     self.meetingsStatusLabel?.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 {
                     let seconds = max(retryAfterSeconds ?? 300, 30)
                     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 {
                     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."
                 }
+                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() {
             meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
-            Task { await self.loadScheduledMeetings() }
+            triggerMeetingsRefresh(force: true)
         }
     }
 
@@ -3976,7 +4053,9 @@ class ViewController: NSViewController {
 
         var allMeetings: [ZoomMeeting] = []
         var nextPageToken: String?
+        var pageCount = 0
         repeat {
+            pageCount += 1
             var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
             var items: [URLQueryItem] = [
                 URLQueryItem(name: "type", value: "scheduled"),
@@ -4008,7 +4087,7 @@ class ViewController: NSViewController {
             allMeetings.append(contentsOf: decoded.meetings)
             let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
             nextPageToken = (token?.isEmpty == false) ? token : nil
-        } while nextPageToken != nil
+        } while nextPageToken != nil && pageCount < maxMeetingListPages
 
         return mapMeetings(allMeetings)
     }
@@ -4899,7 +4978,7 @@ class ViewController: NSViewController {
                 self.reconcileDesktopWidgetsForPremiumAccess()
                 if (wasPremium == false) && newIsPremium {
                     self.startMeetingsAutoRefresh()
-                    self.triggerMeetingsRefresh(force: true)
+                    self.triggerMeetingsRefresh(force: false)
                     self.schedulePremiumRatingPromptIfNeeded()
                 }
                 if wasPremium && !newIsPremium {
@@ -5173,7 +5252,7 @@ class ViewController: NSViewController {
 
         let keepSelected = selectedHomeSidebarItem
         selectedHomeSidebarItem = keepSelected
-        showHomeView(profile: nil)
+        showHomeView(profile: nil, refreshMeetings: false)
         updatePremiumLockedSections()
     }