diff --git a/MeetingBar/Core/Models/MBEvent+Helpers.swift b/MeetingBar/Core/Models/MBEvent+Helpers.swift index 7c647ac7..456eeccd 100644 --- a/MeetingBar/Core/Models/MBEvent+Helpers.swift +++ b/MeetingBar/Core/Models/MBEvent+Helpers.swift @@ -172,4 +172,46 @@ public extension Array where Element == MBEvent { } return nextEvent } + + /// Returns the event that is currently in progress. + /// + /// Unlike `nextEvent()`, this does not depend on ongoing visibility settings. + /// - Parameter linkRequired: If `true`, only events with a meeting link are considered. + /// - Returns: The currently running event that passes filters, or `nil`. + func currentEvent(linkRequired: Bool = false) -> MBEvent? { + let now = Date() + + for event in self { + guard event.startDate <= now, event.endDate > now else { + continue + } + if Defaults[.dismissedEvents].contains(where: { $0.id == event.id }) { + continue + } + if event.isAllDay { + continue + } + if event.meetingLink == nil, linkRequired { + continue + } + if event.participationStatus == .declined { + continue + } + if event.participationStatus == .pending, + Defaults[.showPendingEvents] == .hide || Defaults[.showPendingEvents] == .show_inactive { + continue + } + if event.participationStatus == .tentative, + Defaults[.showTentativeEvents] == .hide || Defaults[.showTentativeEvents] == .show_inactive { + continue + } + if event.status == .canceled { + continue + } + + return event + } + + return nil + } } diff --git a/MeetingBar/Extensions/KeyboardShortcutsNames.swift b/MeetingBar/Extensions/KeyboardShortcutsNames.swift index ad166298..16ba1f7e 100644 --- a/MeetingBar/Extensions/KeyboardShortcutsNames.swift +++ b/MeetingBar/Extensions/KeyboardShortcutsNames.swift @@ -11,9 +11,16 @@ import KeyboardShortcuts extension KeyboardShortcuts.Name: @unchecked @retroactive Sendable {} extension KeyboardShortcuts.Name { + /// Global shortcut used to create an ad-hoc meeting. static let createMeetingShortcut = Self("createMeetingShortcut") + /// Global shortcut used to open the status bar menu. static let openMenuShortcut = Self("openMenuShortcut") + /// Global shortcut used to join the nearest meeting (current or next). static let joinEventShortcut = Self("joinEventShortcut") + /// Global shortcut used to join only the currently running meeting. + static let joinCurrentEventShortcut = Self("joinCurrentEventShortcut") + /// Global shortcut used to open a meeting link from clipboard. static let openClipboardShortcut = Self("openClipboardShortcut") + /// Global shortcut used to toggle status bar meeting title visibility. static let toggleMeetingTitleVisibilityShortcut = Self("toggleMeetingTitleVisibilityShortcut") } diff --git a/MeetingBar/UI/StatusBar/MenuBuilder.swift b/MeetingBar/UI/StatusBar/MenuBuilder.swift index e42ee158..a6884d5a 100644 --- a/MeetingBar/UI/StatusBar/MenuBuilder.swift +++ b/MeetingBar/UI/StatusBar/MenuBuilder.swift @@ -77,10 +77,13 @@ struct MenuBuilder { let joinItem = NSMenuItem( title: itemTitle, - action: #selector(StatusBarItemController.joinNextMeeting), + action: nextEvent.startDate < now + ? #selector(StatusBarItemController.joinCurrentMeeting) + : #selector(StatusBarItemController.joinNextMeeting), keyEquivalent: "" ) joinItem.target = target + joinItem.setShortcut(for: nextEvent.startDate < now ? .joinCurrentEventShortcut : .joinEventShortcut) items.append(joinItem) } diff --git a/MeetingBar/UI/StatusBar/StatusBarItemController.swift b/MeetingBar/UI/StatusBar/StatusBarItemController.swift index f663446a..072308e5 100644 --- a/MeetingBar/UI/StatusBar/StatusBarItemController.swift +++ b/MeetingBar/UI/StatusBar/StatusBarItemController.swift @@ -127,6 +127,10 @@ final class StatusBarItemController { private func setupKeyboardShortcuts() { KeyboardShortcuts.onKeyUp(for: .createMeetingShortcut, action: createMeeting) + KeyboardShortcuts.onKeyUp(for: .joinCurrentEventShortcut) { + Task { @MainActor in self.joinCurrentMeeting() } + } + KeyboardShortcuts.onKeyUp(for: .joinEventShortcut) { Task { @MainActor in self.joinNextMeeting() } } @@ -400,6 +404,18 @@ final class StatusBarItemController { createMeeting() } + @objc + /// Joins the meeting link for the event currently in progress. + /// + /// If there is no active event, a user-facing notification is shown. + func joinCurrentMeeting() { + if let currentEvent = events.currentEvent() { + currentEvent.openMeeting() + } else { + sendNotification("status_bar_section_join_current_meeting".loco(), "next_meeting_empty_message".loco()) + } + } + @objc func joinNextMeeting() { if let nextEvent = events.nextEvent() { diff --git a/MeetingBar/UI/Views/Preferences/GeneralTab.swift b/MeetingBar/UI/Views/Preferences/GeneralTab.swift index caa797bc..95439e21 100644 --- a/MeetingBar/UI/Views/Preferences/GeneralTab.swift +++ b/MeetingBar/UI/Views/Preferences/GeneralTab.swift @@ -43,6 +43,9 @@ struct ShortcutsSection: View { Text("preferences_general_shortcut_create_meeting".loco()) KeyboardShortcuts.Recorder(for: .createMeetingShortcut) + Text("status_bar_section_join_current_meeting".loco() + ":") + KeyboardShortcuts.Recorder(for: .joinCurrentEventShortcut) + Text("preferences_general_shortcut_join_next".loco()) KeyboardShortcuts.Recorder(for: .joinEventShortcut) @@ -74,6 +77,11 @@ struct ShortcutsModal: View { Spacer() KeyboardShortcuts.Recorder(for: .createMeetingShortcut) } + HStack { + Text("status_bar_section_join_current_meeting".loco() + ":") + Spacer() + KeyboardShortcuts.Recorder(for: .joinCurrentEventShortcut) + } HStack { Text("preferences_general_shortcut_join_next".loco()) Spacer() diff --git a/MeetingBarTests/NextEventTests.swift b/MeetingBarTests/NextEventTests.swift index fb9c1f50..99e54b58 100644 --- a/MeetingBarTests/NextEventTests.swift +++ b/MeetingBarTests/NextEventTests.swift @@ -131,4 +131,32 @@ class NextEventTests: BaseTestCase { let array = [future, running] XCTAssertEqual(array.nextEvent(), running) } + + func test_currentEvent_returnsRunningEvent() { + let running = makeFakeEvent( + id: "RUN", + start: now.addingTimeInterval(-120), + end: now.addingTimeInterval(600), + withLink: true + ) + let future = makeFakeEvent( + id: "FUT", + start: now.addingTimeInterval(120), + end: now.addingTimeInterval(600), + withLink: true + ) + + XCTAssertEqual([future, running].currentEvent(), running) + } + + func test_currentEvent_respectsLinkRequirement() { + let runningWithoutLink = makeFakeEvent( + id: "NO-LINK", + start: now.addingTimeInterval(-120), + end: now.addingTimeInterval(600), + withLink: false + ) + + XCTAssertNil([runningWithoutLink].currentEvent(linkRequired: true)) + } } diff --git a/MeetingBarTests/StatusBarItem/MenuBuilderTests.swift b/MeetingBarTests/StatusBarItem/MenuBuilderTests.swift index 5b1cc1de..9a3d1078 100644 --- a/MeetingBarTests/StatusBarItem/MenuBuilderTests.swift +++ b/MeetingBarTests/StatusBarItem/MenuBuilderTests.swift @@ -52,9 +52,23 @@ final class MenuBuilderTests: BaseTestCase { XCTAssertEqual(MenuBuilder.plainTitles(of: items)[0], "status_bar_section_join_current_meeting".loco()) + XCTAssertEqual(items[0].action, #selector(StatusBarItemController.joinCurrentMeeting)) XCTAssertTrue(items.contains { $0.action == #selector(StatusBarItemController.createMeetingAction) }) } + func test_joinSectionFutureEventUsesJoinNextAction() { + let future = makeFakeEvent( + id: "F", + start: Date().addingTimeInterval(300), + end: Date().addingTimeInterval(1_200) + ) + let items = MenuBuilder(target: Dummy()) + .buildJoinSection(nextEvent: future) + + XCTAssertEqual(items[0].title, "status_bar_section_join_next_meeting".loco()) + XCTAssertEqual(items[0].action, #selector(StatusBarItemController.joinNextMeeting)) + } + func test_joinSectionWithoutEvent() { let items = MenuBuilder(target: Dummy()) .buildJoinSection(nextEvent: nil)