|
|
@@ -37,9 +37,17 @@ class ViewController: NSViewController {
|
|
37
|
37
|
private weak var emptyMeetingLabel: NSTextField?
|
|
38
|
38
|
private weak var meetingsListStack: NSStackView?
|
|
39
|
39
|
private weak var meetingsStatusLabel: NSTextField?
|
|
|
40
|
+ private weak var meetingsScrollView: NSScrollView?
|
|
40
|
41
|
private var clockTimer: Timer?
|
|
|
42
|
+ private var meetingsRefreshTimer: Timer?
|
|
41
|
43
|
private var isSigningIn = false
|
|
42
|
44
|
private var isPromptingZoomCredentials = false
|
|
|
45
|
+ private var isLoadingMeetings = false
|
|
|
46
|
+ private var meetingsScrollObserver: NSObjectProtocol?
|
|
|
47
|
+ private var lastMeetingsRefreshAt = Date.distantPast
|
|
|
48
|
+ private var lastScrollEdgeRefreshAt = Date.distantPast
|
|
|
49
|
+ private let meetingsRefreshInterval: TimeInterval = 8
|
|
|
50
|
+ private let scrollRefreshCooldown: TimeInterval = 3
|
|
43
|
51
|
|
|
44
|
52
|
override func viewDidLoad() {
|
|
45
|
53
|
super.viewDidLoad()
|
|
|
@@ -73,6 +81,9 @@ class ViewController: NSViewController {
|
|
73
|
81
|
|
|
74
|
82
|
private func showLoginView() {
|
|
75
|
83
|
clockTimer?.invalidate()
|
|
|
84
|
+ meetingsRefreshTimer?.invalidate()
|
|
|
85
|
+ meetingsRefreshTimer = nil
|
|
|
86
|
+ clearMeetingsScrollObserver()
|
|
76
|
87
|
homeView?.removeFromSuperview()
|
|
77
|
88
|
homeView = nil
|
|
78
|
89
|
isSigningIn = false
|
|
|
@@ -89,12 +100,14 @@ class ViewController: NSViewController {
|
|
89
|
100
|
|
|
90
|
101
|
private func showHomeView(profile: GoogleUserProfile?) {
|
|
91
|
102
|
loginView?.removeFromSuperview()
|
|
|
103
|
+ clearMeetingsScrollObserver()
|
|
92
|
104
|
homeView?.removeFromSuperview()
|
|
93
|
105
|
homeView = makeHomeView(profile: profile)
|
|
94
|
106
|
if let homeView { attachToRoot(homeView) }
|
|
95
|
107
|
persistLoggedInState(true)
|
|
96
|
108
|
startClock()
|
|
97
|
|
- Task { await loadScheduledMeetings() }
|
|
|
109
|
+ startMeetingsAutoRefresh()
|
|
|
110
|
+ triggerMeetingsRefresh(force: true)
|
|
98
|
111
|
}
|
|
99
|
112
|
|
|
100
|
113
|
private func isUserLoggedIn() -> Bool {
|
|
|
@@ -188,12 +201,81 @@ class ViewController: NSViewController {
|
|
188
|
201
|
}
|
|
189
|
202
|
|
|
190
|
203
|
@objc private func logoutTapped() {
|
|
|
204
|
+ meetingsRefreshTimer?.invalidate()
|
|
|
205
|
+ meetingsRefreshTimer = nil
|
|
|
206
|
+ clearMeetingsScrollObserver()
|
|
191
|
207
|
googleOAuth.clearSavedTokens()
|
|
192
|
208
|
zoomOAuth.clearSavedTokens()
|
|
193
|
209
|
persistLoggedInState(false)
|
|
194
|
210
|
showLoginView()
|
|
195
|
211
|
}
|
|
196
|
212
|
|
|
|
213
|
+ private func startMeetingsAutoRefresh() {
|
|
|
214
|
+ meetingsRefreshTimer?.invalidate()
|
|
|
215
|
+ // Poll Zoom meetings periodically so newly scheduled meetings appear automatically.
|
|
|
216
|
+ meetingsRefreshTimer = Timer.scheduledTimer(withTimeInterval: meetingsRefreshInterval, repeats: true) { [weak self] _ in
|
|
|
217
|
+ guard let self else { return }
|
|
|
218
|
+ self.triggerMeetingsRefresh()
|
|
|
219
|
+ }
|
|
|
220
|
+ }
|
|
|
221
|
+
|
|
|
222
|
+ private func triggerMeetingsRefresh(force: Bool = false) {
|
|
|
223
|
+ let now = Date()
|
|
|
224
|
+ if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
|
|
|
225
|
+ return
|
|
|
226
|
+ }
|
|
|
227
|
+ lastMeetingsRefreshAt = now
|
|
|
228
|
+ Task { await self.loadScheduledMeetings() }
|
|
|
229
|
+ }
|
|
|
230
|
+
|
|
|
231
|
+ private func clearMeetingsScrollObserver() {
|
|
|
232
|
+ if let meetingsScrollObserver {
|
|
|
233
|
+ NotificationCenter.default.removeObserver(meetingsScrollObserver)
|
|
|
234
|
+ }
|
|
|
235
|
+ meetingsScrollObserver = nil
|
|
|
236
|
+ meetingsScrollView?.contentView.postsBoundsChangedNotifications = false
|
|
|
237
|
+ meetingsScrollView = nil
|
|
|
238
|
+ }
|
|
|
239
|
+
|
|
|
240
|
+ private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
|
|
|
241
|
+ clearMeetingsScrollObserver()
|
|
|
242
|
+ meetingsScrollView = scrollView
|
|
|
243
|
+ scrollView.contentView.postsBoundsChangedNotifications = true
|
|
|
244
|
+ meetingsScrollObserver = NotificationCenter.default.addObserver(
|
|
|
245
|
+ forName: NSView.boundsDidChangeNotification,
|
|
|
246
|
+ object: scrollView.contentView,
|
|
|
247
|
+ queue: .main
|
|
|
248
|
+ ) { [weak self, weak scrollView] _ in
|
|
|
249
|
+ guard let self, let scrollView else { return }
|
|
|
250
|
+ self.refreshMeetingsIfScrolledToEdge(scrollView)
|
|
|
251
|
+ }
|
|
|
252
|
+ }
|
|
|
253
|
+
|
|
|
254
|
+ private func refreshMeetingsIfScrolledToEdge(_ scrollView: NSScrollView) {
|
|
|
255
|
+ guard let documentView = scrollView.documentView else { return }
|
|
|
256
|
+
|
|
|
257
|
+ let visibleRect = scrollView.contentView.bounds
|
|
|
258
|
+ let contentHeight = documentView.bounds.height
|
|
|
259
|
+ let viewportHeight = visibleRect.height
|
|
|
260
|
+ if contentHeight <= viewportHeight + 2 {
|
|
|
261
|
+ return
|
|
|
262
|
+ }
|
|
|
263
|
+
|
|
|
264
|
+ let maxOffset = max(contentHeight - viewportHeight, 0)
|
|
|
265
|
+ let y = visibleRect.origin.y
|
|
|
266
|
+ let threshold: CGFloat = 12
|
|
|
267
|
+ let reachedTop = y <= threshold
|
|
|
268
|
+ let reachedBottom = y >= (maxOffset - threshold)
|
|
|
269
|
+ guard reachedTop || reachedBottom else { return }
|
|
|
270
|
+
|
|
|
271
|
+ let now = Date()
|
|
|
272
|
+ if now.timeIntervalSince(lastScrollEdgeRefreshAt) < scrollRefreshCooldown {
|
|
|
273
|
+ return
|
|
|
274
|
+ }
|
|
|
275
|
+ lastScrollEdgeRefreshAt = now
|
|
|
276
|
+ triggerMeetingsRefresh(force: true)
|
|
|
277
|
+ }
|
|
|
278
|
+
|
|
197
|
279
|
@MainActor
|
|
198
|
280
|
private func resetLoginSigningInState() {
|
|
199
|
281
|
isSigningIn = false
|
|
|
@@ -251,6 +333,9 @@ class ViewController: NSViewController {
|
|
251
|
333
|
}
|
|
252
|
334
|
|
|
253
|
335
|
private func loadScheduledMeetings() async {
|
|
|
336
|
+ if isLoadingMeetings { return }
|
|
|
337
|
+ isLoadingMeetings = true
|
|
|
338
|
+ defer { isLoadingMeetings = false }
|
|
254
|
339
|
do {
|
|
255
|
340
|
let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
|
|
256
|
341
|
let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken)
|
|
|
@@ -729,6 +814,7 @@ class ViewController: NSViewController {
|
|
729
|
814
|
meetingsListStack = meetingsStack
|
|
730
|
815
|
meetingsStatusLabel = meetingsStatus
|
|
731
|
816
|
emptyMeetingLabel = noMeeting
|
|
|
817
|
+ observeMeetingsScrollEdges(in: meetingsScrollView)
|
|
732
|
818
|
updateClock()
|
|
733
|
819
|
return root
|
|
734
|
820
|
}
|