@ai-matrx/meet 0.11.32 → 0.11.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.js CHANGED
@@ -1,6 +1,139 @@
1
1
  "use client";
2
2
  "use client";
3
3
 
4
+ // src/core/tab-claim.ts
5
+ var TAB_ID_KEY = "mx.meet.tab";
6
+ var TAB_ANSWER_MS = 250;
7
+ function validTabId(value) {
8
+ return value !== null && /^[A-Za-z0-9_-]{4,64}$/.test(value);
9
+ }
10
+ function wait(ms2) {
11
+ return new Promise((resolve) => setTimeout(resolve, ms2));
12
+ }
13
+ function createTabClaim(platform = webTabPlatform()) {
14
+ let mine = null;
15
+ let current = null;
16
+ const self = platform.randomId();
17
+ const unsubscribe = platform.bus?.subscribe((message) => {
18
+ if (message.kind === "tab-alive?" && mine !== null && message.tabId === mine && message.from !== self) {
19
+ platform.bus?.post({ kind: "tab-alive", tabId: mine });
20
+ } else if (message.kind === "who-is-in?" && current !== null && mine !== null && message.from !== self && message.room === current.room && message.person === current.person) {
21
+ platform.bus?.post({ kind: "in-room", room: current.room, person: current.person, tabId: mine, to: message.from });
22
+ }
23
+ }) ?? (() => {
24
+ });
25
+ async function someoneAnswersFor(tabId2) {
26
+ const bus = platform.bus;
27
+ if (bus === null) return false;
28
+ let answered = false;
29
+ const stop = bus.subscribe((message) => {
30
+ if (message.kind === "tab-alive" && message.tabId === tabId2) answered = true;
31
+ });
32
+ bus.post({ kind: "tab-alive?", tabId: tabId2, from: self });
33
+ await wait(TAB_ANSWER_MS);
34
+ stop();
35
+ return answered;
36
+ }
37
+ async function claim() {
38
+ const stored = platform.storage?.get(TAB_ID_KEY) ?? null;
39
+ let candidate = validTabId(stored) ? stored : platform.randomId();
40
+ for (let attempt = 0; attempt < 4; attempt++) {
41
+ const held = await platform.tryLock(`${TAB_ID_KEY}:${candidate}`);
42
+ if (held) break;
43
+ if (!await someoneAnswersFor(candidate)) break;
44
+ candidate = platform.randomId();
45
+ }
46
+ mine = candidate;
47
+ platform.storage?.set(TAB_ID_KEY, candidate);
48
+ return candidate;
49
+ }
50
+ const tabId = claim();
51
+ return {
52
+ tabId,
53
+ inRoom(room, person) {
54
+ current = { room, person };
55
+ },
56
+ left() {
57
+ current = null;
58
+ },
59
+ async presentElsewhere(room, person) {
60
+ const bus = platform.bus;
61
+ if (bus === null) return false;
62
+ let seen = false;
63
+ const stop = bus.subscribe((message) => {
64
+ if (message.kind === "in-room" && message.to === self && message.room === room && message.person === person) {
65
+ seen = true;
66
+ }
67
+ });
68
+ bus.post({ kind: "who-is-in?", room, person, from: self });
69
+ await wait(TAB_ANSWER_MS);
70
+ stop();
71
+ return seen;
72
+ },
73
+ dispose() {
74
+ unsubscribe();
75
+ }
76
+ };
77
+ }
78
+ function randomTabId() {
79
+ const crypto = globalThis.crypto;
80
+ const raw = crypto?.randomUUID?.() ?? `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
81
+ return raw.replace(/[^A-Za-z0-9]/g, "").slice(0, 16);
82
+ }
83
+ function webTabPlatform() {
84
+ const g = globalThis;
85
+ const session = g.sessionStorage;
86
+ const storage = session === void 0 ? null : {
87
+ get(key) {
88
+ try {
89
+ return session.getItem(key);
90
+ } catch {
91
+ return null;
92
+ }
93
+ },
94
+ set(key, value) {
95
+ try {
96
+ session.setItem(key, value);
97
+ } catch {
98
+ }
99
+ }
100
+ };
101
+ const channel = g.BroadcastChannel !== void 0 ? new g.BroadcastChannel("mx.meet.tabs") : null;
102
+ const bus = channel === null ? null : {
103
+ post(message) {
104
+ channel.postMessage(message);
105
+ },
106
+ subscribe(listener) {
107
+ const handler = (event) => {
108
+ const data = event.data;
109
+ if (data !== null && typeof data === "object" && typeof data.kind === "string") listener(data);
110
+ };
111
+ channel.addEventListener("message", handler);
112
+ return () => channel.removeEventListener("message", handler);
113
+ }
114
+ };
115
+ return {
116
+ storage,
117
+ bus,
118
+ randomId: randomTabId,
119
+ tryLock(name) {
120
+ const locks = g.navigator?.locks;
121
+ if (locks === void 0) return Promise.resolve(false);
122
+ return new Promise((resolve) => {
123
+ locks.request(name, { ifAvailable: true }, (lock) => {
124
+ if (lock === null) {
125
+ resolve(false);
126
+ return Promise.resolve();
127
+ }
128
+ resolve(true);
129
+ return new Promise(() => {
130
+ });
131
+ }).catch(() => resolve(false));
132
+ });
133
+ }
134
+ };
135
+ }
136
+
4
137
  // src/core/connection-supervisor.ts
5
138
  var DEFAULT_CONNECTION_POLICY = {
6
139
  bannerDelayMs: 2e3,
@@ -380,12 +513,18 @@ var MeetError = class extends Error {
380
513
  * renders `refused:<reason>` (or `ended`) from it — never from the HTTP status.
381
514
  */
382
515
  reason;
383
- constructor(code, message, remedy, cause, reason) {
516
+ /**
517
+ * A refusal that lifts by itself says when: whole seconds from the moment the server answered
518
+ * (a denial's cooldown, `reknock_cooldown_seconds`). Null for every refusal that does not.
519
+ */
520
+ retryAfterSeconds;
521
+ constructor(code, message, remedy, cause, reason, retryAfterSeconds) {
384
522
  super(message);
385
523
  this.name = "MeetError";
386
524
  this.code = code;
387
525
  this.remedy = remedy;
388
526
  this.reason = reason ?? null;
527
+ this.retryAfterSeconds = retryAfterSeconds ?? null;
389
528
  if (cause !== void 0) this.cause = cause;
390
529
  }
391
530
  /** True when retrying after the host re-establishes a session is the fix. */
@@ -719,7 +858,8 @@ function parseRoomToken(body, roomName) {
719
858
  const serverUrl = readString(payload, "server_url") ?? readString(payload, "serverUrl");
720
859
  const identity = readString(payload, "identity");
721
860
  const expiresAt = readString(payload, "expires_at") ?? readString(payload, "expiresAt");
722
- if (token === null || serverUrl === null || identity === null) {
861
+ const secondDeviceChoice = readString(payload, "second_device_choice");
862
+ if (token === null && secondDeviceChoice === null || serverUrl === null || identity === null) {
723
863
  throw invalidResponse(
724
864
  "meet.token",
725
865
  "the response is missing token, server_url, or identity \u2014 joining with a partial token would fail inside LiveKit with an opaque error instead of here"
@@ -731,11 +871,15 @@ function parseRoomToken(body, roomName) {
731
871
  "the response carries no expires_at. Without it the package cannot refresh before the token dies, and a long meeting silently loses its ability to reconnect"
732
872
  );
733
873
  }
874
+ const personKey = readString(payload, "person_key") ?? identity;
734
875
  return {
735
- token,
876
+ token: token ?? "",
736
877
  serverUrl,
737
878
  roomName,
738
- identity,
879
+ identity: personKey,
880
+ connectionIdentity: readString(payload, "connection_identity") ?? personKey,
881
+ connectionKind: readString(payload, "connection_kind") === "companion" ? "companion" : "main",
882
+ secondDeviceChoice,
739
883
  displayName: readString(payload, "display_name") ?? readString(payload, "displayName") ?? identity,
740
884
  expiresAt,
741
885
  canPublish: readBoolean(payload, "can_publish", true),
@@ -810,7 +954,9 @@ function createTokenClient(options) {
810
954
  organization_id: input.identity.organizationId,
811
955
  display_name: input.identity.displayName,
812
956
  device_id: input.identity.deviceId,
813
- guest
957
+ guest,
958
+ ...input.tabId !== void 0 ? { tab_id: input.tabId } : {},
959
+ ...input.secondDevice !== void 0 ? { second_device: input.secondDevice } : {}
814
960
  }
815
961
  });
816
962
  } catch (error) {
@@ -827,7 +973,8 @@ function createTokenClient(options) {
827
973
  said?.message ?? "You can't join this meeting.",
828
974
  said?.remedy ?? "Ask the person who shared the link to let you in, then open the link again.",
829
975
  void 0,
830
- said?.reason ?? null
976
+ said?.reason ?? null,
977
+ said?.retryAfterSeconds ?? null
831
978
  );
832
979
  }
833
980
  if (response.status === 404) {
@@ -885,6 +1032,10 @@ function createTokenClient(options) {
885
1032
  if (!expired(entry)) return Promise.resolve(entry.token);
886
1033
  }
887
1034
  const pending = request(input, true).then((token) => {
1035
+ if (token.secondDeviceChoice != null) {
1036
+ cache.delete(input.roomName);
1037
+ return token;
1038
+ }
888
1039
  cache.set(input.roomName, { token, mintedAt: now(), inFlight: null });
889
1040
  remember(token);
890
1041
  return token;
@@ -919,6 +1070,9 @@ function placeholder(roomName) {
919
1070
  serverUrl: "",
920
1071
  roomName,
921
1072
  identity: "",
1073
+ connectionIdentity: "",
1074
+ connectionKind: "main",
1075
+ secondDeviceChoice: null,
922
1076
  displayName: "",
923
1077
  expiresAt: (/* @__PURE__ */ new Date(0)).toISOString(),
924
1078
  canPublish: false,
@@ -943,7 +1097,9 @@ async function serverSentence(response) {
943
1097
  if (message === null) return null;
944
1098
  const remedy = typeof source.remedy === "string" && source.remedy.trim() !== "" ? source.remedy : void 0;
945
1099
  const reason = typeof source.reason === "string" && source.reason !== "" ? source.reason : null;
946
- return { message, remedy, reason };
1100
+ const retry = source.retry_after_seconds;
1101
+ const retryAfterSeconds = typeof retry === "number" && Number.isFinite(retry) && retry > 0 ? Math.ceil(retry) : null;
1102
+ return { message, remedy, reason, retryAfterSeconds };
947
1103
  } catch {
948
1104
  return null;
949
1105
  }
@@ -1394,7 +1550,10 @@ var JOINABLE_FROM = /* @__PURE__ */ new Set([
1394
1550
  "superseded",
1395
1551
  "inactive",
1396
1552
  "denied",
1397
- "knock_expired"
1553
+ "knock_expired",
1554
+ // A refused knock whose rule lets the person ask again (a cooldown that ran out): the machine
1555
+ // allows the press; the server's gate decides whether it knocks or is refused again.
1556
+ "refused:lobby_rejects"
1398
1557
  ]);
1399
1558
  var NOT_ENDABLE = /* @__PURE__ */ new Set([
1400
1559
  "ended",
@@ -1478,6 +1637,8 @@ function nextPhase(from, event) {
1478
1637
  return WAITING_PHASES.has(from) ? "joining" : null;
1479
1638
  case "connected":
1480
1639
  return from === "joining" ? "in_call" : null;
1640
+ case "second_device":
1641
+ return from === "joining" ? "prejoin" : null;
1481
1642
  case "join_failed":
1482
1643
  return from === "joining" ? refusalPhase(event.reason) : null;
1483
1644
  case "exit":
@@ -1495,7 +1656,7 @@ function nextPhase(from, event) {
1495
1656
  case "call_outcome":
1496
1657
  return RINGING.has(from) ? event.outcome : null;
1497
1658
  case "reset":
1498
- return LIVE_PHASES.has(from) ? null : "resolving";
1659
+ return LIVE_PHASES.has(from) && event.teardown !== true ? null : "resolving";
1499
1660
  }
1500
1661
  }
1501
1662
  function resolveExit(input) {
@@ -1801,8 +1962,8 @@ function createCallCenter(options) {
1801
1962
  "A late accept must not resurrect a settled call. Close the ringing UI; the caller has already moved on."
1802
1963
  );
1803
1964
  }
1804
- if (options.store.snapshot().phase === "ringing_in") options.store.dispatch({ type: "call_answered" });
1805
1965
  const settled = await repository.settleCallInvite(id, "connecting", null);
1966
+ if (options.store.snapshot().phase === "ringing_in") options.store.dispatch({ type: "call_answered" });
1806
1967
  options.store.patch({ call: { invite: settled, direction: "incoming" } });
1807
1968
  settleLocal(settled);
1808
1969
  channel?.send(MEET_EVENTS.settled, settled);
@@ -2013,11 +2174,17 @@ function createDeviceManager(options) {
2013
2174
  }
2014
2175
 
2015
2176
  // src/core/names.ts
2177
+ var CONNECTION_SEPARATOR = "~";
2178
+ function personOf(identity) {
2179
+ const value = String(identity ?? "").trim();
2180
+ const cut = value.indexOf(CONNECTION_SEPARATOR);
2181
+ return cut === -1 ? value : value.slice(0, cut);
2182
+ }
2016
2183
  function isGuestIdentity(identity) {
2017
- return String(identity).startsWith("guest:");
2184
+ return personOf(identity).startsWith("guest:");
2018
2185
  }
2019
2186
  function isAgentIdentity(identity) {
2020
- return String(identity).startsWith("agent:");
2187
+ return personOf(identity).startsWith("agent:");
2021
2188
  }
2022
2189
  var NOTE_TAKER_PRODUCT_NAME = "Matrx note-taker";
2023
2190
  var AGENT_PRODUCT_NAME = "Matrx agent";
@@ -3617,6 +3784,7 @@ var EMPTY = {
3617
3784
  waiting: null,
3618
3785
  meeting: null,
3619
3786
  localIdentity: null,
3787
+ secondDevice: null,
3620
3788
  grants: NO_GRANTS,
3621
3789
  participants: [],
3622
3790
  activeSpeakers: [],
@@ -3867,7 +4035,8 @@ function createMeetStore(initial = {}) {
3867
4035
  commit({ ...state, media: { ...state.media, [kind]: next } });
3868
4036
  },
3869
4037
  reset() {
3870
- commit({ ...EMPTY, effects: state.effects });
4038
+ const phase = nextPhase(state.phase, { type: "reset", teardown: true }) ?? EMPTY.phase;
4039
+ commit({ ...EMPTY, effects: state.effects, phase, reason: reasonOf(phase), phaseNotice: null });
3871
4040
  }
3872
4041
  };
3873
4042
  }
@@ -3900,6 +4069,28 @@ function lobbyEntryFrom(attendee, now = Date.now()) {
3900
4069
  decidedBy: null
3901
4070
  };
3902
4071
  }
4072
+ function deniedEntryFrom(attendee) {
4073
+ if (attendee.admissionState !== "denied") return null;
4074
+ return {
4075
+ identity: attendee.identity,
4076
+ displayName: attendee.displayName,
4077
+ avatarUrl: attendee.avatarUrl,
4078
+ userId: attendee.userId,
4079
+ state: "denied",
4080
+ knockedAt: attendee.knockedAt ?? attendee.joinedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
4081
+ knockExpiresAt: null,
4082
+ decidedAt: null,
4083
+ decidedBy: null
4084
+ };
4085
+ }
4086
+ function applyDoorRow(store, attendee) {
4087
+ const entry = lobbyEntryFrom(attendee) ?? deniedEntryFrom(attendee);
4088
+ if (entry !== null) {
4089
+ store.upsertLobby(entry);
4090
+ return;
4091
+ }
4092
+ store.setAdmission(attendee.identity, decidedState(attendee.admissionState), null);
4093
+ }
3903
4094
  function decidedState(state) {
3904
4095
  return WAITING_ADMISSION_STATES.includes(state) ? "expired" : state;
3905
4096
  }
@@ -4034,6 +4225,7 @@ function createMeetingSession(options) {
4034
4225
  admission,
4035
4226
  admittersPresent: read.admitters_present ?? null,
4036
4227
  reknockAllowed: read.reknock_allowed ?? true,
4228
+ reknockAt: typeof read.reknock_in_seconds === "number" && read.reknock_in_seconds > 0 ? Date.now() + read.reknock_in_seconds * 1e3 : null,
4037
4229
  knockExpiresAt: read.knock_expires_at ?? null,
4038
4230
  pollSeconds: read.poll_seconds ?? snapshot.waiting?.pollSeconds ?? 3,
4039
4231
  liveVersion: read.live_version ?? 0
@@ -4116,11 +4308,7 @@ function createMeetingSession(options) {
4116
4308
  try {
4117
4309
  const attendees = await repository.attendees(meeting.id);
4118
4310
  store.learn(attendees);
4119
- for (const attendee of attendees) {
4120
- const entry = lobbyEntryFrom(attendee);
4121
- if (entry !== null) store.upsertLobby(entry);
4122
- else store.setAdmission(attendee.identity, decidedState(attendee.admissionState), null);
4123
- }
4311
+ for (const attendee of attendees) applyDoorRow(store, attendee);
4124
4312
  } catch {
4125
4313
  }
4126
4314
  }
@@ -4202,12 +4390,7 @@ function createMeetingSession(options) {
4202
4390
  if (row === null) return;
4203
4391
  const attendee = repository.projectAttendee(row);
4204
4392
  store.learn([attendee]);
4205
- const entry = lobbyEntryFrom(attendee);
4206
- if (entry !== null) {
4207
- store.upsertLobby(entry);
4208
- return;
4209
- }
4210
- store.setAdmission(attendee.identity, decidedState(attendee.admissionState), null);
4393
+ applyDoorRow(store, attendee);
4211
4394
  }
4212
4395
  },
4213
4396
  {
@@ -4254,14 +4437,7 @@ function createMeetingSession(options) {
4254
4437
  store.setRecording(latest.state, latest.startedBy, "record", latest.id);
4255
4438
  }
4256
4439
  store.learn(attendees);
4257
- for (const attendee of attendees) {
4258
- const entry = lobbyEntryFrom(attendee);
4259
- if (entry !== null) {
4260
- store.upsertLobby(entry);
4261
- } else {
4262
- store.setAdmission(attendee.identity, decidedState(attendee.admissionState), null);
4263
- }
4264
- }
4440
+ for (const attendee of attendees) applyDoorRow(store, attendee);
4265
4441
  }
4266
4442
  });
4267
4443
  meetingChannel.requestBackfill("manual");
@@ -5165,6 +5341,7 @@ function SCREEN_SHARE_BLOCKED_REMEDY_FOR(remedy) {
5165
5341
  }
5166
5342
  var PARTICIPANT_ROLE_ATTRIBUTE = "matrx.participant.role";
5167
5343
  var PARTICIPANT_CAN_RECORD_ATTRIBUTE = "matrx.participant.can_record";
5344
+ var CONNECTION_KIND_ATTRIBUTE = "matrx.participant.connection_kind";
5168
5345
  var PARTICIPANT_STANDING_ATTRIBUTE = "matrx.participant.standing";
5169
5346
  var KNOWN_STANDINGS = ["member", "invited", "outsider", "guest"];
5170
5347
  function parseStanding(value) {
@@ -5188,7 +5365,8 @@ function projectParticipant(driver, joinedAt) {
5188
5365
  }
5189
5366
  const userId = typeof metadata.user_id === "string" ? asUserId(metadata.user_id) : null;
5190
5367
  return {
5191
- identity: asParticipantIdentity(driver.identity),
5368
+ // S7: a tile is a PERSON; LiveKit names the tab's connection (`<person>~<tab>`).
5369
+ identity: personOf(driver.identity),
5192
5370
  userId,
5193
5371
  // 🚨 A RAW LIVEKIT IDENTITY IS NEVER A NAME (MRI-A18). Through 0.4.12 this
5194
5372
  // line read `driver.name.length > 0 ? driver.name : driver.identity`, and
@@ -5235,6 +5413,11 @@ function createRoomEngine(options) {
5235
5413
  let ghosts = /* @__PURE__ */ new Map();
5236
5414
  let ghostTimer = null;
5237
5415
  let liveRoster = [];
5416
+ let personConnections = /* @__PURE__ */ new Map();
5417
+ let mainConnection = /* @__PURE__ */ new Map();
5418
+ function connectionsOf(who) {
5419
+ return personConnections.get(personOf(who)) ?? [who];
5420
+ }
5238
5421
  const departures = /* @__PURE__ */ new Map();
5239
5422
  function requestAsNow() {
5240
5423
  if (lastRequest === null) return null;
@@ -5299,7 +5482,12 @@ function createRoomEngine(options) {
5299
5482
  function failJoin(error, operation) {
5300
5483
  const normalized = normalizeMeetError(error, operation);
5301
5484
  store.patch({
5302
- failure: { message: normalized.message, remedy: normalized.remedy, reason: normalized.reason }
5485
+ failure: {
5486
+ message: normalized.message,
5487
+ remedy: normalized.remedy,
5488
+ reason: normalized.reason,
5489
+ retryAt: normalized.retryAfterSeconds === null ? null : now() + normalized.retryAfterSeconds * 1e3
5490
+ }
5303
5491
  });
5304
5492
  store.dispatch({ type: "join_failed", reason: normalized.reason ?? null });
5305
5493
  report(
@@ -5371,10 +5559,10 @@ function createRoomEngine(options) {
5371
5559
  if (store.snapshot().spotlight !== spotlight) store.patch({ spotlight });
5372
5560
  },
5373
5561
  activeSpeakers(identities) {
5374
- store.setSpeaking(identities.map(asParticipantIdentity));
5562
+ store.setSpeaking([...new Set(identities.map((who) => personOf(who)))]);
5375
5563
  },
5376
5564
  quality(who, value) {
5377
- store.setQuality(asParticipantIdentity(who), value);
5565
+ store.setQuality(personOf(who), value);
5378
5566
  },
5379
5567
  connectionState(state) {
5380
5568
  supervisor.driverState(state);
@@ -5394,7 +5582,7 @@ function createRoomEngine(options) {
5394
5582
  );
5395
5583
  return;
5396
5584
  }
5397
- const speakerIdentity = asParticipantIdentity(event.speakerIdentity);
5585
+ const speakerIdentity = personOf(event.speakerIdentity);
5398
5586
  const speaker = snapshot.participants.find(
5399
5587
  (participant) => participant.identity === speakerIdentity
5400
5588
  );
@@ -5468,7 +5656,7 @@ function createRoomEngine(options) {
5468
5656
  );
5469
5657
  return;
5470
5658
  }
5471
- const sender = asParticipantIdentity(from ?? "");
5659
+ const sender = personOf(from ?? "");
5472
5660
  switch (topic) {
5473
5661
  case DATA_TOPICS.hand:
5474
5662
  store.setHand(
@@ -5510,7 +5698,7 @@ function createRoomEngine(options) {
5510
5698
  }
5511
5699
  };
5512
5700
  async function resolveExitAndApply(hint) {
5513
- const connection = current?.identity ?? store.snapshot().localIdentity ?? null;
5701
+ const connection = current?.connectionIdentity ?? current?.identity ?? store.snapshot().localIdentity ?? null;
5514
5702
  lastRequest = requestAsNow();
5515
5703
  await dropRoom();
5516
5704
  let server;
@@ -5548,14 +5736,29 @@ function createRoomEngine(options) {
5548
5736
  }
5549
5737
  async function connect(request) {
5550
5738
  store.patch({ failure: null, waiting: null, roomName: request.roomName, share: NO_SHARE, connection: "stable" });
5739
+ const tabId = options.tabClaim !== void 0 ? await options.tabClaim.tabId : null;
5551
5740
  const token = await tokens.get({
5552
5741
  roomName: request.roomName,
5553
5742
  identity,
5554
5743
  ...request.meetingId !== void 0 ? { meetingId: request.meetingId } : {},
5555
5744
  ...request.slug !== void 0 ? { slug: request.slug } : {},
5556
5745
  ...request.breakoutRoomId != null ? { breakoutRoomId: request.breakoutRoomId } : {},
5557
- ...request.observe === true ? { observe: true } : {}
5746
+ ...request.observe === true ? { observe: true } : {},
5747
+ ...tabId !== null ? { tabId } : {},
5748
+ ...request.secondDevice !== void 0 ? { secondDevice: request.secondDevice } : {}
5558
5749
  });
5750
+ if (token.secondDeviceChoice != null) {
5751
+ store.patch({ secondDevice: { rule: token.secondDeviceChoice }, localIdentity: token.identity });
5752
+ store.dispatch({ type: "second_device" });
5753
+ report("info", `meet: already in this meeting elsewhere \u2014 offering the choice (${token.secondDeviceChoice})`);
5754
+ return;
5755
+ }
5756
+ if (store.snapshot().secondDevice !== null) store.patch({ secondDevice: null });
5757
+ if (request.secondDevice === "switch_here" && lastRequest !== null) {
5758
+ const { secondDevice: _answered, ...rest } = lastRequest;
5759
+ lastRequest = rest;
5760
+ }
5761
+ options.tabClaim?.inRoom(String(request.roomName), String(token.identity));
5559
5762
  current = token;
5560
5763
  lastServerUrl = token.serverUrl;
5561
5764
  connectionPolicy = token.connectionPolicy ?? DEFAULT_CONNECTION_POLICY;
@@ -5620,7 +5823,7 @@ function createRoomEngine(options) {
5620
5823
  if (request.mode === "video") {
5621
5824
  await enableTrack(created, "camera", true);
5622
5825
  }
5623
- if (request.microphone !== false) {
5826
+ if (request.microphone !== false && current?.connectionKind !== "companion") {
5624
5827
  await enableTrack(created, "microphone", true);
5625
5828
  }
5626
5829
  media.onConnected();
@@ -5642,7 +5845,8 @@ function createRoomEngine(options) {
5642
5845
  }
5643
5846
  async function publish(topic, body, to) {
5644
5847
  if (driver === null) return;
5645
- await driver.publishData(topic, JSON.stringify(body), to);
5848
+ const addresses = to === void 0 ? void 0 : to.flatMap((who) => connectionsOf(who));
5849
+ await driver.publishData(topic, JSON.stringify(body), addresses);
5646
5850
  }
5647
5851
  async function applyEffects(target, effects) {
5648
5852
  try {
@@ -5680,8 +5884,19 @@ function createRoomEngine(options) {
5680
5884
  liveRoster = list;
5681
5885
  const merged = mergeGhosts(list, ghosts, at);
5682
5886
  ghosts = merged.kept;
5887
+ const people = /* @__PURE__ */ new Map();
5888
+ const connections = /* @__PURE__ */ new Map();
5889
+ for (const entry of merged.roster) {
5890
+ const person = personOf(entry.identity);
5891
+ connections.set(person, [...connections.get(person) ?? [], entry.identity]);
5892
+ const rank = (entry.isLocal ? 8 : 0) + (entry.reconnecting === true ? 0 : 4) + (entry.attributes[CONNECTION_KIND_ATTRIBUTE] === "companion" ? 0 : 2) + (entry.cameraEnabled || entry.micEnabled ? 1 : 0);
5893
+ const held = people.get(person);
5894
+ if (held === void 0 || rank > held.rank) people.set(person, { entry, rank });
5895
+ }
5896
+ personConnections = connections;
5897
+ mainConnection = new Map([...people].map(([person, { entry }]) => [person, entry.identity]));
5683
5898
  store.setParticipants(
5684
- merged.roster.map((entry) => {
5899
+ [...people.values()].map(({ entry }) => {
5685
5900
  const projected = projectParticipant(entry, joinedAt);
5686
5901
  return entry.reconnecting === true ? { ...projected, reconnecting: true, quality: "lost" } : projected;
5687
5902
  })
@@ -5858,7 +6073,13 @@ function createRoomEngine(options) {
5858
6073
  },
5859
6074
  async setScreenShareEnabled(enabled) {
5860
6075
  if (driver === null) return;
5861
- if (enabled && driver.supportsScreenShare?.() === false) {
6076
+ let unsupported = false;
6077
+ try {
6078
+ unsupported = enabled && driver.supportsScreenShare?.() === false;
6079
+ } catch {
6080
+ unsupported = false;
6081
+ }
6082
+ if (unsupported) {
5862
6083
  store.patch({ share: { state: "refused", reason: "unsupported", remedy: null, at: now() } });
5863
6084
  report("warn", "meet: this browser cannot share a screen", "Share from a desktop browser (Chrome, Edge, Firefox or Safari).");
5864
6085
  return;
@@ -5957,7 +6178,7 @@ function createRoomEngine(options) {
5957
6178
  },
5958
6179
  attach(who, kind, element) {
5959
6180
  if (driver === null) return () => void 0;
5960
- return driver.attach(who, kind, element);
6181
+ return driver.attach(mainConnection.get(personOf(who)) ?? who, kind, element);
5961
6182
  },
5962
6183
  token: () => current,
5963
6184
  async dispose() {
@@ -6691,6 +6912,12 @@ function defaultDiagnostics(event) {
6691
6912
  else if (event.level === "warn") console.warn(line);
6692
6913
  else console.info(line);
6693
6914
  }
6915
+ var claimedTab = null;
6916
+ function pageTabClaim() {
6917
+ if (typeof window === "undefined") return void 0;
6918
+ claimedTab ??= createTabClaim();
6919
+ return claimedTab;
6920
+ }
6694
6921
  function deviceId() {
6695
6922
  const storage = globalThis.localStorage;
6696
6923
  const key = "mx.meet.device";
@@ -6808,6 +7035,8 @@ function MeetRuntime(props) {
6808
7035
  store,
6809
7036
  tokens,
6810
7037
  identity,
7038
+ // S7: this page's tab id (one per page, claimed at load) — the LiveKit identity's tab half.
7039
+ tabClaim: pageTabClaim(),
6811
7040
  resolveMeeting: (slug) => repository.meetingBySlug(slug),
6812
7041
  driverFactory: (handlers) => (driverRef.current ?? createLiveKitDriver)(handlers),
6813
7042
  onDiagnostic: report,
@@ -7883,6 +8112,7 @@ function useRoom() {
7883
8112
  phase: snapshot?.phase ?? "resolving",
7884
8113
  participants: snapshot?.participants ?? [],
7885
8114
  failure: snapshot?.failure ?? null,
8115
+ secondDevice: snapshot?.secondDevice ?? null,
7886
8116
  local,
7887
8117
  canShareScreen: engine?.supportsScreenShare() ?? true,
7888
8118
  join: useCallback2(
@@ -8028,6 +8258,7 @@ function useLobby() {
8028
8258
  const session = host?.meetings ?? null;
8029
8259
  return {
8030
8260
  waiting: (snapshot?.lobby ?? []).filter((entry) => entry.state === "knocking"),
8261
+ declined: (snapshot?.lobby ?? []).filter((entry) => entry.state === "denied"),
8031
8262
  // No runtime means no host powers — `<LobbyPanel>` renders nothing rather
8032
8263
  // than an admit button that would throw.
8033
8264
  canAdmit: session !== null && isHost,
@@ -8049,6 +8280,37 @@ function useLobby() {
8049
8280
  )
8050
8281
  };
8051
8282
  }
8283
+ function useCountdown(untilMs) {
8284
+ const [now, setNow] = useState2(() => Date.now());
8285
+ const active = typeof untilMs === "number" && untilMs > now;
8286
+ useEffect3(() => {
8287
+ if (!active) return void 0;
8288
+ setNow(Date.now());
8289
+ const timer = setInterval(() => setNow(Date.now()), 1e3);
8290
+ return () => clearInterval(timer);
8291
+ }, [active, untilMs]);
8292
+ if (typeof untilMs !== "number") return null;
8293
+ return Math.max(0, untilMs - now);
8294
+ }
8295
+ function useKnockNotice() {
8296
+ const lobby = useLobby();
8297
+ const [dismissed, setDismissed] = useState2([]);
8298
+ const keyOf = (entry) => `${entry.identity}@${entry.knockedAt}`;
8299
+ const open = lobby.canAdmit ? lobby.waiting.filter((entry) => !dismissed.includes(keyOf(entry))) : [];
8300
+ const knocker = open[0] ?? null;
8301
+ return {
8302
+ knocker,
8303
+ others: Math.max(0, open.length - 1),
8304
+ admit: useCallback2(async () => {
8305
+ if (knocker === null) return;
8306
+ setDismissed((list) => [...list, keyOf(knocker)]);
8307
+ await lobby.admit(knocker);
8308
+ }, [knocker, lobby.admit]),
8309
+ dismiss: useCallback2(() => {
8310
+ if (knocker !== null) setDismissed((list) => [...list, keyOf(knocker)]);
8311
+ }, [knocker])
8312
+ };
8313
+ }
8052
8314
  function useWaiting() {
8053
8315
  const host = useMeetHost();
8054
8316
  const snapshot = useMeetSnapshot();
@@ -10798,6 +11060,7 @@ function ControlBar({ onLeave }) {
10798
11060
  }
10799
11061
  ) }) }) : null,
10800
11062
  /* @__PURE__ */ jsx6(BreakoutBanner, {}),
11063
+ panel !== "people" ? /* @__PURE__ */ jsx6(KnockNotice, { onView: () => setPanel("people") }) : null,
10801
11064
  controls.notice !== null && panel !== "people" ? /* @__PURE__ */ jsx6(Toast, { text: controls.notice.text, failed: controls.notice.failed, onDone: controls.clearNotice }) : null,
10802
11065
  /* @__PURE__ */ jsxs3("div", { className: "mx-meet__controls", children: [
10803
11066
  /* @__PURE__ */ jsx6(
@@ -11128,10 +11391,21 @@ function MediaNotices() {
11128
11391
  const name = (kind) => kind === "microphone" ? "Microphone" : "Camera";
11129
11392
  if (chips.length === 0 && region.audioOutput !== "blocked" && notice === null && offer === null) return null;
11130
11393
  return /* @__PURE__ */ jsxs3("div", { className: "mx-meet__media-notices", children: [
11131
- region.audioOutput === "blocked" ? /* @__PURE__ */ jsxs3("div", { className: "mx-meet__media-pill", role: "alert", "data-meet-notice": "audio-blocked", children: [
11132
- /* @__PURE__ */ jsx6("span", { children: "Sound is paused in this tab" }),
11133
- /* @__PURE__ */ jsx6("button", { type: "button", className: "mx-meet__primary mx-meet__primary--small", onClick: () => void enableAudio(), children: "Enable sound" })
11134
- ] }) : null,
11394
+ region.audioOutput === "blocked" ? /* @__PURE__ */ jsxs3(
11395
+ ErrorBox,
11396
+ {
11397
+ as: "div",
11398
+ className: "mx-meet__media-pill",
11399
+ "data-meet-notice": "audio-blocked",
11400
+ message: "Sound is paused in this tab",
11401
+ operation: "Enable meeting sound",
11402
+ origin: "meet/MediaNotices",
11403
+ children: [
11404
+ /* @__PURE__ */ jsx6("span", { children: "Sound is paused in this tab" }),
11405
+ /* @__PURE__ */ jsx6("button", { type: "button", className: "mx-meet__primary mx-meet__primary--small", onClick: () => void enableAudio(), children: "Enable sound" })
11406
+ ]
11407
+ }
11408
+ ) : null,
11135
11409
  chips.map(({ kind, reason }) => /* @__PURE__ */ jsxs3("div", { className: "mx-meet__media-pill", role: "status", "data-meet-notice": deviceNoticeCode(kind, reason), children: [
11136
11410
  /* @__PURE__ */ jsx6("span", { children: deviceChipLabel(kind, reason) }),
11137
11411
  /* @__PURE__ */ jsx6(
@@ -11931,9 +12205,68 @@ function useMicrophoneStream(deviceId2) {
11931
12205
  }, [deviceId2]);
11932
12206
  return stream;
11933
12207
  }
12208
+ function KnockNotice({ onView }) {
12209
+ const { knocker, others, admit, dismiss } = useKnockNotice();
12210
+ if (knocker === null) return null;
12211
+ return /* @__PURE__ */ jsxs3("div", { className: "mx-meet__notice-bar", role: "status", "data-meet-notice": "knock-received", "data-meet-participant-name": knocker.displayName, children: [
12212
+ /* @__PURE__ */ jsxs3("span", { children: [
12213
+ knocker.displayName,
12214
+ " wants to join",
12215
+ others > 0 ? ` (+${others} more)` : ""
12216
+ ] }),
12217
+ /* @__PURE__ */ jsx6("button", { type: "button", className: "mx-meet__link", onClick: () => void admit().catch(() => void 0), children: "Admit" }),
12218
+ /* @__PURE__ */ jsx6(
12219
+ "button",
12220
+ {
12221
+ type: "button",
12222
+ className: "mx-meet__link",
12223
+ onClick: () => {
12224
+ dismiss();
12225
+ onView();
12226
+ },
12227
+ children: "View"
12228
+ }
12229
+ ),
12230
+ /* @__PURE__ */ jsx6("button", { type: "button", className: "mx-meet__toast-close", "aria-label": "Dismiss", onClick: dismiss, children: /* @__PURE__ */ jsx6(CloseIcon, { width: 14, height: 14 }) })
12231
+ ] });
12232
+ }
11934
12233
  function LobbyPanel() {
11935
12234
  const lobby = useLobby();
11936
- if (!lobby.canAdmit || lobby.waiting.length === 0) return null;
12235
+ if (!lobby.canAdmit || lobby.waiting.length === 0 && lobby.declined.length === 0) return null;
12236
+ return /* @__PURE__ */ jsxs3(Fragment, { children: [
12237
+ lobby.waiting.length > 0 ? /* @__PURE__ */ jsx6(WaitingList, { lobby }) : null,
12238
+ lobby.declined.length > 0 ? /* @__PURE__ */ jsx6(DeclinedList, { lobby }) : null
12239
+ ] });
12240
+ }
12241
+ function DeclinedList({ lobby }) {
12242
+ return /* @__PURE__ */ jsxs3("section", { className: "mx-meet__lobby", "aria-label": "Declined requests", "data-meet-declined": "", children: [
12243
+ /* @__PURE__ */ jsx6("header", { className: "mx-meet__lobby-head", children: /* @__PURE__ */ jsx6("h3", { className: "mx-meet__lobby-title", children: "Declined" }) }),
12244
+ /* @__PURE__ */ jsx6("ul", { className: "mx-meet__lobby-list", children: lobby.declined.map((entry) => /* @__PURE__ */ jsxs3(
12245
+ "li",
12246
+ {
12247
+ className: "mx-meet__lobby-row",
12248
+ "data-meet-declined-entry": "",
12249
+ "data-meet-participant-name": entry.displayName,
12250
+ children: [
12251
+ /* @__PURE__ */ jsx6(Avatar, { name: entry.displayName, url: entry.avatarUrl, size: 32 }),
12252
+ /* @__PURE__ */ jsx6("span", { className: "mx-meet__person-name", children: entry.displayName }),
12253
+ /* @__PURE__ */ jsx6(
12254
+ "button",
12255
+ {
12256
+ type: "button",
12257
+ className: "mx-meet__link",
12258
+ "aria-label": `Let ${entry.displayName} ask again`,
12259
+ onClick: () => void lobby.undeny(entry),
12260
+ children: "Let them ask again"
12261
+ }
12262
+ )
12263
+ ]
12264
+ },
12265
+ entry.identity
12266
+ )) })
12267
+ ] });
12268
+ }
12269
+ function WaitingList({ lobby }) {
11937
12270
  return /* @__PURE__ */ jsxs3("section", { className: "mx-meet__lobby", "aria-label": "Waiting room", children: [
11938
12271
  /* @__PURE__ */ jsxs3("header", { className: "mx-meet__lobby-head", children: [
11939
12272
  /* @__PURE__ */ jsx6("h3", { className: "mx-meet__lobby-title", children: lobby.waiting.length === 1 ? "1 person is waiting" : `${lobby.waiting.length} people are waiting` }),
@@ -12197,6 +12530,21 @@ function PreJoinBody({
12197
12530
  useEffect6(() => {
12198
12531
  void host.devices.refresh();
12199
12532
  }, [host]);
12533
+ const joinWith = (secondDevice) => {
12534
+ setJoining(true);
12535
+ preview.release();
12536
+ void room.join({
12537
+ roomName,
12538
+ // An observer publishes nothing: no camera, no microphone (MD-16). A companion
12539
+ // ("join here too") never opens the microphone (S7).
12540
+ mode: observe2 ? "audio" : mode,
12541
+ microphone: observe2 || secondDevice === "join_too" ? false : microphone,
12542
+ ...meetingId !== void 0 ? { meetingId } : {},
12543
+ ...slug !== void 0 ? { slug } : {},
12544
+ ...observe2 ? { observe: true } : {},
12545
+ ...secondDevice !== void 0 ? { secondDevice } : {}
12546
+ }).then(() => onJoined?.()).catch(() => void 0).finally(() => setJoining(false));
12547
+ };
12200
12548
  return /* @__PURE__ */ jsxs3("div", { className: "mx-meet__prejoin", children: [
12201
12549
  /* @__PURE__ */ jsxs3("div", { className: "mx-meet__preview", children: [
12202
12550
  mode === "video" && preview.preview !== null ? /* @__PURE__ */ jsx6(
@@ -12281,27 +12629,34 @@ function PreJoinBody({
12281
12629
  ),
12282
12630
  /* @__PURE__ */ jsx6(Label, { htmlFor: "mx-meet-join-mic", children: "Join with microphone" })
12283
12631
  ] }),
12284
- /* @__PURE__ */ jsx6(
12285
- Button,
12286
- {
12287
- type: "button",
12288
- className: "mx-meet__join",
12289
- disabled: joining,
12290
- onClick: () => {
12291
- setJoining(true);
12292
- preview.release();
12293
- void room.join({
12294
- roomName,
12295
- // An observer publishes nothing: no camera, no microphone (MD-16).
12296
- mode: observe2 ? "audio" : mode,
12297
- microphone: observe2 ? false : microphone,
12298
- ...meetingId !== void 0 ? { meetingId } : {},
12299
- ...slug !== void 0 ? { slug } : {},
12300
- ...observe2 ? { observe: true } : {}
12301
- }).then(() => onJoined?.()).catch(() => void 0).finally(() => setJoining(false));
12302
- },
12303
- children: joining ? "Joining\u2026" : observe2 ? "Observe" : "Join now"
12304
- }
12632
+ room.secondDevice === null ? /* @__PURE__ */ jsx6(Button, { type: "button", className: "mx-meet__join", disabled: joining, onClick: () => joinWith(), children: joining ? "Joining\u2026" : observe2 ? "Observe" : "Join now" }) : (
12633
+ // S7: ALREADY IN THIS MEETING on another tab or device — the meeting's choice
12634
+ // (Meet: Switch here / Join here too; Teams: Transfer here / Add this device).
12635
+ /* @__PURE__ */ jsxs3("div", { className: "mx-meet__second-device", "data-meet-region": "second-device", children: [
12636
+ /* @__PURE__ */ jsx6("p", { className: "mx-meet__hint", children: "You're in this meeting on another device" }),
12637
+ /* @__PURE__ */ jsx6(
12638
+ Button,
12639
+ {
12640
+ type: "button",
12641
+ className: "mx-meet__join",
12642
+ "data-meet-control": "switch-here",
12643
+ disabled: joining,
12644
+ onClick: () => joinWith("switch_here"),
12645
+ children: room.secondDevice.rule === "ask_transfer_or_add" ? "Transfer here" : "Switch here"
12646
+ }
12647
+ ),
12648
+ /* @__PURE__ */ jsx6(
12649
+ Button,
12650
+ {
12651
+ type: "button",
12652
+ variant: "outline",
12653
+ "data-meet-control": "join-too",
12654
+ disabled: joining,
12655
+ onClick: () => joinWith("join_too"),
12656
+ children: room.secondDevice.rule === "ask_transfer_or_add" ? "Add this device" : "Join here too"
12657
+ }
12658
+ )
12659
+ ] })
12305
12660
  )
12306
12661
  ] }),
12307
12662
  room.failure !== null ? /* @__PURE__ */ jsxs3("p", { className: "mx-meet__error", children: [
@@ -12499,16 +12854,21 @@ var REFUSED_TITLES = {
12499
12854
  not_invited: "You aren't invited to this meeting",
12500
12855
  locked: "This meeting is locked",
12501
12856
  removed: "You can't rejoin this meeting",
12502
- lobby_rejects: "You can't ask to join this meeting",
12857
+ lobby_rejects: "The host declined your request",
12503
12858
  meeting_full: "This meeting is full",
12504
12859
  platform_capacity: "Meetings are busy right now",
12505
12860
  verification_required: "Verify your account to join",
12506
12861
  registration_required: "Register to join this meeting"
12507
12862
  };
12863
+ function clockText(ms2) {
12864
+ const total = Math.ceil(ms2 / 1e3);
12865
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
12866
+ }
12508
12867
  function ExitScreen({ onLeave }) {
12509
12868
  const { phase, reason, host, snapshot, actions, call, connection } = useMeetCore();
12510
12869
  const [busy, setBusy] = useState4(false);
12511
12870
  const failure = snapshot?.failure ?? null;
12871
+ const retryLeft = useCountdown(phase === "refused:lobby_rejects" ? failure?.retryAt ?? null : null);
12512
12872
  const rejoin = /* @__PURE__ */ jsx6(
12513
12873
  Button,
12514
12874
  {
@@ -12551,6 +12911,33 @@ function ExitScreen({ onLeave }) {
12551
12911
  /* @__PURE__ */ jsx6(Button, { type: "button", variant: "outline", onClick: actions.retry, children: "Try again" }),
12552
12912
  home
12553
12913
  ] });
12914
+ if (code === "lobby_rejects") {
12915
+ if (retryLeft !== null && retryLeft > 0) {
12916
+ line = `You can ask again in ${clockText(retryLeft)}`;
12917
+ notice = "reknock-cooldown";
12918
+ actionsNode = home;
12919
+ } else if (retryLeft === 0) {
12920
+ line = "You can ask to join again";
12921
+ actionsNode = /* @__PURE__ */ jsxs3(Fragment, { children: [
12922
+ /* @__PURE__ */ jsx6(
12923
+ Button,
12924
+ {
12925
+ type: "button",
12926
+ disabled: busy,
12927
+ onClick: () => {
12928
+ setBusy(true);
12929
+ void actions.rejoin().catch(() => void 0).finally(() => setBusy(false));
12930
+ },
12931
+ children: "Ask to join again"
12932
+ }
12933
+ ),
12934
+ home
12935
+ ] });
12936
+ } else {
12937
+ line = "Ask the host to let you in, then open the link again";
12938
+ actionsNode = home;
12939
+ }
12940
+ }
12554
12941
  } else {
12555
12942
  switch (phase) {
12556
12943
  case "left":
@@ -12611,7 +12998,7 @@ function ExitScreen({ onLeave }) {
12611
12998
  /* @__PURE__ */ jsx6(Avatar, { name: host?.identity.displayName ?? "", url: host?.identity.avatarUrl ?? null, size: 72 }),
12612
12999
  /* @__PURE__ */ jsx6("h1", { className: "mx-meet__prejoin-title", children: title }),
12613
13000
  line !== null ? /* @__PURE__ */ jsx6("p", { className: "mx-meet__empty-body", children: line }) : null,
12614
- notice !== null ? /* @__PURE__ */ jsx6("span", { role: "status", "data-meet-notice": notice, className: "mx-meet__sr-only", children: title }) : null,
13001
+ notice !== null ? /* @__PURE__ */ jsx6("span", { role: "status", "data-meet-notice": notice, className: "mx-meet__sr-only", children: notice === "reknock-cooldown" && line !== null ? line : title }) : null,
12615
13002
  /* @__PURE__ */ jsx6("div", { className: "mx-meet__waiting-actions", children: actionsNode })
12616
13003
  ] });
12617
13004
  }
@@ -12620,6 +13007,7 @@ function WaitingRoom({ host, onLeave }) {
12620
13007
  const { phaseNotice } = useMeetCore();
12621
13008
  const [busy, setBusy] = useState4(false);
12622
13009
  const admission = waiting?.admission ?? "knocking";
13010
+ const reknockLeft = useCountdown(admission === "denied" ? waiting?.reknockAt ?? null : null);
12623
13011
  const run = (action) => () => {
12624
13012
  setBusy(true);
12625
13013
  void action().catch(() => void 0).finally(() => setBusy(false));
@@ -12627,6 +13015,7 @@ function WaitingRoom({ host, onLeave }) {
12627
13015
  const home = /* @__PURE__ */ jsx6(Button, { type: "button", variant: "outline", onClick: () => onLeave !== void 0 ? onLeave() : window.location.assign("/"), children: "Back to home" });
12628
13016
  let title;
12629
13017
  let line = null;
13018
+ let cooldownNotice = false;
12630
13019
  let actions;
12631
13020
  switch (admission) {
12632
13021
  case "waiting_host":
@@ -12635,8 +13024,12 @@ function WaitingRoom({ host, onLeave }) {
12635
13024
  break;
12636
13025
  case "denied":
12637
13026
  title = "The host declined your request";
13027
+ if (reknockLeft !== null && reknockLeft > 0) {
13028
+ line = `You can ask again in ${clockText(reknockLeft)}`;
13029
+ cooldownNotice = true;
13030
+ }
12638
13031
  actions = /* @__PURE__ */ jsxs3(Fragment, { children: [
12639
- waiting?.reknockAllowed === true ? /* @__PURE__ */ jsx6(Button, { type: "button", disabled: busy, onClick: run(askAgain), children: "Ask to join again" }) : null,
13032
+ waiting?.reknockAllowed === true || waiting?.reknockAt != null && reknockLeft === 0 ? /* @__PURE__ */ jsx6(Button, { type: "button", disabled: busy, onClick: run(askAgain), children: "Ask to join again" }) : null,
12640
13033
  home
12641
13034
  ] });
12642
13035
  break;
@@ -12667,7 +13060,15 @@ function WaitingRoom({ host, onLeave }) {
12667
13060
  /* @__PURE__ */ jsx6(Avatar, { name: host.identity.displayName, url: host.identity.avatarUrl, size: 72 }),
12668
13061
  /* @__PURE__ */ jsx6("h1", { className: "mx-meet__prejoin-title", children: title }),
12669
13062
  phaseNotice === "sent-to-waiting" ? /* @__PURE__ */ jsx6("p", { className: "mx-meet__empty-body", role: "status", "data-meet-notice": "sent-to-waiting", children: "The host moved you back to the waiting room" }) : null,
12670
- line !== null ? /* @__PURE__ */ jsx6("p", { className: "mx-meet__empty-body", role: "status", "data-meet-notice": "host-absent", children: line }) : null,
13063
+ line !== null ? /* @__PURE__ */ jsx6(
13064
+ "p",
13065
+ {
13066
+ className: "mx-meet__empty-body",
13067
+ role: "status",
13068
+ "data-meet-notice": cooldownNotice ? "reknock-cooldown" : "host-absent",
13069
+ children: line
13070
+ }
13071
+ ) : null,
12671
13072
  /* @__PURE__ */ jsx6("div", { className: "mx-meet__waiting-actions", children: actions })
12672
13073
  ] });
12673
13074
  }