Copy Invite Link (⌘⇧I) and audio details behind an ⓘ
Call → Copy Invite Link, beside Mute, puts the meeting's existing invitation on the clipboard (the Share button's path, which reuses the sealed link and never revokes one) with a brief toast, announced to VoiceOver; a guest is told why not. The iPad takes the same shortcut. The device panel's audio readout moves into an Audio details popover, with one warning line in the panel only when something is wrong.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+314
−14
native/apple/Sources/CallsKit/AudioHealth.swift+29 −0native/apple/Sources/CallsKit/Call.swift+3 −0native/apple/Sources/CallsKit/DevicePanel.swift+39 −7native/apple/Sources/CallsKit/InviteLinkCopy.swift+121 −0native/apple/Sources/CallsKit/MacCallView.swift+3 −0native/apple/Sources/CallsKit/MiniWindow.swift+2 −0native/apple/Sources/CallsMac/CallsMac.swift+21 −6native/apple/Tests/CallsKitTests/CallChromeTests.swift+86 −1native/ios/Sources/CallsPhone.swift+10 −0
diff --git a/native/apple/Sources/CallsKit/AudioHealth.swift b/native/apple/Sources/CallsKit/AudioHealth.swift| index fbfc83b..019dec1 100644 |
| --- a/native/apple/Sources/CallsKit/AudioHealth.swift |
| +++ b/native/apple/Sources/CallsKit/AudioHealth.swift |
| @@ -77,3 +77,32 @@ public struct AudioHealth: Equatable, Sendable { |
| return "Audio is arriving cleanly (\(loss)% filled in, \(drift)% resampled)." |
| } |
| } |
| + |
| +/// The one line the device panel shows about audio, and only when |
| +/// something is actually wrong; the full readout is a tap away behind |
| +/// "Audio details". Nil keeps the panel clean. Worst first: a voice that |
| +/// sounds wrong, then one that breaks up, then processing that is not |
| +/// doing what was asked. |
| +public enum AudioWarning { |
| + public static func line( |
| + health: AudioHealth?, processing: ProcessingReport?, reduceNoise: Bool |
| + ) -> String? { |
| + if let health, !health.silent { |
| + if health.driftRate >= 0.02 { |
| + return "Incoming audio is being stretched to keep time, so voices may sound slowed or growling." |
| + } |
| + if health.concealmentRate >= 0.05 { |
| + return "\(Int((health.concealmentRate * 100).rounded()))% of incoming audio is going missing." |
| + } |
| + } |
| + guard let processing, processing.started else { return nil } |
| + if processing.refused != nil { return "WebRTC refused the audio processing Calls asked for." } |
| + if processing.echoCancellation == "off" { |
| + return "Echo cancellation is off, so others may hear themselves." |
| + } |
| + if reduceNoise, case .unsupportedRate(let rate) = processing.suppression { |
| + return "Noise reduction is bypassed: the microphone runs at \(rate / 1000) kHz." |
| + } |
| + return nil |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 49e8365..cbb21e7 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -66,6 +66,9 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| @Published public private(set) var invitationURL: URL? |
| @Published public private(set) var invitationError = "" |
| @Published public private(set) var loadingInvitation = false |
| + /// A brief message over the stage, such as "Invite link copied". |
| + @Published public internal(set) var toast: String? |
| + var toastSerial = 0 |
| /// What the last device change had to say, and usually nothing. |
| @Published public private(set) var deviceNote = "" |
| /// The audio-health readout, once a call has been up long enough for |
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift| index cde891b..f7408f7 100644 |
| --- a/native/apple/Sources/CallsKit/DevicePanel.swift |
| +++ b/native/apple/Sources/CallsKit/DevicePanel.swift |
| @@ -17,6 +17,7 @@ import SwiftUI |
| @ObservedObject private var audio: AudioRoutes |
| @State private var camera: String? |
| @State private var cameraRequest = 0 |
| + @State private var showingAudioDetails = false |
| #if os(iOS) |
| @StateObject private var micModes = MicrophoneModes() |
| #endif |
| @@ -53,8 +54,7 @@ import SwiftUI |
| screenSection |
| - Divider() |
| - audioReadout |
| + audioFoot |
| } |
| .padding(16) |
| .onAppear { |
| @@ -247,13 +247,37 @@ import SwiftUI |
| Text(call.screenMode.explanation) |
| .font(.footnote).foregroundStyle(.secondary) |
| .fixedSize(horizontal: false, vertical: true) |
| - if call.sharingScreen { |
| - Text(call.screenReport?.summary ?? "Screen: starting…") |
| - .font(.callout).foregroundStyle(.secondary) |
| + } |
| + } |
| + |
| + /// The panel's foot: a warning line only when something is wrong, and |
| + /// the ⓘ that opens the full readout. The readout used to fill the |
| + /// bottom of the panel all the time; most of the time it said all is |
| + /// well, at length. |
| + private var audioFoot: some View { |
| + HStack(alignment: .firstTextBaseline, spacing: 8) { |
| + if let warning = AudioWarning.line( |
| + health: call.audioHealth, processing: call.audioProcessing, reduceNoise: call.reduceNoise) |
| + { |
| + Label(warning, systemImage: "exclamationmark.triangle.fill") |
| + .font(.callout).foregroundStyle(.orange) |
| .fixedSize(horizontal: false, vertical: true) |
| - .textSelection(.enabled) |
| - .accessibilityIdentifier("screenReport") |
| + .accessibilityIdentifier("audioWarning") |
| } |
| + Spacer(minLength: 0) |
| + Button("Audio details", systemImage: "info.circle") { showingAudioDetails.toggle() } |
| + .labelStyle(.iconOnly) |
| + .buttonStyle(.borderless) |
| + .help("Audio details") |
| + .accessibilityIdentifier("audioDetails") |
| + .popover(isPresented: $showingAudioDetails) { |
| + ScrollView { audioReadout.padding(16) } |
| + .frame(minWidth: 280, idealWidth: 320, maxWidth: 360, maxHeight: 420) |
| + .fixedSize(horizontal: false, vertical: true) |
| + #if os(iOS) |
| + .presentationCompactAdaptation(.popover) |
| + #endif |
| + } |
| } |
| } |
| @@ -275,7 +299,15 @@ import SwiftUI |
| .fixedSize(horizontal: false, vertical: true) |
| .accessibilityIdentifier("audioProcessing") |
| } |
| + if call.sharingScreen { |
| + Text("Screen sharing").font(.headline).padding(.top, 6) |
| + Text(call.screenReport?.summary ?? "Screen: starting…") |
| + .font(.callout).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + .accessibilityIdentifier("screenReport") |
| + } |
| } |
| + .textSelection(.enabled) |
| } |
| private func row(_ label: String, _ value: String) -> some View { |
diff --git a/native/apple/Sources/CallsKit/InviteLinkCopy.swift b/native/apple/Sources/CallsKit/InviteLinkCopy.swift| new file mode 100644 |
| index 0000000..aa6ab1e |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/InviteLinkCopy.swift |
| @@ -0,0 +1,121 @@ |
| +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 |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift| index 59feee2..e3607bf 100644 |
| --- a/native/apple/Sources/CallsKit/MacCallView.swift |
| +++ b/native/apple/Sources/CallsKit/MacCallView.swift |
| @@ -56,6 +56,9 @@ |
| immersive |
| } |
| } |
| + .overlay(alignment: .bottom) { |
| + CallToast(call: call).padding(.bottom, layout == .classic ? 100 : Self.barReserve + 16) |
| + } |
| .onAppear { |
| if showingChat { readChatCount = call.receivedChatCount } |
| } |
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift| index 2da30ef..841a696 100644 |
| --- a/native/apple/Sources/CallsKit/MiniWindow.swift |
| +++ b/native/apple/Sources/CallsKit/MiniWindow.swift |
| @@ -375,6 +375,8 @@ |
| ZStack { |
| if let call = content.call { |
| MiniCallStage(call: call, showingControls: showingControls, actions: actions) |
| + CallToast(call: call).padding(8) |
| + .frame(maxHeight: .infinity, alignment: .top) |
| } else { |
| Color.black |
| } |
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift| index c3124be..23b1461 100644 |
| --- a/native/apple/Sources/CallsMac/CallsMac.swift |
| +++ b/native/apple/Sources/CallsMac/CallsMac.swift |
| @@ -88,19 +88,34 @@ final class CallsAppDelegate: NSObject, NSApplicationDelegate { |
| } |
| /// The Call menu. Mute is here so that its shortcut can be found, and so |
| -/// that there is one when Space belongs to a text field or a button. |
| +/// that there is one when Space belongs to a text field or a button. Copy |
| +/// Invite Link puts the meeting's link on the clipboard without the share |
| +/// sheet. Both work from the mini window too: the menu is the app's. |
| struct CallMenuItems: View { |
| @ObservedObject var model: CallsModel |
| - var body: some View { MuteMenuItem(call: model.call, inCall: model.joining) } |
| + var body: some View { CallMenuButtons(call: model.call, inCall: model.joining) } |
| } |
| -private struct MuteMenuItem: View { |
| +private struct CallMenuButtons: View { |
| @ObservedObject var call: Call |
| let inCall: Bool |
| var body: some View { |
| - Button(call.muted ? "Unmute Microphone" : "Mute Microphone") { call.mute() } |
| - .keyboardShortcut("m", modifiers: [.command, .shift]) |
| - .disabled(!inCall) |
| + ForEach(CallMenuItem.allCases, id: \.self) { item in |
| + switch item { |
| + case .mute: |
| + Button(item.title(muted: call.muted)) { call.mute() } |
| + .keyboardShortcut(item.shortcut) |
| + .disabled(!inCall) |
| + case .copyInviteLink: |
| + Button(item.title(muted: call.muted)) { |
| + Task { await call.copyInviteLink(inCall: inCall) } |
| + } |
| + .keyboardShortcut(item.shortcut) |
| + .disabled( |
| + InviteLinkCopy.rule(inCall: inCall, phase: call.state?.phase, standing: call.standing) |
| + == .unavailable) |
| + } |
| + } |
| } |
| } |
diff --git a/native/apple/Tests/CallsKitTests/CallChromeTests.swift b/native/apple/Tests/CallsKitTests/CallChromeTests.swift| index 71342d3..84bdf06 100644 |
| --- a/native/apple/Tests/CallsKitTests/CallChromeTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/CallChromeTests.swift |
| @@ -1,7 +1,7 @@ |
| import SwiftUI |
| import XCTest |
| -@testable import CallsKit |
| +@testable @_spi(Fixtures) import CallsKit |
| /// The full-window call's rules: when its controls show and hide, where |
| /// the self view settles, which notices outlive the controls, and how a |
| @@ -345,3 +345,88 @@ final class CameraFlipTests: XCTestCase { |
| XCTAssertNil(CameraFlip.next(from: nil, in: [])) |
| } |
| } |
| + |
| +final class InviteLinkCopyTests: XCTestCase { |
| + func testOnlyInACall() { |
| + XCTAssertEqual( |
| + InviteLinkCopy.rule(inCall: false, phase: nil, standing: "member"), .unavailable) |
| + XCTAssertEqual( |
| + InviteLinkCopy.rule(inCall: true, phase: "ended", standing: "member"), .unavailable, |
| + "an ended meeting has no link to share") |
| + } |
| + |
| + func testAMemberCopies() { |
| + for phase in ["admitted", "waiting", "reconnecting", nil] { |
| + XCTAssertEqual(InviteLinkCopy.rule(inCall: true, phase: phase, standing: "member"), .copy) |
| + } |
| + } |
| + |
| + func testAGuestIsToldWhyNot() { |
| + guard case .explain(let why) = InviteLinkCopy.rule(inCall: true, phase: "admitted", standing: "guest") |
| + else { return XCTFail("a guest is told, not beeped at") } |
| + XCTAssertTrue(why.contains("Only members")) |
| + guard case .explain = InviteLinkCopy.rule(inCall: true, phase: nil, standing: "") else { |
| + return XCTFail("before the meeting says who you are") |
| + } |
| + } |
| + |
| + func testTheCallMenuHasCopyInviteLinkBesideMute() { |
| + XCTAssertEqual(CallMenuItem.allCases, [.mute, .copyInviteLink]) |
| + XCTAssertEqual(CallMenuItem.copyInviteLink.title(muted: false), "Copy Invite Link") |
| + XCTAssertEqual(CallMenuItem.copyInviteLink.shortcut.key, "i") |
| + XCTAssertEqual(CallMenuItem.copyInviteLink.shortcut.modifiers, [.command, .shift]) |
| + XCTAssertEqual(CallMenuItem.mute.shortcut.key, "m") |
| + XCTAssertEqual(CallMenuItem.mute.shortcut.modifiers, [.command, .shift]) |
| + } |
| + |
| + @MainActor func testAGuestPressGivesAToastAndNoClipboard() async { |
| + let call = Call(playAudio: false) |
| + call.showFixture( |
| + title: "x", standing: "guest", |
| + state: #"{"phase":"admitted","peer":"me","boot":"b","peers":[],"waiting":[]}"#) |
| + await call.copyInviteLink() |
| + XCTAssertEqual(call.toast, "Only members of this meeting can share its invite link.") |
| + } |
| +} |
| + |
| +final class AudioWarningTests: XCTestCase { |
| + func report( |
| + _ suppression: SuppressionState = .running(engine: "DeepFilterNet", cpu: 0.1), |
| + echo: String = "WebRTC", refused: String? = nil, started: Bool = true |
| + ) -> ProcessingReport { |
| + ProcessingReport( |
| + suppression: suppression, echoCancellation: echo, noiseSuppression: "off", |
| + gainControl: "Calls, after noise reduction", refused: refused, started: started) |
| + } |
| + let clean = AudioHealth(concealed: 10, total: 10_000, inserted: 5, removed: 5, jitterBufferMs: 60) |
| + |
| + func testAHealthyCallSaysNothing() { |
| + XCTAssertNil(AudioWarning.line(health: clean, processing: report(), reduceNoise: true)) |
| + XCTAssertNil(AudioWarning.line(health: nil, processing: nil, reduceNoise: true)) |
| + } |
| + |
| + func testGrowlAndDropouts() { |
| + let growl = AudioHealth(concealed: 0, total: 10_000, inserted: 400, removed: 0, jitterBufferMs: 60) |
| + XCTAssertTrue( |
| + AudioWarning.line(health: growl, processing: report(), reduceNoise: true)!.contains("growling")) |
| + let lossy = AudioHealth(concealed: 900, total: 10_000, inserted: 0, removed: 0, jitterBufferMs: 60) |
| + XCTAssertEqual( |
| + AudioWarning.line(health: lossy, processing: report(), reduceNoise: true), |
| + "9% of incoming audio is going missing.") |
| + } |
| + |
| + func testProcessingProblems() { |
| + XCTAssertNotNil(AudioWarning.line(health: clean, processing: report(echo: "off"), reduceNoise: true)) |
| + XCTAssertNotNil( |
| + AudioWarning.line(health: clean, processing: report(refused: "aec"), reduceNoise: true)) |
| + XCTAssertEqual( |
| + AudioWarning.line(health: clean, processing: report(.unsupportedRate(16_000)), reduceNoise: true), |
| + "Noise reduction is bypassed: the microphone runs at 16 kHz.") |
| + XCTAssertNil( |
| + AudioWarning.line(health: clean, processing: report(.standingAside), reduceNoise: true), |
| + "Voice Isolation standing in is a choice, not a fault") |
| + XCTAssertNil( |
| + AudioWarning.line(health: clean, processing: report(echo: "off", started: false), reduceNoise: true), |
| + "before the microphone starts, the defaults mean nothing") |
| + } |
| +} |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index 07012c1..d1a42e9 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -151,6 +151,16 @@ struct PhoneCall: View { |
| } else { |
| fullScreen |
| } |
| + } |
| + .overlay(alignment: .bottom) { |
| + CallToast(call: call).padding(.bottom, 110) |
| + } |
| + .background { |
| + // ⌘⇧I on an iPad's keyboard, as on the Mac: the invite link to the |
| + // clipboard. A key command with no button to show for it. |
| + Button(InviteLinkCopy.title) { Task { await call.copyInviteLink() } } |
| + .keyboardShortcut(InviteLinkCopy.shortcut) |
| + .opacity(0).frame(width: 0, height: 0).accessibilityHidden(true) |
| } |
| .task { await call.watchAudio() } |
| .onAppear { screenShare.listen(for: call, leave: leave) } |