| 1 | import SwiftUI |
| 2 | |
| 3 | #if os(iOS) |
| 4 | import UIKit |
| 5 | #else |
| 6 | import AppKit |
| 7 | #endif |
| 8 | |
| 9 | /// Call → Copy Invite Link (⌘⇧I): the meeting's invitation on the |
| 10 | /// clipboard without opening the share sheet. |
| 11 | /// |
| 12 | /// It asks the server the way the Share button does — `POST |
| 13 | /// /m/{sqid}/share`, which hands a member the meeting's current sealed |
| 14 | /// invitation, and mints one only if there is none. It never replaces or |
| 15 | /// revokes a link (see store.shareLink and |
| 16 | /// TestShareReusesSealedInvitationAndPreservesLegacyLinks). |
| 17 | public enum InviteLinkCopy { |
| 18 | public enum Rule: Equatable, Sendable { |
| 19 | /// The menu item is off: there is no call to invite anyone to. |
| 20 | case unavailable |
| 21 | /// On, and a press copies the link. |
| 22 | case copy |
| 23 | /// On, and a press says why there is nothing to copy, rather than a |
| 24 | /// beep: a guest cannot share a meeting, and before the meeting has |
| 25 | /// said who you are, nobody knows yet. |
| 26 | case explain(String) |
| 27 | } |
| 28 | |
| 29 | public static let shortcut = KeyboardShortcut("i", modifiers: [.command, .shift]) |
| 30 | public static let title = "Copy Invite Link" |
| 31 | |
| 32 | /// Whether ⌘⇧I does anything, and what. |
| 33 | public static func rule(inCall: Bool, phase: String?, standing: String) -> Rule { |
| 34 | guard inCall, phase != "ended" else { return .unavailable } |
| 35 | switch standing { |
| 36 | case "member": return .copy |
| 37 | case "": return .explain("The invite link isn’t ready yet. Try again in a moment.") |
| 38 | default: return .explain("Only members of this meeting can share its invite link.") |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | extension Call { |
| 44 | /// Copies the meeting's invite link, or says why not, in `notice` (the |
| 45 | /// brief message over the stage) and to VoiceOver. |
| 46 | public func copyInviteLink(inCall: Bool = true) async { |
| 47 | switch InviteLinkCopy.rule(inCall: inCall, phase: state?.phase, standing: standing) { |
| 48 | case .unavailable: return |
| 49 | case .explain(let why): say(why) |
| 50 | case .copy: |
| 51 | if invitationURL == nil { await loadInvitation() } |
| 52 | guard let url = invitationURL else { |
| 53 | say("Could not get the invite link. Try again.") |
| 54 | return |
| 55 | } |
| 56 | #if os(iOS) |
| 57 | UIPasteboard.general.url = url |
| 58 | #else |
| 59 | NSPasteboard.general.clearContents() |
| 60 | NSPasteboard.general.setString(url.absoluteString, forType: .string) |
| 61 | #endif |
| 62 | say("Invite link copied") |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | /// A short message over the call that goes by itself: never a dialog. |
| 67 | func say(_ text: String) { |
| 68 | toastSerial += 1 |
| 69 | let serial = toastSerial |
| 70 | toast = text |
| 71 | AccessibilityNotification.Announcement(text).post() |
| 72 | Task { @MainActor [weak self] in |
| 73 | try? await Task.sleep(for: .seconds(2.5)) |
| 74 | guard let self, self.toastSerial == serial else { return } |
| 75 | self.toast = nil |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | /// The call's brief message ("Invite link copied"), over the stage. |
| 81 | /// Announced when it is set, so this is hidden from VoiceOver. |
| 82 | public struct CallToast: View { |
| 83 | @ObservedObject private var call: Call |
| 84 | public init(call: Call) { self.call = call } |
| 85 | public var body: some View { |
| 86 | VStack { |
| 87 | if let text = call.toast { |
| 88 | Label(text, systemImage: text == "Invite link copied" ? "link" : "info.circle") |
| 89 | .font(.callout.weight(.medium)) |
| 90 | .padding(.horizontal, 14).padding(.vertical, 8) |
| 91 | .background(.regularMaterial, in: Capsule()) |
| 92 | .shadow(color: .black.opacity(0.2), radius: 8, y: 2) |
| 93 | .transition(.opacity) |
| 94 | .accessibilityHidden(true) |
| 95 | } |
| 96 | } |
| 97 | .animation(.easeInOut(duration: 0.15), value: call.toast) |
| 98 | .allowsHitTesting(false) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | /// The Call menu's items, in order, as data the app builds its menu from |
| 103 | /// and the tests can read: title and shortcut. |
| 104 | public enum CallMenuItem: CaseIterable, Sendable { |
| 105 | case mute |
| 106 | case copyInviteLink |
| 107 | |
| 108 | public func title(muted: Bool) -> String { |
| 109 | switch self { |
| 110 | case .mute: muted ? "Unmute Microphone" : "Mute Microphone" |
| 111 | case .copyInviteLink: InviteLinkCopy.title |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | public var shortcut: KeyboardShortcut { |
| 116 | switch self { |
| 117 | case .mute: KeyboardShortcut("m", modifiers: [.command, .shift]) |
| 118 | case .copyInviteLink: InviteLinkCopy.shortcut |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 | |