|
@@ -49,6 +49,12 @@ class ViewController: NSViewController {
|
|
|
private weak var meetingsListStack: NSStackView?
|
|
private weak var meetingsListStack: NSStackView?
|
|
|
private weak var meetingsStatusLabel: NSTextField?
|
|
private weak var meetingsStatusLabel: NSTextField?
|
|
|
private weak var meetingsScrollView: NSScrollView?
|
|
private weak var meetingsScrollView: NSScrollView?
|
|
|
|
|
+ private weak var homeSearchField: NSTextField?
|
|
|
|
|
+ private weak var homeSearchPill: NSView?
|
|
|
|
|
+ private var allScheduledMeetings: [ScheduledMeeting] = []
|
|
|
|
|
+ private var searchTextObserver: NSObjectProtocol?
|
|
|
|
|
+ private var searchShortcutMonitor: Any?
|
|
|
|
|
+ private var searchOutsideClickMonitor: Any?
|
|
|
private var clockTimer: Timer?
|
|
private var clockTimer: Timer?
|
|
|
private var meetingsRefreshTimer: Timer?
|
|
private var meetingsRefreshTimer: Timer?
|
|
|
private var isSigningIn = false
|
|
private var isSigningIn = false
|
|
@@ -113,6 +119,11 @@ class ViewController: NSViewController {
|
|
|
meetingsRefreshTimer?.invalidate()
|
|
meetingsRefreshTimer?.invalidate()
|
|
|
meetingsRefreshTimer = nil
|
|
meetingsRefreshTimer = nil
|
|
|
clearMeetingsScrollObserver()
|
|
clearMeetingsScrollObserver()
|
|
|
|
|
+ removeSearchFieldObserver()
|
|
|
|
|
+ removeSearchShortcutMonitor()
|
|
|
|
|
+ homeSearchField = nil
|
|
|
|
|
+ homeSearchPill = nil
|
|
|
|
|
+ allScheduledMeetings = []
|
|
|
homeView?.removeFromSuperview()
|
|
homeView?.removeFromSuperview()
|
|
|
homeView = nil
|
|
homeView = nil
|
|
|
isSigningIn = false
|
|
isSigningIn = false
|
|
@@ -130,9 +141,14 @@ class ViewController: NSViewController {
|
|
|
private func showHomeView(profile: GoogleUserProfile?) {
|
|
private func showHomeView(profile: GoogleUserProfile?) {
|
|
|
loginView?.removeFromSuperview()
|
|
loginView?.removeFromSuperview()
|
|
|
clearMeetingsScrollObserver()
|
|
clearMeetingsScrollObserver()
|
|
|
|
|
+ removeSearchFieldObserver()
|
|
|
|
|
+ removeSearchShortcutMonitor()
|
|
|
|
|
+ homeSearchField = nil
|
|
|
|
|
+ homeSearchPill = nil
|
|
|
homeView?.removeFromSuperview()
|
|
homeView?.removeFromSuperview()
|
|
|
homeView = makeHomeView(profile: profile)
|
|
homeView = makeHomeView(profile: profile)
|
|
|
if let homeView { attachToRoot(homeView) }
|
|
if let homeView { attachToRoot(homeView) }
|
|
|
|
|
+ installSearchShortcutMonitor()
|
|
|
persistLoggedInState(true)
|
|
persistLoggedInState(true)
|
|
|
startClock()
|
|
startClock()
|
|
|
startMeetingsAutoRefresh()
|
|
startMeetingsAutoRefresh()
|
|
@@ -270,6 +286,73 @@ class ViewController: NSViewController {
|
|
|
meetingsScrollView = nil
|
|
meetingsScrollView = nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func removeSearchFieldObserver() {
|
|
|
|
|
+ if let searchTextObserver {
|
|
|
|
|
+ NotificationCenter.default.removeObserver(searchTextObserver)
|
|
|
|
|
+ }
|
|
|
|
|
+ searchTextObserver = nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func installSearchShortcutMonitor() {
|
|
|
|
|
+ removeSearchShortcutMonitor()
|
|
|
|
|
+ searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
|
|
|
+ guard let self else { return event }
|
|
|
|
|
+ guard event.modifierFlags.contains(.command),
|
|
|
|
|
+ event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
|
|
|
|
|
+ guard self.homeSearchField != nil else { return event }
|
|
|
|
|
+ DispatchQueue.main.async {
|
|
|
|
|
+ self.focusHomeSearchField()
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+ searchOutsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
|
|
|
|
|
+ guard let self else { return event }
|
|
|
|
|
+ guard let window = self.view.window, event.window === window else { return event }
|
|
|
|
|
+ guard let field = self.homeSearchField else { return event }
|
|
|
|
|
+ guard self.isSearchFieldActive(field, in: window) else { return event }
|
|
|
|
|
+ let location = event.locationInWindow
|
|
|
|
|
+ let pill = self.homeSearchPill ?? field
|
|
|
|
|
+ let rectInWindow = pill.convert(pill.bounds, to: nil)
|
|
|
|
|
+ if rectInWindow.contains(location) { return event }
|
|
|
|
|
+ DispatchQueue.main.async {
|
|
|
|
|
+ window.makeFirstResponder(nil)
|
|
|
|
|
+ (field as? SearchPillTextField)?.forceClearFocusState()
|
|
|
|
|
+ self.applySearchPillFocusBorder(focused: false)
|
|
|
|
|
+ }
|
|
|
|
|
+ return event
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func removeSearchShortcutMonitor() {
|
|
|
|
|
+ if let searchShortcutMonitor {
|
|
|
|
|
+ NSEvent.removeMonitor(searchShortcutMonitor)
|
|
|
|
|
+ }
|
|
|
|
|
+ searchShortcutMonitor = nil
|
|
|
|
|
+ if let searchOutsideClickMonitor {
|
|
|
|
|
+ NSEvent.removeMonitor(searchOutsideClickMonitor)
|
|
|
|
|
+ }
|
|
|
|
|
+ searchOutsideClickMonitor = nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func isSearchFieldActive(_ field: NSTextField, in window: NSWindow) -> Bool {
|
|
|
|
|
+ guard let fr = window.firstResponder else { return false }
|
|
|
|
|
+ if fr === field { return true }
|
|
|
|
|
+ if let editor = field.currentEditor(), fr === editor { return true }
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func applySearchPillFocusBorder(focused: Bool) {
|
|
|
|
|
+ homeSearchPill?.layer?.borderWidth = focused ? 1.5 : 0
|
|
|
|
|
+ homeSearchPill?.layer?.borderColor = accentBlue.cgColor
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func focusHomeSearchField() {
|
|
|
|
|
+ guard let field = homeSearchField else { return }
|
|
|
|
|
+ view.window?.makeFirstResponder(field)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
|
|
private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
|
|
|
clearMeetingsScrollObserver()
|
|
clearMeetingsScrollObserver()
|
|
|
meetingsScrollView = scrollView
|
|
meetingsScrollView = scrollView
|
|
@@ -345,16 +428,41 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func applyMeetings(_ meetings: [ScheduledMeeting]) {
|
|
private func applyMeetings(_ meetings: [ScheduledMeeting]) {
|
|
|
|
|
+ allScheduledMeetings = meetings
|
|
|
|
|
+ applyFilteredMeetings()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func applyFilteredMeetings() {
|
|
|
guard let stack = meetingsListStack else { return }
|
|
guard let stack = meetingsListStack else { return }
|
|
|
stack.arrangedSubviews.forEach { view in
|
|
stack.arrangedSubviews.forEach { view in
|
|
|
stack.removeArrangedSubview(view)
|
|
stack.removeArrangedSubview(view)
|
|
|
view.removeFromSuperview()
|
|
view.removeFromSuperview()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- let ordered = meetings.sorted(by: { $0.start < $1.start })
|
|
|
|
|
|
|
+ let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
|
|
+ let source = allScheduledMeetings
|
|
|
|
|
+ let filtered: [ScheduledMeeting]
|
|
|
|
|
+ if query.isEmpty {
|
|
|
|
|
+ filtered = source
|
|
|
|
|
+ } else {
|
|
|
|
|
+ filtered = source.filter { meeting in
|
|
|
|
|
+ meeting.title.lowercased().contains(query)
|
|
|
|
|
+ || meeting.host.lowercased().contains(query)
|
|
|
|
|
+ || meeting.source.lowercased().contains(query)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let ordered = filtered.sorted(by: { $0.start < $1.start })
|
|
|
if ordered.isEmpty {
|
|
if ordered.isEmpty {
|
|
|
emptyMeetingLabel?.isHidden = false
|
|
emptyMeetingLabel?.isHidden = false
|
|
|
- meetingsStatusLabel?.stringValue = "No upcoming Zoom meetings found."
|
|
|
|
|
|
|
+ if source.isEmpty {
|
|
|
|
|
+ meetingsStatusLabel?.stringValue = "No upcoming Zoom meetings found."
|
|
|
|
|
+ emptyMeetingLabel?.stringValue = "No meetings scheduled for today."
|
|
|
|
|
+ } else {
|
|
|
|
|
+ meetingsStatusLabel?.stringValue = "Zoom meetings"
|
|
|
|
|
+ emptyMeetingLabel?.stringValue = "No meetings match your search."
|
|
|
|
|
+ }
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -743,8 +851,39 @@ class ViewController: NSViewController {
|
|
|
searchPill.wantsLayer = true
|
|
searchPill.wantsLayer = true
|
|
|
searchPill.layer?.backgroundColor = searchPillBackground.cgColor
|
|
searchPill.layer?.backgroundColor = searchPillBackground.cgColor
|
|
|
searchPill.layer?.cornerRadius = 10
|
|
searchPill.layer?.cornerRadius = 10
|
|
|
- let search = makeLabel("Search (\u{2318}E)", size: 13, color: mutedText, weight: .regular, centered: true)
|
|
|
|
|
-
|
|
|
|
|
|
|
+ searchPill.layer?.borderWidth = 0
|
|
|
|
|
+ let searchIcon = NSImageView()
|
|
|
|
|
+ searchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")
|
|
|
|
|
+ searchIcon.contentTintColor = mutedText.withAlphaComponent(0.9)
|
|
|
|
|
+ searchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
|
|
|
|
|
+ searchIcon.imageScaling = .scaleProportionallyUpOrDown
|
|
|
|
|
+ let searchHintLabel = makeLabel("Search ⌘ + E", size: 13, color: mutedText, weight: .regular, centered: true)
|
|
|
|
|
+ searchHintLabel.isHidden = false
|
|
|
|
|
+ let searchField = SearchPillTextField()
|
|
|
|
|
+ searchField.isBordered = false
|
|
|
|
|
+ searchField.drawsBackground = false
|
|
|
|
|
+ searchField.backgroundColor = .clear
|
|
|
|
|
+ searchField.focusRingType = .none
|
|
|
|
|
+ searchField.font = .systemFont(ofSize: 13, weight: .regular)
|
|
|
|
|
+ searchField.textColor = primaryText
|
|
|
|
|
+ searchField.alignment = .left
|
|
|
|
|
+ searchField.placeholderString = nil
|
|
|
|
|
+ if let cell = searchField.cell as? NSTextFieldCell {
|
|
|
|
|
+ cell.isBezeled = false
|
|
|
|
|
+ cell.isBordered = false
|
|
|
|
|
+ cell.backgroundColor = .clear
|
|
|
|
|
+ }
|
|
|
|
|
+ let updateSearchHintVisibility = { [weak searchField, weak searchHintLabel] in
|
|
|
|
|
+ guard let searchField, let searchHintLabel else { return }
|
|
|
|
|
+ let shouldShow = searchField.isSearchFocused == false && searchField.stringValue.isEmpty
|
|
|
|
|
+ searchHintLabel.isHidden = shouldShow == false
|
|
|
|
|
+ }
|
|
|
|
|
+ searchField.onFocusChange = { [weak self] focused in
|
|
|
|
|
+ self?.applySearchPillFocusBorder(focused: focused)
|
|
|
|
|
+ updateSearchHintVisibility()
|
|
|
|
|
+ }
|
|
|
|
|
+ updateSearchHintVisibility()
|
|
|
|
|
+
|
|
|
let backForwardCluster = NSStackView()
|
|
let backForwardCluster = NSStackView()
|
|
|
backForwardCluster.orientation = .horizontal
|
|
backForwardCluster.orientation = .horizontal
|
|
|
backForwardCluster.spacing = 4
|
|
backForwardCluster.spacing = 4
|
|
@@ -840,15 +979,18 @@ class ViewController: NSViewController {
|
|
|
content.addSubview(topBarDivider)
|
|
content.addSubview(topBarDivider)
|
|
|
content.addSubview(contentColumn)
|
|
content.addSubview(contentColumn)
|
|
|
|
|
|
|
|
- [brandStack, searchRow, backForwardCluster, leftTopBarCluster, rightTopBarCluster, searchPill, search].forEach {
|
|
|
|
|
|
|
+ [brandStack, searchRow, backForwardCluster, leftTopBarCluster, rightTopBarCluster, searchPill, searchField, searchIcon, searchHintLabel].forEach {
|
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
|
}
|
|
}
|
|
|
[brandStack].forEach {
|
|
[brandStack].forEach {
|
|
|
shell.addSubview($0)
|
|
shell.addSubview($0)
|
|
|
}
|
|
}
|
|
|
- [searchRow, rightTopBarCluster, search].forEach {
|
|
|
|
|
|
|
+ [searchRow, rightTopBarCluster].forEach {
|
|
|
topBar.addSubview($0)
|
|
topBar.addSubview($0)
|
|
|
}
|
|
}
|
|
|
|
|
+ [searchIcon, searchField, searchHintLabel].forEach {
|
|
|
|
|
+ searchPill.addSubview($0)
|
|
|
|
|
+ }
|
|
|
[welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, noMeeting, meetingsScrollView, openRecordings].forEach {
|
|
[welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, noMeeting, meetingsScrollView, openRecordings].forEach {
|
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
|
contentColumn.addSubview($0)
|
|
contentColumn.addSubview($0)
|
|
@@ -893,9 +1035,16 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
searchPill.heightAnchor.constraint(equalToConstant: 32),
|
|
searchPill.heightAnchor.constraint(equalToConstant: 32),
|
|
|
searchPill.widthAnchor.constraint(equalToConstant: 320),
|
|
searchPill.widthAnchor.constraint(equalToConstant: 320),
|
|
|
- search.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
|
|
|
|
|
- search.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -12),
|
|
|
|
|
- search.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
|
|
|
|
|
|
|
+ searchIcon.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
|
|
|
|
|
+ searchIcon.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
|
|
|
|
|
+ searchIcon.widthAnchor.constraint(equalToConstant: 16),
|
|
|
|
|
+ searchIcon.heightAnchor.constraint(equalToConstant: 16),
|
|
|
|
|
+ searchHintLabel.centerXAnchor.constraint(equalTo: searchPill.centerXAnchor),
|
|
|
|
|
+ searchHintLabel.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
|
|
|
|
|
+ searchHintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: searchIcon.trailingAnchor, constant: 8),
|
|
|
|
|
+ searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
|
|
|
|
|
+ searchField.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -10),
|
|
|
|
|
+ searchField.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
|
|
|
profileChip.widthAnchor.constraint(equalToConstant: 34),
|
|
profileChip.widthAnchor.constraint(equalToConstant: 34),
|
|
|
profileChip.heightAnchor.constraint(equalToConstant: 34),
|
|
profileChip.heightAnchor.constraint(equalToConstant: 34),
|
|
|
|
|
|
|
@@ -953,9 +1102,26 @@ class ViewController: NSViewController {
|
|
|
emptyMeetingLabel = noMeeting
|
|
emptyMeetingLabel = noMeeting
|
|
|
observeMeetingsScrollEdges(in: meetingsScrollView)
|
|
observeMeetingsScrollEdges(in: meetingsScrollView)
|
|
|
updateClock()
|
|
updateClock()
|
|
|
|
|
+
|
|
|
|
|
+ homeSearchField = searchField
|
|
|
|
|
+ homeSearchPill = searchPill
|
|
|
|
|
+ searchTextObserver = NotificationCenter.default.addObserver(
|
|
|
|
|
+ forName: NSControl.textDidChangeNotification,
|
|
|
|
|
+ object: searchField,
|
|
|
|
|
+ queue: .main
|
|
|
|
|
+ ) { [weak self] _ in
|
|
|
|
|
+ self?.applyFilteredMeetings()
|
|
|
|
|
+ updateSearchHintVisibility()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
return root
|
|
return root
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ deinit {
|
|
|
|
|
+ removeSearchFieldObserver()
|
|
|
|
|
+ removeSearchShortcutMonitor()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func startClock() {
|
|
private func startClock() {
|
|
|
clockTimer?.invalidate()
|
|
clockTimer?.invalidate()
|
|
|
clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
|
clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
|
@@ -1234,6 +1400,34 @@ class ViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+private final class SearchPillTextField: NSTextField {
|
|
|
|
|
+ var onFocusChange: ((Bool) -> Void)?
|
|
|
|
|
+ private(set) var isSearchFocused = false
|
|
|
|
|
+
|
|
|
|
|
+ func forceClearFocusState() {
|
|
|
|
|
+ isSearchFocused = false
|
|
|
|
|
+ onFocusChange?(false)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func becomeFirstResponder() -> Bool {
|
|
|
|
|
+ let ok = super.becomeFirstResponder()
|
|
|
|
|
+ if ok {
|
|
|
|
|
+ isSearchFocused = true
|
|
|
|
|
+ onFocusChange?(true)
|
|
|
|
|
+ }
|
|
|
|
|
+ return ok
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func resignFirstResponder() -> Bool {
|
|
|
|
|
+ let ok = super.resignFirstResponder()
|
|
|
|
|
+ if ok {
|
|
|
|
|
+ isSearchFocused = false
|
|
|
|
|
+ onFocusChange?(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ return ok
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
struct GoogleOAuthTokens: Codable, Equatable {
|
|
struct GoogleOAuthTokens: Codable, Equatable {
|
|
|
var accessToken: String
|
|
var accessToken: String
|
|
|
var refreshToken: String?
|
|
var refreshToken: String?
|