Эх сурвалжийг харах

Add join-meeting sheet with URL or ID + passcode, open in browser

- Show styled NSPanel from Join action: segmented URL vs ID, passcode for ID mode
- Hide traffic lights; custom header with close; dark-mode aligned chrome
- Open Zoom links via NSWorkspace in default browser; wc/join URL for ID flow
- Join form fields: pill container, centered text, custom cell for vertical center
- Panel key window and first-responder focus; avoid clipping field editor

Made-with: Cursor
huzaifahayat12 3 сар өмнө
parent
commit
5977e6f64c

+ 460 - 1
zoom_app/ViewController.swift

@@ -189,6 +189,13 @@ class ViewController: NSViewController {
     private weak var settingsGoogleActionButton: NSButton?
     private weak var topBarPremiumButton: NSButton?
     private var paywallWindow: NSWindow?
+    private var joinMeetingWindow: NSWindow?
+    private weak var joinURLField: NSTextField?
+    private weak var joinMeetingIDField: NSTextField?
+    private weak var joinPasscodeField: NSTextField?
+    private weak var joinURLFieldsContainer: NSView?
+    private weak var joinIDFieldsContainer: NSView?
+    private weak var joinModeSegment: NSSegmentedControl?
     private let paywallContentWidth: CGFloat = 520
     private var selectedPremiumPlan: PremiumPlan = .monthly
     private var paywallPlanViews: [PremiumPlan: NSView] = [:]
@@ -551,6 +558,301 @@ class ViewController: NSViewController {
         }
     }
 
+    @objc private func joinMeetingTapped() {
+        if let existing = joinMeetingWindow {
+            existing.makeKeyAndOrderFront(nil)
+            NSApp.activate(ignoringOtherApps: true)
+            DispatchQueue.main.async { [weak self] in
+                _ = self?.joinURLField?.becomeFirstResponder()
+            }
+            return
+        }
+
+        let content = makeJoinMeetingPanelContent()
+        let controller = NSViewController()
+        controller.view = content
+
+        let panel = NSPanel(
+            contentRect: NSRect(x: 0, y: 0, width: 520, height: 430),
+            styleMask: [.titled, .closable, .fullSizeContentView],
+            backing: .buffered,
+            defer: false
+        )
+        panel.title = "Join a meeting"
+        panel.titleVisibility = .hidden
+        panel.titlebarAppearsTransparent = true
+        panel.hidesOnDeactivate = true
+        panel.isReleasedWhenClosed = false
+        panel.standardWindowButton(.closeButton)?.isHidden = true
+        panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
+        panel.standardWindowButton(.zoomButton)?.isHidden = true
+        panel.isMovableByWindowBackground = true
+        // Allow the panel to become key immediately so text fields receive keyboard input (avoids "can't type" with floating/key heuristics).
+        panel.becomesKeyOnlyIfNeeded = false
+        panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
+        panel.center()
+        panel.contentViewController = controller
+        panel.delegate = self
+        applyWindowBackgroundForCurrentTheme(panel)
+        panel.makeKeyAndOrderFront(nil)
+        NSApp.activate(ignoringOtherApps: true)
+        joinMeetingWindow = panel
+        DispatchQueue.main.async { [weak self] in
+            guard let self else { return }
+            _ = self.joinURLField?.becomeFirstResponder()
+        }
+    }
+
+    private func makeJoinMeetingPanelContent() -> NSView {
+        let root = NSView()
+        root.translatesAutoresizingMaskIntoConstraints = false
+        root.wantsLayer = true
+        root.layer?.backgroundColor = appBackground.cgColor
+
+        let titleLabel = makeLabel("Join a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
+        let subtitleLabel = makeLabel("Opens in your default web browser", size: 12, color: mutedText, weight: .regular, centered: false)
+
+        let closeButton = HoverButton(title: "✕", target: self, action: #selector(joinMeetingCancelTapped))
+        closeButton.translatesAutoresizingMaskIntoConstraints = false
+        closeButton.isBordered = false
+        closeButton.bezelStyle = .regularSquare
+        closeButton.wantsLayer = true
+        closeButton.layer?.cornerRadius = 14
+        closeButton.normalColor = palette.inputBackground
+        closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
+        closeButton.layer?.borderColor = palette.inputBorder.cgColor
+        closeButton.layer?.borderWidth = 1
+        closeButton.font = .systemFont(ofSize: 13, weight: .bold)
+        closeButton.contentTintColor = secondaryText
+        closeButton.toolTip = "Close"
+        closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
+        closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
+
+        let titleRow = NSStackView()
+        titleRow.orientation = .horizontal
+        titleRow.alignment = .centerY
+        titleRow.spacing = 10
+        titleRow.translatesAutoresizingMaskIntoConstraints = false
+        let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
+        titleStack.orientation = .vertical
+        titleStack.spacing = 2
+        titleStack.alignment = .leading
+        let titleSpacer = NSView()
+        titleSpacer.translatesAutoresizingMaskIntoConstraints = false
+        titleRow.addArrangedSubview(titleStack)
+        titleRow.addArrangedSubview(titleSpacer)
+        titleRow.addArrangedSubview(closeButton)
+
+        let headerDivider = NSView()
+        headerDivider.wantsLayer = true
+        headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
+        headerDivider.translatesAutoresizingMaskIntoConstraints = false
+
+        let mode = NSSegmentedControl(labels: ["Join with URL", "Join with ID"], trackingMode: .selectOne, target: self, action: #selector(joinMeetingModeChanged(_:)))
+        mode.segmentStyle = .rounded
+        mode.selectedSegment = 0
+        mode.translatesAutoresizingMaskIntoConstraints = false
+        mode.font = .systemFont(ofSize: 12, weight: .semibold)
+        mode.controlSize = .large
+        if #available(macOS 11.0, *) {
+            mode.selectedSegmentBezelColor = accentBlue
+        }
+        joinModeSegment = mode
+
+        let urlBox = makeJoinFormField(placeholder: "https://zoom.us/j/… or paste invite link")
+        joinURLField = urlBox.textField
+        let urlLabel = makeLabel("Meeting link", size: 12, color: secondaryText, weight: .medium, centered: false)
+        let urlStack = NSStackView(views: [urlLabel, urlBox])
+        urlStack.orientation = .vertical
+        urlStack.spacing = 8
+        urlStack.alignment = .leading
+        urlStack.translatesAutoresizingMaskIntoConstraints = false
+        joinURLFieldsContainer = urlStack
+
+        let idBox = makeJoinFormField(placeholder: "Meeting ID (numbers only)")
+        let passBox = makeJoinFormField(placeholder: "Passcode (if required)")
+        joinMeetingIDField = idBox.textField
+        joinPasscodeField = passBox.textField
+        let idLabel = makeLabel("Meeting ID", size: 12, color: secondaryText, weight: .medium, centered: false)
+        let passLabel = makeLabel("Passcode", size: 12, color: secondaryText, weight: .medium, centered: false)
+        let idStack = NSStackView(views: [idLabel, idBox, passLabel, passBox])
+        idStack.orientation = .vertical
+        idStack.spacing = 8
+        idStack.alignment = .leading
+        idStack.translatesAutoresizingMaskIntoConstraints = false
+        idStack.isHidden = true
+        joinIDFieldsContainer = idStack
+
+        let formCard = NSView()
+        formCard.translatesAutoresizingMaskIntoConstraints = false
+        formCard.wantsLayer = true
+        formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
+        formCard.layer?.cornerRadius = 14
+        formCard.layer?.borderWidth = 1
+        formCard.layer?.borderColor = palette.inputBorder.cgColor
+
+        let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(joinMeetingCancelTapped))
+        cancelButton.isBordered = false
+        cancelButton.wantsLayer = true
+        cancelButton.layer?.cornerRadius = 10
+        cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
+        cancelButton.layer?.borderWidth = 1
+        cancelButton.layer?.borderColor = palette.inputBorder.cgColor
+        cancelButton.contentTintColor = primaryText
+        cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
+
+        let joinButton = HoverButton(title: "Join", target: self, action: #selector(joinMeetingSubmitTapped))
+        joinButton.isBordered = false
+        joinButton.wantsLayer = true
+        joinButton.layer?.cornerRadius = 10
+        joinButton.normalColor = accentBlue
+        joinButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
+        joinButton.contentTintColor = .white
+        joinButton.font = .systemFont(ofSize: 13, weight: .bold)
+        joinButton.keyEquivalent = "\r"
+
+        let buttons = NSStackView(views: [cancelButton, joinButton])
+        buttons.orientation = .horizontal
+        buttons.spacing = 12
+        buttons.alignment = .centerY
+        buttons.distribution = .fillEqually
+
+        let innerStack = NSStackView(views: [mode, urlStack, idStack, buttons])
+        innerStack.orientation = .vertical
+        innerStack.spacing = 18
+        innerStack.alignment = .leading
+        innerStack.translatesAutoresizingMaskIntoConstraints = false
+        formCard.addSubview(innerStack)
+
+        [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
+
+        NSLayoutConstraint.activate([
+            titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
+            titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
+            titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
+
+            headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
+            headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
+            headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
+            headerDivider.heightAnchor.constraint(equalToConstant: 1),
+
+            formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
+            formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
+            formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
+            formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
+
+            innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
+            innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
+            innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
+            innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
+
+            mode.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
+            urlBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
+            idBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
+            passBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
+            buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
+            joinButton.heightAnchor.constraint(equalToConstant: 40),
+            cancelButton.heightAnchor.constraint(equalToConstant: 40)
+        ])
+
+        return root
+    }
+
+    private func makeJoinFormField(placeholder: String) -> JoinPanelFieldContainer {
+        JoinPanelFieldContainer(
+            placeholder: placeholder,
+            normalBorder: palette.inputBorder,
+            focusBorder: accentBlue.withAlphaComponent(0.9),
+            fill: palette.inputBackground,
+            primaryText: primaryText,
+            mutedText: mutedText
+        )
+    }
+
+    @objc private func joinMeetingModeChanged(_ sender: NSSegmentedControl) {
+        let urlMode = sender.selectedSegment == 0
+        joinURLFieldsContainer?.isHidden = urlMode == false
+        joinIDFieldsContainer?.isHidden = urlMode
+    }
+
+    @objc private func joinMeetingCancelTapped() {
+        joinMeetingWindow?.performClose(nil)
+    }
+
+    @objc private func joinMeetingSubmitTapped() {
+        guard let segment = joinModeSegment else { return }
+        if segment.selectedSegment == 0 {
+            let raw = joinURLField?.stringValue ?? ""
+            guard let url = parseZoomJoinURLFromUserInput(raw) else {
+                showSimpleAlert(title: "Invalid link", message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…).")
+                return
+            }
+            openZoomMeetingInDefaultBrowser(url)
+        } else {
+            let idRaw = joinMeetingIDField?.stringValue ?? ""
+            let digits = idRaw.filter(\.isNumber)
+            guard digits.isEmpty == false else {
+                showSimpleAlert(title: "Meeting ID required", message: "Enter the numeric meeting ID.")
+                return
+            }
+            let pass = joinPasscodeField?.stringValue ?? ""
+            guard let url = zoomWebClientJoinURL(meetingIdDigits: digits, passcode: pass) else {
+                showSimpleAlert(title: "Unable to join", message: "Could not build a join link from that meeting ID.")
+                return
+            }
+            openZoomMeetingInDefaultBrowser(url)
+        }
+    }
+
+    /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd`).
+    private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String) -> URL? {
+        guard meetingIdDigits.isEmpty == false else { return nil }
+        var components = URLComponents()
+        components.scheme = "https"
+        components.host = "zoom.us"
+        components.path = "/wc/join/\(meetingIdDigits)"
+        let trimmed = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
+        if trimmed.isEmpty == false {
+            components.queryItems = [URLQueryItem(name: "pwd", value: trimmed)]
+        }
+        return components.url
+    }
+
+    private func parseZoomJoinURLFromUserInput(_ raw: String) -> URL? {
+        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard trimmed.isEmpty == false else { return nil }
+        let normalized: String
+        if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") {
+            normalized = trimmed
+        } else {
+            normalized = "https://\(trimmed)"
+        }
+        guard let url = URL(string: normalized), let host = url.host?.lowercased() else { return nil }
+        let isZoom = host == "zoom.us" || host.hasSuffix(".zoom.us")
+            || host == "zoom.com" || host.hasSuffix(".zoom.com")
+        guard isZoom else { return nil }
+        return url
+    }
+
+    private func openZoomMeetingInDefaultBrowser(_ url: URL) {
+        let opened = NSWorkspace.shared.open(url)
+        if opened {
+            joinMeetingWindow?.performClose(nil)
+        } else {
+            showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
+        }
+    }
+
+    private func resetJoinMeetingPanelReferences() {
+        joinMeetingWindow = nil
+        joinURLField = nil
+        joinMeetingIDField = nil
+        joinPasscodeField = nil
+        joinURLFieldsContainer = nil
+        joinIDFieldsContainer = nil
+        joinModeSegment = nil
+    }
+
     @objc private func logoutTapped() {
         meetingsRefreshTimer?.invalidate()
         meetingsRefreshTimer = nil
@@ -2288,7 +2590,7 @@ class ViewController: NSViewController {
 
         let actions = NSStackView(views: [
             makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange),
-            makeActionTile(title: "Join", symbol: "plus", color: accentBlue),
+            makeActionTile(title: "Join", symbol: "plus", color: accentBlue, action: #selector(joinMeetingTapped)),
             makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingWebTapped))
         ])
         actions.orientation = .horizontal
@@ -3105,6 +3407,13 @@ class ViewController: NSViewController {
     }
 }
 
+extension ViewController: NSWindowDelegate {
+    func windowWillClose(_ notification: Notification) {
+        guard let window = notification.object as? NSWindow, window === joinMeetingWindow else { return }
+        resetJoinMeetingPanelReferences()
+    }
+}
+
 private extension Array {
     subscript(safe index: Int) -> Element? {
         guard index >= 0, index < count else { return nil }
@@ -3112,6 +3421,156 @@ private extension Array {
     }
 }
 
+/// Vertical centering without changing `drawingRect` (so the field editor keeps a full-height frame and typing works).
+private final class JoinPanelVerticallyCenteredTextFieldCell: NSTextFieldCell {
+    private func lineHeight() -> CGFloat {
+        guard let font = font else { return 0 }
+        return ceil(font.ascender - font.descender + font.leading)
+    }
+
+    private func verticalMargin(forBoundsHeight h: CGFloat) -> CGFloat {
+        let lh = lineHeight()
+        guard lh > 0, h > lh else { return 0 }
+        return max(0, floor((h - lh) / 2))
+    }
+
+    private func verticallyCenteredInteriorFrame(_ cellFrame: NSRect) -> NSRect {
+        let lh = lineHeight()
+        guard lh > 0 else { return cellFrame }
+        let m = verticalMargin(forBoundsHeight: cellFrame.height)
+        var r = cellFrame
+        r.origin.y += m
+        r.size.height = lh
+        return r
+    }
+
+    override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
+        super.drawInterior(withFrame: verticallyCenteredInteriorFrame(cellFrame), in: controlView)
+    }
+
+    override func setUpFieldEditorAttributes(_ textObj: NSText) -> NSText {
+        let text = super.setUpFieldEditorAttributes(textObj)
+        guard let tv = text as? NSTextView else { return text }
+        // Bounds can be 0 on first editor setup; join fields are laid out at 42pt tall inside the pill.
+        let h = max(controlView?.bounds.height ?? 0, 42)
+        let margin = verticalMargin(forBoundsHeight: h)
+        if margin > 0 {
+            // NSTextView still uses `NSSize` here: horizontal inset, vertical inset from bounds origin (symmetric padding).
+            tv.textContainerInset = NSSize(width: 0, height: margin)
+        }
+        return text
+    }
+}
+
+/// Pill-shaped chrome with inset text; focus ring handled via edit notifications (reliable vs. cell overrides).
+private final class JoinPanelFieldContainer: NSView {
+    let textField: NSTextField
+    private let normalBorder: NSColor
+    private let focusBorder: NSColor
+    private let fill: NSColor
+    private var beginObserver: NSObjectProtocol?
+    private var endObserver: NSObjectProtocol?
+
+    init(
+        placeholder: String,
+        normalBorder: NSColor,
+        focusBorder: NSColor,
+        fill: NSColor,
+        primaryText: NSColor,
+        mutedText: NSColor
+    ) {
+        self.normalBorder = normalBorder
+        self.focusBorder = focusBorder
+        self.fill = fill
+        textField = NSTextField()
+        textField.cell = JoinPanelVerticallyCenteredTextFieldCell(textCell: "")
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        wantsLayer = true
+        // Do not clip subviews: `masksToBounds` + corner radius can clip the field editor (NSTextView) and block typing.
+        layer?.masksToBounds = false
+        layer?.cornerRadius = 23
+        layer?.backgroundColor = fill.cgColor
+        applyBorder(focused: false)
+
+        textField.font = .systemFont(ofSize: 14, weight: .regular)
+        textField.textColor = primaryText
+        textField.alignment = .center
+        textField.isEditable = true
+        textField.isSelectable = true
+        textField.refusesFirstResponder = false
+        textField.focusRingType = .none
+        textField.isBordered = false
+        textField.drawsBackground = false
+        textField.translatesAutoresizingMaskIntoConstraints = false
+        if let cell = textField.cell as? NSTextFieldCell {
+            cell.isBezeled = false
+            cell.drawsBackground = false
+            cell.alignment = .center
+            cell.usesSingleLineMode = true
+            cell.lineBreakMode = .byTruncatingTail
+            let placeholderParagraph = NSMutableParagraphStyle()
+            placeholderParagraph.alignment = .center
+            cell.placeholderAttributedString = NSAttributedString(
+                string: placeholder,
+                attributes: [
+                    .foregroundColor: mutedText.withAlphaComponent(0.88),
+                    .font: NSFont.systemFont(ofSize: 14, weight: .regular),
+                    .paragraphStyle: placeholderParagraph
+                ]
+            )
+        }
+        addSubview(textField)
+        NSLayoutConstraint.activate([
+            textField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
+            textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
+            textField.topAnchor.constraint(equalTo: topAnchor, constant: 2),
+            textField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2),
+            heightAnchor.constraint(equalToConstant: 46)
+        ])
+
+        beginObserver = NotificationCenter.default.addObserver(
+            forName: NSControl.textDidBeginEditingNotification,
+            object: textField,
+            queue: .main
+        ) { [weak self] _ in
+            self?.applyBorder(focused: true)
+        }
+        endObserver = NotificationCenter.default.addObserver(
+            forName: NSControl.textDidEndEditingNotification,
+            object: textField,
+            queue: .main
+        ) { [weak self] _ in
+            self?.applyBorder(focused: false)
+        }
+    }
+
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    deinit {
+        if let beginObserver {
+            NotificationCenter.default.removeObserver(beginObserver)
+        }
+        if let endObserver {
+            NotificationCenter.default.removeObserver(endObserver)
+        }
+    }
+
+    private func applyBorder(focused: Bool) {
+        layer?.borderColor = (focused ? focusBorder : normalBorder).cgColor
+        layer?.borderWidth = focused ? 1.5 : 1
+    }
+
+    override func viewDidMoveToWindow() {
+        super.viewDidMoveToWindow()
+        if window == nil {
+            applyBorder(focused: false)
+        }
+    }
+}
+
 private final class SearchPillTextField: NSTextField {
     var onFocusChange: ((Bool) -> Void)?
     private(set) var isSearchFocused = false