|
|
@@ -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()
|
|
|
}
|
|
|
|