import SwiftUI #if os(iOS) import UIKit #else import AppKit #endif /// Call → Copy Invite Link (⌘⇧I): the meeting's invitation on the /// clipboard without opening the share sheet. /// /// It asks the server the way the Share button does — `POST /// /m/{sqid}/share`, which hands a member the meeting's current sealed /// invitation, and mints one only if there is none. It never replaces or /// revokes a link (see store.shareLink and /// TestShareReusesSealedInvitationAndPreservesLegacyLinks). public enum InviteLinkCopy { public enum Rule: Equatable, Sendable { /// The menu item is off: there is no call to invite anyone to. case unavailable /// On, and a press copies the link. case copy /// On, and a press says why there is nothing to copy, rather than a /// beep: a guest cannot share a meeting, and before the meeting has /// said who you are, nobody knows yet. case explain(String) } public static let shortcut = KeyboardShortcut("i", modifiers: [.command, .shift]) public static let title = "Copy Invite Link" /// Whether ⌘⇧I does anything, and what. public static func rule(inCall: Bool, phase: String?, standing: String) -> Rule { guard inCall, phase != "ended" else { return .unavailable } switch standing { case "member": return .copy case "": return .explain("The invite link isn’t ready yet. Try again in a moment.") default: return .explain("Only members of this meeting can share its invite link.") } } } extension Call { /// Copies the meeting's invite link, or says why not, in `notice` (the /// brief message over the stage) and to VoiceOver. public func copyInviteLink(inCall: Bool = true) async { switch InviteLinkCopy.rule(inCall: inCall, phase: state?.phase, standing: standing) { case .unavailable: return case .explain(let why): say(why) case .copy: if invitationURL == nil { await loadInvitation() } guard let url = invitationURL else { say("Could not get the invite link. Try again.") return } #if os(iOS) UIPasteboard.general.url = url #else NSPasteboard.general.clearContents() NSPasteboard.general.setString(url.absoluteString, forType: .string) #endif say("Invite link copied") } } /// A short message over the call that goes by itself: never a dialog. func say(_ text: String) { toastSerial += 1 let serial = toastSerial toast = text AccessibilityNotification.Announcement(text).post() Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(2.5)) guard let self, self.toastSerial == serial else { return } self.toast = nil } } } /// The call's brief message ("Invite link copied"), over the stage. /// Announced when it is set, so this is hidden from VoiceOver. public struct CallToast: View { @ObservedObject private var call: Call public init(call: Call) { self.call = call } public var body: some View { VStack { if let text = call.toast { Label(text, systemImage: text == "Invite link copied" ? "link" : "info.circle") .font(.callout.weight(.medium)) .padding(.horizontal, 14).padding(.vertical, 8) .background(.regularMaterial, in: Capsule()) .shadow(color: .black.opacity(0.2), radius: 8, y: 2) .transition(.opacity) .accessibilityHidden(true) } } .animation(.easeInOut(duration: 0.15), value: call.toast) .allowsHitTesting(false) } } /// The Call menu's items, in order, as data the app builds its menu from /// and the tests can read: title and shortcut. public enum CallMenuItem: CaseIterable, Sendable { case mute case copyInviteLink public func title(muted: Bool) -> String { switch self { case .mute: muted ? "Unmute Microphone" : "Mute Microphone" case .copyInviteLink: InviteLinkCopy.title } } public var shortcut: KeyboardShortcut { switch self { case .mute: KeyboardShortcut("m", modifiers: [.command, .shift]) case .copyInviteLink: InviteLinkCopy.shortcut } } }