diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 31acac8..958ed34 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -10809,6 +10809,9 @@ } } } + }, + "Join the meeting" : { + }, "Keyboard Backlight HUD" : { "localizations" : { @@ -12429,6 +12432,9 @@ } } } + }, + "Meeting starts at %@" : { + }, "Memory Usage" : { "localizations" : { diff --git a/DynamicIsland/Providers/CalendarServiceProviding.swift b/DynamicIsland/Providers/CalendarServiceProviding.swift index 963760e..f7ca046 100644 --- a/DynamicIsland/Providers/CalendarServiceProviding.swift +++ b/DynamicIsland/Providers/CalendarServiceProviding.swift @@ -39,27 +39,19 @@ class CalendarService: CalendarServiceProviding { } private func performAccessRequest(for type: EKEntityType) async throws -> Bool { - if #available(macOS 14.0, *) { - switch type { - case .event: - return try await store.requestFullAccessToEvents() - case .reminder: - return try await store.requestFullAccessToReminders() - @unknown default: - return false - } - } else { - return try await store.requestAccess(to: type) + switch type { + case .event: + return try await store.requestFullAccessToEvents() + case .reminder: + return try await store.requestFullAccessToReminders() + @unknown default: + return false } } private func hasAccess(to entityType: EKEntityType) -> Bool { let status = EKEventStore.authorizationStatus(for: entityType) - if #available(macOS 14.0, *) { - return status == .fullAccess - } else { - return status == .authorized - } + return status == .fullAccess } func calendars() async -> [CalendarModel] { @@ -168,7 +160,8 @@ extension EventModel { participants: .init(from: event), timeZone: calendar.isSubscribed || calendar.isDelegate ? nil : event.timeZone, hasRecurrenceRules: event.hasRecurrenceRules || event.isDetached, - priority: nil + priority: nil, + conferenceURL: event.extractConferenceURL() ) } @@ -192,7 +185,8 @@ extension EventModel { participants: [], timeZone: calendar.isSubscribed || calendar.isDelegate ? nil : reminder.timeZone, hasRecurrenceRules: reminder.hasRecurrenceRules, - priority: .init(from: reminder.priority) + priority: .init(from: reminder.priority), + conferenceURL: nil ) } } @@ -271,11 +265,7 @@ private extension EKCalendar { } var isDelegate: Bool { - if #available(macOS 13.0, *) { - return source.isDelegate - } else { - return false - } + return source.isDelegate } } @@ -291,6 +281,60 @@ private extension EKEvent { let endOfDay = calendar.dateInterval(of: .day, for: endDate)?.end return startDate == startOfDay && endDate == endOfDay } + + /// Extract conference call URL from various sources + func extractConferenceURL() -> URL? { + // First try the URL field if it's a conference URL + if let eventURL = url, isConferenceURL(eventURL) { + return eventURL + } + + // Then try to extract from location field + if let location = location, let conferenceURL = extractURLFromText(location) { + return conferenceURL + } + + // Finally try to extract from notes + if let notes = notes, let conferenceURL = extractURLFromText(notes) { + return conferenceURL + } + + return nil + } + + /// Check if a URL is likely a conference URL + private func isConferenceURL(_ url: URL) -> Bool { + let host = url.host?.lowercased() ?? "" + let conferenceHosts = [ + "zoom.us", + "teams.microsoft.com", + "meet.google.com", + "webex.com", + "gotomeeting.com", + "bluejeans.com", + "whereby.com", + "meet.jit.si", + "discord.gg", + "discord.com", + "facetime.apple.com" + ] + + return conferenceHosts.contains { host.contains($0) } + } + + /// Extract first valid conference URL from text + private func extractURLFromText(_ text: String) -> URL? { + let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + let matches = detector?.matches(in: text, options: [], range: NSRange(text.startIndex..., in: text)) + + for match in matches ?? [] { + if let url = match.url, isConferenceURL(url) { + return url + } + } + + return nil + } } private extension Calendar { diff --git a/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift b/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift index 4fa4f71..5dac248 100644 --- a/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift +++ b/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift @@ -429,6 +429,11 @@ struct EventListView: View { .foregroundColor(Color(white: 0.65)) .lineLimit(1) } + + // Show Join button if conference URL is available + if let conferenceURL = event.conferenceURL { + ConferenceJoinButton(url: conferenceURL, event: event) + } } Spacer(minLength: 0) VStack(alignment: .trailing, spacing: 4) { @@ -484,6 +489,104 @@ struct ReminderToggle: View { } } +// MARK: - Conference Provider + +enum ConferenceProvider: CaseIterable { + case zoom, teams, meet, webex, facetime, gotomeeting, bluejeans, whereby, jitsi, discord, generic + + var name: String { + switch self { + case .zoom: return "Zoom" + case .teams: return "Teams" + case .meet: return "Meet" + case .webex: return "Webex" + case .facetime: return "FaceTime" + case .gotomeeting: return "GoToMeeting" + case .bluejeans: return "BlueJeans" + case .whereby: return "Whereby" + case .jitsi: return "Jitsi" + case .discord: return "Discord" + case .generic: return "" + } + } + + var color: Color { + switch self { + case .zoom: return Color(red: 0.16, green: 0.52, blue: 0.95) + case .teams: return Color(red: 0.36, green: 0.42, blue: 0.89) + case .meet: return Color(red: 0.0, green: 0.65, blue: 0.42) + case .webex: return Color(red: 0.0, green: 0.71, blue: 0.84) + case .facetime: return Color(red: 0.2, green: 0.78, blue: 0.35) + case .gotomeeting: return Color(red: 0.95, green: 0.5, blue: 0.13) + case .bluejeans: return Color(red: 0.0, green: 0.48, blue: 0.87) + case .whereby: return Color(red: 0.27, green: 0.51, blue: 0.96) + case .jitsi: return Color(red: 0.16, green: 0.68, blue: 0.95) + case .discord: return Color(red: 0.35, green: 0.39, blue: 0.98) + case .generic: return Color.accentColor + } + } + + private var hostIdentifiers: [String] { + switch self { + case .zoom: return ["zoom.us"] + case .teams: return ["teams.microsoft.com"] + case .meet: return ["meet.google.com"] + case .webex: return ["webex.com"] + case .facetime: return ["facetime.apple.com"] + case .gotomeeting: return ["gotomeeting.com"] + case .bluejeans: return ["bluejeans.com"] + case .whereby: return ["whereby.com"] + case .jitsi: return ["meet.jit.si", "jitsi"] + case .discord: return ["discord.gg", "discord.com"] + case .generic: return [] + } + } + + static func detect(from url: URL) -> ConferenceProvider { + let host = url.host?.lowercased() ?? "" + return allCases.first { provider in + provider.hostIdentifiers.contains { host.contains($0) } + } ?? .generic + } +} + +// MARK: - Conference Join Button + +struct ConferenceJoinButton: View { + let url: URL + let event: EventModel + @Environment(\.openURL) private var openURL + + private var provider: ConferenceProvider { .detect(from: url) } + private var isJoinable: Bool { + event.start.addingTimeInterval(-15 * 60) <= Date() + } + + private var buttonText: String { + event.eventStatus == .inProgress ? "Rejoin" : "Join" + } + + var body: some View { + Button(action: { openURL(url) }) { + HStack(spacing: 4) { + Image(systemName: "video.fill") + .font(.system(size: 9)) + Text(provider.name.isEmpty ? buttonText : "\(buttonText) \(provider.name)") + .font(.caption2) + .fontWeight(.medium) + } + .foregroundColor(isJoinable ? .white : Color(white: 0.5)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(isJoinable ? provider.color.opacity(0.85) : Color.gray.opacity(0.3)) + .cornerRadius(6) + } + .buttonStyle(PlainButtonStyle()) + .disabled(!isJoinable) + .help(isJoinable ? "Join the meeting" : "Meeting starts at \(event.start.formatted(date: .omitted, time: .shortened))") + } +} + #Preview { CalendarView() .frame(width: 250) diff --git a/DynamicIsland/models/EventModel.swift b/DynamicIsland/models/EventModel.swift index aab5b15..267b913 100644 --- a/DynamicIsland/models/EventModel.swift +++ b/DynamicIsland/models/EventModel.swift @@ -24,6 +24,7 @@ struct EventModel: Equatable, Identifiable { let timeZone: TimeZone? let hasRecurrenceRules: Bool let priority: Priority? + let conferenceURL: URL? } enum AttendanceStatus: Comparable {