@ai-matrx/meet 0.9.0 → 0.10.0

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
@@ -322,7 +322,17 @@ var MEET_ROUTES = {
322
322
  */
323
323
  collab: "/api/v1/meet/collab",
324
324
  /** Meet wave 5: translate finished caption lines into this viewer's language. */
325
- captionsTranslate: "/api/v1/meet/captions/translate"
325
+ captionsTranslate: "/api/v1/meet/captions/translate",
326
+ /**
327
+ * S4 — THE STATE READ (CORE-DESIGN §2.6): `GET ?meeting_id=` with `x-meet-pass`. This
328
+ * person's admission (lazy expiry applied), whether anyone who can admit is here, the run's
329
+ * phase. Read every `waiting_poll_seconds` while waiting and on every `mx.state.changed`.
330
+ */
331
+ state: "/api/v1/meet/state",
332
+ /** S4: `withdraw` (stop waiting) or `leave` (close this tab); the pass may ride in the body. */
333
+ leave: "/api/v1/meet/leave",
334
+ /** S4: a pass holder (guests included) saw the recording notice, for this run. */
335
+ consent: "/api/v1/meet/consent"
326
336
  };
327
337
  function meetingPath(template, meetingId) {
328
338
  return template.replace("{meeting_id}", encodeURIComponent(meetingId));
@@ -396,6 +406,8 @@ function parseRoomToken(body, roomName) {
396
406
  meetingPassExpiresAt: readString(payload, "meeting_pass_expires_at"),
397
407
  sessionId: readString(payload, "session_id"),
398
408
  lobby: readBoolean(payload, "lobby", false),
409
+ admissionState: readString(payload, "admission_state") ?? void 0,
410
+ pollSeconds: typeof payload["poll_seconds"] === "number" ? payload["poll_seconds"] : void 0,
399
411
  breakoutRoomId: readString(payload, "breakout_room_id") ?? null
400
412
  };
401
413
  }
@@ -983,14 +995,15 @@ var MEET_EVENTS = {
983
995
  cancel: "mx.call.cancel",
984
996
  /** You answered (or declined) on another device. Stop ringing here. */
985
997
  settled: "mx.call.settled",
986
- /** Someone is in the lobby. Hosts render the knock. */
987
- knock: "mx.lobby.knock",
988
- /** A host decided. The knocker learns it here — they have no DB read. */
989
- admission: "mx.lobby.admission",
990
- /** The room was locked or unlocked. */
991
- lock: "mx.room.lock",
992
- /** Recording started or stopped. EVERY participant renders the indicator. */
993
- recording: "mx.room.recording"
998
+ /**
999
+ * 🚨 S4 (CORE-DESIGN §2.6): the ONE room-channel event — payload-free "something changed,
1000
+ * read again". Nothing a person must see rides it: a waiting person learns admitted /
1001
+ * denied / expired / ended by READING their row (`GET /v1/meet/state`) on a timer and on
1002
+ * every hint. The four payload broadcasts (`mx.lobby.knock`, `mx.lobby.admission`,
1003
+ * `mx.room.lock`, `mx.room.recording`) are gone with their senders and receivers. The
1004
+ * server publishes the same event on the same topic (aidream `admission.room_topic`).
1005
+ */
1006
+ stateChanged: "mx.state.changed"
994
1007
  };
995
1008
 
996
1009
  // src/core/types.ts
@@ -1016,6 +1029,7 @@ function isLinkFollower(standing) {
1016
1029
  function isHostRole(role) {
1017
1030
  return role === "host" || role === "cohost";
1018
1031
  }
1032
+ var WAITING_ADMISSION_STATES = ["waiting_host", "knocking"];
1019
1033
 
1020
1034
  // src/core/calls.ts
1021
1035
  var TRANSITIONS = {
@@ -2031,6 +2045,7 @@ var NO_GRANTS = {
2031
2045
  };
2032
2046
  var EMPTY = {
2033
2047
  phase: "idle",
2048
+ waiting: null,
2034
2049
  meeting: null,
2035
2050
  localIdentity: null,
2036
2051
  grants: NO_GRANTS,
@@ -2287,19 +2302,25 @@ function roleOf(participants, identity) {
2287
2302
  return participants.find((p) => p.identity === identity)?.role ?? "participant";
2288
2303
  }
2289
2304
  var END_MEETING_NOT_SERVED = "Ending a meeting is not available on this server yet";
2290
- function lobbyEntryFrom(attendee) {
2291
- if (attendee.admissionState !== "knocking") return null;
2305
+ function lobbyEntryFrom(attendee, now = Date.now()) {
2306
+ if (!WAITING_ADMISSION_STATES.includes(attendee.admissionState)) return null;
2307
+ const expires = attendee.knockExpiresAt ? Date.parse(attendee.knockExpiresAt) : Number.NaN;
2308
+ if (!Number.isNaN(expires) && expires <= now) return null;
2292
2309
  return {
2293
2310
  identity: attendee.identity,
2294
2311
  displayName: attendee.displayName,
2295
2312
  avatarUrl: attendee.avatarUrl,
2296
2313
  userId: attendee.userId,
2297
2314
  state: "knocking",
2298
- knockedAt: attendee.joinedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2315
+ knockedAt: attendee.knockedAt ?? attendee.joinedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2316
+ knockExpiresAt: attendee.knockExpiresAt ?? null,
2299
2317
  decidedAt: null,
2300
2318
  decidedBy: null
2301
2319
  };
2302
2320
  }
2321
+ function decidedState(state) {
2322
+ return WAITING_ADMISSION_STATES.includes(state) ? "expired" : state;
2323
+ }
2303
2324
  function createMeetingSession(options) {
2304
2325
  const { manager, repository, store, identity } = options;
2305
2326
  let channel = null;
@@ -2323,11 +2344,6 @@ function createMeetingSession(options) {
2323
2344
  }
2324
2345
  return identity.userId;
2325
2346
  }
2326
- function knockIdentity() {
2327
- return asParticipantIdentity(
2328
- identity.userId !== null ? `user:${identity.userId}` : `guest:${identity.deviceId}`
2329
- );
2330
- }
2331
2347
  function requireMeeting(operation) {
2332
2348
  const meeting = store.snapshot().meeting;
2333
2349
  if (meeting === null) {
@@ -2339,6 +2355,191 @@ function createMeetingSession(options) {
2339
2355
  }
2340
2356
  return meeting;
2341
2357
  }
2358
+ let polling = false;
2359
+ let pollTimer = null;
2360
+ let reading = null;
2361
+ let admittedOnce = false;
2362
+ let unlisten = null;
2363
+ function report(level, message, remedy) {
2364
+ options.onDiagnostic?.(remedy === void 0 ? { level, message } : { level, message, remedy });
2365
+ }
2366
+ function pollMs() {
2367
+ const seconds = store.snapshot().waiting?.pollSeconds ?? 3;
2368
+ return Math.max(1, Math.min(60, seconds)) * 1e3;
2369
+ }
2370
+ function schedule(ms2) {
2371
+ if (pollTimer !== null) clearTimeout(pollTimer);
2372
+ if (!polling) return;
2373
+ pollTimer = setTimeout(() => {
2374
+ pollTimer = null;
2375
+ void readState2().finally(() => schedule(pollMs()));
2376
+ }, ms2);
2377
+ }
2378
+ function listen() {
2379
+ if (unlisten !== null || typeof window === "undefined") return;
2380
+ const nudge = () => {
2381
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
2382
+ void readState2();
2383
+ };
2384
+ const goodbye = () => {
2385
+ const meeting = store.snapshot().meeting;
2386
+ const pass = options.resolvePass?.() ?? null;
2387
+ const waiting = store.snapshot().waiting;
2388
+ if (meeting === null || pass === null || waiting === null) return;
2389
+ if (!WAITING_ADMISSION_STATES.includes(waiting.admission)) return;
2390
+ try {
2391
+ navigator.sendBeacon?.(
2392
+ options.api.url(MEET_ROUTES.leave),
2393
+ new Blob([JSON.stringify({ meeting_id: meeting.id, intent: "withdraw", meeting_pass: pass })], {
2394
+ type: "application/json"
2395
+ })
2396
+ );
2397
+ } catch {
2398
+ }
2399
+ };
2400
+ window.addEventListener("online", nudge);
2401
+ window.addEventListener("pagehide", goodbye);
2402
+ document.addEventListener("visibilitychange", nudge);
2403
+ unlisten = () => {
2404
+ window.removeEventListener("online", nudge);
2405
+ window.removeEventListener("pagehide", goodbye);
2406
+ document.removeEventListener("visibilitychange", nudge);
2407
+ };
2408
+ }
2409
+ function stopWaiting() {
2410
+ polling = false;
2411
+ if (pollTimer !== null) clearTimeout(pollTimer);
2412
+ pollTimer = null;
2413
+ unlisten?.();
2414
+ unlisten = null;
2415
+ }
2416
+ function patchWaiting(next) {
2417
+ const current = store.snapshot().waiting;
2418
+ const base = current ?? {
2419
+ admission: "knocking",
2420
+ admittersPresent: null,
2421
+ reknockAllowed: true,
2422
+ knockExpiresAt: null,
2423
+ pollSeconds: 3,
2424
+ liveVersion: 0
2425
+ };
2426
+ store.patch({ waiting: { ...base, ...next } });
2427
+ }
2428
+ function apply(read) {
2429
+ const snapshot = store.snapshot();
2430
+ if (snapshot.phase !== "lobby") return;
2431
+ const admission = read.admission_state ?? null;
2432
+ const ended = admission === "ended" || read.session_phase === "ended" || read.meeting_phase === "ended";
2433
+ if (admission === "admitted" && !ended) {
2434
+ if (admittedOnce) return;
2435
+ admittedOnce = true;
2436
+ stopWaiting();
2437
+ options.onAdmitted?.();
2438
+ return;
2439
+ }
2440
+ if (ended) {
2441
+ stopWaiting();
2442
+ patchWaiting({ admission: "ended" });
2443
+ const meeting = snapshot.meeting;
2444
+ if (read.meeting_phase === "ended" && meeting !== null && meeting.endedAt === null) {
2445
+ store.patch({ meeting: { ...meeting, endedAt: (/* @__PURE__ */ new Date()).toISOString() } });
2446
+ }
2447
+ return;
2448
+ }
2449
+ if (admission === null) return;
2450
+ patchWaiting({
2451
+ admission,
2452
+ admittersPresent: read.admitters_present ?? null,
2453
+ reknockAllowed: read.reknock_allowed ?? true,
2454
+ knockExpiresAt: read.knock_expires_at ?? null,
2455
+ pollSeconds: read.poll_seconds ?? snapshot.waiting?.pollSeconds ?? 3,
2456
+ liveVersion: read.live_version ?? 0
2457
+ });
2458
+ if (admission === "denied" || admission === "expired" || admission === "withdrawn" || admission === "removed") {
2459
+ stopWaiting();
2460
+ }
2461
+ }
2462
+ async function readState2() {
2463
+ if (reading !== null) return reading;
2464
+ const meeting = store.snapshot().meeting;
2465
+ const pass = options.resolvePass?.() ?? null;
2466
+ if (meeting === null || pass === null) return;
2467
+ reading = (async () => {
2468
+ try {
2469
+ const response = await options.api.request({
2470
+ path: `${MEET_ROUTES.state}?meeting_id=${encodeURIComponent(meeting.id)}`,
2471
+ method: "GET",
2472
+ requireAuth: false,
2473
+ operation: "meet.state",
2474
+ headers: { [MEETING_PASS_HEADER]: pass }
2475
+ });
2476
+ if (!response.ok) {
2477
+ let body = null;
2478
+ try {
2479
+ body = await response.json();
2480
+ } catch {
2481
+ body = null;
2482
+ }
2483
+ const refusal2 = readServerRefusal(body);
2484
+ report(
2485
+ "warn",
2486
+ refusal2?.reason ?? `meet.state: the server answered ${response.status}`,
2487
+ refusal2?.remedy ?? "The waiting room keeps asking; nothing on this screen changes until the server answers."
2488
+ );
2489
+ return;
2490
+ }
2491
+ apply(await response.json());
2492
+ } catch (error) {
2493
+ report("info", `meet.state: ${String(error)}`, "Waiting continues; the state is read again shortly.");
2494
+ } finally {
2495
+ reading = null;
2496
+ }
2497
+ })();
2498
+ return reading;
2499
+ }
2500
+ async function postLeave(intent) {
2501
+ const meeting = requireMeeting(`meet.${intent}`);
2502
+ const pass = options.resolvePass?.() ?? null;
2503
+ const response = await options.api.request({
2504
+ path: MEET_ROUTES.leave,
2505
+ method: "POST",
2506
+ requireAuth: false,
2507
+ operation: `meet.${intent}`,
2508
+ body: { meeting_id: meeting.id, intent },
2509
+ ...pass !== null ? { headers: { [MEETING_PASS_HEADER]: pass } } : {}
2510
+ });
2511
+ if (!response.ok) {
2512
+ let body = null;
2513
+ try {
2514
+ body = await response.json();
2515
+ } catch {
2516
+ body = null;
2517
+ }
2518
+ const refusal2 = readServerRefusal(body);
2519
+ throw new MeetError(
2520
+ "transport",
2521
+ refusal2?.reason ?? `meet.${intent}: the server answered ${response.status}`,
2522
+ refusal2?.remedy ?? "You are still in the waiting room. Try again."
2523
+ );
2524
+ }
2525
+ }
2526
+ function hint() {
2527
+ channel?.send(MEET_EVENTS.stateChanged, { v: null });
2528
+ }
2529
+ async function refreshQueue() {
2530
+ const meeting = store.snapshot().meeting;
2531
+ if (meeting === null || isGuest(identity) || !isHostRole(effectiveRole(store.snapshot()))) return;
2532
+ try {
2533
+ const attendees = await repository.attendees(meeting.id);
2534
+ store.learn(attendees);
2535
+ for (const attendee of attendees) {
2536
+ const entry = lobbyEntryFrom(attendee);
2537
+ if (entry !== null) store.upsertLobby(entry);
2538
+ else store.setAdmission(attendee.identity, decidedState(attendee.admissionState), null);
2539
+ }
2540
+ } catch {
2541
+ }
2542
+ }
2342
2543
  return {
2343
2544
  watch(roomName) {
2344
2545
  channel?.close();
@@ -2346,58 +2547,22 @@ function createMeetingSession(options) {
2346
2547
  topic: roomTopic(roomName),
2347
2548
  broadcast: [
2348
2549
  {
2349
- event: MEET_EVENTS.knock,
2350
- onMessage: (message) => {
2351
- store.upsertLobby(message.data);
2352
- }
2353
- },
2354
- {
2355
- event: MEET_EVENTS.admission,
2356
- onMessage: (message) => {
2357
- const verdict = message.data;
2358
- const decided = asParticipantIdentity(verdict.identity);
2359
- store.setAdmission(
2360
- decided,
2361
- verdict.admitted ? "admitted" : "denied",
2362
- verdict.by === null ? null : verdict.by
2363
- );
2364
- if (decided === knockIdentity()) {
2365
- if (verdict.admitted) options.onAdmitted?.();
2366
- else options.onDenied?.();
2367
- }
2368
- }
2369
- },
2370
- {
2371
- event: MEET_EVENTS.lock,
2372
- onMessage: (message) => {
2373
- const { locked } = message.data;
2374
- const meeting = store.snapshot().meeting;
2375
- store.patch({
2376
- locked,
2377
- ...meeting !== null ? { meeting: { ...meeting, locked } } : {}
2378
- });
2379
- }
2380
- },
2381
- {
2382
- event: MEET_EVENTS.recording,
2383
- onMessage: (message) => {
2384
- const payload = message.data;
2385
- store.setRecording(
2386
- payload.state,
2387
- payload.by === null ? null : payload.by,
2388
- "room"
2389
- );
2550
+ // 🚨 PAYLOAD-FREE. Every receiver reads again; a spoofed hint only causes a read.
2551
+ event: MEET_EVENTS.stateChanged,
2552
+ onMessage: () => {
2553
+ if (store.snapshot().phase === "lobby") void readState2();
2554
+ void refreshQueue();
2390
2555
  }
2391
2556
  }
2392
2557
  ],
2393
- // THE BACKFILL DOOR. A host whose socket blipped must not miss a knock:
2394
- // re-reading the meeting restores lock/recording truth, and knockers
2395
- // re-announce on their own timer.
2558
+ // THE BACKFILL DOOR. A socket blip must not lose a decision: re-read the meeting
2559
+ // row, and a waiting tab re-reads its own state.
2396
2560
  onBackfill: async () => {
2397
2561
  const meeting = store.snapshot().meeting;
2398
2562
  if (meeting === null) return;
2399
2563
  const fresh = isGuest(identity) ? await repository.meetingBySlug(meeting.slug) : await readMeeting(meeting.id);
2400
2564
  store.patch({ meeting: fresh, locked: fresh.locked });
2565
+ if (store.snapshot().phase === "lobby") void readState2();
2401
2566
  }
2402
2567
  });
2403
2568
  },
@@ -2458,7 +2623,7 @@ function createMeetingSession(options) {
2458
2623
  store.upsertLobby(entry);
2459
2624
  return;
2460
2625
  }
2461
- store.setAdmission(attendee.identity, attendee.admissionState, null);
2626
+ store.setAdmission(attendee.identity, decidedState(attendee.admissionState), null);
2462
2627
  }
2463
2628
  },
2464
2629
  {
@@ -2510,62 +2675,75 @@ function createMeetingSession(options) {
2510
2675
  if (entry !== null) {
2511
2676
  store.upsertLobby(entry);
2512
2677
  } else {
2513
- store.setAdmission(attendee.identity, attendee.admissionState, null);
2678
+ store.setAdmission(attendee.identity, decidedState(attendee.admissionState), null);
2514
2679
  }
2515
2680
  }
2516
2681
  }
2517
2682
  });
2518
2683
  meetingChannel.requestBackfill("manual");
2519
2684
  },
2520
- knock(roomName) {
2521
- const handle = channel ?? manager.open({ topic: roomTopic(roomName) });
2522
- channel = handle;
2523
- const entry = {
2524
- identity: knockIdentity(),
2525
- displayName: identity.displayName,
2526
- avatarUrl: identity.avatarUrl,
2527
- userId: identity.userId,
2528
- state: "knocking",
2529
- knockedAt: (/* @__PURE__ */ new Date()).toISOString(),
2530
- decidedAt: null,
2531
- decidedBy: null
2532
- };
2533
- store.upsertLobby(entry);
2534
- handle.send(MEET_EVENTS.knock, entry);
2535
- },
2536
2685
  async admit(entry, admitted) {
2537
2686
  const by = requireHostUser("meet.admit");
2538
2687
  const meeting = requireMeeting("meet.admit");
2539
- await repository.admit({
2688
+ const row = await repository.decideAdmission({
2540
2689
  meetingId: meeting.id,
2541
2690
  identity: entry.identity,
2542
- admitted,
2691
+ decision: admitted ? "admit" : "deny",
2543
2692
  byUserId: by
2544
2693
  });
2545
2694
  store.upsertLobby({
2546
2695
  ...entry,
2547
- state: admitted ? "admitted" : "denied",
2696
+ state: decidedState(row.admissionState),
2548
2697
  decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
2549
2698
  decidedBy: by
2550
2699
  });
2551
- channel?.send(MEET_EVENTS.admission, {
2700
+ hint();
2701
+ },
2702
+ async undeny(entry) {
2703
+ const by = requireHostUser("meet.undeny");
2704
+ const meeting = requireMeeting("meet.undeny");
2705
+ const row = await repository.decideAdmission({
2706
+ meetingId: meeting.id,
2552
2707
  identity: entry.identity,
2553
- admitted,
2554
- by
2708
+ decision: "undeny",
2709
+ byUserId: by
2555
2710
  });
2711
+ store.upsertLobby({ ...entry, state: decidedState(row.admissionState), decidedAt: (/* @__PURE__ */ new Date()).toISOString(), decidedBy: by });
2712
+ hint();
2556
2713
  },
2557
2714
  async admitAll() {
2558
- const knocking = store.snapshot().lobby.filter((entry) => entry.state === "knocking");
2559
- for (const entry of knocking) {
2560
- await this.admit(entry, true);
2561
- }
2715
+ const by = requireHostUser("meet.admitAll");
2716
+ const meeting = requireMeeting("meet.admitAll");
2717
+ const rows = await repository.admitAll(meeting.id, by);
2718
+ for (const row of rows) {
2719
+ store.setAdmission(row.identity, row.admissionState, by);
2720
+ }
2721
+ hint();
2722
+ },
2723
+ startWaiting() {
2724
+ if (polling) return;
2725
+ polling = true;
2726
+ admittedOnce = false;
2727
+ listen();
2728
+ schedule(0);
2729
+ },
2730
+ stopWaiting,
2731
+ readState: readState2,
2732
+ async withdraw() {
2733
+ stopWaiting();
2734
+ await postLeave("withdraw");
2735
+ patchWaiting({ admission: "withdrawn" });
2736
+ hint();
2737
+ },
2738
+ async askAgain() {
2739
+ await options.rejoin?.();
2562
2740
  },
2563
2741
  async setLock(locked) {
2564
2742
  const by = requireHostUser("meet.setLock");
2565
2743
  const meeting = requireMeeting("meet.setLock");
2566
2744
  const updated = await repository.setLock(meeting.id, locked, by);
2567
2745
  store.patch({ meeting: updated, locked: updated.locked });
2568
- channel?.send(MEET_EVENTS.lock, { locked: updated.locked });
2746
+ hint();
2569
2747
  return updated;
2570
2748
  },
2571
2749
  /**
@@ -2623,6 +2801,7 @@ function createMeetingSession(options) {
2623
2801
  return { endedAt, served: true };
2624
2802
  },
2625
2803
  dispose() {
2804
+ stopWaiting();
2626
2805
  channel?.close();
2627
2806
  channel = null;
2628
2807
  meetingChannel?.close();
@@ -2667,7 +2846,14 @@ var RPCS = {
2667
2846
  getOrCreateMeeting: "meet_get_or_create_meeting",
2668
2847
  /** Resolve a durable public link, before/during/after its scheduled time. */
2669
2848
  meetingBySlug: "meet_meeting_by_slug",
2670
- admit: "meet_admit_participant",
2849
+ /*
2850
+ * ── S4 ADMISSION (CORE-DESIGN §2.4). Each returns the decided row; the caller then
2851
+ * sends the payload-free `mx.state.changed` hint. Authorized by `who_may_admit`.
2852
+ */
2853
+ admit: "meet_admit",
2854
+ deny: "meet_deny",
2855
+ undeny: "meet_undeny",
2856
+ admitAll: "meet_admit_all",
2671
2857
  setLock: "meet_set_lock",
2672
2858
  recordConsent: "meet_record_consent",
2673
2859
  /*
@@ -2710,13 +2896,13 @@ var RPCS = {
2710
2896
  /** Titles, agendas, current notes and final transcript lines; snippets mark hits with ⟦ ⟧. */
2711
2897
  search: "meet_search"
2712
2898
  };
2713
- var VOID_RPCS = [RPCS.admit, RPCS.recordConsent];
2899
+ var VOID_RPCS = [RPCS.recordConsent];
2714
2900
  var MEETING_COLUMNS = "id,organization_id,room_name,slug,title,kind,host_user_id,scheduled_for,scheduled_duration_minutes,started_at,ended_at,locked,lobby_enabled,recording_policy,ai_enabled,metadata,time_zone,agenda,recurrence_rule,join_before_host,cancelled_at,cancellation_reason,deleted_at,calendar_sequence,version";
2715
2901
  var INVITEE_COLUMNS = "id,meeting_id,invitee_user_id,email,display_name,role,rsvp_state,responded_at,rsvp_note,last_sent_sequence,last_sent_at";
2716
2902
  var RECORDING_COLUMNS = "id,meeting_id,state,started_at,ended_at,started_by,file_id,failure_reason";
2717
2903
  var SEGMENT_COLUMNS = "id,meeting_id,identity,speaker,text,started_at,is_final,metadata";
2718
2904
  var NOTE_COLUMNS = "id,meeting_id,text,kind,assignee_user_id,created_at,metadata";
2719
- var ATTENDEE_COLUMNS = "id,meeting_id,identity,participant_user_id,display_name,avatar_url,role,admission_state,is_agent,joined_at,left_at,sessions:metadata->sessions";
2905
+ var ATTENDEE_COLUMNS = "id,meeting_id,identity,participant_user_id,display_name,avatar_url,role,admission_state,knocked_at,knock_expires_at,is_agent,joined_at,left_at,sessions:metadata->sessions";
2720
2906
  function projectStays(value) {
2721
2907
  if (!Array.isArray(value)) return [];
2722
2908
  const stays = [];
@@ -3083,7 +3269,16 @@ function createMeetRepository(options) {
3083
3269
  const role = text(row, "role");
3084
3270
  const admission = text(row, "admission_state");
3085
3271
  const known = ["host", "cohost", "presenter", "participant", "viewer"];
3086
- const states = ["knocking", "admitted", "denied", "removed"];
3272
+ const states = [
3273
+ "waiting_host",
3274
+ "knocking",
3275
+ "admitted",
3276
+ "denied",
3277
+ "expired",
3278
+ "withdrawn",
3279
+ "removed",
3280
+ "ended"
3281
+ ];
3087
3282
  const userId = text(row, "participant_user_id");
3088
3283
  const identity = requireText(row, "identity", operation);
3089
3284
  return {
@@ -3097,6 +3292,8 @@ function createMeetRepository(options) {
3097
3292
  userId: userId === null ? null : asUserId(userId),
3098
3293
  role: known.includes(role) ? role : "participant",
3099
3294
  admissionState: states.includes(admission) ? admission : "admitted",
3295
+ knockedAt: text(row, "knocked_at"),
3296
+ knockExpiresAt: text(row, "knock_expires_at"),
3100
3297
  isAgent: bool(row, "is_agent", false),
3101
3298
  joinedAt: text(row, "joined_at"),
3102
3299
  leftAt: text(row, "left_at"),
@@ -3375,16 +3572,26 @@ function createMeetRepository(options) {
3375
3572
  hasMore: rows.length > limit
3376
3573
  };
3377
3574
  },
3378
- async admit({ meetingId, identity, admitted, byUserId }) {
3379
- await guardVoid(
3380
- "meet.admit",
3381
- () => db.rpc(RPCS.admit, {
3575
+ async decideAdmission({ meetingId, identity, decision, byUserId }) {
3576
+ const fn = decision === "admit" ? RPCS.admit : decision === "deny" ? RPCS.deny : RPCS.undeny;
3577
+ const row = await guard(
3578
+ `meet.${decision}`,
3579
+ () => db.rpc(fn, {
3382
3580
  p_meeting_id: meetingId,
3383
3581
  p_identity: identity,
3384
- p_admitted: admitted,
3385
3582
  p_by_user_id: byUserId
3386
3583
  })
3387
3584
  );
3585
+ return projectAttendee(row);
3586
+ },
3587
+ async admitAll(meetingId, byUserId) {
3588
+ const response = await db.rpc(RPCS.admitAll, {
3589
+ p_meeting_id: meetingId,
3590
+ p_by_user_id: byUserId
3591
+ });
3592
+ if (response.error !== null) throw normalizeMeetError(response.error, "meet.admitAll");
3593
+ const rows = Array.isArray(response.data) ? response.data : [];
3594
+ return rows.map(projectAttendee);
3388
3595
  },
3389
3596
  async setLock(meetingId, locked, byUserId) {
3390
3597
  const row = await guard(
@@ -4231,7 +4438,7 @@ function createRoomEngine(options) {
4231
4438
  }
4232
4439
  };
4233
4440
  async function connect(request) {
4234
- store.patch({ phase: "requesting-token", failure: null });
4441
+ store.patch({ phase: "requesting-token", failure: null, waiting: null });
4235
4442
  const token = await tokens.get({
4236
4443
  roomName: request.roomName,
4237
4444
  identity,
@@ -4251,11 +4458,21 @@ function createRoomEngine(options) {
4251
4458
  canSubscribe: token.canSubscribe
4252
4459
  }
4253
4460
  });
4254
- if (token.lobby) {
4255
- store.patch({ phase: "lobby" });
4461
+ if (token.lobby || !token.canSubscribe) {
4462
+ store.patch({
4463
+ phase: "lobby",
4464
+ waiting: {
4465
+ admission: token.admissionState !== void 0 && token.admissionState !== "admitted" ? token.admissionState : "knocking",
4466
+ admittersPresent: null,
4467
+ reknockAllowed: true,
4468
+ knockExpiresAt: null,
4469
+ pollSeconds: token.pollSeconds ?? 3,
4470
+ liveVersion: 0
4471
+ }
4472
+ });
4256
4473
  return;
4257
4474
  }
4258
- store.patch({ phase: "connecting" });
4475
+ store.patch({ phase: "connecting", waiting: null });
4259
4476
  const created = await options.driverFactory(handlers);
4260
4477
  if (disposed) {
4261
4478
  await created.disconnect();
@@ -5338,14 +5555,18 @@ function MeetRuntime(props) {
5338
5555
  api,
5339
5556
  onDiagnostic: report,
5340
5557
  // ADMISSION IS A JOIN, NOT A RE-RENDER. The lobby token grants neither
5341
- // publish nor subscribe, so the host saying yes has to mint a new one.
5558
+ // publish nor subscribe, so the host saying yes has to mint a new one. The
5559
+ // "yes" is read from the server (the state read), never a broadcast (S4).
5342
5560
  onAdmitted: () => {
5343
- void engine.rejoin();
5344
- },
5345
- // A "no" is ALSO a mint: the server refuses it with its own sentence, which
5346
- // turns the waiting room into the pre-join screen showing that sentence.
5347
- onDenied: () => {
5348
5561
  void engine.rejoin().catch(() => void 0);
5562
+ },
5563
+ rejoin: () => engine.rejoin(),
5564
+ // THE MEETING PASS this device holds — what the state read and `/leave` carry.
5565
+ resolvePass: () => {
5566
+ const roomName = store.snapshot().meeting?.roomName;
5567
+ const active = engine.token();
5568
+ if (active?.meetingPass) return active.meetingPass;
5569
+ return roomName === void 0 ? null : tokens.remembered(roomName)?.meetingPass ?? null;
5349
5570
  }
5350
5571
  });
5351
5572
  const resolveRoomToken = () => {
@@ -5357,6 +5578,14 @@ function MeetRuntime(props) {
5357
5578
  };
5358
5579
  const ai = createMeetAi({ api, store, resolveRoomToken });
5359
5580
  const inRoom = createInRoomClient({ api, store, resolveRoomToken });
5581
+ store.subscribe(() => {
5582
+ const snapshot = store.snapshot();
5583
+ if (snapshot.phase === "lobby" && snapshot.waiting !== null && (snapshot.waiting.admission === "knocking" || snapshot.waiting.admission === "waiting_host")) {
5584
+ meetings.startWaiting();
5585
+ } else if (snapshot.phase !== "lobby") {
5586
+ meetings.stopWaiting();
5587
+ }
5588
+ });
5360
5589
  let chatReadFor = null;
5361
5590
  store.subscribe(() => {
5362
5591
  const snapshot = store.snapshot();
@@ -5564,7 +5793,7 @@ function observedPhase(input) {
5564
5793
  case "left":
5565
5794
  return "prejoin";
5566
5795
  case "lobby":
5567
- return "waiting:knocking";
5796
+ return waitingPhase(snapshot);
5568
5797
  case "requesting-token":
5569
5798
  case "connecting":
5570
5799
  return "joining";
@@ -5573,6 +5802,24 @@ function observedPhase(input) {
5573
5802
  return "in_call";
5574
5803
  }
5575
5804
  }
5805
+ function waitingPhase(snapshot) {
5806
+ switch (snapshot.waiting?.admission) {
5807
+ case "waiting_host":
5808
+ return "waiting:host_not_started";
5809
+ case "denied":
5810
+ return "denied";
5811
+ case "expired":
5812
+ return "knock_expired";
5813
+ case "withdrawn":
5814
+ return "left";
5815
+ case "ended":
5816
+ return "ended";
5817
+ case "removed":
5818
+ return "refused:removed";
5819
+ default:
5820
+ return "waiting:knocking";
5821
+ }
5822
+ }
5576
5823
  var ADMITTER_ROLES = /* @__PURE__ */ new Set(["host", "cohost"]);
5577
5824
  function observe(input) {
5578
5825
  const { snapshot } = input;
@@ -5587,7 +5834,7 @@ function observe(input) {
5587
5834
  reason: phase.startsWith("refused:") ? phase.slice("refused:".length) : null,
5588
5835
  role,
5589
5836
  connection: snapshot?.phase === "reconnecting" ? "reconnecting" : "stable",
5590
- hostPresent: inCall && snapshot !== null ? snapshot.participants.some((p) => ADMITTER_ROLES.has(p.role)) : null,
5837
+ hostPresent: inCall && snapshot !== null ? snapshot.participants.some((p) => ADMITTER_ROLES.has(p.role)) : snapshot?.phase === "lobby" ? snapshot.waiting?.admittersPresent ?? null : null,
5591
5838
  lobbyCount: canAdmit && snapshot !== null ? snapshot.lobby.filter((entry) => entry.state === "knocking").length : 0,
5592
5839
  recording: snapshot?.recording === "recording" || snapshot?.recording === "stopping",
5593
5840
  audioBlocked: null,
@@ -6548,12 +6795,22 @@ function useLobby() {
6548
6795
  async () => requireSession(session, "admitAll").admitAll(),
6549
6796
  [session]
6550
6797
  ),
6551
- knock: useCallback2(
6552
- (roomName) => requireSession(session, "knock").knock(roomName),
6798
+ undeny: useCallback2(
6799
+ async (entry) => requireSession(session, "undeny").undeny(entry),
6553
6800
  [session]
6554
6801
  )
6555
6802
  };
6556
6803
  }
6804
+ function useWaiting() {
6805
+ const host = useMeetHost();
6806
+ const snapshot = useMeetSnapshot();
6807
+ const session = host?.meetings ?? null;
6808
+ return {
6809
+ waiting: snapshot?.waiting ?? null,
6810
+ withdraw: useCallback2(async () => requireSession(session, "withdraw").withdraw(), [session]),
6811
+ askAgain: useCallback2(async () => requireSession(session, "askAgain").askAgain(), [session])
6812
+ };
6813
+ }
6557
6814
  function useRecording() {
6558
6815
  const host = useMeetHost();
6559
6816
  const snapshot = useMeetSnapshot();
@@ -10684,7 +10941,7 @@ function MeetSkinRoom({
10684
10941
  ) });
10685
10942
  }
10686
10943
  if (phase === "lobby") {
10687
- return /* @__PURE__ */ jsx5("div", { className: "mx-meet mx-meet--lobby", children: /* @__PURE__ */ jsx5(WaitingRoom, { roomName, host }) });
10944
+ return /* @__PURE__ */ jsx5("div", { className: "mx-meet mx-meet--lobby", children: /* @__PURE__ */ jsx5(WaitingRoom, { host, ...onLeave !== void 0 ? { onLeave } : {} }) });
10688
10945
  }
10689
10946
  return /* @__PURE__ */ jsxs3("div", { className: "mx-meet", children: [
10690
10947
  /* @__PURE__ */ jsx5(ConsentNotice, {}),
@@ -10709,21 +10966,58 @@ function MeetSkinRoom({
10709
10966
  /* @__PURE__ */ jsx5(ControlBar, { ...onLeave !== void 0 ? { onLeave } : {} })
10710
10967
  ] });
10711
10968
  }
10712
- function WaitingRoom({ roomName, host }) {
10713
- const knocked = useRef6(false);
10714
- useEffect6(() => {
10715
- if (knocked.current) return;
10716
- knocked.current = true;
10717
- host.meetings.knock(roomName);
10718
- }, [host, roomName]);
10969
+ function WaitingRoom({ host, onLeave }) {
10970
+ const { waiting, withdraw, askAgain } = useWaiting();
10971
+ const [busy, setBusy] = useState4(false);
10972
+ const admission = waiting?.admission ?? "knocking";
10973
+ const run = (action) => () => {
10974
+ setBusy(true);
10975
+ void action().catch(() => void 0).finally(() => setBusy(false));
10976
+ };
10977
+ const home = /* @__PURE__ */ jsx5(Button, { type: "button", variant: "outline", onClick: () => onLeave !== void 0 ? onLeave() : window.location.assign("/"), children: "Back to home" });
10978
+ let title;
10979
+ let line = null;
10980
+ let actions;
10981
+ switch (admission) {
10982
+ case "waiting_host":
10983
+ title = "Waiting for the host to start";
10984
+ actions = /* @__PURE__ */ jsx5(Button, { type: "button", variant: "outline", disabled: busy, onClick: run(withdraw), children: "Leave" });
10985
+ break;
10986
+ case "denied":
10987
+ title = "The host declined your request";
10988
+ actions = /* @__PURE__ */ jsxs3(Fragment, { children: [
10989
+ waiting?.reknockAllowed === true ? /* @__PURE__ */ jsx5(Button, { type: "button", disabled: busy, onClick: run(askAgain), children: "Ask to join again" }) : null,
10990
+ home
10991
+ ] });
10992
+ break;
10993
+ case "expired":
10994
+ title = "No one responded";
10995
+ actions = /* @__PURE__ */ jsxs3(Fragment, { children: [
10996
+ /* @__PURE__ */ jsx5(Button, { type: "button", disabled: busy, onClick: run(askAgain), children: "Ask again" }),
10997
+ home
10998
+ ] });
10999
+ break;
11000
+ case "withdrawn":
11001
+ title = "You left the waiting room";
11002
+ actions = /* @__PURE__ */ jsxs3(Fragment, { children: [
11003
+ /* @__PURE__ */ jsx5(Button, { type: "button", disabled: busy, onClick: run(askAgain), children: "Rejoin" }),
11004
+ home
11005
+ ] });
11006
+ break;
11007
+ case "ended":
11008
+ title = "The meeting has ended";
11009
+ actions = home;
11010
+ break;
11011
+ default:
11012
+ title = "Waiting for the host to let you in";
11013
+ line = waiting?.admittersPresent === false ? "The host isn't here yet" : null;
11014
+ actions = /* @__PURE__ */ jsx5(Button, { type: "button", variant: "outline", disabled: busy, onClick: run(withdraw), children: "Cancel request" });
11015
+ }
10719
11016
  return /* @__PURE__ */ jsxs3("div", { className: "mx-meet__waiting", children: [
10720
11017
  /* @__PURE__ */ jsx5(Avatar, { name: host.identity.displayName, url: host.identity.avatarUrl, size: 72 }),
10721
- /* @__PURE__ */ jsx5("h1", { className: "mx-meet__prejoin-title", children: "Waiting for the host to let you in" }),
10722
- /* @__PURE__ */ jsxs3("p", { className: "mx-meet__empty-body", children: [
10723
- "You are in the waiting room as ",
10724
- host.identity.displayName,
10725
- ". You will join automatically once you are admitted."
10726
- ] })
11018
+ /* @__PURE__ */ jsx5("h1", { className: "mx-meet__prejoin-title", children: title }),
11019
+ line !== null ? /* @__PURE__ */ jsx5("p", { className: "mx-meet__empty-body", role: "status", "data-meet-notice": "host-absent", children: line }) : null,
11020
+ /* @__PURE__ */ jsx5("div", { className: "mx-meet__waiting-actions", children: actions })
10727
11021
  ] });
10728
11022
  }
10729
11023
  function HostMenu() {