@ai-matrx/meet 0.7.10 → 0.7.11

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/index.cjs CHANGED
@@ -33,10 +33,12 @@ __export(src_exports, {
33
33
  ATTENDANCE_CSV_HEADER: () => ATTENDANCE_CSV_HEADER,
34
34
  CALL_INVITE_KIND: () => CALL_INVITE_KIND,
35
35
  CALL_INVITE_VERSION: () => CALL_INVITE_VERSION,
36
+ CAPTION_LANGUAGES: () => CAPTION_LANGUAGES,
36
37
  CONSENT_NOTICE_ATTRIBUTE: () => CONSENT_NOTICE_ATTRIBUTE,
37
38
  DATA_TOPICS: () => DATA_TOPICS,
38
39
  DEFAULT_EFFECTS: () => DEFAULT_EFFECTS,
39
40
  DEFAULT_PAGE_SIZE: () => DEFAULT_PAGE_SIZE,
41
+ EMPTY_COLLAB: () => EMPTY_COLLAB,
40
42
  END_MEETING_NOT_SERVED: () => END_MEETING_NOT_SERVED,
41
43
  FILE_SESSION_PATH: () => FILE_SESSION_PATH,
42
44
  GUEST_RECORD_ACCESS_CHOICES: () => GUEST_RECORD_ACCESS_CHOICES,
@@ -74,6 +76,7 @@ __export(src_exports, {
74
76
  absenceSentence: () => absenceSentence,
75
77
  activeWrapUpSummary: () => activeWrapUpSummary,
76
78
  announceMeeting: () => announceMeeting,
79
+ applyCollabPacket: () => applyCollabPacket,
77
80
  applyRoomEvent: () => applyRoomEvent,
78
81
  asCallId: () => asCallId,
79
82
  asMeetingId: () => asMeetingId,
@@ -81,10 +84,13 @@ __export(src_exports, {
81
84
  asParticipantIdentity: () => asParticipantIdentity,
82
85
  asRoomName: () => asRoomName,
83
86
  asUserId: () => asUserId,
87
+ assignedRoom: () => assignedRoom,
84
88
  attendanceCsv: () => attendanceCsv,
85
89
  attendanceReport: () => attendanceReport,
86
90
  attendanceTotals: () => attendanceTotals,
87
91
  attendeeKey: () => attendeeKey,
92
+ autoAssign: () => autoAssign,
93
+ breakoutRemainingMs: () => breakoutRemainingMs,
88
94
  callInviteAction: () => callInviteAction,
89
95
  callInviteText: () => callInviteText,
90
96
  callStateLabel: () => callStateLabel,
@@ -94,6 +100,7 @@ __export(src_exports, {
94
100
  captionSegment: () => captionSegment,
95
101
  changesPatch: () => changesPatch,
96
102
  clockPosition: () => clockPosition,
103
+ collabFromState: () => collabFromState,
97
104
  computeStage: () => computeStage,
98
105
  countLabel: () => countLabel,
99
106
  createCallCenter: () => createCallCenter,
@@ -114,6 +121,7 @@ __export(src_exports, {
114
121
  createWebDeviceStorage: () => createWebDeviceStorage,
115
122
  createWebEffectsStorage: () => createWebEffectsStorage,
116
123
  createWebRoomTokenStorage: () => createWebRoomTokenStorage,
124
+ defaultCaptionLanguage: () => defaultCaptionLanguage,
117
125
  displayNameFor: () => displayNameFor,
118
126
  fileDownloadPath: () => fileDownloadPath,
119
127
  gridColumns: () => gridColumns,
@@ -136,6 +144,7 @@ __export(src_exports, {
136
144
  listenOnlyNotice: () => listenOnlyNotice,
137
145
  loadFollowUpDraft: () => loadFollowUpDraft,
138
146
  loadGuestMeetingRecord: () => loadGuestMeetingRecord,
147
+ loadMeetingActivities: () => loadMeetingActivities,
139
148
  loadMeetingChat: () => loadMeetingChat,
140
149
  loadMeetingRecord: () => loadMeetingRecord,
141
150
  mapConnectionState: () => mapConnectionState,
@@ -191,6 +200,7 @@ __export(src_exports, {
191
200
  uniqueAttendees: () => uniqueAttendees,
192
201
  userTopic: () => userTopic,
193
202
  wasRefused: () => wasRefused,
203
+ withItem: () => withItem,
194
204
  wrapUpStatus: () => wrapUpStatus
195
205
  });
196
206
  module.exports = __toCommonJS(src_exports);
@@ -418,7 +428,14 @@ var MEET_ROUTES = {
418
428
  */
419
429
  moderate: "/api/v1/meet/moderate",
420
430
  /** Meet wave 2: the SAVED chat — `POST` sends to everyone, `GET ?meeting_id=` reads history. */
421
- chat: "/api/v1/meet/chat"
431
+ chat: "/api/v1/meet/chat",
432
+ /**
433
+ * Meet wave 5: polls, Q&A, the shared whiteboard and breakout rooms. `POST` acts
434
+ * (`{meeting_id, action, …}`), `GET ?meeting_id=` reads what this viewer may see.
435
+ */
436
+ collab: "/api/v1/meet/collab",
437
+ /** Meet wave 5: translate finished caption lines into this viewer's language. */
438
+ captionsTranslate: "/api/v1/meet/captions/translate"
422
439
  };
423
440
  function meetingPath(template, meetingId) {
424
441
  return template.replace("{meeting_id}", encodeURIComponent(meetingId));
@@ -477,7 +494,8 @@ function parseRoomToken(body, roomName) {
477
494
  // server that has not landed MRI-A11 — and never invents a host from
478
495
  // silence, because both fallbacks below grant nothing.
479
496
  role: readRole(payload, "role") ?? (isHost ? "host" : identity.startsWith("guest:") ? "guest" : "participant"),
480
- lobby: readBoolean(payload, "lobby", false)
497
+ lobby: readBoolean(payload, "lobby", false),
498
+ breakoutRoomId: readString(payload, "breakout_room_id") ?? null
481
499
  };
482
500
  }
483
501
  function createTokenClient(options) {
@@ -525,6 +543,7 @@ function createTokenClient(options) {
525
543
  room_name: input.roomName,
526
544
  ...input.meetingId !== void 0 ? { meeting_id: input.meetingId } : {},
527
545
  ...input.slug !== void 0 ? { slug: input.slug } : {},
546
+ ...input.breakoutRoomId != null ? { breakout_room_id: input.breakoutRoomId } : {},
528
547
  organization_id: input.identity.organizationId,
529
548
  display_name: input.identity.displayName,
530
549
  device_id: input.identity.deviceId,
@@ -588,7 +607,8 @@ function createTokenClient(options) {
588
607
  return {
589
608
  get(input) {
590
609
  const entry = cache.get(input.roomName);
591
- if (entry !== void 0) {
610
+ const sameRoom = entry !== void 0 && (entry.token.breakoutRoomId ?? null) === (input.breakoutRoomId ?? null);
611
+ if (entry !== void 0 && sameRoom) {
592
612
  if (entry.inFlight !== null) return entry.inFlight;
593
613
  if (!expired(entry)) return Promise.resolve(entry.token);
594
614
  }
@@ -634,7 +654,8 @@ function placeholder(roomName) {
634
654
  canRecord: false,
635
655
  isHost: false,
636
656
  role: "participant",
637
- lobby: false
657
+ lobby: false,
658
+ breakoutRoomId: null
638
659
  };
639
660
  }
640
661
  async function serverSentence(response) {
@@ -1363,6 +1384,339 @@ function createCallCenter(options) {
1363
1384
  };
1364
1385
  }
1365
1386
 
1387
+ // src/core/collab.ts
1388
+ var EMPTY_COLLAB = {
1389
+ loaded: false,
1390
+ error: null,
1391
+ settings: null,
1392
+ polls: [],
1393
+ questions: [],
1394
+ strokes: [],
1395
+ breakout: null,
1396
+ broadcast: null,
1397
+ moveRequest: null,
1398
+ panelRequest: null,
1399
+ captionLanguage: null,
1400
+ captionTranslations: {},
1401
+ captionTranslationError: null
1402
+ };
1403
+ function obj(value) {
1404
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
1405
+ }
1406
+ function str(value) {
1407
+ return typeof value === "string" ? value : null;
1408
+ }
1409
+ function num(value) {
1410
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1411
+ }
1412
+ function strings(value) {
1413
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
1414
+ }
1415
+ function pollFromRow(row) {
1416
+ const id = str(row["id"]);
1417
+ if (id === null) return null;
1418
+ const options = [];
1419
+ for (const raw of Array.isArray(row["options"]) ? row["options"] : []) {
1420
+ const option = obj(raw);
1421
+ const optionId = option === null ? null : str(option["id"]);
1422
+ if (option === null || optionId === null) continue;
1423
+ const votes = num(option["votes"]);
1424
+ options.push({
1425
+ id: optionId,
1426
+ text: str(option["text"]) ?? "",
1427
+ ...votes !== null ? { votes } : {},
1428
+ ...Array.isArray(option["voters"]) ? { voters: strings(option["voters"]) } : {}
1429
+ });
1430
+ }
1431
+ const state = str(row["state"]);
1432
+ return {
1433
+ id,
1434
+ question: str(row["question"]) ?? "",
1435
+ options,
1436
+ multiple: row["multiple"] === true,
1437
+ anonymous: row["anonymous"] === true,
1438
+ state: state === "live" || state === "closed" ? state : "draft",
1439
+ resultsShared: row["results_shared"] === true,
1440
+ voterCount: num(row["voter_count"]),
1441
+ myVote: strings(row["my_vote"]),
1442
+ createdByName: str(row["created_by_name"]) ?? "",
1443
+ createdAt: str(row["created_at"])
1444
+ };
1445
+ }
1446
+ function questionFromRow(row) {
1447
+ const id = str(row["id"]);
1448
+ if (id === null) return null;
1449
+ const state = str(row["state"]);
1450
+ return {
1451
+ id,
1452
+ text: str(row["text"]) ?? "",
1453
+ askerName: str(row["asker_name"]) ?? "",
1454
+ anonymous: row["anonymous"] === true,
1455
+ state: state === "answered" || state === "dismissed" ? state : "open",
1456
+ answer: str(row["answer"]),
1457
+ answeredByName: str(row["answered_by_name"]),
1458
+ upvotes: num(row["upvotes"]) ?? 0,
1459
+ upvotedByMe: row["upvoted_by_me"] === true,
1460
+ mine: row["mine"] === true,
1461
+ createdAt: str(row["created_at"])
1462
+ };
1463
+ }
1464
+ function strokeFromRow(row) {
1465
+ const id = str(row["id"]);
1466
+ if (id === null || !Array.isArray(row["points"])) return null;
1467
+ const points = [];
1468
+ for (const raw of row["points"]) {
1469
+ if (Array.isArray(raw) && typeof raw[0] === "number" && typeof raw[1] === "number") {
1470
+ points.push([raw[0], raw[1]]);
1471
+ }
1472
+ }
1473
+ return {
1474
+ id,
1475
+ points,
1476
+ color: str(row["color"]) ?? "#111827",
1477
+ width: num(row["width"]) ?? 3,
1478
+ tool: row["tool"] === "highlighter" ? "highlighter" : "pen",
1479
+ by: str(row["by"]) ?? "",
1480
+ byName: str(row["by_name"]) ?? ""
1481
+ };
1482
+ }
1483
+ function broadcastFrom(value) {
1484
+ const row = obj(value);
1485
+ const text3 = row === null ? null : str(row["text"]);
1486
+ if (row === null || text3 === null) return null;
1487
+ return { text: text3, by: str(row["by"]) ?? "", at: str(row["at"]) ?? "" };
1488
+ }
1489
+ function planFromRow(row) {
1490
+ const id = str(row["id"]);
1491
+ if (id === null) return null;
1492
+ const rooms = [];
1493
+ for (const raw of Array.isArray(row["rooms"]) ? row["rooms"] : []) {
1494
+ const room = obj(raw);
1495
+ const roomId = room === null ? null : str(room["id"]);
1496
+ if (room === null || roomId === null) continue;
1497
+ rooms.push({ id: roomId, name: str(room["name"]) ?? roomId, identities: strings(room["identities"]) });
1498
+ }
1499
+ return {
1500
+ id,
1501
+ state: row["state"] === "open" ? "open" : "closed",
1502
+ rooms,
1503
+ allowChoose: row["allow_choose"] === true,
1504
+ minutes: num(row["minutes"]) ?? 0,
1505
+ openedAt: str(row["opened_at"]),
1506
+ endsAt: str(row["ends_at"]),
1507
+ closedAt: str(row["closed_at"]),
1508
+ broadcasts: (Array.isArray(row["broadcasts"]) ? row["broadcasts"] : []).map(broadcastFrom).filter((b) => b !== null)
1509
+ };
1510
+ }
1511
+ function settingsFromRow(row) {
1512
+ if (row === null) return null;
1513
+ return {
1514
+ pollsEnabled: row["polls_enabled"] === true,
1515
+ pollMaxOptions: num(row["poll_max_options"]) ?? 10,
1516
+ qaEnabled: row["qa_enabled"] === true,
1517
+ qaAnonymousAllowed: row["qa_anonymous_allowed"] === true,
1518
+ whiteboardEnabled: row["whiteboard_enabled"] === true,
1519
+ breakoutsEnabled: row["breakouts_enabled"] === true,
1520
+ breakoutMaxRooms: num(row["breakout_max_rooms"]) ?? 20,
1521
+ breakoutDefaultMinutes: num(row["breakout_default_minutes"]) ?? 15,
1522
+ captionTranslationEnabled: row["caption_translation_enabled"] === true,
1523
+ captionTranslationLanguage: str(row["caption_translation_language"]) ?? ""
1524
+ };
1525
+ }
1526
+ function collabFromState(body, previous) {
1527
+ const list = (key, parse) => (Array.isArray(body[key]) ? body[key] : []).map((raw) => {
1528
+ const row = obj(raw);
1529
+ return row === null ? null : parse(row);
1530
+ }).filter((item) => item !== null);
1531
+ const breakoutRow = obj(body["breakout"]);
1532
+ return {
1533
+ ...previous,
1534
+ loaded: true,
1535
+ error: null,
1536
+ settings: settingsFromRow(obj(body["settings"])),
1537
+ polls: list("polls", pollFromRow),
1538
+ questions: list("questions", questionFromRow),
1539
+ strokes: list("strokes", strokeFromRow),
1540
+ breakout: breakoutRow === null ? null : planFromRow(breakoutRow)
1541
+ };
1542
+ }
1543
+ function upsert(items, next) {
1544
+ const index = items.findIndex((item) => item.id === next.id);
1545
+ if (index === -1) return [...items, next];
1546
+ const copy = items.slice();
1547
+ copy[index] = next;
1548
+ return copy;
1549
+ }
1550
+ function withItem(collab, row, fromRoom = false) {
1551
+ const kind = str(row["kind"]);
1552
+ if (kind === "poll") {
1553
+ const poll = pollFromRow(row);
1554
+ if (poll === null) return collab;
1555
+ const known = collab.polls.find((p) => p.id === poll.id);
1556
+ const merged = fromRoom && known !== void 0 ? { ...poll, myVote: known.myVote } : poll;
1557
+ return { ...collab, polls: upsert(collab.polls, merged) };
1558
+ }
1559
+ if (kind === "question") {
1560
+ const question = questionFromRow(row);
1561
+ if (question === null) return collab;
1562
+ const known = collab.questions.find((q) => q.id === question.id);
1563
+ const merged = fromRoom && known !== void 0 ? { ...question, mine: known.mine, upvotedByMe: known.upvotedByMe } : question;
1564
+ return { ...collab, questions: upsert(collab.questions, merged) };
1565
+ }
1566
+ if (kind === "stroke") {
1567
+ const stroke = strokeFromRow(row);
1568
+ if (stroke === null) return collab;
1569
+ return { ...collab, strokes: upsert(collab.strokes, stroke) };
1570
+ }
1571
+ if (kind === "breakout") {
1572
+ const plan = planFromRow(row);
1573
+ if (plan === null) return collab;
1574
+ return { ...collab, breakout: plan };
1575
+ }
1576
+ return collab;
1577
+ }
1578
+ function without(collab, ids) {
1579
+ const gone = new Set(ids);
1580
+ return {
1581
+ ...collab,
1582
+ polls: collab.polls.filter((p) => !gone.has(p.id)),
1583
+ questions: collab.questions.filter((q) => !gone.has(q.id)),
1584
+ strokes: collab.strokes.filter((s) => !gone.has(s.id))
1585
+ };
1586
+ }
1587
+ function applyCollabPacket(collab, payload, context) {
1588
+ const item = obj(payload["item"]);
1589
+ const audience = str(payload["audience"]);
1590
+ if (item !== null) {
1591
+ const kind = str(item["kind"]);
1592
+ const hostOnlyView = kind === "poll" || kind === "question";
1593
+ if (hostOnlyView && context.isHost && audience !== "hosts") {
1594
+ return { next: null, dropped: "the room copy of an item a host receives in full" };
1595
+ }
1596
+ let next = withItem(collab, item, true);
1597
+ if (kind === "poll" && str(item["state"]) === "live") {
1598
+ const before = collab.polls.find((p) => p.id === str(item["id"]));
1599
+ if (before === void 0 || before.state !== "live") {
1600
+ next = { ...next, panelRequest: { panel: "polls", at: context.now } };
1601
+ }
1602
+ }
1603
+ if (kind === "breakout") {
1604
+ const plan = planFromRow(item);
1605
+ if (plan !== null && plan.state === "closed") {
1606
+ next = { ...next, moveRequest: { roomId: null, at: context.now } };
1607
+ }
1608
+ }
1609
+ const move = obj(payload["move"]);
1610
+ if (move !== null && str(move["identity"]) === context.localIdentity) {
1611
+ next = { ...next, moveRequest: { roomId: str(move["room_id"]), at: context.now } };
1612
+ }
1613
+ return { next };
1614
+ }
1615
+ if (Array.isArray(payload["removed"])) {
1616
+ return { next: without(collab, strings(payload["removed"])) };
1617
+ }
1618
+ if (Array.isArray(payload["hidden"])) {
1619
+ return context.isHost ? { next: null, dropped: "hosts keep dismissed questions" } : { next: without(collab, strings(payload["hidden"])) };
1620
+ }
1621
+ if (payload["cleared"] === "whiteboard") {
1622
+ return { next: { ...collab, strokes: [] } };
1623
+ }
1624
+ const broadcast = broadcastFrom(payload["broadcast"]);
1625
+ if (broadcast !== null) {
1626
+ return { next: { ...collab, broadcast } };
1627
+ }
1628
+ return { next: null, dropped: "a collab packet with nothing this version understands" };
1629
+ }
1630
+ function assignedRoom(plan, identity) {
1631
+ if (plan === null || plan.state !== "open" || identity === null) return null;
1632
+ return plan.rooms.find((room) => room.identities.includes(identity)) ?? null;
1633
+ }
1634
+ function autoAssign(identities, rooms, exclude = []) {
1635
+ const count2 = Math.max(1, Math.floor(rooms));
1636
+ const out = Array.from({ length: count2 }, () => []);
1637
+ const skip = new Set(exclude);
1638
+ let index = 0;
1639
+ for (const identity of identities) {
1640
+ if (skip.has(identity)) continue;
1641
+ out[index % count2].push(identity);
1642
+ index += 1;
1643
+ }
1644
+ return out;
1645
+ }
1646
+ function breakoutRemainingMs(plan, now) {
1647
+ if (plan === null || plan.state !== "open" || plan.endsAt === null) return null;
1648
+ const end = Date.parse(plan.endsAt);
1649
+ return Number.isNaN(end) ? null : Math.max(0, end - now);
1650
+ }
1651
+ function defaultCaptionLanguage(settings, viewerLanguage) {
1652
+ const configured = settings?.captionTranslationLanguage.trim() ?? "";
1653
+ if (configured.length > 0) return configured;
1654
+ const own = (viewerLanguage ?? "").trim();
1655
+ return own.length > 0 ? own : "en";
1656
+ }
1657
+ var CAPTION_LANGUAGES = [
1658
+ { tag: "en", name: "English" },
1659
+ { tag: "es", name: "Spanish" },
1660
+ { tag: "fr", name: "French" },
1661
+ { tag: "de", name: "German" },
1662
+ { tag: "it", name: "Italian" },
1663
+ { tag: "pt", name: "Portuguese" },
1664
+ { tag: "nl", name: "Dutch" },
1665
+ { tag: "pl", name: "Polish" },
1666
+ { tag: "ru", name: "Russian" },
1667
+ { tag: "uk", name: "Ukrainian" },
1668
+ { tag: "tr", name: "Turkish" },
1669
+ { tag: "ar", name: "Arabic" },
1670
+ { tag: "he", name: "Hebrew" },
1671
+ { tag: "fa", name: "Persian" },
1672
+ { tag: "hi", name: "Hindi" },
1673
+ { tag: "bn", name: "Bengali" },
1674
+ { tag: "zh", name: "Chinese" },
1675
+ { tag: "ja", name: "Japanese" },
1676
+ { tag: "ko", name: "Korean" },
1677
+ { tag: "vi", name: "Vietnamese" },
1678
+ { tag: "th", name: "Thai" },
1679
+ { tag: "id", name: "Indonesian" },
1680
+ { tag: "sv", name: "Swedish" }
1681
+ ];
1682
+ async function loadMeetingActivities(api, meetingId, roomToken = null) {
1683
+ let response;
1684
+ try {
1685
+ response = await api.request({
1686
+ path: `${MEET_ROUTES.collab}?meeting_id=${encodeURIComponent(meetingId)}`,
1687
+ method: "GET",
1688
+ operation: "meet.loadMeetingActivities",
1689
+ requireAuth: roomToken === null,
1690
+ ...roomToken !== null ? { headers: { [ROOM_TOKEN_HEADER]: roomToken } } : {}
1691
+ });
1692
+ } catch (error) {
1693
+ return {
1694
+ collab: null,
1695
+ refusal: {
1696
+ message: `Polls, questions and the whiteboard could not be read (${error.message}).`,
1697
+ remedy: error.remedy ?? "Reload the page."
1698
+ }
1699
+ };
1700
+ }
1701
+ if (!response.ok) {
1702
+ let body = null;
1703
+ try {
1704
+ body = await response.json();
1705
+ } catch {
1706
+ body = null;
1707
+ }
1708
+ const said = readServerRefusal(body);
1709
+ return {
1710
+ collab: null,
1711
+ refusal: {
1712
+ message: said?.reason ?? `Polls and questions could not be read (the server answered ${response.status}).`,
1713
+ remedy: said?.remedy || "Reload the page."
1714
+ }
1715
+ };
1716
+ }
1717
+ return { collab: collabFromState(await response.json(), EMPTY_COLLAB), refusal: null };
1718
+ }
1719
+
1366
1720
  // src/core/repository.ts
1367
1721
  var MEET_SCHEMA = "communication";
1368
1722
  var TABLES = {
@@ -1438,7 +1792,19 @@ var INVITEE_COLUMNS = "id,meeting_id,invitee_user_id,email,display_name,role,rsv
1438
1792
  var RECORDING_COLUMNS = "id,meeting_id,state,started_at,ended_at,started_by,file_id,failure_reason";
1439
1793
  var SEGMENT_COLUMNS = "id,meeting_id,identity,speaker,text,started_at,is_final,metadata";
1440
1794
  var NOTE_COLUMNS = "id,meeting_id,text,kind,assignee_user_id,created_at,metadata";
1441
- var ATTENDEE_COLUMNS = "id,meeting_id,identity,participant_user_id,display_name,avatar_url,role,admission_state,is_agent,joined_at,left_at";
1795
+ 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";
1796
+ function projectStays(value) {
1797
+ if (!Array.isArray(value)) return [];
1798
+ const stays = [];
1799
+ for (const item of value) {
1800
+ if (item === null || typeof item !== "object") continue;
1801
+ const joined = item["joined_at"];
1802
+ const left = item["left_at"];
1803
+ if (typeof joined !== "string" || joined.length === 0) continue;
1804
+ stays.push({ joinedAt: joined, leftAt: typeof left === "string" && left.length > 0 ? left : null });
1805
+ }
1806
+ return stays;
1807
+ }
1442
1808
  function text(row, key) {
1443
1809
  const value = row[key];
1444
1810
  return typeof value === "string" && value.length > 0 ? value : null;
@@ -1537,7 +1903,7 @@ var OCCURRENCE_STATES = ["scheduled", "moved", "cancelled"];
1537
1903
  function rsvpState(value) {
1538
1904
  return RSVP_STATES.includes(value) ? value : "needs_action";
1539
1905
  }
1540
- function num(row, key) {
1906
+ function num2(row, key) {
1541
1907
  const value = row[key];
1542
1908
  return typeof value === "number" ? value : null;
1543
1909
  }
@@ -1559,7 +1925,7 @@ function projectInvitee(row) {
1559
1925
  rsvpState: rsvpState(text(row, "rsvp_state")),
1560
1926
  respondedAt: when(row, "responded_at"),
1561
1927
  rsvpNote: text(row, "rsvp_note"),
1562
- lastSentSequence: num(row, "last_sent_sequence"),
1928
+ lastSentSequence: num2(row, "last_sent_sequence"),
1563
1929
  lastSentAt: when(row, "last_sent_at")
1564
1930
  };
1565
1931
  }
@@ -1570,10 +1936,10 @@ function projectOccurrence(row) {
1570
1936
  meetingId: asMeetingId(requireText(row, "meeting_id", operation)),
1571
1937
  originalStart: when(row, "original_start") ?? requireText(row, "original_start", operation),
1572
1938
  occurrenceStart: when(row, "occurrence_start") ?? requireText(row, "occurrence_start", operation),
1573
- durationMinutes: num(row, "duration_minutes"),
1939
+ durationMinutes: num2(row, "duration_minutes"),
1574
1940
  // An unknown state is shown as scheduled, never dropped: the row is real.
1575
1941
  state: OCCURRENCE_STATES.includes(state) ? state : "scheduled",
1576
- sequence: num(row, "sequence") ?? 0
1942
+ sequence: num2(row, "sequence") ?? 0
1577
1943
  };
1578
1944
  }
1579
1945
  function projectUpcoming(row) {
@@ -1602,9 +1968,9 @@ function projectOverride(row) {
1602
1968
  originalStart: when(row, "original_start") ?? requireText(row, "original_start", operation),
1603
1969
  state: text(row, "state") === "moved" ? "moved" : "cancelled",
1604
1970
  newStart: when(row, "new_start"),
1605
- newDurationMinutes: num(row, "new_duration_minutes"),
1971
+ newDurationMinutes: num2(row, "new_duration_minutes"),
1606
1972
  reason: text(row, "reason"),
1607
- calendarSequence: num(row, "calendar_sequence") ?? 0,
1973
+ calendarSequence: num2(row, "calendar_sequence") ?? 0,
1608
1974
  deletedAt: when(row, "deleted_at")
1609
1975
  };
1610
1976
  }
@@ -1628,7 +1994,7 @@ function projectRsvpInvitation(body) {
1628
1994
  title: text(meeting, "title") ?? "Meeting",
1629
1995
  slug: requireText(meeting, "slug", operation),
1630
1996
  scheduledFor: when(meeting, "scheduled_for"),
1631
- durationMinutes: num(meeting, "duration_minutes"),
1997
+ durationMinutes: num2(meeting, "duration_minutes"),
1632
1998
  timeZone: text(meeting, "time_zone") ?? "UTC",
1633
1999
  agenda: text(meeting, "agenda"),
1634
2000
  recurrenceRule: text(meeting, "recurrence_rule"),
@@ -1662,6 +2028,7 @@ function changesPatch(changes) {
1662
2028
  patch["scheduled_duration_minutes"] = changes.scheduledDurationMinutes;
1663
2029
  }
1664
2030
  if (changes.recurrenceRule !== void 0) patch["recurrence_rule"] = changes.recurrenceRule;
2031
+ if (changes.keepExceptions !== void 0) patch["keep_exceptions"] = changes.keepExceptions;
1665
2032
  return patch;
1666
2033
  }
1667
2034
  function createMeetRepository(options) {
@@ -1808,7 +2175,8 @@ function createMeetRepository(options) {
1808
2175
  admissionState: states.includes(admission) ? admission : "admitted",
1809
2176
  isAgent: bool(row, "is_agent", false),
1810
2177
  joinedAt: text(row, "joined_at"),
1811
- leftAt: text(row, "left_at")
2178
+ leftAt: text(row, "left_at"),
2179
+ sessions: projectStays(row["sessions"])
1812
2180
  };
1813
2181
  }
1814
2182
  function requireOrg(organizationId, operation) {
@@ -2202,8 +2570,8 @@ function projectLibraryRecording(row) {
2202
2570
  fileId: text(row, "file_id"),
2203
2571
  startedAt: text(row, "started_at"),
2204
2572
  endedAt: text(row, "ended_at"),
2205
- durationMs: num(row, "duration_ms"),
2206
- sizeBytes: num(row, "size_bytes"),
2573
+ durationMs: num2(row, "duration_ms"),
2574
+ sizeBytes: num2(row, "size_bytes"),
2207
2575
  failureReason: text(row, "failure_reason"),
2208
2576
  archivedAt: text(row, "deleted_at"),
2209
2577
  organizationId: asOrganizationId(requireText(row, "organization_id", operation))
@@ -2230,7 +2598,7 @@ function projectSearchHit(row) {
2230
2598
  speaker: text(row, "speaker"),
2231
2599
  snippet: text(row, "snippet") ?? "",
2232
2600
  at: text(row, "said_at"),
2233
- rank: num(row, "rank") ?? 0
2601
+ rank: num2(row, "rank") ?? 0
2234
2602
  };
2235
2603
  }
2236
2604
  function snippetRuns(snippet) {
@@ -2247,567 +2615,1031 @@ function snippetRuns(snippet) {
2247
2615
  return runs;
2248
2616
  }
2249
2617
 
2250
- // src/core/room-events.ts
2251
- var RECORDING_STATES = [
2252
- "idle",
2253
- "starting",
2254
- "recording",
2255
- "stopping",
2256
- "processing",
2257
- "available",
2258
- "expired",
2259
- "failed"
2260
- ];
2261
- var ROOM_EVENT_PREFIX = "matrx.meet.";
2262
- var ROOM_EVENTS = {
2263
- /** One durable `meet_notes` row was written. Payload `{ note: <row> }`. */
2264
- note: "matrx.meet.note",
2265
- /** `meet_meetings.metadata.note_taker` changed. Payload `{ meeting_id, note_taker }`. */
2266
- noteTaker: "matrx.meet.note_taker",
2267
- /** A wrap-up pass finished. Payload `{ meeting_id, note_ids, segment_count }`. */
2268
- wrapUp: "matrx.meet.wrap_up",
2269
- /** The meeting ended for everyone. Payload `{ meeting_id, ended_at, trigger }`. */
2270
- ended: "matrx.meet.ended",
2271
- /**
2272
- * A `meet_recordings` row changed state. Payload
2273
- * `{ meeting_id, recording: { id, state, started_by } }`.
2274
- *
2275
- * 🚨 THIS IS THE ONLY WAY A GUEST LEARNS A RECORDING IS RUNNING. PreJoin
2276
- * promises every joiner an indicator, and `anon` holds no SELECT grant on
2277
- * `meet_recordings` (MRI-A10 settled that widening it is forbidden), so
2278
- * without this packet the promise was a sentence with nothing behind it: on
2279
- * production 2026-09-09 the guest lane showed no indicator at all while the
2280
- * host recorded (MRI-C8).
2281
- */
2282
- recording: "matrx.meet.recording",
2283
- /**
2284
- * A chat message to everyone was SAVED (Meet wave 2). Payload
2285
- * `{ message: { id, meeting_id, sender_identity, sender_name, body, created_at } }`.
2286
- */
2287
- chat: "matrx.meet.chat",
2288
- /**
2289
- * A host or co-host acted (Meet wave 2). Payload `{ meeting_id, kind, … }`:
2290
- * `muted {source, by}` · `ask-unmute {by}` · `removed {by}` — addressed to the
2291
- * person concerned; `lower-hands {identities: [...] | "*"}` · `role {identity,
2292
- * role}` — to everyone.
2293
- */
2294
- moderation: "matrx.meet.moderation"
2295
- };
2296
- function isRoomEventTopic(topic) {
2297
- return topic.startsWith(ROOM_EVENT_PREFIX);
2298
- }
2299
- function object(value) {
2300
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
2618
+ // src/core/names.ts
2619
+ function isGuestIdentity(identity) {
2620
+ return String(identity).startsWith("guest:");
2301
2621
  }
2302
- function text2(value) {
2303
- return typeof value === "string" && value.length > 0 ? value : null;
2622
+ function isAgentIdentity(identity) {
2623
+ return String(identity).startsWith("agent:");
2304
2624
  }
2305
- var HANDLED = { handled: true };
2306
- function ignored(reason) {
2307
- return { handled: false, ignored: reason };
2625
+ var NOTE_TAKER_PRODUCT_NAME = "Matrx note-taker";
2626
+ var AGENT_PRODUCT_NAME = "Matrx agent";
2627
+ var UNNAMED_GUEST = "Unnamed guest";
2628
+ var UNNAMED_PARTICIPANT = "Unnamed participant";
2629
+ var PLACEHOLDER_NAMES = [
2630
+ NOTE_TAKER_PRODUCT_NAME,
2631
+ AGENT_PRODUCT_NAME,
2632
+ UNNAMED_GUEST,
2633
+ UNNAMED_PARTICIPANT
2634
+ ];
2635
+ function isPlaceholderName(name) {
2636
+ return typeof name === "string" && PLACEHOLDER_NAMES.includes(name.trim());
2308
2637
  }
2309
- function applyRoomEvent(args) {
2310
- const { store, topic } = args;
2311
- if (!isRoomEventTopic(topic)) {
2312
- return ignored(`${topic} is not a Meet room event`);
2313
- }
2314
- const payload = object(args.payload);
2315
- if (payload === null) {
2316
- return ignored(`${topic} carried no object payload`);
2317
- }
2318
- const now = args.now ?? (() => Date.now());
2319
- switch (topic) {
2320
- case ROOM_EVENTS.note:
2321
- return applyNote(store, payload);
2322
- case ROOM_EVENTS.noteTaker:
2323
- return applyNoteTaker(store, payload);
2324
- case ROOM_EVENTS.wrapUp:
2325
- return applyWrapUp(store, payload, now);
2326
- case ROOM_EVENTS.ended:
2327
- return applyEnded(store, payload);
2328
- case ROOM_EVENTS.recording:
2329
- return applyRecording(store, payload);
2330
- case ROOM_EVENTS.chat:
2331
- return applyChat(store, payload);
2332
- case ROOM_EVENTS.moderation:
2333
- return applyModeration(store, payload, now);
2334
- default:
2335
- return ignored(`${topic} is not known to this version of @ai-matrx/meet`);
2638
+ function placeholderNameFor(identity) {
2639
+ const key = String(identity);
2640
+ if (isAgentIdentity(key)) {
2641
+ return key.startsWith("agent:note_taker:") || key.startsWith("agent:note-taker") ? NOTE_TAKER_PRODUCT_NAME : AGENT_PRODUCT_NAME;
2336
2642
  }
2643
+ return isGuestIdentity(key) ? UNNAMED_GUEST : UNNAMED_PARTICIPANT;
2337
2644
  }
2338
- function applyNote(store, payload) {
2339
- const row = object(payload["note"]);
2340
- if (row === null) return ignored("a note event with no `note` row");
2341
- if (text2(row["id"]) === null || text2(row["meeting_id"]) === null) {
2342
- return ignored("a note row with no id or meeting_id");
2343
- }
2344
- const meeting = store.snapshot().meeting;
2345
- if (meeting !== null && row["meeting_id"] !== meeting.id) {
2346
- return ignored("a note for a different meeting than this room is showing");
2347
- }
2348
- try {
2349
- store.upsertNote({ ...projectNote(row), source: "room" });
2350
- } catch (error) {
2351
- return ignored(`a note row this client could not read: ${String(error)}`);
2352
- }
2353
- return HANDLED;
2645
+ function isRealName(identity, name) {
2646
+ if (typeof name !== "string") return false;
2647
+ const trimmed = name.trim();
2648
+ if (trimmed.length === 0) return false;
2649
+ if (trimmed === String(identity)) return false;
2650
+ if (isPlaceholderName(trimmed)) return false;
2651
+ return true;
2354
2652
  }
2355
- function applyNoteTaker(store, payload) {
2356
- const noteTaker = object(payload["note_taker"]);
2357
- if (noteTaker === null) return ignored("a note-taker event with no state");
2358
- const meeting = store.snapshot().meeting;
2359
- if (meeting === null) {
2360
- return ignored("a note-taker state with no meeting record to attach it to");
2361
- }
2362
- const meetingId = text2(payload["meeting_id"]);
2363
- if (meetingId !== null && meetingId !== meeting.id) {
2364
- return ignored("a note-taker state for a different meeting");
2365
- }
2366
- const next = {
2367
- ...meeting,
2368
- metadata: { ...meeting.metadata, note_taker: noteTaker }
2369
- };
2370
- store.patch({ meeting: next });
2371
- return HANDLED;
2653
+ function learnName(book, identity, name) {
2654
+ const key = String(identity);
2655
+ if (key.length === 0) return book;
2656
+ if (!isRealName(key, name)) return book;
2657
+ const learned = name.trim();
2658
+ if (book[key] === learned) return book;
2659
+ return { ...book, [key]: learned };
2372
2660
  }
2373
- function applyWrapUp(store, payload, now) {
2374
- const meetingId = text2(payload["meeting_id"]);
2375
- if (meetingId === null) return ignored("a wrap-up event with no meeting id");
2376
- const meeting = store.snapshot().meeting;
2377
- if (meeting !== null && meetingId !== meeting.id) {
2378
- return ignored("a wrap-up for a different meeting");
2379
- }
2380
- const rawIds = payload["note_ids"];
2381
- const noteIds = Array.isArray(rawIds) ? rawIds.filter((value) => typeof value === "string") : [];
2382
- const segmentCount = payload["segment_count"];
2383
- const announcement = {
2384
- meetingId: asMeetingId(meetingId),
2385
- noteIds,
2386
- segmentCount: typeof segmentCount === "number" ? segmentCount : 0,
2387
- at: new Date(now()).toISOString()
2388
- };
2389
- store.announceWrapUp(announcement);
2390
- return HANDLED;
2391
- }
2392
- function applyRecording(store, payload) {
2393
- const row = object(payload["recording"]);
2394
- if (row === null) return ignored("a recording event with no `recording` row");
2395
- const meetingId = text2(payload["meeting_id"]);
2396
- const meeting = store.snapshot().meeting;
2397
- if (meeting !== null && meetingId !== null && meetingId !== meeting.id) {
2398
- return ignored("a recording state for a different meeting than this room is showing");
2399
- }
2400
- const state = text2(row["state"]);
2401
- if (state === null || !RECORDING_STATES.includes(state)) {
2402
- return ignored(`a recording state this version does not know: ${String(row["state"])}`);
2403
- }
2404
- const by = text2(row["started_by"]);
2405
- store.setRecording(
2406
- state,
2407
- by === null ? null : by,
2408
- "room",
2409
- text2(row["id"])
2410
- );
2411
- return HANDLED;
2412
- }
2413
- function applyEnded(store, payload) {
2414
- const meetingId = text2(payload["meeting_id"]);
2415
- const meeting = store.snapshot().meeting;
2416
- if (meeting === null) return ignored("an ended event with no meeting record");
2417
- if (meetingId !== null && meetingId !== meeting.id) {
2418
- return ignored("an ended event for a different meeting");
2419
- }
2420
- const endedAt = text2(payload["ended_at"]) ?? (/* @__PURE__ */ new Date()).toISOString();
2421
- if (meeting.endedAt === endedAt) return HANDLED;
2422
- store.patch({ meeting: { ...meeting, endedAt } });
2423
- return HANDLED;
2424
- }
2425
- function chatFromRow(row) {
2426
- const id = text2(row["id"]);
2427
- const identity = text2(row["sender_identity"]);
2428
- const body = text2(row["body"]);
2429
- if (id === null || identity === null || body === null) return null;
2430
- return {
2431
- id,
2432
- identity: asParticipantIdentity(identity),
2433
- displayName: text2(row["sender_name"]) ?? "Participant",
2434
- text: body,
2435
- at: text2(row["created_at"]) ?? (/* @__PURE__ */ new Date()).toISOString(),
2436
- // `false` only from a server whose chat table has not landed: the room heard
2437
- // it, nobody will see it later — and the line says so.
2438
- saved: row["saved"] !== false
2439
- };
2440
- }
2441
- function applyChat(store, payload) {
2442
- const row = object(payload["message"]);
2443
- if (row === null) return ignored("a chat event with no `message` row");
2444
- const meeting = store.snapshot().meeting;
2445
- if (meeting !== null && text2(row["meeting_id"]) !== null && row["meeting_id"] !== meeting.id) {
2446
- return ignored("a chat message for a different meeting");
2447
- }
2448
- const message = chatFromRow(row);
2449
- if (message === null) return ignored("a chat row with no id, sender or body");
2450
- store.addChat(message);
2451
- return HANDLED;
2661
+ function learnNames(book, entries) {
2662
+ let next = book;
2663
+ for (const entry of entries) next = learnName(next, entry.identity, entry.displayName);
2664
+ return next;
2452
2665
  }
2453
- function moderationText(kind, payload) {
2454
- const by = text2(payload["by"]) ?? "The host";
2455
- switch (kind) {
2456
- case "muted": {
2457
- const source = text2(payload["source"]) ?? "microphone";
2458
- return source === "microphone" ? `${by} muted you.` : source === "camera" ? `${by} turned off your camera.` : `${by} stopped your screen share.`;
2459
- }
2460
- case "ask-unmute":
2461
- return `${by} is asking you to unmute.`;
2462
- case "removed":
2463
- return `${by} removed you from this meeting.`;
2464
- default:
2465
- return null;
2466
- }
2666
+ function displayNameFor(book, identity, preferred) {
2667
+ const key = String(identity);
2668
+ if (isRealName(key, preferred)) return preferred.trim();
2669
+ const known = book[key];
2670
+ if (known !== void 0) return known;
2671
+ return placeholderNameFor(key);
2467
2672
  }
2468
- function applyModeration(store, payload, now) {
2469
- const snapshot = store.snapshot();
2470
- const meetingId = text2(payload["meeting_id"]);
2471
- if (snapshot.meeting !== null && meetingId !== null && meetingId !== snapshot.meeting.id) {
2472
- return ignored("a moderation event for a different meeting");
2473
- }
2474
- const kind = text2(payload["kind"]);
2475
- switch (kind) {
2476
- case "lower-hands": {
2477
- const raw = payload["identities"];
2478
- const everyone = raw === "*";
2479
- const named = new Set(
2480
- Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []
2481
- );
2482
- for (const participant of snapshot.participants) {
2483
- if (participant.handRaised && (everyone || named.has(participant.identity))) {
2484
- store.setHand(participant.identity, false);
2485
- }
2673
+
2674
+ // src/core/recording.ts
2675
+ function createRecordingController(options) {
2676
+ async function post(route, body) {
2677
+ let response;
2678
+ try {
2679
+ response = await options.api.request({
2680
+ path: route,
2681
+ method: "POST",
2682
+ // A GUEST CAN NEVER START OR STOP A RECORDING (R5) — refused here,
2683
+ // before the wire, with the same sentence the hidden control implies.
2684
+ requireAuth: true,
2685
+ operation: "meet.recording",
2686
+ body
2687
+ });
2688
+ } catch (error) {
2689
+ if (error.code === "session-unavailable") {
2690
+ throw new MeetError(
2691
+ "forbidden",
2692
+ "meet.recording: recording requires an authenticated host",
2693
+ "A guest can never start or stop a recording (R5). The control is hidden for guests."
2694
+ );
2486
2695
  }
2487
- return HANDLED;
2696
+ throw error;
2488
2697
  }
2489
- case "role": {
2490
- const identity = text2(payload["identity"]);
2491
- const role = text2(payload["role"]);
2492
- if (identity === null || role === null || !ROLES.includes(role)) {
2493
- return ignored("a role event with no identity or an unknown role");
2494
- }
2495
- if (identity === snapshot.localIdentity) {
2496
- store.patch({
2497
- grants: { ...snapshot.grants, role },
2498
- moderationNotice: {
2499
- kind: "role",
2500
- text: role === "host" ? "You are now the host." : role === "cohost" ? "You are now a co-host." : "You are no longer a co-host.",
2501
- at: now()
2502
- }
2503
- });
2504
- }
2505
- return HANDLED;
2698
+ if (response.status === 403) {
2699
+ throw new MeetError(
2700
+ "forbidden",
2701
+ "meet.recording: the server refused this recording action",
2702
+ "The actor is not a host of this meeting, or the org policy is `disabled`. The server is the authority; do not re-decide it on the client."
2703
+ );
2506
2704
  }
2507
- case "muted":
2508
- case "ask-unmute":
2509
- case "removed": {
2510
- const sentence = moderationText(kind, payload);
2511
- if (sentence === null) return ignored("a moderation event with no sentence");
2512
- store.patch({ moderationNotice: { kind, text: sentence, at: now() } });
2513
- return HANDLED;
2705
+ if (!response.ok) {
2706
+ throw new MeetError(
2707
+ "transport",
2708
+ `meet.recording: the server returned ${response.status}`,
2709
+ "Egress could not be controlled. The recording state stays where it was rather than showing a recording indicator for a recording that is not running."
2710
+ );
2514
2711
  }
2515
- default:
2516
- return ignored(`a moderation kind this version does not know: ${String(kind)}`);
2517
2712
  }
2518
- }
2519
- var ROLES = ["host", "cohost", "participant", "guest"];
2520
-
2521
- // src/core/devices.ts
2522
- var KINDS = ["audioinput", "videoinput", "audiooutput"];
2523
- function isDeviceKind(value) {
2524
- return value === "audioinput" || value === "videoinput" || value === "audiooutput";
2525
- }
2526
- function createWebDeviceStorage(key = "mx.meet.devices") {
2527
- const store = globalThis.localStorage;
2528
- if (store === void 0) return createMemoryDeviceStorage();
2529
2713
  return {
2530
- durable: true,
2531
- read() {
2714
+ canStart(meeting, isHost) {
2715
+ if (meeting === null) return false;
2716
+ if (meeting.recordingPolicy === "disabled") return false;
2717
+ if (meeting.recordingPolicy === "always-on") return false;
2718
+ return isHost;
2719
+ },
2720
+ async start({ meetingId, roomName, by }) {
2721
+ options.store.setRecording("starting", by, "local");
2532
2722
  try {
2533
- const raw = store.getItem(key);
2534
- if (raw === null) return {};
2535
- const parsed = JSON.parse(raw);
2536
- return typeof parsed === "object" && parsed !== null ? parsed : {};
2537
- } catch {
2538
- return {};
2723
+ await post(MEET_ROUTES.recordingStart, {
2724
+ meeting_id: meetingId,
2725
+ room_name: roomName
2726
+ });
2727
+ } catch (error) {
2728
+ options.store.setRecording("idle", null, "local");
2729
+ throw error;
2539
2730
  }
2731
+ options.announce?.("starting", by);
2540
2732
  },
2541
- write(value) {
2733
+ async stop({ meetingId, by }) {
2734
+ options.store.setRecording("stopping", options.store.snapshot().recordingStartedBy, "local");
2542
2735
  try {
2543
- store.setItem(key, JSON.stringify(value));
2544
- } catch {
2736
+ await post(MEET_ROUTES.recordingStop, { meeting_id: meetingId });
2737
+ } catch (error) {
2738
+ options.store.setRecording(
2739
+ "recording",
2740
+ options.store.snapshot().recordingStartedBy,
2741
+ "local"
2742
+ );
2743
+ throw error;
2744
+ }
2745
+ options.announce?.("stopping", by);
2746
+ },
2747
+ consentNotice(meeting) {
2748
+ if (meeting === null || meeting.recordingPolicy === "disabled") return null;
2749
+ if (meeting.recordingPolicy === "always-on") {
2750
+ return "This meeting is recorded and transcribed automatically. By joining, you consent to being recorded.";
2545
2751
  }
2752
+ return "The host can record and transcribe this meeting. You will see a recording indicator whenever it is running.";
2753
+ },
2754
+ async acknowledge(meetingId) {
2755
+ await options.acknowledgeConsent?.(meetingId);
2546
2756
  }
2547
2757
  };
2548
2758
  }
2549
- function createMemoryDeviceStorage() {
2550
- let value = {};
2551
- return {
2552
- durable: false,
2553
- read: () => value,
2554
- write: (next) => {
2555
- value = next;
2556
- }
2557
- };
2759
+ var RECORDING_IN_FLIGHT = [
2760
+ "starting",
2761
+ "recording",
2762
+ "stopping"
2763
+ ];
2764
+ function isRecordingInFlight(state) {
2765
+ return RECORDING_IN_FLIGHT.includes(state);
2558
2766
  }
2559
- function createDeviceManager(options) {
2560
- const storage = options.storage ?? createMemoryDeviceStorage();
2561
- const listeners = /* @__PURE__ */ new Set();
2562
- const stored = storage.read();
2563
- let devices = {
2564
- audioinput: [],
2565
- videoinput: [],
2566
- audiooutput: []
2567
- };
2568
- let selected = {
2569
- audioinput: stored["audioinput"] ?? null,
2570
- videoinput: stored["videoinput"] ?? null,
2571
- audiooutput: stored["audiooutput"] ?? null
2572
- };
2573
- let labelsHidden = true;
2574
- let permission = "unknown";
2575
- let fallbackNotice = null;
2576
- let current = {
2577
- devices,
2578
- selected,
2579
- labelsHidden,
2580
- permission,
2581
- fallbackNotice
2582
- };
2583
- function snapshot() {
2584
- return current;
2585
- }
2586
- function commit() {
2587
- current = { devices, selected, labelsHidden, permission, fallbackNotice };
2588
- return current;
2589
- }
2590
- function emit() {
2591
- const next = commit();
2592
- options.onChange?.(next);
2593
- for (const listener of listeners) listener(next);
2594
- return next;
2595
- }
2596
- async function enumerate() {
2597
- let raw;
2598
- try {
2599
- raw = await options.media.enumerateDevices();
2600
- } catch (error) {
2601
- throw normalizeDeviceError(error, "meet.enumerateDevices");
2602
- }
2603
- const grouped = {
2604
- audioinput: [],
2605
- videoinput: [],
2606
- audiooutput: []
2607
- };
2608
- let anyLabel = false;
2609
- for (const device of raw) {
2610
- if (!isDeviceKind(device.kind)) continue;
2611
- if (device.label.length > 0) anyLabel = true;
2612
- grouped[device.kind].push({
2613
- deviceId: device.deviceId,
2614
- kind: device.kind,
2615
- label: device.label
2616
- });
2617
- }
2618
- devices = grouped;
2619
- labelsHidden = !anyLabel && raw.length > 0;
2620
- const lost = [];
2621
- for (const kind of KINDS) {
2622
- const current2 = selected[kind];
2623
- if (current2 === null) continue;
2624
- if (!grouped[kind].some((device) => device.deviceId === current2)) {
2625
- selected = { ...selected, [kind]: null };
2626
- lost.push(kind);
2627
- }
2628
- }
2629
- fallbackNotice = lost.length === 0 ? null : `A remembered ${lost.join(" and ")} device is no longer connected \u2014 using the system default. Pick another in the device menu.`;
2630
- return emit();
2631
- }
2632
- const onDeviceChange = () => {
2633
- void enumerate();
2634
- };
2635
- options.media.addEventListener?.("devicechange", onDeviceChange);
2636
- return {
2637
- snapshot,
2638
- refresh: enumerate,
2639
- async requestPermission(kinds) {
2640
- if (!kinds.audio && !kinds.video) {
2641
- throw new MeetError(
2642
- "misconfigured",
2643
- "meet.requestPermission: asked for neither audio nor video",
2644
- "Request at least one kind. Prompting for nothing silently resolves and leaves labels hidden, which looks like a broken picker."
2645
- );
2646
- }
2647
- try {
2648
- const stream = await options.media.getUserMedia({
2649
- ...kinds.audio ? { audio: true } : {},
2650
- ...kinds.video ? { video: true } : {}
2651
- });
2652
- for (const track of stream.getTracks()) track.stop();
2653
- permission = "granted";
2654
- } catch (error) {
2655
- permission = "denied";
2656
- emit();
2657
- throw normalizeDeviceError(error, "meet.requestPermission");
2658
- }
2659
- return enumerate();
2660
- },
2661
- select(kind, deviceId) {
2662
- selected = { ...selected, [kind]: deviceId };
2663
- fallbackNotice = null;
2664
- storage.write({
2665
- ...storage.read(),
2666
- [kind]: deviceId
2667
- });
2668
- return emit();
2669
- },
2670
- resolve(kind) {
2671
- const current2 = selected[kind];
2672
- if (current2 !== null && devices[kind].some((device) => device.deviceId === current2)) {
2673
- return current2;
2674
- }
2675
- return devices[kind][0]?.deviceId ?? null;
2676
- },
2677
- subscribe(listener) {
2678
- listeners.add(listener);
2679
- return () => listeners.delete(listener);
2680
- },
2681
- dispose() {
2682
- options.media.removeEventListener?.("devicechange", onDeviceChange);
2683
- listeners.clear();
2684
- }
2685
- };
2686
- }
2687
-
2688
- // src/core/format.ts
2689
- function participantSummary(participants) {
2690
- const names = participants.filter((p) => !p.isLocal).map((p) => p.displayName);
2691
- if (names.length === 0) return "Just you";
2692
- if (names.length === 1) return names[0] ?? "";
2693
- if (names.length === 2) return `${names[0]} and ${names[1]}`;
2694
- return `${names[0]}, ${names[1]} and ${names.length - 2} ${names.length - 2 === 1 ? "other" : "others"}`;
2695
- }
2696
- function callStateLabel(state, outgoing) {
2767
+ function recordingLabel(state) {
2697
2768
  switch (state) {
2698
- case "dialing":
2699
- case "ringing":
2700
- return outgoing ? "Calling\u2026" : "Incoming call";
2701
- case "connecting":
2702
- return "Connecting\u2026";
2703
- case "active":
2704
- return "In call";
2705
- case "declined":
2706
- return outgoing ? "Declined" : "You declined";
2707
- case "missed":
2708
- return outgoing ? "No answer" : "Missed call";
2709
- case "busy":
2710
- return outgoing ? "On another call" : "You were busy";
2711
- case "cancelled":
2712
- return outgoing ? "Cancelled" : "Cancelled by caller";
2713
- case "ended":
2714
- return "Call ended";
2769
+ case "starting":
2770
+ return "Starting recording\u2026";
2771
+ case "recording":
2772
+ return "Recording";
2773
+ case "stopping":
2774
+ return "Stopping recording\u2026";
2775
+ case "processing":
2776
+ return "Processing recording\u2026";
2777
+ case "available":
2778
+ return "Recording ready";
2779
+ case "expired":
2780
+ return "Recording expired";
2715
2781
  case "failed":
2716
- return "Call failed";
2782
+ return "Recording failed";
2717
2783
  default:
2718
- return "Call";
2784
+ return null;
2719
2785
  }
2720
2786
  }
2721
- function countLabel(count2, singular, plural = `${singular}s`) {
2722
- return `${count2} ${count2 === 1 ? singular : plural}`;
2723
- }
2724
2787
 
2725
- // src/core/invitations.ts
2726
- var CALL_INVITE_KIND = "meet.call-invite";
2727
- var CALL_INVITE_VERSION = 1;
2728
- function callInviteAction(invite) {
2729
- return {
2730
- kind: CALL_INVITE_KIND,
2731
- version: CALL_INVITE_VERSION,
2732
- payload: {
2733
- invite_id: invite.id,
2734
- room_name: invite.roomName,
2735
- mode: invite.mode,
2736
- caller_name: invite.callerName,
2737
- expires_at: invite.expiresAt
2738
- }
2739
- };
2740
- }
2741
- function callInviteText(invite) {
2742
- return invite.mode === "video" ? `${invite.callerName} started a video call` : `${invite.callerName} started an audio call`;
2788
+ // src/core/attendance.ts
2789
+ var IDLE_TRACK = { enabled: false, unavailable: null };
2790
+ var IDLE_MEDIA = {
2791
+ microphone: IDLE_TRACK,
2792
+ camera: IDLE_TRACK
2793
+ };
2794
+ function unavailableFrom(error) {
2795
+ return { code: error.code, message: error.message, remedy: error.remedy };
2743
2796
  }
2744
- function createCallInviteHandler(options) {
2745
- const now = options.now ?? (() => Date.now());
2746
- return {
2747
- kind: CALL_INVITE_KIND,
2748
- versions: [CALL_INVITE_VERSION],
2749
- choices: (payload) => {
2750
- if (Date.parse(payload.expires_at) < now()) return [];
2751
- return [
2752
- { id: "join", label: payload.mode === "video" ? "Join video" : "Join call", tone: "primary" },
2753
- { id: "decline", label: "Decline", tone: "neutral" }
2754
- ];
2755
- },
2756
- summarize: (payload) => payload.mode === "video" ? `${payload.caller_name} started a video call` : `${payload.caller_name} started an audio call`,
2757
- execute: async (payload, context) => {
2758
- const settledAt = new Date(now()).toISOString();
2759
- const inviteId = asCallId(payload.invite_id);
2760
- const pending = await options.repository.pendingInvites(asUserId(String(context.actorId)));
2761
- const live = pending.find((invite) => invite.id === inviteId) ?? null;
2762
- if (live === null) {
2763
- return {
2764
- kind: CALL_INVITE_KIND,
2765
- messageId: context.messageId,
2766
- actorId: context.actorId,
2767
- outcome: "unavailable",
2768
- label: "This call has ended",
2769
- settledAt,
2770
- detail: "The invite is no longer ringing \u2014 it was answered elsewhere, cancelled, or it timed out. The chips are replaced by the outcome rather than left clickable."
2771
- };
2772
- }
2773
- if (context.choice === "decline") {
2774
- await options.calls.decline(inviteId);
2775
- return {
2776
- kind: CALL_INVITE_KIND,
2777
- messageId: context.messageId,
2778
- actorId: context.actorId,
2779
- outcome: "declined",
2780
- label: "Declined",
2781
- settledAt
2782
- };
2783
- }
2784
- const accepted = await options.calls.accept(inviteId, live.mode);
2785
- await options.onJoin(accepted);
2786
- return {
2787
- kind: CALL_INVITE_KIND,
2788
- messageId: context.messageId,
2789
- actorId: context.actorId,
2790
- outcome: "applied",
2791
- label: "Joined",
2792
- settledAt
2793
- };
2794
- }
2795
- };
2797
+ var LISTEN_ONLY_NOTICE = "You're attending without a microphone \u2014 others can't hear you";
2798
+ function isListenOnly(media) {
2799
+ return !media.microphone.enabled;
2796
2800
  }
2797
- var MEETING_INVITE_KIND = "meet.meeting-invite";
2798
- var MEETING_INVITE_VERSION = 1;
2799
- function meetingInviteAction(payload) {
2800
- return { kind: MEETING_INVITE_KIND, version: MEETING_INVITE_VERSION, payload };
2801
+ function listenOnlyNotice(media) {
2802
+ return isListenOnly(media) ? LISTEN_ONLY_NOTICE : null;
2801
2803
  }
2802
- function createMeetingInviteHandler(options) {
2803
- const now = options.now ?? (() => Date.now());
2804
- return {
2805
- kind: MEETING_INVITE_KIND,
2806
- versions: [MEETING_INVITE_VERSION],
2807
- // A DURABLE LINK NEVER EXPIRES (R3) — the chip stays useful after the
2808
- // meeting too, because that is when people go looking for the notes.
2809
- choices: () => [{ id: "open", label: "Open meeting", tone: "primary" }],
2810
- summarize: (payload) => payload.scheduled_for === null ? payload.title : `${payload.title} \u2014 ${payload.scheduled_for}`,
2804
+
2805
+ // src/core/store.ts
2806
+ var DEFAULT_EFFECTS = {
2807
+ noiseSuppression: true,
2808
+ background: "none",
2809
+ backgroundImage: null
2810
+ };
2811
+ var NO_GRANTS = {
2812
+ role: "participant",
2813
+ canRecord: false,
2814
+ canPublish: false,
2815
+ canSubscribe: false
2816
+ };
2817
+ var EMPTY = {
2818
+ phase: "idle",
2819
+ meeting: null,
2820
+ localIdentity: null,
2821
+ grants: NO_GRANTS,
2822
+ participants: [],
2823
+ activeSpeakers: [],
2824
+ pinned: null,
2825
+ layout: "grid",
2826
+ lobby: [],
2827
+ locked: false,
2828
+ recording: "idle",
2829
+ recordingStartedBy: null,
2830
+ recordingSource: "local",
2831
+ recordingId: null,
2832
+ chat: [],
2833
+ chatLoaded: false,
2834
+ chatHistoryError: null,
2835
+ reactions: [],
2836
+ spotlight: null,
2837
+ moderationNotice: null,
2838
+ actionNotice: null,
2839
+ privateChatRequest: null,
2840
+ effects: DEFAULT_EFFECTS,
2841
+ transcript: [],
2842
+ notes: [],
2843
+ names: {},
2844
+ wrapUp: null,
2845
+ noteTakerRefusal: null,
2846
+ media: IDLE_MEDIA,
2847
+ failure: null,
2848
+ collab: EMPTY_COLLAB,
2849
+ breakoutRoomId: null
2850
+ };
2851
+ function effectiveRole(snapshot) {
2852
+ const me = snapshot.participants.find(
2853
+ (participant) => participant.identity === snapshot.localIdentity
2854
+ );
2855
+ const fromRoster = me?.role ?? "participant";
2856
+ const fromToken = snapshot.grants.role;
2857
+ return ROLE_RANK[fromRoster] >= ROLE_RANK[fromToken] ? fromRoster : fromToken;
2858
+ }
2859
+ var REACTION_TTL_MS = 5e3;
2860
+ var CHAT_LIMIT = 200;
2861
+ function createMeetStore(initial = {}) {
2862
+ let state = { ...EMPTY, ...initial };
2863
+ const listeners = /* @__PURE__ */ new Set();
2864
+ function commit(next) {
2865
+ if (next === state) return;
2866
+ state = next;
2867
+ for (const listener of listeners) listener();
2868
+ }
2869
+ return {
2870
+ snapshot: () => state,
2871
+ subscribe(listener) {
2872
+ listeners.add(listener);
2873
+ return () => listeners.delete(listener);
2874
+ },
2875
+ patch(next) {
2876
+ commit({ ...state, ...next });
2877
+ },
2878
+ setParticipants(participants) {
2879
+ const names = learnNames(state.names, participants);
2880
+ const hands = new Map(state.participants.map((p) => [p.identity, p.handRaised]));
2881
+ const handTimes = new Map(state.participants.map((p) => [p.identity, p.handRaisedAt ?? null]));
2882
+ const quality = new Map(state.participants.map((p) => [p.identity, p.quality]));
2883
+ commit({
2884
+ ...state,
2885
+ names,
2886
+ participants: participants.map((participant) => ({
2887
+ ...participant,
2888
+ // 🚨 THE ONE ROSTER RESOLUTION. Every surface that renders a person —
2889
+ // the tile, the People row, the header summary, every Pin/Mute label —
2890
+ // reads `participant.displayName`, so this is the single place a raw
2891
+ // identity could reach a screen, and it is the place that asks the
2892
+ // name book. `roster-names.test.ts` fails if a token ever gets past it.
2893
+ displayName: displayNameFor(names, participant.identity, participant.displayName),
2894
+ handRaised: hands.get(participant.identity) ?? participant.handRaised,
2895
+ handRaisedAt: handTimes.get(participant.identity) ?? participant.handRaisedAt ?? null,
2896
+ quality: participant.quality === "unknown" ? quality.get(participant.identity) ?? "unknown" : participant.quality
2897
+ }))
2898
+ });
2899
+ },
2900
+ setSpeaking(identities) {
2901
+ const speaking = new Set(identities);
2902
+ commit({
2903
+ ...state,
2904
+ activeSpeakers: identities,
2905
+ participants: state.participants.map(
2906
+ (participant) => participant.speaking === speaking.has(participant.identity) ? participant : { ...participant, speaking: speaking.has(participant.identity) }
2907
+ )
2908
+ });
2909
+ },
2910
+ setQuality(identity, quality) {
2911
+ commit({
2912
+ ...state,
2913
+ participants: state.participants.map(
2914
+ (participant) => participant.identity === identity ? { ...participant, quality } : participant
2915
+ )
2916
+ });
2917
+ },
2918
+ setHand(identity, raised, at) {
2919
+ commit({
2920
+ ...state,
2921
+ participants: state.participants.map(
2922
+ (participant) => participant.identity === identity ? {
2923
+ ...participant,
2924
+ handRaised: raised,
2925
+ // A hand that is ALREADY up keeps its place in the queue when
2926
+ // it is re-announced (to a late joiner): the first time wins.
2927
+ handRaisedAt: raised ? participant.handRaised && participant.handRaisedAt != null ? participant.handRaisedAt : at ?? Date.now() : null
2928
+ } : participant
2929
+ )
2930
+ });
2931
+ },
2932
+ addChat(message) {
2933
+ if (state.chat.some((existing) => existing.id === message.id)) return;
2934
+ const chat = [...state.chat, message];
2935
+ const names = learnName(state.names, message.identity, message.displayName);
2936
+ commit({ ...state, chat: chat.slice(-CHAT_LIMIT), names });
2937
+ },
2938
+ loadChat(messages) {
2939
+ const known = new Set(state.chat.map((message) => message.id));
2940
+ const merged = [...messages.filter((message) => !known.has(message.id)), ...state.chat].sort(
2941
+ (a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0
2942
+ );
2943
+ const names = learnNames(
2944
+ state.names,
2945
+ messages.map((message) => ({ identity: message.identity, displayName: message.displayName }))
2946
+ );
2947
+ commit({ ...state, chat: merged.slice(-CHAT_LIMIT), chatLoaded: true, chatHistoryError: null, names });
2948
+ },
2949
+ settleChat(localId, next) {
2950
+ const index = state.chat.findIndex((message) => message.id === localId);
2951
+ if (index === -1) return;
2952
+ const current = state.chat[index];
2953
+ let replacement;
2954
+ if ("failed" in next && typeof next.failed === "string" && !("id" in next)) {
2955
+ replacement = { ...current, failed: next.failed };
2956
+ } else {
2957
+ replacement = next;
2958
+ }
2959
+ const duplicate = replacement.id !== localId && state.chat.some((message) => message.id === replacement.id);
2960
+ const chat = duplicate ? state.chat.filter((message) => message.id !== localId) : state.chat.map((message, position) => position === index ? replacement : message);
2961
+ commit({ ...state, chat });
2962
+ },
2963
+ addReaction(reaction) {
2964
+ commit({ ...state, reactions: [...state.reactions, reaction] });
2965
+ },
2966
+ expireReactions(now) {
2967
+ const live = state.reactions.filter((reaction) => now - reaction.at < REACTION_TTL_MS);
2968
+ if (live.length === state.reactions.length) return;
2969
+ commit({ ...state, reactions: live });
2970
+ },
2971
+ setRecording(next, by, source, recordingId = null) {
2972
+ if (state.recordingSource === "record" && source !== "record") {
2973
+ const aboutTheSameRecording = source === "room" && (recordingId === null || state.recordingId === null || recordingId === state.recordingId);
2974
+ if (aboutTheSameRecording || isRecordingInFlight(state.recording)) return;
2975
+ }
2976
+ if (state.recording === next && state.recordingStartedBy === by && state.recordingSource === source && state.recordingId === (recordingId ?? state.recordingId)) {
2977
+ return;
2978
+ }
2979
+ commit({
2980
+ ...state,
2981
+ recording: next,
2982
+ recordingStartedBy: by,
2983
+ recordingSource: source,
2984
+ recordingId: recordingId ?? state.recordingId
2985
+ });
2986
+ },
2987
+ upsertLobby(entry) {
2988
+ const index = state.lobby.findIndex((existing) => existing.identity === entry.identity);
2989
+ const lobby = index === -1 ? [...state.lobby, entry] : state.lobby.map((existing, position) => position === index ? entry : existing);
2990
+ commit({ ...state, lobby });
2991
+ },
2992
+ setAdmission(identity, admissionState, by) {
2993
+ commit({
2994
+ ...state,
2995
+ lobby: state.lobby.map(
2996
+ (entry) => entry.identity === identity ? {
2997
+ ...entry,
2998
+ state: admissionState,
2999
+ decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
3000
+ decidedBy: by
3001
+ } : entry
3002
+ )
3003
+ });
3004
+ },
3005
+ learn(entries) {
3006
+ const names = learnNames(state.names, entries);
3007
+ if (names === state.names) return;
3008
+ commit({ ...state, names });
3009
+ },
3010
+ ingestCaption(segment) {
3011
+ const names = learnName(state.names, segment.identity, segment.speaker);
3012
+ const transcript = mergeCaption(state.transcript, segment);
3013
+ if (transcript === state.transcript && names === state.names) return;
3014
+ commit({ ...state, transcript, names });
3015
+ },
3016
+ ingestRecord(segment) {
3017
+ const names = learnName(state.names, segment.identity, segment.speaker);
3018
+ const transcript = mergeRecord(state.transcript, segment);
3019
+ if (transcript === state.transcript && names === state.names) return;
3020
+ commit({ ...state, transcript, names });
3021
+ },
3022
+ upsertNote(note) {
3023
+ const index = state.notes.findIndex((existing2) => existing2.id === note.id);
3024
+ if (index === -1) {
3025
+ commit({ ...state, notes: [...state.notes, note] });
3026
+ return;
3027
+ }
3028
+ const existing = state.notes[index];
3029
+ if (existing !== void 0 && existing.text === note.text && existing.kind === note.kind && existing.supersededBy === note.supersededBy && existing.source === note.source) {
3030
+ return;
3031
+ }
3032
+ if (existing !== void 0 && existing.source === "record" && note.source === "room") {
3033
+ return;
3034
+ }
3035
+ commit({
3036
+ ...state,
3037
+ notes: state.notes.map((entry, position) => position === index ? note : entry)
3038
+ });
3039
+ },
3040
+ announceWrapUp(announcement) {
3041
+ const current = state.wrapUp;
3042
+ if (current !== null && current.at === announcement.at) return;
3043
+ commit({ ...state, wrapUp: announcement });
3044
+ },
3045
+ setLocalTrack(kind, next) {
3046
+ const current = state.media[kind];
3047
+ if (current.enabled === next.enabled && current.unavailable?.code === next.unavailable?.code && current.unavailable?.message === next.unavailable?.message) {
3048
+ return;
3049
+ }
3050
+ commit({ ...state, media: { ...state.media, [kind]: next } });
3051
+ },
3052
+ reset() {
3053
+ commit({ ...EMPTY, effects: state.effects });
3054
+ }
3055
+ };
3056
+ }
3057
+
3058
+ // src/core/room-events.ts
3059
+ var RECORDING_STATES = [
3060
+ "idle",
3061
+ "starting",
3062
+ "recording",
3063
+ "stopping",
3064
+ "processing",
3065
+ "available",
3066
+ "expired",
3067
+ "failed"
3068
+ ];
3069
+ var ROOM_EVENT_PREFIX = "matrx.meet.";
3070
+ var ROOM_EVENTS = {
3071
+ /** One durable `meet_notes` row was written. Payload `{ note: <row> }`. */
3072
+ note: "matrx.meet.note",
3073
+ /** `meet_meetings.metadata.note_taker` changed. Payload `{ meeting_id, note_taker }`. */
3074
+ noteTaker: "matrx.meet.note_taker",
3075
+ /** A wrap-up pass finished. Payload `{ meeting_id, note_ids, segment_count }`. */
3076
+ wrapUp: "matrx.meet.wrap_up",
3077
+ /** The meeting ended for everyone. Payload `{ meeting_id, ended_at, trigger }`. */
3078
+ ended: "matrx.meet.ended",
3079
+ /**
3080
+ * A `meet_recordings` row changed state. Payload
3081
+ * `{ meeting_id, recording: { id, state, started_by } }`.
3082
+ *
3083
+ * 🚨 THIS IS THE ONLY WAY A GUEST LEARNS A RECORDING IS RUNNING. PreJoin
3084
+ * promises every joiner an indicator, and `anon` holds no SELECT grant on
3085
+ * `meet_recordings` (MRI-A10 settled that widening it is forbidden), so
3086
+ * without this packet the promise was a sentence with nothing behind it: on
3087
+ * production 2026-09-09 the guest lane showed no indicator at all while the
3088
+ * host recorded (MRI-C8).
3089
+ */
3090
+ recording: "matrx.meet.recording",
3091
+ /**
3092
+ * A chat message to everyone was SAVED (Meet wave 2). Payload
3093
+ * `{ message: { id, meeting_id, sender_identity, sender_name, body, created_at } }`.
3094
+ */
3095
+ chat: "matrx.meet.chat",
3096
+ /**
3097
+ * A host or co-host acted (Meet wave 2). Payload `{ meeting_id, kind, … }`:
3098
+ * `muted {source, by}` · `ask-unmute {by}` · `removed {by}` — addressed to the
3099
+ * person concerned; `lower-hands {identities: [...] | "*"}` · `role {identity,
3100
+ * role}` — to everyone.
3101
+ */
3102
+ moderation: "matrx.meet.moderation",
3103
+ /**
3104
+ * A meeting ROOM ITEM changed (Meet wave 5): a poll, a Q&A question, a whiteboard
3105
+ * stroke or the breakout plan. Payload `{ meeting_id, item }` · `{ removed: [ids] }` ·
3106
+ * `{ hidden: [ids] }` · `{ cleared: "whiteboard" }` · `{ broadcast }` · `{ item, move }`.
3107
+ * A host's fuller copy of a poll or question carries `audience: "hosts"`.
3108
+ */
3109
+ collab: "matrx.meet.collab"
3110
+ };
3111
+ function isRoomEventTopic(topic) {
3112
+ return topic.startsWith(ROOM_EVENT_PREFIX);
3113
+ }
3114
+ function object(value) {
3115
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
3116
+ }
3117
+ function text2(value) {
3118
+ return typeof value === "string" && value.length > 0 ? value : null;
3119
+ }
3120
+ var HANDLED = { handled: true };
3121
+ function ignored(reason) {
3122
+ return { handled: false, ignored: reason };
3123
+ }
3124
+ function applyRoomEvent(args) {
3125
+ const { store, topic } = args;
3126
+ if (!isRoomEventTopic(topic)) {
3127
+ return ignored(`${topic} is not a Meet room event`);
3128
+ }
3129
+ const payload = object(args.payload);
3130
+ if (payload === null) {
3131
+ return ignored(`${topic} carried no object payload`);
3132
+ }
3133
+ const now = args.now ?? (() => Date.now());
3134
+ switch (topic) {
3135
+ case ROOM_EVENTS.note:
3136
+ return applyNote(store, payload);
3137
+ case ROOM_EVENTS.noteTaker:
3138
+ return applyNoteTaker(store, payload);
3139
+ case ROOM_EVENTS.wrapUp:
3140
+ return applyWrapUp(store, payload, now);
3141
+ case ROOM_EVENTS.ended:
3142
+ return applyEnded(store, payload);
3143
+ case ROOM_EVENTS.recording:
3144
+ return applyRecording(store, payload);
3145
+ case ROOM_EVENTS.chat:
3146
+ return applyChat(store, payload);
3147
+ case ROOM_EVENTS.moderation:
3148
+ return applyModeration(store, payload, now);
3149
+ case ROOM_EVENTS.collab:
3150
+ return applyCollab(store, payload, now);
3151
+ default:
3152
+ return ignored(`${topic} is not known to this version of @ai-matrx/meet`);
3153
+ }
3154
+ }
3155
+ function applyCollab(store, payload, now) {
3156
+ const snapshot = store.snapshot();
3157
+ const meeting = snapshot.meeting;
3158
+ if (meeting !== null && text2(payload["meeting_id"]) !== null && payload["meeting_id"] !== meeting.id) {
3159
+ return ignored("a collab event for a different meeting than this room is showing");
3160
+ }
3161
+ const result = applyCollabPacket(snapshot.collab, payload, {
3162
+ localIdentity: snapshot.localIdentity,
3163
+ isHost: ROLE_RANK[effectiveRole(snapshot)] >= ROLE_RANK.cohost,
3164
+ now: now()
3165
+ });
3166
+ if (result.next === null) return ignored(result.dropped ?? "a collab packet that changed nothing");
3167
+ store.patch({ collab: result.next });
3168
+ return HANDLED;
3169
+ }
3170
+ function applyNote(store, payload) {
3171
+ const row = object(payload["note"]);
3172
+ if (row === null) return ignored("a note event with no `note` row");
3173
+ if (text2(row["id"]) === null || text2(row["meeting_id"]) === null) {
3174
+ return ignored("a note row with no id or meeting_id");
3175
+ }
3176
+ const meeting = store.snapshot().meeting;
3177
+ if (meeting !== null && row["meeting_id"] !== meeting.id) {
3178
+ return ignored("a note for a different meeting than this room is showing");
3179
+ }
3180
+ try {
3181
+ store.upsertNote({ ...projectNote(row), source: "room" });
3182
+ } catch (error) {
3183
+ return ignored(`a note row this client could not read: ${String(error)}`);
3184
+ }
3185
+ return HANDLED;
3186
+ }
3187
+ function applyNoteTaker(store, payload) {
3188
+ const noteTaker = object(payload["note_taker"]);
3189
+ if (noteTaker === null) return ignored("a note-taker event with no state");
3190
+ const meeting = store.snapshot().meeting;
3191
+ if (meeting === null) {
3192
+ return ignored("a note-taker state with no meeting record to attach it to");
3193
+ }
3194
+ const meetingId = text2(payload["meeting_id"]);
3195
+ if (meetingId !== null && meetingId !== meeting.id) {
3196
+ return ignored("a note-taker state for a different meeting");
3197
+ }
3198
+ const next = {
3199
+ ...meeting,
3200
+ metadata: { ...meeting.metadata, note_taker: noteTaker }
3201
+ };
3202
+ store.patch({ meeting: next });
3203
+ return HANDLED;
3204
+ }
3205
+ function applyWrapUp(store, payload, now) {
3206
+ const meetingId = text2(payload["meeting_id"]);
3207
+ if (meetingId === null) return ignored("a wrap-up event with no meeting id");
3208
+ const meeting = store.snapshot().meeting;
3209
+ if (meeting !== null && meetingId !== meeting.id) {
3210
+ return ignored("a wrap-up for a different meeting");
3211
+ }
3212
+ const rawIds = payload["note_ids"];
3213
+ const noteIds = Array.isArray(rawIds) ? rawIds.filter((value) => typeof value === "string") : [];
3214
+ const segmentCount = payload["segment_count"];
3215
+ const announcement = {
3216
+ meetingId: asMeetingId(meetingId),
3217
+ noteIds,
3218
+ segmentCount: typeof segmentCount === "number" ? segmentCount : 0,
3219
+ at: new Date(now()).toISOString()
3220
+ };
3221
+ store.announceWrapUp(announcement);
3222
+ return HANDLED;
3223
+ }
3224
+ function applyRecording(store, payload) {
3225
+ const row = object(payload["recording"]);
3226
+ if (row === null) return ignored("a recording event with no `recording` row");
3227
+ const meetingId = text2(payload["meeting_id"]);
3228
+ const meeting = store.snapshot().meeting;
3229
+ if (meeting !== null && meetingId !== null && meetingId !== meeting.id) {
3230
+ return ignored("a recording state for a different meeting than this room is showing");
3231
+ }
3232
+ const state = text2(row["state"]);
3233
+ if (state === null || !RECORDING_STATES.includes(state)) {
3234
+ return ignored(`a recording state this version does not know: ${String(row["state"])}`);
3235
+ }
3236
+ const by = text2(row["started_by"]);
3237
+ store.setRecording(
3238
+ state,
3239
+ by === null ? null : by,
3240
+ "room",
3241
+ text2(row["id"])
3242
+ );
3243
+ return HANDLED;
3244
+ }
3245
+ function applyEnded(store, payload) {
3246
+ const meetingId = text2(payload["meeting_id"]);
3247
+ const meeting = store.snapshot().meeting;
3248
+ if (meeting === null) return ignored("an ended event with no meeting record");
3249
+ if (meetingId !== null && meetingId !== meeting.id) {
3250
+ return ignored("an ended event for a different meeting");
3251
+ }
3252
+ const endedAt = text2(payload["ended_at"]) ?? (/* @__PURE__ */ new Date()).toISOString();
3253
+ if (meeting.endedAt === endedAt) return HANDLED;
3254
+ store.patch({ meeting: { ...meeting, endedAt } });
3255
+ return HANDLED;
3256
+ }
3257
+ function chatFromRow(row) {
3258
+ const id = text2(row["id"]);
3259
+ const identity = text2(row["sender_identity"]);
3260
+ const body = text2(row["body"]);
3261
+ if (id === null || identity === null || body === null) return null;
3262
+ return {
3263
+ id,
3264
+ identity: asParticipantIdentity(identity),
3265
+ displayName: text2(row["sender_name"]) ?? "Participant",
3266
+ text: body,
3267
+ at: text2(row["created_at"]) ?? (/* @__PURE__ */ new Date()).toISOString(),
3268
+ // `false` only from a server whose chat table has not landed: the room heard
3269
+ // it, nobody will see it later — and the line says so.
3270
+ saved: row["saved"] !== false
3271
+ };
3272
+ }
3273
+ function applyChat(store, payload) {
3274
+ const row = object(payload["message"]);
3275
+ if (row === null) return ignored("a chat event with no `message` row");
3276
+ const meeting = store.snapshot().meeting;
3277
+ if (meeting !== null && text2(row["meeting_id"]) !== null && row["meeting_id"] !== meeting.id) {
3278
+ return ignored("a chat message for a different meeting");
3279
+ }
3280
+ const message = chatFromRow(row);
3281
+ if (message === null) return ignored("a chat row with no id, sender or body");
3282
+ store.addChat(message);
3283
+ return HANDLED;
3284
+ }
3285
+ function moderationText(kind, payload) {
3286
+ const by = text2(payload["by"]) ?? "The host";
3287
+ switch (kind) {
3288
+ case "muted": {
3289
+ const source = text2(payload["source"]) ?? "microphone";
3290
+ return source === "microphone" ? `${by} muted you.` : source === "camera" ? `${by} turned off your camera.` : `${by} stopped your screen share.`;
3291
+ }
3292
+ case "ask-unmute":
3293
+ return `${by} is asking you to unmute.`;
3294
+ case "removed":
3295
+ return `${by} removed you from this meeting.`;
3296
+ default:
3297
+ return null;
3298
+ }
3299
+ }
3300
+ function applyModeration(store, payload, now) {
3301
+ const snapshot = store.snapshot();
3302
+ const meetingId = text2(payload["meeting_id"]);
3303
+ if (snapshot.meeting !== null && meetingId !== null && meetingId !== snapshot.meeting.id) {
3304
+ return ignored("a moderation event for a different meeting");
3305
+ }
3306
+ const kind = text2(payload["kind"]);
3307
+ switch (kind) {
3308
+ case "lower-hands": {
3309
+ const raw = payload["identities"];
3310
+ const everyone = raw === "*";
3311
+ const named = new Set(
3312
+ Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []
3313
+ );
3314
+ for (const participant of snapshot.participants) {
3315
+ if (participant.handRaised && (everyone || named.has(participant.identity))) {
3316
+ store.setHand(participant.identity, false);
3317
+ }
3318
+ }
3319
+ return HANDLED;
3320
+ }
3321
+ case "role": {
3322
+ const identity = text2(payload["identity"]);
3323
+ const role = text2(payload["role"]);
3324
+ if (identity === null || role === null || !ROLES.includes(role)) {
3325
+ return ignored("a role event with no identity or an unknown role");
3326
+ }
3327
+ if (identity === snapshot.localIdentity) {
3328
+ store.patch({
3329
+ grants: { ...snapshot.grants, role },
3330
+ moderationNotice: {
3331
+ kind: "role",
3332
+ text: role === "host" ? "You are now the host." : role === "cohost" ? "You are now a co-host." : "You are no longer a co-host.",
3333
+ at: now()
3334
+ }
3335
+ });
3336
+ }
3337
+ return HANDLED;
3338
+ }
3339
+ case "muted":
3340
+ case "ask-unmute":
3341
+ case "removed": {
3342
+ const sentence = moderationText(kind, payload);
3343
+ if (sentence === null) return ignored("a moderation event with no sentence");
3344
+ store.patch({ moderationNotice: { kind, text: sentence, at: now() } });
3345
+ return HANDLED;
3346
+ }
3347
+ default:
3348
+ return ignored(`a moderation kind this version does not know: ${String(kind)}`);
3349
+ }
3350
+ }
3351
+ var ROLES = ["host", "cohost", "participant", "guest"];
3352
+
3353
+ // src/core/devices.ts
3354
+ var KINDS = ["audioinput", "videoinput", "audiooutput"];
3355
+ function isDeviceKind(value) {
3356
+ return value === "audioinput" || value === "videoinput" || value === "audiooutput";
3357
+ }
3358
+ function createWebDeviceStorage(key = "mx.meet.devices") {
3359
+ const store = globalThis.localStorage;
3360
+ if (store === void 0) return createMemoryDeviceStorage();
3361
+ return {
3362
+ durable: true,
3363
+ read() {
3364
+ try {
3365
+ const raw = store.getItem(key);
3366
+ if (raw === null) return {};
3367
+ const parsed = JSON.parse(raw);
3368
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
3369
+ } catch {
3370
+ return {};
3371
+ }
3372
+ },
3373
+ write(value) {
3374
+ try {
3375
+ store.setItem(key, JSON.stringify(value));
3376
+ } catch {
3377
+ }
3378
+ }
3379
+ };
3380
+ }
3381
+ function createMemoryDeviceStorage() {
3382
+ let value = {};
3383
+ return {
3384
+ durable: false,
3385
+ read: () => value,
3386
+ write: (next) => {
3387
+ value = next;
3388
+ }
3389
+ };
3390
+ }
3391
+ function createDeviceManager(options) {
3392
+ const storage = options.storage ?? createMemoryDeviceStorage();
3393
+ const listeners = /* @__PURE__ */ new Set();
3394
+ const stored = storage.read();
3395
+ let devices = {
3396
+ audioinput: [],
3397
+ videoinput: [],
3398
+ audiooutput: []
3399
+ };
3400
+ let selected = {
3401
+ audioinput: stored["audioinput"] ?? null,
3402
+ videoinput: stored["videoinput"] ?? null,
3403
+ audiooutput: stored["audiooutput"] ?? null
3404
+ };
3405
+ let labelsHidden = true;
3406
+ let permission = "unknown";
3407
+ let fallbackNotice = null;
3408
+ let current = {
3409
+ devices,
3410
+ selected,
3411
+ labelsHidden,
3412
+ permission,
3413
+ fallbackNotice
3414
+ };
3415
+ function snapshot() {
3416
+ return current;
3417
+ }
3418
+ function commit() {
3419
+ current = { devices, selected, labelsHidden, permission, fallbackNotice };
3420
+ return current;
3421
+ }
3422
+ function emit() {
3423
+ const next = commit();
3424
+ options.onChange?.(next);
3425
+ for (const listener of listeners) listener(next);
3426
+ return next;
3427
+ }
3428
+ async function enumerate() {
3429
+ let raw;
3430
+ try {
3431
+ raw = await options.media.enumerateDevices();
3432
+ } catch (error) {
3433
+ throw normalizeDeviceError(error, "meet.enumerateDevices");
3434
+ }
3435
+ const grouped = {
3436
+ audioinput: [],
3437
+ videoinput: [],
3438
+ audiooutput: []
3439
+ };
3440
+ let anyLabel = false;
3441
+ for (const device of raw) {
3442
+ if (!isDeviceKind(device.kind)) continue;
3443
+ if (device.label.length > 0) anyLabel = true;
3444
+ grouped[device.kind].push({
3445
+ deviceId: device.deviceId,
3446
+ kind: device.kind,
3447
+ label: device.label
3448
+ });
3449
+ }
3450
+ devices = grouped;
3451
+ labelsHidden = !anyLabel && raw.length > 0;
3452
+ const lost = [];
3453
+ for (const kind of KINDS) {
3454
+ const current2 = selected[kind];
3455
+ if (current2 === null) continue;
3456
+ if (!grouped[kind].some((device) => device.deviceId === current2)) {
3457
+ selected = { ...selected, [kind]: null };
3458
+ lost.push(kind);
3459
+ }
3460
+ }
3461
+ fallbackNotice = lost.length === 0 ? null : `A remembered ${lost.join(" and ")} device is no longer connected \u2014 using the system default. Pick another in the device menu.`;
3462
+ return emit();
3463
+ }
3464
+ const onDeviceChange = () => {
3465
+ void enumerate();
3466
+ };
3467
+ options.media.addEventListener?.("devicechange", onDeviceChange);
3468
+ return {
3469
+ snapshot,
3470
+ refresh: enumerate,
3471
+ async requestPermission(kinds) {
3472
+ if (!kinds.audio && !kinds.video) {
3473
+ throw new MeetError(
3474
+ "misconfigured",
3475
+ "meet.requestPermission: asked for neither audio nor video",
3476
+ "Request at least one kind. Prompting for nothing silently resolves and leaves labels hidden, which looks like a broken picker."
3477
+ );
3478
+ }
3479
+ try {
3480
+ const stream = await options.media.getUserMedia({
3481
+ ...kinds.audio ? { audio: true } : {},
3482
+ ...kinds.video ? { video: true } : {}
3483
+ });
3484
+ for (const track of stream.getTracks()) track.stop();
3485
+ permission = "granted";
3486
+ } catch (error) {
3487
+ permission = "denied";
3488
+ emit();
3489
+ throw normalizeDeviceError(error, "meet.requestPermission");
3490
+ }
3491
+ return enumerate();
3492
+ },
3493
+ select(kind, deviceId) {
3494
+ selected = { ...selected, [kind]: deviceId };
3495
+ fallbackNotice = null;
3496
+ storage.write({
3497
+ ...storage.read(),
3498
+ [kind]: deviceId
3499
+ });
3500
+ return emit();
3501
+ },
3502
+ resolve(kind) {
3503
+ const current2 = selected[kind];
3504
+ if (current2 !== null && devices[kind].some((device) => device.deviceId === current2)) {
3505
+ return current2;
3506
+ }
3507
+ return devices[kind][0]?.deviceId ?? null;
3508
+ },
3509
+ subscribe(listener) {
3510
+ listeners.add(listener);
3511
+ return () => listeners.delete(listener);
3512
+ },
3513
+ dispose() {
3514
+ options.media.removeEventListener?.("devicechange", onDeviceChange);
3515
+ listeners.clear();
3516
+ }
3517
+ };
3518
+ }
3519
+
3520
+ // src/core/format.ts
3521
+ function participantSummary(participants) {
3522
+ const names = participants.filter((p) => !p.isLocal).map((p) => p.displayName);
3523
+ if (names.length === 0) return "Just you";
3524
+ if (names.length === 1) return names[0] ?? "";
3525
+ if (names.length === 2) return `${names[0]} and ${names[1]}`;
3526
+ return `${names[0]}, ${names[1]} and ${names.length - 2} ${names.length - 2 === 1 ? "other" : "others"}`;
3527
+ }
3528
+ function callStateLabel(state, outgoing) {
3529
+ switch (state) {
3530
+ case "dialing":
3531
+ case "ringing":
3532
+ return outgoing ? "Calling\u2026" : "Incoming call";
3533
+ case "connecting":
3534
+ return "Connecting\u2026";
3535
+ case "active":
3536
+ return "In call";
3537
+ case "declined":
3538
+ return outgoing ? "Declined" : "You declined";
3539
+ case "missed":
3540
+ return outgoing ? "No answer" : "Missed call";
3541
+ case "busy":
3542
+ return outgoing ? "On another call" : "You were busy";
3543
+ case "cancelled":
3544
+ return outgoing ? "Cancelled" : "Cancelled by caller";
3545
+ case "ended":
3546
+ return "Call ended";
3547
+ case "failed":
3548
+ return "Call failed";
3549
+ default:
3550
+ return "Call";
3551
+ }
3552
+ }
3553
+ function countLabel(count2, singular, plural = `${singular}s`) {
3554
+ return `${count2} ${count2 === 1 ? singular : plural}`;
3555
+ }
3556
+
3557
+ // src/core/invitations.ts
3558
+ var CALL_INVITE_KIND = "meet.call-invite";
3559
+ var CALL_INVITE_VERSION = 1;
3560
+ function callInviteAction(invite) {
3561
+ return {
3562
+ kind: CALL_INVITE_KIND,
3563
+ version: CALL_INVITE_VERSION,
3564
+ payload: {
3565
+ invite_id: invite.id,
3566
+ room_name: invite.roomName,
3567
+ mode: invite.mode,
3568
+ caller_name: invite.callerName,
3569
+ expires_at: invite.expiresAt
3570
+ }
3571
+ };
3572
+ }
3573
+ function callInviteText(invite) {
3574
+ return invite.mode === "video" ? `${invite.callerName} started a video call` : `${invite.callerName} started an audio call`;
3575
+ }
3576
+ function createCallInviteHandler(options) {
3577
+ const now = options.now ?? (() => Date.now());
3578
+ return {
3579
+ kind: CALL_INVITE_KIND,
3580
+ versions: [CALL_INVITE_VERSION],
3581
+ choices: (payload) => {
3582
+ if (Date.parse(payload.expires_at) < now()) return [];
3583
+ return [
3584
+ { id: "join", label: payload.mode === "video" ? "Join video" : "Join call", tone: "primary" },
3585
+ { id: "decline", label: "Decline", tone: "neutral" }
3586
+ ];
3587
+ },
3588
+ summarize: (payload) => payload.mode === "video" ? `${payload.caller_name} started a video call` : `${payload.caller_name} started an audio call`,
3589
+ execute: async (payload, context) => {
3590
+ const settledAt = new Date(now()).toISOString();
3591
+ const inviteId = asCallId(payload.invite_id);
3592
+ const pending = await options.repository.pendingInvites(asUserId(String(context.actorId)));
3593
+ const live = pending.find((invite) => invite.id === inviteId) ?? null;
3594
+ if (live === null) {
3595
+ return {
3596
+ kind: CALL_INVITE_KIND,
3597
+ messageId: context.messageId,
3598
+ actorId: context.actorId,
3599
+ outcome: "unavailable",
3600
+ label: "This call has ended",
3601
+ settledAt,
3602
+ detail: "The invite is no longer ringing \u2014 it was answered elsewhere, cancelled, or it timed out. The chips are replaced by the outcome rather than left clickable."
3603
+ };
3604
+ }
3605
+ if (context.choice === "decline") {
3606
+ await options.calls.decline(inviteId);
3607
+ return {
3608
+ kind: CALL_INVITE_KIND,
3609
+ messageId: context.messageId,
3610
+ actorId: context.actorId,
3611
+ outcome: "declined",
3612
+ label: "Declined",
3613
+ settledAt
3614
+ };
3615
+ }
3616
+ const accepted = await options.calls.accept(inviteId, live.mode);
3617
+ await options.onJoin(accepted);
3618
+ return {
3619
+ kind: CALL_INVITE_KIND,
3620
+ messageId: context.messageId,
3621
+ actorId: context.actorId,
3622
+ outcome: "applied",
3623
+ label: "Joined",
3624
+ settledAt
3625
+ };
3626
+ }
3627
+ };
3628
+ }
3629
+ var MEETING_INVITE_KIND = "meet.meeting-invite";
3630
+ var MEETING_INVITE_VERSION = 1;
3631
+ function meetingInviteAction(payload) {
3632
+ return { kind: MEETING_INVITE_KIND, version: MEETING_INVITE_VERSION, payload };
3633
+ }
3634
+ function createMeetingInviteHandler(options) {
3635
+ const now = options.now ?? (() => Date.now());
3636
+ return {
3637
+ kind: MEETING_INVITE_KIND,
3638
+ versions: [MEETING_INVITE_VERSION],
3639
+ // A DURABLE LINK NEVER EXPIRES (R3) — the chip stays useful after the
3640
+ // meeting too, because that is when people go looking for the notes.
3641
+ choices: () => [{ id: "open", label: "Open meeting", tone: "primary" }],
3642
+ summarize: (payload) => payload.scheduled_for === null ? payload.title : `${payload.title} \u2014 ${payload.scheduled_for}`,
2811
3643
  execute: async (payload, context) => {
2812
3644
  if (payload.slug.length === 0) {
2813
3645
  throw new MeetError(
@@ -3095,586 +3927,148 @@ function createLiveKitDriver(handlers, options = {}) {
3095
3927
  return container;
3096
3928
  }
3097
3929
  function requireRoom(operation) {
3098
- if (room === null) {
3099
- throw new MeetError(
3100
- "transport",
3101
- `${operation}: the room is not connected`,
3102
- "Call connect() first. The engine never reaches this \u2014 it gates every media call on its own phase \u2014 so seeing it means a consumer drove the driver directly."
3103
- );
3104
- }
3105
- return room;
3106
- }
3107
- return {
3108
- async connect(serverUrl, token) {
3109
- const target = await load();
3110
- try {
3111
- await target.connect(serverUrl, token);
3112
- } catch (error) {
3113
- throw normalizeDeviceError(error, "meet.connect");
3114
- }
3115
- projectRoster?.();
3116
- handlers.roomMetadata?.(target.metadata ?? "");
3117
- },
3118
- async disconnect() {
3119
- if (room === null) return;
3120
- const target = room;
3121
- room = null;
3122
- try {
3123
- target.unregisterTextStreamHandler(TRANSCRIPTION_TOPIC);
3124
- } catch {
3125
- }
3126
- projectRoster = null;
3127
- audioContainer?.remove();
3128
- audioContainer = null;
3129
- await target.disconnect();
3130
- },
3131
- async setMicrophoneEnabled(enabled) {
3132
- try {
3133
- await requireRoom("setMicrophoneEnabled").localParticipant.setMicrophoneEnabled(
3134
- enabled,
3135
- audioCapture
3136
- );
3137
- } catch (error) {
3138
- throw normalizeDeviceError(error, "meet.setMicrophoneEnabled");
3139
- }
3140
- },
3141
- async setCameraEnabled(enabled) {
3142
- try {
3143
- const target = requireRoom("setCameraEnabled");
3144
- await target.localParticipant.setCameraEnabled(enabled);
3145
- if (enabled && videoEffect.kind !== "none") await applyVideoEffect(target, videoEffect);
3146
- } catch (error) {
3147
- throw normalizeDeviceError(error, "meet.setCameraEnabled");
3148
- }
3149
- },
3150
- async setScreenShareEnabled(enabled) {
3151
- try {
3152
- await requireRoom("setScreenShareEnabled").localParticipant.setScreenShareEnabled(enabled);
3153
- } catch (error) {
3154
- throw normalizeDeviceError(error, "meet.setScreenShareEnabled");
3155
- }
3156
- },
3157
- async publishData(topic, payload, to) {
3158
- await requireRoom("publishData").localParticipant.publishData(
3159
- // `reliable` is not optional for a control plane: a dropped
3160
- // "hand lowered" leaves a hand up on every other screen forever.
3161
- encoder.encode(payload),
3162
- to === void 0 || to.length === 0 ? { reliable: true, topic } : { reliable: true, topic, destinationIdentities: [...to] }
3163
- );
3164
- },
3165
- async setAudioProcessing(processing) {
3166
- audioCapture = { ...audioCapture, ...processing };
3167
- const target = room;
3168
- if (target === null) return;
3169
- const { Track } = await import("livekit-client");
3170
- const publication = target.localParticipant.getTrackPublication(Track.Source.Microphone);
3171
- const track = publication?.track;
3172
- if (track === void 0 || publication?.isMuted === true) return;
3173
- try {
3174
- await track.restartTrack({
3175
- ...audioCapture,
3176
- ...target.getActiveDevice("audioinput") !== void 0 ? { deviceId: target.getActiveDevice("audioinput") } : {}
3177
- });
3178
- } catch (error) {
3179
- throw normalizeDeviceError(error, "meet.setAudioProcessing");
3180
- }
3181
- },
3182
- async setVideoEffect(effect) {
3183
- videoEffect = effect;
3184
- const target = room;
3185
- if (target === null) return;
3186
- await applyVideoEffect(target, effect);
3187
- },
3188
- supportsVideoEffects() {
3189
- return videoEffectsSupported();
3190
- },
3191
- attach(identity, kind, element) {
3192
- const target = requireRoom("attach");
3193
- const source = sources?.[kind];
3194
- if (source === void 0) return () => void 0;
3195
- let current = null;
3196
- const sync = () => {
3197
- const participant = target.localParticipant.identity === identity ? target.localParticipant : target.remoteParticipants.get(identity);
3198
- const track = participant?.getTrackPublication(source)?.track;
3199
- if (track === void 0 || track === current) return;
3200
- current?.detach(element);
3201
- track.attach(element);
3202
- current = track;
3203
- };
3204
- const onTrack = (...args) => {
3205
- const who = args[args.length - 1];
3206
- if (who?.identity === identity) sync();
3207
- };
3208
- sync();
3209
- const events = trackEvents;
3210
- if (events !== null) {
3211
- target.on(events.subscribed, onTrack);
3212
- target.on(events.localPublished, onTrack);
3213
- }
3214
- return () => {
3215
- if (events !== null) {
3216
- target.off(events.subscribed, onTrack);
3217
- target.off(events.localPublished, onTrack);
3218
- }
3219
- current?.detach(element);
3220
- current = null;
3221
- };
3222
- },
3223
- activeDevices() {
3224
- const target = room;
3225
- if (target === null) {
3226
- return { audioinput: null, videoinput: null, audiooutput: null };
3227
- }
3228
- return {
3229
- audioinput: target.getActiveDevice("audioinput") ?? null,
3230
- videoinput: target.getActiveDevice("videoinput") ?? null,
3231
- audiooutput: target.getActiveDevice("audiooutput") ?? null
3232
- };
3233
- },
3234
- async switchDevice(kind, deviceId) {
3235
- try {
3236
- await requireRoom("switchDevice").switchActiveDevice(kind, deviceId);
3237
- } catch (error) {
3238
- throw normalizeDeviceError(error, "meet.switchDevice");
3239
- }
3240
- }
3241
- };
3242
- }
3243
-
3244
- // src/core/names.ts
3245
- function isGuestIdentity(identity) {
3246
- return String(identity).startsWith("guest:");
3247
- }
3248
- function isAgentIdentity(identity) {
3249
- return String(identity).startsWith("agent:");
3250
- }
3251
- var NOTE_TAKER_PRODUCT_NAME = "Matrx note-taker";
3252
- var AGENT_PRODUCT_NAME = "Matrx agent";
3253
- var UNNAMED_GUEST = "Unnamed guest";
3254
- var UNNAMED_PARTICIPANT = "Unnamed participant";
3255
- var PLACEHOLDER_NAMES = [
3256
- NOTE_TAKER_PRODUCT_NAME,
3257
- AGENT_PRODUCT_NAME,
3258
- UNNAMED_GUEST,
3259
- UNNAMED_PARTICIPANT
3260
- ];
3261
- function isPlaceholderName(name) {
3262
- return typeof name === "string" && PLACEHOLDER_NAMES.includes(name.trim());
3263
- }
3264
- function placeholderNameFor(identity) {
3265
- const key = String(identity);
3266
- if (isAgentIdentity(key)) {
3267
- return key.startsWith("agent:note_taker:") || key.startsWith("agent:note-taker") ? NOTE_TAKER_PRODUCT_NAME : AGENT_PRODUCT_NAME;
3268
- }
3269
- return isGuestIdentity(key) ? UNNAMED_GUEST : UNNAMED_PARTICIPANT;
3270
- }
3271
- function isRealName(identity, name) {
3272
- if (typeof name !== "string") return false;
3273
- const trimmed = name.trim();
3274
- if (trimmed.length === 0) return false;
3275
- if (trimmed === String(identity)) return false;
3276
- if (isPlaceholderName(trimmed)) return false;
3277
- return true;
3278
- }
3279
- function learnName(book, identity, name) {
3280
- const key = String(identity);
3281
- if (key.length === 0) return book;
3282
- if (!isRealName(key, name)) return book;
3283
- const learned = name.trim();
3284
- if (book[key] === learned) return book;
3285
- return { ...book, [key]: learned };
3286
- }
3287
- function learnNames(book, entries) {
3288
- let next = book;
3289
- for (const entry of entries) next = learnName(next, entry.identity, entry.displayName);
3290
- return next;
3291
- }
3292
- function displayNameFor(book, identity, preferred) {
3293
- const key = String(identity);
3294
- if (isRealName(key, preferred)) return preferred.trim();
3295
- const known = book[key];
3296
- if (known !== void 0) return known;
3297
- return placeholderNameFor(key);
3298
- }
3299
-
3300
- // src/core/recording.ts
3301
- function createRecordingController(options) {
3302
- async function post(route, body) {
3303
- let response;
3304
- try {
3305
- response = await options.api.request({
3306
- path: route,
3307
- method: "POST",
3308
- // A GUEST CAN NEVER START OR STOP A RECORDING (R5) — refused here,
3309
- // before the wire, with the same sentence the hidden control implies.
3310
- requireAuth: true,
3311
- operation: "meet.recording",
3312
- body
3313
- });
3314
- } catch (error) {
3315
- if (error.code === "session-unavailable") {
3316
- throw new MeetError(
3317
- "forbidden",
3318
- "meet.recording: recording requires an authenticated host",
3319
- "A guest can never start or stop a recording (R5). The control is hidden for guests."
3320
- );
3321
- }
3322
- throw error;
3323
- }
3324
- if (response.status === 403) {
3325
- throw new MeetError(
3326
- "forbidden",
3327
- "meet.recording: the server refused this recording action",
3328
- "The actor is not a host of this meeting, or the org policy is `disabled`. The server is the authority; do not re-decide it on the client."
3329
- );
3330
- }
3331
- if (!response.ok) {
3930
+ if (room === null) {
3332
3931
  throw new MeetError(
3333
3932
  "transport",
3334
- `meet.recording: the server returned ${response.status}`,
3335
- "Egress could not be controlled. The recording state stays where it was rather than showing a recording indicator for a recording that is not running."
3933
+ `${operation}: the room is not connected`,
3934
+ "Call connect() first. The engine never reaches this \u2014 it gates every media call on its own phase \u2014 so seeing it means a consumer drove the driver directly."
3336
3935
  );
3337
3936
  }
3937
+ return room;
3338
3938
  }
3339
3939
  return {
3340
- canStart(meeting, isHost) {
3341
- if (meeting === null) return false;
3342
- if (meeting.recordingPolicy === "disabled") return false;
3343
- if (meeting.recordingPolicy === "always-on") return false;
3344
- return isHost;
3345
- },
3346
- async start({ meetingId, roomName, by }) {
3347
- options.store.setRecording("starting", by, "local");
3348
- try {
3349
- await post(MEET_ROUTES.recordingStart, {
3350
- meeting_id: meetingId,
3351
- room_name: roomName
3352
- });
3353
- } catch (error) {
3354
- options.store.setRecording("idle", null, "local");
3355
- throw error;
3356
- }
3357
- options.announce?.("starting", by);
3358
- },
3359
- async stop({ meetingId, by }) {
3360
- options.store.setRecording("stopping", options.store.snapshot().recordingStartedBy, "local");
3940
+ async connect(serverUrl, token) {
3941
+ const target = await load();
3361
3942
  try {
3362
- await post(MEET_ROUTES.recordingStop, { meeting_id: meetingId });
3363
- } catch (error) {
3364
- options.store.setRecording(
3365
- "recording",
3366
- options.store.snapshot().recordingStartedBy,
3367
- "local"
3368
- );
3369
- throw error;
3370
- }
3371
- options.announce?.("stopping", by);
3372
- },
3373
- consentNotice(meeting) {
3374
- if (meeting === null || meeting.recordingPolicy === "disabled") return null;
3375
- if (meeting.recordingPolicy === "always-on") {
3376
- return "This meeting is recorded and transcribed automatically. By joining, you consent to being recorded.";
3377
- }
3378
- return "The host can record and transcribe this meeting. You will see a recording indicator whenever it is running.";
3379
- },
3380
- async acknowledge(meetingId) {
3381
- await options.acknowledgeConsent?.(meetingId);
3382
- }
3383
- };
3384
- }
3385
- var RECORDING_IN_FLIGHT = [
3386
- "starting",
3387
- "recording",
3388
- "stopping"
3389
- ];
3390
- function isRecordingInFlight(state) {
3391
- return RECORDING_IN_FLIGHT.includes(state);
3392
- }
3393
- function recordingLabel(state) {
3394
- switch (state) {
3395
- case "starting":
3396
- return "Starting recording\u2026";
3397
- case "recording":
3398
- return "Recording";
3399
- case "stopping":
3400
- return "Stopping recording\u2026";
3401
- case "processing":
3402
- return "Processing recording\u2026";
3403
- case "available":
3404
- return "Recording ready";
3405
- case "expired":
3406
- return "Recording expired";
3407
- case "failed":
3408
- return "Recording failed";
3409
- default:
3410
- return null;
3411
- }
3412
- }
3413
-
3414
- // src/core/attendance.ts
3415
- var IDLE_TRACK = { enabled: false, unavailable: null };
3416
- var IDLE_MEDIA = {
3417
- microphone: IDLE_TRACK,
3418
- camera: IDLE_TRACK
3419
- };
3420
- function unavailableFrom(error) {
3421
- return { code: error.code, message: error.message, remedy: error.remedy };
3422
- }
3423
- var LISTEN_ONLY_NOTICE = "You're attending without a microphone \u2014 others can't hear you";
3424
- function isListenOnly(media) {
3425
- return !media.microphone.enabled;
3426
- }
3427
- function listenOnlyNotice(media) {
3428
- return isListenOnly(media) ? LISTEN_ONLY_NOTICE : null;
3429
- }
3430
-
3431
- // src/core/store.ts
3432
- var DEFAULT_EFFECTS = {
3433
- noiseSuppression: true,
3434
- background: "none",
3435
- backgroundImage: null
3436
- };
3437
- var NO_GRANTS = {
3438
- role: "participant",
3439
- canRecord: false,
3440
- canPublish: false,
3441
- canSubscribe: false
3442
- };
3443
- var EMPTY = {
3444
- phase: "idle",
3445
- meeting: null,
3446
- localIdentity: null,
3447
- grants: NO_GRANTS,
3448
- participants: [],
3449
- activeSpeakers: [],
3450
- pinned: null,
3451
- layout: "grid",
3452
- lobby: [],
3453
- locked: false,
3454
- recording: "idle",
3455
- recordingStartedBy: null,
3456
- recordingSource: "local",
3457
- recordingId: null,
3458
- chat: [],
3459
- chatLoaded: false,
3460
- chatHistoryError: null,
3461
- reactions: [],
3462
- spotlight: null,
3463
- moderationNotice: null,
3464
- actionNotice: null,
3465
- privateChatRequest: null,
3466
- effects: DEFAULT_EFFECTS,
3467
- transcript: [],
3468
- notes: [],
3469
- names: {},
3470
- wrapUp: null,
3471
- noteTakerRefusal: null,
3472
- media: IDLE_MEDIA,
3473
- failure: null
3474
- };
3475
- function effectiveRole(snapshot) {
3476
- const me = snapshot.participants.find(
3477
- (participant) => participant.identity === snapshot.localIdentity
3478
- );
3479
- const fromRoster = me?.role ?? "participant";
3480
- const fromToken = snapshot.grants.role;
3481
- return ROLE_RANK[fromRoster] >= ROLE_RANK[fromToken] ? fromRoster : fromToken;
3482
- }
3483
- var REACTION_TTL_MS = 5e3;
3484
- var CHAT_LIMIT = 200;
3485
- function createMeetStore(initial = {}) {
3486
- let state = { ...EMPTY, ...initial };
3487
- const listeners = /* @__PURE__ */ new Set();
3488
- function commit(next) {
3489
- if (next === state) return;
3490
- state = next;
3491
- for (const listener of listeners) listener();
3492
- }
3493
- return {
3494
- snapshot: () => state,
3495
- subscribe(listener) {
3496
- listeners.add(listener);
3497
- return () => listeners.delete(listener);
3498
- },
3499
- patch(next) {
3500
- commit({ ...state, ...next });
3501
- },
3502
- setParticipants(participants) {
3503
- const names = learnNames(state.names, participants);
3504
- const hands = new Map(state.participants.map((p) => [p.identity, p.handRaised]));
3505
- const handTimes = new Map(state.participants.map((p) => [p.identity, p.handRaisedAt ?? null]));
3506
- const quality = new Map(state.participants.map((p) => [p.identity, p.quality]));
3507
- commit({
3508
- ...state,
3509
- names,
3510
- participants: participants.map((participant) => ({
3511
- ...participant,
3512
- // 🚨 THE ONE ROSTER RESOLUTION. Every surface that renders a person —
3513
- // the tile, the People row, the header summary, every Pin/Mute label —
3514
- // reads `participant.displayName`, so this is the single place a raw
3515
- // identity could reach a screen, and it is the place that asks the
3516
- // name book. `roster-names.test.ts` fails if a token ever gets past it.
3517
- displayName: displayNameFor(names, participant.identity, participant.displayName),
3518
- handRaised: hands.get(participant.identity) ?? participant.handRaised,
3519
- handRaisedAt: handTimes.get(participant.identity) ?? participant.handRaisedAt ?? null,
3520
- quality: participant.quality === "unknown" ? quality.get(participant.identity) ?? "unknown" : participant.quality
3521
- }))
3522
- });
3523
- },
3524
- setSpeaking(identities) {
3525
- const speaking = new Set(identities);
3526
- commit({
3527
- ...state,
3528
- activeSpeakers: identities,
3529
- participants: state.participants.map(
3530
- (participant) => participant.speaking === speaking.has(participant.identity) ? participant : { ...participant, speaking: speaking.has(participant.identity) }
3531
- )
3532
- });
3533
- },
3534
- setQuality(identity, quality) {
3535
- commit({
3536
- ...state,
3537
- participants: state.participants.map(
3538
- (participant) => participant.identity === identity ? { ...participant, quality } : participant
3539
- )
3540
- });
3541
- },
3542
- setHand(identity, raised, at) {
3543
- commit({
3544
- ...state,
3545
- participants: state.participants.map(
3546
- (participant) => participant.identity === identity ? {
3547
- ...participant,
3548
- handRaised: raised,
3549
- // A hand that is ALREADY up keeps its place in the queue when
3550
- // it is re-announced (to a late joiner): the first time wins.
3551
- handRaisedAt: raised ? participant.handRaised && participant.handRaisedAt != null ? participant.handRaisedAt : at ?? Date.now() : null
3552
- } : participant
3553
- )
3554
- });
3555
- },
3556
- addChat(message) {
3557
- if (state.chat.some((existing) => existing.id === message.id)) return;
3558
- const chat = [...state.chat, message];
3559
- const names = learnName(state.names, message.identity, message.displayName);
3560
- commit({ ...state, chat: chat.slice(-CHAT_LIMIT), names });
3561
- },
3562
- loadChat(messages) {
3563
- const known = new Set(state.chat.map((message) => message.id));
3564
- const merged = [...messages.filter((message) => !known.has(message.id)), ...state.chat].sort(
3565
- (a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0
3566
- );
3567
- const names = learnNames(
3568
- state.names,
3569
- messages.map((message) => ({ identity: message.identity, displayName: message.displayName }))
3570
- );
3571
- commit({ ...state, chat: merged.slice(-CHAT_LIMIT), chatLoaded: true, chatHistoryError: null, names });
3572
- },
3573
- settleChat(localId, next) {
3574
- const index = state.chat.findIndex((message) => message.id === localId);
3575
- if (index === -1) return;
3576
- const current = state.chat[index];
3577
- let replacement;
3578
- if ("failed" in next && typeof next.failed === "string" && !("id" in next)) {
3579
- replacement = { ...current, failed: next.failed };
3580
- } else {
3581
- replacement = next;
3582
- }
3583
- const duplicate = replacement.id !== localId && state.chat.some((message) => message.id === replacement.id);
3584
- const chat = duplicate ? state.chat.filter((message) => message.id !== localId) : state.chat.map((message, position) => position === index ? replacement : message);
3585
- commit({ ...state, chat });
3586
- },
3587
- addReaction(reaction) {
3588
- commit({ ...state, reactions: [...state.reactions, reaction] });
3943
+ await target.connect(serverUrl, token);
3944
+ } catch (error) {
3945
+ throw normalizeDeviceError(error, "meet.connect");
3946
+ }
3947
+ projectRoster?.();
3948
+ handlers.roomMetadata?.(target.metadata ?? "");
3589
3949
  },
3590
- expireReactions(now) {
3591
- const live = state.reactions.filter((reaction) => now - reaction.at < REACTION_TTL_MS);
3592
- if (live.length === state.reactions.length) return;
3593
- commit({ ...state, reactions: live });
3950
+ async disconnect() {
3951
+ if (room === null) return;
3952
+ const target = room;
3953
+ room = null;
3954
+ try {
3955
+ target.unregisterTextStreamHandler(TRANSCRIPTION_TOPIC);
3956
+ } catch {
3957
+ }
3958
+ projectRoster = null;
3959
+ audioContainer?.remove();
3960
+ audioContainer = null;
3961
+ await target.disconnect();
3594
3962
  },
3595
- setRecording(next, by, source, recordingId = null) {
3596
- if (state.recordingSource === "record" && source !== "record") {
3597
- const aboutTheSameRecording = source === "room" && (recordingId === null || state.recordingId === null || recordingId === state.recordingId);
3598
- if (aboutTheSameRecording || isRecordingInFlight(state.recording)) return;
3963
+ async setMicrophoneEnabled(enabled) {
3964
+ try {
3965
+ await requireRoom("setMicrophoneEnabled").localParticipant.setMicrophoneEnabled(
3966
+ enabled,
3967
+ audioCapture
3968
+ );
3969
+ } catch (error) {
3970
+ throw normalizeDeviceError(error, "meet.setMicrophoneEnabled");
3599
3971
  }
3600
- if (state.recording === next && state.recordingStartedBy === by && state.recordingSource === source && state.recordingId === (recordingId ?? state.recordingId)) {
3601
- return;
3972
+ },
3973
+ async setCameraEnabled(enabled) {
3974
+ try {
3975
+ const target = requireRoom("setCameraEnabled");
3976
+ await target.localParticipant.setCameraEnabled(enabled);
3977
+ if (enabled && videoEffect.kind !== "none") await applyVideoEffect(target, videoEffect);
3978
+ } catch (error) {
3979
+ throw normalizeDeviceError(error, "meet.setCameraEnabled");
3602
3980
  }
3603
- commit({
3604
- ...state,
3605
- recording: next,
3606
- recordingStartedBy: by,
3607
- recordingSource: source,
3608
- recordingId: recordingId ?? state.recordingId
3609
- });
3610
3981
  },
3611
- upsertLobby(entry) {
3612
- const index = state.lobby.findIndex((existing) => existing.identity === entry.identity);
3613
- const lobby = index === -1 ? [...state.lobby, entry] : state.lobby.map((existing, position) => position === index ? entry : existing);
3614
- commit({ ...state, lobby });
3982
+ async setScreenShareEnabled(enabled) {
3983
+ try {
3984
+ await requireRoom("setScreenShareEnabled").localParticipant.setScreenShareEnabled(enabled);
3985
+ } catch (error) {
3986
+ throw normalizeDeviceError(error, "meet.setScreenShareEnabled");
3987
+ }
3615
3988
  },
3616
- setAdmission(identity, admissionState, by) {
3617
- commit({
3618
- ...state,
3619
- lobby: state.lobby.map(
3620
- (entry) => entry.identity === identity ? {
3621
- ...entry,
3622
- state: admissionState,
3623
- decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
3624
- decidedBy: by
3625
- } : entry
3626
- )
3627
- });
3989
+ async publishData(topic, payload, to) {
3990
+ await requireRoom("publishData").localParticipant.publishData(
3991
+ // `reliable` is not optional for a control plane: a dropped
3992
+ // "hand lowered" leaves a hand up on every other screen forever.
3993
+ encoder.encode(payload),
3994
+ to === void 0 || to.length === 0 ? { reliable: true, topic } : { reliable: true, topic, destinationIdentities: [...to] }
3995
+ );
3628
3996
  },
3629
- learn(entries) {
3630
- const names = learnNames(state.names, entries);
3631
- if (names === state.names) return;
3632
- commit({ ...state, names });
3997
+ async setAudioProcessing(processing) {
3998
+ audioCapture = { ...audioCapture, ...processing };
3999
+ const target = room;
4000
+ if (target === null) return;
4001
+ const { Track } = await import("livekit-client");
4002
+ const publication = target.localParticipant.getTrackPublication(Track.Source.Microphone);
4003
+ const track = publication?.track;
4004
+ if (track === void 0 || publication?.isMuted === true) return;
4005
+ try {
4006
+ await track.restartTrack({
4007
+ ...audioCapture,
4008
+ ...target.getActiveDevice("audioinput") !== void 0 ? { deviceId: target.getActiveDevice("audioinput") } : {}
4009
+ });
4010
+ } catch (error) {
4011
+ throw normalizeDeviceError(error, "meet.setAudioProcessing");
4012
+ }
3633
4013
  },
3634
- ingestCaption(segment) {
3635
- const names = learnName(state.names, segment.identity, segment.speaker);
3636
- const transcript = mergeCaption(state.transcript, segment);
3637
- if (transcript === state.transcript && names === state.names) return;
3638
- commit({ ...state, transcript, names });
4014
+ async setVideoEffect(effect) {
4015
+ videoEffect = effect;
4016
+ const target = room;
4017
+ if (target === null) return;
4018
+ await applyVideoEffect(target, effect);
3639
4019
  },
3640
- ingestRecord(segment) {
3641
- const names = learnName(state.names, segment.identity, segment.speaker);
3642
- const transcript = mergeRecord(state.transcript, segment);
3643
- if (transcript === state.transcript && names === state.names) return;
3644
- commit({ ...state, transcript, names });
4020
+ supportsVideoEffects() {
4021
+ return videoEffectsSupported();
3645
4022
  },
3646
- upsertNote(note) {
3647
- const index = state.notes.findIndex((existing2) => existing2.id === note.id);
3648
- if (index === -1) {
3649
- commit({ ...state, notes: [...state.notes, note] });
3650
- return;
3651
- }
3652
- const existing = state.notes[index];
3653
- if (existing !== void 0 && existing.text === note.text && existing.kind === note.kind && existing.supersededBy === note.supersededBy && existing.source === note.source) {
3654
- return;
3655
- }
3656
- if (existing !== void 0 && existing.source === "record" && note.source === "room") {
3657
- return;
4023
+ attach(identity, kind, element) {
4024
+ const target = requireRoom("attach");
4025
+ const source = sources?.[kind];
4026
+ if (source === void 0) return () => void 0;
4027
+ let current = null;
4028
+ const sync = () => {
4029
+ const participant = target.localParticipant.identity === identity ? target.localParticipant : target.remoteParticipants.get(identity);
4030
+ const track = participant?.getTrackPublication(source)?.track;
4031
+ if (track === void 0 || track === current) return;
4032
+ current?.detach(element);
4033
+ track.attach(element);
4034
+ current = track;
4035
+ };
4036
+ const onTrack = (...args) => {
4037
+ const who = args[args.length - 1];
4038
+ if (who?.identity === identity) sync();
4039
+ };
4040
+ sync();
4041
+ const events = trackEvents;
4042
+ if (events !== null) {
4043
+ target.on(events.subscribed, onTrack);
4044
+ target.on(events.localPublished, onTrack);
3658
4045
  }
3659
- commit({
3660
- ...state,
3661
- notes: state.notes.map((entry, position) => position === index ? note : entry)
3662
- });
3663
- },
3664
- announceWrapUp(announcement) {
3665
- const current = state.wrapUp;
3666
- if (current !== null && current.at === announcement.at) return;
3667
- commit({ ...state, wrapUp: announcement });
4046
+ return () => {
4047
+ if (events !== null) {
4048
+ target.off(events.subscribed, onTrack);
4049
+ target.off(events.localPublished, onTrack);
4050
+ }
4051
+ current?.detach(element);
4052
+ current = null;
4053
+ };
3668
4054
  },
3669
- setLocalTrack(kind, next) {
3670
- const current = state.media[kind];
3671
- if (current.enabled === next.enabled && current.unavailable?.code === next.unavailable?.code && current.unavailable?.message === next.unavailable?.message) {
3672
- return;
4055
+ activeDevices() {
4056
+ const target = room;
4057
+ if (target === null) {
4058
+ return { audioinput: null, videoinput: null, audiooutput: null };
3673
4059
  }
3674
- commit({ ...state, media: { ...state.media, [kind]: next } });
4060
+ return {
4061
+ audioinput: target.getActiveDevice("audioinput") ?? null,
4062
+ videoinput: target.getActiveDevice("videoinput") ?? null,
4063
+ audiooutput: target.getActiveDevice("audiooutput") ?? null
4064
+ };
3675
4065
  },
3676
- reset() {
3677
- commit({ ...EMPTY, effects: state.effects });
4066
+ async switchDevice(kind, deviceId) {
4067
+ try {
4068
+ await requireRoom("switchDevice").switchActiveDevice(kind, deviceId);
4069
+ } catch (error) {
4070
+ throw normalizeDeviceError(error, "meet.switchDevice");
4071
+ }
3678
4072
  }
3679
4073
  };
3680
4074
  }
@@ -4202,12 +4596,19 @@ function coveredMs(intervals) {
4202
4596
  }
4203
4597
  function attendanceReport(meeting, attendees, names = {}) {
4204
4598
  const end = ms(meeting.endedAt);
4599
+ const measuredOf = /* @__PURE__ */ new Map();
4205
4600
  const perJoin = attendees.map((person) => {
4206
- const joined = ms(person.joinedAt);
4207
- const left = ms(person.leftAt);
4208
- const until = left ?? end;
4209
- const duration = joined !== null && until !== null && until >= joined ? until - joined : null;
4210
- return {
4601
+ const stays = person.sessions !== void 0 && person.sessions.length > 0 ? person.sessions.map((stay) => [ms(stay.joinedAt), ms(stay.leftAt)]) : [[ms(person.joinedAt), ms(person.leftAt)]];
4602
+ let toEnd = false;
4603
+ const measured = [];
4604
+ for (const [joined, left] of stays) {
4605
+ const until = left ?? end;
4606
+ if (joined === null || until === null || until < joined) continue;
4607
+ if (left === null) toEnd = true;
4608
+ measured.push([joined, until]);
4609
+ }
4610
+ const duration = measured.length === 0 ? null : coveredMs(measured);
4611
+ const row = {
4211
4612
  id: person.id,
4212
4613
  identity: person.identity,
4213
4614
  name: displayNameFor(names, person.identity, person.displayName),
@@ -4216,10 +4617,12 @@ function attendanceReport(meeting, attendees, names = {}) {
4216
4617
  joinedAt: person.joinedAt,
4217
4618
  leftAt: person.leftAt,
4218
4619
  durationMs: duration,
4219
- durationToEnd: duration !== null && left === null,
4620
+ durationToEnd: duration !== null && toEnd,
4220
4621
  admission: person.admissionState,
4221
- joins: 1
4622
+ joins: Math.max(1, person.sessions?.length ?? 0)
4222
4623
  };
4624
+ measuredOf.set(row, measured);
4625
+ return row;
4223
4626
  });
4224
4627
  const groups = /* @__PURE__ */ new Map();
4225
4628
  attendees.forEach((person, index) => {
@@ -4242,17 +4645,14 @@ function attendanceReport(meeting, attendees, names = {}) {
4242
4645
  const left = ms(row.leftAt);
4243
4646
  return left !== null && (latest === null || left > latest) ? left : latest;
4244
4647
  }, null);
4245
- const intervals = came.flatMap((row) => {
4246
- const joined = ms(row.joinedAt);
4247
- return joined !== null && row.durationMs !== null ? [[joined, joined + row.durationMs]] : [];
4248
- });
4648
+ const intervals = came.flatMap((row) => measuredOf.get(row) ?? []);
4249
4649
  return {
4250
4650
  ...first,
4251
4651
  kind,
4252
4652
  leftAt: open ? null : lastLeave === null ? null : new Date(lastLeave).toISOString(),
4253
4653
  durationMs: intervals.length === 0 ? null : coveredMs(intervals),
4254
4654
  durationToEnd: came.some((row) => row.durationToEnd),
4255
- joins: came.length === 0 ? joins.length : came.length
4655
+ joins: came.length === 0 ? joins.length : came.reduce((total, row) => total + row.joins, 0)
4256
4656
  };
4257
4657
  });
4258
4658
  const rank = KIND_RANK;
@@ -4643,6 +5043,7 @@ function createRoomEngine(options) {
4643
5043
  let joining = null;
4644
5044
  let lastRequest = null;
4645
5045
  let disposed = false;
5046
+ let moving = false;
4646
5047
  const confirmingEnds = /* @__PURE__ */ new WeakSet();
4647
5048
  async function leaveRoom() {
4648
5049
  const target = driver;
@@ -4872,6 +5273,7 @@ function createRoomEngine(options) {
4872
5273
  }
4873
5274
  },
4874
5275
  disconnected(reason) {
5276
+ if (moving) return;
4875
5277
  if (current !== null) {
4876
5278
  tokens.invalidate(current.roomName);
4877
5279
  current = null;
@@ -4886,11 +5288,13 @@ function createRoomEngine(options) {
4886
5288
  roomName: request.roomName,
4887
5289
  identity,
4888
5290
  ...request.meetingId !== void 0 ? { meetingId: request.meetingId } : {},
4889
- ...request.slug !== void 0 ? { slug: request.slug } : {}
5291
+ ...request.slug !== void 0 ? { slug: request.slug } : {},
5292
+ ...request.breakoutRoomId != null ? { breakoutRoomId: request.breakoutRoomId } : {}
4890
5293
  });
4891
5294
  current = token;
4892
5295
  store.patch({
4893
5296
  localIdentity: token.identity,
5297
+ breakoutRoomId: token.breakoutRoomId ?? null,
4894
5298
  grants: {
4895
5299
  role: token.role,
4896
5300
  canRecord: token.canRecord,
@@ -5009,6 +5413,56 @@ function createRoomEngine(options) {
5009
5413
  });
5010
5414
  return joining;
5011
5415
  },
5416
+ async moveToBreakout(roomId) {
5417
+ const request = lastRequest;
5418
+ if (request === null || driver === null) {
5419
+ report(
5420
+ "warn",
5421
+ "meet.moveToBreakout: this engine is not in a meeting room",
5422
+ "Join the meeting first; a breakout is a room OF a meeting."
5423
+ );
5424
+ return;
5425
+ }
5426
+ if ((store.snapshot().breakoutRoomId ?? null) === roomId) return;
5427
+ if (joining !== null) await joining.catch(() => void 0);
5428
+ const previous = driver;
5429
+ const next = {
5430
+ ...request,
5431
+ breakoutRoomId: roomId,
5432
+ // Arrive as you were: camera and microphone as they are now.
5433
+ mode: store.snapshot().media.camera.enabled ? "video" : "audio",
5434
+ microphone: store.snapshot().media.microphone.enabled
5435
+ };
5436
+ moving = true;
5437
+ try {
5438
+ driver = null;
5439
+ tokens.invalidate(request.roomName);
5440
+ current = null;
5441
+ await previous.disconnect();
5442
+ lastRequest = next;
5443
+ joining = connect(next).finally(() => {
5444
+ joining = null;
5445
+ });
5446
+ await joining;
5447
+ } catch (error) {
5448
+ const normalized = normalizeMeetError(error, "meet.moveToBreakout");
5449
+ report("warn", normalized.message, normalized.remedy);
5450
+ if (roomId !== null) {
5451
+ lastRequest = { ...next, breakoutRoomId: null };
5452
+ joining = connect(lastRequest).finally(() => {
5453
+ joining = null;
5454
+ });
5455
+ await joining.catch((again) => {
5456
+ throw fail(again, "meet.moveToBreakout");
5457
+ });
5458
+ } else {
5459
+ throw fail(error, "meet.moveToBreakout");
5460
+ }
5461
+ throw normalized;
5462
+ } finally {
5463
+ moving = false;
5464
+ }
5465
+ },
5012
5466
  leave: leaveRoom,
5013
5467
  // Turning a device on mid-meeting is the SAME operation as turning it on
5014
5468
  // during the join, so it runs the same function. A failure here used to
@@ -5278,6 +5732,109 @@ function createInRoomClient(options) {
5278
5732
  (row) => typeof row === "object" && row !== null ? chatFromRow(row) : null
5279
5733
  ).filter((message) => message !== null);
5280
5734
  store.loadChat(messages);
5735
+ },
5736
+ async loadCollab() {
5737
+ const operation = "meet.loadCollab";
5738
+ const id = meetingId(operation);
5739
+ const hdrs = headers();
5740
+ const response = await api.request({
5741
+ path: `${MEET_ROUTES.collab}?meeting_id=${encodeURIComponent(id)}`,
5742
+ method: "GET",
5743
+ operation,
5744
+ ...hdrs !== void 0 ? { headers: hdrs } : {}
5745
+ });
5746
+ if (!response.ok) {
5747
+ const error = await refusal2(
5748
+ response,
5749
+ operation,
5750
+ "Polls, questions and the whiteboard could not be read. New ones still arrive."
5751
+ );
5752
+ store.patch({ collab: { ...store.snapshot().collab, loaded: true, error: error.message } });
5753
+ throw error;
5754
+ }
5755
+ const body = await response.json();
5756
+ store.patch({ collab: collabFromState(body, store.snapshot().collab) });
5757
+ },
5758
+ async collab(request) {
5759
+ const operation = `meet.collab(${request.action})`;
5760
+ const hdrs = headers();
5761
+ const response = await api.request({
5762
+ path: MEET_ROUTES.collab,
5763
+ method: "POST",
5764
+ operation,
5765
+ body: {
5766
+ meeting_id: meetingId(operation),
5767
+ action: request.action,
5768
+ ...request.itemId !== void 0 ? { item_id: request.itemId } : {},
5769
+ ...request.question !== void 0 ? { question: request.question } : {},
5770
+ ...request.options !== void 0 ? { options: request.options } : {},
5771
+ ...request.multiple !== void 0 ? { multiple: request.multiple } : {},
5772
+ ...request.anonymous !== void 0 ? { anonymous: request.anonymous } : {},
5773
+ ...request.optionIds !== void 0 ? { option_ids: request.optionIds } : {},
5774
+ ...request.text !== void 0 ? { text: request.text } : {},
5775
+ ...request.stroke !== void 0 ? { stroke: request.stroke } : {},
5776
+ ...request.rooms !== void 0 ? { rooms: request.rooms } : {},
5777
+ ...request.allowChoose !== void 0 ? { allow_choose: request.allowChoose } : {},
5778
+ ...request.minutes !== void 0 ? { minutes: request.minutes } : {},
5779
+ ...request.identity !== void 0 ? { identity: request.identity } : {},
5780
+ ...request.roomId !== void 0 ? { room_id: request.roomId } : {}
5781
+ },
5782
+ ...hdrs !== void 0 ? { headers: hdrs } : {}
5783
+ });
5784
+ if (!response.ok) {
5785
+ throw await refusal2(response, operation, "Nothing was changed.");
5786
+ }
5787
+ const payload = await response.json();
5788
+ const removed = Array.isArray(payload["removed"]) ? payload["removed"].filter((v) => typeof v === "string") : [];
5789
+ let next = store.snapshot().collab;
5790
+ const item = payload["item"];
5791
+ if (typeof item === "object" && item !== null) next = withItem(next, item);
5792
+ if (removed.length > 0) {
5793
+ const gone = new Set(removed);
5794
+ next = {
5795
+ ...next,
5796
+ polls: next.polls.filter((p) => !gone.has(p.id)),
5797
+ questions: next.questions.filter((q) => !gone.has(q.id)),
5798
+ strokes: next.strokes.filter((st) => !gone.has(st.id))
5799
+ };
5800
+ }
5801
+ store.patch({ collab: next });
5802
+ const itemId = typeof item === "object" && item !== null && typeof item["id"] === "string" ? item["id"] : null;
5803
+ return {
5804
+ action: request.action,
5805
+ message: typeof payload["message"] === "string" ? payload["message"] : "Done.",
5806
+ removed,
5807
+ itemId
5808
+ };
5809
+ },
5810
+ async translateCaptions(language, lines) {
5811
+ if (lines.length === 0) return;
5812
+ const operation = "meet.translateCaptions";
5813
+ const hdrs = headers();
5814
+ const response = await api.request({
5815
+ path: MEET_ROUTES.captionsTranslate,
5816
+ method: "POST",
5817
+ operation,
5818
+ body: { meeting_id: meetingId(operation), target_language: language, lines },
5819
+ ...hdrs !== void 0 ? { headers: hdrs } : {}
5820
+ });
5821
+ if (!response.ok) {
5822
+ const error = await refusal2(response, operation, "Captions stay in the language they were spoken.");
5823
+ store.patch({
5824
+ collab: { ...store.snapshot().collab, captionTranslationError: error.message }
5825
+ });
5826
+ throw error;
5827
+ }
5828
+ const payload = await response.json();
5829
+ const current = store.snapshot().collab;
5830
+ if (current.captionLanguage !== language) return;
5831
+ const merged = { ...current.captionTranslations };
5832
+ for (const [id, value] of Object.entries(payload.translations ?? {})) {
5833
+ if (typeof value === "string") merged[id] = value;
5834
+ }
5835
+ store.patch({
5836
+ collab: { ...current, captionTranslations: merged, captionTranslationError: null }
5837
+ });
5281
5838
  }
5282
5839
  };
5283
5840
  }