Merge pull request #219 from Ebullioscopic/feature/calendar

"Join Meeting" buttons in the Event List in the calendar
This commit is contained in:
Hariharan Mudaliar 2025-12-29 23:59:49 +05:30 • committed by GitHub
commit ce2013084e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 177 additions and 23 deletions

View file

@ -10809,6 +10809,9 @@
} }
} }
} }
},
"Join the meeting" : {
}, },
"Keyboard Backlight HUD" : { "Keyboard Backlight HUD" : {
"localizations" : { "localizations" : {
@ -12429,6 +12432,9 @@
} }
} }
} }
},
"Meeting starts at %@" : {
}, },
"Memory Usage" : { "Memory Usage" : {
"localizations" : { "localizations" : {

View file

@ -39,7 +39,6 @@ class CalendarService: CalendarServiceProviding {
} }
private func performAccessRequest(for type: EKEntityType) async throws -> Bool { private func performAccessRequest(for type: EKEntityType) async throws -> Bool {
if #available(macOS 14.0, *) {
switch type { switch type {
case .event: case .event:
return try await store.requestFullAccessToEvents() return try await store.requestFullAccessToEvents()
@ -48,18 +47,11 @@ class CalendarService: CalendarServiceProviding {
@unknown default: @unknown default:
return false return false
} }
} else {
return try await store.requestAccess(to: type)
}
} }
private func hasAccess(to entityType: EKEntityType) -> Bool { private func hasAccess(to entityType: EKEntityType) -> Bool {
let status = EKEventStore.authorizationStatus(for: entityType) let status = EKEventStore.authorizationStatus(for: entityType)
if #available(macOS 14.0, *) {
return status == .fullAccess return status == .fullAccess
} else {
return status == .authorized
}
} }
func calendars() async -> [CalendarModel] { func calendars() async -> [CalendarModel] {
@ -168,7 +160,8 @@ extension EventModel {
participants: .init(from: event), participants: .init(from: event),
timeZone: calendar.isSubscribed || calendar.isDelegate ? nil : event.timeZone, timeZone: calendar.isSubscribed || calendar.isDelegate ? nil : event.timeZone,
hasRecurrenceRules: event.hasRecurrenceRules || event.isDetached, hasRecurrenceRules: event.hasRecurrenceRules || event.isDetached,
priority: nil priority: nil,
conferenceURL: event.extractConferenceURL()
) )
} }
@ -192,7 +185,8 @@ extension EventModel {
participants: [], participants: [],
timeZone: calendar.isSubscribed || calendar.isDelegate ? nil : reminder.timeZone, timeZone: calendar.isSubscribed || calendar.isDelegate ? nil : reminder.timeZone,
hasRecurrenceRules: reminder.hasRecurrenceRules, 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 { var isDelegate: Bool {
if #available(macOS 13.0, *) {
return source.isDelegate return source.isDelegate
} else {
return false
}
} }
} }
@ -291,6 +281,60 @@ private extension EKEvent {
let endOfDay = calendar.dateInterval(of: .day, for: endDate)?.end let endOfDay = calendar.dateInterval(of: .day, for: endDate)?.end
return startDate == startOfDay && endDate == endOfDay 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 { private extension Calendar {

View file

@ -429,6 +429,11 @@ struct EventListView: View {
.foregroundColor(Color(white: 0.65)) .foregroundColor(Color(white: 0.65))
.lineLimit(1) .lineLimit(1)
} }
// Show Join button if conference URL is available
if let conferenceURL = event.conferenceURL {
ConferenceJoinButton(url: conferenceURL, event: event)
}
} }
Spacer(minLength: 0) Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: 4) { 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 { #Preview {
CalendarView() CalendarView()
.frame(width: 250) .frame(width: 250)

View file

@ -24,6 +24,7 @@ struct EventModel: Equatable, Identifiable {
let timeZone: TimeZone? let timeZone: TimeZone?
let hasRecurrenceRules: Bool let hasRecurrenceRules: Bool
let priority: Priority? let priority: Priority?
let conferenceURL: URL?
} }
enum AttendanceStatus: Comparable { enum AttendanceStatus: Comparable {