|
|
@@ -228,6 +228,7 @@ class ViewController: NSViewController {
|
|
|
private weak var meetingsPageListStack: NSStackView?
|
|
|
private weak var meetingsPageEmptyLabel: NSTextField?
|
|
|
private weak var meetingsPageLoadMoreButton: NSButton?
|
|
|
+ private weak var meetingsPageScrollView: NSScrollView?
|
|
|
private var schedulerDayButtons: [Date: NSButton] = [:]
|
|
|
private var schedulerDateByButtonTag: [Int: Date] = [:]
|
|
|
private var nextSchedulerButtonTag: Int = 1
|
|
|
@@ -283,11 +284,14 @@ class ViewController: NSViewController {
|
|
|
private var isPromptingZoomCredentials = false
|
|
|
private var isLoadingMeetings = 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 meetingsPageAutoLoadCooldown: TimeInterval = 0.25
|
|
|
private var meetingsRateLimitedUntil: Date?
|
|
|
private var meetingsPageFilteredMeetings: [ScheduledMeeting] = []
|
|
|
private var meetingsPageSelectedRangePreset: String = "All"
|
|
|
@@ -497,6 +501,7 @@ class ViewController: NSViewController {
|
|
|
meetingsRefreshTimer?.invalidate()
|
|
|
meetingsRefreshTimer = nil
|
|
|
clearMeetingsScrollObserver()
|
|
|
+ clearMeetingsPageScrollObserver()
|
|
|
removeSearchFieldObserver()
|
|
|
removeSearchShortcutMonitor()
|
|
|
homeSearchField = nil
|
|
|
@@ -534,6 +539,7 @@ class ViewController: NSViewController {
|
|
|
private func showHomeView(profile: GoogleUserProfile?) {
|
|
|
loginView?.removeFromSuperview()
|
|
|
clearMeetingsScrollObserver()
|
|
|
+ clearMeetingsPageScrollObserver()
|
|
|
removeSearchFieldObserver()
|
|
|
removeSearchShortcutMonitor()
|
|
|
homeSearchField = nil
|
|
|
@@ -1447,6 +1453,15 @@ class ViewController: NSViewController {
|
|
|
meetingsScrollView = nil
|
|
|
}
|
|
|
|
|
|
+ private func clearMeetingsPageScrollObserver() {
|
|
|
+ if let meetingsPageScrollObserver {
|
|
|
+ NotificationCenter.default.removeObserver(meetingsPageScrollObserver)
|
|
|
+ }
|
|
|
+ meetingsPageScrollObserver = nil
|
|
|
+ meetingsPageScrollView?.contentView.postsBoundsChangedNotifications = false
|
|
|
+ meetingsPageScrollView = nil
|
|
|
+ }
|
|
|
+
|
|
|
private func removeSearchFieldObserver() {
|
|
|
if let searchTextObserver {
|
|
|
NotificationCenter.default.removeObserver(searchTextObserver)
|
|
|
@@ -1553,6 +1568,42 @@ class ViewController: NSViewController {
|
|
|
triggerMeetingsRefresh(force: true)
|
|
|
}
|
|
|
|
|
|
+ private func observeMeetingsPageInfiniteScroll(in scrollView: NSScrollView) {
|
|
|
+ clearMeetingsPageScrollObserver()
|
|
|
+ meetingsPageScrollView = scrollView
|
|
|
+ scrollView.contentView.postsBoundsChangedNotifications = true
|
|
|
+ meetingsPageScrollObserver = NotificationCenter.default.addObserver(
|
|
|
+ forName: NSView.boundsDidChangeNotification,
|
|
|
+ object: scrollView.contentView,
|
|
|
+ queue: .main
|
|
|
+ ) { [weak self, weak scrollView] _ in
|
|
|
+ guard let self, let scrollView else { return }
|
|
|
+ self.loadMoreMeetingsIfNeededForScroll(scrollView)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func loadMoreMeetingsIfNeededForScroll(_ scrollView: NSScrollView) {
|
|
|
+ guard meetingsPageVisibleCount < meetingsPageFilteredMeetings.count else { return }
|
|
|
+ guard let documentView = scrollView.documentView else { return }
|
|
|
+ let visibleRect = scrollView.contentView.bounds
|
|
|
+ let contentHeight = documentView.bounds.height
|
|
|
+ let viewportHeight = visibleRect.height
|
|
|
+ guard contentHeight > 0, viewportHeight > 0 else { return }
|
|
|
+
|
|
|
+ let maxOffset = max(contentHeight - viewportHeight, 0)
|
|
|
+ let y = visibleRect.origin.y
|
|
|
+ let threshold: CGFloat = 72
|
|
|
+ guard y >= (maxOffset - threshold) else { return }
|
|
|
+
|
|
|
+ let now = Date()
|
|
|
+ guard now.timeIntervalSince(lastMeetingsPageAutoLoadAt) >= meetingsPageAutoLoadCooldown else { return }
|
|
|
+ lastMeetingsPageAutoLoadAt = now
|
|
|
+
|
|
|
+ let nextCount = meetingsPageVisibleCount + meetingsPageSize
|
|
|
+ meetingsPageVisibleCount = min(nextCount, meetingsPageFilteredMeetings.count)
|
|
|
+ renderMeetingsPageList()
|
|
|
+ }
|
|
|
+
|
|
|
@MainActor
|
|
|
private func resetLoginSigningInState() {
|
|
|
isSigningIn = false
|
|
|
@@ -1664,20 +1715,52 @@ class ViewController: NSViewController {
|
|
|
@MainActor
|
|
|
private func applyMeetingsPageFilters(resetVisible: Bool) {
|
|
|
let calendar = Calendar.current
|
|
|
+ let now = Date()
|
|
|
+ let activePreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? meetingsPageSelectedRangePreset
|
|
|
+ meetingsPageSelectedRangePreset = activePreset
|
|
|
+
|
|
|
+ let activeFromDate: Date?
|
|
|
+ let activeToDate: Date?
|
|
|
+ if activePreset == "Custom range",
|
|
|
+ let fromPicker = meetingsPageFromDatePicker,
|
|
|
+ let toPicker = meetingsPageToDatePicker {
|
|
|
+ let fromDay = calendar.startOfDay(for: fromPicker.dateValue)
|
|
|
+ let toDay = calendar.startOfDay(for: toPicker.dateValue)
|
|
|
+ if fromDay <= toDay {
|
|
|
+ activeFromDate = fromDay
|
|
|
+ activeToDate = toDay
|
|
|
+ } else {
|
|
|
+ activeFromDate = toDay
|
|
|
+ activeToDate = fromDay
|
|
|
+ }
|
|
|
+ meetingsPageFromDateFilter = activeFromDate
|
|
|
+ meetingsPageToDateFilter = activeToDate
|
|
|
+ } else {
|
|
|
+ activeFromDate = meetingsPageFromDateFilter
|
|
|
+ activeToDate = meetingsPageToDateFilter
|
|
|
+ }
|
|
|
+
|
|
|
var filtered = allScheduledMeetings.filter { meeting in
|
|
|
- switch meetingsPageSelectedRangePreset {
|
|
|
+ guard meeting.start >= now else { return false }
|
|
|
+ switch activePreset {
|
|
|
case "Today":
|
|
|
- return calendar.isDate(meeting.start, inSameDayAs: Date())
|
|
|
+ let dayStart = calendar.startOfDay(for: now)
|
|
|
+ let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? now
|
|
|
+ return meeting.start >= now && meeting.start < dayEnd
|
|
|
case "This week":
|
|
|
- return calendar.isDate(meeting.start, equalTo: Date(), toGranularity: .weekOfYear)
|
|
|
+ let weekInterval = calendar.dateInterval(of: .weekOfYear, for: now)
|
|
|
+ let weekEnd = weekInterval?.end ?? now
|
|
|
+ return meeting.start >= now && meeting.start < weekEnd
|
|
|
case "This month":
|
|
|
- return calendar.isDate(meeting.start, equalTo: Date(), toGranularity: .month)
|
|
|
+ let monthInterval = calendar.dateInterval(of: .month, for: now)
|
|
|
+ let monthEnd = monthInterval?.end ?? now
|
|
|
+ return meeting.start >= now && meeting.start < monthEnd
|
|
|
case "Custom range":
|
|
|
- if let fromDate = meetingsPageFromDateFilter {
|
|
|
+ if let fromDate = activeFromDate {
|
|
|
let startOfFrom = calendar.startOfDay(for: fromDate)
|
|
|
if meeting.start < startOfFrom { return false }
|
|
|
}
|
|
|
- if let toDate = meetingsPageToDateFilter {
|
|
|
+ if let toDate = activeToDate {
|
|
|
let startOfTo = calendar.startOfDay(for: toDate)
|
|
|
let endOfTo = calendar.date(byAdding: .day, value: 1, to: startOfTo) ?? toDate
|
|
|
if meeting.start >= endOfTo { return false }
|
|
|
@@ -1727,9 +1810,7 @@ class ViewController: NSViewController {
|
|
|
card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
|
}
|
|
|
}
|
|
|
- let hasMore = visibleCount < totalCount
|
|
|
- meetingsPageLoadMoreButton?.isHidden = hasMore == false
|
|
|
- meetingsPageLoadMoreButton?.title = hasMore ? "Load more" : "All meetings loaded"
|
|
|
+ meetingsPageLoadMoreButton?.isHidden = true
|
|
|
}
|
|
|
|
|
|
@objc private func meetingsPageApplyFiltersTapped() {
|
|
|
@@ -1774,6 +1855,9 @@ class ViewController: NSViewController {
|
|
|
@objc private func meetingsPageRangePresetChanged(_ sender: NSPopUpButton) {
|
|
|
meetingsPageSelectedRangePreset = sender.titleOfSelectedItem ?? "All"
|
|
|
updateMeetingsDatePickersEnabledState()
|
|
|
+ Task { @MainActor in
|
|
|
+ self.applyMeetingsPageFilters(resetVisible: true)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
private func updateMeetingsDatePickersEnabledState() {
|
|
|
@@ -1784,14 +1868,6 @@ class ViewController: NSViewController {
|
|
|
meetingsPageToDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
|
|
|
}
|
|
|
|
|
|
- @objc private func meetingsPageLoadMoreTapped() {
|
|
|
- Task { @MainActor in
|
|
|
- let nextCount = meetingsPageVisibleCount + meetingsPageSize
|
|
|
- meetingsPageVisibleCount = min(nextCount, meetingsPageFilteredMeetings.count)
|
|
|
- self.renderMeetingsPageList()
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
private func meetingsForDay(_ dayStart: Date) -> [ScheduledMeeting] {
|
|
|
let calendar = Calendar.current
|
|
|
let safeDayStart = calendar.startOfDay(for: dayStart)
|
|
|
@@ -4036,7 +4112,7 @@ class ViewController: NSViewController {
|
|
|
empty.translatesAutoresizingMaskIntoConstraints = false
|
|
|
empty.isHidden = true
|
|
|
|
|
|
- let loadMoreButton = NSButton(title: "Load more", target: self, action: #selector(meetingsPageLoadMoreTapped))
|
|
|
+ let loadMoreButton = NSButton(title: "", target: nil, action: nil)
|
|
|
loadMoreButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
loadMoreButton.isBordered = false
|
|
|
loadMoreButton.wantsLayer = true
|
|
|
@@ -4081,7 +4157,7 @@ class ViewController: NSViewController {
|
|
|
listScroll.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 10),
|
|
|
listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
|
|
|
listScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
|
|
|
- listScroll.bottomAnchor.constraint(equalTo: loadMoreButton.topAnchor, constant: -12),
|
|
|
+ listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
|
|
|
|
|
|
listDocument.widthAnchor.constraint(equalTo: listScroll.contentView.widthAnchor),
|
|
|
listStack.topAnchor.constraint(equalTo: listDocument.topAnchor),
|
|
|
@@ -4096,7 +4172,7 @@ class ViewController: NSViewController {
|
|
|
loadMoreButton.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
|
|
|
loadMoreButton.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
|
|
|
loadMoreButton.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
|
|
|
- loadMoreButton.heightAnchor.constraint(equalToConstant: 40)
|
|
|
+ loadMoreButton.heightAnchor.constraint(equalToConstant: 1)
|
|
|
])
|
|
|
|
|
|
meetingsPageCategoryFilter = categoryFilter
|
|
|
@@ -4107,13 +4183,16 @@ class ViewController: NSViewController {
|
|
|
meetingsPageStatusLabel = statusLabel
|
|
|
meetingsPageListStack = listStack
|
|
|
meetingsPageEmptyLabel = empty
|
|
|
+ meetingsPageScrollView = listScroll
|
|
|
meetingsPageLoadMoreButton = loadMoreButton
|
|
|
+ observeMeetingsPageInfiniteScroll(in: listScroll)
|
|
|
updateMeetingsDatePickersEnabledState()
|
|
|
return root
|
|
|
}
|
|
|
|
|
|
deinit {
|
|
|
removeSearchFieldObserver()
|
|
|
+ clearMeetingsPageScrollObserver()
|
|
|
removeSearchShortcutMonitor()
|
|
|
}
|
|
|
|