From be546b48ffb214b52b60220436a4007be4a6d607 Mon Sep 17 00:00:00 2001 From: Santiago Quihui Date: Sun, 28 Dec 2025 13:54:29 -0700 Subject: [PATCH] feat: add join button for conference calls --- DynamicIsland/Localizable.xcstrings | 16 ++++ .../Providers/CalendarServiceProviding.swift | 90 ++++++++++++++----- .../Calendar/DynamicIslandCalendar.swift | 73 +++++++++++++++ DynamicIsland/models/EventModel.swift | 1 + 4 files changed, 157 insertions(+), 23 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 31acac8..508b3f8 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -747,6 +747,16 @@ } } }, + "%@ %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@" + } + } + } + }, "%@ Models" : { "localizations" : { "fr" : { @@ -10809,6 +10819,9 @@ } } } + }, + "Join the meeting" : { + }, "Keyboard Backlight HUD" : { "localizations" : { @@ -12429,6 +12442,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..9fd68d1 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,74 @@ struct ReminderToggle: View { } } +struct ConferenceJoinButton: View { + let url: URL + let event: EventModel + @Environment(\.openURL) private var openURL + + private var isJoinable: Bool { + // Allow joining 15 minutes before the event starts + let joinableTime = event.start.addingTimeInterval(-15 * 60) + return Date() >= joinableTime + } + + private var buttonText: String { + if event.eventStatus == .inProgress { + return "Rejoin" + } else { + return "Join" + } + } + + private var conferenceService: String? { + let host = url.host?.lowercased() ?? "" + if host.contains("zoom.us") { + return "Zoom" + } else if host.contains("teams.microsoft.com") { + return "Teams" + } else if host.contains("meet.google.com") { + return "Meet" + } else if host.contains("webex.com") { + return "Webex" + } else if host.contains("facetime.apple.com") { + return "FaceTime" + } + return nil + } + + var body: some View { + Button(action: { + openURL(url) + }) { + HStack(spacing: 4) { + Image(systemName: "video.fill") + .font(.system(size: 9)) + if let service = conferenceService { + Text("\(buttonText) \(service)") + .font(.caption2) + .fontWeight(.medium) + } else { + Text(buttonText) + .font(.caption2) + .fontWeight(.medium) + } + } + .foregroundColor(isJoinable ? .white : Color(white: 0.5)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + isJoinable + ? Color.accentColor.opacity(0.8) + : 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 {