oficina / meet Public

Say when someone is waiting, to a member who is not looking

The admission queue has always been on screen for a member watching the
call. For a member who is not — another Space, another app, a phone in a
pocket — someone knocking waited until the host happened to look.

A knock is now announced while the app is not frontmost. The decision of
whether to announce is kept apart from the act of announcing, because the
decision is the part with rules in it and the act needs a notification
centre and a real bundle. KnockWatch is those rules and is tested
without either.

The rule that makes it bearable: someone who arrived while the queue was
visible is recorded as though announced, so tabbing away afterwards does
not then produce a pile of notifications about people who have been
waiting a while. Only an arrival during an absence is news. Leaving the
queue and knocking again is heard again, because a set that only grew
would swallow the second knock — someone let in, or who gave up and came
back, is a new person at the door. One person swapped for another is one
announcement, which a count would have read as "still one waiting" and
said nothing about.

The name in the body is the server's roster text and therefore someone
else's, so it is carried as data and never into a format string.
Authorization is asked at join rather than at the first knock: a
permission sheet arriving at the same moment as the thing it is about is
a sheet that gets dismissed to get at the thing. The notification centre
is nil outside a real bundle, because UNUserNotificationCenter raises
rather than erroring when there is no bundle identifier — which is every
unit test and the interop executable, neither of which should take the
process down for want of a notification nobody would see.

This is the local half, and only the local half. There is no APNs
anywhere in this suite — no push key, no device tokens, no sender — so a
member whose app is closed is not reached. That case needs server-side
push and a decision about whether a host who is not in the meeting can
be notified at all, since the waiting queue lives only in the hub's
memory and reaches members over SSE.

Gates: swift test (24), make native-browser, and the iPhone simulator
build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 432028572ea8825d779df7f31cb0dbb65d42602d parent 5e64482
4 files changed, +209 −0
  • native/README.md +18 −0
  • native/apple/Sources/CallsKit/Call.swift +6 −0
  • native/apple/Sources/CallsKit/Knocks.swift +101 −0
  • native/apple/Tests/CallsKitTests/KnockWatchTests.swift +84 −0

CI passed — run details

diff --git a/native/README.md b/native/README.md
index d9bbcc0..d442e03 100644
--- a/native/README.md
+++ b/native/README.md
@@ -66,6 +66,24 @@ sender's clock — and a stretched voice is what a growl is. On macOS it is
the only instrument there is, since a bad device cannot be ruled out by
switching away from it.
+**Knocks** are announced when someone asks to be let in while the app is
+not frontmost. The admission queue is already on screen for a member who is
+looking at the call; this is for the member who is in another Space,
+another app, or has the phone in a pocket.
+
+These are **local** notifications, and that is the whole of what is
+possible here today: there is no APNs anywhere in this suite — no push
+key, no device token registration, no sender — so a member whose app is
+*closed* is not reached. That case needs server-side push and a decision
+about whether a host who is not in the meeting can be notified at all,
+since the waiting queue lives only in the hub's memory and reaches members
+over SSE.
+
+Someone who arrived while the queue was visible is never announced later,
+so tabbing away does not produce a pile of notifications about people who
+have been waiting a while. Only an arrival during an absence is news, and
+leaving the queue and knocking again is heard again.
+
Members can use **Share** in the call to retrieve the current invitation, copy
it, or open the system sharing menu. This needs the server's
`POST /m/{sqid}/share` endpoint and the encrypted-invitation migration.
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index 95bd93d..55909c3 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -54,6 +54,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
public let audio = AudioRoutes()
private var cameraWork: Task<Void, Never>?
private var cameraSwitches = 0
+ private let knocks = KnockWatch()
fileprivate var epoch = UUID()
private var bridge: BridgeClient?
private var events: Events?
@@ -99,6 +100,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
localTrack = media.localVideo
currentCamera = media.currentCamera
audio.refresh()
+ KnockNotice.requestPermission()
try client.start()
}
public func close() {
@@ -118,6 +120,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
deviceNote = ""
audioHealth = nil
currentCamera = nil
+ knocks.reset()
cameraWork?.cancel()
cameraWork = nil
if let old { DispatchQueue.global().async { old.close() } }
@@ -262,6 +265,9 @@ private final class Events: NSObject, BridgeListenerProtocol {
case "state":
guard let state = event.state else { return }
self.state = state
+ // Only a member is sent a waiting list at all (spec §4.3), so this
+ // needs no check of its own for who is allowed to be told.
+ KnockNotice.announce(knocks.arrivals(waiting: state.waiting, visible: KnockNotice.appIsFrontmost))
switch state.phase {
case "admitted": status = "In the meeting"
case "waiting": status = "Waiting to be let in"
diff --git a/native/apple/Sources/CallsKit/Knocks.swift b/native/apple/Sources/CallsKit/Knocks.swift
new file mode 100644
index 0000000..1577912
--- /dev/null
+++ b/native/apple/Sources/CallsKit/Knocks.swift
@@ -0,0 +1,101 @@
+import Foundation
+import UserNotifications
+
+#if os(iOS)
+ import UIKit
+#else
+ import AppKit
+#endif
+
+/// Notices when someone new is waiting to be let in, and says so once.
+///
+/// The admission queue is already on screen for a member who is looking at
+/// the call. This is for the member who is not: another Space, another
+/// app, a phone in a pocket. Without it, someone knocking on a call whose
+/// host has tabbed away waits until the host happens to look.
+///
+/// The decision of *whether* to announce is kept apart from the act of
+/// announcing, because the decision is the part with rules in it — once
+/// per person, never for someone already announced, forgotten when they
+/// stop waiting so a re-knock is heard again — and the act needs a
+/// notification centre and a real bundle.
+@MainActor public final class KnockWatch {
+ /// Peers already announced and still waiting. Kept rather than a plain
+ /// count so that one person leaving the queue and another arriving is
+ /// one announcement and not none.
+ private(set) var announced: Set<String> = []
+
+ public init() {}
+
+ /// Returns the people worth announcing, and records them.
+ ///
+ /// `visible` is the whole of the "don't shout at someone already
+ /// looking" rule: when the queue is on screen, arrivals are recorded as
+ /// though announced, so tabbing away afterwards does not then produce a
+ /// pile of notifications about people who have been waiting for a
+ /// while. Only someone who arrives *while* you are away is news.
+ public func arrivals(waiting: [RosterPeer], visible: Bool) -> [RosterPeer] {
+ let present = Set(waiting.map(\.peer))
+ // Someone who left the queue is forgotten, so that knocking again
+ // after being let in — or after giving up — is heard again.
+ announced.formIntersection(present)
+ let fresh = waiting.filter { !announced.contains($0.peer) }
+ announced.formUnion(present)
+ return visible ? [] : fresh
+ }
+
+ /// Dropped when a call ends: a queue from a finished meeting has
+ /// nothing to say about the next one.
+ public func reset() { announced = [] }
+}
+
+/// The act, as opposed to the decision. Local notifications only: there
+/// is no APNs anywhere in this suite — no push key, no device tokens, no
+/// sender — so this reaches a member whose app is running and not
+/// frontmost, and not one whose app is closed. That second case is real
+/// and is not solved here.
+@MainActor public enum KnockNotice {
+ /// True when the app is in front, and the queue therefore already
+ /// visible to anyone looking at it.
+ public static var appIsFrontmost: Bool {
+ #if os(iOS)
+ return UIApplication.shared.applicationState == .active
+ #else
+ return NSApp?.isActive ?? false
+ #endif
+ }
+
+ /// Asks once, at join, rather than at the first knock: a permission
+ /// sheet arriving at the same moment as the thing it is about is a
+ /// sheet that gets dismissed to get at the thing.
+ public static func requestPermission() {
+ guard let centre = centre else { return }
+ centre.requestAuthorization(options: [.alert, .sound]) { _, _ in }
+ }
+
+ public static func announce(_ people: [RosterPeer]) {
+ guard let centre = centre, !people.isEmpty else { return }
+ for person in people {
+ let content = UNMutableNotificationContent()
+ content.title = "Someone is waiting to join"
+ // The person's own name, which came from the server's roster and is
+ // someone else's text — so it goes in the body as data and never
+ // into a format string that could be read as anything else.
+ content.body = person.name
+ content.sound = .default
+ centre.add(
+ UNNotificationRequest(
+ identifier: "knock-" + person.peer, content: content, trigger: nil))
+ }
+ }
+
+ /// Nil outside a real application bundle. UNUserNotificationCenter
+ /// raises rather than returning an error when there is no bundle
+ /// identifier to register against, which is every unit test and the
+ /// interop executable — neither of which should take the app down for
+ /// want of a notification nobody would see.
+ private static var centre: UNUserNotificationCenter? {
+ guard Bundle.main.bundleIdentifier != nil else { return nil }
+ return UNUserNotificationCenter.current()
+ }
+}
diff --git a/native/apple/Tests/CallsKitTests/KnockWatchTests.swift b/native/apple/Tests/CallsKitTests/KnockWatchTests.swift
new file mode 100644
index 0000000..3ba339f
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/KnockWatchTests.swift
@@ -0,0 +1,84 @@
+import XCTest
+
+@testable import CallsKit
+
+/// The rules about when someone waiting is worth interrupting for. All of
+/// them live in KnockWatch precisely so they can be held here, without a
+/// notification centre or an app bundle.
+final class KnockWatchTests: XCTestCase {
+ private func peer(_ id: String, _ name: String) -> RosterPeer {
+ let json = #"{"peer":"\#(id)","name":"\#(name)","kind":"guest","gen":null}"#
+ return try! JSONDecoder().decode(RosterPeer.self, from: Data(json.utf8))
+ }
+
+ @MainActor func testSomeoneArrivingWhileAwayIsAnnouncedExactlyOnce() {
+ let watch = KnockWatch()
+ let bea = peer("p1", "Bea")
+ XCTAssertEqual(watch.arrivals(waiting: [bea], visible: false).map(\.name), ["Bea"])
+ // The same roster arriving again is the same person still waiting,
+ // not a second knock. State events repeat constantly.
+ XCTAssertTrue(watch.arrivals(waiting: [bea], visible: false).isEmpty)
+ XCTAssertTrue(watch.arrivals(waiting: [bea], visible: false).isEmpty)
+ }
+
+ @MainActor func testNobodyIsAnnouncedWhileTheQueueIsOnScreen() {
+ let watch = KnockWatch()
+ XCTAssertTrue(watch.arrivals(waiting: [peer("p1", "Bea")], visible: true).isEmpty)
+ }
+
+ @MainActor func testPeopleWhoArrivedWhileLookingAreNotAnnouncedOnTabbingAway() {
+ // The rule that makes the feature bearable. Someone watching the call
+ // sees the queue; tabbing away afterwards must not then produce a
+ // pile of notifications about people who have been there for a while.
+ // Only an arrival *during* the absence is news.
+ let watch = KnockWatch()
+ let bea = peer("p1", "Bea")
+ _ = watch.arrivals(waiting: [bea], visible: true)
+ XCTAssertTrue(
+ watch.arrivals(waiting: [bea], visible: false).isEmpty,
+ "someone seen while the app was in front must not be announced later")
+ let cass = peer("p2", "Cass")
+ XCTAssertEqual(
+ watch.arrivals(waiting: [bea, cass], visible: false).map(\.name), ["Cass"],
+ "the person who arrived while away is the only news")
+ }
+
+ @MainActor func testLeavingTheQueueAndKnockingAgainIsHeardAgain() {
+ // Someone let in, or who gave up and came back, is a new knock. A set
+ // that only ever grew would swallow the second one in silence.
+ let watch = KnockWatch()
+ let bea = peer("p1", "Bea")
+ XCTAssertEqual(watch.arrivals(waiting: [bea], visible: false).count, 1)
+ XCTAssertTrue(watch.arrivals(waiting: [], visible: false).isEmpty)
+ XCTAssertEqual(
+ watch.arrivals(waiting: [bea], visible: false).map(\.name), ["Bea"],
+ "a second knock from the same person must be heard")
+ }
+
+ @MainActor func testOnePersonSwappedForAnotherIsStillOneAnnouncement() {
+ // A count would read this as "still one waiting" and say nothing. The
+ // set is what makes the swap visible.
+ let watch = KnockWatch()
+ _ = watch.arrivals(waiting: [peer("p1", "Bea")], visible: false)
+ XCTAssertEqual(
+ watch.arrivals(waiting: [peer("p2", "Cass")], visible: false).map(\.name), ["Cass"])
+ }
+
+ @MainActor func testSeveralArrivingAtOnceAreAllAnnounced() {
+ let watch = KnockWatch()
+ let names = watch.arrivals(
+ waiting: [peer("p1", "Bea"), peer("p2", "Cass")], visible: false
+ ).map(\.name)
+ XCTAssertEqual(names, ["Bea", "Cass"])
+ }
+
+ @MainActor func testEndingACallForgetsItsQueue() {
+ let watch = KnockWatch()
+ let bea = peer("p1", "Bea")
+ _ = watch.arrivals(waiting: [bea], visible: false)
+ watch.reset()
+ XCTAssertEqual(
+ watch.arrivals(waiting: [bea], visible: false).map(\.name), ["Bea"],
+ "a queue from a finished meeting says nothing about the next one")
+ }
+}