@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.js CHANGED
@@ -221,7 +221,14 @@ var MEET_ROUTES = {
221
221
  */
222
222
  moderate: "/api/v1/meet/moderate",
223
223
  /** Meet wave 2: the SAVED chat — `POST` sends to everyone, `GET ?meeting_id=` reads history. */
224
- chat: "/api/v1/meet/chat"
224
+ chat: "/api/v1/meet/chat",
225
+ /**
226
+ * Meet wave 5: polls, Q&A, the shared whiteboard and breakout rooms. `POST` acts
227
+ * (`{meeting_id, action, …}`), `GET ?meeting_id=` reads what this viewer may see.
228
+ */
229
+ collab: "/api/v1/meet/collab",
230
+ /** Meet wave 5: translate finished caption lines into this viewer's language. */
231
+ captionsTranslate: "/api/v1/meet/captions/translate"
225
232
  };
226
233
  function meetingPath(template, meetingId) {
227
234
  return template.replace("{meeting_id}", encodeURIComponent(meetingId));
@@ -280,7 +287,8 @@ function parseRoomToken(body, roomName) {
280
287
  // server that has not landed MRI-A11 — and never invents a host from
281
288
  // silence, because both fallbacks below grant nothing.
282
289
  role: readRole(payload, "role") ?? (isHost ? "host" : identity.startsWith("guest:") ? "guest" : "participant"),
283
- lobby: readBoolean(payload, "lobby", false)
290
+ lobby: readBoolean(payload, "lobby", false),
291
+ breakoutRoomId: readString(payload, "breakout_room_id") ?? null
284
292
  };
285
293
  }
286
294
  function createTokenClient(options) {
@@ -328,6 +336,7 @@ function createTokenClient(options) {
328
336
  room_name: input.roomName,
329
337
  ...input.meetingId !== void 0 ? { meeting_id: input.meetingId } : {},
330
338
  ...input.slug !== void 0 ? { slug: input.slug } : {},
339
+ ...input.breakoutRoomId != null ? { breakout_room_id: input.breakoutRoomId } : {},
331
340
  organization_id: input.identity.organizationId,
332
341
  display_name: input.identity.displayName,
333
342
  device_id: input.identity.deviceId,
@@ -391,7 +400,8 @@ function createTokenClient(options) {
391
400
  return {
392
401
  get(input) {
393
402
  const entry = cache.get(input.roomName);
394
- if (entry !== void 0) {
403
+ const sameRoom = entry !== void 0 && (entry.token.breakoutRoomId ?? null) === (input.breakoutRoomId ?? null);
404
+ if (entry !== void 0 && sameRoom) {
395
405
  if (entry.inFlight !== null) return entry.inFlight;
396
406
  if (!expired(entry)) return Promise.resolve(entry.token);
397
407
  }
@@ -437,7 +447,8 @@ function placeholder(roomName) {
437
447
  canRecord: false,
438
448
  isHost: false,
439
449
  role: "participant",
440
- lobby: false
450
+ lobby: false,
451
+ breakoutRoomId: null
441
452
  };
442
453
  }
443
454
  async function serverSentence(response) {
@@ -1166,6 +1177,339 @@ function createCallCenter(options) {
1166
1177
  };
1167
1178
  }
1168
1179
 
1180
+ // src/core/collab.ts
1181
+ var EMPTY_COLLAB = {
1182
+ loaded: false,
1183
+ error: null,
1184
+ settings: null,
1185
+ polls: [],
1186
+ questions: [],
1187
+ strokes: [],
1188
+ breakout: null,
1189
+ broadcast: null,
1190
+ moveRequest: null,
1191
+ panelRequest: null,
1192
+ captionLanguage: null,
1193
+ captionTranslations: {},
1194
+ captionTranslationError: null
1195
+ };
1196
+ function obj(value) {
1197
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
1198
+ }
1199
+ function str(value) {
1200
+ return typeof value === "string" ? value : null;
1201
+ }
1202
+ function num(value) {
1203
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1204
+ }
1205
+ function strings(value) {
1206
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
1207
+ }
1208
+ function pollFromRow(row) {
1209
+ const id = str(row["id"]);
1210
+ if (id === null) return null;
1211
+ const options = [];
1212
+ for (const raw of Array.isArray(row["options"]) ? row["options"] : []) {
1213
+ const option = obj(raw);
1214
+ const optionId = option === null ? null : str(option["id"]);
1215
+ if (option === null || optionId === null) continue;
1216
+ const votes = num(option["votes"]);
1217
+ options.push({
1218
+ id: optionId,
1219
+ text: str(option["text"]) ?? "",
1220
+ ...votes !== null ? { votes } : {},
1221
+ ...Array.isArray(option["voters"]) ? { voters: strings(option["voters"]) } : {}
1222
+ });
1223
+ }
1224
+ const state = str(row["state"]);
1225
+ return {
1226
+ id,
1227
+ question: str(row["question"]) ?? "",
1228
+ options,
1229
+ multiple: row["multiple"] === true,
1230
+ anonymous: row["anonymous"] === true,
1231
+ state: state === "live" || state === "closed" ? state : "draft",
1232
+ resultsShared: row["results_shared"] === true,
1233
+ voterCount: num(row["voter_count"]),
1234
+ myVote: strings(row["my_vote"]),
1235
+ createdByName: str(row["created_by_name"]) ?? "",
1236
+ createdAt: str(row["created_at"])
1237
+ };
1238
+ }
1239
+ function questionFromRow(row) {
1240
+ const id = str(row["id"]);
1241
+ if (id === null) return null;
1242
+ const state = str(row["state"]);
1243
+ return {
1244
+ id,
1245
+ text: str(row["text"]) ?? "",
1246
+ askerName: str(row["asker_name"]) ?? "",
1247
+ anonymous: row["anonymous"] === true,
1248
+ state: state === "answered" || state === "dismissed" ? state : "open",
1249
+ answer: str(row["answer"]),
1250
+ answeredByName: str(row["answered_by_name"]),
1251
+ upvotes: num(row["upvotes"]) ?? 0,
1252
+ upvotedByMe: row["upvoted_by_me"] === true,
1253
+ mine: row["mine"] === true,
1254
+ createdAt: str(row["created_at"])
1255
+ };
1256
+ }
1257
+ function strokeFromRow(row) {
1258
+ const id = str(row["id"]);
1259
+ if (id === null || !Array.isArray(row["points"])) return null;
1260
+ const points = [];
1261
+ for (const raw of row["points"]) {
1262
+ if (Array.isArray(raw) && typeof raw[0] === "number" && typeof raw[1] === "number") {
1263
+ points.push([raw[0], raw[1]]);
1264
+ }
1265
+ }
1266
+ return {
1267
+ id,
1268
+ points,
1269
+ color: str(row["color"]) ?? "#111827",
1270
+ width: num(row["width"]) ?? 3,
1271
+ tool: row["tool"] === "highlighter" ? "highlighter" : "pen",
1272
+ by: str(row["by"]) ?? "",
1273
+ byName: str(row["by_name"]) ?? ""
1274
+ };
1275
+ }
1276
+ function broadcastFrom(value) {
1277
+ const row = obj(value);
1278
+ const text3 = row === null ? null : str(row["text"]);
1279
+ if (row === null || text3 === null) return null;
1280
+ return { text: text3, by: str(row["by"]) ?? "", at: str(row["at"]) ?? "" };
1281
+ }
1282
+ function planFromRow(row) {
1283
+ const id = str(row["id"]);
1284
+ if (id === null) return null;
1285
+ const rooms = [];
1286
+ for (const raw of Array.isArray(row["rooms"]) ? row["rooms"] : []) {
1287
+ const room = obj(raw);
1288
+ const roomId = room === null ? null : str(room["id"]);
1289
+ if (room === null || roomId === null) continue;
1290
+ rooms.push({ id: roomId, name: str(room["name"]) ?? roomId, identities: strings(room["identities"]) });
1291
+ }
1292
+ return {
1293
+ id,
1294
+ state: row["state"] === "open" ? "open" : "closed",
1295
+ rooms,
1296
+ allowChoose: row["allow_choose"] === true,
1297
+ minutes: num(row["minutes"]) ?? 0,
1298
+ openedAt: str(row["opened_at"]),
1299
+ endsAt: str(row["ends_at"]),
1300
+ closedAt: str(row["closed_at"]),
1301
+ broadcasts: (Array.isArray(row["broadcasts"]) ? row["broadcasts"] : []).map(broadcastFrom).filter((b) => b !== null)
1302
+ };
1303
+ }
1304
+ function settingsFromRow(row) {
1305
+ if (row === null) return null;
1306
+ return {
1307
+ pollsEnabled: row["polls_enabled"] === true,
1308
+ pollMaxOptions: num(row["poll_max_options"]) ?? 10,
1309
+ qaEnabled: row["qa_enabled"] === true,
1310
+ qaAnonymousAllowed: row["qa_anonymous_allowed"] === true,
1311
+ whiteboardEnabled: row["whiteboard_enabled"] === true,
1312
+ breakoutsEnabled: row["breakouts_enabled"] === true,
1313
+ breakoutMaxRooms: num(row["breakout_max_rooms"]) ?? 20,
1314
+ breakoutDefaultMinutes: num(row["breakout_default_minutes"]) ?? 15,
1315
+ captionTranslationEnabled: row["caption_translation_enabled"] === true,
1316
+ captionTranslationLanguage: str(row["caption_translation_language"]) ?? ""
1317
+ };
1318
+ }
1319
+ function collabFromState(body, previous) {
1320
+ const list = (key, parse) => (Array.isArray(body[key]) ? body[key] : []).map((raw) => {
1321
+ const row = obj(raw);
1322
+ return row === null ? null : parse(row);
1323
+ }).filter((item) => item !== null);
1324
+ const breakoutRow = obj(body["breakout"]);
1325
+ return {
1326
+ ...previous,
1327
+ loaded: true,
1328
+ error: null,
1329
+ settings: settingsFromRow(obj(body["settings"])),
1330
+ polls: list("polls", pollFromRow),
1331
+ questions: list("questions", questionFromRow),
1332
+ strokes: list("strokes", strokeFromRow),
1333
+ breakout: breakoutRow === null ? null : planFromRow(breakoutRow)
1334
+ };
1335
+ }
1336
+ function upsert(items, next) {
1337
+ const index = items.findIndex((item) => item.id === next.id);
1338
+ if (index === -1) return [...items, next];
1339
+ const copy = items.slice();
1340
+ copy[index] = next;
1341
+ return copy;
1342
+ }
1343
+ function withItem(collab, row, fromRoom = false) {
1344
+ const kind = str(row["kind"]);
1345
+ if (kind === "poll") {
1346
+ const poll = pollFromRow(row);
1347
+ if (poll === null) return collab;
1348
+ const known = collab.polls.find((p) => p.id === poll.id);
1349
+ const merged = fromRoom && known !== void 0 ? { ...poll, myVote: known.myVote } : poll;
1350
+ return { ...collab, polls: upsert(collab.polls, merged) };
1351
+ }
1352
+ if (kind === "question") {
1353
+ const question = questionFromRow(row);
1354
+ if (question === null) return collab;
1355
+ const known = collab.questions.find((q) => q.id === question.id);
1356
+ const merged = fromRoom && known !== void 0 ? { ...question, mine: known.mine, upvotedByMe: known.upvotedByMe } : question;
1357
+ return { ...collab, questions: upsert(collab.questions, merged) };
1358
+ }
1359
+ if (kind === "stroke") {
1360
+ const stroke = strokeFromRow(row);
1361
+ if (stroke === null) return collab;
1362
+ return { ...collab, strokes: upsert(collab.strokes, stroke) };
1363
+ }
1364
+ if (kind === "breakout") {
1365
+ const plan = planFromRow(row);
1366
+ if (plan === null) return collab;
1367
+ return { ...collab, breakout: plan };
1368
+ }
1369
+ return collab;
1370
+ }
1371
+ function without(collab, ids) {
1372
+ const gone = new Set(ids);
1373
+ return {
1374
+ ...collab,
1375
+ polls: collab.polls.filter((p) => !gone.has(p.id)),
1376
+ questions: collab.questions.filter((q) => !gone.has(q.id)),
1377
+ strokes: collab.strokes.filter((s) => !gone.has(s.id))
1378
+ };
1379
+ }
1380
+ function applyCollabPacket(collab, payload, context) {
1381
+ const item = obj(payload["item"]);
1382
+ const audience = str(payload["audience"]);
1383
+ if (item !== null) {
1384
+ const kind = str(item["kind"]);
1385
+ const hostOnlyView = kind === "poll" || kind === "question";
1386
+ if (hostOnlyView && context.isHost && audience !== "hosts") {
1387
+ return { next: null, dropped: "the room copy of an item a host receives in full" };
1388
+ }
1389
+ let next = withItem(collab, item, true);
1390
+ if (kind === "poll" && str(item["state"]) === "live") {
1391
+ const before = collab.polls.find((p) => p.id === str(item["id"]));
1392
+ if (before === void 0 || before.state !== "live") {
1393
+ next = { ...next, panelRequest: { panel: "polls", at: context.now } };
1394
+ }
1395
+ }
1396
+ if (kind === "breakout") {
1397
+ const plan = planFromRow(item);
1398
+ if (plan !== null && plan.state === "closed") {
1399
+ next = { ...next, moveRequest: { roomId: null, at: context.now } };
1400
+ }
1401
+ }
1402
+ const move = obj(payload["move"]);
1403
+ if (move !== null && str(move["identity"]) === context.localIdentity) {
1404
+ next = { ...next, moveRequest: { roomId: str(move["room_id"]), at: context.now } };
1405
+ }
1406
+ return { next };
1407
+ }
1408
+ if (Array.isArray(payload["removed"])) {
1409
+ return { next: without(collab, strings(payload["removed"])) };
1410
+ }
1411
+ if (Array.isArray(payload["hidden"])) {
1412
+ return context.isHost ? { next: null, dropped: "hosts keep dismissed questions" } : { next: without(collab, strings(payload["hidden"])) };
1413
+ }
1414
+ if (payload["cleared"] === "whiteboard") {
1415
+ return { next: { ...collab, strokes: [] } };
1416
+ }
1417
+ const broadcast = broadcastFrom(payload["broadcast"]);
1418
+ if (broadcast !== null) {
1419
+ return { next: { ...collab, broadcast } };
1420
+ }
1421
+ return { next: null, dropped: "a collab packet with nothing this version understands" };
1422
+ }
1423
+ function assignedRoom(plan, identity) {
1424
+ if (plan === null || plan.state !== "open" || identity === null) return null;
1425
+ return plan.rooms.find((room) => room.identities.includes(identity)) ?? null;
1426
+ }
1427
+ function autoAssign(identities, rooms, exclude = []) {
1428
+ const count2 = Math.max(1, Math.floor(rooms));
1429
+ const out = Array.from({ length: count2 }, () => []);
1430
+ const skip = new Set(exclude);
1431
+ let index = 0;
1432
+ for (const identity of identities) {
1433
+ if (skip.has(identity)) continue;
1434
+ out[index % count2].push(identity);
1435
+ index += 1;
1436
+ }
1437
+ return out;
1438
+ }
1439
+ function breakoutRemainingMs(plan, now) {
1440
+ if (plan === null || plan.state !== "open" || plan.endsAt === null) return null;
1441
+ const end = Date.parse(plan.endsAt);
1442
+ return Number.isNaN(end) ? null : Math.max(0, end - now);
1443
+ }
1444
+ function defaultCaptionLanguage(settings, viewerLanguage) {
1445
+ const configured = settings?.captionTranslationLanguage.trim() ?? "";
1446
+ if (configured.length > 0) return configured;
1447
+ const own = (viewerLanguage ?? "").trim();
1448
+ return own.length > 0 ? own : "en";
1449
+ }
1450
+ var CAPTION_LANGUAGES = [
1451
+ { tag: "en", name: "English" },
1452
+ { tag: "es", name: "Spanish" },
1453
+ { tag: "fr", name: "French" },
1454
+ { tag: "de", name: "German" },
1455
+ { tag: "it", name: "Italian" },
1456
+ { tag: "pt", name: "Portuguese" },
1457
+ { tag: "nl", name: "Dutch" },
1458
+ { tag: "pl", name: "Polish" },
1459
+ { tag: "ru", name: "Russian" },
1460
+ { tag: "uk", name: "Ukrainian" },
1461
+ { tag: "tr", name: "Turkish" },
1462
+ { tag: "ar", name: "Arabic" },
1463
+ { tag: "he", name: "Hebrew" },
1464
+ { tag: "fa", name: "Persian" },
1465
+ { tag: "hi", name: "Hindi" },
1466
+ { tag: "bn", name: "Bengali" },
1467
+ { tag: "zh", name: "Chinese" },
1468
+ { tag: "ja", name: "Japanese" },
1469
+ { tag: "ko", name: "Korean" },
1470
+ { tag: "vi", name: "Vietnamese" },
1471
+ { tag: "th", name: "Thai" },
1472
+ { tag: "id", name: "Indonesian" },
1473
+ { tag: "sv", name: "Swedish" }
1474
+ ];
1475
+ async function loadMeetingActivities(api, meetingId, roomToken = null) {
1476
+ let response;
1477
+ try {
1478
+ response = await api.request({
1479
+ path: `${MEET_ROUTES.collab}?meeting_id=${encodeURIComponent(meetingId)}`,
1480
+ method: "GET",
1481
+ operation: "meet.loadMeetingActivities",
1482
+ requireAuth: roomToken === null,
1483
+ ...roomToken !== null ? { headers: { [ROOM_TOKEN_HEADER]: roomToken } } : {}
1484
+ });
1485
+ } catch (error) {
1486
+ return {
1487
+ collab: null,
1488
+ refusal: {
1489
+ message: `Polls, questions and the whiteboard could not be read (${error.message}).`,
1490
+ remedy: error.remedy ?? "Reload the page."
1491
+ }
1492
+ };
1493
+ }
1494
+ if (!response.ok) {
1495
+ let body = null;
1496
+ try {
1497
+ body = await response.json();
1498
+ } catch {
1499
+ body = null;
1500
+ }
1501
+ const said = readServerRefusal(body);
1502
+ return {
1503
+ collab: null,
1504
+ refusal: {
1505
+ message: said?.reason ?? `Polls and questions could not be read (the server answered ${response.status}).`,
1506
+ remedy: said?.remedy || "Reload the page."
1507
+ }
1508
+ };
1509
+ }
1510
+ return { collab: collabFromState(await response.json(), EMPTY_COLLAB), refusal: null };
1511
+ }
1512
+
1169
1513
  // src/core/repository.ts
1170
1514
  var MEET_SCHEMA = "communication";
1171
1515
  var TABLES = {
@@ -1241,7 +1585,19 @@ var INVITEE_COLUMNS = "id,meeting_id,invitee_user_id,email,display_name,role,rsv
1241
1585
  var RECORDING_COLUMNS = "id,meeting_id,state,started_at,ended_at,started_by,file_id,failure_reason";
1242
1586
  var SEGMENT_COLUMNS = "id,meeting_id,identity,speaker,text,started_at,is_final,metadata";
1243
1587
  var NOTE_COLUMNS = "id,meeting_id,text,kind,assignee_user_id,created_at,metadata";
1244
- var ATTENDEE_COLUMNS = "id,meeting_id,identity,participant_user_id,display_name,avatar_url,role,admission_state,is_agent,joined_at,left_at";
1588
+ 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";
1589
+ function projectStays(value) {
1590
+ if (!Array.isArray(value)) return [];
1591
+ const stays = [];
1592
+ for (const item of value) {
1593
+ if (item === null || typeof item !== "object") continue;
1594
+ const joined = item["joined_at"];
1595
+ const left = item["left_at"];
1596
+ if (typeof joined !== "string" || joined.length === 0) continue;
1597
+ stays.push({ joinedAt: joined, leftAt: typeof left === "string" && left.length > 0 ? left : null });
1598
+ }
1599
+ return stays;
1600
+ }
1245
1601
  function text(row, key) {
1246
1602
  const value = row[key];
1247
1603
  return typeof value === "string" && value.length > 0 ? value : null;
@@ -1340,7 +1696,7 @@ var OCCURRENCE_STATES = ["scheduled", "moved", "cancelled"];
1340
1696
  function rsvpState(value) {
1341
1697
  return RSVP_STATES.includes(value) ? value : "needs_action";
1342
1698
  }
1343
- function num(row, key) {
1699
+ function num2(row, key) {
1344
1700
  const value = row[key];
1345
1701
  return typeof value === "number" ? value : null;
1346
1702
  }
@@ -1362,7 +1718,7 @@ function projectInvitee(row) {
1362
1718
  rsvpState: rsvpState(text(row, "rsvp_state")),
1363
1719
  respondedAt: when(row, "responded_at"),
1364
1720
  rsvpNote: text(row, "rsvp_note"),
1365
- lastSentSequence: num(row, "last_sent_sequence"),
1721
+ lastSentSequence: num2(row, "last_sent_sequence"),
1366
1722
  lastSentAt: when(row, "last_sent_at")
1367
1723
  };
1368
1724
  }
@@ -1373,10 +1729,10 @@ function projectOccurrence(row) {
1373
1729
  meetingId: asMeetingId(requireText(row, "meeting_id", operation)),
1374
1730
  originalStart: when(row, "original_start") ?? requireText(row, "original_start", operation),
1375
1731
  occurrenceStart: when(row, "occurrence_start") ?? requireText(row, "occurrence_start", operation),
1376
- durationMinutes: num(row, "duration_minutes"),
1732
+ durationMinutes: num2(row, "duration_minutes"),
1377
1733
  // An unknown state is shown as scheduled, never dropped: the row is real.
1378
1734
  state: OCCURRENCE_STATES.includes(state) ? state : "scheduled",
1379
- sequence: num(row, "sequence") ?? 0
1735
+ sequence: num2(row, "sequence") ?? 0
1380
1736
  };
1381
1737
  }
1382
1738
  function projectUpcoming(row) {
@@ -1405,9 +1761,9 @@ function projectOverride(row) {
1405
1761
  originalStart: when(row, "original_start") ?? requireText(row, "original_start", operation),
1406
1762
  state: text(row, "state") === "moved" ? "moved" : "cancelled",
1407
1763
  newStart: when(row, "new_start"),
1408
- newDurationMinutes: num(row, "new_duration_minutes"),
1764
+ newDurationMinutes: num2(row, "new_duration_minutes"),
1409
1765
  reason: text(row, "reason"),
1410
- calendarSequence: num(row, "calendar_sequence") ?? 0,
1766
+ calendarSequence: num2(row, "calendar_sequence") ?? 0,
1411
1767
  deletedAt: when(row, "deleted_at")
1412
1768
  };
1413
1769
  }
@@ -1431,7 +1787,7 @@ function projectRsvpInvitation(body) {
1431
1787
  title: text(meeting, "title") ?? "Meeting",
1432
1788
  slug: requireText(meeting, "slug", operation),
1433
1789
  scheduledFor: when(meeting, "scheduled_for"),
1434
- durationMinutes: num(meeting, "duration_minutes"),
1790
+ durationMinutes: num2(meeting, "duration_minutes"),
1435
1791
  timeZone: text(meeting, "time_zone") ?? "UTC",
1436
1792
  agenda: text(meeting, "agenda"),
1437
1793
  recurrenceRule: text(meeting, "recurrence_rule"),
@@ -1465,6 +1821,7 @@ function changesPatch(changes) {
1465
1821
  patch["scheduled_duration_minutes"] = changes.scheduledDurationMinutes;
1466
1822
  }
1467
1823
  if (changes.recurrenceRule !== void 0) patch["recurrence_rule"] = changes.recurrenceRule;
1824
+ if (changes.keepExceptions !== void 0) patch["keep_exceptions"] = changes.keepExceptions;
1468
1825
  return patch;
1469
1826
  }
1470
1827
  function createMeetRepository(options) {
@@ -1611,7 +1968,8 @@ function createMeetRepository(options) {
1611
1968
  admissionState: states.includes(admission) ? admission : "admitted",
1612
1969
  isAgent: bool(row, "is_agent", false),
1613
1970
  joinedAt: text(row, "joined_at"),
1614
- leftAt: text(row, "left_at")
1971
+ leftAt: text(row, "left_at"),
1972
+ sessions: projectStays(row["sessions"])
1615
1973
  };
1616
1974
  }
1617
1975
  function requireOrg(organizationId, operation) {
@@ -2005,8 +2363,8 @@ function projectLibraryRecording(row) {
2005
2363
  fileId: text(row, "file_id"),
2006
2364
  startedAt: text(row, "started_at"),
2007
2365
  endedAt: text(row, "ended_at"),
2008
- durationMs: num(row, "duration_ms"),
2009
- sizeBytes: num(row, "size_bytes"),
2366
+ durationMs: num2(row, "duration_ms"),
2367
+ sizeBytes: num2(row, "size_bytes"),
2010
2368
  failureReason: text(row, "failure_reason"),
2011
2369
  archivedAt: text(row, "deleted_at"),
2012
2370
  organizationId: asOrganizationId(requireText(row, "organization_id", operation))
@@ -2033,7 +2391,7 @@ function projectSearchHit(row) {
2033
2391
  speaker: text(row, "speaker"),
2034
2392
  snippet: text(row, "snippet") ?? "",
2035
2393
  at: text(row, "said_at"),
2036
- rank: num(row, "rank") ?? 0
2394
+ rank: num2(row, "rank") ?? 0
2037
2395
  };
2038
2396
  }
2039
2397
  function snippetRuns(snippet) {
@@ -2050,567 +2408,1031 @@ function snippetRuns(snippet) {
2050
2408
  return runs;
2051
2409
  }
2052
2410
 
2053
- // src/core/room-events.ts
2054
- var RECORDING_STATES = [
2055
- "idle",
2056
- "starting",
2057
- "recording",
2058
- "stopping",
2059
- "processing",
2060
- "available",
2061
- "expired",
2062
- "failed"
2063
- ];
2064
- var ROOM_EVENT_PREFIX = "matrx.meet.";
2065
- var ROOM_EVENTS = {
2066
- /** One durable `meet_notes` row was written. Payload `{ note: <row> }`. */
2067
- note: "matrx.meet.note",
2068
- /** `meet_meetings.metadata.note_taker` changed. Payload `{ meeting_id, note_taker }`. */
2069
- noteTaker: "matrx.meet.note_taker",
2070
- /** A wrap-up pass finished. Payload `{ meeting_id, note_ids, segment_count }`. */
2071
- wrapUp: "matrx.meet.wrap_up",
2072
- /** The meeting ended for everyone. Payload `{ meeting_id, ended_at, trigger }`. */
2073
- ended: "matrx.meet.ended",
2074
- /**
2075
- * A `meet_recordings` row changed state. Payload
2076
- * `{ meeting_id, recording: { id, state, started_by } }`.
2077
- *
2078
- * 🚨 THIS IS THE ONLY WAY A GUEST LEARNS A RECORDING IS RUNNING. PreJoin
2079
- * promises every joiner an indicator, and `anon` holds no SELECT grant on
2080
- * `meet_recordings` (MRI-A10 settled that widening it is forbidden), so
2081
- * without this packet the promise was a sentence with nothing behind it: on
2082
- * production 2026-09-09 the guest lane showed no indicator at all while the
2083
- * host recorded (MRI-C8).
2084
- */
2085
- recording: "matrx.meet.recording",
2086
- /**
2087
- * A chat message to everyone was SAVED (Meet wave 2). Payload
2088
- * `{ message: { id, meeting_id, sender_identity, sender_name, body, created_at } }`.
2089
- */
2090
- chat: "matrx.meet.chat",
2091
- /**
2092
- * A host or co-host acted (Meet wave 2). Payload `{ meeting_id, kind, … }`:
2093
- * `muted {source, by}` · `ask-unmute {by}` · `removed {by}` — addressed to the
2094
- * person concerned; `lower-hands {identities: [...] | "*"}` · `role {identity,
2095
- * role}` — to everyone.
2096
- */
2097
- moderation: "matrx.meet.moderation"
2098
- };
2099
- function isRoomEventTopic(topic) {
2100
- return topic.startsWith(ROOM_EVENT_PREFIX);
2101
- }
2102
- function object(value) {
2103
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
2411
+ // src/core/names.ts
2412
+ function isGuestIdentity(identity) {
2413
+ return String(identity).startsWith("guest:");
2104
2414
  }
2105
- function text2(value) {
2106
- return typeof value === "string" && value.length > 0 ? value : null;
2415
+ function isAgentIdentity(identity) {
2416
+ return String(identity).startsWith("agent:");
2107
2417
  }
2108
- var HANDLED = { handled: true };
2109
- function ignored(reason) {
2110
- return { handled: false, ignored: reason };
2418
+ var NOTE_TAKER_PRODUCT_NAME = "Matrx note-taker";
2419
+ var AGENT_PRODUCT_NAME = "Matrx agent";
2420
+ var UNNAMED_GUEST = "Unnamed guest";
2421
+ var UNNAMED_PARTICIPANT = "Unnamed participant";
2422
+ var PLACEHOLDER_NAMES = [
2423
+ NOTE_TAKER_PRODUCT_NAME,
2424
+ AGENT_PRODUCT_NAME,
2425
+ UNNAMED_GUEST,
2426
+ UNNAMED_PARTICIPANT
2427
+ ];
2428
+ function isPlaceholderName(name) {
2429
+ return typeof name === "string" && PLACEHOLDER_NAMES.includes(name.trim());
2111
2430
  }
2112
- function applyRoomEvent(args) {
2113
- const { store, topic } = args;
2114
- if (!isRoomEventTopic(topic)) {
2115
- return ignored(`${topic} is not a Meet room event`);
2116
- }
2117
- const payload = object(args.payload);
2118
- if (payload === null) {
2119
- return ignored(`${topic} carried no object payload`);
2120
- }
2121
- const now = args.now ?? (() => Date.now());
2122
- switch (topic) {
2123
- case ROOM_EVENTS.note:
2124
- return applyNote(store, payload);
2125
- case ROOM_EVENTS.noteTaker:
2126
- return applyNoteTaker(store, payload);
2127
- case ROOM_EVENTS.wrapUp:
2128
- return applyWrapUp(store, payload, now);
2129
- case ROOM_EVENTS.ended:
2130
- return applyEnded(store, payload);
2131
- case ROOM_EVENTS.recording:
2132
- return applyRecording(store, payload);
2133
- case ROOM_EVENTS.chat:
2134
- return applyChat(store, payload);
2135
- case ROOM_EVENTS.moderation:
2136
- return applyModeration(store, payload, now);
2137
- default:
2138
- return ignored(`${topic} is not known to this version of @ai-matrx/meet`);
2431
+ function placeholderNameFor(identity) {
2432
+ const key = String(identity);
2433
+ if (isAgentIdentity(key)) {
2434
+ return key.startsWith("agent:note_taker:") || key.startsWith("agent:note-taker") ? NOTE_TAKER_PRODUCT_NAME : AGENT_PRODUCT_NAME;
2139
2435
  }
2436
+ return isGuestIdentity(key) ? UNNAMED_GUEST : UNNAMED_PARTICIPANT;
2140
2437
  }
2141
- function applyNote(store, payload) {
2142
- const row = object(payload["note"]);
2143
- if (row === null) return ignored("a note event with no `note` row");
2144
- if (text2(row["id"]) === null || text2(row["meeting_id"]) === null) {
2145
- return ignored("a note row with no id or meeting_id");
2146
- }
2147
- const meeting = store.snapshot().meeting;
2148
- if (meeting !== null && row["meeting_id"] !== meeting.id) {
2149
- return ignored("a note for a different meeting than this room is showing");
2150
- }
2151
- try {
2152
- store.upsertNote({ ...projectNote(row), source: "room" });
2153
- } catch (error) {
2154
- return ignored(`a note row this client could not read: ${String(error)}`);
2155
- }
2156
- return HANDLED;
2438
+ function isRealName(identity, name) {
2439
+ if (typeof name !== "string") return false;
2440
+ const trimmed = name.trim();
2441
+ if (trimmed.length === 0) return false;
2442
+ if (trimmed === String(identity)) return false;
2443
+ if (isPlaceholderName(trimmed)) return false;
2444
+ return true;
2157
2445
  }
2158
- function applyNoteTaker(store, payload) {
2159
- const noteTaker = object(payload["note_taker"]);
2160
- if (noteTaker === null) return ignored("a note-taker event with no state");
2161
- const meeting = store.snapshot().meeting;
2162
- if (meeting === null) {
2163
- return ignored("a note-taker state with no meeting record to attach it to");
2164
- }
2165
- const meetingId = text2(payload["meeting_id"]);
2166
- if (meetingId !== null && meetingId !== meeting.id) {
2167
- return ignored("a note-taker state for a different meeting");
2168
- }
2169
- const next = {
2170
- ...meeting,
2171
- metadata: { ...meeting.metadata, note_taker: noteTaker }
2172
- };
2173
- store.patch({ meeting: next });
2174
- return HANDLED;
2446
+ function learnName(book, identity, name) {
2447
+ const key = String(identity);
2448
+ if (key.length === 0) return book;
2449
+ if (!isRealName(key, name)) return book;
2450
+ const learned = name.trim();
2451
+ if (book[key] === learned) return book;
2452
+ return { ...book, [key]: learned };
2175
2453
  }
2176
- function applyWrapUp(store, payload, now) {
2177
- const meetingId = text2(payload["meeting_id"]);
2178
- if (meetingId === null) return ignored("a wrap-up event with no meeting id");
2179
- const meeting = store.snapshot().meeting;
2180
- if (meeting !== null && meetingId !== meeting.id) {
2181
- return ignored("a wrap-up for a different meeting");
2182
- }
2183
- const rawIds = payload["note_ids"];
2184
- const noteIds = Array.isArray(rawIds) ? rawIds.filter((value) => typeof value === "string") : [];
2185
- const segmentCount = payload["segment_count"];
2186
- const announcement = {
2187
- meetingId: asMeetingId(meetingId),
2188
- noteIds,
2189
- segmentCount: typeof segmentCount === "number" ? segmentCount : 0,
2190
- at: new Date(now()).toISOString()
2191
- };
2192
- store.announceWrapUp(announcement);
2193
- return HANDLED;
2194
- }
2195
- function applyRecording(store, payload) {
2196
- const row = object(payload["recording"]);
2197
- if (row === null) return ignored("a recording event with no `recording` row");
2198
- const meetingId = text2(payload["meeting_id"]);
2199
- const meeting = store.snapshot().meeting;
2200
- if (meeting !== null && meetingId !== null && meetingId !== meeting.id) {
2201
- return ignored("a recording state for a different meeting than this room is showing");
2202
- }
2203
- const state = text2(row["state"]);
2204
- if (state === null || !RECORDING_STATES.includes(state)) {
2205
- return ignored(`a recording state this version does not know: ${String(row["state"])}`);
2206
- }
2207
- const by = text2(row["started_by"]);
2208
- store.setRecording(
2209
- state,
2210
- by === null ? null : by,
2211
- "room",
2212
- text2(row["id"])
2213
- );
2214
- return HANDLED;
2215
- }
2216
- function applyEnded(store, payload) {
2217
- const meetingId = text2(payload["meeting_id"]);
2218
- const meeting = store.snapshot().meeting;
2219
- if (meeting === null) return ignored("an ended event with no meeting record");
2220
- if (meetingId !== null && meetingId !== meeting.id) {
2221
- return ignored("an ended event for a different meeting");
2222
- }
2223
- const endedAt = text2(payload["ended_at"]) ?? (/* @__PURE__ */ new Date()).toISOString();
2224
- if (meeting.endedAt === endedAt) return HANDLED;
2225
- store.patch({ meeting: { ...meeting, endedAt } });
2226
- return HANDLED;
2227
- }
2228
- function chatFromRow(row) {
2229
- const id = text2(row["id"]);
2230
- const identity = text2(row["sender_identity"]);
2231
- const body = text2(row["body"]);
2232
- if (id === null || identity === null || body === null) return null;
2233
- return {
2234
- id,
2235
- identity: asParticipantIdentity(identity),
2236
- displayName: text2(row["sender_name"]) ?? "Participant",
2237
- text: body,
2238
- at: text2(row["created_at"]) ?? (/* @__PURE__ */ new Date()).toISOString(),
2239
- // `false` only from a server whose chat table has not landed: the room heard
2240
- // it, nobody will see it later — and the line says so.
2241
- saved: row["saved"] !== false
2242
- };
2243
- }
2244
- function applyChat(store, payload) {
2245
- const row = object(payload["message"]);
2246
- if (row === null) return ignored("a chat event with no `message` row");
2247
- const meeting = store.snapshot().meeting;
2248
- if (meeting !== null && text2(row["meeting_id"]) !== null && row["meeting_id"] !== meeting.id) {
2249
- return ignored("a chat message for a different meeting");
2250
- }
2251
- const message = chatFromRow(row);
2252
- if (message === null) return ignored("a chat row with no id, sender or body");
2253
- store.addChat(message);
2254
- return HANDLED;
2454
+ function learnNames(book, entries) {
2455
+ let next = book;
2456
+ for (const entry of entries) next = learnName(next, entry.identity, entry.displayName);
2457
+ return next;
2255
2458
  }
2256
- function moderationText(kind, payload) {
2257
- const by = text2(payload["by"]) ?? "The host";
2258
- switch (kind) {
2259
- case "muted": {
2260
- const source = text2(payload["source"]) ?? "microphone";
2261
- return source === "microphone" ? `${by} muted you.` : source === "camera" ? `${by} turned off your camera.` : `${by} stopped your screen share.`;
2262
- }
2263
- case "ask-unmute":
2264
- return `${by} is asking you to unmute.`;
2265
- case "removed":
2266
- return `${by} removed you from this meeting.`;
2267
- default:
2268
- return null;
2269
- }
2459
+ function displayNameFor(book, identity, preferred) {
2460
+ const key = String(identity);
2461
+ if (isRealName(key, preferred)) return preferred.trim();
2462
+ const known = book[key];
2463
+ if (known !== void 0) return known;
2464
+ return placeholderNameFor(key);
2270
2465
  }
2271
- function applyModeration(store, payload, now) {
2272
- const snapshot = store.snapshot();
2273
- const meetingId = text2(payload["meeting_id"]);
2274
- if (snapshot.meeting !== null && meetingId !== null && meetingId !== snapshot.meeting.id) {
2275
- return ignored("a moderation event for a different meeting");
2276
- }
2277
- const kind = text2(payload["kind"]);
2278
- switch (kind) {
2279
- case "lower-hands": {
2280
- const raw = payload["identities"];
2281
- const everyone = raw === "*";
2282
- const named = new Set(
2283
- Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []
2284
- );
2285
- for (const participant of snapshot.participants) {
2286
- if (participant.handRaised && (everyone || named.has(participant.identity))) {
2287
- store.setHand(participant.identity, false);
2288
- }
2466
+
2467
+ // src/core/recording.ts
2468
+ function createRecordingController(options) {
2469
+ async function post(route, body) {
2470
+ let response;
2471
+ try {
2472
+ response = await options.api.request({
2473
+ path: route,
2474
+ method: "POST",
2475
+ // A GUEST CAN NEVER START OR STOP A RECORDING (R5) — refused here,
2476
+ // before the wire, with the same sentence the hidden control implies.
2477
+ requireAuth: true,
2478
+ operation: "meet.recording",
2479
+ body
2480
+ });
2481
+ } catch (error) {
2482
+ if (error.code === "session-unavailable") {
2483
+ throw new MeetError(
2484
+ "forbidden",
2485
+ "meet.recording: recording requires an authenticated host",
2486
+ "A guest can never start or stop a recording (R5). The control is hidden for guests."
2487
+ );
2289
2488
  }
2290
- return HANDLED;
2489
+ throw error;
2291
2490
  }
2292
- case "role": {
2293
- const identity = text2(payload["identity"]);
2294
- const role = text2(payload["role"]);
2295
- if (identity === null || role === null || !ROLES.includes(role)) {
2296
- return ignored("a role event with no identity or an unknown role");
2297
- }
2298
- if (identity === snapshot.localIdentity) {
2299
- store.patch({
2300
- grants: { ...snapshot.grants, role },
2301
- moderationNotice: {
2302
- kind: "role",
2303
- text: role === "host" ? "You are now the host." : role === "cohost" ? "You are now a co-host." : "You are no longer a co-host.",
2304
- at: now()
2305
- }
2306
- });
2307
- }
2308
- return HANDLED;
2491
+ if (response.status === 403) {
2492
+ throw new MeetError(
2493
+ "forbidden",
2494
+ "meet.recording: the server refused this recording action",
2495
+ "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."
2496
+ );
2309
2497
  }
2310
- case "muted":
2311
- case "ask-unmute":
2312
- case "removed": {
2313
- const sentence = moderationText(kind, payload);
2314
- if (sentence === null) return ignored("a moderation event with no sentence");
2315
- store.patch({ moderationNotice: { kind, text: sentence, at: now() } });
2316
- return HANDLED;
2498
+ if (!response.ok) {
2499
+ throw new MeetError(
2500
+ "transport",
2501
+ `meet.recording: the server returned ${response.status}`,
2502
+ "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."
2503
+ );
2317
2504
  }
2318
- default:
2319
- return ignored(`a moderation kind this version does not know: ${String(kind)}`);
2320
2505
  }
2321
- }
2322
- var ROLES = ["host", "cohost", "participant", "guest"];
2323
-
2324
- // src/core/devices.ts
2325
- var KINDS = ["audioinput", "videoinput", "audiooutput"];
2326
- function isDeviceKind(value) {
2327
- return value === "audioinput" || value === "videoinput" || value === "audiooutput";
2328
- }
2329
- function createWebDeviceStorage(key = "mx.meet.devices") {
2330
- const store = globalThis.localStorage;
2331
- if (store === void 0) return createMemoryDeviceStorage();
2332
2506
  return {
2333
- durable: true,
2334
- read() {
2507
+ canStart(meeting, isHost) {
2508
+ if (meeting === null) return false;
2509
+ if (meeting.recordingPolicy === "disabled") return false;
2510
+ if (meeting.recordingPolicy === "always-on") return false;
2511
+ return isHost;
2512
+ },
2513
+ async start({ meetingId, roomName, by }) {
2514
+ options.store.setRecording("starting", by, "local");
2335
2515
  try {
2336
- const raw = store.getItem(key);
2337
- if (raw === null) return {};
2338
- const parsed = JSON.parse(raw);
2339
- return typeof parsed === "object" && parsed !== null ? parsed : {};
2340
- } catch {
2341
- return {};
2516
+ await post(MEET_ROUTES.recordingStart, {
2517
+ meeting_id: meetingId,
2518
+ room_name: roomName
2519
+ });
2520
+ } catch (error) {
2521
+ options.store.setRecording("idle", null, "local");
2522
+ throw error;
2342
2523
  }
2524
+ options.announce?.("starting", by);
2343
2525
  },
2344
- write(value) {
2526
+ async stop({ meetingId, by }) {
2527
+ options.store.setRecording("stopping", options.store.snapshot().recordingStartedBy, "local");
2345
2528
  try {
2346
- store.setItem(key, JSON.stringify(value));
2347
- } catch {
2529
+ await post(MEET_ROUTES.recordingStop, { meeting_id: meetingId });
2530
+ } catch (error) {
2531
+ options.store.setRecording(
2532
+ "recording",
2533
+ options.store.snapshot().recordingStartedBy,
2534
+ "local"
2535
+ );
2536
+ throw error;
2537
+ }
2538
+ options.announce?.("stopping", by);
2539
+ },
2540
+ consentNotice(meeting) {
2541
+ if (meeting === null || meeting.recordingPolicy === "disabled") return null;
2542
+ if (meeting.recordingPolicy === "always-on") {
2543
+ return "This meeting is recorded and transcribed automatically. By joining, you consent to being recorded.";
2348
2544
  }
2545
+ return "The host can record and transcribe this meeting. You will see a recording indicator whenever it is running.";
2546
+ },
2547
+ async acknowledge(meetingId) {
2548
+ await options.acknowledgeConsent?.(meetingId);
2349
2549
  }
2350
2550
  };
2351
2551
  }
2352
- function createMemoryDeviceStorage() {
2353
- let value = {};
2354
- return {
2355
- durable: false,
2356
- read: () => value,
2357
- write: (next) => {
2358
- value = next;
2359
- }
2360
- };
2552
+ var RECORDING_IN_FLIGHT = [
2553
+ "starting",
2554
+ "recording",
2555
+ "stopping"
2556
+ ];
2557
+ function isRecordingInFlight(state) {
2558
+ return RECORDING_IN_FLIGHT.includes(state);
2361
2559
  }
2362
- function createDeviceManager(options) {
2363
- const storage = options.storage ?? createMemoryDeviceStorage();
2364
- const listeners = /* @__PURE__ */ new Set();
2365
- const stored = storage.read();
2366
- let devices = {
2367
- audioinput: [],
2368
- videoinput: [],
2369
- audiooutput: []
2370
- };
2371
- let selected = {
2372
- audioinput: stored["audioinput"] ?? null,
2373
- videoinput: stored["videoinput"] ?? null,
2374
- audiooutput: stored["audiooutput"] ?? null
2375
- };
2376
- let labelsHidden = true;
2377
- let permission = "unknown";
2378
- let fallbackNotice = null;
2379
- let current = {
2380
- devices,
2381
- selected,
2382
- labelsHidden,
2383
- permission,
2384
- fallbackNotice
2385
- };
2386
- function snapshot() {
2387
- return current;
2388
- }
2389
- function commit() {
2390
- current = { devices, selected, labelsHidden, permission, fallbackNotice };
2391
- return current;
2392
- }
2393
- function emit() {
2394
- const next = commit();
2395
- options.onChange?.(next);
2396
- for (const listener of listeners) listener(next);
2397
- return next;
2398
- }
2399
- async function enumerate() {
2400
- let raw;
2401
- try {
2402
- raw = await options.media.enumerateDevices();
2403
- } catch (error) {
2404
- throw normalizeDeviceError(error, "meet.enumerateDevices");
2405
- }
2406
- const grouped = {
2407
- audioinput: [],
2408
- videoinput: [],
2409
- audiooutput: []
2410
- };
2411
- let anyLabel = false;
2412
- for (const device of raw) {
2413
- if (!isDeviceKind(device.kind)) continue;
2414
- if (device.label.length > 0) anyLabel = true;
2415
- grouped[device.kind].push({
2416
- deviceId: device.deviceId,
2417
- kind: device.kind,
2418
- label: device.label
2419
- });
2420
- }
2421
- devices = grouped;
2422
- labelsHidden = !anyLabel && raw.length > 0;
2423
- const lost = [];
2424
- for (const kind of KINDS) {
2425
- const current2 = selected[kind];
2426
- if (current2 === null) continue;
2427
- if (!grouped[kind].some((device) => device.deviceId === current2)) {
2428
- selected = { ...selected, [kind]: null };
2429
- lost.push(kind);
2430
- }
2431
- }
2432
- 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.`;
2433
- return emit();
2434
- }
2435
- const onDeviceChange = () => {
2436
- void enumerate();
2437
- };
2438
- options.media.addEventListener?.("devicechange", onDeviceChange);
2439
- return {
2440
- snapshot,
2441
- refresh: enumerate,
2442
- async requestPermission(kinds) {
2443
- if (!kinds.audio && !kinds.video) {
2444
- throw new MeetError(
2445
- "misconfigured",
2446
- "meet.requestPermission: asked for neither audio nor video",
2447
- "Request at least one kind. Prompting for nothing silently resolves and leaves labels hidden, which looks like a broken picker."
2448
- );
2449
- }
2450
- try {
2451
- const stream = await options.media.getUserMedia({
2452
- ...kinds.audio ? { audio: true } : {},
2453
- ...kinds.video ? { video: true } : {}
2454
- });
2455
- for (const track of stream.getTracks()) track.stop();
2456
- permission = "granted";
2457
- } catch (error) {
2458
- permission = "denied";
2459
- emit();
2460
- throw normalizeDeviceError(error, "meet.requestPermission");
2461
- }
2462
- return enumerate();
2463
- },
2464
- select(kind, deviceId) {
2465
- selected = { ...selected, [kind]: deviceId };
2466
- fallbackNotice = null;
2467
- storage.write({
2468
- ...storage.read(),
2469
- [kind]: deviceId
2470
- });
2471
- return emit();
2472
- },
2473
- resolve(kind) {
2474
- const current2 = selected[kind];
2475
- if (current2 !== null && devices[kind].some((device) => device.deviceId === current2)) {
2476
- return current2;
2477
- }
2478
- return devices[kind][0]?.deviceId ?? null;
2479
- },
2480
- subscribe(listener) {
2481
- listeners.add(listener);
2482
- return () => listeners.delete(listener);
2483
- },
2484
- dispose() {
2485
- options.media.removeEventListener?.("devicechange", onDeviceChange);
2486
- listeners.clear();
2487
- }
2488
- };
2489
- }
2490
-
2491
- // src/core/format.ts
2492
- function participantSummary(participants) {
2493
- const names = participants.filter((p) => !p.isLocal).map((p) => p.displayName);
2494
- if (names.length === 0) return "Just you";
2495
- if (names.length === 1) return names[0] ?? "";
2496
- if (names.length === 2) return `${names[0]} and ${names[1]}`;
2497
- return `${names[0]}, ${names[1]} and ${names.length - 2} ${names.length - 2 === 1 ? "other" : "others"}`;
2498
- }
2499
- function callStateLabel(state, outgoing) {
2560
+ function recordingLabel(state) {
2500
2561
  switch (state) {
2501
- case "dialing":
2502
- case "ringing":
2503
- return outgoing ? "Calling\u2026" : "Incoming call";
2504
- case "connecting":
2505
- return "Connecting\u2026";
2506
- case "active":
2507
- return "In call";
2508
- case "declined":
2509
- return outgoing ? "Declined" : "You declined";
2510
- case "missed":
2511
- return outgoing ? "No answer" : "Missed call";
2512
- case "busy":
2513
- return outgoing ? "On another call" : "You were busy";
2514
- case "cancelled":
2515
- return outgoing ? "Cancelled" : "Cancelled by caller";
2516
- case "ended":
2517
- return "Call ended";
2562
+ case "starting":
2563
+ return "Starting recording\u2026";
2564
+ case "recording":
2565
+ return "Recording";
2566
+ case "stopping":
2567
+ return "Stopping recording\u2026";
2568
+ case "processing":
2569
+ return "Processing recording\u2026";
2570
+ case "available":
2571
+ return "Recording ready";
2572
+ case "expired":
2573
+ return "Recording expired";
2518
2574
  case "failed":
2519
- return "Call failed";
2575
+ return "Recording failed";
2520
2576
  default:
2521
- return "Call";
2577
+ return null;
2522
2578
  }
2523
2579
  }
2524
- function countLabel(count2, singular, plural = `${singular}s`) {
2525
- return `${count2} ${count2 === 1 ? singular : plural}`;
2526
- }
2527
2580
 
2528
- // src/core/invitations.ts
2529
- var CALL_INVITE_KIND = "meet.call-invite";
2530
- var CALL_INVITE_VERSION = 1;
2531
- function callInviteAction(invite) {
2532
- return {
2533
- kind: CALL_INVITE_KIND,
2534
- version: CALL_INVITE_VERSION,
2535
- payload: {
2536
- invite_id: invite.id,
2537
- room_name: invite.roomName,
2538
- mode: invite.mode,
2539
- caller_name: invite.callerName,
2540
- expires_at: invite.expiresAt
2541
- }
2542
- };
2543
- }
2544
- function callInviteText(invite) {
2545
- return invite.mode === "video" ? `${invite.callerName} started a video call` : `${invite.callerName} started an audio call`;
2581
+ // src/core/attendance.ts
2582
+ var IDLE_TRACK = { enabled: false, unavailable: null };
2583
+ var IDLE_MEDIA = {
2584
+ microphone: IDLE_TRACK,
2585
+ camera: IDLE_TRACK
2586
+ };
2587
+ function unavailableFrom(error) {
2588
+ return { code: error.code, message: error.message, remedy: error.remedy };
2546
2589
  }
2547
- function createCallInviteHandler(options) {
2548
- const now = options.now ?? (() => Date.now());
2549
- return {
2550
- kind: CALL_INVITE_KIND,
2551
- versions: [CALL_INVITE_VERSION],
2552
- choices: (payload) => {
2553
- if (Date.parse(payload.expires_at) < now()) return [];
2554
- return [
2555
- { id: "join", label: payload.mode === "video" ? "Join video" : "Join call", tone: "primary" },
2556
- { id: "decline", label: "Decline", tone: "neutral" }
2557
- ];
2558
- },
2559
- summarize: (payload) => payload.mode === "video" ? `${payload.caller_name} started a video call` : `${payload.caller_name} started an audio call`,
2560
- execute: async (payload, context) => {
2561
- const settledAt = new Date(now()).toISOString();
2562
- const inviteId = asCallId(payload.invite_id);
2563
- const pending = await options.repository.pendingInvites(asUserId(String(context.actorId)));
2564
- const live = pending.find((invite) => invite.id === inviteId) ?? null;
2565
- if (live === null) {
2566
- return {
2567
- kind: CALL_INVITE_KIND,
2568
- messageId: context.messageId,
2569
- actorId: context.actorId,
2570
- outcome: "unavailable",
2571
- label: "This call has ended",
2572
- settledAt,
2573
- 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."
2574
- };
2575
- }
2576
- if (context.choice === "decline") {
2577
- await options.calls.decline(inviteId);
2578
- return {
2579
- kind: CALL_INVITE_KIND,
2580
- messageId: context.messageId,
2581
- actorId: context.actorId,
2582
- outcome: "declined",
2583
- label: "Declined",
2584
- settledAt
2585
- };
2586
- }
2587
- const accepted = await options.calls.accept(inviteId, live.mode);
2588
- await options.onJoin(accepted);
2589
- return {
2590
- kind: CALL_INVITE_KIND,
2591
- messageId: context.messageId,
2592
- actorId: context.actorId,
2593
- outcome: "applied",
2594
- label: "Joined",
2595
- settledAt
2596
- };
2597
- }
2598
- };
2590
+ var LISTEN_ONLY_NOTICE = "You're attending without a microphone \u2014 others can't hear you";
2591
+ function isListenOnly(media) {
2592
+ return !media.microphone.enabled;
2599
2593
  }
2600
- var MEETING_INVITE_KIND = "meet.meeting-invite";
2601
- var MEETING_INVITE_VERSION = 1;
2602
- function meetingInviteAction(payload) {
2603
- return { kind: MEETING_INVITE_KIND, version: MEETING_INVITE_VERSION, payload };
2594
+ function listenOnlyNotice(media) {
2595
+ return isListenOnly(media) ? LISTEN_ONLY_NOTICE : null;
2604
2596
  }
2605
- function createMeetingInviteHandler(options) {
2606
- const now = options.now ?? (() => Date.now());
2607
- return {
2608
- kind: MEETING_INVITE_KIND,
2609
- versions: [MEETING_INVITE_VERSION],
2610
- // A DURABLE LINK NEVER EXPIRES (R3) — the chip stays useful after the
2611
- // meeting too, because that is when people go looking for the notes.
2612
- choices: () => [{ id: "open", label: "Open meeting", tone: "primary" }],
2613
- summarize: (payload) => payload.scheduled_for === null ? payload.title : `${payload.title} \u2014 ${payload.scheduled_for}`,
2597
+
2598
+ // src/core/store.ts
2599
+ var DEFAULT_EFFECTS = {
2600
+ noiseSuppression: true,
2601
+ background: "none",
2602
+ backgroundImage: null
2603
+ };
2604
+ var NO_GRANTS = {
2605
+ role: "participant",
2606
+ canRecord: false,
2607
+ canPublish: false,
2608
+ canSubscribe: false
2609
+ };
2610
+ var EMPTY = {
2611
+ phase: "idle",
2612
+ meeting: null,
2613
+ localIdentity: null,
2614
+ grants: NO_GRANTS,
2615
+ participants: [],
2616
+ activeSpeakers: [],
2617
+ pinned: null,
2618
+ layout: "grid",
2619
+ lobby: [],
2620
+ locked: false,
2621
+ recording: "idle",
2622
+ recordingStartedBy: null,
2623
+ recordingSource: "local",
2624
+ recordingId: null,
2625
+ chat: [],
2626
+ chatLoaded: false,
2627
+ chatHistoryError: null,
2628
+ reactions: [],
2629
+ spotlight: null,
2630
+ moderationNotice: null,
2631
+ actionNotice: null,
2632
+ privateChatRequest: null,
2633
+ effects: DEFAULT_EFFECTS,
2634
+ transcript: [],
2635
+ notes: [],
2636
+ names: {},
2637
+ wrapUp: null,
2638
+ noteTakerRefusal: null,
2639
+ media: IDLE_MEDIA,
2640
+ failure: null,
2641
+ collab: EMPTY_COLLAB,
2642
+ breakoutRoomId: null
2643
+ };
2644
+ function effectiveRole(snapshot) {
2645
+ const me = snapshot.participants.find(
2646
+ (participant) => participant.identity === snapshot.localIdentity
2647
+ );
2648
+ const fromRoster = me?.role ?? "participant";
2649
+ const fromToken = snapshot.grants.role;
2650
+ return ROLE_RANK[fromRoster] >= ROLE_RANK[fromToken] ? fromRoster : fromToken;
2651
+ }
2652
+ var REACTION_TTL_MS = 5e3;
2653
+ var CHAT_LIMIT = 200;
2654
+ function createMeetStore(initial = {}) {
2655
+ let state = { ...EMPTY, ...initial };
2656
+ const listeners = /* @__PURE__ */ new Set();
2657
+ function commit(next) {
2658
+ if (next === state) return;
2659
+ state = next;
2660
+ for (const listener of listeners) listener();
2661
+ }
2662
+ return {
2663
+ snapshot: () => state,
2664
+ subscribe(listener) {
2665
+ listeners.add(listener);
2666
+ return () => listeners.delete(listener);
2667
+ },
2668
+ patch(next) {
2669
+ commit({ ...state, ...next });
2670
+ },
2671
+ setParticipants(participants) {
2672
+ const names = learnNames(state.names, participants);
2673
+ const hands = new Map(state.participants.map((p) => [p.identity, p.handRaised]));
2674
+ const handTimes = new Map(state.participants.map((p) => [p.identity, p.handRaisedAt ?? null]));
2675
+ const quality = new Map(state.participants.map((p) => [p.identity, p.quality]));
2676
+ commit({
2677
+ ...state,
2678
+ names,
2679
+ participants: participants.map((participant) => ({
2680
+ ...participant,
2681
+ // 🚨 THE ONE ROSTER RESOLUTION. Every surface that renders a person —
2682
+ // the tile, the People row, the header summary, every Pin/Mute label —
2683
+ // reads `participant.displayName`, so this is the single place a raw
2684
+ // identity could reach a screen, and it is the place that asks the
2685
+ // name book. `roster-names.test.ts` fails if a token ever gets past it.
2686
+ displayName: displayNameFor(names, participant.identity, participant.displayName),
2687
+ handRaised: hands.get(participant.identity) ?? participant.handRaised,
2688
+ handRaisedAt: handTimes.get(participant.identity) ?? participant.handRaisedAt ?? null,
2689
+ quality: participant.quality === "unknown" ? quality.get(participant.identity) ?? "unknown" : participant.quality
2690
+ }))
2691
+ });
2692
+ },
2693
+ setSpeaking(identities) {
2694
+ const speaking = new Set(identities);
2695
+ commit({
2696
+ ...state,
2697
+ activeSpeakers: identities,
2698
+ participants: state.participants.map(
2699
+ (participant) => participant.speaking === speaking.has(participant.identity) ? participant : { ...participant, speaking: speaking.has(participant.identity) }
2700
+ )
2701
+ });
2702
+ },
2703
+ setQuality(identity, quality) {
2704
+ commit({
2705
+ ...state,
2706
+ participants: state.participants.map(
2707
+ (participant) => participant.identity === identity ? { ...participant, quality } : participant
2708
+ )
2709
+ });
2710
+ },
2711
+ setHand(identity, raised, at) {
2712
+ commit({
2713
+ ...state,
2714
+ participants: state.participants.map(
2715
+ (participant) => participant.identity === identity ? {
2716
+ ...participant,
2717
+ handRaised: raised,
2718
+ // A hand that is ALREADY up keeps its place in the queue when
2719
+ // it is re-announced (to a late joiner): the first time wins.
2720
+ handRaisedAt: raised ? participant.handRaised && participant.handRaisedAt != null ? participant.handRaisedAt : at ?? Date.now() : null
2721
+ } : participant
2722
+ )
2723
+ });
2724
+ },
2725
+ addChat(message) {
2726
+ if (state.chat.some((existing) => existing.id === message.id)) return;
2727
+ const chat = [...state.chat, message];
2728
+ const names = learnName(state.names, message.identity, message.displayName);
2729
+ commit({ ...state, chat: chat.slice(-CHAT_LIMIT), names });
2730
+ },
2731
+ loadChat(messages) {
2732
+ const known = new Set(state.chat.map((message) => message.id));
2733
+ const merged = [...messages.filter((message) => !known.has(message.id)), ...state.chat].sort(
2734
+ (a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0
2735
+ );
2736
+ const names = learnNames(
2737
+ state.names,
2738
+ messages.map((message) => ({ identity: message.identity, displayName: message.displayName }))
2739
+ );
2740
+ commit({ ...state, chat: merged.slice(-CHAT_LIMIT), chatLoaded: true, chatHistoryError: null, names });
2741
+ },
2742
+ settleChat(localId, next) {
2743
+ const index = state.chat.findIndex((message) => message.id === localId);
2744
+ if (index === -1) return;
2745
+ const current = state.chat[index];
2746
+ let replacement;
2747
+ if ("failed" in next && typeof next.failed === "string" && !("id" in next)) {
2748
+ replacement = { ...current, failed: next.failed };
2749
+ } else {
2750
+ replacement = next;
2751
+ }
2752
+ const duplicate = replacement.id !== localId && state.chat.some((message) => message.id === replacement.id);
2753
+ const chat = duplicate ? state.chat.filter((message) => message.id !== localId) : state.chat.map((message, position) => position === index ? replacement : message);
2754
+ commit({ ...state, chat });
2755
+ },
2756
+ addReaction(reaction) {
2757
+ commit({ ...state, reactions: [...state.reactions, reaction] });
2758
+ },
2759
+ expireReactions(now) {
2760
+ const live = state.reactions.filter((reaction) => now - reaction.at < REACTION_TTL_MS);
2761
+ if (live.length === state.reactions.length) return;
2762
+ commit({ ...state, reactions: live });
2763
+ },
2764
+ setRecording(next, by, source, recordingId = null) {
2765
+ if (state.recordingSource === "record" && source !== "record") {
2766
+ const aboutTheSameRecording = source === "room" && (recordingId === null || state.recordingId === null || recordingId === state.recordingId);
2767
+ if (aboutTheSameRecording || isRecordingInFlight(state.recording)) return;
2768
+ }
2769
+ if (state.recording === next && state.recordingStartedBy === by && state.recordingSource === source && state.recordingId === (recordingId ?? state.recordingId)) {
2770
+ return;
2771
+ }
2772
+ commit({
2773
+ ...state,
2774
+ recording: next,
2775
+ recordingStartedBy: by,
2776
+ recordingSource: source,
2777
+ recordingId: recordingId ?? state.recordingId
2778
+ });
2779
+ },
2780
+ upsertLobby(entry) {
2781
+ const index = state.lobby.findIndex((existing) => existing.identity === entry.identity);
2782
+ const lobby = index === -1 ? [...state.lobby, entry] : state.lobby.map((existing, position) => position === index ? entry : existing);
2783
+ commit({ ...state, lobby });
2784
+ },
2785
+ setAdmission(identity, admissionState, by) {
2786
+ commit({
2787
+ ...state,
2788
+ lobby: state.lobby.map(
2789
+ (entry) => entry.identity === identity ? {
2790
+ ...entry,
2791
+ state: admissionState,
2792
+ decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
2793
+ decidedBy: by
2794
+ } : entry
2795
+ )
2796
+ });
2797
+ },
2798
+ learn(entries) {
2799
+ const names = learnNames(state.names, entries);
2800
+ if (names === state.names) return;
2801
+ commit({ ...state, names });
2802
+ },
2803
+ ingestCaption(segment) {
2804
+ const names = learnName(state.names, segment.identity, segment.speaker);
2805
+ const transcript = mergeCaption(state.transcript, segment);
2806
+ if (transcript === state.transcript && names === state.names) return;
2807
+ commit({ ...state, transcript, names });
2808
+ },
2809
+ ingestRecord(segment) {
2810
+ const names = learnName(state.names, segment.identity, segment.speaker);
2811
+ const transcript = mergeRecord(state.transcript, segment);
2812
+ if (transcript === state.transcript && names === state.names) return;
2813
+ commit({ ...state, transcript, names });
2814
+ },
2815
+ upsertNote(note) {
2816
+ const index = state.notes.findIndex((existing2) => existing2.id === note.id);
2817
+ if (index === -1) {
2818
+ commit({ ...state, notes: [...state.notes, note] });
2819
+ return;
2820
+ }
2821
+ const existing = state.notes[index];
2822
+ if (existing !== void 0 && existing.text === note.text && existing.kind === note.kind && existing.supersededBy === note.supersededBy && existing.source === note.source) {
2823
+ return;
2824
+ }
2825
+ if (existing !== void 0 && existing.source === "record" && note.source === "room") {
2826
+ return;
2827
+ }
2828
+ commit({
2829
+ ...state,
2830
+ notes: state.notes.map((entry, position) => position === index ? note : entry)
2831
+ });
2832
+ },
2833
+ announceWrapUp(announcement) {
2834
+ const current = state.wrapUp;
2835
+ if (current !== null && current.at === announcement.at) return;
2836
+ commit({ ...state, wrapUp: announcement });
2837
+ },
2838
+ setLocalTrack(kind, next) {
2839
+ const current = state.media[kind];
2840
+ if (current.enabled === next.enabled && current.unavailable?.code === next.unavailable?.code && current.unavailable?.message === next.unavailable?.message) {
2841
+ return;
2842
+ }
2843
+ commit({ ...state, media: { ...state.media, [kind]: next } });
2844
+ },
2845
+ reset() {
2846
+ commit({ ...EMPTY, effects: state.effects });
2847
+ }
2848
+ };
2849
+ }
2850
+
2851
+ // src/core/room-events.ts
2852
+ var RECORDING_STATES = [
2853
+ "idle",
2854
+ "starting",
2855
+ "recording",
2856
+ "stopping",
2857
+ "processing",
2858
+ "available",
2859
+ "expired",
2860
+ "failed"
2861
+ ];
2862
+ var ROOM_EVENT_PREFIX = "matrx.meet.";
2863
+ var ROOM_EVENTS = {
2864
+ /** One durable `meet_notes` row was written. Payload `{ note: <row> }`. */
2865
+ note: "matrx.meet.note",
2866
+ /** `meet_meetings.metadata.note_taker` changed. Payload `{ meeting_id, note_taker }`. */
2867
+ noteTaker: "matrx.meet.note_taker",
2868
+ /** A wrap-up pass finished. Payload `{ meeting_id, note_ids, segment_count }`. */
2869
+ wrapUp: "matrx.meet.wrap_up",
2870
+ /** The meeting ended for everyone. Payload `{ meeting_id, ended_at, trigger }`. */
2871
+ ended: "matrx.meet.ended",
2872
+ /**
2873
+ * A `meet_recordings` row changed state. Payload
2874
+ * `{ meeting_id, recording: { id, state, started_by } }`.
2875
+ *
2876
+ * 🚨 THIS IS THE ONLY WAY A GUEST LEARNS A RECORDING IS RUNNING. PreJoin
2877
+ * promises every joiner an indicator, and `anon` holds no SELECT grant on
2878
+ * `meet_recordings` (MRI-A10 settled that widening it is forbidden), so
2879
+ * without this packet the promise was a sentence with nothing behind it: on
2880
+ * production 2026-09-09 the guest lane showed no indicator at all while the
2881
+ * host recorded (MRI-C8).
2882
+ */
2883
+ recording: "matrx.meet.recording",
2884
+ /**
2885
+ * A chat message to everyone was SAVED (Meet wave 2). Payload
2886
+ * `{ message: { id, meeting_id, sender_identity, sender_name, body, created_at } }`.
2887
+ */
2888
+ chat: "matrx.meet.chat",
2889
+ /**
2890
+ * A host or co-host acted (Meet wave 2). Payload `{ meeting_id, kind, … }`:
2891
+ * `muted {source, by}` · `ask-unmute {by}` · `removed {by}` — addressed to the
2892
+ * person concerned; `lower-hands {identities: [...] | "*"}` · `role {identity,
2893
+ * role}` — to everyone.
2894
+ */
2895
+ moderation: "matrx.meet.moderation",
2896
+ /**
2897
+ * A meeting ROOM ITEM changed (Meet wave 5): a poll, a Q&A question, a whiteboard
2898
+ * stroke or the breakout plan. Payload `{ meeting_id, item }` · `{ removed: [ids] }` ·
2899
+ * `{ hidden: [ids] }` · `{ cleared: "whiteboard" }` · `{ broadcast }` · `{ item, move }`.
2900
+ * A host's fuller copy of a poll or question carries `audience: "hosts"`.
2901
+ */
2902
+ collab: "matrx.meet.collab"
2903
+ };
2904
+ function isRoomEventTopic(topic) {
2905
+ return topic.startsWith(ROOM_EVENT_PREFIX);
2906
+ }
2907
+ function object(value) {
2908
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
2909
+ }
2910
+ function text2(value) {
2911
+ return typeof value === "string" && value.length > 0 ? value : null;
2912
+ }
2913
+ var HANDLED = { handled: true };
2914
+ function ignored(reason) {
2915
+ return { handled: false, ignored: reason };
2916
+ }
2917
+ function applyRoomEvent(args) {
2918
+ const { store, topic } = args;
2919
+ if (!isRoomEventTopic(topic)) {
2920
+ return ignored(`${topic} is not a Meet room event`);
2921
+ }
2922
+ const payload = object(args.payload);
2923
+ if (payload === null) {
2924
+ return ignored(`${topic} carried no object payload`);
2925
+ }
2926
+ const now = args.now ?? (() => Date.now());
2927
+ switch (topic) {
2928
+ case ROOM_EVENTS.note:
2929
+ return applyNote(store, payload);
2930
+ case ROOM_EVENTS.noteTaker:
2931
+ return applyNoteTaker(store, payload);
2932
+ case ROOM_EVENTS.wrapUp:
2933
+ return applyWrapUp(store, payload, now);
2934
+ case ROOM_EVENTS.ended:
2935
+ return applyEnded(store, payload);
2936
+ case ROOM_EVENTS.recording:
2937
+ return applyRecording(store, payload);
2938
+ case ROOM_EVENTS.chat:
2939
+ return applyChat(store, payload);
2940
+ case ROOM_EVENTS.moderation:
2941
+ return applyModeration(store, payload, now);
2942
+ case ROOM_EVENTS.collab:
2943
+ return applyCollab(store, payload, now);
2944
+ default:
2945
+ return ignored(`${topic} is not known to this version of @ai-matrx/meet`);
2946
+ }
2947
+ }
2948
+ function applyCollab(store, payload, now) {
2949
+ const snapshot = store.snapshot();
2950
+ const meeting = snapshot.meeting;
2951
+ if (meeting !== null && text2(payload["meeting_id"]) !== null && payload["meeting_id"] !== meeting.id) {
2952
+ return ignored("a collab event for a different meeting than this room is showing");
2953
+ }
2954
+ const result = applyCollabPacket(snapshot.collab, payload, {
2955
+ localIdentity: snapshot.localIdentity,
2956
+ isHost: ROLE_RANK[effectiveRole(snapshot)] >= ROLE_RANK.cohost,
2957
+ now: now()
2958
+ });
2959
+ if (result.next === null) return ignored(result.dropped ?? "a collab packet that changed nothing");
2960
+ store.patch({ collab: result.next });
2961
+ return HANDLED;
2962
+ }
2963
+ function applyNote(store, payload) {
2964
+ const row = object(payload["note"]);
2965
+ if (row === null) return ignored("a note event with no `note` row");
2966
+ if (text2(row["id"]) === null || text2(row["meeting_id"]) === null) {
2967
+ return ignored("a note row with no id or meeting_id");
2968
+ }
2969
+ const meeting = store.snapshot().meeting;
2970
+ if (meeting !== null && row["meeting_id"] !== meeting.id) {
2971
+ return ignored("a note for a different meeting than this room is showing");
2972
+ }
2973
+ try {
2974
+ store.upsertNote({ ...projectNote(row), source: "room" });
2975
+ } catch (error) {
2976
+ return ignored(`a note row this client could not read: ${String(error)}`);
2977
+ }
2978
+ return HANDLED;
2979
+ }
2980
+ function applyNoteTaker(store, payload) {
2981
+ const noteTaker = object(payload["note_taker"]);
2982
+ if (noteTaker === null) return ignored("a note-taker event with no state");
2983
+ const meeting = store.snapshot().meeting;
2984
+ if (meeting === null) {
2985
+ return ignored("a note-taker state with no meeting record to attach it to");
2986
+ }
2987
+ const meetingId = text2(payload["meeting_id"]);
2988
+ if (meetingId !== null && meetingId !== meeting.id) {
2989
+ return ignored("a note-taker state for a different meeting");
2990
+ }
2991
+ const next = {
2992
+ ...meeting,
2993
+ metadata: { ...meeting.metadata, note_taker: noteTaker }
2994
+ };
2995
+ store.patch({ meeting: next });
2996
+ return HANDLED;
2997
+ }
2998
+ function applyWrapUp(store, payload, now) {
2999
+ const meetingId = text2(payload["meeting_id"]);
3000
+ if (meetingId === null) return ignored("a wrap-up event with no meeting id");
3001
+ const meeting = store.snapshot().meeting;
3002
+ if (meeting !== null && meetingId !== meeting.id) {
3003
+ return ignored("a wrap-up for a different meeting");
3004
+ }
3005
+ const rawIds = payload["note_ids"];
3006
+ const noteIds = Array.isArray(rawIds) ? rawIds.filter((value) => typeof value === "string") : [];
3007
+ const segmentCount = payload["segment_count"];
3008
+ const announcement = {
3009
+ meetingId: asMeetingId(meetingId),
3010
+ noteIds,
3011
+ segmentCount: typeof segmentCount === "number" ? segmentCount : 0,
3012
+ at: new Date(now()).toISOString()
3013
+ };
3014
+ store.announceWrapUp(announcement);
3015
+ return HANDLED;
3016
+ }
3017
+ function applyRecording(store, payload) {
3018
+ const row = object(payload["recording"]);
3019
+ if (row === null) return ignored("a recording event with no `recording` row");
3020
+ const meetingId = text2(payload["meeting_id"]);
3021
+ const meeting = store.snapshot().meeting;
3022
+ if (meeting !== null && meetingId !== null && meetingId !== meeting.id) {
3023
+ return ignored("a recording state for a different meeting than this room is showing");
3024
+ }
3025
+ const state = text2(row["state"]);
3026
+ if (state === null || !RECORDING_STATES.includes(state)) {
3027
+ return ignored(`a recording state this version does not know: ${String(row["state"])}`);
3028
+ }
3029
+ const by = text2(row["started_by"]);
3030
+ store.setRecording(
3031
+ state,
3032
+ by === null ? null : by,
3033
+ "room",
3034
+ text2(row["id"])
3035
+ );
3036
+ return HANDLED;
3037
+ }
3038
+ function applyEnded(store, payload) {
3039
+ const meetingId = text2(payload["meeting_id"]);
3040
+ const meeting = store.snapshot().meeting;
3041
+ if (meeting === null) return ignored("an ended event with no meeting record");
3042
+ if (meetingId !== null && meetingId !== meeting.id) {
3043
+ return ignored("an ended event for a different meeting");
3044
+ }
3045
+ const endedAt = text2(payload["ended_at"]) ?? (/* @__PURE__ */ new Date()).toISOString();
3046
+ if (meeting.endedAt === endedAt) return HANDLED;
3047
+ store.patch({ meeting: { ...meeting, endedAt } });
3048
+ return HANDLED;
3049
+ }
3050
+ function chatFromRow(row) {
3051
+ const id = text2(row["id"]);
3052
+ const identity = text2(row["sender_identity"]);
3053
+ const body = text2(row["body"]);
3054
+ if (id === null || identity === null || body === null) return null;
3055
+ return {
3056
+ id,
3057
+ identity: asParticipantIdentity(identity),
3058
+ displayName: text2(row["sender_name"]) ?? "Participant",
3059
+ text: body,
3060
+ at: text2(row["created_at"]) ?? (/* @__PURE__ */ new Date()).toISOString(),
3061
+ // `false` only from a server whose chat table has not landed: the room heard
3062
+ // it, nobody will see it later — and the line says so.
3063
+ saved: row["saved"] !== false
3064
+ };
3065
+ }
3066
+ function applyChat(store, payload) {
3067
+ const row = object(payload["message"]);
3068
+ if (row === null) return ignored("a chat event with no `message` row");
3069
+ const meeting = store.snapshot().meeting;
3070
+ if (meeting !== null && text2(row["meeting_id"]) !== null && row["meeting_id"] !== meeting.id) {
3071
+ return ignored("a chat message for a different meeting");
3072
+ }
3073
+ const message = chatFromRow(row);
3074
+ if (message === null) return ignored("a chat row with no id, sender or body");
3075
+ store.addChat(message);
3076
+ return HANDLED;
3077
+ }
3078
+ function moderationText(kind, payload) {
3079
+ const by = text2(payload["by"]) ?? "The host";
3080
+ switch (kind) {
3081
+ case "muted": {
3082
+ const source = text2(payload["source"]) ?? "microphone";
3083
+ return source === "microphone" ? `${by} muted you.` : source === "camera" ? `${by} turned off your camera.` : `${by} stopped your screen share.`;
3084
+ }
3085
+ case "ask-unmute":
3086
+ return `${by} is asking you to unmute.`;
3087
+ case "removed":
3088
+ return `${by} removed you from this meeting.`;
3089
+ default:
3090
+ return null;
3091
+ }
3092
+ }
3093
+ function applyModeration(store, payload, now) {
3094
+ const snapshot = store.snapshot();
3095
+ const meetingId = text2(payload["meeting_id"]);
3096
+ if (snapshot.meeting !== null && meetingId !== null && meetingId !== snapshot.meeting.id) {
3097
+ return ignored("a moderation event for a different meeting");
3098
+ }
3099
+ const kind = text2(payload["kind"]);
3100
+ switch (kind) {
3101
+ case "lower-hands": {
3102
+ const raw = payload["identities"];
3103
+ const everyone = raw === "*";
3104
+ const named = new Set(
3105
+ Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []
3106
+ );
3107
+ for (const participant of snapshot.participants) {
3108
+ if (participant.handRaised && (everyone || named.has(participant.identity))) {
3109
+ store.setHand(participant.identity, false);
3110
+ }
3111
+ }
3112
+ return HANDLED;
3113
+ }
3114
+ case "role": {
3115
+ const identity = text2(payload["identity"]);
3116
+ const role = text2(payload["role"]);
3117
+ if (identity === null || role === null || !ROLES.includes(role)) {
3118
+ return ignored("a role event with no identity or an unknown role");
3119
+ }
3120
+ if (identity === snapshot.localIdentity) {
3121
+ store.patch({
3122
+ grants: { ...snapshot.grants, role },
3123
+ moderationNotice: {
3124
+ kind: "role",
3125
+ text: role === "host" ? "You are now the host." : role === "cohost" ? "You are now a co-host." : "You are no longer a co-host.",
3126
+ at: now()
3127
+ }
3128
+ });
3129
+ }
3130
+ return HANDLED;
3131
+ }
3132
+ case "muted":
3133
+ case "ask-unmute":
3134
+ case "removed": {
3135
+ const sentence = moderationText(kind, payload);
3136
+ if (sentence === null) return ignored("a moderation event with no sentence");
3137
+ store.patch({ moderationNotice: { kind, text: sentence, at: now() } });
3138
+ return HANDLED;
3139
+ }
3140
+ default:
3141
+ return ignored(`a moderation kind this version does not know: ${String(kind)}`);
3142
+ }
3143
+ }
3144
+ var ROLES = ["host", "cohost", "participant", "guest"];
3145
+
3146
+ // src/core/devices.ts
3147
+ var KINDS = ["audioinput", "videoinput", "audiooutput"];
3148
+ function isDeviceKind(value) {
3149
+ return value === "audioinput" || value === "videoinput" || value === "audiooutput";
3150
+ }
3151
+ function createWebDeviceStorage(key = "mx.meet.devices") {
3152
+ const store = globalThis.localStorage;
3153
+ if (store === void 0) return createMemoryDeviceStorage();
3154
+ return {
3155
+ durable: true,
3156
+ read() {
3157
+ try {
3158
+ const raw = store.getItem(key);
3159
+ if (raw === null) return {};
3160
+ const parsed = JSON.parse(raw);
3161
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
3162
+ } catch {
3163
+ return {};
3164
+ }
3165
+ },
3166
+ write(value) {
3167
+ try {
3168
+ store.setItem(key, JSON.stringify(value));
3169
+ } catch {
3170
+ }
3171
+ }
3172
+ };
3173
+ }
3174
+ function createMemoryDeviceStorage() {
3175
+ let value = {};
3176
+ return {
3177
+ durable: false,
3178
+ read: () => value,
3179
+ write: (next) => {
3180
+ value = next;
3181
+ }
3182
+ };
3183
+ }
3184
+ function createDeviceManager(options) {
3185
+ const storage = options.storage ?? createMemoryDeviceStorage();
3186
+ const listeners = /* @__PURE__ */ new Set();
3187
+ const stored = storage.read();
3188
+ let devices = {
3189
+ audioinput: [],
3190
+ videoinput: [],
3191
+ audiooutput: []
3192
+ };
3193
+ let selected = {
3194
+ audioinput: stored["audioinput"] ?? null,
3195
+ videoinput: stored["videoinput"] ?? null,
3196
+ audiooutput: stored["audiooutput"] ?? null
3197
+ };
3198
+ let labelsHidden = true;
3199
+ let permission = "unknown";
3200
+ let fallbackNotice = null;
3201
+ let current = {
3202
+ devices,
3203
+ selected,
3204
+ labelsHidden,
3205
+ permission,
3206
+ fallbackNotice
3207
+ };
3208
+ function snapshot() {
3209
+ return current;
3210
+ }
3211
+ function commit() {
3212
+ current = { devices, selected, labelsHidden, permission, fallbackNotice };
3213
+ return current;
3214
+ }
3215
+ function emit() {
3216
+ const next = commit();
3217
+ options.onChange?.(next);
3218
+ for (const listener of listeners) listener(next);
3219
+ return next;
3220
+ }
3221
+ async function enumerate() {
3222
+ let raw;
3223
+ try {
3224
+ raw = await options.media.enumerateDevices();
3225
+ } catch (error) {
3226
+ throw normalizeDeviceError(error, "meet.enumerateDevices");
3227
+ }
3228
+ const grouped = {
3229
+ audioinput: [],
3230
+ videoinput: [],
3231
+ audiooutput: []
3232
+ };
3233
+ let anyLabel = false;
3234
+ for (const device of raw) {
3235
+ if (!isDeviceKind(device.kind)) continue;
3236
+ if (device.label.length > 0) anyLabel = true;
3237
+ grouped[device.kind].push({
3238
+ deviceId: device.deviceId,
3239
+ kind: device.kind,
3240
+ label: device.label
3241
+ });
3242
+ }
3243
+ devices = grouped;
3244
+ labelsHidden = !anyLabel && raw.length > 0;
3245
+ const lost = [];
3246
+ for (const kind of KINDS) {
3247
+ const current2 = selected[kind];
3248
+ if (current2 === null) continue;
3249
+ if (!grouped[kind].some((device) => device.deviceId === current2)) {
3250
+ selected = { ...selected, [kind]: null };
3251
+ lost.push(kind);
3252
+ }
3253
+ }
3254
+ 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.`;
3255
+ return emit();
3256
+ }
3257
+ const onDeviceChange = () => {
3258
+ void enumerate();
3259
+ };
3260
+ options.media.addEventListener?.("devicechange", onDeviceChange);
3261
+ return {
3262
+ snapshot,
3263
+ refresh: enumerate,
3264
+ async requestPermission(kinds) {
3265
+ if (!kinds.audio && !kinds.video) {
3266
+ throw new MeetError(
3267
+ "misconfigured",
3268
+ "meet.requestPermission: asked for neither audio nor video",
3269
+ "Request at least one kind. Prompting for nothing silently resolves and leaves labels hidden, which looks like a broken picker."
3270
+ );
3271
+ }
3272
+ try {
3273
+ const stream = await options.media.getUserMedia({
3274
+ ...kinds.audio ? { audio: true } : {},
3275
+ ...kinds.video ? { video: true } : {}
3276
+ });
3277
+ for (const track of stream.getTracks()) track.stop();
3278
+ permission = "granted";
3279
+ } catch (error) {
3280
+ permission = "denied";
3281
+ emit();
3282
+ throw normalizeDeviceError(error, "meet.requestPermission");
3283
+ }
3284
+ return enumerate();
3285
+ },
3286
+ select(kind, deviceId) {
3287
+ selected = { ...selected, [kind]: deviceId };
3288
+ fallbackNotice = null;
3289
+ storage.write({
3290
+ ...storage.read(),
3291
+ [kind]: deviceId
3292
+ });
3293
+ return emit();
3294
+ },
3295
+ resolve(kind) {
3296
+ const current2 = selected[kind];
3297
+ if (current2 !== null && devices[kind].some((device) => device.deviceId === current2)) {
3298
+ return current2;
3299
+ }
3300
+ return devices[kind][0]?.deviceId ?? null;
3301
+ },
3302
+ subscribe(listener) {
3303
+ listeners.add(listener);
3304
+ return () => listeners.delete(listener);
3305
+ },
3306
+ dispose() {
3307
+ options.media.removeEventListener?.("devicechange", onDeviceChange);
3308
+ listeners.clear();
3309
+ }
3310
+ };
3311
+ }
3312
+
3313
+ // src/core/format.ts
3314
+ function participantSummary(participants) {
3315
+ const names = participants.filter((p) => !p.isLocal).map((p) => p.displayName);
3316
+ if (names.length === 0) return "Just you";
3317
+ if (names.length === 1) return names[0] ?? "";
3318
+ if (names.length === 2) return `${names[0]} and ${names[1]}`;
3319
+ return `${names[0]}, ${names[1]} and ${names.length - 2} ${names.length - 2 === 1 ? "other" : "others"}`;
3320
+ }
3321
+ function callStateLabel(state, outgoing) {
3322
+ switch (state) {
3323
+ case "dialing":
3324
+ case "ringing":
3325
+ return outgoing ? "Calling\u2026" : "Incoming call";
3326
+ case "connecting":
3327
+ return "Connecting\u2026";
3328
+ case "active":
3329
+ return "In call";
3330
+ case "declined":
3331
+ return outgoing ? "Declined" : "You declined";
3332
+ case "missed":
3333
+ return outgoing ? "No answer" : "Missed call";
3334
+ case "busy":
3335
+ return outgoing ? "On another call" : "You were busy";
3336
+ case "cancelled":
3337
+ return outgoing ? "Cancelled" : "Cancelled by caller";
3338
+ case "ended":
3339
+ return "Call ended";
3340
+ case "failed":
3341
+ return "Call failed";
3342
+ default:
3343
+ return "Call";
3344
+ }
3345
+ }
3346
+ function countLabel(count2, singular, plural = `${singular}s`) {
3347
+ return `${count2} ${count2 === 1 ? singular : plural}`;
3348
+ }
3349
+
3350
+ // src/core/invitations.ts
3351
+ var CALL_INVITE_KIND = "meet.call-invite";
3352
+ var CALL_INVITE_VERSION = 1;
3353
+ function callInviteAction(invite) {
3354
+ return {
3355
+ kind: CALL_INVITE_KIND,
3356
+ version: CALL_INVITE_VERSION,
3357
+ payload: {
3358
+ invite_id: invite.id,
3359
+ room_name: invite.roomName,
3360
+ mode: invite.mode,
3361
+ caller_name: invite.callerName,
3362
+ expires_at: invite.expiresAt
3363
+ }
3364
+ };
3365
+ }
3366
+ function callInviteText(invite) {
3367
+ return invite.mode === "video" ? `${invite.callerName} started a video call` : `${invite.callerName} started an audio call`;
3368
+ }
3369
+ function createCallInviteHandler(options) {
3370
+ const now = options.now ?? (() => Date.now());
3371
+ return {
3372
+ kind: CALL_INVITE_KIND,
3373
+ versions: [CALL_INVITE_VERSION],
3374
+ choices: (payload) => {
3375
+ if (Date.parse(payload.expires_at) < now()) return [];
3376
+ return [
3377
+ { id: "join", label: payload.mode === "video" ? "Join video" : "Join call", tone: "primary" },
3378
+ { id: "decline", label: "Decline", tone: "neutral" }
3379
+ ];
3380
+ },
3381
+ summarize: (payload) => payload.mode === "video" ? `${payload.caller_name} started a video call` : `${payload.caller_name} started an audio call`,
3382
+ execute: async (payload, context) => {
3383
+ const settledAt = new Date(now()).toISOString();
3384
+ const inviteId = asCallId(payload.invite_id);
3385
+ const pending = await options.repository.pendingInvites(asUserId(String(context.actorId)));
3386
+ const live = pending.find((invite) => invite.id === inviteId) ?? null;
3387
+ if (live === null) {
3388
+ return {
3389
+ kind: CALL_INVITE_KIND,
3390
+ messageId: context.messageId,
3391
+ actorId: context.actorId,
3392
+ outcome: "unavailable",
3393
+ label: "This call has ended",
3394
+ settledAt,
3395
+ 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."
3396
+ };
3397
+ }
3398
+ if (context.choice === "decline") {
3399
+ await options.calls.decline(inviteId);
3400
+ return {
3401
+ kind: CALL_INVITE_KIND,
3402
+ messageId: context.messageId,
3403
+ actorId: context.actorId,
3404
+ outcome: "declined",
3405
+ label: "Declined",
3406
+ settledAt
3407
+ };
3408
+ }
3409
+ const accepted = await options.calls.accept(inviteId, live.mode);
3410
+ await options.onJoin(accepted);
3411
+ return {
3412
+ kind: CALL_INVITE_KIND,
3413
+ messageId: context.messageId,
3414
+ actorId: context.actorId,
3415
+ outcome: "applied",
3416
+ label: "Joined",
3417
+ settledAt
3418
+ };
3419
+ }
3420
+ };
3421
+ }
3422
+ var MEETING_INVITE_KIND = "meet.meeting-invite";
3423
+ var MEETING_INVITE_VERSION = 1;
3424
+ function meetingInviteAction(payload) {
3425
+ return { kind: MEETING_INVITE_KIND, version: MEETING_INVITE_VERSION, payload };
3426
+ }
3427
+ function createMeetingInviteHandler(options) {
3428
+ const now = options.now ?? (() => Date.now());
3429
+ return {
3430
+ kind: MEETING_INVITE_KIND,
3431
+ versions: [MEETING_INVITE_VERSION],
3432
+ // A DURABLE LINK NEVER EXPIRES (R3) — the chip stays useful after the
3433
+ // meeting too, because that is when people go looking for the notes.
3434
+ choices: () => [{ id: "open", label: "Open meeting", tone: "primary" }],
3435
+ summarize: (payload) => payload.scheduled_for === null ? payload.title : `${payload.title} \u2014 ${payload.scheduled_for}`,
2614
3436
  execute: async (payload, context) => {
2615
3437
  if (payload.slug.length === 0) {
2616
3438
  throw new MeetError(
@@ -2898,586 +3720,148 @@ function createLiveKitDriver(handlers, options = {}) {
2898
3720
  return container;
2899
3721
  }
2900
3722
  function requireRoom(operation) {
2901
- if (room === null) {
2902
- throw new MeetError(
2903
- "transport",
2904
- `${operation}: the room is not connected`,
2905
- "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."
2906
- );
2907
- }
2908
- return room;
2909
- }
2910
- return {
2911
- async connect(serverUrl, token) {
2912
- const target = await load();
2913
- try {
2914
- await target.connect(serverUrl, token);
2915
- } catch (error) {
2916
- throw normalizeDeviceError(error, "meet.connect");
2917
- }
2918
- projectRoster?.();
2919
- handlers.roomMetadata?.(target.metadata ?? "");
2920
- },
2921
- async disconnect() {
2922
- if (room === null) return;
2923
- const target = room;
2924
- room = null;
2925
- try {
2926
- target.unregisterTextStreamHandler(TRANSCRIPTION_TOPIC);
2927
- } catch {
2928
- }
2929
- projectRoster = null;
2930
- audioContainer?.remove();
2931
- audioContainer = null;
2932
- await target.disconnect();
2933
- },
2934
- async setMicrophoneEnabled(enabled) {
2935
- try {
2936
- await requireRoom("setMicrophoneEnabled").localParticipant.setMicrophoneEnabled(
2937
- enabled,
2938
- audioCapture
2939
- );
2940
- } catch (error) {
2941
- throw normalizeDeviceError(error, "meet.setMicrophoneEnabled");
2942
- }
2943
- },
2944
- async setCameraEnabled(enabled) {
2945
- try {
2946
- const target = requireRoom("setCameraEnabled");
2947
- await target.localParticipant.setCameraEnabled(enabled);
2948
- if (enabled && videoEffect.kind !== "none") await applyVideoEffect(target, videoEffect);
2949
- } catch (error) {
2950
- throw normalizeDeviceError(error, "meet.setCameraEnabled");
2951
- }
2952
- },
2953
- async setScreenShareEnabled(enabled) {
2954
- try {
2955
- await requireRoom("setScreenShareEnabled").localParticipant.setScreenShareEnabled(enabled);
2956
- } catch (error) {
2957
- throw normalizeDeviceError(error, "meet.setScreenShareEnabled");
2958
- }
2959
- },
2960
- async publishData(topic, payload, to) {
2961
- await requireRoom("publishData").localParticipant.publishData(
2962
- // `reliable` is not optional for a control plane: a dropped
2963
- // "hand lowered" leaves a hand up on every other screen forever.
2964
- encoder.encode(payload),
2965
- to === void 0 || to.length === 0 ? { reliable: true, topic } : { reliable: true, topic, destinationIdentities: [...to] }
2966
- );
2967
- },
2968
- async setAudioProcessing(processing) {
2969
- audioCapture = { ...audioCapture, ...processing };
2970
- const target = room;
2971
- if (target === null) return;
2972
- const { Track } = await import("livekit-client");
2973
- const publication = target.localParticipant.getTrackPublication(Track.Source.Microphone);
2974
- const track = publication?.track;
2975
- if (track === void 0 || publication?.isMuted === true) return;
2976
- try {
2977
- await track.restartTrack({
2978
- ...audioCapture,
2979
- ...target.getActiveDevice("audioinput") !== void 0 ? { deviceId: target.getActiveDevice("audioinput") } : {}
2980
- });
2981
- } catch (error) {
2982
- throw normalizeDeviceError(error, "meet.setAudioProcessing");
2983
- }
2984
- },
2985
- async setVideoEffect(effect) {
2986
- videoEffect = effect;
2987
- const target = room;
2988
- if (target === null) return;
2989
- await applyVideoEffect(target, effect);
2990
- },
2991
- supportsVideoEffects() {
2992
- return videoEffectsSupported();
2993
- },
2994
- attach(identity, kind, element) {
2995
- const target = requireRoom("attach");
2996
- const source = sources?.[kind];
2997
- if (source === void 0) return () => void 0;
2998
- let current = null;
2999
- const sync = () => {
3000
- const participant = target.localParticipant.identity === identity ? target.localParticipant : target.remoteParticipants.get(identity);
3001
- const track = participant?.getTrackPublication(source)?.track;
3002
- if (track === void 0 || track === current) return;
3003
- current?.detach(element);
3004
- track.attach(element);
3005
- current = track;
3006
- };
3007
- const onTrack = (...args) => {
3008
- const who = args[args.length - 1];
3009
- if (who?.identity === identity) sync();
3010
- };
3011
- sync();
3012
- const events = trackEvents;
3013
- if (events !== null) {
3014
- target.on(events.subscribed, onTrack);
3015
- target.on(events.localPublished, onTrack);
3016
- }
3017
- return () => {
3018
- if (events !== null) {
3019
- target.off(events.subscribed, onTrack);
3020
- target.off(events.localPublished, onTrack);
3021
- }
3022
- current?.detach(element);
3023
- current = null;
3024
- };
3025
- },
3026
- activeDevices() {
3027
- const target = room;
3028
- if (target === null) {
3029
- return { audioinput: null, videoinput: null, audiooutput: null };
3030
- }
3031
- return {
3032
- audioinput: target.getActiveDevice("audioinput") ?? null,
3033
- videoinput: target.getActiveDevice("videoinput") ?? null,
3034
- audiooutput: target.getActiveDevice("audiooutput") ?? null
3035
- };
3036
- },
3037
- async switchDevice(kind, deviceId) {
3038
- try {
3039
- await requireRoom("switchDevice").switchActiveDevice(kind, deviceId);
3040
- } catch (error) {
3041
- throw normalizeDeviceError(error, "meet.switchDevice");
3042
- }
3043
- }
3044
- };
3045
- }
3046
-
3047
- // src/core/names.ts
3048
- function isGuestIdentity(identity) {
3049
- return String(identity).startsWith("guest:");
3050
- }
3051
- function isAgentIdentity(identity) {
3052
- return String(identity).startsWith("agent:");
3053
- }
3054
- var NOTE_TAKER_PRODUCT_NAME = "Matrx note-taker";
3055
- var AGENT_PRODUCT_NAME = "Matrx agent";
3056
- var UNNAMED_GUEST = "Unnamed guest";
3057
- var UNNAMED_PARTICIPANT = "Unnamed participant";
3058
- var PLACEHOLDER_NAMES = [
3059
- NOTE_TAKER_PRODUCT_NAME,
3060
- AGENT_PRODUCT_NAME,
3061
- UNNAMED_GUEST,
3062
- UNNAMED_PARTICIPANT
3063
- ];
3064
- function isPlaceholderName(name) {
3065
- return typeof name === "string" && PLACEHOLDER_NAMES.includes(name.trim());
3066
- }
3067
- function placeholderNameFor(identity) {
3068
- const key = String(identity);
3069
- if (isAgentIdentity(key)) {
3070
- return key.startsWith("agent:note_taker:") || key.startsWith("agent:note-taker") ? NOTE_TAKER_PRODUCT_NAME : AGENT_PRODUCT_NAME;
3071
- }
3072
- return isGuestIdentity(key) ? UNNAMED_GUEST : UNNAMED_PARTICIPANT;
3073
- }
3074
- function isRealName(identity, name) {
3075
- if (typeof name !== "string") return false;
3076
- const trimmed = name.trim();
3077
- if (trimmed.length === 0) return false;
3078
- if (trimmed === String(identity)) return false;
3079
- if (isPlaceholderName(trimmed)) return false;
3080
- return true;
3081
- }
3082
- function learnName(book, identity, name) {
3083
- const key = String(identity);
3084
- if (key.length === 0) return book;
3085
- if (!isRealName(key, name)) return book;
3086
- const learned = name.trim();
3087
- if (book[key] === learned) return book;
3088
- return { ...book, [key]: learned };
3089
- }
3090
- function learnNames(book, entries) {
3091
- let next = book;
3092
- for (const entry of entries) next = learnName(next, entry.identity, entry.displayName);
3093
- return next;
3094
- }
3095
- function displayNameFor(book, identity, preferred) {
3096
- const key = String(identity);
3097
- if (isRealName(key, preferred)) return preferred.trim();
3098
- const known = book[key];
3099
- if (known !== void 0) return known;
3100
- return placeholderNameFor(key);
3101
- }
3102
-
3103
- // src/core/recording.ts
3104
- function createRecordingController(options) {
3105
- async function post(route, body) {
3106
- let response;
3107
- try {
3108
- response = await options.api.request({
3109
- path: route,
3110
- method: "POST",
3111
- // A GUEST CAN NEVER START OR STOP A RECORDING (R5) — refused here,
3112
- // before the wire, with the same sentence the hidden control implies.
3113
- requireAuth: true,
3114
- operation: "meet.recording",
3115
- body
3116
- });
3117
- } catch (error) {
3118
- if (error.code === "session-unavailable") {
3119
- throw new MeetError(
3120
- "forbidden",
3121
- "meet.recording: recording requires an authenticated host",
3122
- "A guest can never start or stop a recording (R5). The control is hidden for guests."
3123
- );
3124
- }
3125
- throw error;
3126
- }
3127
- if (response.status === 403) {
3128
- throw new MeetError(
3129
- "forbidden",
3130
- "meet.recording: the server refused this recording action",
3131
- "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."
3132
- );
3133
- }
3134
- if (!response.ok) {
3723
+ if (room === null) {
3135
3724
  throw new MeetError(
3136
3725
  "transport",
3137
- `meet.recording: the server returned ${response.status}`,
3138
- "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."
3726
+ `${operation}: the room is not connected`,
3727
+ "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."
3139
3728
  );
3140
3729
  }
3730
+ return room;
3141
3731
  }
3142
3732
  return {
3143
- canStart(meeting, isHost) {
3144
- if (meeting === null) return false;
3145
- if (meeting.recordingPolicy === "disabled") return false;
3146
- if (meeting.recordingPolicy === "always-on") return false;
3147
- return isHost;
3148
- },
3149
- async start({ meetingId, roomName, by }) {
3150
- options.store.setRecording("starting", by, "local");
3151
- try {
3152
- await post(MEET_ROUTES.recordingStart, {
3153
- meeting_id: meetingId,
3154
- room_name: roomName
3155
- });
3156
- } catch (error) {
3157
- options.store.setRecording("idle", null, "local");
3158
- throw error;
3159
- }
3160
- options.announce?.("starting", by);
3161
- },
3162
- async stop({ meetingId, by }) {
3163
- options.store.setRecording("stopping", options.store.snapshot().recordingStartedBy, "local");
3733
+ async connect(serverUrl, token) {
3734
+ const target = await load();
3164
3735
  try {
3165
- await post(MEET_ROUTES.recordingStop, { meeting_id: meetingId });
3166
- } catch (error) {
3167
- options.store.setRecording(
3168
- "recording",
3169
- options.store.snapshot().recordingStartedBy,
3170
- "local"
3171
- );
3172
- throw error;
3173
- }
3174
- options.announce?.("stopping", by);
3175
- },
3176
- consentNotice(meeting) {
3177
- if (meeting === null || meeting.recordingPolicy === "disabled") return null;
3178
- if (meeting.recordingPolicy === "always-on") {
3179
- return "This meeting is recorded and transcribed automatically. By joining, you consent to being recorded.";
3180
- }
3181
- return "The host can record and transcribe this meeting. You will see a recording indicator whenever it is running.";
3182
- },
3183
- async acknowledge(meetingId) {
3184
- await options.acknowledgeConsent?.(meetingId);
3185
- }
3186
- };
3187
- }
3188
- var RECORDING_IN_FLIGHT = [
3189
- "starting",
3190
- "recording",
3191
- "stopping"
3192
- ];
3193
- function isRecordingInFlight(state) {
3194
- return RECORDING_IN_FLIGHT.includes(state);
3195
- }
3196
- function recordingLabel(state) {
3197
- switch (state) {
3198
- case "starting":
3199
- return "Starting recording\u2026";
3200
- case "recording":
3201
- return "Recording";
3202
- case "stopping":
3203
- return "Stopping recording\u2026";
3204
- case "processing":
3205
- return "Processing recording\u2026";
3206
- case "available":
3207
- return "Recording ready";
3208
- case "expired":
3209
- return "Recording expired";
3210
- case "failed":
3211
- return "Recording failed";
3212
- default:
3213
- return null;
3214
- }
3215
- }
3216
-
3217
- // src/core/attendance.ts
3218
- var IDLE_TRACK = { enabled: false, unavailable: null };
3219
- var IDLE_MEDIA = {
3220
- microphone: IDLE_TRACK,
3221
- camera: IDLE_TRACK
3222
- };
3223
- function unavailableFrom(error) {
3224
- return { code: error.code, message: error.message, remedy: error.remedy };
3225
- }
3226
- var LISTEN_ONLY_NOTICE = "You're attending without a microphone \u2014 others can't hear you";
3227
- function isListenOnly(media) {
3228
- return !media.microphone.enabled;
3229
- }
3230
- function listenOnlyNotice(media) {
3231
- return isListenOnly(media) ? LISTEN_ONLY_NOTICE : null;
3232
- }
3233
-
3234
- // src/core/store.ts
3235
- var DEFAULT_EFFECTS = {
3236
- noiseSuppression: true,
3237
- background: "none",
3238
- backgroundImage: null
3239
- };
3240
- var NO_GRANTS = {
3241
- role: "participant",
3242
- canRecord: false,
3243
- canPublish: false,
3244
- canSubscribe: false
3245
- };
3246
- var EMPTY = {
3247
- phase: "idle",
3248
- meeting: null,
3249
- localIdentity: null,
3250
- grants: NO_GRANTS,
3251
- participants: [],
3252
- activeSpeakers: [],
3253
- pinned: null,
3254
- layout: "grid",
3255
- lobby: [],
3256
- locked: false,
3257
- recording: "idle",
3258
- recordingStartedBy: null,
3259
- recordingSource: "local",
3260
- recordingId: null,
3261
- chat: [],
3262
- chatLoaded: false,
3263
- chatHistoryError: null,
3264
- reactions: [],
3265
- spotlight: null,
3266
- moderationNotice: null,
3267
- actionNotice: null,
3268
- privateChatRequest: null,
3269
- effects: DEFAULT_EFFECTS,
3270
- transcript: [],
3271
- notes: [],
3272
- names: {},
3273
- wrapUp: null,
3274
- noteTakerRefusal: null,
3275
- media: IDLE_MEDIA,
3276
- failure: null
3277
- };
3278
- function effectiveRole(snapshot) {
3279
- const me = snapshot.participants.find(
3280
- (participant) => participant.identity === snapshot.localIdentity
3281
- );
3282
- const fromRoster = me?.role ?? "participant";
3283
- const fromToken = snapshot.grants.role;
3284
- return ROLE_RANK[fromRoster] >= ROLE_RANK[fromToken] ? fromRoster : fromToken;
3285
- }
3286
- var REACTION_TTL_MS = 5e3;
3287
- var CHAT_LIMIT = 200;
3288
- function createMeetStore(initial = {}) {
3289
- let state = { ...EMPTY, ...initial };
3290
- const listeners = /* @__PURE__ */ new Set();
3291
- function commit(next) {
3292
- if (next === state) return;
3293
- state = next;
3294
- for (const listener of listeners) listener();
3295
- }
3296
- return {
3297
- snapshot: () => state,
3298
- subscribe(listener) {
3299
- listeners.add(listener);
3300
- return () => listeners.delete(listener);
3301
- },
3302
- patch(next) {
3303
- commit({ ...state, ...next });
3304
- },
3305
- setParticipants(participants) {
3306
- const names = learnNames(state.names, participants);
3307
- const hands = new Map(state.participants.map((p) => [p.identity, p.handRaised]));
3308
- const handTimes = new Map(state.participants.map((p) => [p.identity, p.handRaisedAt ?? null]));
3309
- const quality = new Map(state.participants.map((p) => [p.identity, p.quality]));
3310
- commit({
3311
- ...state,
3312
- names,
3313
- participants: participants.map((participant) => ({
3314
- ...participant,
3315
- // 🚨 THE ONE ROSTER RESOLUTION. Every surface that renders a person —
3316
- // the tile, the People row, the header summary, every Pin/Mute label —
3317
- // reads `participant.displayName`, so this is the single place a raw
3318
- // identity could reach a screen, and it is the place that asks the
3319
- // name book. `roster-names.test.ts` fails if a token ever gets past it.
3320
- displayName: displayNameFor(names, participant.identity, participant.displayName),
3321
- handRaised: hands.get(participant.identity) ?? participant.handRaised,
3322
- handRaisedAt: handTimes.get(participant.identity) ?? participant.handRaisedAt ?? null,
3323
- quality: participant.quality === "unknown" ? quality.get(participant.identity) ?? "unknown" : participant.quality
3324
- }))
3325
- });
3326
- },
3327
- setSpeaking(identities) {
3328
- const speaking = new Set(identities);
3329
- commit({
3330
- ...state,
3331
- activeSpeakers: identities,
3332
- participants: state.participants.map(
3333
- (participant) => participant.speaking === speaking.has(participant.identity) ? participant : { ...participant, speaking: speaking.has(participant.identity) }
3334
- )
3335
- });
3336
- },
3337
- setQuality(identity, quality) {
3338
- commit({
3339
- ...state,
3340
- participants: state.participants.map(
3341
- (participant) => participant.identity === identity ? { ...participant, quality } : participant
3342
- )
3343
- });
3344
- },
3345
- setHand(identity, raised, at) {
3346
- commit({
3347
- ...state,
3348
- participants: state.participants.map(
3349
- (participant) => participant.identity === identity ? {
3350
- ...participant,
3351
- handRaised: raised,
3352
- // A hand that is ALREADY up keeps its place in the queue when
3353
- // it is re-announced (to a late joiner): the first time wins.
3354
- handRaisedAt: raised ? participant.handRaised && participant.handRaisedAt != null ? participant.handRaisedAt : at ?? Date.now() : null
3355
- } : participant
3356
- )
3357
- });
3358
- },
3359
- addChat(message) {
3360
- if (state.chat.some((existing) => existing.id === message.id)) return;
3361
- const chat = [...state.chat, message];
3362
- const names = learnName(state.names, message.identity, message.displayName);
3363
- commit({ ...state, chat: chat.slice(-CHAT_LIMIT), names });
3364
- },
3365
- loadChat(messages) {
3366
- const known = new Set(state.chat.map((message) => message.id));
3367
- const merged = [...messages.filter((message) => !known.has(message.id)), ...state.chat].sort(
3368
- (a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0
3369
- );
3370
- const names = learnNames(
3371
- state.names,
3372
- messages.map((message) => ({ identity: message.identity, displayName: message.displayName }))
3373
- );
3374
- commit({ ...state, chat: merged.slice(-CHAT_LIMIT), chatLoaded: true, chatHistoryError: null, names });
3375
- },
3376
- settleChat(localId, next) {
3377
- const index = state.chat.findIndex((message) => message.id === localId);
3378
- if (index === -1) return;
3379
- const current = state.chat[index];
3380
- let replacement;
3381
- if ("failed" in next && typeof next.failed === "string" && !("id" in next)) {
3382
- replacement = { ...current, failed: next.failed };
3383
- } else {
3384
- replacement = next;
3385
- }
3386
- const duplicate = replacement.id !== localId && state.chat.some((message) => message.id === replacement.id);
3387
- const chat = duplicate ? state.chat.filter((message) => message.id !== localId) : state.chat.map((message, position) => position === index ? replacement : message);
3388
- commit({ ...state, chat });
3389
- },
3390
- addReaction(reaction) {
3391
- commit({ ...state, reactions: [...state.reactions, reaction] });
3736
+ await target.connect(serverUrl, token);
3737
+ } catch (error) {
3738
+ throw normalizeDeviceError(error, "meet.connect");
3739
+ }
3740
+ projectRoster?.();
3741
+ handlers.roomMetadata?.(target.metadata ?? "");
3392
3742
  },
3393
- expireReactions(now) {
3394
- const live = state.reactions.filter((reaction) => now - reaction.at < REACTION_TTL_MS);
3395
- if (live.length === state.reactions.length) return;
3396
- commit({ ...state, reactions: live });
3743
+ async disconnect() {
3744
+ if (room === null) return;
3745
+ const target = room;
3746
+ room = null;
3747
+ try {
3748
+ target.unregisterTextStreamHandler(TRANSCRIPTION_TOPIC);
3749
+ } catch {
3750
+ }
3751
+ projectRoster = null;
3752
+ audioContainer?.remove();
3753
+ audioContainer = null;
3754
+ await target.disconnect();
3397
3755
  },
3398
- setRecording(next, by, source, recordingId = null) {
3399
- if (state.recordingSource === "record" && source !== "record") {
3400
- const aboutTheSameRecording = source === "room" && (recordingId === null || state.recordingId === null || recordingId === state.recordingId);
3401
- if (aboutTheSameRecording || isRecordingInFlight(state.recording)) return;
3756
+ async setMicrophoneEnabled(enabled) {
3757
+ try {
3758
+ await requireRoom("setMicrophoneEnabled").localParticipant.setMicrophoneEnabled(
3759
+ enabled,
3760
+ audioCapture
3761
+ );
3762
+ } catch (error) {
3763
+ throw normalizeDeviceError(error, "meet.setMicrophoneEnabled");
3402
3764
  }
3403
- if (state.recording === next && state.recordingStartedBy === by && state.recordingSource === source && state.recordingId === (recordingId ?? state.recordingId)) {
3404
- return;
3765
+ },
3766
+ async setCameraEnabled(enabled) {
3767
+ try {
3768
+ const target = requireRoom("setCameraEnabled");
3769
+ await target.localParticipant.setCameraEnabled(enabled);
3770
+ if (enabled && videoEffect.kind !== "none") await applyVideoEffect(target, videoEffect);
3771
+ } catch (error) {
3772
+ throw normalizeDeviceError(error, "meet.setCameraEnabled");
3405
3773
  }
3406
- commit({
3407
- ...state,
3408
- recording: next,
3409
- recordingStartedBy: by,
3410
- recordingSource: source,
3411
- recordingId: recordingId ?? state.recordingId
3412
- });
3413
3774
  },
3414
- upsertLobby(entry) {
3415
- const index = state.lobby.findIndex((existing) => existing.identity === entry.identity);
3416
- const lobby = index === -1 ? [...state.lobby, entry] : state.lobby.map((existing, position) => position === index ? entry : existing);
3417
- commit({ ...state, lobby });
3775
+ async setScreenShareEnabled(enabled) {
3776
+ try {
3777
+ await requireRoom("setScreenShareEnabled").localParticipant.setScreenShareEnabled(enabled);
3778
+ } catch (error) {
3779
+ throw normalizeDeviceError(error, "meet.setScreenShareEnabled");
3780
+ }
3418
3781
  },
3419
- setAdmission(identity, admissionState, by) {
3420
- commit({
3421
- ...state,
3422
- lobby: state.lobby.map(
3423
- (entry) => entry.identity === identity ? {
3424
- ...entry,
3425
- state: admissionState,
3426
- decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
3427
- decidedBy: by
3428
- } : entry
3429
- )
3430
- });
3782
+ async publishData(topic, payload, to) {
3783
+ await requireRoom("publishData").localParticipant.publishData(
3784
+ // `reliable` is not optional for a control plane: a dropped
3785
+ // "hand lowered" leaves a hand up on every other screen forever.
3786
+ encoder.encode(payload),
3787
+ to === void 0 || to.length === 0 ? { reliable: true, topic } : { reliable: true, topic, destinationIdentities: [...to] }
3788
+ );
3431
3789
  },
3432
- learn(entries) {
3433
- const names = learnNames(state.names, entries);
3434
- if (names === state.names) return;
3435
- commit({ ...state, names });
3790
+ async setAudioProcessing(processing) {
3791
+ audioCapture = { ...audioCapture, ...processing };
3792
+ const target = room;
3793
+ if (target === null) return;
3794
+ const { Track } = await import("livekit-client");
3795
+ const publication = target.localParticipant.getTrackPublication(Track.Source.Microphone);
3796
+ const track = publication?.track;
3797
+ if (track === void 0 || publication?.isMuted === true) return;
3798
+ try {
3799
+ await track.restartTrack({
3800
+ ...audioCapture,
3801
+ ...target.getActiveDevice("audioinput") !== void 0 ? { deviceId: target.getActiveDevice("audioinput") } : {}
3802
+ });
3803
+ } catch (error) {
3804
+ throw normalizeDeviceError(error, "meet.setAudioProcessing");
3805
+ }
3436
3806
  },
3437
- ingestCaption(segment) {
3438
- const names = learnName(state.names, segment.identity, segment.speaker);
3439
- const transcript = mergeCaption(state.transcript, segment);
3440
- if (transcript === state.transcript && names === state.names) return;
3441
- commit({ ...state, transcript, names });
3807
+ async setVideoEffect(effect) {
3808
+ videoEffect = effect;
3809
+ const target = room;
3810
+ if (target === null) return;
3811
+ await applyVideoEffect(target, effect);
3442
3812
  },
3443
- ingestRecord(segment) {
3444
- const names = learnName(state.names, segment.identity, segment.speaker);
3445
- const transcript = mergeRecord(state.transcript, segment);
3446
- if (transcript === state.transcript && names === state.names) return;
3447
- commit({ ...state, transcript, names });
3813
+ supportsVideoEffects() {
3814
+ return videoEffectsSupported();
3448
3815
  },
3449
- upsertNote(note) {
3450
- const index = state.notes.findIndex((existing2) => existing2.id === note.id);
3451
- if (index === -1) {
3452
- commit({ ...state, notes: [...state.notes, note] });
3453
- return;
3454
- }
3455
- const existing = state.notes[index];
3456
- if (existing !== void 0 && existing.text === note.text && existing.kind === note.kind && existing.supersededBy === note.supersededBy && existing.source === note.source) {
3457
- return;
3458
- }
3459
- if (existing !== void 0 && existing.source === "record" && note.source === "room") {
3460
- return;
3816
+ attach(identity, kind, element) {
3817
+ const target = requireRoom("attach");
3818
+ const source = sources?.[kind];
3819
+ if (source === void 0) return () => void 0;
3820
+ let current = null;
3821
+ const sync = () => {
3822
+ const participant = target.localParticipant.identity === identity ? target.localParticipant : target.remoteParticipants.get(identity);
3823
+ const track = participant?.getTrackPublication(source)?.track;
3824
+ if (track === void 0 || track === current) return;
3825
+ current?.detach(element);
3826
+ track.attach(element);
3827
+ current = track;
3828
+ };
3829
+ const onTrack = (...args) => {
3830
+ const who = args[args.length - 1];
3831
+ if (who?.identity === identity) sync();
3832
+ };
3833
+ sync();
3834
+ const events = trackEvents;
3835
+ if (events !== null) {
3836
+ target.on(events.subscribed, onTrack);
3837
+ target.on(events.localPublished, onTrack);
3461
3838
  }
3462
- commit({
3463
- ...state,
3464
- notes: state.notes.map((entry, position) => position === index ? note : entry)
3465
- });
3466
- },
3467
- announceWrapUp(announcement) {
3468
- const current = state.wrapUp;
3469
- if (current !== null && current.at === announcement.at) return;
3470
- commit({ ...state, wrapUp: announcement });
3839
+ return () => {
3840
+ if (events !== null) {
3841
+ target.off(events.subscribed, onTrack);
3842
+ target.off(events.localPublished, onTrack);
3843
+ }
3844
+ current?.detach(element);
3845
+ current = null;
3846
+ };
3471
3847
  },
3472
- setLocalTrack(kind, next) {
3473
- const current = state.media[kind];
3474
- if (current.enabled === next.enabled && current.unavailable?.code === next.unavailable?.code && current.unavailable?.message === next.unavailable?.message) {
3475
- return;
3848
+ activeDevices() {
3849
+ const target = room;
3850
+ if (target === null) {
3851
+ return { audioinput: null, videoinput: null, audiooutput: null };
3476
3852
  }
3477
- commit({ ...state, media: { ...state.media, [kind]: next } });
3853
+ return {
3854
+ audioinput: target.getActiveDevice("audioinput") ?? null,
3855
+ videoinput: target.getActiveDevice("videoinput") ?? null,
3856
+ audiooutput: target.getActiveDevice("audiooutput") ?? null
3857
+ };
3478
3858
  },
3479
- reset() {
3480
- commit({ ...EMPTY, effects: state.effects });
3859
+ async switchDevice(kind, deviceId) {
3860
+ try {
3861
+ await requireRoom("switchDevice").switchActiveDevice(kind, deviceId);
3862
+ } catch (error) {
3863
+ throw normalizeDeviceError(error, "meet.switchDevice");
3864
+ }
3481
3865
  }
3482
3866
  };
3483
3867
  }
@@ -4005,12 +4389,19 @@ function coveredMs(intervals) {
4005
4389
  }
4006
4390
  function attendanceReport(meeting, attendees, names = {}) {
4007
4391
  const end = ms(meeting.endedAt);
4392
+ const measuredOf = /* @__PURE__ */ new Map();
4008
4393
  const perJoin = attendees.map((person) => {
4009
- const joined = ms(person.joinedAt);
4010
- const left = ms(person.leftAt);
4011
- const until = left ?? end;
4012
- const duration = joined !== null && until !== null && until >= joined ? until - joined : null;
4013
- return {
4394
+ 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)]];
4395
+ let toEnd = false;
4396
+ const measured = [];
4397
+ for (const [joined, left] of stays) {
4398
+ const until = left ?? end;
4399
+ if (joined === null || until === null || until < joined) continue;
4400
+ if (left === null) toEnd = true;
4401
+ measured.push([joined, until]);
4402
+ }
4403
+ const duration = measured.length === 0 ? null : coveredMs(measured);
4404
+ const row = {
4014
4405
  id: person.id,
4015
4406
  identity: person.identity,
4016
4407
  name: displayNameFor(names, person.identity, person.displayName),
@@ -4019,10 +4410,12 @@ function attendanceReport(meeting, attendees, names = {}) {
4019
4410
  joinedAt: person.joinedAt,
4020
4411
  leftAt: person.leftAt,
4021
4412
  durationMs: duration,
4022
- durationToEnd: duration !== null && left === null,
4413
+ durationToEnd: duration !== null && toEnd,
4023
4414
  admission: person.admissionState,
4024
- joins: 1
4415
+ joins: Math.max(1, person.sessions?.length ?? 0)
4025
4416
  };
4417
+ measuredOf.set(row, measured);
4418
+ return row;
4026
4419
  });
4027
4420
  const groups = /* @__PURE__ */ new Map();
4028
4421
  attendees.forEach((person, index) => {
@@ -4045,17 +4438,14 @@ function attendanceReport(meeting, attendees, names = {}) {
4045
4438
  const left = ms(row.leftAt);
4046
4439
  return left !== null && (latest === null || left > latest) ? left : latest;
4047
4440
  }, null);
4048
- const intervals = came.flatMap((row) => {
4049
- const joined = ms(row.joinedAt);
4050
- return joined !== null && row.durationMs !== null ? [[joined, joined + row.durationMs]] : [];
4051
- });
4441
+ const intervals = came.flatMap((row) => measuredOf.get(row) ?? []);
4052
4442
  return {
4053
4443
  ...first,
4054
4444
  kind,
4055
4445
  leftAt: open ? null : lastLeave === null ? null : new Date(lastLeave).toISOString(),
4056
4446
  durationMs: intervals.length === 0 ? null : coveredMs(intervals),
4057
4447
  durationToEnd: came.some((row) => row.durationToEnd),
4058
- joins: came.length === 0 ? joins.length : came.length
4448
+ joins: came.length === 0 ? joins.length : came.reduce((total, row) => total + row.joins, 0)
4059
4449
  };
4060
4450
  });
4061
4451
  const rank = KIND_RANK;
@@ -4446,6 +4836,7 @@ function createRoomEngine(options) {
4446
4836
  let joining = null;
4447
4837
  let lastRequest = null;
4448
4838
  let disposed = false;
4839
+ let moving = false;
4449
4840
  const confirmingEnds = /* @__PURE__ */ new WeakSet();
4450
4841
  async function leaveRoom() {
4451
4842
  const target = driver;
@@ -4675,6 +5066,7 @@ function createRoomEngine(options) {
4675
5066
  }
4676
5067
  },
4677
5068
  disconnected(reason) {
5069
+ if (moving) return;
4678
5070
  if (current !== null) {
4679
5071
  tokens.invalidate(current.roomName);
4680
5072
  current = null;
@@ -4689,11 +5081,13 @@ function createRoomEngine(options) {
4689
5081
  roomName: request.roomName,
4690
5082
  identity,
4691
5083
  ...request.meetingId !== void 0 ? { meetingId: request.meetingId } : {},
4692
- ...request.slug !== void 0 ? { slug: request.slug } : {}
5084
+ ...request.slug !== void 0 ? { slug: request.slug } : {},
5085
+ ...request.breakoutRoomId != null ? { breakoutRoomId: request.breakoutRoomId } : {}
4693
5086
  });
4694
5087
  current = token;
4695
5088
  store.patch({
4696
5089
  localIdentity: token.identity,
5090
+ breakoutRoomId: token.breakoutRoomId ?? null,
4697
5091
  grants: {
4698
5092
  role: token.role,
4699
5093
  canRecord: token.canRecord,
@@ -4812,6 +5206,56 @@ function createRoomEngine(options) {
4812
5206
  });
4813
5207
  return joining;
4814
5208
  },
5209
+ async moveToBreakout(roomId) {
5210
+ const request = lastRequest;
5211
+ if (request === null || driver === null) {
5212
+ report(
5213
+ "warn",
5214
+ "meet.moveToBreakout: this engine is not in a meeting room",
5215
+ "Join the meeting first; a breakout is a room OF a meeting."
5216
+ );
5217
+ return;
5218
+ }
5219
+ if ((store.snapshot().breakoutRoomId ?? null) === roomId) return;
5220
+ if (joining !== null) await joining.catch(() => void 0);
5221
+ const previous = driver;
5222
+ const next = {
5223
+ ...request,
5224
+ breakoutRoomId: roomId,
5225
+ // Arrive as you were: camera and microphone as they are now.
5226
+ mode: store.snapshot().media.camera.enabled ? "video" : "audio",
5227
+ microphone: store.snapshot().media.microphone.enabled
5228
+ };
5229
+ moving = true;
5230
+ try {
5231
+ driver = null;
5232
+ tokens.invalidate(request.roomName);
5233
+ current = null;
5234
+ await previous.disconnect();
5235
+ lastRequest = next;
5236
+ joining = connect(next).finally(() => {
5237
+ joining = null;
5238
+ });
5239
+ await joining;
5240
+ } catch (error) {
5241
+ const normalized = normalizeMeetError(error, "meet.moveToBreakout");
5242
+ report("warn", normalized.message, normalized.remedy);
5243
+ if (roomId !== null) {
5244
+ lastRequest = { ...next, breakoutRoomId: null };
5245
+ joining = connect(lastRequest).finally(() => {
5246
+ joining = null;
5247
+ });
5248
+ await joining.catch((again) => {
5249
+ throw fail(again, "meet.moveToBreakout");
5250
+ });
5251
+ } else {
5252
+ throw fail(error, "meet.moveToBreakout");
5253
+ }
5254
+ throw normalized;
5255
+ } finally {
5256
+ moving = false;
5257
+ }
5258
+ },
4815
5259
  leave: leaveRoom,
4816
5260
  // Turning a device on mid-meeting is the SAME operation as turning it on
4817
5261
  // during the join, so it runs the same function. A failure here used to
@@ -5081,6 +5525,109 @@ function createInRoomClient(options) {
5081
5525
  (row) => typeof row === "object" && row !== null ? chatFromRow(row) : null
5082
5526
  ).filter((message) => message !== null);
5083
5527
  store.loadChat(messages);
5528
+ },
5529
+ async loadCollab() {
5530
+ const operation = "meet.loadCollab";
5531
+ const id = meetingId(operation);
5532
+ const hdrs = headers();
5533
+ const response = await api.request({
5534
+ path: `${MEET_ROUTES.collab}?meeting_id=${encodeURIComponent(id)}`,
5535
+ method: "GET",
5536
+ operation,
5537
+ ...hdrs !== void 0 ? { headers: hdrs } : {}
5538
+ });
5539
+ if (!response.ok) {
5540
+ const error = await refusal2(
5541
+ response,
5542
+ operation,
5543
+ "Polls, questions and the whiteboard could not be read. New ones still arrive."
5544
+ );
5545
+ store.patch({ collab: { ...store.snapshot().collab, loaded: true, error: error.message } });
5546
+ throw error;
5547
+ }
5548
+ const body = await response.json();
5549
+ store.patch({ collab: collabFromState(body, store.snapshot().collab) });
5550
+ },
5551
+ async collab(request) {
5552
+ const operation = `meet.collab(${request.action})`;
5553
+ const hdrs = headers();
5554
+ const response = await api.request({
5555
+ path: MEET_ROUTES.collab,
5556
+ method: "POST",
5557
+ operation,
5558
+ body: {
5559
+ meeting_id: meetingId(operation),
5560
+ action: request.action,
5561
+ ...request.itemId !== void 0 ? { item_id: request.itemId } : {},
5562
+ ...request.question !== void 0 ? { question: request.question } : {},
5563
+ ...request.options !== void 0 ? { options: request.options } : {},
5564
+ ...request.multiple !== void 0 ? { multiple: request.multiple } : {},
5565
+ ...request.anonymous !== void 0 ? { anonymous: request.anonymous } : {},
5566
+ ...request.optionIds !== void 0 ? { option_ids: request.optionIds } : {},
5567
+ ...request.text !== void 0 ? { text: request.text } : {},
5568
+ ...request.stroke !== void 0 ? { stroke: request.stroke } : {},
5569
+ ...request.rooms !== void 0 ? { rooms: request.rooms } : {},
5570
+ ...request.allowChoose !== void 0 ? { allow_choose: request.allowChoose } : {},
5571
+ ...request.minutes !== void 0 ? { minutes: request.minutes } : {},
5572
+ ...request.identity !== void 0 ? { identity: request.identity } : {},
5573
+ ...request.roomId !== void 0 ? { room_id: request.roomId } : {}
5574
+ },
5575
+ ...hdrs !== void 0 ? { headers: hdrs } : {}
5576
+ });
5577
+ if (!response.ok) {
5578
+ throw await refusal2(response, operation, "Nothing was changed.");
5579
+ }
5580
+ const payload = await response.json();
5581
+ const removed = Array.isArray(payload["removed"]) ? payload["removed"].filter((v) => typeof v === "string") : [];
5582
+ let next = store.snapshot().collab;
5583
+ const item = payload["item"];
5584
+ if (typeof item === "object" && item !== null) next = withItem(next, item);
5585
+ if (removed.length > 0) {
5586
+ const gone = new Set(removed);
5587
+ next = {
5588
+ ...next,
5589
+ polls: next.polls.filter((p) => !gone.has(p.id)),
5590
+ questions: next.questions.filter((q) => !gone.has(q.id)),
5591
+ strokes: next.strokes.filter((st) => !gone.has(st.id))
5592
+ };
5593
+ }
5594
+ store.patch({ collab: next });
5595
+ const itemId = typeof item === "object" && item !== null && typeof item["id"] === "string" ? item["id"] : null;
5596
+ return {
5597
+ action: request.action,
5598
+ message: typeof payload["message"] === "string" ? payload["message"] : "Done.",
5599
+ removed,
5600
+ itemId
5601
+ };
5602
+ },
5603
+ async translateCaptions(language, lines) {
5604
+ if (lines.length === 0) return;
5605
+ const operation = "meet.translateCaptions";
5606
+ const hdrs = headers();
5607
+ const response = await api.request({
5608
+ path: MEET_ROUTES.captionsTranslate,
5609
+ method: "POST",
5610
+ operation,
5611
+ body: { meeting_id: meetingId(operation), target_language: language, lines },
5612
+ ...hdrs !== void 0 ? { headers: hdrs } : {}
5613
+ });
5614
+ if (!response.ok) {
5615
+ const error = await refusal2(response, operation, "Captions stay in the language they were spoken.");
5616
+ store.patch({
5617
+ collab: { ...store.snapshot().collab, captionTranslationError: error.message }
5618
+ });
5619
+ throw error;
5620
+ }
5621
+ const payload = await response.json();
5622
+ const current = store.snapshot().collab;
5623
+ if (current.captionLanguage !== language) return;
5624
+ const merged = { ...current.captionTranslations };
5625
+ for (const [id, value] of Object.entries(payload.translations ?? {})) {
5626
+ if (typeof value === "string") merged[id] = value;
5627
+ }
5628
+ store.patch({
5629
+ collab: { ...current, captionTranslations: merged, captionTranslationError: null }
5630
+ });
5084
5631
  }
5085
5632
  };
5086
5633
  }
@@ -5481,10 +6028,12 @@ export {
5481
6028
  ATTENDANCE_CSV_HEADER,
5482
6029
  CALL_INVITE_KIND,
5483
6030
  CALL_INVITE_VERSION,
6031
+ CAPTION_LANGUAGES,
5484
6032
  CONSENT_NOTICE_ATTRIBUTE,
5485
6033
  DATA_TOPICS,
5486
6034
  DEFAULT_EFFECTS,
5487
6035
  DEFAULT_PAGE_SIZE,
6036
+ EMPTY_COLLAB,
5488
6037
  END_MEETING_NOT_SERVED,
5489
6038
  FILE_SESSION_PATH,
5490
6039
  GUEST_RECORD_ACCESS_CHOICES,
@@ -5522,6 +6071,7 @@ export {
5522
6071
  absenceSentence,
5523
6072
  activeWrapUpSummary,
5524
6073
  announceMeeting,
6074
+ applyCollabPacket,
5525
6075
  applyRoomEvent,
5526
6076
  asCallId,
5527
6077
  asMeetingId,
@@ -5529,10 +6079,13 @@ export {
5529
6079
  asParticipantIdentity,
5530
6080
  asRoomName,
5531
6081
  asUserId,
6082
+ assignedRoom,
5532
6083
  attendanceCsv,
5533
6084
  attendanceReport,
5534
6085
  attendanceTotals,
5535
6086
  attendeeKey,
6087
+ autoAssign,
6088
+ breakoutRemainingMs,
5536
6089
  callInviteAction,
5537
6090
  callInviteText,
5538
6091
  callStateLabel,
@@ -5542,6 +6095,7 @@ export {
5542
6095
  captionSegment,
5543
6096
  changesPatch,
5544
6097
  clockPosition,
6098
+ collabFromState,
5545
6099
  computeStage,
5546
6100
  countLabel,
5547
6101
  createCallCenter,
@@ -5562,6 +6116,7 @@ export {
5562
6116
  createWebDeviceStorage,
5563
6117
  createWebEffectsStorage,
5564
6118
  createWebRoomTokenStorage,
6119
+ defaultCaptionLanguage,
5565
6120
  displayNameFor,
5566
6121
  fileDownloadPath,
5567
6122
  gridColumns,
@@ -5584,6 +6139,7 @@ export {
5584
6139
  listenOnlyNotice,
5585
6140
  loadFollowUpDraft,
5586
6141
  loadGuestMeetingRecord,
6142
+ loadMeetingActivities,
5587
6143
  loadMeetingChat,
5588
6144
  loadMeetingRecord,
5589
6145
  mapConnectionState,
@@ -5639,6 +6195,7 @@ export {
5639
6195
  uniqueAttendees,
5640
6196
  userTopic,
5641
6197
  wasRefused,
6198
+ withItem,
5642
6199
  wrapUpStatus
5643
6200
  };
5644
6201
  //# sourceMappingURL=index.js.map