oficina / meet Public

Merge feat/cant-hear-you-yet: "… is connecting and can't hear you yet" (§15.24)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 890de9af8ea89b41ef7d5a60fe396624a4beef52 parent f86d4daparent ef933d1
31 files changed, +2048 −80
  • Makefile +1 −0
  • callscore/media_test.go +112 −3
  • callscore/room.go +69 −22
  • docs/superpowers/specs/2026-09-07-meet-design.md +99 −0
  • internal/calls/hub/media.go +6 −1
  • internal/calls/hub/media_test.go +71 −0
  • internal/calls/signal.go +18 −8
  • internal/calls/static/calls.css +17 −0
  • internal/calls/static/room.js +292 −15
  • internal/callstest/browser_test.go +1 −0
  • internal/callstest/media_test.go +65 −1
  • native/README.md +9 −0
  • native/apple/Sources/CallsInterop/CallSnapshots.swift +46 −0
  • native/apple/Sources/CallsInterop/Interop.swift +6 −0
  • native/apple/Sources/CallsKit/AudioMeter.swift +63 −8
  • native/apple/Sources/CallsKit/AudioWork.swift +6 −0
  • native/apple/Sources/CallsKit/Call.swift +114 −8
  • native/apple/Sources/CallsKit/CallFixtures.swift +8 −0
  • native/apple/Sources/CallsKit/ConnectingWatch.swift +172 −0
  • native/apple/Sources/CallsKit/Media.swift +51 −7
  • native/apple/Sources/CallsKit/MiniWindow.swift +52 −1
  • native/apple/Sources/CallsKit/Wire.swift +6 −1
  • native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift +240 −0
  • native/bridge/bridge.go +15 −0
  • native/ios/UITests/CallsPhoneUITests.swift +24 −0
  • protocol/messages.go +17 −1
  • test/browser/call.mjs +184 −1
  • test/browser/native-auth.mjs +3 −2
  • test/browser/native.mjs +41 −0
  • test/browser/room-connecting.test.mjs +196 −0
  • test/browser/room-mute.test.mjs +44 −1

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
ok  	amadan.net/oficina/calls/callscore	7.288s
?   	amadan.net/oficina/calls/cmd/calls	[no test files]
ok  	amadan.net/oficina/calls/internal/calls	1.627s
ok  	amadan.net/oficina/calls/internal/calls/apns	(cached)
ok  	amadan.net/oficina/calls/internal/calls/hub	0.358s
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	(cached)
ok  	amadan.net/oficina/calls/internal/calls/seal	(cached)
ok  	amadan.net/oficina/calls/internal/callstest	8.829s
?   	amadan.net/oficina/calls/protocol	[no test files]
?   	amadan.net/oficina/calls/scripts/writeestilo	[no test files]
=== migration-check ===
go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
=== race ===
CGO_ENABLED=1 go test -race ./internal/...
ok  	amadan.net/oficina/calls/internal/calls	10.359s
ok  	amadan.net/oficina/calls/internal/calls/apns	(cached)
ok  	amadan.net/oficina/calls/internal/calls/hub	2.912s
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	(cached)
ok  	amadan.net/oficina/calls/internal/calls/seal	(cached)
ok  	amadan.net/oficina/calls/internal/callstest	28.505s
diff --git a/Makefile b/Makefile
index 0075e0f..4b39b17 100644
--- a/Makefile
+++ b/Makefile
@@ -152,6 +152,7 @@ browser:
node --test test/browser/room-hearing.test.mjs
node --test test/browser/room-screen.test.mjs
node --test test/browser/room-reconnect.test.mjs
+ node --test test/browser/room-connecting.test.mjs
node --test test/browser/room-glow.test.mjs
node --test test/browser/room-layout.test.mjs
node --test test/browser/room-window.test.mjs
diff --git a/callscore/media_test.go b/callscore/media_test.go
index 6c3010e..28956d5 100644
--- a/callscore/media_test.go
+++ b/callscore/media_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
"time"
@@ -85,7 +86,7 @@ func TestOwnMediaIsReportedOnAdmissionAndOnChange(t *testing.T) {
}
// A later roster that already says what we say: nothing to send.
- if err := r.receive(roster(protocol.RosterPeer{Peer: "self", Media: &protocol.MediaState{Muted: true}}, protocol.RosterPeer{Peer: "other", Gen: 1})); err != nil {
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self", Media: &protocol.MediaState{Muted: true, Hearing: []protocol.PeerID{}}}, protocol.RosterPeer{Peer: "other", Gen: 1})); err != nil {
t.Fatal(err)
}
nothingPosted(t, posted, "a roster that agrees needs no report")
@@ -263,7 +264,8 @@ func TestScreenStalledFallsBackOnAnOldServer(t *testing.T) {
var m protocol.MediaState
_ = json.Unmarshal(s.Payload, &m)
posted <- s
- if len(m.ScreenStalled) > 0 {
+ // A server from before §15.21 knows neither key (nor §15.24's).
+ if len(m.ScreenStalled) > 0 || m.Hearing != nil {
w.WriteHeader(http.StatusBadRequest)
}
}))
@@ -279,7 +281,8 @@ func TestScreenStalledFallsBackOnAnOldServer(t *testing.T) {
if err := r.receive(roster(protocol.RosterPeer{Peer: "self"}, protocol.RosterPeer{Peer: b, Gen: 1})); err != nil {
t.Fatal(err)
}
- nextMedia(t, posted)
+ nextMedia(t, posted) // with hearing, refused
+ nextMedia(t, posted) // without it
if err := r.SetMedia(true, false); err != nil {
t.Fatal(err)
}
@@ -300,3 +303,109 @@ func TestScreenStalledFallsBackOnAnOldServer(t *testing.T) {
t.Fatalf("later report %+v, want it plain", m)
}
}
+
+// §15.24: this core always says who it hears — [] until it hears anyone,
+// so it is never mistaken for a client that does not say — and the list
+// is kept like noSoundFrom's: roster peers only, in roster order, a
+// repeat not a change.
+func TestHearingIsAlwaysReportedFromTheRoster(t *testing.T) {
+ r, posted := mediaRoom(t)
+ a, b := protocol.PeerID("aaaa"), protocol.PeerID("bbbb")
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self"}, protocol.RosterPeer{Peer: a, Gen: 1}, protocol.RosterPeer{Peer: b, Gen: 1})); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case s := <-posted:
+ if !strings.Contains(string(s.Payload), `"hearing":[]`) {
+ t.Fatalf("admission report %s, want hearing: [] on the wire", s.Payload)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("no admission report")
+ }
+ if err := r.SetHearing([]protocol.PeerID{b, "self", "stranger", a}); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); len(m.Hearing) != 2 || m.Hearing[0] != a || m.Hearing[1] != b {
+ t.Fatalf("hearing %v, want [aaaa bbbb] in roster order", m.Hearing)
+ }
+ if err := r.SetHearing([]protocol.PeerID{a, b}); err != nil {
+ t.Fatal(err)
+ }
+ nothingPosted(t, posted, "the same list is not a change")
+ if err := r.SetHearing(nil); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); m.Hearing == nil || len(m.Hearing) != 0 {
+ t.Fatalf("cleared %+v, want hearing: [] and not absent", m)
+ }
+ // A roster that carries the same [] needs nothing; one that lost the
+ // key (a restarted hub) is told again.
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self", Media: &protocol.MediaState{Hearing: []protocol.PeerID{}}}, protocol.RosterPeer{Peer: a, Gen: 1})); err != nil {
+ t.Fatal(err)
+ }
+ nothingPosted(t, posted, "a roster that agrees needs no report")
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self", Media: &protocol.MediaState{}}, protocol.RosterPeer{Peer: a, Gen: 1})); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); m.Hearing == nil {
+ t.Fatalf("a roster without our hearing must be corrected, got %+v", m)
+ }
+}
+
+// A server from before §15.24 refuses every report from this core, which
+// always carries hearing. The core sends the same report again without
+// it and leaves it out from then on, so a mute is never lost; a server
+// from before §15.21 as well refuses the stalled screens next, and they
+// go too.
+func TestHearingFallsBackOnAnOldServer(t *testing.T) {
+ posted := make(chan protocol.Signal, 16)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ var s protocol.Signal
+ _ = json.NewDecoder(req.Body).Decode(&s)
+ var m protocol.MediaState
+ _ = json.Unmarshal(s.Payload, &m)
+ posted <- s
+ if m.Hearing != nil || len(m.ScreenStalled) > 0 {
+ w.WriteHeader(http.StatusBadRequest)
+ }
+ }))
+ t.Cleanup(srv.Close)
+ r, err := New(srv.URL, "room", srv.Client(), func(Event) {})
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.started = true
+ r.state.Peer = "self"
+ t.Cleanup(r.Close)
+ b := protocol.PeerID("bbbb")
+ _ = r.SetMedia(true, false)
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self"}, protocol.RosterPeer{Peer: b, Gen: 1})); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); m.Hearing == nil {
+ t.Fatalf("first report %+v, want hearing", m)
+ }
+ if m := nextMedia(t, posted); m.Hearing != nil || !m.Muted {
+ t.Fatalf("resent %+v, want the same report without hearing", m)
+ }
+ if err := r.SetScreenStalled([]protocol.PeerID{b}); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); len(m.ScreenStalled) != 1 || m.Hearing != nil {
+ t.Fatalf("report %+v, want stalled screens and no hearing", m)
+ }
+ if m := nextMedia(t, posted); len(m.ScreenStalled) != 0 || m.Hearing != nil || !m.Muted {
+ t.Fatalf("resent %+v, want it plain and still muted", m)
+ }
+ if err := r.SetHearing([]protocol.PeerID{b}); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); m.Hearing != nil {
+ t.Fatalf("later report %+v, want no hearing", m)
+ }
+ // The roster agrees with what this server can hold: nothing to send.
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self", Media: &protocol.MediaState{Muted: true}}, protocol.RosterPeer{Peer: b, Gen: 1})); err != nil {
+ t.Fatal(err)
+ }
+ nothingPosted(t, posted, "a roster that agrees with the plain report needs nothing")
+}
diff --git a/callscore/room.go b/callscore/room.go
index 25a56ee..e9950fb 100644
--- a/callscore/room.go
+++ b/callscore/room.go
@@ -63,6 +63,10 @@ type Room struct {
// plainScreen: the server refused a report naming stalled screens
// (it predates §15.21), so reports go without them.
plainScreen bool
+ // plainHearing: the server refused a report saying who this client
+ // hears (it predates §15.24), so reports go without the key, and the
+ // people on new clients fall back to their own evidence about us.
+ plainHearing bool
}
func New(origin, room string, authenticated *http.Client, callback func(Event)) (*Room, error) {
@@ -74,7 +78,7 @@ func New(origin, room string, authenticated *http.Client, callback func(Event))
return nil, errors.New("a room event handler is required")
}
ctx, cancel := context.WithCancel(context.Background())
- return &Room{t: t, callback: callback, ctx: ctx, cancel: cancel, state: Snapshot{Phase: "ready", Peers: []protocol.RosterPeer{}, Waiting: []protocol.RosterPeer{}}, last: map[protocol.PeerID]uint64{}, seq: map[protocol.PeerID]uint64{}, lanes: map[protocol.PeerID]*lane{}, blocked: map[protocol.PeerID]bool{}, iceWake: make(chan struct{}, 1)}, nil
+ return &Room{t: t, callback: callback, ctx: ctx, cancel: cancel, state: Snapshot{Phase: "ready", Peers: []protocol.RosterPeer{}, Waiting: []protocol.RosterPeer{}}, media: protocol.MediaState{Hearing: []protocol.PeerID{}}, last: map[protocol.PeerID]uint64{}, seq: map[protocol.PeerID]uint64{}, lanes: map[protocol.PeerID]*lane{}, blocked: map[protocol.PeerID]bool{}, iceWake: make(chan struct{}, 1)}, nil
}
func (r *Room) emit(e Event) { r.emitMu.Lock(); defer r.emitMu.Unlock(); r.callback(e) }
func (r *Room) snapshotLocked() Snapshot {
@@ -316,6 +320,34 @@ func (r *Room) SetScreenStalled(peers []protocol.PeerID) error {
return r.reportMedia()
}
+// SetHearing records which peers this client is actually receiving and
+// playing audio from (spec §15.24) and reports it with the rest of its
+// media state. Roster peers only, in roster order, bounded, exactly as
+// SetNoSoundFrom keeps its list — except that empty is not absent here:
+// this client always says who it hears, and "nobody yet" is [] on the
+// wire, so the people it has not yet heard know to wait for it rather
+// than fall back to guessing.
+func (r *Room) SetHearing(peers []protocol.PeerID) error {
+ want := make(map[protocol.PeerID]bool, len(peers))
+ for _, p := range peers {
+ want[p] = true
+ }
+ r.mu.Lock()
+ next := []protocol.PeerID{}
+ for _, p := range r.state.Peers {
+ if p.Peer != r.state.Peer && want[p.Peer] && len(next) < protocol.MaxHearing {
+ next = append(next, p.Peer)
+ }
+ }
+ if (protocol.MediaState{Hearing: next}).Equal(protocol.MediaState{Hearing: r.media.Hearing}) {
+ r.mu.Unlock()
+ return nil
+ }
+ r.media.Hearing = next
+ r.mu.Unlock()
+ return r.reportMedia()
+}
+
func (r *Room) reportMedia() error {
r.mu.Lock()
report := r.state.Phase == "admitted" && !r.stopped
@@ -328,37 +360,56 @@ func (r *Room) reportMedia() error {
func (r *Room) sendMedia() error {
r.mu.Lock()
+ b, err := json.Marshal(r.wireMediaLocked())
+ r.mu.Unlock()
+ if err != nil {
+ return err
+ }
+ return r.Send(protocol.TypeMedia, "", 0, string(b))
+}
+
+// wireMediaLocked is the report as this server will take it: the host's
+// state without the keys an older server has refused.
+func (r *Room) wireMediaLocked() protocol.MediaState {
report := r.media
if r.plainScreen {
// A server from before §15.21 refuses the key; everything else
// in the report still matters.
report.ScreenStalled = nil
}
- b, err := json.Marshal(report)
- r.mu.Unlock()
- if err != nil {
- return err
+ if r.plainHearing {
+ report.Hearing = nil
}
- return r.Send(protocol.TypeMedia, "", 0, string(b))
+ return report
}
-// refusedScreenStalled is a server from before §15.21 refusing a media
-// report because it named stalled screens: from then on the report goes
-// without them, and this one is sent again that way. The sharer never
-// hears, and recovers on its own watchdog alone.
-func (r *Room) refusedScreenStalled(s protocol.Signal) bool {
+// refusedExtras is a server from before §15.24 or §15.21 refusing a media
+// report because of a key it does not know: the newest key goes first —
+// hearing, which every report from this core carries — and the report is
+// sent again without it; a second refusal, of a report naming stalled
+// screens, takes those off too. From then on reports go without them, so
+// a mute is never lost to an old server. Without hearing, the people on
+// new clients fall back to their own evidence that they can be heard
+// (§15.24); without screenStalled the sharer recovers on its own watchdog.
+func (r *Room) refusedExtras(s protocol.Signal) bool {
var sent protocol.MediaState
- if json.Unmarshal(s.Payload, &sent) != nil || len(sent.ScreenStalled) == 0 {
+ if json.Unmarshal(s.Payload, &sent) != nil {
return false
}
r.mu.Lock()
- already := r.plainScreen
- r.plainScreen = true
+ dropped := false
+ if sent.Hearing != nil && !r.plainHearing {
+ r.plainHearing = true
+ dropped = true
+ } else if len(sent.ScreenStalled) > 0 && !r.plainScreen {
+ r.plainScreen = true
+ dropped = true
+ }
r.mu.Unlock()
- if !already {
+ if dropped {
go func() { _ = r.sendMedia() }()
}
- return true
+ return dropped
}
// mediaStaleLocked says whether the hub's record of this client — its
@@ -368,11 +419,7 @@ func (r *Room) refusedScreenStalled(s protocol.Signal) bool {
func (r *Room) mediaStaleLocked() bool {
for _, p := range r.state.Peers {
if p.Peer == r.state.Peer {
- want := r.media
- if r.plainScreen {
- want.ScreenStalled = nil
- }
- return p.Media == nil || !p.Media.Equal(want)
+ return p.Media == nil || !p.Media.Equal(r.wireMediaLocked())
}
}
return false
@@ -648,7 +695,7 @@ func (r *Room) sendLoop(l *lane) {
if refusal.Status == 429 {
delay = max(delay, refusal.RetryAfter)
} else if refusal.Status < 500 {
- if refusal.Status == 400 && s.Type == protocol.TypeMedia && r.refusedScreenStalled(s) {
+ if refusal.Status == 400 && s.Type == protocol.TypeMedia && r.refusedExtras(s) {
break
}
if refusal.Status == 401 || refusal.Status == 403 || refusal.Status == 302 || refusal.Status == 303 {
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md
index 243ec8e..97222f9 100644
--- a/docs/superpowers/specs/2026-09-07-meet-design.md
+++ b/docs/superpowers/specs/2026-09-07-meet-design.md
@@ -2566,3 +2566,102 @@ the apps, `RECONNECT_*` in room.js) with unit tests either side; the browser
gate's `renewal` scenario and the native gate's `resume` and `renewals` watch
for the words through several renewals and fail on any. The core's phase is
unchanged: controls that need an admitted call still follow it.
+
+### 15.24 §15.19, §15.20 — "… is connecting and can't hear you yet" (2026-10-01)
+
+**Why.** Paul, 2026-10-01, asking for what Zoom shows: "didemeski is
+connecting to audio and cannot hear you yet …". When someone joins, there is
+a gap — a few hundred milliseconds on a good network, seconds on a bad one,
+for ever on a broken one — before their side is receiving and playing your
+audio, and nothing said so: people greeted a newcomer who could not yet hear
+them. Nothing on the wire said whether anyone could hear anyone; §15.20 only
+carried bad news after five seconds of flat sound.
+
+**Decision.** A positive receive signal, and a notice built on it.
+
+- **`hearing`.** §15.19's `media` gains one more optional member: the peers
+ whose audio this client is actually receiving and playing. Unlike §15.20's
+ and §15.21's lists it is good news, and its presence is itself news: an
+ absent list is a client that does not say (one from before this section),
+ `[]` is a client that hears nobody yet. So it is sent whenever the server
+ takes it, `[]` included, and the hub keeps it with its emptiness — `[]`
+ travels as `[]`, and a change between absent and `[]` is a change.
+ Otherwise it is held as `noSoundFrom` is: at most fifteen distinct
+ well-formed peer ids, checked for shape and not presence, a `null`, a
+ non-array, a repeat or a sixteenth id refused 400; re-encoded last
+ (`muted`, `cameraOff`, `audioBlocked`, `noSoundFrom`, `screenStalled`,
+ `hearing`). The raw payload cap is 3 KB, room for all three lists full
+ (about 1,700 bytes) and whitespace. Sent only on a change, like the rest.
+- **The rule — who a client lists.** A peer is listed once the audio path
+ from them is established and is playing here:
+ - *established*: the connection is up (ICE connected or completed) and
+ either their audio is arriving — inbound packets received and decoded
+ samples coming out (`totalSamplesReceived` in the browser; frames at the
+ receive tap in the apps) on a live receiver — or **they say they are
+ muted**, in which case the connection being up and the receiver live
+ (or no audio receiver at all, for someone with no microphone) is enough.
+ A muted sender may send only silence, or nothing, and must not look
+ unheard: the moment they unmute, it plays. Once established it stays so
+ until the connection fails or closes; a blip is not losing them.
+ - *playing*: in the browser, the tile's element is playing and was not
+ refused by an autoplay policy (a refused tile is §15.20's
+ `audioBlocked`, and is not listed until a click plays it). The apps play
+ whatever they decode; there is no policy to refuse them.
+- **The notice.** From the moment a person appears in the roster — or
+ appears again, or is given a new connection (a new pair generation) —
+ their tile says "Samuel is connecting and can't hear you yet", with three
+ dots after it that brighten in turn (one steady ellipsis under reduced
+ motion). It goes the moment their report lists you in `hearing`, and a
+ screen reader is told "Samuel can hear you now". In order:
+ - **The grace.** It is not said at all if they hear you within 1 s of
+ appearing (`CONNECTING_GRACE`, `ConnectingWatch.grace`), so ordinary
+ joins say nothing. The browser gate measured 284 ms from appearing to
+ being heard on a loopback.
+ - **More specific news replaces it**: their `audioBlocked` ("Samuel's
+ browser is blocking sound — they need to click to hear you"), or you in
+ their `noSoundFrom` while you are unmuted ("Samuel isn't getting any
+ sound from you"). Those are their own reports and show even inside the
+ grace.
+ - **"Reconnecting to Samuel…" waits until after** (§15.23): a connection
+ that has never been up is not reconnecting. While someone is in their
+ first moments the reconnect watch is told nothing is wrong with them,
+ so a slow join of two people is never "Reconnecting…" for the call.
+ - **A client that does not say** (no `hearing` in its report — a client
+ from before this section, or any client on a server that refused the
+ key) is let go once the connection to it is up and either its receiver
+ reports show your audio reaching it (`remote-inbound-rtp` for your
+ microphone, which exists only once one has come back, with packets
+ sent) or it has made a media report without the list. A client that
+ does say is believed over this evidence.
+ - **The cap.** Whatever happens, it is given up 20 s after they appeared
+ (`CONNECTING_CAP`, `ConnectingWatch.cap`), silently; §15.20's "isn't
+ getting any sound" and §15.23's "Reconnecting to …" speak for any
+ problem after that.
+ - **Your own mute does not matter**: it is about their being able to
+ hear you once you speak.
+- **Where.** On the person's tile, in the place and the glass of the other
+ tile notices, with an ear where they have a warning triangle: every
+ layout on the Mac and the iPhone, and the browser's call window. In the
+ browser and in the apps' Grid, Speaker and Classic every person in the
+ roster has a tile from the roster on, so there is no call-level form;
+ the Mac's mini window, which shows one person, puts a small ear on its
+ glass for anyone else still connecting (their names in its tooltip and
+ for VoiceOver), and its tab shows the same ear. Screen readers hear the
+ sentence once when it appears and "… can hear you now" when it goes,
+ politely, and never the dots.
+
+**Compatibility, both orders.** Old clients ignore the member (the browser's
+`mediaFrom`, the core's decoder and the apps' `PeerMedia` all drop unknown
+keys). A server from before this section refuses a report carrying it
+(400): the browser then drops the extras for the rest of the page (§15.20)
+— though the browser's page is served by the same server, so only a page
+open across a deploy meets that — and the apps' core resends the same
+report without `hearing` and leaves it out from then on, then without
+`screenStalled` too if that is refused next, so a mute is never lost. The
+people on new clients then fall back to their own evidence about that
+client, as they do for an old one. So the server and the clients may ship
+in either order.
+
+**Not logged, not stored.** As §15.19. The apps log "… can hear us" in the
+connection log (`os_log` category `connection`), the browser on its
+console, each with the first six characters of the peer id only.
diff --git a/internal/calls/hub/media.go b/internal/calls/hub/media.go
index 5c85873..63a7d7f 100644
--- a/internal/calls/hub/media.go
+++ b/internal/calls/hub/media.go
@@ -14,7 +14,7 @@ import (
//
// §15.20 adds the receiving half to the same report — audioBlocked and
// noSoundFrom, each present only when it has bad news, and 2026-09-28's
-// screenStalled beside them — and nothing
+// screenStalled beside them, and §15.24's hearing after those — and nothing
// about the rules: it is still the sender's own claim, recorded and
// carried in rosters, and fanned out only when it changes.
//
@@ -61,6 +61,11 @@ func (h *Hub) SetMedia(ctx context.Context, sqid, session string, from PeerID, m
next := m
next.NoSoundFrom = append([]PeerID(nil), m.NoSoundFrom...)
next.ScreenStalled = append([]PeerID(nil), m.ScreenStalled...)
+ // §15.24's hearing keeps its emptiness: [] says "nobody yet", where
+ // nil says nothing at all.
+ if m.Hearing != nil {
+ next.Hearing = append(make([]PeerID, 0, len(m.Hearing)), m.Hearing...)
+ }
p.Media = &next
payload, err := json.Marshal(next)
if err != nil {
diff --git a/internal/calls/hub/media_test.go b/internal/calls/hub/media_test.go
index a1889f0..480b845 100644
--- a/internal/calls/hub/media_test.go
+++ b/internal/calls/hub/media_test.go
@@ -417,3 +417,74 @@ func TestSetMediaCarriesTheReceivingHalf(t *testing.T) {
t.Fatalf("clearing the bad news sent %v", got)
}
}
+
+// §15.24: hearing is kept with its emptiness — [] is "nobody yet", nil is
+// a client that does not say — carried after the other lists, copied so
+// nothing the caller holds is shared with a roster, and a change between
+// nil and [] is a change.
+func TestSetMediaCarriesHearing(t *testing.T) {
+ now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ ctx := context.Background()
+ a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
+ h.Join(ctx, "r1", "sa", a.Peer)
+ b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
+ h.Join(ctx, "r1", "sb", b.Peer)
+ drain(a.Sub)
+ drain(b.Sub)
+
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Hearing: []PeerID{}}); err != nil {
+ t.Fatal(err)
+ }
+ got := drain(b.Sub)
+ if len(got) != 1 || string(got[0].Payload) != `{"muted":false,"cameraOff":false,"hearing":[]}` {
+ t.Fatalf("empty hearing sent %v", got)
+ }
+ list := []PeerID{b.Peer}
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{ScreenStalled: list, Hearing: list}); err != nil {
+ t.Fatal(err)
+ }
+ got = drain(b.Sub)
+ want := `{"muted":false,"cameraOff":false,"screenStalled":["` + string(b.Peer) + `"],"hearing":["` + string(b.Peer) + `"]}`
+ if len(got) != 1 || string(got[0].Payload) != want {
+ t.Fatalf("payload %v, want %s", got, want)
+ }
+ list[0] = "rewritten"
+ c, _ := h.Open("r1", "sc", 3, "Cy", KindMember)
+ h.Join(ctx, "r1", "sc", c.Peer)
+ var ro *Roster
+ for _, ev := range drain(c.Sub) {
+ if ev.Type == TypeRoster {
+ ro = ev.Roster
+ }
+ }
+ drain(b.Sub)
+ m, _ := rosterMedia(ro, a.Peer)
+ if m == nil || len(m.Hearing) != 1 || m.Hearing[0] != b.Peer {
+ t.Fatalf("roster media %+v, want the hub's own copy of the list", m)
+ }
+ // Back to []: a change. Then nil: a change again, and the two-key report.
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Hearing: []PeerID{}}); err != nil {
+ t.Fatal(err)
+ }
+ if got := drain(b.Sub); len(got) != 1 {
+ t.Fatalf("back to empty sent %d", len(got))
+ }
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{}); err != nil {
+ t.Fatal(err)
+ }
+ got = drain(b.Sub)
+ if len(got) != 1 || string(got[0].Payload) != `{"muted":false,"cameraOff":false}` {
+ t.Fatalf("nil after [] sent %v", got)
+ }
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{}); err != nil {
+ t.Fatal(err)
+ }
+ if got := drain(b.Sub); len(got) != 0 {
+ t.Fatalf("the same report again reached Bob: %v", types(got))
+ }
+ var round MediaState
+ if err := json.Unmarshal([]byte(`{"muted":false,"cameraOff":false,"hearing":[]}`), &round); err != nil || round.Hearing == nil {
+ t.Fatalf("[] must decode as an empty, non-nil list: %+v %v", round, err)
+ }
+}
diff --git a/internal/calls/signal.go b/internal/calls/signal.go
index 22db7f5..1edfc73 100644
--- a/internal/calls/signal.go
+++ b/internal/calls/signal.go
@@ -186,17 +186,20 @@ func exactKeys(raw json.RawMessage, want ...string) bool {
// maxMediaBytes bounds §15.19's payload before it is decoded. The
// longest honest one — both booleans false, audioBlocked, and full
-// noSoundFrom and screenStalled lists of fifteen 32-character peer ids
-// each (§15.20, §15.21) — is a little over 1,150 bytes; the rest is room
+// noSoundFrom, screenStalled and hearing lists of fifteen 32-character
+// peer ids each (§15.20, §15.21, §15.24) — is a little over 1,700 bytes;
+// the rest is room
// for whitespace a client's encoder might add, and nothing a peer can use
// to put more on anyone's stream: what goes out is re-encoded from the
// validated values.
-const maxMediaBytes = 2048
+const maxMediaBytes = 3072
-// validateMedia is §15.19's shape as §15.20 and §15.21 extend it: "muted"
-// and "cameraOff", both booleans and both required; "audioBlocked", a
-// boolean, and "noSoundFrom" and "screenStalled", each an array of at
-// most fifteen distinct peer ids, all optional. Keys byte-exact and each
+// validateMedia is §15.19's shape as §15.20, §15.21 and §15.24 extend it:
+// "muted" and "cameraOff", both booleans and both required; "audioBlocked",
+// a boolean, and "noSoundFrom", "screenStalled" and "hearing", each an
+// array of at most fifteen distinct peer ids, all optional. An empty
+// "hearing" is kept as empty rather than absent: it is a client saying it
+// hears nobody yet, where an absent one says nothing (§15.24). Keys byte-exact and each
// once, no other member, judged on the raw bytes the same way
// validateFanout judges chat and share (Codex S4-1). It returns the
// decoded state, or a status and an outcome word for the refusal log.
@@ -209,7 +212,7 @@ func validateMedia(raw json.RawMessage) (protocol.MediaState, int, string) {
if len(raw) > maxMediaBytes {
return protocol.MediaState{}, http.StatusRequestEntityTooLarge, "size"
}
- if !memberKeys(raw, []string{"muted", "cameraOff"}, []string{"audioBlocked", "noSoundFrom", "screenStalled"}) {
+ if !memberKeys(raw, []string{"muted", "cameraOff"}, []string{"audioBlocked", "noSoundFrom", "screenStalled", "hearing"}) {
return protocol.MediaState{}, http.StatusBadRequest, "shape"
}
var p struct {
@@ -218,6 +221,7 @@ func validateMedia(raw json.RawMessage) (protocol.MediaState, int, string) {
AudioBlocked json.RawMessage `json:"audioBlocked"`
NoSoundFrom json.RawMessage `json:"noSoundFrom"`
ScreenStalled json.RawMessage `json:"screenStalled"`
+ Hearing json.RawMessage `json:"hearing"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.Muted == nil || p.CameraOff == nil {
return protocol.MediaState{}, http.StatusBadRequest, "shape"
@@ -237,6 +241,12 @@ func validateMedia(raw json.RawMessage) (protocol.MediaState, int, string) {
if m.ScreenStalled, ok = peerList(p.ScreenStalled, protocol.MaxScreenStalled); !ok {
return protocol.MediaState{}, http.StatusBadRequest, "shape"
}
+ if m.Hearing, ok = peerList(p.Hearing, protocol.MaxHearing); !ok {
+ return protocol.MediaState{}, http.StatusBadRequest, "shape"
+ }
+ if p.Hearing != nil && m.Hearing == nil {
+ m.Hearing = []hub.PeerID{}
+ }
return m, 0, ""
}
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index a3a16b1..aa85ff6 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -868,6 +868,23 @@ body:has(> .calls-window) { background: var(--rst-bg); }
flex-basis: 100%;
font-weight: 600;
}
+/* §15.24: "Samuel is connecting and can't hear you yet", the words
+ steady and three dots after them that brighten in turn — waiting, not
+ wrong. One steady ellipsis under reduced motion. The dots are
+ aria-hidden: a screen reader hears the sentence once. */
+.calls-ellipsis {
+ display: inline-flex;
+ letter-spacing: 0.04em;
+}
+@media (prefers-reduced-motion: no-preference) {
+ .calls-ellipsis > span { animation: calls-ellipsis 1.4s ease-in-out infinite both; }
+ .calls-ellipsis > span:nth-child(2) { animation-delay: 0.2s; }
+ .calls-ellipsis > span:nth-child(3) { animation-delay: 0.4s; }
+}
+@keyframes calls-ellipsis {
+ 0%, 80%, 100% { opacity: 0.35; }
+ 40% { opacity: 1; }
+}
/* A filmstrip tile, or any small one (0.4.1). A tile too small for the
camera-off words keeps the symbol alone, then a smaller one. The
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index ad248c5..ee8aefe 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -315,6 +315,13 @@
const RECONNECT_AUDIO_AFTER = 2000;
const RECONNECT_HOLD = 1000;
const RECONNECT_EVERY = 200;
+ // §15.24 (2026-10-01), "<name> is connecting and can't hear you yet":
+ // said on a person's tile from the moment they appear until their own
+ // report says they hear us — but only once CONNECTING_GRACE has passed
+ // without that, so an ordinary join says nothing, and never past
+ // CONNECTING_CAP, after which §15.20's and §15.23's words take over.
+ const CONNECTING_GRACE = 1000;
+ const CONNECTING_CAP = 20000;
// §5.4, sending: "Optimise shared screen for". What a screen gives up
// first when the uplink or the encoder cannot carry all of it. The
// first is the default; sharePlan says what each one means.
@@ -393,6 +400,10 @@
// §15.23: when the stream went down (0 while it is up), and the watch.
let streamDownSince = 0;
const reconnect = reconnectRecord();
+ // §15.24: who can hear us yet (connectingStep's record), and the
+ // `hearing` list as last reported, joined, so a change is seen.
+ const connecting = connectingRecord();
+ let hearingKey = '';
// §15.20: peer id -> what this tab is hearing from that peer, as
// pollLevels reads it (hearingRecord). selfHearing is the same record
// for our own microphone, read off the sender.
@@ -4230,6 +4241,16 @@
}
if (ids.length) out.screenStalled = ids;
}
+ // §15.24: kept even when empty — [] is "I hear nobody yet", where an
+ // absent list is a client that does not say.
+ if (Array.isArray(value.hearing)) {
+ const ids = [];
+ for (const id of value.hearing) {
+ if (ids.length >= 15) break;
+ if (typeof id === 'string' && /^[0-9a-f]{32}$/.test(id) && !ids.includes(id)) ids.push(id);
+ }
+ out.hearing = ids;
+ }
return out;
}
@@ -4246,6 +4267,7 @@
if (m.audioBlocked) key += 'b';
if (m.noSoundFrom && m.noSoundFrom.length) key += ':' + m.noSoundFrom.slice().sort().join(',');
if (m.screenStalled && m.screenStalled.length) key += '/' + m.screenStalled.slice().sort().join(',');
+ if (Array.isArray(m.hearing)) key += '|' + m.hearing.slice().sort().join(',');
return key;
}
@@ -4259,17 +4281,19 @@
}
// mediaPayload is the report itself: §15.19's two booleans, and
- // §15.20's two keys only when they carry bad news — so the ordinary
- // report is still exactly the two-key object every server takes.
- // `plain` is a server that has refused the extras once. Pure, and
- // lifted by name in the node test.
- function mediaPayload(mine, blocked, silentFrom, plain, stalled) {
+ // §15.20's two keys only when they carry bad news. §15.24's `hearing`
+ // is the exception, and travels whenever it is given, [] included: it
+ // is good news, and its presence tells the others this tab says who it
+ // hears. `plain` is a server that has refused the extras once. Pure,
+ // and lifted by name in the node test.
+ function mediaPayload(mine, blocked, silentFrom, plain, stalled, heard) {
const out = { muted: !!mine.muted, cameraOff: !!mine.cameraOff };
if (plain) return out;
if (blocked) out.audioBlocked = true;
if (silentFrom && silentFrom.length) out.noSoundFrom = silentFrom.slice(0, 15);
// §15.21: whose screens have stalled here, bad news only, like the rest.
if (stalled && stalled.length) out.screenStalled = stalled.slice(0, 15);
+ if (Array.isArray(heard)) out.hearing = heard.slice(0, 15);
return out;
}
@@ -4285,7 +4309,7 @@
// as muted or not matters more than being heard as unable to hear.
function reportMedia() {
if (!state.admitted || state.stopped || !state.peer) return;
- const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras, screenStalled);
+ const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras, screenStalled, hearingList());
const key = mediaKey(now);
if (key === mediaSent) return;
mediaSent = key;
@@ -4313,7 +4337,7 @@
learnMedia(rp.peer, rp.media);
continue;
}
- const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras, screenStalled);
+ const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras, screenStalled, hearingList());
mediaSent = sameMedia(mediaFrom(rp.media), now) ? mediaKey(now) : '';
}
reportMedia();
@@ -4721,13 +4745,193 @@
rec.peers = shown;
}
+ // ---------------------------------------------------------------
+ // "… is connecting and can't hear you yet" (§15.24, 2026-10-01)
+ // ---------------------------------------------------------------
+ //
+ // Paul asked for Zoom's "… is connecting to audio and cannot hear you
+ // yet": the gap after someone joins, before their side is receiving and
+ // playing our audio, said rather than talked into. Their own report's
+ // `hearing` is the authority; a client that does not send one is judged
+ // on what this tab can see of its own audio reaching them.
+
+ function connectingRecord() {
+ return { peers: new Map() };
+ }
+
+ // hearsYou is a peer's report on us: true when its `hearing` lists us,
+ // false when it has a list and we are not on it, and null when it has
+ // no list — no report yet, or a client from before §15.24. Pure, and
+ // lifted by name in the node test.
+ function hearsYou(media, me) {
+ if (!media || !Array.isArray(media.hearing)) return null;
+ return !!me && media.hearing.includes(me);
+ }
+
+ // connectingStep is one look at everyone else: `people` is peer id ->
+ // {epoch, hearsMe, reported, connected, acked}, where epoch is the pair
+ // generation (a new one is a new connection, and they cannot hear us on
+ // it yet), hearsMe is hearsYou's answer, reported whether they have
+ // made any media report, connected whether the connection to them is
+ // up, and acked whether their receiver reports say our audio reaches
+ // them. rec.peers holds {epoch, since, done, shown} per person. Returns
+ // the people whose words have just gone because they can hear us, for
+ // "<name> can hear you now". The native app's ConnectingWatch is its
+ // twin. Pure, and lifted by name in the node test.
+ //
+ // Clears when they say they hear us; or, for a client that does not
+ // say (hearsMe null), once the connection is up and either our audio is
+ // acknowledged or they have reported without a list. Said only past
+ // CONNECTING_GRACE, and given up at CONNECTING_CAP whatever happens.
+ function connectingStep(rec, now, people) {
+ const cleared = [];
+ for (const id of Array.from(rec.peers.keys())) if (!people.has(id)) rec.peers.delete(id);
+ for (const [id, p] of people) {
+ let r = rec.peers.get(id);
+ if (!r || r.epoch !== p.epoch) {
+ r = { epoch: p.epoch, since: now, done: false, shown: false };
+ rec.peers.set(id, r);
+ }
+ if (r.done) continue;
+ const fallback = p.hearsMe === null && p.connected && (p.acked || p.reported);
+ if (p.hearsMe === true || fallback) {
+ if (r.shown) cleared.push(id);
+ r.done = true;
+ r.shown = false;
+ } else if (now - r.since >= CONNECTING_CAP) {
+ r.done = true;
+ r.shown = false;
+ } else {
+ r.shown = now - r.since >= CONNECTING_GRACE;
+ }
+ }
+ return cleared;
+ }
+
+ // connectingNow: whether a person is still in their first moments, as
+ // far as we can tell not hearing us — the words may not show yet.
+ function connectingNow(id) {
+ const r = connecting.peers.get(id);
+ return !!r && !r.done;
+ }
+
+ // The words. The ellipsis is drawn apart from them (calls-ellipsis), so
+ // a screen reader hears the sentence and never the dots.
+ function connectingWords(name) {
+ return name + ' is connecting and can\u2019t hear you yet';
+ }
+ function canHearWords(name) {
+ return name + ' can hear you now';
+ }
+
+ // peerNotice is what a person's tile says about them and us, in order:
+ // in their first moments (`joining`), a more specific report about us
+ // (their browser blocking sound, or no sound from us) and otherwise
+ // "connecting" once the grace has passed (`shown`) — "Reconnecting"
+ // would be wrong of a connection that has never been up; after that,
+ // §15.23's "Reconnecting to …" first and then the report. Returns
+ // {kind, text}: kind '' for nothing, 'connecting' for the words with
+ // the ellipsis. Pure, and lifted by name in the node test.
+ function peerNotice(name, media, me, muted, joining, shown, reconnecting) {
+ const report = remoteNotice(name, media, me, muted);
+ if (joining) {
+ if (report) return { kind: 'report', text: report };
+ if (shown) return { kind: 'connecting', text: connectingWords(name) };
+ return { kind: '', text: '' };
+ }
+ if (reconnecting) return { kind: 'reconnecting', text: 'Reconnecting to ' + name + '\u2026' };
+ if (report) return { kind: 'report', text: report };
+ return { kind: '', text: '' };
+ }
+
+ // judgeHeard is this tab's half of §15.24 for one connection: whether
+ // the audio path from that person is established. Once it is, it stays
+ // so until the connection fails or closes. Established is: the
+ // connection up, and either their audio arriving — packets received and
+ // decoded samples coming out — on a live receiver, or, for someone who
+ // says they are muted, nothing more than that (a muted sender may send
+ // only silence, or no audio at all, and must not look unheard: the
+ // moment they unmute, it plays). `f` is {connected, bad, hasReceiver,
+ // receiverLive, packets, samples, senderMuted}. Pure, and lifted by
+ // name in the node test.
+ function judgeHeard(was, f) {
+ if (f.bad) return false;
+ if (was) return true;
+ if (!f.connected) return false;
+ if (f.senderMuted && (!f.hasReceiver || f.receiverLive)) return true;
+ return !!(f.receiverLive && f.packets > 0 && f.samples > 0);
+ }
+
+ // hearingList is the `hearing` this tab would report now: everyone
+ // whose audio path is established and whose tile is playing — not
+ // refused by an autoplay policy — sorted, at most fifteen.
+ function hearingList() {
+ const out = [];
+ for (const [id, entry] of pcs) {
+ if (!entry.heard) continue;
+ const t = tiles.get(id);
+ if (!t || refusedTiles.has(id)) continue;
+ if (t.video && t.video.paused && entry.audioLive) continue;
+ out.push(id);
+ }
+ return out.sort().slice(0, 15);
+ }
+
+ // hearingMoved is whether `hearing` has changed in a way worth a report
+ // (both lists joined with commas): someone heard who was not, or someone
+ // still here no longer heard. Someone who has left is not news — every
+ // departure would otherwise cost a report from everyone left — and their
+ // id rides along, ignored, until the next report for another reason.
+ // Pure, and lifted by name in the node test.
+ function hearingMoved(before, now, present) {
+ const was = new Set(before ? before.split(',') : []);
+ const is = new Set(now ? now.split(',') : []);
+ for (const id of is) if (!was.has(id)) return true;
+ for (const id of was) if (!is.has(id) && present.has(id)) return true;
+ return false;
+ }
+
+ // watchConnecting feeds connectingStep what this tab knows, and says
+ // "<name> can hear you now" for anyone whose words have just gone.
+ function watchConnecting(now) {
+ const people = new Map();
+ for (const [id, entry] of pcs) {
+ const media = remoteMedia.get(id) || null;
+ const ice = entry.pc.iceConnectionState;
+ people.set(id, {
+ epoch: gen.get(id) || 0,
+ hearsMe: hearsYou(media, state.peer),
+ reported: !!media,
+ connected: entry.pc.connectionState === 'connected' || ice === 'connected' || ice === 'completed',
+ acked: !!entry.acked,
+ });
+ }
+ const was = Array.from(connecting.peers).filter(([, r]) => !r.done).map(([id]) => id).sort().join();
+ const wasShown = Array.from(connecting.peers).filter(([, r]) => r.shown).map(([id]) => id).sort().join();
+ const cleared = connectingStep(connecting, now, people);
+ for (const id of cleared) {
+ console.info('calls: ' + id.slice(0, 6) + ' can hear us');
+ announceMedia(canHearWords(names.get(id) || 'Someone'));
+ }
+ const is = Array.from(connecting.peers).filter(([, r]) => !r.done).map(([id]) => id).sort().join();
+ const isShown = Array.from(connecting.peers).filter(([, r]) => r.shown).map(([id]) => id).sort().join();
+ return is !== was || isShown !== wasShown;
+ }
+
// watchReconnect feeds the watch what this tab can see, five times a
// second, and redraws what changed.
function watchReconnect() {
if (state.stopped) return;
const now = Date.now();
+ const joiningMoved = watchConnecting(now);
const people = new Map();
for (const [id, entry] of pcs) {
+ // Someone in their first moments has not lost anything yet: §15.24
+ // speaks for them until it gives up.
+ if (connectingNow(id)) {
+ people.set(id, { connectionBad: false, audioMissing: false });
+ continue;
+ }
const ice = entry.pc.iceConnectionState;
const rec = hearing.get(id);
const media = remoteMedia.get(id);
@@ -4744,7 +4948,7 @@
console.info('calls: reconnecting ' + (reconnect.call ? 'shown (a problem that lasted)' : 'cleared'));
renderStatus();
}
- if (Array.from(reconnect.peers).sort().join() !== wasPeers) {
+ if (Array.from(reconnect.peers).sort().join() !== wasPeers || joiningMoved) {
for (const id of pcs.keys()) drawHearing(id);
}
}
@@ -5226,7 +5430,7 @@
const notice = document.createElement('span');
notice.className = 'calls-tile-notice';
notice.hidden = true;
- return { box, text, why, notice, kind: '', glow: '0.00', on: '0' };
+ return { box, text, why, notice, noticeText: '', noticeKind: '', kind: '', glow: '0.00', on: '0' };
}
// drawHearing brings one tile's hearing parts up to date. Called from
@@ -5277,11 +5481,17 @@
h.box.hidden = !kind;
}
if (self) return;
- const notice = reconnect.peers.has(key)
- ? 'Reconnecting to ' + name + '\u2026'
- : remoteNotice(name, media, state.peer, localMedia().muted);
- if (h.notice.textContent !== notice) {
+ const joining = connecting.peers.get(key);
+ const said = peerNotice(name, media, state.peer, localMedia().muted, !!joining && !joining.done,
+ !!joining && joining.shown, reconnect.peers.has(key));
+ const notice = said.text;
+ if (h.noticeText !== notice || h.noticeKind !== said.kind) {
+ h.noticeText = notice;
+ h.noticeKind = said.kind;
h.notice.textContent = notice;
+ if (said.kind === 'connecting') h.notice.append(ellipsisEl());
+ if (said.kind) h.notice.dataset.kind = said.kind;
+ else delete h.notice.dataset.kind;
h.notice.hidden = !notice;
}
if (rec && rec.notice !== notice) {
@@ -5290,6 +5500,21 @@
}
}
+ // ellipsisEl is three dots that pulse in turn (calls.css; one steady
+ // ellipsis under reduced motion), hidden from screen readers: the
+ // sentence is said once, the dots never.
+ function ellipsisEl() {
+ const dots = document.createElement('span');
+ dots.className = 'calls-ellipsis';
+ dots.setAttribute('aria-hidden', 'true');
+ for (let i = 0; i < 3; i++) {
+ const dot = document.createElement('span');
+ dot.textContent = '.';
+ dots.append(dot);
+ }
+ return dots;
+ }
+
// audioPart finds a connection's microphone sender or receiver — the
// camera m-line's audio, which is the only audio a connection carries
// (§5.4's screen has none).
@@ -5313,6 +5538,22 @@
return found;
}
+ // audioAcked reads our microphone's sender: packets have left, and the
+ // far side's receiver reports (remote-inbound-rtp, which exists only
+ // once one has arrived) say they are reaching it.
+ async function audioAcked(sender) {
+ const stats = await sender.getStats();
+ let sent = 0;
+ let acked = false;
+ stats.forEach((r) => {
+ if (r.kind !== 'audio' && r.mediaType !== 'audio') return;
+ if (r.type === 'outbound-rtp') sent = Math.max(sent, r.packetsSent || 0);
+ if (r.type === 'remote-inbound-rtp' && ((r.packetsReceived || 0) > 0 ||
+ (r.roundTripTimeMeasurements || 0) > 0 || typeof r.roundTripTime === 'number')) acked = true;
+ });
+ return sent > 0 && acked;
+ }
+
// speakerRecord is who has the floor, for Speaker view's stage.
function speakerRecord() {
return { current: '', since: -Infinity, candidate: '', candidateSince: 0 };
@@ -5391,6 +5632,31 @@
} catch {
// A closing connection: nothing heard.
}
+ // §15.24: whether the path from them is established, for `hearing`.
+ const pcState = entry.pc.connectionState;
+ const iceState = entry.pc.iceConnectionState;
+ const remote = remoteMedia.get(id) || null;
+ entry.audioLive = !!(receiver && receiver.track && receiver.track.readyState === 'live');
+ entry.heard = judgeHeard(!!entry.heard, {
+ connected: pcState === 'connected' || iceState === 'connected' || iceState === 'completed',
+ bad: pcState === 'failed' || pcState === 'closed',
+ hasReceiver: !!receiver,
+ receiverLive: entry.audioLive,
+ packets: report && typeof report.packetsReceived === 'number' ? report.packetsReceived : 0,
+ samples: report && typeof report.totalSamplesReceived === 'number' ? report.totalSamplesReceived : 0,
+ senderMuted: !!(remote && remote.muted),
+ });
+ // And for someone who does not say who they hear: whether their
+ // receiver reports show our audio reaching them. Asked only while
+ // it could still matter.
+ if (!entry.acked && connectingNow(id) && hearsYou(remote, state.peer) === null) {
+ const sender = audioPart(entry.pc, 'getSenders');
+ try {
+ entry.acked = sender ? await audioAcked(sender) : false;
+ } catch {
+ entry.acked = false;
+ }
+ }
if (!report) {
rec.level = 0;
rec.speaking = false;
@@ -5439,7 +5705,10 @@
else delete tilesEl.dataset.speaker;
}
const key = noSoundList().join(',');
- if (key !== noSoundKey) {
+ const heardKey = hearingList().join(',');
+ const heardMoved = hearingMoved(hearingKey, heardKey, new Set(pcs.keys()));
+ hearingKey = heardKey;
+ if (key !== noSoundKey || heardMoved) {
noSoundKey = key;
reportMedia();
}
@@ -6755,7 +7024,9 @@
speaking: t.label.dataset.glow === 'on',
glow: Number(t.hear.glow) || 0,
text: t.hear.box.hidden ? '' : t.hear.text.textContent,
- notice: t.hear.notice.hidden ? '' : t.hear.notice.textContent,
+ // The words alone, without the connecting notice's dots.
+ notice: t.hear.notice.hidden ? '' : t.hear.noticeText,
+ noticeKind: t.hear.notice.hidden ? '' : t.hear.noticeKind,
};
}
return out;
@@ -6978,6 +7249,12 @@
audioBlocked,
soundBanner: !!(soundBanner && !soundBanner.box.hidden),
noSoundFrom: noSoundList(),
+ // §15.24: who this tab reports it hears, and, per peer, whether
+ // they are still in their first moments (`joining`) and whether
+ // "… can't hear you yet" is showing for them.
+ hearingList: hearingList(),
+ connecting: Object.fromEntries(Array.from(connecting.peers,
+ ([id, r]) => [id, { joining: !r.done, shown: r.shown }])),
// Speaker view: who has the floor (speakerStep), by peer id.
speaker: stageSpeaker.current,
mediaExtras,
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go
index e3ca30c..e8356ac 100644
--- a/internal/callstest/browser_test.go
+++ b/internal/callstest/browser_test.go
@@ -122,6 +122,7 @@ var scenarios = []string{
"nowebrtc",
"playfallback",
"autoplay",
+ "connecting",
"scriptless",
}
diff --git a/internal/callstest/media_test.go b/internal/callstest/media_test.go
index f62dc18..229607f 100644
--- a/internal/callstest/media_test.go
+++ b/internal/callstest/media_test.go
@@ -121,6 +121,18 @@ func TestMediaShapes(t *testing.T) {
{"screenStalled sixteen ids", `{"muted":true,"cameraOff":false,"screenStalled":[` + sixteenIDs() + `]}`},
{"screenStalled twice", `{"muted":true,"cameraOff":false,"screenStalled":[],"screenStalled":[]}`},
{"screenStalled snake case", `{"muted":true,"cameraOff":false,"screen_stalled":[]}`},
+ // §15.24's hearing, held exactly as noSoundFrom is.
+ {"hearing null", `{"muted":true,"cameraOff":false,"hearing":null}`},
+ {"hearing a bool", `{"muted":true,"cameraOff":false,"hearing":true}`},
+ {"hearing an object", `{"muted":true,"cameraOff":false,"hearing":{"` + strings.Repeat("a", 32) + `":true}}`},
+ {"hearing a short id", `{"muted":true,"cameraOff":false,"hearing":["abc"]}`},
+ {"hearing an upper-case id", `{"muted":true,"cameraOff":false,"hearing":["` + strings.Repeat("A", 32) + `"]}`},
+ {"hearing a number", `{"muted":true,"cameraOff":false,"hearing":[1]}`},
+ {"hearing a repeat", `{"muted":true,"cameraOff":false,"hearing":["` + strings.Repeat("a", 32) + `","` + strings.Repeat("a", 32) + `"]}`},
+ {"hearing sixteen ids", `{"muted":true,"cameraOff":false,"hearing":[` + sixteenIDs() + `]}`},
+ {"hearing twice", `{"muted":true,"cameraOff":false,"hearing":[],"hearing":[]}`},
+ {"hearing capitalised", `{"muted":true,"cameraOff":false,"Hearing":[]}`},
+ {"hearing alone", `{"hearing":[]}`},
}
for _, c := range bad {
t.Run(c.name, func(t *testing.T) {
@@ -150,7 +162,7 @@ func TestMediaShapes(t *testing.T) {
}
})
t.Run("padded past the cap", func(t *testing.T) {
- payload := `{"muted":true,` + strings.Repeat(" ", 2048) + `"cameraOff":false}`
+ payload := `{"muted":true,` + strings.Repeat(" ", 3072) + `"cameraOff":false}`
res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","payload":`+payload+`}`)
if res.StatusCode != http.StatusRequestEntityTooLarge || !strings.Contains(string(rb), `"error":"too-large"`) {
t.Fatalf("%d %s, want 413 too-large", res.StatusCode, rb)
@@ -250,6 +262,58 @@ func TestMediaCarriesStalledScreens(t *testing.T) {
}
}
+// Spec §15.24: who this client can actually hear. Good news, unlike the
+// lists before it, and its presence is news too: an empty list ("nobody
+// yet") travels as [] and is a different report from one without the
+// key, which is a client that does not say. The longest honest report —
+// all three lists full — fits under the cap.
+func TestMediaCarriesWhoTheReceiverHears(t *testing.T) {
+ a, sqid, pa, sb := chatPair(t)
+ res, rb := a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"hearing":[]}}`)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("empty hearing: %d %s", res.StatusCode, rb)
+ }
+ _, data, ok := sb.nextOf("media", 2*time.Second)
+ if !ok || !strings.Contains(string(data), `"payload":{"muted":false,"cameraOff":false,"hearing":[]}`) {
+ t.Fatalf("empty hearing must travel as []: %s", data)
+ }
+ heard := strings.Repeat("0", 31) + "3"
+ res, rb = a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","payload":{"hearing":["`+heard+`"],"muted":true,"cameraOff":false,"noSoundFrom":["`+heard+`"]}}`)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("hearing: %d %s", res.StatusCode, rb)
+ }
+ _, data, ok = sb.nextOf("media", 2*time.Second)
+ want := `"payload":{"muted":true,"cameraOff":false,"noSoundFrom":["` + heard + `"],"hearing":["` + heard + `"]}`
+ if !ok || !strings.Contains(string(data), want) {
+ t.Fatalf("envelope %s, want %s", data, want)
+ }
+ ids := strings.Join(strings.Split(sixteenIDs(), ",")[:15], ",")
+ full := `{"muted":false,"cameraOff":false,"audioBlocked":true,"noSoundFrom":[` + ids + `],"screenStalled":[` + ids + `],"hearing":[` + ids + `]}`
+ res, rb = a.signal(sqid, pa, `{"type":"media","payload":`+full+`}`)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("all three lists full: %d %s", res.StatusCode, rb)
+ }
+ _, data, ok = sb.nextOf("media", 2*time.Second)
+ if !ok || !strings.Contains(string(data), `"payload":`+full) {
+ t.Fatalf("full report %s", data)
+ }
+ // Without the key: a different report from the empty list, so it is
+ // a change and goes out as the two-key object.
+ res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"hearing":[]}}`)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("back to empty: %d", res.StatusCode)
+ }
+ sb.nextOf("media", 2*time.Second)
+ res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false}}`)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("no key: %d", res.StatusCode)
+ }
+ _, data, ok = sb.nextOf("media", 2*time.Second)
+ if !ok || !strings.Contains(string(data), `"payload":{"muted":false,"cameraOff":false}`) {
+ t.Fatalf("absent hearing must be a change from [], got %s", data)
+ }
+}
+
// A waiting guest has nobody to tell: its report is the usual 404, and
// it hears nothing of the room's.
func TestMediaFromAWaitingGuestIsTheUsual404(t *testing.T) {
diff --git a/native/README.md b/native/README.md
index bfefb36..2dc0fb9 100644
--- a/native/README.md
+++ b/native/README.md
@@ -135,6 +135,15 @@ because nothing on it measured sound. Now:
isn't getting any sound from you". Each line is in the tile's accessibility
label and announced when it appears. The app itself is never blocked from
playing sound, so it never reports `audioBlocked`.
+- **Who can hear you yet** (spec §15.24): the app reports `hearing`, the people
+ whose audio path is established here (connected, packets in and decoded
+ frames at the tap, or connected and muted), read five times a second until
+ it is. From the moment someone joins, their tile says "Samuel is connecting
+ and can't hear you yet" — past a one-second grace, with dots that take turns
+ (steady under Reduce Motion) — until their report lists you, their own
+ blocked or no-sound report replacing it, and never past twenty seconds
+ (`ConnectingWatch.swift`). The mini window and its tab show a small ear for
+ anyone it is not showing.
The rules (`AudioLevelRules.swift`) are held by `AudioLevelTests` on an
injected clock; `TestNativeMacCallsBrowser` checks that the browser measures
diff --git a/native/apple/Sources/CallsInterop/CallSnapshots.swift b/native/apple/Sources/CallsInterop/CallSnapshots.swift
index adfe63b..192da92 100644
--- a/native/apple/Sources/CallsInterop/CallSnapshots.swift
+++ b/native/apple/Sources/CallsInterop/CallSnapshots.swift
@@ -60,6 +60,52 @@ import SwiftUI
}
}
+ /// Spec §15.24: two people who have just joined — Samuel still
+ /// connecting and unable to hear you yet, Bea's browser blocking sound in
+ /// place of that — in Grid, Speaker and Classic, light and dark, over a
+ /// busy picture, and the mini window and its tab with the small ear for
+ /// someone it is not showing.
+ static func saveConnecting(to folder: URL) async throws {
+ NSApplication.shared.setActivationPolicy(.prohibited)
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ let size = CGSize(width: 1000, height: 640)
+ for scheme in [ColorScheme.light, .dark] {
+ let suffix = LobbySnapshots.name(scheme)
+ func shot(
+ _ name: String, grid: Bool = false, style: CallLayoutStyle = .fullWindow,
+ backdrop: FixtureBackdrop? = nil
+ ) async throws {
+ let call = Call(playAudio: false)
+ CallFixture.connecting.apply(to: call)
+ try await LobbySnapshots.render(
+ MacCallView(
+ call: call, grid: grid, showingChat: false, showingDevices: false, style: style,
+ controlsShown: true, leave: {}, rejoin: {}
+ ).tint(CallsAppearance.accent).environment(\.fixtureBackdrop, backdrop),
+ scheme: scheme, size: size,
+ to: folder.appendingPathComponent("mac-connecting-\(name)-\(suffix).png"))
+ }
+ try await shot("grid", grid: true)
+ try await shot("speaker")
+ try await shot("classic-grid", grid: true, style: .classic)
+ try await shot("busy-grid", grid: true, backdrop: .busy)
+ for hovering in [false, true] {
+ let call = Call(playAudio: false)
+ CallFixture.connecting.apply(to: call)
+ let width = MiniWindowRules.size.width
+ try await LobbySnapshots.render(
+ MiniWindowSnapshot.window(call: call, hovering: hovering), scheme: scheme,
+ size: CGSize(width: width, height: width / MiniWindowRules.aspect),
+ to: folder.appendingPathComponent("mac-connecting-mini\(hovering ? "-hover" : "")-\(suffix).png"))
+ }
+ let call = Call(playAudio: false)
+ CallFixture.connecting.apply(to: call)
+ try await LobbySnapshots.render(
+ MiniWindowSnapshot.tab(call: call), scheme: scheme, size: MiniWindowTab.size,
+ to: folder.appendingPathComponent("mac-connecting-tab-\(suffix).png"))
+ }
+ }
+
/// The glass and the glow as the window server draws them (0.3.7). An
/// off-screen bitmap has no Liquid Glass and no backdrop to blur, so
/// these are real windows, parked below the desktop where nobody sees
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index 21ef045..af6e902 100644
--- a/native/apple/Sources/CallsInterop/Interop.swift
+++ b/native/apple/Sources/CallsInterop/Interop.swift
@@ -34,6 +34,12 @@ struct Input: Decodable {
} catch { exit(1) }
return
}
+ if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--connecting-snapshots" {
+ do {
+ try await CallSnapshots.saveConnecting(to: URL(fileURLWithPath: CommandLine.arguments[2]))
+ } catch { exit(1) }
+ return
+ }
if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--glass-snapshots" {
do {
try await CallSnapshots.saveGlass(to: URL(fileURLWithPath: CommandLine.arguments[2]))
diff --git a/native/apple/Sources/CallsKit/AudioMeter.swift b/native/apple/Sources/CallsKit/AudioMeter.swift
index ccf985d..a8312ba 100644
--- a/native/apple/Sources/CallsKit/AudioMeter.swift
+++ b/native/apple/Sources/CallsKit/AudioMeter.swift
@@ -46,15 +46,19 @@ import SwiftUI
@Published public private(set) var speaking = false
/// Their report about you: blocked, or not hearing you.
@Published public private(set) var notices: [String] = []
+ /// "<name> is connecting and can't hear you yet" (spec §15.24), drawn
+ /// with a moving ellipsis; never beside `notices`.
+ @Published public private(set) var connecting: String?
/// The glow behind their name.
public let glow = GlowLevel()
public init() {}
- func show(state: PeerAudioState, speaking: Bool, notices: [String]) {
+ func show(state: PeerAudioState, speaking: Bool, notices: [String], connecting: String? = nil) {
if self.state != state { self.state = state }
if self.speaking != speaking { self.speaking = speaking }
if self.notices != notices { self.notices = notices }
+ if self.connecting != connecting { self.connecting = connecting }
}
}
@@ -124,7 +128,8 @@ import SwiftUI
/// Returns the state the rule arrived at.
@discardableResult
func updatePeer(
- _ id: String, name: String, sample: PeerAudioSample, notices: [String], now: Double
+ _ id: String, name: String, sample: PeerAudioSample, notices: [String], connecting: String? = nil,
+ now: Double
) -> PeerAudioState {
var watch = watches[id] ?? PeerAudioWatch()
let before = watch.state
@@ -132,8 +137,9 @@ import SwiftUI
watches[id] = watch
lastLevel[id] = sample.level
let model = peer(id)
- let fresh = notices.filter { !model.notices.contains($0) }
- model.show(state: state, speaking: watch.speaking, notices: notices)
+ var fresh = notices.filter { !model.notices.contains($0) }
+ if let connecting, model.connecting != connecting { fresh.append(connecting) }
+ model.show(state: state, speaking: watch.speaking, notices: notices, connecting: connecting)
model.glow.update(level: sample.level, speaking: watch.speaking, now: now)
if state == .noSound, before != .noSound { AudioAnnouncer.say(AudioWords.noSound(from: name)) }
for line in fresh { AudioAnnouncer.say(line) }
@@ -224,6 +230,9 @@ public struct TileAudioInfo: Equatable {
/// Lines above the name: their notices about you, or your microphone
/// warning.
public var notices: [String]
+ /// "<name> is connecting and can't hear you yet" (spec §15.24), with its
+ /// moving ellipsis; nil when not said.
+ public var connecting: String? = nil
/// The ⓘ's text, when a line has one.
public var detail: String?
@@ -232,6 +241,7 @@ public struct TileAudioInfo: Equatable {
var out: [String] = []
if case .noSound(let name) = status { out.append(AudioWords.noSound(from: name)) }
out.append(contentsOf: notices)
+ if let connecting { out.append(connecting) }
return out
}
@@ -251,7 +261,7 @@ public struct TileAudioInfo: Equatable {
case .meter: status = .live
}
return TileAudioInfo(
- status: status, speaking: audio.speaking, notices: audio.notices,
+ status: status, speaking: audio.speaking, notices: audio.notices, connecting: audio.connecting,
detail: audio.state == .noSound ? AudioWords.noSoundDetail : nil)
}
}
@@ -338,15 +348,55 @@ public struct TileAudioNotices: View {
let info: TileAudioInfo?
public init(_ info: TileAudioInfo?) { self.info = info }
public var body: some View {
- if let info, !info.notices.isEmpty {
+ if let info, !info.notices.isEmpty || info.connecting != nil {
VStack(alignment: .leading, spacing: 4) {
ForEach(info.notices, id: \.self) { line in
AudioLine(
text: line, systemImage: "exclamationmark.triangle.fill",
detail: line == AudioWords.micSilent ? info.detail : nil)
}
+ if let connecting = info.connecting {
+ AudioLine(text: connecting, systemImage: "ear", ellipsis: true)
+ .accessibilityIdentifier("connectingNotice")
+ }
+ }
+ }
+ }
+}
+
+/// "<name> is connecting and can't hear you yet" and three dots after it
+/// (spec §15.24) that brighten in turn — waiting, not wrong — or one steady
+/// ellipsis under Reduce Motion. One run of text, so a long name wraps the
+/// dots with the words. VoiceOver hears the sentence, never the dots.
+public struct ConnectingText: View {
+ let words: String
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ public init(_ words: String) { self.words = words }
+ public var body: some View {
+ Group {
+ if reduceMotion {
+ Text(words + "…")
+ } else {
+ TimelineView(.periodic(from: .now, by: 0.1)) { context in
+ let t = context.date.timeIntervalSinceReferenceDate
+ (0..<3).reduce(Text(words)) { line, dot in
+ line + Text(".").foregroundColor(.white.opacity(Self.opacity(at: t, dot: dot)))
+ }
+ }
}
}
+ .accessibilityLabel(words)
+ }
+
+ /// Each dot's brightness at `t`: dim, lit at its turn in a 1.4 s cycle,
+ /// a fifth of a second after the one before — the browser's
+ /// `calls-ellipsis` keyframes.
+ static func opacity(at t: Double, dot: Int) -> Double {
+ let cycle = 1.4
+ let phase = (t - Double(dot) * 0.2).truncatingRemainder(dividingBy: cycle)
+ let p = (phase < 0 ? phase + cycle : phase) / cycle
+ let lift = p <= 0.4 ? p / 0.4 : p <= 0.8 ? (0.8 - p) / 0.4 : 0
+ return 0.35 + 0.65 * lift
}
}
@@ -357,12 +407,17 @@ struct AudioLine: View {
let text: String
let systemImage: String
var detail: String?
+ /// The connecting notice's moving dots after the words (spec §15.24).
+ var ellipsis = false
@State private var showing = false
var body: some View {
HStack(spacing: 5) {
Image(systemName: systemImage)
- Text(text).lineLimit(2).multilineTextAlignment(.leading)
- .fixedSize(horizontal: false, vertical: true)
+ Group {
+ if ellipsis { ConnectingText(text) } else { Text(text) }
+ }
+ .lineLimit(2).multilineTextAlignment(.leading)
+ .fixedSize(horizontal: false, vertical: true)
if let detail {
Button {
showing.toggle()
diff --git a/native/apple/Sources/CallsKit/AudioWork.swift b/native/apple/Sources/CallsKit/AudioWork.swift
index ccf38ec..15e0a36 100644
--- a/native/apple/Sources/CallsKit/AudioWork.swift
+++ b/native/apple/Sources/CallsKit/AudioWork.swift
@@ -131,6 +131,12 @@ enum PeerCalls {
}
}
+ static func statistics(_ pc: RTCPeerConnection, for sender: RTCRtpSender) async -> RTCStatisticsReport {
+ await withCheckedContinuation { c in
+ AudioWork.queue.async { pc.statistics(for: sender) { c.resume(returning: $0) } }
+ }
+ }
+
/// Anything else, read or done on the audio queue.
static func read<T>(_ pc: RTCPeerConnection, _ body: @escaping (RTCPeerConnection) -> T) async -> T {
await AudioWork.run { body(pc) }
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index 56f4107..46b4418 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -87,6 +87,17 @@ private final class Events: NSObject, BridgeListenerProtocol {
private var reconnectWatch = ReconnectWatch()
@Published public private(set) var reconnecting = false
@Published public private(set) var reconnectingPeers: Set<String> = []
+ /// "<name> is connecting and can't hear you yet" (spec §15.24): the
+ /// watch, and the people it is said for now — on their tiles, and as a
+ /// dot in the mini window when they are not the one it shows.
+ private var connectingWatch = ConnectingWatch()
+ @Published public private(set) var connectingPeers: Set<String> = []
+ /// This app's own half: whose audio path is established (HearingRule),
+ /// per connection generation; whose receiver reports have shown this
+ /// app's audio reaching them; and the list last reported as `hearing`.
+ private var heardFrom: [String: (epoch: Int64, heard: Bool)] = [:]
+ private var ackedBy: [String: Int64] = [:]
+ private var reportedHearing: [String] = []
/// The phase the notices are drawn for: the core's, except that its
/// `reconnecting` is only said once ReconnectWatch says so, and is said
/// for a second after it is over.
@@ -519,6 +530,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
private func watchLevels() {
levelWatch?.cancel()
levels.reset()
+ resetConnecting()
reportedNoSound = []
audioBytes = [:]
audioArriving = [:]
@@ -544,11 +556,21 @@ private final class Events: NSObject, BridgeListenerProtocol {
screenReceived = [:]
screenReceivedMoving = [:]
levels.reset()
+ resetConnecting()
reportedNoSound = []
audioBytes = [:]
audioArriving = [:]
}
+ private func resetConnecting() {
+ connectingWatch = ConnectingWatch()
+ if !connectingPeers.isEmpty { connectingPeers = [] }
+ heardFrom = [:]
+ ackedBy = [:]
+ // The core starts every report with `hearing: []` (spec §15.24).
+ reportedHearing = []
+ }
+
private func sampleMic(now: Double) {
let denoiser = media.audio.denoiser
let sent = denoiser.level.take()
@@ -575,30 +597,79 @@ private final class Events: NSObject, BridgeListenerProtocol {
}
private func samplePeers(stats: Bool, now: Double) async {
- let readings = await media.peerAudio(stats: stats)
+ // §15.24: the people whose audio path is not established yet are read
+ // closely, five times a second, until it is; the people in their first
+ // moments who do not say who they hear are asked about this app's audio.
+ let mine = state?.peer ?? ""
+ let joining = connectingWatch.joining
+ var detail = Set<String>()
+ var acks = Set<String>()
+ for person in state?.peers ?? [] where person.peer != mine {
+ let epoch = person.gen ?? 0
+ if heardFrom[person.peer].map({ $0.epoch != epoch || !$0.heard }) ?? true { detail.insert(person.peer) }
+ if joining.contains(person.peer), HearingRule.hearsYou(person.media, me: mine) == nil,
+ ackedBy[person.peer] != epoch
+ {
+ acks.insert(person.peer)
+ }
+ }
+ let readings = await media.peerAudio(stats: stats, detail: detail, acks: acks)
guard levelWatch != nil, let state else { return }
let me = state.peer
var present = Set<String>()
var people: [String: ReconnectWatch.Peer] = [:]
+ var arrivals: [String: ConnectingWatch.Peer] = [:]
for person in state.peers where person.peer != me {
guard let reading = readings[person.peer] else { continue }
present.insert(person.peer)
- if let bytes = reading.bytes {
+ if stats, let bytes = reading.bytes {
audioArriving[person.peer] = bytes > (audioBytes[person.peer] ?? 0)
audioBytes[person.peer] = bytes
}
- people[person.peer] = ReconnectWatch.Peer(
- connectionBad: reading.connectionBad,
- audioMissing: audioArriving[person.peer] == false && person.media?.muted != true)
- var notices = AudioWords.notices(name: person.name, media: person.media, me: me, muted: muted)
- if reconnectingPeers.contains(person.peer) { notices.insert(ReconnectWords.peer(person.name), at: 0) }
+ let epoch = person.gen ?? 0
+ var was = heardFrom[person.peer] ?? (epoch, false)
+ if was.epoch != epoch { was = (epoch, false) }
+ let heard = HearingRule.heard(
+ was: was.heard,
+ .init(
+ connected: reading.connected, bad: reading.gone, hasReceiver: reading.hasReceiver,
+ receiverLive: reading.receiverLive, packets: reading.packets ?? 0, decoded: reading.buffers,
+ senderMuted: person.media?.muted == true))
+ heardFrom[person.peer] = (epoch, heard)
+ if reading.acked == true { ackedBy[person.peer] = epoch }
+ arrivals[person.peer] = ConnectingWatch.Peer(
+ epoch: epoch, hearsMe: HearingRule.hearsYou(person.media, me: me), reported: person.media != nil,
+ connected: reading.connected, acked: ackedBy[person.peer] == epoch)
+ }
+ for id in Array(heardFrom.keys) where !present.contains(id) { heardFrom.removeValue(forKey: id) }
+ for id in Array(ackedBy.keys) where !present.contains(id) { ackedBy.removeValue(forKey: id) }
+ for id in connectingWatch.observe(at: now, people: arrivals) {
+ CallLog.info("\(id.prefix(6)) can hear us")
+ AudioAnnouncer.say(ConnectingWords.canHear(name(of: id)))
+ }
+ let nowJoining = connectingWatch.joining
+ if connectingPeers != connectingWatch.shown { connectingPeers = connectingWatch.shown }
+ for person in state.peers where person.peer != me {
+ guard let reading = readings[person.peer] else { continue }
+ // Someone in their first moments has not lost anything yet: §15.24
+ // speaks for them until it gives up.
+ people[person.peer] =
+ nowJoining.contains(person.peer)
+ ? ReconnectWatch.Peer()
+ : ReconnectWatch.Peer(
+ connectionBad: reading.connectionBad,
+ audioMissing: audioArriving[person.peer] == false && person.media?.muted != true)
+ let said = ConnectingWatch.tile(
+ name: person.name, media: person.media, me: me, muted: muted,
+ joining: nowJoining.contains(person.peer), shown: connectingPeers.contains(person.peer),
+ reconnecting: reconnectingPeers.contains(person.peer))
remotePeakSinceSample = max(remotePeakSinceSample, reading.peak)
levels.updatePeer(
person.peer, name: person.name,
sample: PeerAudioSample(
reported: person.media, connected: reading.connected,
arriving: audioArriving[person.peer] ?? false, level: reading.peak),
- notices: notices,
+ notices: said.notices, connecting: said.connecting,
now: now)
}
watchReconnect(now: now, people: people)
@@ -608,6 +679,18 @@ private final class Events: NSObject, BridgeListenerProtocol {
audioArriving.removeValue(forKey: id)
}
reportNoSound(levels.noSoundFrom)
+ reportHearing(heardFrom.filter { $0.value.heard }.keys.sorted(), present: present)
+ }
+
+ /// §15.24: whose audio this app is receiving and playing. Sent only on a
+ /// change, in order, off the main thread, as `noSoundFrom` is.
+ private func reportHearing(_ peers: [String], present: Set<String>) {
+ guard peers != reportedHearing, let client = bridge else { return }
+ let moved = HearingRule.moved(from: reportedHearing, to: peers, present: present)
+ reportedHearing = peers
+ guard moved else { return }
+ let body = json(peers) ?? "[]"
+ noSoundQueue.async { try? client.setHearing(body) }
}
/// One look by ReconnectWatch (spec §15.23), and the words it says.
@@ -1052,6 +1135,11 @@ private final class Events: NSObject, BridgeListenerProtocol {
values["remoteScreens"] = UInt64(remoteScreens.count)
values["screens"] = UInt64(screens.count)
values["invalidEvents"] = invalidEvents
+ // §15.24: people still in their first moments, people whose tiles say
+ // they cannot hear you yet, and how many this app reports it hears.
+ values["connectingJoining"] = UInt64(connectingWatch.joining.count)
+ values["connectingShown"] = UInt64(connectingPeers.count)
+ values["hearing"] = UInt64(reportedHearing.count)
// This app's own screen, as it leaves: the slowest connection's.
if let screen = screenReport {
values["screenSendWidth"] = UInt64(screen.width)
@@ -1156,6 +1244,24 @@ extension Call {
self.sharingScreen = sharingScreen
}
+ /// Spec §15.24 for the snapshots: these people have just joined and the
+ /// grace has passed, so each tile says what it would — "connecting", or
+ /// a report of theirs that replaces it.
+ @_spi(Fixtures) public func showFixtureConnecting(_ peers: [String]) {
+ let me = state?.peer ?? ""
+ var shown: Set<String> = []
+ for person in state?.peers ?? [] where peers.contains(person.peer) {
+ let said = ConnectingWatch.tile(
+ name: person.name, media: person.media, me: me, muted: muted, joining: true, shown: true,
+ reconnecting: false)
+ if said.connecting != nil { shown.insert(person.peer) }
+ levels.peer(person.peer).show(
+ state: person.media?.muted == true ? .muted : .meter(0), speaking: false, notices: said.notices,
+ connecting: said.connecting)
+ }
+ connectingPeers = shown
+ }
+
/// Everyone's glow, and yours, held at one level, for the snapshots.
@_spi(Fixtures) public func showFixtureSpeaking(level: Float) {
levels.mic.glow.showFixture(level: level)
diff --git a/native/apple/Sources/CallsKit/CallFixtures.swift b/native/apple/Sources/CallsKit/CallFixtures.swift
index 071b94b..f09378d 100644
--- a/native/apple/Sources/CallsKit/CallFixtures.swift
+++ b/native/apple/Sources/CallsKit/CallFixtures.swift
@@ -16,6 +16,9 @@ import SwiftUI
case screen
/// Somebody waiting to be let in, and the connection coming back.
case notices
+ /// Two people who have just joined (spec §15.24): one still connecting
+ /// and unable to hear you yet, the other's browser blocking sound.
+ case connecting
public static let title = "Call, 24 Sep 10:21"
@@ -41,6 +44,11 @@ import SwiftUI
call.showFixture(
title: Self.title, status: "Reconnecting…",
state: roster("reconnecting", [me, ana], waiting: [knock]), muted: true, floor: true)
+ case .connecting:
+ let sam = #"{"peer":"sam","name":"Samuel Okafor","kind":"member","media":{"muted":false,"cameraOff":false,"hearing":[]}}"#
+ let bea = #"{"peer":"bea","name":"Bea Ruiz","kind":"guest","media":{"muted":false,"cameraOff":true,"audioBlocked":true,"hearing":[]}}"#
+ call.showFixture(title: Self.title, state: roster("admitted", [me, ana, sam, bea]))
+ call.showFixtureConnecting(["sam", "bea"])
}
}
}
diff --git a/native/apple/Sources/CallsKit/ConnectingWatch.swift b/native/apple/Sources/CallsKit/ConnectingWatch.swift
new file mode 100644
index 0000000..798cafc
--- /dev/null
+++ b/native/apple/Sources/CallsKit/ConnectingWatch.swift
@@ -0,0 +1,172 @@
+import Foundation
+
+/// "<name> is connecting and can't hear you yet" (spec §15.24, 2026-10-01).
+///
+/// Paul asked for Zoom's "… is connecting to audio and cannot hear you yet":
+/// the gap after someone joins, before their side is receiving and playing
+/// your audio, said rather than talked into. Their own report's `hearing`
+/// is the authority; a client that does not send one is judged on what this
+/// app can see of its own audio reaching them.
+///
+/// For each person from the moment they appear in the roster (or appear
+/// again, or are given a new connection):
+///
+/// - the words are said only once `grace` (1 s) has passed without their
+/// hearing you, so an ordinary join says nothing;
+/// - they go the moment their report lists you in `hearing` — "Samuel can
+/// hear you now";
+/// - for a client that does not say who it hears, once the connection is up
+/// and either their receiver reports show your audio reaching them or
+/// they have made a report without the list;
+/// - and never past `cap` (20 s), after which §15.20's and §15.23's words
+/// speak for any problem.
+///
+/// Your own mute does not matter: it is about their hearing you when you
+/// speak. Pure: fed the facts as they are, with the time. The browser's
+/// `connectingStep` is its twin.
+struct ConnectingWatch: Equatable {
+ static let grace = 1.0
+ static let cap = 20.0
+
+ /// One person as they are now.
+ struct Peer: Equatable {
+ /// The pair generation: a new one is a new connection, and they cannot
+ /// hear you on it yet.
+ var epoch: Int64 = 0
+ /// Their report on you (`HearingRule.hearsYou`): listed, not listed,
+ /// or nil for no list at all.
+ var hearsMe: Bool?
+ /// They have made a media report.
+ var reported = false
+ /// The connection to them is up.
+ var connected = false
+ /// Their receiver reports say your audio reaches them.
+ var acked = false
+ }
+
+ private struct Record: Equatable {
+ var epoch: Int64
+ var since: Double
+ var done = false
+ var shown = false
+ }
+ private var records: [String: Record] = [:]
+
+ init() {}
+
+ /// People still in their first moments, whether or not the words show yet.
+ var joining: Set<String> { Set(records.filter { !$0.value.done }.keys) }
+ /// People whose tiles say it.
+ var shown: Set<String> { Set(records.filter { $0.value.shown }.keys) }
+
+ /// One look at everyone but you. Returns the people whose words have just
+ /// gone because they can hear you, for "… can hear you now".
+ @discardableResult
+ mutating func observe(at now: Double, people: [String: Peer]) -> [String] {
+ for id in Array(records.keys) where people[id] == nil { records.removeValue(forKey: id) }
+ var cleared: [String] = []
+ for (id, p) in people {
+ var r = records[id] ?? Record(epoch: p.epoch, since: now)
+ if r.epoch != p.epoch { r = Record(epoch: p.epoch, since: now) }
+ if !r.done {
+ let fallback = p.hearsMe == nil && p.connected && (p.acked || p.reported)
+ if p.hearsMe == true || fallback {
+ if r.shown { cleared.append(id) }
+ r.done = true
+ r.shown = false
+ } else if now - r.since >= Self.cap {
+ r.done = true
+ r.shown = false
+ } else {
+ r.shown = now - r.since >= Self.grace
+ }
+ }
+ records[id] = r
+ }
+ return cleared.sorted()
+ }
+
+ /// What a person's tile says about them and you, in order (the browser's
+ /// `peerNotice`): in their first moments, their own more specific report
+ /// (their browser blocking sound, or no sound from you) and otherwise the
+ /// connecting words once the grace has passed — "Reconnecting" would be
+ /// wrong of a connection that has never been up; after that, §15.23's
+ /// "Reconnecting to …" first and then their reports. `connecting` is the
+ /// line that carries the moving ellipsis, and is never set beside
+ /// `notices`.
+ static func tile(
+ name: String, media: PeerMedia?, me: String, muted: Bool, joining: Bool, shown: Bool,
+ reconnecting: Bool
+ ) -> (notices: [String], connecting: String?) {
+ let reports = AudioWords.notices(name: name, media: media, me: me, muted: muted)
+ if joining {
+ if !reports.isEmpty { return (reports, nil) }
+ return ([], shown ? ConnectingWords.connecting(name) : nil)
+ }
+ return ((reconnecting ? [ReconnectWords.peer(name)] : []) + reports, nil)
+ }
+}
+
+/// This app's half of §15.24: whose audio it can say it hears.
+enum HearingRule {
+ /// One connection's audio as it is now.
+ struct Facts: Equatable {
+ /// ICE connected or completed.
+ var connected = false
+ /// ICE failed or closed: the path is gone.
+ var bad = false
+ var hasReceiver = false
+ /// The audio receiver's track is live.
+ var receiverLive = false
+ /// Audio packets received on it so far.
+ var packets: UInt64 = 0
+ /// Decoded audio frames that reached the receive tap since the last look.
+ var decoded = 0
+ /// They say they are muted (or have no microphone).
+ var senderMuted = false
+ }
+
+ /// Whether the audio path from someone is established. Once it is, it
+ /// stays so until the connection fails or closes. Established is: the
+ /// connection up, and either their audio arriving — packets received and
+ /// decoded frames coming out — on a live receiver, or, for someone who
+ /// says they are muted, nothing more than that: a muted sender may send
+ /// only silence, or no audio at all, and must not look unheard, because
+ /// the moment they unmute, it plays. The browser's `judgeHeard` is its
+ /// twin. A native app plays whatever it decodes: there is no autoplay
+ /// policy to refuse it.
+ static func heard(was: Bool, _ f: Facts) -> Bool {
+ if f.bad { return false }
+ if was { return true }
+ if !f.connected { return false }
+ if f.senderMuted, !f.hasReceiver || f.receiverLive { return true }
+ return f.receiverLive && f.packets > 0 && f.decoded > 0
+ }
+
+ /// Whether `hearing` has changed in a way worth a report: someone heard
+ /// who was not, or someone still here no longer heard. Someone who has
+ /// left is not news — every departure would otherwise cost a report from
+ /// everyone left — and the core carries their id, ignored, until the next
+ /// report for another reason. The browser's `hearingMoved` is its twin.
+ static func moved(from before: [String], to now: [String], present: Set<String>) -> Bool {
+ let was = Set(before)
+ let is_ = Set(now)
+ if !is_.subtracting(was).isEmpty { return true }
+ return !was.subtracting(is_).intersection(present).isEmpty
+ }
+
+ /// A person's report on you: true when their `hearing` lists you, false
+ /// when it has a list and you are not on it, nil when it has no list — no
+ /// report yet, or a client from before §15.24.
+ static func hearsYou(_ media: PeerMedia?, me: String) -> Bool? {
+ guard let list = media?.hearing else { return nil }
+ return !me.isEmpty && list.contains(me)
+ }
+}
+
+/// The words (spec §15.24). The ellipsis is drawn apart from them
+/// (`ConnectingText`), so VoiceOver hears the sentence and not the dots.
+public enum ConnectingWords {
+ public static func connecting(_ name: String) -> String { "\(name) is connecting and can’t hear you yet" }
+ public static func canHear(_ name: String) -> String { "\(name) can hear you now" }
+}
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift
index 92857a4..b96c140 100644
--- a/native/apple/Sources/CallsKit/Media.swift
+++ b/native/apple/Sources/CallsKit/Media.swift
@@ -386,32 +386,66 @@ import LiveKitWebRTC
/// Each remote person's sound, for the meters (spec §15.20): the level
/// their receive tap has seen since the last look, whether the
- /// connection is up, and — when `stats` is set — the audio bytes
- /// received so far, one `statistics(for:)` per audio receiver rather
- /// than the whole report. Nil bytes when not asked.
- func peerAudio(stats: Bool) async -> [String: PeerAudioReading] {
+ /// connection is up, and — when `stats` is set, or for the people in
+ /// `detail` — the audio bytes and packets received so far and whether
+ /// the receiver is live, one `statistics(for:)` per audio receiver rather
+ /// than the whole report. For the people in `acks`, whether their
+ /// receiver reports say this app's audio reaches them (spec §15.24's
+ /// fallback for a client that does not say who it hears).
+ func peerAudio(stats: Bool, detail: Set<String> = [], acks: Set<String> = []) async -> [String: PeerAudioReading] {
var out: [String: PeerAudioReading] = [:]
for (id, peer) in peers {
guard let pc = peer.pc else { continue }
let (peak, buffers) = peer.tap.take()
+ let deep = stats || detail.contains(id)
+ let ack = acks.contains(id)
// Read on the audio queue, as every call into the connection is
// (PeerCalls): five times a second, and each one a blocking hop to
// WebRTC's signalling thread.
- let (state, receivers) = await PeerCalls.read(pc) { pc in
- (pc.iceConnectionState, stats ? pc.receivers.filter { $0.track?.kind == "audio" } : [])
+ let (state, receivers, senders) = await PeerCalls.read(pc) { pc in
+ (
+ pc.iceConnectionState, deep ? pc.receivers.filter { $0.track?.kind == "audio" } : [],
+ ack ? pc.senders.filter { $0.track?.kind == "audio" } : []
+ )
}
var reading = PeerAudioReading(
peak: peak, buffers: buffers, connected: state == .connected || state == .completed,
connectionBad: state == .disconnected || state == .failed)
- if stats {
+ reading.gone = state == .failed || state == .closed
+ if deep {
var bytes: UInt64 = 0
+ var packets: UInt64 = 0
+ var live = false
for receiver in receivers {
+ if receiver.track?.readyState == .live { live = true }
let report = await PeerCalls.statistics(pc, for: receiver)
for stat in report.statistics.values where stat.type == "inbound-rtp" {
bytes += (stat.values["bytesReceived"] as? NSNumber)?.uint64Value ?? 0
+ packets += (stat.values["packetsReceived"] as? NSNumber)?.uint64Value ?? 0
}
}
reading.bytes = bytes
+ reading.packets = packets
+ reading.hasReceiver = !receivers.isEmpty
+ reading.receiverLive = live
+ }
+ if ack {
+ var sent: UInt64 = 0
+ var acked = false
+ for sender in senders {
+ let report = await PeerCalls.statistics(pc, for: sender)
+ for stat in report.statistics.values {
+ if stat.type == "outbound-rtp" {
+ sent += (stat.values["packetsSent"] as? NSNumber)?.uint64Value ?? 0
+ } else if stat.type == "remote-inbound-rtp" {
+ // Exists only once a receiver report has come back.
+ let received = (stat.values["packetsReceived"] as? NSNumber)?.uint64Value ?? 0
+ let measured = (stat.values["roundTripTimeMeasurements"] as? NSNumber)?.uint64Value ?? 0
+ if received > 0 || measured > 0 || stat.values["roundTripTime"] != nil { acked = true }
+ }
+ }
+ }
+ reading.acked = sent > 0 && acked
}
out[id] = reading
}
@@ -464,7 +498,17 @@ struct PeerAudioReading: Equatable, Sendable {
var connected: Bool
/// ICE `disconnected` or `failed` (spec §15.23's three seconds).
var connectionBad = false
+ /// ICE `failed` or `closed`: whatever was heard on it is gone (§15.24).
+ var gone = false
var bytes: UInt64?
+ /// Audio packets received, the receiver there at all, and its track
+ /// live (§15.24); read with `bytes`.
+ var packets: UInt64?
+ var hasReceiver = false
+ var receiverLive = false
+ /// Their receiver reports say this app's audio reaches them; nil when
+ /// not asked.
+ var acked: Bool?
}
@MainActor private final class MediaPeer: NSObject, RTCPeerConnectionDelegate {
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift
index 9b819b0..d4af785 100644
--- a/native/apple/Sources/CallsKit/MiniWindow.swift
+++ b/native/apple/Sources/CallsKit/MiniWindow.swift
@@ -422,6 +422,13 @@
return item
}
+ /// The people still connecting who are not the one on show.
+ private var offStage: [String] {
+ var shown: String?
+ if case .camera(let peer) = subject { shown = peer }
+ return call.connectingPeers.filter { $0 != shown }.sorted()
+ }
+
var body: some View {
ZStack {
Group {
@@ -455,6 +462,10 @@
.accessibilityLabel("Your microphone is muted")
}
Spacer()
+ // Spec §15.24, for the people this window is not showing: the
+ // one it shows says it on its own tile. Top right, clear of
+ // the tile's Muted badge.
+ ConnectingDot(names: offStage.map { call.name(of: $0) })
if showingControls {
Button("Collapse to a tab", glyph: "arrow.down.right.and.arrow.up.left") {
actions.collapse()
@@ -513,6 +524,17 @@
.background(
call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent,
in: Circle())
+ // Spec §15.24: someone is still connecting and cannot hear you
+ // yet. A dot only — the tab has no room for words; its
+ // accessibility label and the window, opened, say who.
+ .overlay(alignment: .topTrailing) {
+ if !call.connectingPeers.isEmpty {
+ Circle().fill(ConnectingDot.colour).frame(width: 8, height: 8)
+ .overlay(Circle().strokeBorder(Color.black.opacity(0.35), lineWidth: 1))
+ .offset(x: 2, y: -2)
+ .help(call.connectingPeers.sorted().map { ConnectingWords.connecting(call.name(of: $0)) }.joined(separator: "\n"))
+ }
+ }
Text(caption).font(.callout.weight(.medium)).lineLimit(1).truncationMode(.tail)
.foregroundStyle(.white)
Spacer(minLength: 0)
@@ -531,7 +553,9 @@
.clipShape(Capsule())
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
- .accessibilityLabel("Calls, \(caption)")
+ .accessibilityLabel(
+ (["Calls, \(caption)"] + call.connectingPeers.sorted().map { ConnectingWords.connecting(call.name(of: $0)) })
+ .joined(separator: ". "))
.accessibilityValue(call.muted ? "Microphone muted" : "Microphone on")
.accessibilityHint("Shows the mini window.")
.accessibilityAction { actions.expand() }
@@ -543,6 +567,33 @@
}
}
+ /// The mini window's whole say on spec §15.24: a small ear on the glass
+ /// while someone it is not showing is connecting and cannot hear you yet,
+ /// with their names in its tooltip and for VoiceOver. Nothing at all
+ /// otherwise.
+ struct ConnectingDot: View {
+ /// A warm amber, light enough to tell from the muted microphone's
+ /// brown on the tab.
+ static let colour = Color(red: 1, green: 0.78, blue: 0.42)
+ let names: [String]
+ var body: some View {
+ if !names.isEmpty {
+ let words = names.map(ConnectingWords.connecting).joined(separator: "\n")
+ Image(systemName: "ear")
+ .font(.callout.bold()).foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ .callOverlaySurface(Circle(), rim: false)
+ .overlay(alignment: .topTrailing) {
+ Circle().fill(Self.colour).frame(width: 7, height: 7)
+ }
+ .help(words)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(words)
+ .accessibilityIdentifier("connectingDot")
+ }
+ }
+ }
+
/// The window's background: a click, a drag that moves the window, and
/// — for the expanded window — a drag from an edge or corner that
/// resizes it. Handled here rather than with isMovableByWindowBackground
diff --git a/native/apple/Sources/CallsKit/Wire.swift b/native/apple/Sources/CallsKit/Wire.swift
index 2c20d31..d750c01 100644
--- a/native/apple/Sources/CallsKit/Wire.swift
+++ b/native/apple/Sources/CallsKit/Wire.swift
@@ -24,16 +24,21 @@ public struct PeerMedia: Decodable, Equatable, Sendable {
/// The peers whose shared screen has stopped moving for it (§15.21).
/// A sharer that finds itself here recovers its screen. Nil is none.
public let screenStalled: [String]?
+ /// The peers whose audio it is actually receiving and playing (§15.24).
+ /// Nil (absent) is a client that does not say; empty is one that hears
+ /// nobody yet.
+ public let hearing: [String]?
public init(
muted: Bool, cameraOff: Bool, audioBlocked: Bool? = nil, noSoundFrom: [String]? = nil,
- screenStalled: [String]? = nil
+ screenStalled: [String]? = nil, hearing: [String]? = nil
) {
self.muted = muted
self.cameraOff = cameraOff
self.audioBlocked = audioBlocked
self.noSoundFrom = noSoundFrom
self.screenStalled = screenStalled
+ self.hearing = hearing
}
}
public struct RoomState: Decodable {
diff --git a/native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift b/native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift
new file mode 100644
index 0000000..aa12d4d
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift
@@ -0,0 +1,240 @@
+import XCTest
+
+@testable import CallsKit
+
+/// "<name> is connecting and can't hear you yet" (spec §15.24). Paul,
+/// 2026-10-01, asking for Zoom's "… is connecting to audio and cannot hear
+/// you yet": the gap after someone joins, said rather than talked into.
+final class ConnectingWatchTests: XCTestCase {
+ private typealias Peer = ConnectingWatch.Peer
+ private let waiting = Peer(epoch: 1, hearsMe: false, reported: true, connected: false, acked: false)
+
+ /// Runs the watch at 5 Hz from `from` to `to` seconds; when Sam's words
+ /// showed, and when they cleared with "can hear you now". `sam` nil is
+ /// Sam not in the roster.
+ private func run(
+ _ watch: inout ConnectingWatch, from: Double, to: Double, sam: (Double) -> Peer?
+ ) -> (shown: [Double], cleared: [Double]) {
+ var shown: [Double] = []
+ var cleared: [Double] = []
+ var t = from
+ while t < to {
+ let people = sam(t).map { ["sam": $0] } ?? [:]
+ if watch.observe(at: t, people: people).contains("sam") { cleared.append(t) }
+ if watch.shown.contains("sam") { shown.append(t) }
+ t = (t * 10 + 2).rounded() / 10
+ }
+ return (shown, cleared)
+ }
+
+ func testTheTimingsAreTheOnesTheSpecNames() {
+ XCTAssertEqual(ConnectingWatch.grace, 1)
+ XCTAssertEqual(ConnectingWatch.cap, 20)
+ }
+
+ func testAnOrdinaryJoinHeardWithinTheGraceSaysNothing() {
+ var watch = ConnectingWatch()
+ var sam = waiting
+ let seen = run(&watch, from: 0, to: 10) { t in
+ sam.connected = t >= 0.4
+ sam.hearsMe = t >= 0.8
+ return sam
+ }
+ XCTAssertEqual(seen.shown, [])
+ XCTAssertEqual(seen.cleared, [], "nothing was said, so \"can hear you now\" is not said either")
+ XCTAssertFalse(watch.joining.contains("sam"))
+ }
+
+ func testASlowJoinIsSaidAfterTheGraceAndClearsWhenTheyHearYou() {
+ var watch = ConnectingWatch()
+ var sam = waiting
+ let seen = run(&watch, from: 0, to: 12) { t in
+ sam.connected = t >= 5
+ sam.hearsMe = t >= 6
+ return sam
+ }
+ XCTAssertEqual(seen.shown.first, 1)
+ XCTAssertEqual(seen.shown.last, 5.8)
+ XCTAssertEqual(seen.cleared, [6])
+ }
+
+ func testOnceClearedItStaysClearedWhateverTheirListDoes() {
+ var watch = ConnectingWatch()
+ var sam = waiting
+ sam.connected = true
+ let seen = run(&watch, from: 0, to: 30) { t in
+ sam.hearsMe = t >= 3 && t < 10
+ return sam
+ }
+ XCTAssertEqual(seen.cleared, [3])
+ XCTAssertEqual(seen.shown.last, 2.8)
+ }
+
+ func testItIsGivenUpAtTheCapSilently() {
+ var watch = ConnectingWatch()
+ let seen = run(&watch, from: 0, to: 40) { _ in self.waiting }
+ XCTAssertEqual(seen.shown.first, 1)
+ XCTAssertEqual(seen.shown.last, 19.8)
+ XCTAssertEqual(seen.cleared, [], "a cap is not their hearing you")
+ XCTAssertTrue(watch.joining.isEmpty)
+ }
+
+ func testAClientThatDoesNotSayIsLetGoOnItsFirstReportAndAConnection() {
+ var watch = ConnectingWatch()
+ let seen = run(&watch, from: 0, to: 10) { t in
+ Peer(epoch: 1, hearsMe: nil, reported: t >= 4, connected: t >= 2, acked: false)
+ }
+ XCTAssertEqual(seen.cleared, [4])
+ }
+
+ func testOrOnAConnectionAndReceiverReportsShowingYourAudioReachesThem() {
+ var watch = ConnectingWatch()
+ let seen = run(&watch, from: 0, to: 10) { t in
+ Peer(epoch: 1, hearsMe: nil, reported: false, connected: t >= 2, acked: t >= 3)
+ }
+ XCTAssertEqual(seen.cleared, [3])
+ var unconnected = ConnectingWatch()
+ let never = run(&unconnected, from: 0, to: 10) { _ in
+ Peer(epoch: 1, hearsMe: nil, reported: true, connected: false, acked: true)
+ }
+ XCTAssertEqual(never.cleared, [], "never without a connection")
+ }
+
+ func testAClientThatSaysWhoItHearsIsBelievedOverYourOwnEvidence() {
+ var watch = ConnectingWatch()
+ let seen = run(&watch, from: 0, to: 10) { _ in
+ Peer(epoch: 1, hearsMe: false, reported: true, connected: true, acked: true)
+ }
+ XCTAssertEqual(seen.cleared, [])
+ XCTAssertFalse(seen.shown.isEmpty)
+ }
+
+ func testARejoinStartsItAgainAndSoDoesANewConnection() {
+ var watch = ConnectingWatch()
+ var sam = waiting
+ let seen = run(&watch, from: 0, to: 20) { t in
+ if t >= 5 && t < 6 { return nil }
+ sam.connected = true
+ sam.hearsMe = t < 5 ? t >= 2 : t >= 9
+ return sam
+ }
+ XCTAssertEqual(seen.cleared, [2, 9])
+ XCTAssertTrue(seen.shown.contains(7))
+ XCTAssertFalse(seen.shown.contains(6.8), "the grace again from the moment they are back")
+
+ var rebuilt = ConnectingWatch()
+ var again = waiting
+ again.connected = true
+ let second = run(&rebuilt, from: 0, to: 20) { t in
+ again.epoch = t < 8 ? 1 : 2
+ again.hearsMe = t < 1 || t >= 12
+ return again
+ }
+ XCTAssertEqual(second.cleared, [12])
+ XCTAssertEqual(second.shown.first, 9)
+ }
+
+ func testAMoreSpecificReportReplacesConnectingAndReconnectingWaitsUntilAfter() {
+ let plain = PeerMedia(muted: false, cameraOff: false, hearing: [])
+ func tile(
+ _ media: PeerMedia, muted: Bool = false, joining: Bool = true, shown: Bool = true,
+ reconnecting: Bool = false
+ ) -> (notices: [String], connecting: String?) {
+ ConnectingWatch.tile(
+ name: "Sam", media: media, me: "me", muted: muted, joining: joining, shown: shown,
+ reconnecting: reconnecting)
+ }
+ XCTAssertNil(tile(plain, shown: false).connecting, "inside the grace")
+ XCTAssertEqual(tile(plain).connecting, "Sam is connecting and can’t hear you yet")
+ XCTAssertEqual(tile(plain).notices, [])
+ XCTAssertNotNil(tile(plain, muted: true).connecting, "your own mute does not matter")
+ XCTAssertEqual(
+ tile(plain, reconnecting: true).notices, [], "a connection that has never been up is not reconnecting")
+ let blocked = PeerMedia(muted: false, cameraOff: false, audioBlocked: true, hearing: [])
+ XCTAssertEqual(tile(blocked, shown: false).notices, [AudioWords.blocked("Sam")], "even inside the grace")
+ XCTAssertNil(tile(blocked).connecting)
+ let silent = PeerMedia(muted: false, cameraOff: false, noSoundFrom: ["me"], hearing: ["me"])
+ XCTAssertEqual(tile(silent).notices, [AudioWords.notHearingYou("Sam")])
+ XCTAssertNil(tile(silent).connecting)
+ XCTAssertNotNil(
+ tile(PeerMedia(muted: false, cameraOff: false, noSoundFrom: ["me"]), muted: true).connecting,
+ "no sound from you while you are muted is expected, so it replaces nothing")
+ XCTAssertEqual(
+ tile(plain, joining: false, shown: false, reconnecting: true).notices, [ReconnectWords.peer("Sam")])
+ XCTAssertEqual(
+ tile(blocked, joining: false, shown: false, reconnecting: true).notices,
+ [ReconnectWords.peer("Sam"), AudioWords.blocked("Sam")])
+ XCTAssertEqual(tile(plain, joining: false, shown: false).notices, [])
+ }
+
+ func testHearsYou() {
+ XCTAssertEqual(HearingRule.hearsYou(PeerMedia(muted: false, cameraOff: false, hearing: ["me"]), me: "me"), true)
+ XCTAssertEqual(HearingRule.hearsYou(PeerMedia(muted: false, cameraOff: false, hearing: []), me: "me"), false)
+ XCTAssertNil(HearingRule.hearsYou(PeerMedia(muted: false, cameraOff: false), me: "me"))
+ XCTAssertNil(HearingRule.hearsYou(nil, me: "me"))
+ }
+
+ /// This app's own half: when a path counts as heard. A muted sender must
+ /// not look unheard.
+ func testHeardIsArrivingAndDecodedOrAMutedSenderOnALiveConnection() {
+ let base = HearingRule.Facts(
+ connected: true, bad: false, hasReceiver: true, receiverLive: true, packets: 0, decoded: 0,
+ senderMuted: false)
+ func with(_ change: (inout HearingRule.Facts) -> Void) -> HearingRule.Facts {
+ var f = base
+ change(&f)
+ return f
+ }
+ XCTAssertFalse(HearingRule.heard(was: false, base), "nothing has arrived")
+ XCTAssertFalse(HearingRule.heard(was: false, with { $0.packets = 10 }), "packets, nothing decoded")
+ XCTAssertTrue(HearingRule.heard(was: false, with { $0.packets = 10; $0.decoded = 3 }))
+ XCTAssertFalse(
+ HearingRule.heard(was: false, with { $0.packets = 10; $0.decoded = 3; $0.connected = false }),
+ "not before the connection is up")
+ XCTAssertTrue(HearingRule.heard(was: false, with { $0.senderMuted = true }), "a muted sender")
+ XCTAssertTrue(
+ HearingRule.heard(was: false, with { $0.senderMuted = true; $0.hasReceiver = false; $0.receiverLive = false }),
+ "nor one with no microphone at all")
+ XCTAssertFalse(
+ HearingRule.heard(was: false, with { $0.senderMuted = true; $0.receiverLive = false }), "but not a dead receiver")
+ XCTAssertTrue(HearingRule.heard(was: true, base), "once heard, kept")
+ XCTAssertTrue(HearingRule.heard(was: true, with { $0.connected = false }), "a blip is not losing them")
+ XCTAssertFalse(HearingRule.heard(was: true, with { $0.bad = true }), "a failed connection is")
+ }
+
+ func testTheHearingListDecodesAndAbsentIsNotEmpty() throws {
+ func media(_ json: String) throws -> PeerMedia? {
+ let state = try JSONDecoder().decode(
+ RoomState.self,
+ from: Data(
+ #"{"phase":"admitted","peer":"me","boot":"b","waiting":[],"peers":[{"peer":"sam","name":"Sam","kind":"member","gen":1,"media":\#(json)}]}"#
+ .utf8))
+ return Call.peerMedia("sam", in: state)
+ }
+ XCTAssertEqual(try media(#"{"muted":false,"cameraOff":false,"hearing":["me"]}"#)?.hearing, ["me"])
+ XCTAssertEqual(try media(#"{"muted":false,"cameraOff":false,"hearing":[]}"#)?.hearing, [])
+ XCTAssertNil(try media(#"{"muted":false,"cameraOff":false}"#)?.hearing)
+ }
+
+ func testTheDotsTakeTurnsAndNeverGoOut() {
+ for dot in 0..<3 {
+ for step in 0..<28 {
+ let o = ConnectingText.opacity(at: Double(step) * 0.05, dot: dot)
+ XCTAssertGreaterThanOrEqual(o, 0.35)
+ XCTAssertLessThanOrEqual(o, 1.0001)
+ }
+ }
+ XCTAssertEqual(ConnectingText.opacity(at: 0.56, dot: 0), 1, accuracy: 0.001)
+ XCTAssertEqual(ConnectingText.opacity(at: 0.76, dot: 1), 1, accuracy: 0.001)
+ XCTAssertEqual(ConnectingText.opacity(at: 0.96, dot: 2), 1, accuracy: 0.001)
+ }
+
+ func testAHearingReportGoesForAGainOrALossOfSomeoneStillHereAndNotForADeparture() {
+ let here: Set<String> = ["a", "b"]
+ XCTAssertTrue(HearingRule.moved(from: [], to: ["a"], present: here))
+ XCTAssertTrue(HearingRule.moved(from: ["a"], to: ["a", "b"], present: here))
+ XCTAssertTrue(HearingRule.moved(from: ["a", "b"], to: ["a"], present: here))
+ XCTAssertFalse(HearingRule.moved(from: ["a", "c"], to: ["a"], present: here), "c has left")
+ XCTAssertFalse(HearingRule.moved(from: ["a"], to: ["a"], present: here))
+ }
+}
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go
index 0c1acb2..1c84c10 100644
--- a/native/bridge/bridge.go
+++ b/native/bridge/bridge.go
@@ -140,6 +140,21 @@ func (c *Client) SetScreenStalled(peersJSON string) error {
return c.room.SetScreenStalled(ids)
}
+// SetHearing is spec §15.24: the peers whose audio this app is actually
+// receiving and playing, as a JSON array of peer ids. Reported with the
+// rest of this app's media state; "[]" or "" is "nobody yet", which is
+// still said — the key's presence is what tells the others this app
+// reports it.
+func (c *Client) SetHearing(peersJSON string) error {
+ ids := []protocol.PeerID{}
+ if peersJSON != "" {
+ if err := json.Unmarshal([]byte(peersJSON), &ids); err != nil {
+ return errors.New("invalid peer list")
+ }
+ }
+ return c.room.SetHearing(ids)
+}
+
// RegisterDevice hands the server an Apple device token so a knock can
// reach this device when the app is closed. Best effort: the caller
// ignores the error beyond logging, because a device that did not
diff --git a/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift
index d9a9a47..92525ab 100644
--- a/native/ios/UITests/CallsPhoneUITests.swift
+++ b/native/ios/UITests/CallsPhoneUITests.swift
@@ -244,6 +244,30 @@ final class CallsPhoneUITests: XCTestCase {
}
}
+ /// Spec §15.24: two people who have just joined — one connecting and
+ /// unable to hear you yet, the other's browser blocking sound in place of
+ /// that — in Grid and Speaker, light and dark. The notice is on the tile,
+ /// and VoiceOver hears it in the tile's label, without the dots.
+ @MainActor func testConnectingSnapshots() throws {
+ XCUIDevice.shared.orientation = .portrait
+ for dark in [false, true] {
+ let scheme = dark ? "dark" : "light"
+ for layout in ["grid", "speaker"] {
+ let app = launchCall(
+ "connecting",
+ ["-CallsFixtureControls", "hidden", "-CallsFixtureLayout", layout, "-CallsFixtureOrder", "sam,bea,ana,me"],
+ dark: dark)
+ let notice = app.descendants(matching: .any)
+ .matching(NSPredicate(format: "label CONTAINS %@", "Samuel Okafor is connecting and can’t hear you yet"))
+ .firstMatch
+ XCTAssertTrue(notice.waitForExistence(timeout: 15), "Samuel's tile says it")
+ XCTAssertFalse(notice.label.contains("…"), "and VoiceOver hears no dots")
+ Thread.sleep(forTimeInterval: 1)
+ try snapshot(app, "connecting-\(layout)-portrait-\(scheme)")
+ }
+ }
+ }
+
/// The overlay over black, white and a busy frame, in each of your own
/// states, light and dark: it should look the same in both. The
/// microphone sits beside the menu here so its muted state shows.
diff --git a/protocol/messages.go b/protocol/messages.go
index df779e6..ea7f11c 100644
--- a/protocol/messages.go
+++ b/protocol/messages.go
@@ -139,6 +139,15 @@ type MediaState struct {
// recover (a keyframe first). Bad news only, omitted when there is
// none, at most MaxScreenStalled, each a peer id, none repeated.
ScreenStalled []PeerID `json:"screenStalled,omitempty"`
+ // Hearing (spec §15.24, 2026-10-01): the peers whose audio this client
+ // is actually receiving and playing — the path from them is
+ // established, so they can be heard the moment they speak. Unlike the
+ // members above it is good news, and its presence is itself news: nil
+ // (the key absent) is a client that does not say, an old one, and an
+ // empty list is a client that says it hears nobody yet. So it is
+ // omitted only when nil (omitzero), and an empty list travels as [].
+ // At most MaxHearing, each a peer id, none repeated.
+ Hearing []PeerID `json:"hearing,omitzero"`
}
// MaxNoSoundFrom bounds MediaState.NoSoundFrom: a room holds at most
@@ -149,6 +158,9 @@ const MaxNoSoundFrom = 15
// MaxScreenStalled bounds MediaState.ScreenStalled for the same reason.
const MaxScreenStalled = 15
+// MaxHearing bounds MediaState.Hearing for the same reason.
+const MaxHearing = 15
+
// Equal compares two reports field by field; the peer lists are compared
// in order, which is fine because the hub only asks whether a report
// changed, and a reordered list is a report worth passing on.
@@ -156,7 +168,11 @@ func (m MediaState) Equal(o MediaState) bool {
if m.Muted != o.Muted || m.CameraOff != o.CameraOff || m.AudioBlocked != o.AudioBlocked {
return false
}
- return samePeers(m.NoSoundFrom, o.NoSoundFrom) && samePeers(m.ScreenStalled, o.ScreenStalled)
+ if (m.Hearing == nil) != (o.Hearing == nil) {
+ return false
+ }
+ return samePeers(m.NoSoundFrom, o.NoSoundFrom) && samePeers(m.ScreenStalled, o.ScreenStalled) &&
+ samePeers(m.Hearing, o.Hearing)
}
func samePeers(a, b []PeerID) bool {
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 00fe3c8..f39030c 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -415,6 +415,10 @@ function state(page) {
remoteMedia: s.remoteMedia,
// Speaker view: who has the floor, by peer id ('' before anyone).
speaker: s.speaker,
+ // §15.24: who this tab says it hears, and per peer whether they
+ // are still in their first moments and the words are showing.
+ hearingList: s.hearingList,
+ connecting: s.connecting,
};
});
}
@@ -3870,8 +3874,16 @@ const run = {
return;
}
+ // §15.24: A reports who it hears once the pair is up; let that report
+ // go before everything A posts is held, or it is counted below as a
+ // second message the recovery sent.
+ const settled = await until(async () => ((await state(pageA)).hearingList || []).includes(pair.b.peer), 10000);
+ check(settled, 'A reports that it hears B before the cut');
+ await sleep(500);
+
let firstAt = 0;
let signals = 0;
+ const posted = [];
let hungProbes = 0;
// Everything A posts from here is answered 404, twenty seconds
// after it was sent: "that peer is not here", arriving with a third
@@ -3879,6 +3891,8 @@ const run = {
await ctxA.route('**/signal', async (route) => {
if (!firstAt) firstAt = Date.now();
signals += 1;
+ const m = (route.request().postData() || '').match(/"type":"([a-z-]+)"/);
+ posted.push(m ? m[1] : '?');
await sleep(20000);
await route.fulfill({ status: 404, contentType: 'application/json', body: '{"error":"not-found"}' });
});
@@ -3913,7 +3927,7 @@ const run = {
// scenario sent is the only thing A ever posted, and it is still the
// only thing ten seconds later.
const atDrop = signals;
- check(atDrop === 1, 'the offer was the only thing A posted (' + atDrop + ')');
+ check(atDrop === 1, 'the offer was the only thing A posted (' + atDrop + ': ' + posted.join(', ') + ')');
check(dropped && dropped.stale && dropped.stale.includes(pair.b.peer),
'and A holds the target marked stale, waiting for a roster rather than asking again (' +
JSON.stringify(dropped && dropped.stale) + ')');
@@ -4548,6 +4562,11 @@ const run = {
JSON.stringify(notice || ((await state(pageB)).hearing || {})[a]) + ')');
check(notice && notice.notice === nameA + '’s browser is blocking sound — they need to click to hear you.',
'naming A (' + (notice && notice.notice) + ')');
+ // §15.24: A is new to B and cannot hear B yet, but its own report
+ // is more specific than "connecting", and replaces it.
+ const first = (await state(pageB)).connecting[a];
+ check(notice && notice.noticeKind === 'report' && first && first.joining,
+ 'in place of "' + nameA + ' is connecting and can’t hear you yet" (' + JSON.stringify({ notice, first }) + ')');
// A click on nothing in particular: the page's own heading.
const heading = await pageA.locator('h1').first().boundingBox();
@@ -4582,6 +4601,170 @@ const run = {
}
},
+ // Spec §15.24 (2026-10-01): "<name> is connecting and can't hear you
+ // yet". A fast join says nothing; a join whose connection is held back
+ // says it on the joiner's tile, with dots a screen reader never hears,
+ // and clears with "… can hear you now" the moment the joiner's own
+ // report lists us in `hearing`; and a joiner that does not send
+ // `hearing` (a client from before it, played by stripping the key off
+ // its reports) is let go on its first report once the connection is up.
+ // CALLS_CONNECTING_SNAPSHOTS=<dir> keeps pictures of the notice.
+ async connecting() {
+ const out = process.env.CALLS_CONNECTING_SNAPSHOTS;
+ if (out) mkdirSync(out, { recursive: true });
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const pageA = await openRoom(ctxA);
+ const admitted = await until(async () => ((await state(pageA)).admitted ? true : false), 20000);
+ check(admitted, 'A is in the call on its own — ' + (await diagnose(pageA, 'member A')));
+ const a = (await state(pageA)).peer;
+ // A watches itself, ten times a second, from inside the page: whether
+ // the words ever showed for anyone, when each person appeared, and
+ // when their first moments ended; and everything its live region said.
+ const watchA = () => pageA.evaluate(() => {
+ window.__joins = {};
+ window.__said = [];
+ const live = document.getElementById('media-live');
+ if (live) new MutationObserver(() => window.__said.push(live.textContent)).observe(live, { childList: true, characterData: true, subtree: true });
+ setInterval(() => {
+ const s = window.meet.state();
+ for (const [id, c] of Object.entries(s.connecting || {})) {
+ const j = window.__joins[id] || (window.__joins[id] = { from: Date.now(), done: 0, shown: false });
+ if (c.shown) j.shown = true;
+ if (!c.joining && !j.done) j.done = Date.now();
+ }
+ }, 100);
+ });
+ await watchA();
+ const joins = () => pageA.evaluate(() => window.__joins);
+ const said = () => pageA.evaluate(() => window.__said);
+
+ // 1. A fast join says nothing.
+ let pageB = await openRoom(ctxB);
+ const pair = await assertPair(pageA, pageB, 'member A', 'member B');
+ if (!pair) return;
+ let b = pair.b.peer;
+ const nameB = await selfName(pageB);
+ const heardA = await until(async () => ((await state(pageB)).hearingList || []).includes(a), 15000);
+ check(heardA, 'B reports that it hears A (' + JSON.stringify((await state(pageB)).hearingList) + ')');
+ const fast = await until(async () => {
+ const j = (await joins())[b];
+ return j && j.done ? j : false;
+ }, 15000);
+ check(fast && !fast.shown, 'a fast join never says "can’t hear you yet" (' + JSON.stringify(fast) + ', ' +
+ (fast ? fast.done - fast.from : '?') + ' ms from appearing to being heard)');
+ check(!(await said()).some((t) => /hear you/.test(t)), 'and nothing about it is announced (' + JSON.stringify(await said()) + ')');
+ await leaveRoom(pageB);
+ await pageB.close();
+
+ // 2. B again, with everything that would connect it held back for a
+ // few seconds: its offers, answers and candidates.
+ const HOLD = 6000;
+ let releaseAt = 0;
+ const holdSignals = async (route) => {
+ const body = route.request().postData() || '';
+ if (/"type":"(offer|answer|candidate)"/.test(body)) {
+ if (!releaseAt) releaseAt = Date.now() + HOLD;
+ await sleep(Math.max(0, releaseAt - Date.now()));
+ }
+ await route.continue();
+ };
+ await ctxB.route('**/signal', holdSignals);
+ await pageA.evaluate(() => { window.__said = []; });
+ pageB = await openRoom(ctxB);
+ const joined = await until(async () => {
+ const s = await state(pageB);
+ return s.admitted && s.peer ? s.peer : false;
+ }, 20000);
+ check(joined, 'B is back, connection held — ' + (await diagnose(pageB, 'member B')));
+ b = joined;
+ const words = nameB + ' is connecting and can’t hear you yet';
+ const showing = await until(async () => {
+ const s = await state(pageA);
+ const h = s.hearing[b];
+ return h && h.noticeKind === 'connecting' ? h : false;
+ }, 10000);
+ check(showing && showing.notice === words, 'A says it on B’s tile (' + JSON.stringify(showing) + ')');
+ const drawn = await pageA.evaluate((id) => {
+ const n = document.querySelector('figure[data-tile="' + id + '"] .calls-tile-notice');
+ const dots = n && n.querySelector('.calls-ellipsis');
+ return n && { visible: !n.hidden && n.offsetWidth > 0, dots: !!dots, hidden: dots && dots.getAttribute('aria-hidden') };
+ }, b);
+ check(drawn && drawn.visible && drawn.dots && drawn.hidden === 'true',
+ 'on screen, with the dots hidden from screen readers (' + JSON.stringify(drawn) + ')');
+ check((await said()).includes(words), 'and it is announced once, without the dots (' + JSON.stringify(await said()) + ')');
+ if (out) await pageA.screenshot({ path: out + '/web-connecting.png' });
+ await pageA.emulateMedia({ reducedMotion: 'reduce' });
+ const still = await pageA.evaluate((id) => {
+ const dot = document.querySelector('figure[data-tile="' + id + '"] .calls-ellipsis > span');
+ return dot ? getComputedStyle(dot).animationName : null;
+ }, b);
+ check(still === 'none', 'under reduced motion the dots keep still (' + still + ')');
+ if (out) await pageA.screenshot({ path: out + '/web-connecting-reduced-motion.png' });
+ await pageA.emulateMedia({ reducedMotion: 'no-preference' });
+
+ const clear = await until(async () => {
+ const s = await state(pageA);
+ const h = s.hearing[b];
+ return h && h.notice === '' && s.connecting[b] && !s.connecting[b].joining ? s : false;
+ }, 30000);
+ check(clear, 'once B hears A, the words go — ' + (await diagnose(pageA, 'member A')));
+ check(clear && ((clear.remoteMedia[b] || {}).hearing || []).includes(a), 'because B’s own report says so (' +
+ JSON.stringify(clear && clear.remoteMedia[b]) + ')');
+ check(releaseAt > 0 && Date.now() >= releaseAt, 'and not before the hold let go');
+ check((await said()).includes(nameB + ' can hear you now'), 'and A hears that B can hear it now (' + JSON.stringify(await said()) + ')');
+ await ctxB.unroute('**/signal', holdSignals);
+ await leaveRoom(pageB);
+ await pageB.close();
+
+ // 3. B as a client that does not say who it hears: the key stripped
+ // off every report, and the connection held back as before.
+ releaseAt = 0;
+ const oldClient = async (route) => {
+ let body = route.request().postData() || '';
+ if (body.includes('"type":"media"')) {
+ const sig = JSON.parse(body);
+ delete sig.payload.hearing;
+ body = JSON.stringify(sig);
+ return route.continue({ postData: body });
+ }
+ return holdSignals(route);
+ };
+ await ctxB.route('**/signal', oldClient);
+ pageB = await openRoom(ctxB);
+ const back = await until(async () => {
+ const s = await state(pageB);
+ return s.admitted && s.peer ? s.peer : false;
+ }, 20000);
+ check(back, 'B is back as an old client — ' + (await diagnose(pageB, 'member B')));
+ b = back;
+ const oldShowing = await until(async () => {
+ const h = (await state(pageA)).hearing[b];
+ return h && h.noticeKind === 'connecting' ? h : false;
+ }, 10000);
+ check(oldShowing, 'the words show for an old client too (' + JSON.stringify(oldShowing) + ')');
+ const oldClear = await until(async () => {
+ const s = await state(pageA);
+ return s.connecting[b] && !s.connecting[b].joining && s.hearing[b] && s.hearing[b].notice === '' ? s : false;
+ }, 30000);
+ check(oldClear && oldClear.remoteMedia[b] && !('hearing' in oldClear.remoteMedia[b]) &&
+ oldClear.connection[b] === 'connected',
+ 'and go once its connection is up, without any `hearing` from it (' + JSON.stringify(oldClear && oldClear.remoteMedia[b]) + ')');
+ check(Date.now() - (releaseAt || Date.now()) < 20000, 'well before the twenty-second cap');
+ await ctxB.unroute('**/signal', oldClient);
+
+ check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
+ // A signal held back for seconds can land after the pair has moved to
+ // a new generation, and the hub refuses it 409 (§15.9) — which is the
+ // hold's doing, and what the client recovers from. Nothing else.
+ const errorsB = (consoleErrors.get('member B') || []).filter((e) => !/status of 409/.test(e)).join('; ');
+ check(errorsB === '', 'no console errors in B but the held signals’ 409s (' + errorsB + ')');
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
// Speaker view and the viewer's order (0.4.0), in a call of four: two
// members and two guests. Everyone's microphone is silenced but one,
// which speaks a tone; the stage goes to that person only once they
diff --git a/test/browser/native-auth.mjs b/test/browser/native-auth.mjs
index 25d2624..75bb927 100644
--- a/test/browser/native-auth.mjs
+++ b/test/browser/native-auth.mjs
@@ -13,14 +13,15 @@ try {
process.stdout.write("stage:signin\n");
// A member's target lands on the sign-in page. An invitation now lands on
// the guest screen instead (guest.html, added with guest joining): join in
- // the app, a name form to continue in the browser, and a link to sign in.
+ // the app, a name form to continue in the browser, and the bar's Sign in.
// Only the sign-in page carries the address field, so follow the link when
// that is what this target produced.
const address = page.locator('input[name="address"]');
if ((await address.count()) === 0) {
await Promise.all([
page.waitForURL('**/signin*'),
- page.getByRole('link', { name: 'sign in', exact: true }).click(),
+ // Since the shared chrome (f86d4da) the link is the bar's "Sign in".
+ page.getByRole('link', { name: 'Sign in', exact: true }).first().click(),
]);
}
await address.fill(config.address);
diff --git a/test/browser/native.mjs b/test/browser/native.mjs
index 9ade7d2..f5a85ca 100644
--- a/test/browser/native.mjs
+++ b/test/browser/native.mjs
@@ -28,6 +28,20 @@ try {
await page.goto(`${origin}/m/${room}`);
await page.waitForFunction(() => !!window.meet);
console.log('browser before native', await page.evaluate(() => ({status: document.querySelector('#status')?.textContent, tiles: document.querySelectorAll('#tiles video').length})));
+ // Spec §15.24, both ways: the browser's offers, answers and candidates
+ // are held back for the first few seconds, so the app and the browser
+ // each have someone who cannot hear them yet, and each says so.
+ const HOLD = 5000;
+ let releaseAt = 0;
+ const holdSignals = async (route) => {
+ const body = route.request().postData() || '';
+ if (/"type":"(offer|answer|candidate)"/.test(body)) {
+ if (!releaseAt) releaseAt = Date.now() + HOLD;
+ await new Promise(r => setTimeout(r, Math.max(0, releaseAt - Date.now())));
+ }
+ await route.continue();
+ };
+ if (mode !== 'guest') await page.route('**/signal', holdSignals);
native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] });
native.stdin.write(process.env.CALLS_NATIVE_INPUT + '\n');
let sample = null;
@@ -76,6 +90,33 @@ try {
await page.getByRole('button', { name: 'Admit', exact: true }).click();
console.log('PASS guest media waits for member admission');
}
+ if (mode !== 'guest') {
+ await wait(() => sample?.peer, 'the app is in the call');
+ const app = sample.peer;
+ const connecting = () => page.evaluate((id) => {
+ const s = window.meet.state();
+ return { c: s.connecting[id] || null, h: s.hearing[id] || null, media: s.remoteMedia[id] || null,
+ me: s.peer, hearingList: s.hearingList };
+ }, app);
+ await wait(async () => { const c = await connecting(); return c.h && c.h.noticeKind === 'connecting'; },
+ 'the browser says the app cannot hear it yet', 15);
+ const words = (await connecting()).h.notice;
+ assert.match(words, / is connecting and can’t hear you yet$/);
+ console.log(`PASS the browser says "${words}" while the connection is held`);
+ await wait(() => sample?.stats.connectingShown === 1, 'the app says the browser cannot hear it yet', 15);
+ console.log('PASS the app says the browser cannot hear it yet');
+ await wait(async () => { const c = await connecting(); return c.c && !c.c.joining && c.h.notice === ''; },
+ 'the browser stops saying it once the app hears it', 30);
+ const heard = await connecting();
+ assert.ok((heard.media.hearing || []).includes(heard.me), 'because the app reports it hears the browser');
+ assert.ok(Date.now() >= releaseAt, 'and not before the hold let go');
+ console.log('PASS the app reports hearing the browser, and the browser\u2019s notice goes');
+ await wait(() => sample?.stats.connectingShown === 0 && sample?.stats.connectingJoining === 0 && sample?.stats.hearing === 1,
+ 'the app stops saying it once the browser hears it', 30);
+ assert.ok(heard.hearingList.includes(app) || (await connecting()).hearingList.includes(app), 'the browser reports it hears the app');
+ console.log('PASS the browser reports hearing the app, and the app\u2019s notice goes');
+ await page.unroute('**/signal', holdSignals);
+ }
await wait(() => sample?.stats.framesDecoded > 15 && sample?.stats.audioBytesReceived > 0 && sample?.stats.videoBytesSent > 0 && sample?.stats.audioBytesSent > 0, 'native receives browser audio/video');
console.log('PASS native decoded browser video and received audio');
// The synthetic microphone runs through WebRTC's capture processing, so
diff --git a/test/browser/room-connecting.test.mjs b/test/browser/room-connecting.test.mjs
new file mode 100644
index 0000000..74a57de
--- /dev/null
+++ b/test/browser/room-connecting.test.mjs
@@ -0,0 +1,196 @@
+// room-connecting.test.mjs — "<name> is connecting and can't hear you yet"
+// (spec §15.24, 2026-10-01), gated without a browser.
+//
+// node --test test/browser/room-connecting.test.mjs
+//
+// The functions are lifted out of the shipped room.js by name, with the
+// constants they read, as room-reconnect.test.mjs does:
+//
+// connectingStep the watch: the grace, clearing on `hearing`, the
+// fallback for a client that does not say, the cap, and
+// a rejoin starting it again
+// hearsYou a peer's report on us: true, false, or does not say
+// peerNotice what a tile says, and what replaces what
+// judgeHeard this tab's own half: when a path counts as heard
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const src = readFileSync(fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)), 'utf8');
+
+function lift(name) {
+ const start = src.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing');
+ let depth = 0;
+ for (let i = src.indexOf('{', start); i < src.length; i++) {
+ if (src[i] === '{') depth += 1;
+ else if (src[i] === '}') {
+ depth -= 1;
+ if (depth === 0) return src.slice(start, i + 1);
+ }
+ }
+ assert.fail('unbalanced braces reading ' + name);
+}
+function liftConst(name) {
+ const m = src.match(new RegExp('const ' + name + ' = ([0-9.]+);'));
+ assert.ok(m, name + ' is not a numeric const in room.js any more');
+ return 'const ' + name + ' = ' + m[1] + ';';
+}
+const CONSTS = ['CONNECTING_GRACE', 'CONNECTING_CAP'].map(liftConst).join('\n');
+const names = ['connectingRecord', 'connectingStep', 'hearsYou', 'peerNotice', 'remoteNotice', 'connectingWords',
+ 'canHearWords', 'judgeHeard', 'hearingMoved'];
+// eslint-disable-next-line no-new-func
+const fns = new Function(CONSTS + '\n' + names.map(lift).join('\n') + '; return { ' + names.join(', ') +
+ ', GRACE: CONNECTING_GRACE, CAP: CONNECTING_CAP };')();
+const { connectingRecord, connectingStep, hearsYou, peerNotice, judgeHeard, hearingMoved, GRACE, CAP } = fns;
+
+const ME = 'm'.repeat(32);
+const SAM = 's'.repeat(32);
+const waiting = { epoch: 1, hearsMe: false, reported: true, connected: false, acked: false };
+
+// Five looks a second from `from` to `to` seconds; when Sam's words
+// showed, and when they cleared with "can hear you now".
+function run(rec, from, to, sam) {
+ const shown = [];
+ const cleared = [];
+ for (let t = from * 10; t < to * 10; t += 2) {
+ const s = Math.round(t) / 10;
+ const p = sam(s);
+ const people = p ? new Map([[SAM, p]]) : new Map();
+ if (connectingStep(rec, s * 1000, people).includes(SAM)) cleared.push(s);
+ const r = rec.peers.get(SAM);
+ if (r && r.shown) shown.push(s);
+ }
+ return { shown, cleared };
+}
+
+test('the timings are the ones the spec names', () => {
+ assert.equal(GRACE, 1000);
+ assert.equal(CAP, 20000);
+});
+
+test('an ordinary join, heard within the grace, says nothing at all', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 10, (t) => ({ ...waiting, connected: t >= 0.4, hearsMe: t >= 0.8 }));
+ assert.deepEqual(seen.shown, []);
+ assert.deepEqual(seen.cleared, [], 'nothing was said, so "can hear you now" is not said either');
+ assert.equal(rec.peers.get(SAM).done, true);
+});
+
+test('a slow join says so after the grace, and clears the moment they hear us', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 12, (t) => ({ ...waiting, connected: t >= 5, hearsMe: t >= 6 }));
+ assert.equal(seen.shown[0], 1);
+ assert.equal(seen.shown.at(-1), 5.8);
+ assert.deepEqual(seen.cleared, [6]);
+});
+
+test('once cleared it stays cleared, whatever their list does after', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 30, (t) => ({ ...waiting, connected: true, hearsMe: t >= 3 && t < 10 }));
+ assert.deepEqual(seen.cleared, [3]);
+ assert.equal(seen.shown.at(-1), 2.8, '§15.23 and §15.20 speak for a problem after that');
+});
+
+test('it is given up at the cap, silently', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 40, () => waiting);
+ assert.equal(seen.shown[0], 1);
+ assert.equal(seen.shown.at(-1), 19.8);
+ assert.deepEqual(seen.cleared, [], 'a cap is not their hearing us');
+ assert.equal(rec.peers.get(SAM).done, true);
+});
+
+test('a client that does not say who it hears: its first report and a connection clear it', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 10, (t) => ({ epoch: 1, hearsMe: null, reported: t >= 4, connected: t >= 2, acked: false }));
+ assert.deepEqual(seen.cleared, [4]);
+});
+
+test('or a connection and receiver reports showing our audio reaching them', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 10, (t) => ({ epoch: 1, hearsMe: null, reported: false, connected: t >= 2, acked: t >= 3 }));
+ assert.deepEqual(seen.cleared, [3]);
+ const rec2 = connectingRecord();
+ const unconnected = run(rec2, 0, 10, () => ({ epoch: 1, hearsMe: null, reported: true, connected: false, acked: true }));
+ assert.deepEqual(unconnected.cleared, [], 'never without a connection');
+});
+
+test('a client that says who it hears is believed over our own evidence', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 10, () => ({ epoch: 1, hearsMe: false, reported: true, connected: true, acked: true }));
+ assert.deepEqual(seen.cleared, []);
+ assert.equal(seen.shown.length > 0, true);
+});
+
+test('a rejoin starts it again, and so does a new connection to the same person', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 20, (t) => {
+ if (t >= 5 && t < 6) return null; // gone from the roster
+ if (t < 5) return { ...waiting, connected: true, hearsMe: t >= 2 };
+ return { ...waiting, connected: t >= 9, hearsMe: t >= 9 };
+ });
+ assert.deepEqual(seen.cleared, [2, 9]);
+ assert.equal(seen.shown.includes(7), true);
+ assert.equal(seen.shown.includes(6.8), false, 'the grace again from the moment they are back');
+
+ const rec2 = connectingRecord();
+ const rebuilt = run(rec2, 0, 20, (t) => ({ ...waiting, epoch: t < 8 ? 1 : 2, connected: true, hearsMe: t < 1 || t >= 12 }));
+ assert.deepEqual(rebuilt.cleared, [12]);
+ assert.equal(rebuilt.shown[0], 9);
+});
+
+test('hearsYou: listed, not listed, or not saying', () => {
+ assert.equal(hearsYou({ muted: false, cameraOff: false, hearing: [ME] }, ME), true);
+ assert.equal(hearsYou({ muted: false, cameraOff: false, hearing: [] }, ME), false);
+ assert.equal(hearsYou({ muted: false, cameraOff: false }, ME), null);
+ assert.equal(hearsYou(null, ME), null);
+});
+
+test('peerNotice: a more specific report replaces "connecting", and "Reconnecting" waits until after', () => {
+ const plain = { muted: false, cameraOff: false, hearing: [] };
+ assert.deepEqual(peerNotice('Sam', plain, ME, false, true, false, false), { kind: '', text: '' }, 'inside the grace');
+ assert.deepEqual(peerNotice('Sam', plain, ME, false, true, true, false),
+ { kind: 'connecting', text: 'Sam is connecting and can’t hear you yet' });
+ assert.deepEqual(peerNotice('Sam', plain, ME, true, true, true, false).kind, 'connecting',
+ 'our own mute does not matter: it is about their hearing us when we speak');
+ assert.deepEqual(peerNotice('Sam', plain, ME, false, true, true, true).kind, 'connecting',
+ 'a connection that has never been up is not reconnecting');
+ assert.deepEqual(peerNotice('Sam', { ...plain, audioBlocked: true }, ME, false, true, false, false),
+ { kind: 'report', text: 'Sam’s browser is blocking sound — they need to click to hear you.' },
+ 'even inside the grace: it is their own report');
+ assert.deepEqual(peerNotice('Sam', { ...plain, noSoundFrom: [ME] }, ME, false, true, true, false),
+ { kind: 'report', text: 'Sam isn’t getting any sound from you.' });
+ assert.deepEqual(peerNotice('Sam', { ...plain, noSoundFrom: [ME] }, ME, true, true, true, false).kind, 'connecting',
+ 'no sound from us while we are muted is expected, so it does not replace anything');
+ assert.deepEqual(peerNotice('Sam', plain, ME, false, false, false, true),
+ { kind: 'reconnecting', text: 'Reconnecting to Sam…' });
+ assert.deepEqual(peerNotice('Sam', { ...plain, audioBlocked: true }, ME, false, false, false, false).kind, 'report');
+ assert.deepEqual(peerNotice('Sam', plain, ME, false, false, false, false), { kind: '', text: '' });
+});
+
+test('judgeHeard: arriving and decoded, or a muted sender on a live connection', () => {
+ const base = { connected: true, bad: false, hasReceiver: true, receiverLive: true, packets: 0, samples: 0, senderMuted: false };
+ assert.equal(judgeHeard(false, base), false, 'nothing has arrived');
+ assert.equal(judgeHeard(false, { ...base, packets: 10 }), false, 'packets, but nothing decoded');
+ assert.equal(judgeHeard(false, { ...base, packets: 10, samples: 480 }), true);
+ assert.equal(judgeHeard(false, { ...base, connected: false, packets: 10, samples: 480 }), false, 'not before the connection is up');
+ assert.equal(judgeHeard(false, { ...base, senderMuted: true }), true, 'a muted sender must not look unheard');
+ assert.equal(judgeHeard(false, { ...base, senderMuted: true, hasReceiver: false, receiverLive: false }), true,
+ 'nor one with no microphone at all');
+ assert.equal(judgeHeard(false, { ...base, senderMuted: true, receiverLive: false }), false, 'but not on a dead receiver');
+ assert.equal(judgeHeard(true, base), true, 'once heard, kept while the connection lasts');
+ assert.equal(judgeHeard(true, { ...base, connected: false }), true, 'a blip is not losing them');
+ assert.equal(judgeHeard(true, { ...base, bad: true }), false, 'a failed connection is');
+});
+
+test('hearingMoved: a gain, or a loss of someone still here, and never a departure alone', () => {
+ const here = new Set(['a', 'b']);
+ assert.equal(hearingMoved('', 'a', here), true);
+ assert.equal(hearingMoved('a', 'a,b', here), true);
+ assert.equal(hearingMoved('a,b', 'a', here), true, 'b is still here and no longer heard');
+ assert.equal(hearingMoved('a,c', 'a', here), false, 'c has left: not worth a report from everyone');
+ assert.equal(hearingMoved('a', 'a', here), false);
+ assert.equal(hearingMoved('', '', here), false);
+});
diff --git a/test/browser/room-mute.test.mjs b/test/browser/room-mute.test.mjs
index 20dfcb8..cce4d08 100644
--- a/test/browser/room-mute.test.mjs
+++ b/test/browser/room-mute.test.mjs
@@ -162,7 +162,7 @@ test('the dispatch routes media, and the report rides the broadcast lane', () =>
// a report goes, and that is the pair of them.
function reporter({ admitted = true, local = { muted: false, cameraOff: false }, answer = true, status = 200 } = {}) {
const sent = [];
- const env = { local, answer, status, blocked: false, noSound: [] };
+ const env = { local, answer, status, blocked: false, noSound: [], heard: null };
const made = new Function(
'state', 'remoteMedia', 'localMedia', 'broadcast', 'env',
['mediaFrom', 'sameMedia', 'mediaKey', 'mediaPayload', 'learnMedia', 'reportMedia', 'rosterMedia']
@@ -172,6 +172,7 @@ function reporter({ admitted = true, local = { muted: false, cameraOff: false },
'\nlet audioBlocked = false;' +
'\nlet screenStalled = [];' +
'\nconst noSoundList = () => env.noSound;' +
+ '\nconst hearingList = () => env.heard;' +
'\nreturn { rosterMedia, reportMedia, memo: () => mediaSent, extras: () => mediaExtras,' +
'\n block: (on) => { audioBlocked = on; } };',
)(
@@ -278,3 +279,45 @@ test('a 400 for the extras falls back to the two keys, at once and for good', as
r.reportMedia();
assert.deepEqual(r.sent.at(-1)[1], { muted: true, cameraOff: false });
});
+
+// §15.24: `hearing` is good news and travels whenever it is given, []
+// included — its presence is what tells the others this tab says who it
+// hears — and it is read back the same way: an empty list is kept, and
+// is a different report from one without the key.
+test('hearing is carried even when empty, and read back as given', () => {
+ assert.deepEqual(mediaPayload({ muted: false, cameraOff: false }, false, [], false, [], []),
+ { muted: false, cameraOff: false, hearing: [] });
+ assert.deepEqual(mediaPayload({ muted: false, cameraOff: false }, false, [], false, [], [P1, P2]),
+ { muted: false, cameraOff: false, hearing: [P1, P2] });
+ assert.deepEqual(mediaPayload({ muted: false, cameraOff: false }, false, [], true, [], [P1]),
+ { muted: false, cameraOff: false }, 'a server that refused the extras gets none');
+ assert.deepEqual(mediaFrom({ muted: false, cameraOff: false, hearing: [] }), { muted: false, cameraOff: false, hearing: [] });
+ assert.deepEqual(mediaFrom({ muted: false, cameraOff: false, hearing: [P1, 'nope', P1] }),
+ { muted: false, cameraOff: false, hearing: [P1] });
+ assert.deepEqual(mediaFrom({ muted: false, cameraOff: false, hearing: 'all' }), { muted: false, cameraOff: false });
+ assert.notEqual(mediaKey({ muted: false, cameraOff: false, hearing: [] }), mediaKey({ muted: false, cameraOff: false }));
+ assert.equal(mediaKey({ muted: false, cameraOff: false, hearing: [P2, P1] }), mediaKey({ muted: false, cameraOff: false, hearing: [P1, P2] }));
+});
+
+test('who this tab hears is reported on a change, and a roster that agrees costs nothing', () => {
+ const r = reporter();
+ r.env.heard = [];
+ r.rosterMedia({ peers: [{ peer: 'me' }] });
+ assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false, hearing: [] });
+ r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false, hearing: [] } }] });
+ assert.equal(r.sent.length, 1);
+ r.env.heard = [P1];
+ r.reportMedia();
+ assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false, hearing: [P1] });
+ r.reportMedia();
+ assert.equal(r.sent.length, 2);
+});
+
+test('a 400 for a report carrying hearing falls back to the two keys', async () => {
+ const r = reporter({ status: 400 });
+ r.env.heard = [];
+ r.reportMedia();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ assert.equal(r.extras(), false);
+ assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false });
+});