Stop sharing, inline in the sharing notice; no rim on notices
Paul, 2026-09-28: "I would expect this notification to have a 'Stop sharing' button inline." The Mac and iPhone sharing notice gains a compact light capsule, "Stop sharing" (VoiceOver: "Stop sharing your screen"), focusable, Return and Space press it, the same stop as the bar's toggle and the iPhone's ⋯ sheet. The notice stays while the controls fade, and the watchdog's recovery notice is its own row under it. Notices are drawn without Liquid Glass, whose lit rim was the faint light edge round the pill. The web room gains the same: a sharing notice above the stage with its own Stop sharing button, and the recovery line under it (it was on the status line). The browser gate's share scenario now stops through it, from the keyboard. Calls 0.4.3: iPhone app 0.4.3 (Mac build 17 is stamped at packaging).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+211
−16
internal/calls/static/calls.css+37 −0internal/calls/static/room.js+14 −3internal/calls/templates/room.html+9 −0native/apple/Sources/CallsKit/Call.swift+2 −1native/apple/Sources/CallsKit/CallActionStyle.swift+12 −4native/apple/Sources/CallsKit/CallChrome.swift+60 −2native/apple/Sources/CallsKit/MacFullWindowCall.swift+9 −1native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift+36 −1native/ios/Sources/PhoneFullScreenCall.swift+9 −2native/ios/project.yml+1 −1test/browser/call.mjs+22 −1
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css| index d8c9019..31841a9 100644 |
| --- a/internal/calls/static/calls.css |
| +++ b/internal/calls/static/calls.css |
| @@ -707,3 +707,40 @@ |
| @container (max-width: 9rem) { |
| .calls-tile-off .icon { inline-size: 1.25rem; block-size: 1.25rem; } |
| } |
| + |
| +/* The sharing notice (2026-09-28): a dark pill above the stage with its |
| + own Stop sharing button, and the recovery line under it. No edge: it |
| + is told from the page by its tone, as the native notices are. */ |
| +.calls-share-notice:not([hidden]) { |
| + display: flex; |
| + flex-direction: column; |
| + align-items: center; |
| + gap: var(--rst-sp-1); |
| + margin-block: 0 var(--rst-sp-2); |
| +} |
| +.calls-share-notice-row { |
| + display: inline-flex; |
| + align-items: center; |
| + gap: var(--rst-sp-2); |
| + margin: 0; |
| + padding: 6px 6px 6px 14px; |
| + border-radius: 999px; |
| + background: rgba(20, 20, 20, 0.88); |
| + color: #ffd27a; |
| + font-weight: 600; |
| +} |
| +.calls-share-notice-row svg { inline-size: 1.1em; block-size: 1.1em; } |
| +.calls-share-stop { |
| + min-block-size: 28px; |
| + padding: 2px 12px; |
| + border: 0; |
| + border-radius: 999px; |
| + background: #fff; |
| + color: #111; |
| + font: inherit; |
| + font-weight: 600; |
| + cursor: pointer; |
| +} |
| +.calls-share-stop:hover { background: #e8e8e8; } |
| +.calls-share-stop:focus-visible { outline: 2px solid #fff; outline-offset: 2px; } |
| +.calls-share-recovery { margin: 0; font-size: 0.9em; color: var(--rst-text); } |
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js| index fd8ea9d..63beac8 100644 |
| --- a/internal/calls/static/room.js |
| +++ b/internal/calls/static/room.js |
| @@ -67,6 +67,10 @@ |
| const muteState = $('mute-state'); |
| const cameraState = $('camera-state'); |
| const shareState = $('share-state'); |
| + // The sharing notice and its own Stop sharing button (2026-09-28). |
| + const shareNotice = $('share-notice'); |
| + const shareStop = $('share-stop'); |
| + const shareRecoveryEl = $('share-recovery'); |
| // §15.19: the tile marks, rendered by the server so they are the |
| // vendored badge and the app's own icons rather than a copy of either, |
| // and the polite region that says a change out loud. #status is no |
| @@ -4121,6 +4125,12 @@ |
| if (!shareButton) return; |
| shareButton.setAttribute('aria-pressed', String(!!share)); |
| if (shareState) shareState.textContent = share ? 'On' : 'Off'; |
| + if (shareNotice) shareNotice.hidden = !share; |
| + if (shareRecoveryEl) { |
| + const text = share ? shareRecovery : ''; |
| + shareRecoveryEl.textContent = text; |
| + shareRecoveryEl.hidden = !text; |
| + } |
| } |
| // sharingPeers: the peers holding a share tile, for the gate. |
| @@ -4368,7 +4378,7 @@ |
| const text = recoveringWords(names_); |
| if (text !== shareRecovery) { |
| shareRecovery = text; |
| - renderStatus(); |
| + renderShare(); |
| } |
| } |
| @@ -5176,8 +5186,7 @@ |
| say('You are the only one here.'); |
| return; |
| } |
| - // §15.21: quiet, and only while it lasts. |
| - say(share ? shareRecovery : ''); |
| + say(''); |
| } |
| function isMember() { |
| @@ -5659,6 +5668,8 @@ |
| // a named function with a catch of its own — the same shape as the |
| // pageshow handler's. |
| if (shareButton) { |
| + // The notice's own way out: the same stop as the Share button's. |
| + if (shareStop) shareStop.addEventListener('click', () => stopSharing()); |
| shareButton.addEventListener('click', () => { |
| if (share) { |
| stopSharing(); |
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html| index f8d8628..8b638c2 100644 |
| --- a/internal/calls/templates/room.html |
| +++ b/internal/calls/templates/room.html |
| @@ -27,6 +27,15 @@ |
| it stood between c80a506 and a6c2ac8. */}} |
| <noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it. <a href="/m/{{.Sqid}}/join">Back to the holding screen</a>.</p>{{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">Share link</button></form>{{end}}</noscript> |
| <div id="room" data-sqid="{{.Sqid}}" data-boot="{{.Boot}}" data-self-name="{{.Name}}" data-is-member="{{if .IsMember}}1{{end}}" data-relay="{{if .Relay}}1{{end}}" data-peer="" hidden> |
| + {{/* While this tab shares its screen: said in words, with the way out |
| + beside it (Paul, 2026-09-28: "I would expect this notification to |
| + have a 'Stop sharing' button inline"), and, under it, whose view of |
| + the screen is being recovered (spec §15.21). In the flow above the |
| + stage, so it never covers a tile and never fades. */}} |
| + <div id="share-notice" class="calls-share-notice" hidden> |
| + <p class="calls-share-notice-row">{{icon "monitor"}}<span>You are sharing your screen</span><button type="button" id="share-stop" class="calls-share-stop" aria-label="Stop sharing your screen">Stop sharing</button></p> |
| + <p id="share-recovery" class="calls-share-recovery" role="status" hidden></p> |
| + </div> |
| <div id="call-workspace" estilo-call-workspace="sidebar"> |
| <div class="calls-stage"> |
| <div id="tiles" class="calls-grid"></div> |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 1fe3512..c8f2225 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -880,7 +880,8 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| sharingScreen = false |
| ScreenLog.info("sharing stopped") |
| endScreenWatch() |
| - media.stopScreen() |
| + // What was never built has no screen to stop. |
| + built?.stopScreen() |
| send(type: "share", to: "", gen: 0, payload: json(["on": false]) ?? "") |
| } |
diff --git a/native/apple/Sources/CallsKit/CallActionStyle.swift b/native/apple/Sources/CallsKit/CallActionStyle.swift| index bb1dbcf..9e36e1a 100644 |
| --- a/native/apple/Sources/CallsKit/CallActionStyle.swift |
| +++ b/native/apple/Sources/CallsKit/CallActionStyle.swift |
| @@ -108,12 +108,20 @@ extension EnvironmentValues { |
| /// and iOS 26, the ultra-thin material before — with black at |
| /// `CallOverlay.scrim` on it. Only the background is made dark, so a sheet |
| /// or popover the content opens keeps the person's own appearance. |
| +/// |
| +/// `rim: false` leaves Liquid Glass out even where the system has it: the |
| +/// glass lights its own edge, a faint light hairline that a small pill |
| +/// over a dark picture shows plainly (the sharing notice, 2026-09-28). |
| public struct CallOverlaySurface<S: InsettableShape>: ViewModifier { |
| let shape: S |
| + let rim: Bool |
| @Environment(\.callOverlayGlass) private var glass |
| - public init(_ shape: S) { self.shape = shape } |
| + public init(_ shape: S, rim: Bool = true) { |
| + self.shape = shape |
| + self.rim = rim |
| + } |
| public func body(content: Content) -> some View { |
| - if glass, #available(macOS 26, iOS 26, *) { |
| + if glass, rim, #available(macOS 26, iOS 26, *) { |
| content |
| .background(shape.fill(Color.black.opacity(CallOverlay.scrim))) |
| .glassEffect(.regular, in: shape) |
| @@ -132,8 +140,8 @@ public struct CallOverlaySurface<S: InsettableShape>: ViewModifier { |
| extension View { |
| /// See `CallOverlaySurface`. |
| - public func callOverlaySurface<S: InsettableShape>(_ shape: S) -> some View { |
| - modifier(CallOverlaySurface(shape)) |
| + public func callOverlaySurface<S: InsettableShape>(_ shape: S, rim: Bool = true) -> some View { |
| + modifier(CallOverlaySurface(shape, rim: rim)) |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/CallChrome.swift b/native/apple/Sources/CallsKit/CallChrome.swift| index 1fa86b9..bb3ea17 100644 |
| --- a/native/apple/Sources/CallsKit/CallChrome.swift |
| +++ b/native/apple/Sources/CallsKit/CallChrome.swift |
| @@ -340,8 +340,12 @@ public struct CallNotice: Identifiable, Equatable, Sendable { |
| case ended |
| /// Something went wrong that the person should know about. |
| case problem |
| - /// This app is sharing its screen: said in words for as long as it is. |
| + /// This app is sharing its screen: said in words for as long as it is, |
| + /// with a Stop sharing button of its own (`action`). |
| case sharing |
| + /// Someone's view of this app's screen stalled and the watchdog is |
| + /// putting it right (spec §15.21). Quiet, and under the sharing notice. |
| + case screenRecovering |
| /// Relay unavailable: direct connections only. |
| case floor |
| } |
| @@ -357,6 +361,14 @@ public struct CallNotice: Identifiable, Equatable, Sendable { |
| /// that ends on its own (reconnecting, sharing) go when it does; a |
| /// problem or an ended call is the person's to put away. |
| public var dismissible: Bool { kind == .problem } |
| + |
| + /// What the notice's own button does, if it has one. |
| + public enum Action: Equatable, Sendable { |
| + /// Paul, 2026-09-28: "I would expect this notification to have a |
| + /// 'Stop sharing' button inline." The same stop as the bar's toggle. |
| + case stopSharing |
| + } |
| + public var action: Action? { kind == .sharing ? .stopSharing : nil } |
| } |
| public enum CallNotices { |
| @@ -379,7 +391,7 @@ public enum CallNotices { |
| if sharingScreen { |
| out.append(CallNotice(kind: .sharing, text: "You are sharing your screen")) |
| // Quiet: said while the watchdog puts it right, and gone after. |
| - if !screenRecovery.isEmpty { out.append(CallNotice(kind: .sharing, text: screenRecovery)) } |
| + if !screenRecovery.isEmpty { out.append(CallNotice(kind: .screenRecovering, text: screenRecovery)) } |
| } else if !screenNote.isEmpty { |
| out.append(CallNotice(kind: .problem, text: screenNote)) |
| } |
| @@ -393,6 +405,52 @@ public enum CallNotices { |
| } |
| } |
| +/// A notice's own button (`CallNotice.action`): a compact light capsule |
| +/// that reads on the dark glass, reachable from the keyboard, where |
| +/// Return as well as Space presses it. |
| +public struct NoticeActionButton: View { |
| + let action: CallNotice.Action |
| + let perform: () -> Void |
| + @FocusState private var focused: Bool |
| + |
| + public init(_ action: CallNotice.Action, perform: @escaping () -> Void) { |
| + self.action = action |
| + self.perform = perform |
| + } |
| + |
| + public var body: some View { |
| + switch action { |
| + case .stopSharing: |
| + Button(action: perform) { |
| + Text("Stop sharing").font(.callout.weight(.semibold)) |
| + .padding(.horizontal, 12).padding(.vertical, 4) |
| + .foregroundStyle(Color.black) |
| + .background(Capsule().fill(Color.white.opacity(focused ? 1 : 0.92))) |
| + .overlay(Capsule().strokeBorder(Color.white, lineWidth: focused ? 2 : 0).padding(-3)) |
| + .contentShape(Capsule()) |
| + } |
| + .buttonStyle(.plain) |
| + .focusable() |
| + .focused($focused) |
| + .onKeyPress(.return) { |
| + perform() |
| + return .handled |
| + } |
| + .accessibilityLabel("Stop sharing your screen") |
| + .accessibilityIdentifier("stopSharingInline") |
| + } |
| + } |
| +} |
| + |
| +extension Call { |
| + /// Runs a notice's own action (see `CallNotice.action`). |
| + public func perform(_ action: CallNotice.Action) { |
| + switch action { |
| + case .stopSharing: stopSharingScreen() |
| + } |
| + } |
| +} |
| + |
| // MARK: - Grid |
| /// How a grid of tiles fills a stage without scrolling. |
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift| index 5887419..1e79c08 100644 |
| --- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| +++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| @@ -368,6 +368,8 @@ |
| case .sharing: |
| Label(notice.text, systemImage: "rectangle.on.rectangle") |
| .foregroundStyle(CallOverlay.alertText).bold() |
| + case .screenRecovering: |
| + Label(notice.text, systemImage: "arrow.clockwise") |
| case .reconnecting, .waitingToJoin: |
| // Drawn rather than AppKit's spinner, which stays dark-on-dark |
| // on this surface in a light-appearance app. |
| @@ -387,6 +389,9 @@ |
| case .problem, .floor: |
| Label(notice.text, systemImage: "exclamationmark.triangle.fill") |
| } |
| + if let action = notice.action { |
| + NoticeActionButton(action) { call.perform(action) } |
| + } |
| if notice.dismissible { |
| Button("Dismiss", systemImage: "xmark") { dismissedNotices.insert(notice.id) } |
| .labelStyle(.iconOnly).buttonStyle(.borderless) |
| @@ -517,7 +522,10 @@ |
| content.font(.callout).foregroundStyle(.white) |
| .environment(\.colorScheme, .dark) |
| .padding(.horizontal, 14).padding(.vertical, 8) |
| - .callOverlaySurface(Capsule()) |
| + // Without Liquid Glass: its lit rim is the faint light edge Paul |
| + // saw round the sharing notice (2026-09-28), and no overlay has |
| + // an edge since 0.3.7. |
| + .callOverlaySurface(Capsule(), rim: false) |
| .shadow(color: .black.opacity(0.25), radius: 8, y: 2) |
| } |
| } |
diff --git a/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift b/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift| index 2f9a9cb..1a75ea8 100644 |
| --- a/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift |
| @@ -1,6 +1,7 @@ |
| +import SwiftUI |
| import XCTest |
| -@testable import CallsKit |
| +@_spi(Fixtures) @testable import CallsKit |
| /// The screen watchdogs (ScreenWatchdog.swift): the sharer's, which puts |
| /// its own screen right, and the receiver's, which says when someone |
| @@ -234,3 +235,37 @@ final class ScreenWatchdogTests: XCTestCase { |
| XCTAssertFalse(ICERenewal.restart(gathered: ice(10_000), next: ice(0, servers: 0), now: 20_000)) |
| } |
| } |
| + |
| +/// The sharing notice's own Stop sharing button (Paul, 2026-09-28: "I |
| +/// would expect this notification to have a 'Stop sharing' button |
| +/// inline"), and the recovery notice stacked under it. |
| +@MainActor final class SharingNoticeTests: XCTestCase { |
| + func testTheSharingNoticeCarriesStopSharingAndTheRecoveryNoticeSitsUnderIt() { |
| + let notices = CallNotices.notices( |
| + phase: "admitted", status: "In the meeting", sharingScreen: true, screenNote: "", floor: false, |
| + screenRecovery: ScreenWords.recovering(names: ["Vicky"])) |
| + XCTAssertEqual(notices.map(\.kind), [.sharing, .screenRecovering]) |
| + XCTAssertEqual(notices[0].text, "You are sharing your screen") |
| + XCTAssertEqual(notices[0].action, .stopSharing) |
| + XCTAssertNil(notices[1].action, "the recovery notice has no button of its own") |
| + XCTAssertTrue(notices.allSatisfy(\.important), "both stay while the controls fade") |
| + XCTAssertFalse(notices.contains(where: \.dismissible)) |
| + XCTAssertNotEqual(notices[0].id, notices[1].id, "two rows, never one over the other") |
| + } |
| + |
| + func testNotSharingHasNoStopButton() { |
| + let notices = CallNotices.notices( |
| + phase: "admitted", status: "In the meeting", sharingScreen: false, screenNote: "", floor: false, |
| + screenRecovery: "ignored while not sharing") |
| + XCTAssertTrue(notices.allSatisfy { $0.action == nil }) |
| + XCTAssertFalse(notices.contains { $0.kind == .screenRecovering }) |
| + } |
| + |
| + func testTheButtonStopsTheShare() { |
| + let call = Call(playAudio: false) |
| + call.showFixtureSelf(muted: false, cameraOff: false, sharingScreen: true) |
| + XCTAssertTrue(call.sharingScreen) |
| + call.perform(.stopSharing) |
| + XCTAssertFalse(call.sharingScreen) |
| + } |
| +} |
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift| index cfdb26f..4676f9b 100644 |
| --- a/native/ios/Sources/PhoneFullScreenCall.swift |
| +++ b/native/ios/Sources/PhoneFullScreenCall.swift |
| @@ -267,7 +267,7 @@ extension PhoneCall { |
| private var notices: [CallNotice] { |
| CallNotices.notices( |
| phase: call.state?.phase, status: call.status, sharingScreen: call.sharingScreen, |
| - screenNote: call.screenNote, floor: call.floor) |
| + screenNote: call.screenNote, floor: call.floor, screenRecovery: call.screenRecovery) |
| } |
| private func topOverlay(safe: EdgeInsets) -> some View { |
| @@ -312,6 +312,8 @@ extension PhoneCall { |
| case .sharing: |
| Label(notice.text, systemImage: "rectangle.on.rectangle") |
| .foregroundStyle(CallOverlay.alertText).bold() |
| + case .screenRecovering: |
| + Label(notice.text, systemImage: "arrow.clockwise") |
| case .reconnecting, .waitingToJoin: |
| ProgressView().tint(.white) |
| Text(notice.text) |
| @@ -323,6 +325,10 @@ extension PhoneCall { |
| case .problem, .floor: |
| Label(notice.text, systemImage: "exclamationmark.triangle.fill") |
| } |
| + if let action = notice.action { |
| + // The same stop as the ⋯ sheet's, which ends the broadcast. |
| + NoticeActionButton(action) { call.perform(action) } |
| + } |
| if notice.dismissible { |
| Button("Dismiss", systemImage: "xmark") { dismissedNotices.insert(notice.id) } |
| .labelStyle(.iconOnly) |
| @@ -412,7 +418,8 @@ struct PhoneNoticeSurface: ViewModifier { |
| content.font(.callout).foregroundStyle(.white) |
| .environment(\.colorScheme, .dark) |
| .padding(.horizontal, 14).padding(.vertical, 8) |
| - .callOverlaySurface(RoundedRectangle(cornerRadius: 16, style: .continuous)) |
| + // No Liquid Glass rim: see NoticeSurface on the Mac. |
| + .callOverlaySurface(RoundedRectangle(cornerRadius: 16, style: .continuous), rim: false) |
| .shadow(color: .black.opacity(0.25), radius: 8, y: 2) |
| } |
| } |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index 3382137..c04231a 100644 |
| --- a/native/ios/project.yml |
| +++ b/native/ios/project.yml |
| @@ -10,7 +10,7 @@ settings: |
| base: |
| SWIFT_VERSION: '5.0' |
| CODE_SIGN_STYLE: Automatic |
| - MARKETING_VERSION: '0.4.2' |
| + MARKETING_VERSION: '0.4.3' |
| CURRENT_PROJECT_VERSION: '1' |
| # The App Group the app and its broadcast extension share: the socket |
| # the iPhone's screen crosses lives in its container. One setting |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index eb3b5ff..4ae5777 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -2056,7 +2056,26 @@ const run = { |
| check(armed && armed.readyState === 'live', |
| 'the received screen track is live, and watched for its ending (' + JSON.stringify(armed) + ')'); |
| - await sharer.page.click('#share'); |
| + // Paul, 2026-09-28: the sharing notice carries its own Stop |
| + // sharing button, named for a screen reader, reachable from the |
| + // keyboard — and this stop goes through it, so the button is |
| + // proved to do exactly what the bar's toggle does. |
| + const notice = await sharer.page.evaluate(() => { |
| + const n = document.getElementById('share-notice'); |
| + const b = document.getElementById('share-stop'); |
| + return n && b ? { |
| + shown: !n.hidden && n.getClientRects().length > 0, |
| + text: (n.querySelector('.calls-share-notice-row span') || {}).textContent || '', |
| + label: b.getAttribute('aria-label'), |
| + words: (b.textContent || '').trim(), |
| + focusable: b.tabIndex >= 0 && !b.disabled, |
| + } : null; |
| + }); |
| + check(notice && notice.shown && notice.text === 'You are sharing your screen' && |
| + notice.label === 'Stop sharing your screen' && notice.words === 'Stop sharing' && notice.focusable, |
| + 'the sharing notice shows, with its own Stop sharing button (' + JSON.stringify(notice) + ')'); |
| + await sharer.page.focus('#share-stop'); |
| + await sharer.page.keyboard.press('Enter'); |
| const removed = await until(async () => { |
| const s = await state(viewer.page); |
| @@ -2087,6 +2106,8 @@ const run = { |
| check(off, 'the Share button is back off, in both cues (' + |
| JSON.stringify(await shareControl(sharer.page)) + ')'); |
| check((await state(sharer.page)).sharing === false, 'the sharing tab says it has stopped'); |
| + check(await sharer.page.evaluate(() => document.getElementById('share-notice').hidden), |
| + 'the sharing notice goes with the share'); |
| check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')'); |
| check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')'); |