From be546b48ffb214b52b60220436a4007be4a6d607 Mon Sep 17 00:00:00 2001 From: Santiago Quihui Date: Sun, 28 Dec 2025 13:54:29 -0700 Subject: [PATCH 1/2] 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 { From 43f40e71ed20aa7696f804f683450ba827709689 Mon Sep 17 00:00:00 2001 From: Santiago Quihui Date: Sun, 28 Dec 2025 14:22:48 -0700 Subject: [PATCH 2/2] different colors for providers --- DynamicIsland/Localizable.xcstrings | 10 -- .../Calendar/DynamicIslandCalendar.swift | 112 +++++++++++------- 2 files changed, 71 insertions(+), 51 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 508b3f8..958ed34 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -747,16 +747,6 @@ } } }, - "%@ %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@ %2$@" - } - } - } - }, "%@ Models" : { "localizations" : { "fr" : { diff --git a/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift b/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift index 9fd68d1..5dac248 100644 --- a/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift +++ b/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift @@ -489,66 +489,96 @@ 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 { - // Allow joining 15 minutes before the event starts - let joinableTime = event.start.addingTimeInterval(-15 * 60) - return Date() >= joinableTime + event.start.addingTimeInterval(-15 * 60) <= Date() } 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 + event.eventStatus == .inProgress ? "Rejoin" : "Join" } var body: some View { - Button(action: { - openURL(url) - }) { + 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) - } + 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 - ? Color.accentColor.opacity(0.8) - : Color.gray.opacity(0.3) - ) + .background(isJoinable ? provider.color.opacity(0.85) : Color.gray.opacity(0.3)) .cornerRadius(6) } .buttonStyle(PlainButtonStyle())