|
|
@@ -190,6 +190,13 @@ class ViewController: NSViewController {
|
|
190
|
190
|
private weak var topBarPremiumButton: NSButton?
|
|
191
|
191
|
private var paywallWindow: NSWindow?
|
|
192
|
192
|
private var joinMeetingWindow: NSWindow?
|
|
|
193
|
+ private var scheduleMeetingWindow: NSWindow?
|
|
|
194
|
+ private weak var scheduleTopicField: NSTextField?
|
|
|
195
|
+ private weak var scheduleDateField: NSTextField?
|
|
|
196
|
+ private weak var scheduleTimeField: NSTextField?
|
|
|
197
|
+ private weak var scheduleTimeZoneCombo: NSComboBox?
|
|
|
198
|
+ private weak var scheduleDurationField: NSTextField?
|
|
|
199
|
+ private weak var scheduleSubmitButton: NSButton?
|
|
193
|
200
|
private weak var joinURLField: NSTextField?
|
|
194
|
201
|
private weak var joinMeetingIDField: NSTextField?
|
|
195
|
202
|
private weak var joinPasscodeField: NSTextField?
|
|
|
@@ -550,12 +557,392 @@ class ViewController: NSViewController {
|
|
550
|
557
|
}
|
|
551
|
558
|
}
|
|
552
|
559
|
|
|
553
|
|
- @objc private func scheduleMeetingWebTapped() {
|
|
554
|
|
- guard let url = URL(string: "https://zoom.us/meeting/schedule") else { return }
|
|
555
|
|
- let opened = NSWorkspace.shared.open(url)
|
|
556
|
|
- if opened == false {
|
|
557
|
|
- meetingsStatusLabel?.stringValue = "Unable to open Zoom schedule page."
|
|
|
560
|
+ @objc private func scheduleMeetingTapped() {
|
|
|
561
|
+ if let existing = scheduleMeetingWindow {
|
|
|
562
|
+ existing.makeKeyAndOrderFront(nil)
|
|
|
563
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
564
|
+ DispatchQueue.main.async { [weak self] in
|
|
|
565
|
+ _ = self?.scheduleTopicField?.becomeFirstResponder()
|
|
|
566
|
+ }
|
|
|
567
|
+ return
|
|
|
568
|
+ }
|
|
|
569
|
+
|
|
|
570
|
+ let content = makeScheduleMeetingPanelContent()
|
|
|
571
|
+ let controller = NSViewController()
|
|
|
572
|
+ controller.view = content
|
|
|
573
|
+
|
|
|
574
|
+ let panel = NSPanel(
|
|
|
575
|
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 580),
|
|
|
576
|
+ styleMask: [.titled, .closable, .fullSizeContentView],
|
|
|
577
|
+ backing: .buffered,
|
|
|
578
|
+ defer: false
|
|
|
579
|
+ )
|
|
|
580
|
+ panel.title = "Schedule a meeting"
|
|
|
581
|
+ panel.titleVisibility = .hidden
|
|
|
582
|
+ panel.titlebarAppearsTransparent = true
|
|
|
583
|
+ panel.hidesOnDeactivate = true
|
|
|
584
|
+ panel.isReleasedWhenClosed = false
|
|
|
585
|
+ panel.standardWindowButton(.closeButton)?.isHidden = true
|
|
|
586
|
+ panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
|
|
587
|
+ panel.standardWindowButton(.zoomButton)?.isHidden = true
|
|
|
588
|
+ panel.isMovableByWindowBackground = true
|
|
|
589
|
+ panel.becomesKeyOnlyIfNeeded = false
|
|
|
590
|
+ panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
|
|
|
591
|
+ panel.center()
|
|
|
592
|
+ panel.contentViewController = controller
|
|
|
593
|
+ panel.delegate = self
|
|
|
594
|
+ applyWindowBackgroundForCurrentTheme(panel)
|
|
|
595
|
+ panel.makeKeyAndOrderFront(nil)
|
|
|
596
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
597
|
+ scheduleMeetingWindow = panel
|
|
|
598
|
+ DispatchQueue.main.async { [weak self] in
|
|
|
599
|
+ _ = self?.scheduleTopicField?.becomeFirstResponder()
|
|
|
600
|
+ }
|
|
|
601
|
+ }
|
|
|
602
|
+
|
|
|
603
|
+ private func makeScheduleMeetingPanelContent() -> NSView {
|
|
|
604
|
+ let root = NSView()
|
|
|
605
|
+ root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
606
|
+ root.wantsLayer = true
|
|
|
607
|
+ root.layer?.backgroundColor = appBackground.cgColor
|
|
|
608
|
+
|
|
|
609
|
+ let titleLabel = makeLabel("Schedule a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
|
|
|
610
|
+ let subtitleLabel = makeLabel("Creates a Zoom meeting on your account", size: 12, color: mutedText, weight: .regular, centered: false)
|
|
|
611
|
+
|
|
|
612
|
+ let closeButton = HoverButton(title: "✕", target: self, action: #selector(scheduleMeetingCancelTapped))
|
|
|
613
|
+ closeButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
614
|
+ closeButton.isBordered = false
|
|
|
615
|
+ closeButton.bezelStyle = .regularSquare
|
|
|
616
|
+ closeButton.wantsLayer = true
|
|
|
617
|
+ closeButton.layer?.cornerRadius = 14
|
|
|
618
|
+ closeButton.normalColor = palette.inputBackground
|
|
|
619
|
+ closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
|
|
|
620
|
+ closeButton.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
621
|
+ closeButton.layer?.borderWidth = 1
|
|
|
622
|
+ closeButton.font = .systemFont(ofSize: 13, weight: .bold)
|
|
|
623
|
+ closeButton.contentTintColor = secondaryText
|
|
|
624
|
+ closeButton.toolTip = "Close"
|
|
|
625
|
+ closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
626
|
+ closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
627
|
+
|
|
|
628
|
+ let titleRow = NSStackView()
|
|
|
629
|
+ titleRow.orientation = .horizontal
|
|
|
630
|
+ titleRow.alignment = .centerY
|
|
|
631
|
+ titleRow.spacing = 10
|
|
|
632
|
+ titleRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
633
|
+ let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
|
|
|
634
|
+ titleStack.orientation = .vertical
|
|
|
635
|
+ titleStack.spacing = 2
|
|
|
636
|
+ titleStack.alignment = .leading
|
|
|
637
|
+ let titleSpacer = NSView()
|
|
|
638
|
+ titleSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
639
|
+ titleRow.addArrangedSubview(titleStack)
|
|
|
640
|
+ titleRow.addArrangedSubview(titleSpacer)
|
|
|
641
|
+ titleRow.addArrangedSubview(closeButton)
|
|
|
642
|
+
|
|
|
643
|
+ let headerDivider = NSView()
|
|
|
644
|
+ headerDivider.wantsLayer = true
|
|
|
645
|
+ headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
|
|
|
646
|
+ headerDivider.translatesAutoresizingMaskIntoConstraints = false
|
|
|
647
|
+
|
|
|
648
|
+ let topicBox = makeJoinFormField(placeholder: "Meeting topic")
|
|
|
649
|
+ scheduleTopicField = topicBox.textField
|
|
|
650
|
+ topicBox.textField.stringValue = "Zoom meeting"
|
|
|
651
|
+
|
|
|
652
|
+ let defaultStart = Date().addingTimeInterval(3600)
|
|
|
653
|
+ let dateOnlyFormatter = DateFormatter()
|
|
|
654
|
+ dateOnlyFormatter.locale = Locale(identifier: "en_US_POSIX")
|
|
|
655
|
+ dateOnlyFormatter.timeZone = TimeZone.current
|
|
|
656
|
+ dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
|
|
|
657
|
+ let time12Formatter = DateFormatter()
|
|
|
658
|
+ time12Formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
|
659
|
+ time12Formatter.timeZone = TimeZone.current
|
|
|
660
|
+ time12Formatter.dateFormat = "h:mm a"
|
|
|
661
|
+
|
|
|
662
|
+ let dateBox = makeJoinFormField(placeholder: "YYYY-MM-DD")
|
|
|
663
|
+ scheduleDateField = dateBox.textField
|
|
|
664
|
+ dateBox.textField.stringValue = dateOnlyFormatter.string(from: defaultStart)
|
|
|
665
|
+
|
|
|
666
|
+ let timeBox = makeJoinFormField(placeholder: "2:30 PM")
|
|
|
667
|
+ scheduleTimeField = timeBox.textField
|
|
|
668
|
+ timeBox.textField.stringValue = time12Formatter.string(from: defaultStart)
|
|
|
669
|
+
|
|
|
670
|
+ let timeHint = makeLabel("12-hour time in the timezone below · example 2:30 PM", size: 11, color: mutedText, weight: .regular, centered: false)
|
|
|
671
|
+
|
|
|
672
|
+ let tzLabel = makeLabel("Timezone", size: 12, color: secondaryText, weight: .medium, centered: false)
|
|
|
673
|
+ let tzCombo = NSComboBox()
|
|
|
674
|
+ tzCombo.translatesAutoresizingMaskIntoConstraints = false
|
|
|
675
|
+ tzCombo.font = .systemFont(ofSize: 14, weight: .regular)
|
|
|
676
|
+ tzCombo.textColor = primaryText
|
|
|
677
|
+ tzCombo.backgroundColor = palette.inputBackground
|
|
|
678
|
+ tzCombo.isSelectable = true
|
|
|
679
|
+ tzCombo.completes = true
|
|
|
680
|
+ tzCombo.numberOfVisibleItems = 10
|
|
|
681
|
+ tzCombo.addItems(withObjectValues: TimeZone.knownTimeZoneIdentifiers.sorted())
|
|
|
682
|
+ tzCombo.stringValue = TimeZone.current.identifier
|
|
|
683
|
+ tzCombo.toolTip = "IANA timezone (type to filter). Meeting start is interpreted in this zone."
|
|
|
684
|
+ scheduleTimeZoneCombo = tzCombo
|
|
|
685
|
+ let tzHint = makeLabel("Type to search, e.g. America/New_York or Europe/London", size: 11, color: mutedText, weight: .regular, centered: false)
|
|
|
686
|
+
|
|
|
687
|
+ let tzStack = NSStackView(views: [tzLabel, tzCombo, tzHint])
|
|
|
688
|
+ tzStack.orientation = .vertical
|
|
|
689
|
+ tzStack.spacing = 8
|
|
|
690
|
+ tzStack.alignment = .leading
|
|
|
691
|
+ tzStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
692
|
+
|
|
|
693
|
+ let durationBox = makeJoinFormField(placeholder: "Duration in minutes")
|
|
|
694
|
+ durationBox.textField.stringValue = "60"
|
|
|
695
|
+ scheduleDurationField = durationBox.textField
|
|
|
696
|
+
|
|
|
697
|
+ let topicLabel = makeLabel("Topic", size: 12, color: secondaryText, weight: .medium, centered: false)
|
|
|
698
|
+ let startLabel = makeLabel("Start date & time", size: 12, color: secondaryText, weight: .medium, centered: false)
|
|
|
699
|
+ let durationLabel = makeLabel("Duration", size: 12, color: secondaryText, weight: .medium, centered: false)
|
|
|
700
|
+
|
|
|
701
|
+ let topicStack = NSStackView(views: [topicLabel, topicBox])
|
|
|
702
|
+ topicStack.orientation = .vertical
|
|
|
703
|
+ topicStack.spacing = 8
|
|
|
704
|
+ topicStack.alignment = .leading
|
|
|
705
|
+ topicStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
706
|
+
|
|
|
707
|
+ let dateTimeRow = NSStackView(views: [dateBox, timeBox])
|
|
|
708
|
+ dateTimeRow.orientation = .horizontal
|
|
|
709
|
+ dateTimeRow.spacing = 10
|
|
|
710
|
+ dateTimeRow.alignment = .top
|
|
|
711
|
+ dateTimeRow.distribution = .fillEqually
|
|
|
712
|
+ dateTimeRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
713
|
+
|
|
|
714
|
+ let startStack = NSStackView(views: [startLabel, dateTimeRow, timeHint])
|
|
|
715
|
+ startStack.orientation = .vertical
|
|
|
716
|
+ startStack.spacing = 8
|
|
|
717
|
+ startStack.alignment = .leading
|
|
|
718
|
+ startStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
719
|
+
|
|
|
720
|
+ let durationStack = NSStackView(views: [durationLabel, durationBox])
|
|
|
721
|
+ durationStack.orientation = .vertical
|
|
|
722
|
+ durationStack.spacing = 8
|
|
|
723
|
+ durationStack.alignment = .leading
|
|
|
724
|
+ durationStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
725
|
+
|
|
|
726
|
+ let formCard = NSView()
|
|
|
727
|
+ formCard.translatesAutoresizingMaskIntoConstraints = false
|
|
|
728
|
+ formCard.wantsLayer = true
|
|
|
729
|
+ formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
|
|
|
730
|
+ formCard.layer?.cornerRadius = 14
|
|
|
731
|
+ formCard.layer?.borderWidth = 1
|
|
|
732
|
+ formCard.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
733
|
+
|
|
|
734
|
+ let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(scheduleMeetingCancelTapped))
|
|
|
735
|
+ cancelButton.isBordered = false
|
|
|
736
|
+ cancelButton.wantsLayer = true
|
|
|
737
|
+ cancelButton.layer?.cornerRadius = 10
|
|
|
738
|
+ cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
739
|
+ cancelButton.layer?.borderWidth = 1
|
|
|
740
|
+ cancelButton.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
741
|
+ cancelButton.contentTintColor = primaryText
|
|
|
742
|
+ cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
743
|
+
|
|
|
744
|
+ let submitButton = HoverButton(title: "Schedule", target: self, action: #selector(scheduleMeetingSubmitTapped))
|
|
|
745
|
+ submitButton.isBordered = false
|
|
|
746
|
+ submitButton.wantsLayer = true
|
|
|
747
|
+ submitButton.layer?.cornerRadius = 10
|
|
|
748
|
+ submitButton.normalColor = accentBlue
|
|
|
749
|
+ submitButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
|
|
|
750
|
+ submitButton.contentTintColor = .white
|
|
|
751
|
+ submitButton.font = .systemFont(ofSize: 13, weight: .bold)
|
|
|
752
|
+ submitButton.keyEquivalent = "\r"
|
|
|
753
|
+ scheduleSubmitButton = submitButton
|
|
|
754
|
+
|
|
|
755
|
+ let buttons = NSStackView(views: [cancelButton, submitButton])
|
|
|
756
|
+ buttons.orientation = .horizontal
|
|
|
757
|
+ buttons.spacing = 12
|
|
|
758
|
+ buttons.alignment = .centerY
|
|
|
759
|
+ buttons.distribution = .fillEqually
|
|
|
760
|
+
|
|
|
761
|
+ let innerStack = NSStackView(views: [topicStack, tzStack, startStack, durationStack, buttons])
|
|
|
762
|
+ innerStack.orientation = .vertical
|
|
|
763
|
+ innerStack.spacing = 18
|
|
|
764
|
+ innerStack.alignment = .leading
|
|
|
765
|
+ innerStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
766
|
+ formCard.addSubview(innerStack)
|
|
|
767
|
+
|
|
|
768
|
+ [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
|
|
|
769
|
+
|
|
|
770
|
+ NSLayoutConstraint.activate([
|
|
|
771
|
+ titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
|
|
|
772
|
+ titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
|
|
|
773
|
+ titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
|
|
|
774
|
+
|
|
|
775
|
+ headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
|
|
|
776
|
+ headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
|
|
|
777
|
+ headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
|
|
|
778
|
+ headerDivider.heightAnchor.constraint(equalToConstant: 1),
|
|
|
779
|
+
|
|
|
780
|
+ formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
|
|
|
781
|
+ formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
|
|
|
782
|
+ formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
|
|
|
783
|
+ formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
|
|
|
784
|
+
|
|
|
785
|
+ innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
|
|
|
786
|
+ innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
|
|
|
787
|
+ innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
|
|
|
788
|
+ innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
|
|
|
789
|
+
|
|
|
790
|
+ topicBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
|
|
|
791
|
+ tzCombo.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
|
|
|
792
|
+ tzCombo.heightAnchor.constraint(equalToConstant: 46),
|
|
|
793
|
+ dateTimeRow.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
|
|
|
794
|
+ durationBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
|
|
|
795
|
+ buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
|
|
|
796
|
+ submitButton.heightAnchor.constraint(equalToConstant: 40),
|
|
|
797
|
+ cancelButton.heightAnchor.constraint(equalToConstant: 40)
|
|
|
798
|
+ ])
|
|
|
799
|
+
|
|
|
800
|
+ return root
|
|
|
801
|
+ }
|
|
|
802
|
+
|
|
|
803
|
+ @objc private func scheduleMeetingCancelTapped() {
|
|
|
804
|
+ scheduleMeetingWindow?.performClose(nil)
|
|
|
805
|
+ }
|
|
|
806
|
+
|
|
|
807
|
+ @objc private func scheduleMeetingSubmitTapped() {
|
|
|
808
|
+ let topicRaw = scheduleTopicField?.stringValue ?? ""
|
|
|
809
|
+ let topic = topicRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
810
|
+ guard topic.isEmpty == false else {
|
|
|
811
|
+ showSimpleAlert(title: "Topic required", message: "Enter a name for your meeting.")
|
|
|
812
|
+ return
|
|
558
|
813
|
}
|
|
|
814
|
+ let tzId = scheduleTimeZoneCombo?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
815
|
+ if tzId.isEmpty == false, TimeZone(identifier: tzId) == nil {
|
|
|
816
|
+ showSimpleAlert(
|
|
|
817
|
+ title: "Timezone",
|
|
|
818
|
+ message: "Enter a valid IANA timezone (pick from the list or type to search), for example America/New_York."
|
|
|
819
|
+ )
|
|
|
820
|
+ return
|
|
|
821
|
+ }
|
|
|
822
|
+ guard let draft = scheduleMeetingDraftFromFormFields() else {
|
|
|
823
|
+ showSimpleAlert(
|
|
|
824
|
+ title: "Date & time",
|
|
|
825
|
+ message: "Enter the date as YYYY-MM-DD and the time in 12-hour form with AM or PM (in the selected timezone), for example 2026-04-17 and 2:30 PM."
|
|
|
826
|
+ )
|
|
|
827
|
+ return
|
|
|
828
|
+ }
|
|
|
829
|
+ if draft.startDate < Date().addingTimeInterval(-30) {
|
|
|
830
|
+ showSimpleAlert(title: "Start time", message: "Choose a start time in the future.")
|
|
|
831
|
+ return
|
|
|
832
|
+ }
|
|
|
833
|
+ let durationRaw = scheduleDurationField?.stringValue ?? ""
|
|
|
834
|
+ let digits = durationRaw.filter(\.isNumber)
|
|
|
835
|
+ guard let duration = Int(digits), duration >= 1, duration <= 24 * 60 else {
|
|
|
836
|
+ showSimpleAlert(title: "Duration", message: "Enter duration in minutes (1–1440).")
|
|
|
837
|
+ return
|
|
|
838
|
+ }
|
|
|
839
|
+
|
|
|
840
|
+ scheduleSubmitButton?.isEnabled = false
|
|
|
841
|
+ let tz = draft.timeZone
|
|
|
842
|
+ Task {
|
|
|
843
|
+ do {
|
|
|
844
|
+ let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
|
|
|
845
|
+ guard configured else {
|
|
|
846
|
+ await MainActor.run { self.scheduleSubmitButton?.isEnabled = true }
|
|
|
847
|
+ return
|
|
|
848
|
+ }
|
|
|
849
|
+ let token = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
850
|
+ let result = try await createZoomMeeting(
|
|
|
851
|
+ accessToken: token,
|
|
|
852
|
+ topic: topic,
|
|
|
853
|
+ startTimeWithOffset: draft.startTimeWithOffset,
|
|
|
854
|
+ durationMinutes: duration,
|
|
|
855
|
+ timeZone: tz
|
|
|
856
|
+ )
|
|
|
857
|
+ await MainActor.run {
|
|
|
858
|
+ self.scheduleSubmitButton?.isEnabled = true
|
|
|
859
|
+ self.scheduleMeetingWindow?.performClose(nil)
|
|
|
860
|
+ self.meetingsStatusLabel?.stringValue = "Meeting scheduled."
|
|
|
861
|
+ self.triggerMeetingsRefresh(force: true)
|
|
|
862
|
+ if let join = result.join_url, join.isEmpty == false {
|
|
|
863
|
+ NSPasteboard.general.clearContents()
|
|
|
864
|
+ NSPasteboard.general.setString(join, forType: .string)
|
|
|
865
|
+ self.showSimpleAlert(
|
|
|
866
|
+ title: "Meeting scheduled",
|
|
|
867
|
+ message: "The join link was copied to your clipboard. It will also appear in your upcoming meetings list below."
|
|
|
868
|
+ )
|
|
|
869
|
+ } else {
|
|
|
870
|
+ self.showSimpleAlert(title: "Meeting scheduled", message: "The meeting was added to your list.")
|
|
|
871
|
+ }
|
|
|
872
|
+ }
|
|
|
873
|
+ } catch {
|
|
|
874
|
+ await MainActor.run {
|
|
|
875
|
+ self.scheduleSubmitButton?.isEnabled = true
|
|
|
876
|
+ if case ZoomOAuthError.missingRequiredScope(_) = error {
|
|
|
877
|
+ self.zoomOAuth.clearSavedTokens()
|
|
|
878
|
+ self.showSimpleAlert(
|
|
|
879
|
+ title: "Zoom permissions",
|
|
|
880
|
+ message: "Your Zoom app needs the meeting:write scope to schedule meetings. Add it in the Zoom Marketplace app settings, then sign in again."
|
|
|
881
|
+ )
|
|
|
882
|
+ } else if case ZoomOAuthError.rateLimited(let retryAfter) = error {
|
|
|
883
|
+ let seconds = max(retryAfter ?? 300, 30)
|
|
|
884
|
+ let minutes = Int(ceil(Double(seconds) / 60.0))
|
|
|
885
|
+ self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before scheduling again. Try in about \(minutes) min.")
|
|
|
886
|
+ } else {
|
|
|
887
|
+ self.showSimpleError("Could not schedule", error: error)
|
|
|
888
|
+ }
|
|
|
889
|
+ }
|
|
|
890
|
+ }
|
|
|
891
|
+ }
|
|
|
892
|
+ }
|
|
|
893
|
+
|
|
|
894
|
+ private func resetScheduleMeetingPanelReferences() {
|
|
|
895
|
+ scheduleMeetingWindow = nil
|
|
|
896
|
+ scheduleTopicField = nil
|
|
|
897
|
+ scheduleDateField = nil
|
|
|
898
|
+ scheduleTimeField = nil
|
|
|
899
|
+ scheduleTimeZoneCombo = nil
|
|
|
900
|
+ scheduleDurationField = nil
|
|
|
901
|
+ scheduleSubmitButton = nil
|
|
|
902
|
+ }
|
|
|
903
|
+
|
|
|
904
|
+ /// Resolves the schedule panel timezone (IANA id from combo, or system default).
|
|
|
905
|
+ private func resolvedScheduleTimeZoneForMeeting() -> TimeZone {
|
|
|
906
|
+ let raw = scheduleTimeZoneCombo?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
907
|
+ guard raw.isEmpty == false, let tz = TimeZone(identifier: raw) else { return TimeZone.current }
|
|
|
908
|
+ return tz
|
|
|
909
|
+ }
|
|
|
910
|
+
|
|
|
911
|
+ private struct ScheduleMeetingDraft {
|
|
|
912
|
+ let startDate: Date
|
|
|
913
|
+ let startTimeWithOffset: String
|
|
|
914
|
+ let timeZone: TimeZone
|
|
|
915
|
+ }
|
|
|
916
|
+
|
|
|
917
|
+ /// Keeps entered local date/time exact for Zoom (`start_time`) while also producing `Date` for validation.
|
|
|
918
|
+ private func scheduleMeetingDraftFromFormFields() -> ScheduleMeetingDraft? {
|
|
|
919
|
+ let datePart = scheduleDateField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
920
|
+ let timePart = scheduleTimeField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
921
|
+ guard datePart.isEmpty == false, timePart.isEmpty == false else { return nil }
|
|
|
922
|
+ let tz = resolvedScheduleTimeZoneForMeeting()
|
|
|
923
|
+ let normalizedTime = timePart.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
|
|
924
|
+ let combined = "\(datePart) \(normalizedTime)"
|
|
|
925
|
+ let df = DateFormatter()
|
|
|
926
|
+ df.locale = Locale(identifier: "en_US_POSIX")
|
|
|
927
|
+ df.timeZone = tz
|
|
|
928
|
+ df.isLenient = true
|
|
|
929
|
+ let formats = ["yyyy-MM-dd h:mm a", "yyyy-MM-dd hh:mm a", "yyyy-MM-dd h:mm:ss a"]
|
|
|
930
|
+ for format in formats {
|
|
|
931
|
+ df.dateFormat = format
|
|
|
932
|
+ if let d = df.date(from: combined) {
|
|
|
933
|
+ let localFormatter = DateFormatter()
|
|
|
934
|
+ localFormatter.locale = Locale(identifier: "en_US_POSIX")
|
|
|
935
|
+ localFormatter.timeZone = tz
|
|
|
936
|
+ localFormatter.calendar = Calendar(identifier: .gregorian)
|
|
|
937
|
+ localFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX"
|
|
|
938
|
+ return ScheduleMeetingDraft(
|
|
|
939
|
+ startDate: d,
|
|
|
940
|
+ startTimeWithOffset: localFormatter.string(from: d),
|
|
|
941
|
+ timeZone: tz
|
|
|
942
|
+ )
|
|
|
943
|
+ }
|
|
|
944
|
+ }
|
|
|
945
|
+ return nil
|
|
559
|
946
|
}
|
|
560
|
947
|
|
|
561
|
948
|
@objc private func joinMeetingTapped() {
|
|
|
@@ -1250,7 +1637,7 @@ class ViewController: NSViewController {
|
|
1250
|
1637
|
let alert = NSAlert()
|
|
1251
|
1638
|
alert.alertStyle = .informational
|
|
1252
|
1639
|
alert.messageText = "Configure Zoom OAuth"
|
|
1253
|
|
- alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically."
|
|
|
1640
|
+ alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically. Enable meeting:read and meeting:write scopes on the app so listing and scheduling work."
|
|
1254
|
1641
|
|
|
1255
|
1642
|
let wrapper = NSStackView()
|
|
1256
|
1643
|
wrapper.orientation = .vertical
|
|
|
@@ -1332,6 +1719,54 @@ class ViewController: NSViewController {
|
|
1332
|
1719
|
return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
|
|
1333
|
1720
|
}
|
|
1334
|
1721
|
|
|
|
1722
|
+ private struct ZoomCreateMeetingBody: Encodable {
|
|
|
1723
|
+ let topic: String
|
|
|
1724
|
+ let type: Int
|
|
|
1725
|
+ let start_time: String
|
|
|
1726
|
+ let duration: Int
|
|
|
1727
|
+ let timezone: String
|
|
|
1728
|
+ }
|
|
|
1729
|
+
|
|
|
1730
|
+ private struct ZoomCreateMeetingAPIResult: Decodable {
|
|
|
1731
|
+ let join_url: String?
|
|
|
1732
|
+ }
|
|
|
1733
|
+
|
|
|
1734
|
+ /// Creates a scheduled meeting via [Zoom Create meeting](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate).
|
|
|
1735
|
+ private func createZoomMeeting(accessToken: String, topic: String, startTimeWithOffset: String, durationMinutes: Int, timeZone: TimeZone) async throws -> ZoomCreateMeetingAPIResult {
|
|
|
1736
|
+ let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
|
|
|
1737
|
+ var request = URLRequest(url: url)
|
|
|
1738
|
+ request.httpMethod = "POST"
|
|
|
1739
|
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
|
1740
|
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
1741
|
+
|
|
|
1742
|
+ let body = ZoomCreateMeetingBody(
|
|
|
1743
|
+ topic: topic,
|
|
|
1744
|
+ type: 2,
|
|
|
1745
|
+ start_time: startTimeWithOffset,
|
|
|
1746
|
+ duration: durationMinutes,
|
|
|
1747
|
+ timezone: timeZone.identifier
|
|
|
1748
|
+ )
|
|
|
1749
|
+ request.httpBody = try JSONEncoder().encode(body)
|
|
|
1750
|
+
|
|
|
1751
|
+ let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
1752
|
+ guard let http = response as? HTTPURLResponse else {
|
|
|
1753
|
+ throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
|
|
|
1754
|
+ }
|
|
|
1755
|
+ if http.statusCode == 429 {
|
|
|
1756
|
+ let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
|
|
|
1757
|
+ let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
|
|
|
1758
|
+ throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
|
|
|
1759
|
+ }
|
|
|
1760
|
+ guard (200..<300).contains(http.statusCode) else {
|
|
|
1761
|
+ let raw = String(data: data, encoding: .utf8) ?? "Failed to create meeting"
|
|
|
1762
|
+ if raw.localizedCaseInsensitiveContains("does not contain scopes") {
|
|
|
1763
|
+ throw ZoomOAuthError.missingRequiredScope(raw)
|
|
|
1764
|
+ }
|
|
|
1765
|
+ throw GoogleOAuthError.tokenExchangeFailed(raw)
|
|
|
1766
|
+ }
|
|
|
1767
|
+ return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
|
|
|
1768
|
+ }
|
|
|
1769
|
+
|
|
1335
|
1770
|
private func fetchZoomScheduledMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
|
|
1336
|
1771
|
struct ZoomMeeting: Decodable {
|
|
1337
|
1772
|
let id: Int?
|
|
|
@@ -2591,7 +3026,7 @@ class ViewController: NSViewController {
|
|
2591
|
3026
|
let actions = NSStackView(views: [
|
|
2592
|
3027
|
makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange),
|
|
2593
|
3028
|
makeActionTile(title: "Join", symbol: "plus", color: accentBlue, action: #selector(joinMeetingTapped)),
|
|
2594
|
|
- makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingWebTapped))
|
|
|
3029
|
+ makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingTapped))
|
|
2595
|
3030
|
])
|
|
2596
|
3031
|
actions.orientation = .horizontal
|
|
2597
|
3032
|
actions.spacing = 12
|
|
|
@@ -3090,6 +3525,7 @@ class ViewController: NSViewController {
|
|
3090
|
3525
|
@MainActor
|
|
3091
|
3526
|
private func updateSelectedHomeSectionUI() {
|
|
3092
|
3527
|
let isHome = selectedHomeSidebarItem == "Home"
|
|
|
3528
|
+ let isScheduler = selectedHomeSidebarItem == "Scheduler"
|
|
3093
|
3529
|
let isSettings = selectedHomeSidebarItem == "Settings"
|
|
3094
|
3530
|
let title = selectedHomeSidebarItem
|
|
3095
|
3531
|
|
|
|
@@ -3109,7 +3545,8 @@ class ViewController: NSViewController {
|
|
3109
|
3545
|
meetingsScrollView,
|
|
3110
|
3546
|
refreshMeetingsButton
|
|
3111
|
3547
|
]
|
|
3112
|
|
- let hideDashboard = isHome == false || isSettings
|
|
|
3548
|
+ // Keep the main dashboard (including Schedule) visible on Home and Scheduler; other sidebar items are placeholders.
|
|
|
3549
|
+ let hideDashboard = (isHome == false && isScheduler == false) || isSettings
|
|
3113
|
3550
|
dashboardViews.forEach { $0?.isHidden = hideDashboard }
|
|
3114
|
3551
|
// Do not toggle emptyMeetingLabel with other dashboard views — that overrode applyFilteredMeetings()
|
|
3115
|
3552
|
// and showed "No meetings…" on top of meeting cards when returning to Home.
|
|
|
@@ -3121,7 +3558,7 @@ class ViewController: NSViewController {
|
|
3121
|
3558
|
}
|
|
3122
|
3559
|
homeSettingsView?.isHidden = isSettings == false
|
|
3123
|
3560
|
|
|
3124
|
|
- if isHome {
|
|
|
3561
|
+ if isHome || isScheduler {
|
|
3125
|
3562
|
homePlaceholderLabel?.isHidden = true
|
|
3126
|
3563
|
} else {
|
|
3127
|
3564
|
// Keep non-Home pages empty for now.
|
|
|
@@ -3409,8 +3846,13 @@ class ViewController: NSViewController {
|
|
3409
|
3846
|
|
|
3410
|
3847
|
extension ViewController: NSWindowDelegate {
|
|
3411
|
3848
|
func windowWillClose(_ notification: Notification) {
|
|
3412
|
|
- guard let window = notification.object as? NSWindow, window === joinMeetingWindow else { return }
|
|
3413
|
|
- resetJoinMeetingPanelReferences()
|
|
|
3849
|
+ guard let window = notification.object as? NSWindow else { return }
|
|
|
3850
|
+ if window === joinMeetingWindow {
|
|
|
3851
|
+ resetJoinMeetingPanelReferences()
|
|
|
3852
|
+ }
|
|
|
3853
|
+ if window === scheduleMeetingWindow {
|
|
|
3854
|
+ resetScheduleMeetingPanelReferences()
|
|
|
3855
|
+ }
|
|
3414
|
3856
|
}
|
|
3415
|
3857
|
}
|
|
3416
|
3858
|
|
|
|
@@ -3911,7 +4353,10 @@ final class ZoomOAuthService: NSObject {
|
|
3911
|
4353
|
return parts.contains { part in
|
|
3912
|
4354
|
part == "meeting:read"
|
|
3913
|
4355
|
|| part == "meeting:read:admin"
|
|
|
4356
|
+ || part == "meeting:write"
|
|
|
4357
|
+ || part == "meeting:write:admin"
|
|
3914
|
4358
|
|| part.contains("meeting:read")
|
|
|
4359
|
+ || part.contains("meeting:write")
|
|
3915
|
4360
|
|| part.contains("list_meetings")
|
|
3916
|
4361
|
|| part.contains("list_user_meetings")
|
|
3917
|
4362
|
}
|