Jelajahi Sumber

Replace top-bar inline search with a professional popup that searches all scheduled meetings.

This improves discovery by opening a dedicated overlay on search-pill tap or ⌘+E, adds global matching and sorted results, and supports quick open/copy actions from search results.

Made-with: Cursor
huzaifahayat12 3 bulan lalu
induk
melakukan
b2c8b593e4
1 mengubah file dengan 325 tambahan dan 19 penghapusan
  1. 325 19
      zoom_app/ViewController.swift

+ 325 - 19
zoom_app/ViewController.swift

@@ -210,6 +210,11 @@ class ViewController: NSViewController {
     private weak var homePlaceholderLabel: NSTextField?
     private weak var homeSearchField: NSTextField?
     private weak var homeSearchPill: NSView?
+    private weak var meetingSearchOverlayView: NSView?
+    private weak var meetingSearchPanelView: NSView?
+    private weak var meetingSearchField: NSTextField?
+    private weak var meetingSearchResultsStack: NSStackView?
+    private weak var meetingSearchEmptyLabel: NSTextField?
     private weak var homeSettingsView: NSView?
     private weak var schedulerRootView: NSView?
     private weak var schedulerMonthLabel: NSTextField?
@@ -278,6 +283,8 @@ class ViewController: NSViewController {
     private var searchTextObserver: NSObjectProtocol?
     private var searchShortcutMonitor: Any?
     private var searchOutsideClickMonitor: Any?
+    private var meetingSearchTextObserver: NSObjectProtocol?
+    private var globalSearchResults: [ScheduledMeeting] = []
     private var clockTimer: Timer?
     private var meetingsRefreshTimer: Timer?
     private var isSigningIn = false
@@ -503,9 +510,15 @@ class ViewController: NSViewController {
         clearMeetingsScrollObserver()
         clearMeetingsPageScrollObserver()
         removeSearchFieldObserver()
+        removeMeetingSearchObserver()
         removeSearchShortcutMonitor()
         homeSearchField = nil
         homeSearchPill = nil
+        meetingSearchOverlayView = nil
+        meetingSearchPanelView = nil
+        meetingSearchField = nil
+        meetingSearchResultsStack = nil
+        meetingSearchEmptyLabel = nil
         meetingsPageRootView = nil
         meetingsPageCategoryFilter = nil
         meetingsPageFromDatePicker = nil
@@ -541,9 +554,15 @@ class ViewController: NSViewController {
         clearMeetingsScrollObserver()
         clearMeetingsPageScrollObserver()
         removeSearchFieldObserver()
+        removeMeetingSearchObserver()
         removeSearchShortcutMonitor()
         homeSearchField = nil
         homeSearchPill = nil
+        meetingSearchOverlayView = nil
+        meetingSearchPanelView = nil
+        meetingSearchField = nil
+        meetingSearchResultsStack = nil
+        meetingSearchEmptyLabel = nil
         meetingsPageRootView = nil
         homeView?.removeFromSuperview()
         selectedMeetingsDayStart = Calendar.current.startOfDay(for: Date())
@@ -1469,31 +1488,41 @@ class ViewController: NSViewController {
         searchTextObserver = nil
     }
 
+    private func removeMeetingSearchObserver() {
+        if let meetingSearchTextObserver {
+            NotificationCenter.default.removeObserver(meetingSearchTextObserver)
+        }
+        meetingSearchTextObserver = nil
+    }
+
     private func installSearchShortcutMonitor() {
         removeSearchShortcutMonitor()
         searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
             guard let self else { return event }
+            if event.keyCode == 53, self.meetingSearchOverlayView?.isHidden == false {
+                DispatchQueue.main.async {
+                    self.hideMeetingSearchPopup()
+                }
+                return nil
+            }
             guard event.modifierFlags.contains(.command),
                   event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
-            guard self.homeSearchField != nil else { return event }
+            guard self.homeSearchPill != nil else { return event }
             DispatchQueue.main.async {
-                self.focusHomeSearchField()
+                self.showMeetingSearchPopup(seedQuery: self.meetingSearchField?.stringValue ?? "")
             }
             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)
+            guard self.meetingSearchOverlayView?.isHidden == false else { return event }
+            guard let panel = self.meetingSearchPanelView else { return event }
+            let rectInWindow = panel.convert(panel.bounds, to: nil)
+            if rectInWindow.contains(event.locationInWindow) == false {
+                DispatchQueue.main.async {
+                    self.hideMeetingSearchPopup()
+                }
             }
             return event
         }
@@ -1529,6 +1558,152 @@ class ViewController: NSViewController {
         view.window?.makeFirstResponder(field)
     }
 
+    @MainActor
+    private func showMeetingSearchPopup(seedQuery: String) {
+        guard let overlay = meetingSearchOverlayView else { return }
+        overlay.isHidden = false
+        overlay.alphaValue = 0
+        NSAnimationContext.runAnimationGroup { context in
+            context.duration = 0.16
+            overlay.animator().alphaValue = 1
+        }
+        if let field = meetingSearchField {
+            field.stringValue = seedQuery
+            view.window?.makeFirstResponder(field)
+        }
+        applyGlobalMeetingSearch(query: seedQuery)
+    }
+
+    @MainActor
+    private func hideMeetingSearchPopup() {
+        guard let overlay = meetingSearchOverlayView else { return }
+        overlay.isHidden = true
+        overlay.alphaValue = 1
+        view.window?.makeFirstResponder(nil)
+    }
+
+    private func applyGlobalMeetingSearch(query: String) {
+        let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        let now = Date()
+        let dateFormatter = DateFormatter()
+        dateFormatter.dateStyle = .medium
+        dateFormatter.timeStyle = .short
+
+        let filtered: [ScheduledMeeting]
+        if trimmed.isEmpty {
+            filtered = allScheduledMeetings
+        } else {
+            filtered = allScheduledMeetings.filter { meeting in
+                let dateText = dateFormatter.string(from: meeting.start).lowercased()
+                return meeting.title.lowercased().contains(trimmed)
+                    || meeting.host.lowercased().contains(trimmed)
+                    || meeting.source.lowercased().contains(trimmed)
+                    || (meeting.meetingID?.lowercased().contains(trimmed) ?? false)
+                    || dateText.contains(trimmed)
+            }
+        }
+
+        globalSearchResults = filtered.sorted { lhs, rhs in
+            let lhsIsUpcoming = lhs.start >= now
+            let rhsIsUpcoming = rhs.start >= now
+            if lhsIsUpcoming != rhsIsUpcoming {
+                return lhsIsUpcoming && !rhsIsUpcoming
+            }
+            if lhsIsUpcoming {
+                return lhs.start < rhs.start
+            }
+            return lhs.start > rhs.start
+        }
+        renderMeetingSearchResults()
+    }
+
+    private func renderMeetingSearchResults() {
+        guard let stack = meetingSearchResultsStack else { return }
+        stack.arrangedSubviews.forEach { row in
+            stack.removeArrangedSubview(row)
+            row.removeFromSuperview()
+        }
+        meetingSearchEmptyLabel?.isHidden = globalSearchResults.isEmpty == false
+        for (index, meeting) in globalSearchResults.enumerated() {
+            let row = makeMeetingSearchResultRow(meeting)
+            row.tag = index
+            stack.addArrangedSubview(row)
+            row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+        }
+    }
+
+    private func makeMeetingSearchResultRow(_ meeting: ScheduledMeeting) -> NSButton {
+        let row = NSButton(title: "", target: self, action: #selector(meetingSearchResultTapped(_:)))
+        row.isBordered = false
+        row.bezelStyle = .regularSquare
+        row.setButtonType(.momentaryChange)
+        row.wantsLayer = true
+        row.layer?.backgroundColor = meetingCardBackground.cgColor
+        row.layer?.cornerRadius = 10
+        row.layer?.borderWidth = 1
+        row.layer?.borderColor = palette.inputBorder.cgColor
+        row.translatesAutoresizingMaskIntoConstraints = false
+        row.heightAnchor.constraint(equalToConstant: 84).isActive = true
+
+        let title = makeLabel(meeting.title, size: 14, color: primaryText, weight: .semibold, centered: false)
+        title.lineBreakMode = .byTruncatingTail
+        let subtitle = makeLabel(compactMeetingSummary(meeting), size: 12, color: secondaryText, weight: .regular, centered: false)
+        subtitle.lineBreakMode = .byTruncatingTail
+        let meta = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: mutedText, weight: .regular, centered: false)
+        meta.lineBreakMode = .byTruncatingTail
+        [title, subtitle, meta].forEach {
+            $0.translatesAutoresizingMaskIntoConstraints = false
+            row.addSubview($0)
+        }
+
+        NSLayoutConstraint.activate([
+            title.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
+            title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 12),
+            title.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -12),
+            subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
+            subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
+            subtitle.trailingAnchor.constraint(equalTo: title.trailingAnchor),
+            meta.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4),
+            meta.leadingAnchor.constraint(equalTo: title.leadingAnchor),
+            meta.trailingAnchor.constraint(equalTo: title.trailingAnchor)
+        ])
+        return row
+    }
+
+    private func compactMeetingSummary(_ meeting: ScheduledMeeting) -> String {
+        let dateFormatter = DateFormatter()
+        dateFormatter.dateFormat = "EEE, MMM d"
+        let timeFormatter = DateFormatter()
+        timeFormatter.dateFormat = "h:mm a"
+        let start = timeFormatter.string(from: meeting.start)
+        let end = meeting.end.map { timeFormatter.string(from: $0) }
+        let range = end.map { "\(start) - \($0)" } ?? start
+        if let id = meeting.meetingID, id.isEmpty == false {
+            return "\(dateFormatter.string(from: meeting.start)) • \(range) • ID \(id)"
+        }
+        return "\(dateFormatter.string(from: meeting.start)) • \(range)"
+    }
+
+    @objc private func topSearchPillTapped() {
+        showMeetingSearchPopup(seedQuery: meetingSearchField?.stringValue ?? "")
+    }
+
+    @objc private func meetingSearchResultTapped(_ sender: NSButton) {
+        guard let meeting = globalSearchResults[safe: sender.tag] else { return }
+        if let url = meeting.webURL {
+            NSWorkspace.shared.open(url)
+        } else {
+            let pasteboard = NSPasteboard.general
+            pasteboard.clearContents()
+            if let id = meeting.meetingID, id.isEmpty == false {
+                pasteboard.setString(id, forType: .string)
+            } else {
+                pasteboard.setString(meeting.title, forType: .string)
+            }
+        }
+        hideMeetingSearchPopup()
+    }
+
     private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
         clearMeetingsScrollObserver()
         meetingsScrollView = scrollView
@@ -3521,6 +3696,8 @@ class ViewController: NSViewController {
         searchField.drawsBackground = false
         searchField.backgroundColor = .clear
         searchField.focusRingType = .none
+        searchField.isEditable = false
+        searchField.isSelectable = false
         searchField.font = .systemFont(ofSize: 13, weight: .regular)
         searchField.textColor = primaryText
         searchField.alignment = .left
@@ -3540,6 +3717,11 @@ class ViewController: NSViewController {
             updateSearchHintVisibility()
         }
         updateSearchHintVisibility()
+        let searchTap = NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped))
+        searchPill.addGestureRecognizer(searchTap)
+        searchField.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
+        searchIcon.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
+        searchHintLabel.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
 
         let searchRow = NSStackView()
         searchRow.orientation = .horizontal
@@ -3646,6 +3828,80 @@ class ViewController: NSViewController {
         let meetingsPageView = makeMeetingsPageView()
         meetingsPageView.isHidden = selectedHomeSidebarItem != "Meetings"
 
+        let searchOverlay = NSView()
+        searchOverlay.wantsLayer = true
+        searchOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.45).cgColor
+        searchOverlay.isHidden = true
+        searchOverlay.translatesAutoresizingMaskIntoConstraints = false
+
+        let searchPanel = NSView()
+        searchPanel.wantsLayer = true
+        searchPanel.layer?.backgroundColor = secondaryCardBackground.cgColor
+        searchPanel.layer?.cornerRadius = 14
+        searchPanel.layer?.borderWidth = 1
+        searchPanel.layer?.borderColor = palette.inputBorder.cgColor
+        searchPanel.translatesAutoresizingMaskIntoConstraints = false
+
+        let popupTitle = makeLabel("Search Meetings", size: 18, color: primaryText, weight: .semibold, centered: false)
+        popupTitle.translatesAutoresizingMaskIntoConstraints = false
+        let popupSubtitle = makeLabel("Find any scheduled meeting instantly", size: 12, color: secondaryText, weight: .regular, centered: false)
+        popupSubtitle.translatesAutoresizingMaskIntoConstraints = false
+
+        let popupSearchChrome = NSView()
+        popupSearchChrome.translatesAutoresizingMaskIntoConstraints = false
+        popupSearchChrome.wantsLayer = true
+        popupSearchChrome.layer?.backgroundColor = searchPillBackground.cgColor
+        popupSearchChrome.layer?.cornerRadius = 10
+        popupSearchChrome.layer?.borderWidth = 1
+        popupSearchChrome.layer?.borderColor = palette.inputBorder.cgColor
+
+        let popupSearchIcon = NSImageView()
+        popupSearchIcon.translatesAutoresizingMaskIntoConstraints = false
+        popupSearchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search meetings")
+        popupSearchIcon.contentTintColor = secondaryText
+        popupSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular)
+
+        let popupSearchField = NSTextField()
+        popupSearchField.translatesAutoresizingMaskIntoConstraints = false
+        popupSearchField.isBordered = false
+        popupSearchField.drawsBackground = false
+        popupSearchField.backgroundColor = .clear
+        popupSearchField.focusRingType = .none
+        popupSearchField.font = .systemFont(ofSize: 15, weight: .regular)
+        popupSearchField.textColor = primaryText
+        popupSearchField.placeholderString = "Search all meetings by title, host, source, ID..."
+
+        let popupResultsScroll = NSScrollView()
+        popupResultsScroll.translatesAutoresizingMaskIntoConstraints = false
+        popupResultsScroll.drawsBackground = false
+        popupResultsScroll.hasVerticalScroller = true
+        popupResultsScroll.hasHorizontalScroller = false
+        popupResultsScroll.autohidesScrollers = true
+
+        let popupResultsDocument = FlippedView()
+        popupResultsDocument.translatesAutoresizingMaskIntoConstraints = false
+        let popupResultsStack = NSStackView()
+        popupResultsStack.translatesAutoresizingMaskIntoConstraints = false
+        popupResultsStack.orientation = .vertical
+        popupResultsStack.spacing = 10
+        popupResultsStack.alignment = .leading
+
+        let popupEmpty = makeLabel("No meetings match your search.", size: 13, color: secondaryText, weight: .regular, centered: true)
+        popupEmpty.translatesAutoresizingMaskIntoConstraints = false
+        popupEmpty.isHidden = true
+
+        root.addSubview(searchOverlay)
+        searchOverlay.addSubview(searchPanel)
+        searchPanel.addSubview(popupTitle)
+        searchPanel.addSubview(popupSubtitle)
+        searchPanel.addSubview(popupSearchChrome)
+        searchPanel.addSubview(popupResultsScroll)
+        searchPanel.addSubview(popupEmpty)
+        popupSearchChrome.addSubview(popupSearchIcon)
+        popupSearchChrome.addSubview(popupSearchField)
+        popupResultsScroll.documentView = popupResultsDocument
+        popupResultsDocument.addSubview(popupResultsStack)
+
         let schedulerRoot = NSView()
         schedulerRoot.isHidden = selectedHomeSidebarItem != "Scheduler"
 
@@ -3947,7 +4203,51 @@ class ViewController: NSViewController {
             schedulerMeetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: schedulerMeetingsDocument.bottomAnchor),
 
             schedulerEmptyLabel.centerXAnchor.constraint(equalTo: schedulerMeetingsPanel.centerXAnchor),
-            schedulerEmptyLabel.centerYAnchor.constraint(equalTo: schedulerMeetingsPanel.centerYAnchor)
+            schedulerEmptyLabel.centerYAnchor.constraint(equalTo: schedulerMeetingsPanel.centerYAnchor),
+
+            searchOverlay.leadingAnchor.constraint(equalTo: root.leadingAnchor),
+            searchOverlay.trailingAnchor.constraint(equalTo: root.trailingAnchor),
+            searchOverlay.topAnchor.constraint(equalTo: root.topAnchor),
+            searchOverlay.bottomAnchor.constraint(equalTo: root.bottomAnchor),
+
+            searchPanel.centerXAnchor.constraint(equalTo: searchOverlay.centerXAnchor),
+            searchPanel.centerYAnchor.constraint(equalTo: searchOverlay.centerYAnchor),
+            searchPanel.widthAnchor.constraint(equalToConstant: 640),
+            searchPanel.heightAnchor.constraint(equalToConstant: 460),
+
+            popupTitle.topAnchor.constraint(equalTo: searchPanel.topAnchor, constant: 18),
+            popupTitle.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
+            popupTitle.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
+            popupSubtitle.topAnchor.constraint(equalTo: popupTitle.bottomAnchor, constant: 4),
+            popupSubtitle.leadingAnchor.constraint(equalTo: popupTitle.leadingAnchor),
+            popupSubtitle.trailingAnchor.constraint(equalTo: popupTitle.trailingAnchor),
+
+            popupSearchChrome.topAnchor.constraint(equalTo: popupSubtitle.bottomAnchor, constant: 12),
+            popupSearchChrome.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
+            popupSearchChrome.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
+            popupSearchChrome.heightAnchor.constraint(equalToConstant: 40),
+
+            popupSearchIcon.leadingAnchor.constraint(equalTo: popupSearchChrome.leadingAnchor, constant: 12),
+            popupSearchIcon.centerYAnchor.constraint(equalTo: popupSearchChrome.centerYAnchor),
+            popupSearchIcon.widthAnchor.constraint(equalToConstant: 16),
+            popupSearchIcon.heightAnchor.constraint(equalToConstant: 16),
+            popupSearchField.leadingAnchor.constraint(equalTo: popupSearchIcon.trailingAnchor, constant: 8),
+            popupSearchField.trailingAnchor.constraint(equalTo: popupSearchChrome.trailingAnchor, constant: -12),
+            popupSearchField.centerYAnchor.constraint(equalTo: popupSearchChrome.centerYAnchor),
+
+            popupResultsScroll.topAnchor.constraint(equalTo: popupSearchChrome.bottomAnchor, constant: 12),
+            popupResultsScroll.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
+            popupResultsScroll.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
+            popupResultsScroll.bottomAnchor.constraint(equalTo: searchPanel.bottomAnchor, constant: -18),
+
+            popupResultsStack.topAnchor.constraint(equalTo: popupResultsDocument.topAnchor),
+            popupResultsStack.leadingAnchor.constraint(equalTo: popupResultsDocument.leadingAnchor),
+            popupResultsStack.trailingAnchor.constraint(equalTo: popupResultsDocument.trailingAnchor),
+            popupResultsStack.bottomAnchor.constraint(lessThanOrEqualTo: popupResultsDocument.bottomAnchor),
+            popupResultsStack.widthAnchor.constraint(equalTo: popupResultsScroll.contentView.widthAnchor),
+
+            popupEmpty.centerXAnchor.constraint(equalTo: popupResultsScroll.centerXAnchor),
+            popupEmpty.centerYAnchor.constraint(equalTo: popupResultsScroll.centerYAnchor)
         ])
 
         timeLabel = timeTitle
@@ -3985,16 +4285,21 @@ class ViewController: NSViewController {
 
         homeSearchField = searchField
         homeSearchPill = searchPill
-        searchTextObserver = NotificationCenter.default.addObserver(
+        meetingSearchOverlayView = searchOverlay
+        meetingSearchPanelView = searchPanel
+        meetingSearchField = popupSearchField
+        meetingSearchResultsStack = popupResultsStack
+        meetingSearchEmptyLabel = popupEmpty
+        removeMeetingSearchObserver()
+        meetingSearchTextObserver = NotificationCenter.default.addObserver(
             forName: NSControl.textDidChangeNotification,
-            object: searchField,
+            object: popupSearchField,
             queue: .main
         ) { [weak self] _ in
-            Task { @MainActor [weak self] in
-                self?.applyFilteredMeetings()
-            }
-            updateSearchHintVisibility()
+            guard let self else { return }
+            self.applyGlobalMeetingSearch(query: popupSearchField.stringValue)
         }
+        applyGlobalMeetingSearch(query: "")
 
         return root
     }
@@ -4192,6 +4497,7 @@ class ViewController: NSViewController {
 
     deinit {
         removeSearchFieldObserver()
+        removeMeetingSearchObserver()
         clearMeetingsPageScrollObserver()
         removeSearchShortcutMonitor()
     }