@ai-matrx/meet 0.7.3 → 0.7.5

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
@@ -191,7 +191,16 @@ var MEET_ROUTES = {
191
191
  */
192
192
  meetingAnnounce: "/api/v1/meet/meetings/{meeting_id}/announce",
193
193
  /** Put a one-off meeting on the caller's own Google Calendar (gated by the server). */
194
- meetingGoogleCalendar: "/api/v1/meet/meetings/{meeting_id}/google-calendar"
194
+ meetingGoogleCalendar: "/api/v1/meet/meetings/{meeting_id}/google-calendar",
195
+ /**
196
+ * Meet wave 2: EVERY in-meeting host power (mute, stop video, mute all, ask to
197
+ * unmute, spotlight, lower hands, co-host, hand over, rename, remove, report).
198
+ * Decided by the server from the meeting itself; a guest's pass rides
199
+ * `x-meet-room-token`.
200
+ */
201
+ moderate: "/api/v1/meet/moderate",
202
+ /** Meet wave 2: the SAVED chat — `POST` sends to everyone, `GET ?meeting_id=` reads history. */
203
+ chat: "/api/v1/meet/chat"
195
204
  };
196
205
  function meetingPath(template, meetingId) {
197
206
  return template.replace("{meeting_id}", encodeURIComponent(meetingId));
@@ -1909,7 +1918,19 @@ var ROOM_EVENTS = {
1909
1918
  * production 2026-09-09 the guest lane showed no indicator at all while the
1910
1919
  * host recorded (MRI-C8).
1911
1920
  */
1912
- recording: "matrx.meet.recording"
1921
+ recording: "matrx.meet.recording",
1922
+ /**
1923
+ * A chat message to everyone was SAVED (Meet wave 2). Payload
1924
+ * `{ message: { id, meeting_id, sender_identity, sender_name, body, created_at } }`.
1925
+ */
1926
+ chat: "matrx.meet.chat",
1927
+ /**
1928
+ * A host or co-host acted (Meet wave 2). Payload `{ meeting_id, kind, … }`:
1929
+ * `muted {source, by}` · `ask-unmute {by}` · `removed {by}` — addressed to the
1930
+ * person concerned; `lower-hands {identities: [...] | "*"}` · `role {identity,
1931
+ * role}` — to everyone.
1932
+ */
1933
+ moderation: "matrx.meet.moderation"
1913
1934
  };
1914
1935
  function isRoomEventTopic(topic) {
1915
1936
  return topic.startsWith(ROOM_EVENT_PREFIX);
@@ -1945,6 +1966,10 @@ function applyRoomEvent(args) {
1945
1966
  return applyEnded(store, payload);
1946
1967
  case ROOM_EVENTS.recording:
1947
1968
  return applyRecording(store, payload);
1969
+ case ROOM_EVENTS.chat:
1970
+ return applyChat(store, payload);
1971
+ case ROOM_EVENTS.moderation:
1972
+ return applyModeration(store, payload, now);
1948
1973
  default:
1949
1974
  return ignored(`${topic} is not known to this version of @ai-matrx/meet`);
1950
1975
  }
@@ -2036,6 +2061,101 @@ function applyEnded(store, payload) {
2036
2061
  store.patch({ meeting: { ...meeting, endedAt } });
2037
2062
  return HANDLED;
2038
2063
  }
2064
+ function chatFromRow(row) {
2065
+ const id = text2(row["id"]);
2066
+ const identity = text2(row["sender_identity"]);
2067
+ const body = text2(row["body"]);
2068
+ if (id === null || identity === null || body === null) return null;
2069
+ return {
2070
+ id,
2071
+ identity: asParticipantIdentity(identity),
2072
+ displayName: text2(row["sender_name"]) ?? "Participant",
2073
+ text: body,
2074
+ at: text2(row["created_at"]) ?? (/* @__PURE__ */ new Date()).toISOString(),
2075
+ // `false` only from a server whose chat table has not landed: the room heard
2076
+ // it, nobody will see it later — and the line says so.
2077
+ saved: row["saved"] !== false
2078
+ };
2079
+ }
2080
+ function applyChat(store, payload) {
2081
+ const row = object(payload["message"]);
2082
+ if (row === null) return ignored("a chat event with no `message` row");
2083
+ const meeting = store.snapshot().meeting;
2084
+ if (meeting !== null && text2(row["meeting_id"]) !== null && row["meeting_id"] !== meeting.id) {
2085
+ return ignored("a chat message for a different meeting");
2086
+ }
2087
+ const message = chatFromRow(row);
2088
+ if (message === null) return ignored("a chat row with no id, sender or body");
2089
+ store.addChat(message);
2090
+ return HANDLED;
2091
+ }
2092
+ function moderationText(kind, payload) {
2093
+ const by = text2(payload["by"]) ?? "The host";
2094
+ switch (kind) {
2095
+ case "muted": {
2096
+ const source = text2(payload["source"]) ?? "microphone";
2097
+ return source === "microphone" ? `${by} muted you.` : source === "camera" ? `${by} turned off your camera.` : `${by} stopped your screen share.`;
2098
+ }
2099
+ case "ask-unmute":
2100
+ return `${by} is asking you to unmute.`;
2101
+ case "removed":
2102
+ return `${by} removed you from this meeting.`;
2103
+ default:
2104
+ return null;
2105
+ }
2106
+ }
2107
+ function applyModeration(store, payload, now) {
2108
+ const snapshot = store.snapshot();
2109
+ const meetingId = text2(payload["meeting_id"]);
2110
+ if (snapshot.meeting !== null && meetingId !== null && meetingId !== snapshot.meeting.id) {
2111
+ return ignored("a moderation event for a different meeting");
2112
+ }
2113
+ const kind = text2(payload["kind"]);
2114
+ switch (kind) {
2115
+ case "lower-hands": {
2116
+ const raw = payload["identities"];
2117
+ const everyone = raw === "*";
2118
+ const named = new Set(
2119
+ Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []
2120
+ );
2121
+ for (const participant of snapshot.participants) {
2122
+ if (participant.handRaised && (everyone || named.has(participant.identity))) {
2123
+ store.setHand(participant.identity, false);
2124
+ }
2125
+ }
2126
+ return HANDLED;
2127
+ }
2128
+ case "role": {
2129
+ const identity = text2(payload["identity"]);
2130
+ const role = text2(payload["role"]);
2131
+ if (identity === null || role === null || !ROLES.includes(role)) {
2132
+ return ignored("a role event with no identity or an unknown role");
2133
+ }
2134
+ if (identity === snapshot.localIdentity) {
2135
+ store.patch({
2136
+ grants: { ...snapshot.grants, role },
2137
+ moderationNotice: {
2138
+ kind: "role",
2139
+ text: role === "host" ? "You are now the host." : role === "cohost" ? "You are now a co-host." : "You are no longer a co-host.",
2140
+ at: now()
2141
+ }
2142
+ });
2143
+ }
2144
+ return HANDLED;
2145
+ }
2146
+ case "muted":
2147
+ case "ask-unmute":
2148
+ case "removed": {
2149
+ const sentence = moderationText(kind, payload);
2150
+ if (sentence === null) return ignored("a moderation event with no sentence");
2151
+ store.patch({ moderationNotice: { kind, text: sentence, at: now() } });
2152
+ return HANDLED;
2153
+ }
2154
+ default:
2155
+ return ignored(`a moderation kind this version does not know: ${String(kind)}`);
2156
+ }
2157
+ }
2158
+ var ROLES = ["host", "cohost", "participant", "guest"];
2039
2159
 
2040
2160
  // src/core/devices.ts
2041
2161
  var KINDS = ["audioinput", "videoinput", "audiooutput"];
@@ -2368,7 +2488,8 @@ function computeStage(args) {
2368
2488
  const ordered = orderParticipants(args.participants, args.activeSpeakers);
2369
2489
  const sharer = ordered.find((participant) => participant.screenSharing) ?? null;
2370
2490
  const pinnedParticipant = args.pinned === null ? null : ordered.find((participant) => participant.identity === args.pinned) ?? null;
2371
- const feature = sharer !== null ? sharer : args.layout === "grid" ? null : pinnedParticipant ?? ordered[0] ?? null;
2491
+ const spotlit = args.spotlight == null ? null : ordered.find((participant) => participant.identity === args.spotlight) ?? null;
2492
+ const feature = sharer !== null ? sharer : pinnedParticipant !== null && args.layout !== "grid" ? pinnedParticipant : spotlit !== null ? spotlit : args.layout === "grid" ? null : ordered[0] ?? null;
2372
2493
  const rest = feature === null ? ordered : ordered.filter((participant) => participant.identity !== feature.identity);
2373
2494
  const pageCount = Math.max(1, Math.ceil(rest.length / pageSize));
2374
2495
  const page = Math.min(Math.max(0, args.page ?? 0), pageCount - 1);
@@ -2394,8 +2515,7 @@ function gridColumns(count2) {
2394
2515
  var DATA_TOPICS = {
2395
2516
  hand: "mx.hand",
2396
2517
  reaction: "mx.reaction",
2397
- chat: "mx.chat",
2398
- hostAction: "mx.host"
2518
+ chat: "mx.chat"
2399
2519
  };
2400
2520
  var TRANSCRIPTION_TOPIC = "lk.transcription";
2401
2521
  var TRANSCRIPTION_ATTRIBUTES = {
@@ -2456,11 +2576,48 @@ function snapshotParticipant(participant, isLocal) {
2456
2576
  screenSharing: participant.isScreenShareEnabled
2457
2577
  };
2458
2578
  }
2579
+ var DEFAULT_AUDIO_PROCESSING = {
2580
+ noiseSuppression: true,
2581
+ echoCancellation: true,
2582
+ autoGainControl: true
2583
+ };
2584
+ function videoEffectsSupported() {
2585
+ const scope = globalThis;
2586
+ if (typeof scope.OffscreenCanvas === "undefined" || scope.document === void 0) return false;
2587
+ try {
2588
+ return scope.document.createElement("canvas").getContext("webgl2") !== null;
2589
+ } catch {
2590
+ return false;
2591
+ }
2592
+ }
2593
+ async function applyVideoEffect(target, effect) {
2594
+ const { Track } = await import("livekit-client");
2595
+ const track = target.localParticipant.getTrackPublication(Track.Source.Camera)?.track;
2596
+ if (track === void 0) return;
2597
+ try {
2598
+ if (effect.kind === "none") {
2599
+ await track.stopProcessor();
2600
+ return;
2601
+ }
2602
+ const processors = await import("@livekit/track-processors");
2603
+ const processor = effect.kind === "blur" ? processors.BackgroundBlur(effect.radius ?? 12) : processors.VirtualBackground(effect.imageUrl);
2604
+ await track.setProcessor(processor);
2605
+ } catch (error) {
2606
+ throw new MeetError(
2607
+ "media-unsupported",
2608
+ "The background effect could not start on this camera",
2609
+ "Background effects need a browser with WebGL2 (current Chrome, Edge, Firefox or Safari). Your video continues without the effect.",
2610
+ error
2611
+ );
2612
+ }
2613
+ }
2459
2614
  var encoder = new TextEncoder();
2460
2615
  var decoder = new TextDecoder();
2461
2616
  function createLiveKitDriver(handlers, options = {}) {
2462
2617
  let room = null;
2463
2618
  let projectRoster = null;
2619
+ let audioCapture = DEFAULT_AUDIO_PROCESSING;
2620
+ let videoEffect = { kind: "none" };
2464
2621
  async function load() {
2465
2622
  if (room !== null) return room;
2466
2623
  let module;
@@ -2489,7 +2646,9 @@ function createLiveKitDriver(handlers, options = {}) {
2489
2646
  handlers.participants([local, ...remotes]);
2490
2647
  };
2491
2648
  projectRoster = roster;
2492
- created.on(RoomEvent.ParticipantConnected, roster).on(RoomEvent.ParticipantDisconnected, roster).on(RoomEvent.TrackSubscribed, roster).on(RoomEvent.TrackUnsubscribed, roster).on(RoomEvent.TrackMuted, roster).on(RoomEvent.TrackUnmuted, roster).on(RoomEvent.LocalTrackPublished, roster).on(RoomEvent.LocalTrackUnpublished, roster).on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
2649
+ created.on(RoomEvent.ParticipantConnected, roster).on(RoomEvent.ParticipantDisconnected, roster).on(RoomEvent.TrackSubscribed, roster).on(RoomEvent.TrackUnsubscribed, roster).on(RoomEvent.TrackMuted, roster).on(RoomEvent.TrackUnmuted, roster).on(RoomEvent.LocalTrackPublished, roster).on(RoomEvent.LocalTrackUnpublished, roster).on(RoomEvent.ParticipantNameChanged, roster).on(RoomEvent.ParticipantAttributesChanged, roster).on(RoomEvent.ParticipantMetadataChanged, roster).on(RoomEvent.RoomMetadataChanged, (metadata) => {
2650
+ handlers.roomMetadata?.(metadata ?? "");
2651
+ }).on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
2493
2652
  handlers.activeSpeakers(speakers.map((speaker) => speaker.identity));
2494
2653
  }).on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
2495
2654
  handlers.quality(participant.identity, mapQuality(quality));
@@ -2566,6 +2725,7 @@ function createLiveKitDriver(handlers, options = {}) {
2566
2725
  throw normalizeDeviceError(error, "meet.connect");
2567
2726
  }
2568
2727
  projectRoster?.();
2728
+ handlers.roomMetadata?.(target.metadata ?? "");
2569
2729
  },
2570
2730
  async disconnect() {
2571
2731
  if (room === null) return;
@@ -2580,14 +2740,19 @@ function createLiveKitDriver(handlers, options = {}) {
2580
2740
  },
2581
2741
  async setMicrophoneEnabled(enabled) {
2582
2742
  try {
2583
- await requireRoom("setMicrophoneEnabled").localParticipant.setMicrophoneEnabled(enabled);
2743
+ await requireRoom("setMicrophoneEnabled").localParticipant.setMicrophoneEnabled(
2744
+ enabled,
2745
+ audioCapture
2746
+ );
2584
2747
  } catch (error) {
2585
2748
  throw normalizeDeviceError(error, "meet.setMicrophoneEnabled");
2586
2749
  }
2587
2750
  },
2588
2751
  async setCameraEnabled(enabled) {
2589
2752
  try {
2590
- await requireRoom("setCameraEnabled").localParticipant.setCameraEnabled(enabled);
2753
+ const target = requireRoom("setCameraEnabled");
2754
+ await target.localParticipant.setCameraEnabled(enabled);
2755
+ if (enabled && videoEffect.kind !== "none") await applyVideoEffect(target, videoEffect);
2591
2756
  } catch (error) {
2592
2757
  throw normalizeDeviceError(error, "meet.setCameraEnabled");
2593
2758
  }
@@ -2599,14 +2764,40 @@ function createLiveKitDriver(handlers, options = {}) {
2599
2764
  throw normalizeDeviceError(error, "meet.setScreenShareEnabled");
2600
2765
  }
2601
2766
  },
2602
- async publishData(topic, payload) {
2767
+ async publishData(topic, payload, to) {
2603
2768
  await requireRoom("publishData").localParticipant.publishData(
2604
2769
  // `reliable` is not optional for a control plane: a dropped
2605
2770
  // "hand lowered" leaves a hand up on every other screen forever.
2606
2771
  encoder.encode(payload),
2607
- { reliable: true, topic }
2772
+ to === void 0 || to.length === 0 ? { reliable: true, topic } : { reliable: true, topic, destinationIdentities: [...to] }
2608
2773
  );
2609
2774
  },
2775
+ async setAudioProcessing(processing) {
2776
+ audioCapture = { ...audioCapture, ...processing };
2777
+ const target = room;
2778
+ if (target === null) return;
2779
+ const { Track } = await import("livekit-client");
2780
+ const publication = target.localParticipant.getTrackPublication(Track.Source.Microphone);
2781
+ const track = publication?.track;
2782
+ if (track === void 0 || publication?.isMuted === true) return;
2783
+ try {
2784
+ await track.restartTrack({
2785
+ ...audioCapture,
2786
+ ...target.getActiveDevice("audioinput") !== void 0 ? { deviceId: target.getActiveDevice("audioinput") } : {}
2787
+ });
2788
+ } catch (error) {
2789
+ throw normalizeDeviceError(error, "meet.setAudioProcessing");
2790
+ }
2791
+ },
2792
+ async setVideoEffect(effect) {
2793
+ videoEffect = effect;
2794
+ const target = room;
2795
+ if (target === null) return;
2796
+ await applyVideoEffect(target, effect);
2797
+ },
2798
+ supportsVideoEffects() {
2799
+ return videoEffectsSupported();
2800
+ },
2610
2801
  attach(identity, kind, element) {
2611
2802
  const target = requireRoom("attach");
2612
2803
  const source = sources?.[kind];
@@ -2829,6 +3020,11 @@ function listenOnlyNotice(media) {
2829
3020
  }
2830
3021
 
2831
3022
  // src/core/store.ts
3023
+ var DEFAULT_EFFECTS = {
3024
+ noiseSuppression: true,
3025
+ background: "none",
3026
+ backgroundImage: null
3027
+ };
2832
3028
  var NO_GRANTS = {
2833
3029
  role: "participant",
2834
3030
  canRecord: false,
@@ -2851,7 +3047,12 @@ var EMPTY = {
2851
3047
  recordingSource: "local",
2852
3048
  recordingId: null,
2853
3049
  chat: [],
3050
+ chatLoaded: false,
2854
3051
  reactions: [],
3052
+ spotlight: null,
3053
+ moderationNotice: null,
3054
+ actionNotice: null,
3055
+ effects: DEFAULT_EFFECTS,
2855
3056
  transcript: [],
2856
3057
  notes: [],
2857
3058
  names: {},
@@ -2890,6 +3091,7 @@ function createMeetStore(initial = {}) {
2890
3091
  setParticipants(participants) {
2891
3092
  const names = learnNames(state.names, participants);
2892
3093
  const hands = new Map(state.participants.map((p) => [p.identity, p.handRaised]));
3094
+ const handTimes = new Map(state.participants.map((p) => [p.identity, p.handRaisedAt ?? null]));
2893
3095
  const quality = new Map(state.participants.map((p) => [p.identity, p.quality]));
2894
3096
  commit({
2895
3097
  ...state,
@@ -2903,6 +3105,7 @@ function createMeetStore(initial = {}) {
2903
3105
  // name book. `roster-names.test.ts` fails if a token ever gets past it.
2904
3106
  displayName: displayNameFor(names, participant.identity, participant.displayName),
2905
3107
  handRaised: hands.get(participant.identity) ?? participant.handRaised,
3108
+ handRaisedAt: handTimes.get(participant.identity) ?? participant.handRaisedAt ?? null,
2906
3109
  quality: participant.quality === "unknown" ? quality.get(participant.identity) ?? "unknown" : participant.quality
2907
3110
  }))
2908
3111
  });
@@ -2925,11 +3128,17 @@ function createMeetStore(initial = {}) {
2925
3128
  )
2926
3129
  });
2927
3130
  },
2928
- setHand(identity, raised) {
3131
+ setHand(identity, raised, at) {
2929
3132
  commit({
2930
3133
  ...state,
2931
3134
  participants: state.participants.map(
2932
- (participant) => participant.identity === identity ? { ...participant, handRaised: raised } : participant
3135
+ (participant) => participant.identity === identity ? {
3136
+ ...participant,
3137
+ handRaised: raised,
3138
+ // A hand that is ALREADY up keeps its place in the queue when
3139
+ // it is re-announced (to a late joiner): the first time wins.
3140
+ handRaisedAt: raised ? participant.handRaised && participant.handRaisedAt != null ? participant.handRaisedAt : at ?? Date.now() : null
3141
+ } : participant
2933
3142
  )
2934
3143
  });
2935
3144
  },
@@ -2939,6 +3148,31 @@ function createMeetStore(initial = {}) {
2939
3148
  const names = learnName(state.names, message.identity, message.displayName);
2940
3149
  commit({ ...state, chat: chat.slice(-CHAT_LIMIT), names });
2941
3150
  },
3151
+ loadChat(messages) {
3152
+ const known = new Set(state.chat.map((message) => message.id));
3153
+ const merged = [...messages.filter((message) => !known.has(message.id)), ...state.chat].sort(
3154
+ (a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0
3155
+ );
3156
+ const names = learnNames(
3157
+ state.names,
3158
+ messages.map((message) => ({ identity: message.identity, displayName: message.displayName }))
3159
+ );
3160
+ commit({ ...state, chat: merged.slice(-CHAT_LIMIT), chatLoaded: true, names });
3161
+ },
3162
+ settleChat(localId, next) {
3163
+ const index = state.chat.findIndex((message) => message.id === localId);
3164
+ if (index === -1) return;
3165
+ const current = state.chat[index];
3166
+ let replacement;
3167
+ if ("failed" in next && typeof next.failed === "string" && !("id" in next)) {
3168
+ replacement = { ...current, failed: next.failed };
3169
+ } else {
3170
+ replacement = next;
3171
+ }
3172
+ const duplicate = replacement.id !== localId && state.chat.some((message) => message.id === replacement.id);
3173
+ const chat = duplicate ? state.chat.filter((message) => message.id !== localId) : state.chat.map((message, position) => position === index ? replacement : message);
3174
+ commit({ ...state, chat });
3175
+ },
2942
3176
  addReaction(reaction) {
2943
3177
  commit({ ...state, reactions: [...state.reactions, reaction] });
2944
3178
  },
@@ -3029,7 +3263,7 @@ function createMeetStore(initial = {}) {
3029
3263
  commit({ ...state, media: { ...state.media, [kind]: next } });
3030
3264
  },
3031
3265
  reset() {
3032
- commit({ ...EMPTY });
3266
+ commit({ ...EMPTY, effects: state.effects });
3033
3267
  }
3034
3268
  };
3035
3269
  }
@@ -3661,7 +3895,37 @@ function createRoomEngine(options) {
3661
3895
  const handlers = {
3662
3896
  participants(list) {
3663
3897
  const joinedAt = new Date(now()).toISOString();
3898
+ const before = new Set(store.snapshot().participants.map((p) => p.identity));
3664
3899
  store.setParticipants(list.map((entry) => projectParticipant(entry, joinedAt)));
3900
+ const snapshot = store.snapshot();
3901
+ const newcomer = snapshot.participants.some(
3902
+ (participant) => !participant.isLocal && !before.has(participant.identity)
3903
+ );
3904
+ const me = snapshot.participants.find((p) => p.identity === snapshot.localIdentity);
3905
+ if (newcomer && before.size > 0 && me?.handRaised === true) {
3906
+ void publish(DATA_TOPICS.hand, { raised: true, raisedAt: me.handRaisedAt ?? now() }).catch(
3907
+ () => void 0
3908
+ );
3909
+ }
3910
+ },
3911
+ roomMetadata(metadata) {
3912
+ let spotlight = null;
3913
+ if (metadata.length > 0) {
3914
+ try {
3915
+ const parsed = JSON.parse(metadata);
3916
+ if (typeof parsed === "object" && parsed !== null) {
3917
+ const value = parsed.spotlight;
3918
+ if (typeof value === "string" && value.length > 0) spotlight = asParticipantIdentity(value);
3919
+ }
3920
+ } catch {
3921
+ report(
3922
+ "warn",
3923
+ "meet: the room's metadata is not JSON, so no spotlight was read from it",
3924
+ "Only aidream writes room metadata; check services/meet/moderation.py."
3925
+ );
3926
+ }
3927
+ }
3928
+ if (store.snapshot().spotlight !== spotlight) store.patch({ spotlight });
3665
3929
  },
3666
3930
  activeSpeakers(identities) {
3667
3931
  store.setSpeaking(identities.map(asParticipantIdentity));
@@ -3765,7 +4029,11 @@ function createRoomEngine(options) {
3765
4029
  const sender = asParticipantIdentity(from ?? "");
3766
4030
  switch (topic) {
3767
4031
  case DATA_TOPICS.hand:
3768
- store.setHand(sender, envelope.raised === true);
4032
+ store.setHand(
4033
+ sender,
4034
+ envelope.raised === true,
4035
+ typeof envelope.raisedAt === "number" ? envelope.raisedAt : now()
4036
+ );
3769
4037
  return;
3770
4038
  case DATA_TOPICS.reaction:
3771
4039
  if (typeof envelope.emoji === "string") {
@@ -3779,12 +4047,14 @@ function createRoomEngine(options) {
3779
4047
  return;
3780
4048
  case DATA_TOPICS.chat:
3781
4049
  if (typeof envelope.text === "string") {
4050
+ const local = store.snapshot().localIdentity;
3782
4051
  store.addChat({
3783
4052
  id: envelope.id ?? `${sender}-${now()}`,
3784
4053
  identity: sender,
3785
4054
  displayName: envelope.name ?? sender,
3786
4055
  text: envelope.text,
3787
- at: envelope.at ?? new Date(now()).toISOString()
4056
+ at: envelope.at ?? new Date(now()).toISOString(),
4057
+ ...typeof envelope.to === "string" && local !== null ? { to: local } : {}
3788
4058
  });
3789
4059
  }
3790
4060
  return;
@@ -3830,6 +4100,24 @@ function createRoomEngine(options) {
3830
4100
  store.patch({ phase: "connected", failure: null });
3831
4101
  store.setLocalTrack("microphone", IDLE_TRACK);
3832
4102
  store.setLocalTrack("camera", IDLE_TRACK);
4103
+ const effects = options.effectsStorage?.load() ?? store.snapshot().effects;
4104
+ store.patch({ effects });
4105
+ if (!effects.noiseSuppression || effects.background !== "none") {
4106
+ try {
4107
+ await applyEffects(created, effects);
4108
+ } catch {
4109
+ }
4110
+ }
4111
+ for (const kind of ["audioinput", "videoinput", "audiooutput"]) {
4112
+ const chosen = options.resolveDevice?.(kind) ?? null;
4113
+ if (chosen === null || chosen === "default") continue;
4114
+ try {
4115
+ await created.switchDevice(kind, chosen);
4116
+ } catch (error) {
4117
+ const normalized = normalizeDeviceError(error, `meet.switchDevice(${kind})`);
4118
+ report("warn", normalized.message, normalized.remedy);
4119
+ }
4120
+ }
3833
4121
  if (request.mode === "video") {
3834
4122
  await enableTrack(created, "camera", true);
3835
4123
  }
@@ -3852,9 +4140,30 @@ function createRoomEngine(options) {
3852
4140
  return state;
3853
4141
  }
3854
4142
  }
3855
- async function publish(topic, body) {
4143
+ async function publish(topic, body, to) {
3856
4144
  if (driver === null) return;
3857
- await driver.publishData(topic, JSON.stringify(body));
4145
+ await driver.publishData(topic, JSON.stringify(body), to);
4146
+ }
4147
+ async function applyEffects(target, effects) {
4148
+ try {
4149
+ await target.setAudioProcessing?.({
4150
+ noiseSuppression: effects.noiseSuppression,
4151
+ echoCancellation: true,
4152
+ autoGainControl: true
4153
+ });
4154
+ } catch (error) {
4155
+ report("warn", `meet: noise suppression could not be applied: ${String(error)}`, "The microphone keeps working without it.");
4156
+ }
4157
+ if (target.setVideoEffect === void 0) return;
4158
+ const effect = effects.background === "blur" ? { kind: "blur" } : effects.background === "image" && effects.backgroundImage !== null ? { kind: "image", imageUrl: effects.backgroundImage } : { kind: "none" };
4159
+ if (effect.kind !== "none" && target.supportsVideoEffects?.() === false) return;
4160
+ try {
4161
+ await target.setVideoEffect(effect);
4162
+ } catch (error) {
4163
+ const normalized = normalizeMeetError(error, "meet.setVideoEffect");
4164
+ report("warn", normalized.message, normalized.remedy);
4165
+ throw normalized;
4166
+ }
3858
4167
  }
3859
4168
  return {
3860
4169
  join(request) {
@@ -3910,7 +4219,7 @@ function createRoomEngine(options) {
3910
4219
  async raiseHand(raised) {
3911
4220
  const local = store.snapshot().localIdentity;
3912
4221
  if (local !== null) store.setHand(local, raised);
3913
- await publish(DATA_TOPICS.hand, { raised });
4222
+ await publish(DATA_TOPICS.hand, { raised, raisedAt: now() });
3914
4223
  },
3915
4224
  async react(emoji) {
3916
4225
  const local = store.snapshot().localIdentity;
@@ -3920,32 +4229,41 @@ function createRoomEngine(options) {
3920
4229
  }
3921
4230
  await publish(DATA_TOPICS.reaction, { id, emoji });
3922
4231
  },
3923
- async sendChat(text3) {
4232
+ async sendPrivateChat(to, text3) {
3924
4233
  const trimmed = text3.trim();
3925
4234
  if (trimmed.length === 0) return;
3926
4235
  const local = store.snapshot().localIdentity;
3927
4236
  const id = `${local ?? "local"}-${now()}`;
3928
4237
  const at = new Date(now()).toISOString();
3929
4238
  if (local !== null) {
3930
- store.addChat({
3931
- id,
3932
- identity: local,
3933
- displayName: identity.displayName,
3934
- text: trimmed,
3935
- at
3936
- });
4239
+ store.addChat({ id, identity: local, displayName: identity.displayName, text: trimmed, at, to });
3937
4240
  }
3938
- await publish(DATA_TOPICS.chat, { id, text: trimmed, at, name: identity.displayName });
4241
+ await publish(
4242
+ DATA_TOPICS.chat,
4243
+ { id, text: trimmed, at, name: identity.displayName, to },
4244
+ [to]
4245
+ );
3939
4246
  },
3940
- async publishHostAction(action) {
3941
- if (!isHostRole(effectiveRole(store.snapshot()))) {
3942
- throw new MeetError(
3943
- "forbidden",
3944
- `meet.hostAction(${action.kind}): this participant is not a host`,
3945
- "Host powers come from the server-minted token metadata. Hide the control instead of rendering a dead button (no dead ends)."
3946
- );
4247
+ async setEffects(next) {
4248
+ const previous = store.snapshot().effects;
4249
+ const merged = { ...previous, ...next };
4250
+ store.patch({ effects: merged });
4251
+ options.effectsStorage?.save(merged);
4252
+ if (driver === null) return;
4253
+ try {
4254
+ await applyEffects(driver, merged);
4255
+ } catch {
4256
+ const reverted = { ...merged, background: previous.background, backgroundImage: previous.backgroundImage };
4257
+ store.patch({ effects: reverted });
4258
+ options.effectsStorage?.save(reverted);
3947
4259
  }
3948
- await publish(DATA_TOPICS.hostAction, { ...action });
4260
+ },
4261
+ supportsVideoEffects() {
4262
+ return driver?.supportsVideoEffects?.() ?? false;
4263
+ },
4264
+ async switchDevice(kind, deviceId) {
4265
+ if (driver === null) return;
4266
+ await driver.switchDevice(kind, deviceId);
3949
4267
  },
3950
4268
  pin(who) {
3951
4269
  store.patch({ pinned: who, layout: who === null ? "grid" : "speaker" });
@@ -3976,6 +4294,192 @@ function hostUserIds(participants) {
3976
4294
  return participants.filter((participant) => participant.role === "host" && participant.userId !== null).map((participant) => participant.userId);
3977
4295
  }
3978
4296
 
4297
+ // src/core/effects.ts
4298
+ function coerce(value) {
4299
+ if (typeof value !== "object" || value === null) return DEFAULT_EFFECTS;
4300
+ const row = value;
4301
+ const background = row["background"] === "blur" || row["background"] === "image" ? row["background"] : "none";
4302
+ const backgroundImage = typeof row["backgroundImage"] === "string" ? row["backgroundImage"] : null;
4303
+ return {
4304
+ noiseSuppression: row["noiseSuppression"] !== false,
4305
+ background: background === "image" && backgroundImage === null ? "none" : background,
4306
+ backgroundImage
4307
+ };
4308
+ }
4309
+ function createWebEffectsStorage(key = "mx.meet.effects") {
4310
+ let memory = DEFAULT_EFFECTS;
4311
+ const storage = globalThis.localStorage;
4312
+ return {
4313
+ load() {
4314
+ if (storage === void 0) return memory;
4315
+ try {
4316
+ const raw = storage.getItem(key);
4317
+ return raw === null ? memory : coerce(JSON.parse(raw));
4318
+ } catch {
4319
+ return memory;
4320
+ }
4321
+ },
4322
+ save(effects) {
4323
+ memory = effects;
4324
+ try {
4325
+ storage?.setItem(key, JSON.stringify(effects));
4326
+ } catch {
4327
+ }
4328
+ }
4329
+ };
4330
+ }
4331
+
4332
+ // src/core/in-room.ts
4333
+ var MODERATION_ACTIONS = [
4334
+ "mute",
4335
+ "stop_video",
4336
+ "stop_screen",
4337
+ "mute_all",
4338
+ "ask_unmute",
4339
+ "ask_all_unmute",
4340
+ "spotlight",
4341
+ "lower_hand",
4342
+ "lower_all_hands",
4343
+ "make_cohost",
4344
+ "revoke_cohost",
4345
+ "transfer_host",
4346
+ "rename",
4347
+ "remove",
4348
+ "report"
4349
+ ];
4350
+ function createInRoomClient(options) {
4351
+ const { api, store } = options;
4352
+ const now = options.now ?? (() => Date.now());
4353
+ function meetingId(operation) {
4354
+ const meeting = store.snapshot().meeting;
4355
+ if (meeting === null) {
4356
+ throw new MeetError(
4357
+ "not-found",
4358
+ `${operation}: this room has no meeting record`,
4359
+ "Moderation and saved chat belong to a meeting. An ad-hoc call has neither."
4360
+ );
4361
+ }
4362
+ return meeting.id;
4363
+ }
4364
+ function headers() {
4365
+ const token = options.resolveRoomToken();
4366
+ return token === null ? void 0 : { [ROOM_TOKEN_HEADER]: token };
4367
+ }
4368
+ async function refusal2(response, operation, remedy) {
4369
+ let body = null;
4370
+ try {
4371
+ body = await response.json();
4372
+ } catch {
4373
+ body = null;
4374
+ }
4375
+ const said = readServerRefusal(body);
4376
+ return new MeetError(
4377
+ response.status === 403 || response.status === 401 ? "forbidden" : response.status === 404 ? "not-found" : response.status === 409 ? "conflict" : "transport",
4378
+ said?.reason ?? `${operation}: the server answered ${response.status}`,
4379
+ said?.remedy !== void 0 && said.remedy.length > 0 ? said.remedy : remedy
4380
+ );
4381
+ }
4382
+ return {
4383
+ async moderate(request) {
4384
+ const operation = `meet.moderate(${request.action})`;
4385
+ const hdrs = headers();
4386
+ const response = await api.request({
4387
+ path: MEET_ROUTES.moderate,
4388
+ method: "POST",
4389
+ operation,
4390
+ body: {
4391
+ meeting_id: meetingId(operation),
4392
+ action: request.action,
4393
+ ...request.identity != null ? { identity: request.identity } : {},
4394
+ ...request.name !== void 0 ? { name: request.name } : {},
4395
+ ...request.reason !== void 0 ? { reason: request.reason } : {}
4396
+ },
4397
+ ...hdrs !== void 0 ? { headers: hdrs } : {}
4398
+ });
4399
+ if (!response.ok) {
4400
+ throw await refusal2(
4401
+ response,
4402
+ operation,
4403
+ "Nothing was changed. The server decides host powers from the meeting itself."
4404
+ );
4405
+ }
4406
+ const payload = await response.json();
4407
+ return {
4408
+ action: request.action,
4409
+ affected: Array.isArray(payload["affected"]) ? payload["affected"].filter((value) => typeof value === "string") : [],
4410
+ message: typeof payload["message"] === "string" ? payload["message"] : "Done."
4411
+ };
4412
+ },
4413
+ async sendChat(text3) {
4414
+ const body = text3.trim();
4415
+ if (body.length === 0) return;
4416
+ const operation = "meet.sendChat";
4417
+ const id = meetingId(operation);
4418
+ const snapshot = store.snapshot();
4419
+ const me = snapshot.participants.find((p) => p.identity === snapshot.localIdentity);
4420
+ const localId = `pending-${now()}`;
4421
+ const pending = {
4422
+ id: localId,
4423
+ identity: snapshot.localIdentity ?? "local",
4424
+ displayName: me?.displayName ?? "You",
4425
+ text: body,
4426
+ at: new Date(now()).toISOString(),
4427
+ saved: false
4428
+ };
4429
+ store.addChat(pending);
4430
+ const hdrs = headers();
4431
+ let response;
4432
+ try {
4433
+ response = await api.request({
4434
+ path: MEET_ROUTES.chat,
4435
+ method: "POST",
4436
+ operation,
4437
+ body: { meeting_id: id, body },
4438
+ ...hdrs !== void 0 ? { headers: hdrs } : {}
4439
+ });
4440
+ } catch (error) {
4441
+ store.settleChat(localId, { failed: "Not sent \u2014 the server could not be reached." });
4442
+ throw error;
4443
+ }
4444
+ if (!response.ok) {
4445
+ const error = await refusal2(response, operation, "Your message was not sent.");
4446
+ store.settleChat(localId, { failed: error.message });
4447
+ throw error;
4448
+ }
4449
+ const saved = chatFromRow(await response.json());
4450
+ if (saved === null) {
4451
+ store.settleChat(localId, { failed: "The server's answer could not be read." });
4452
+ return;
4453
+ }
4454
+ store.settleChat(localId, saved);
4455
+ },
4456
+ async loadChat() {
4457
+ const operation = "meet.loadChat";
4458
+ const id = meetingId(operation);
4459
+ const hdrs = headers();
4460
+ const response = await api.request({
4461
+ path: `${MEET_ROUTES.chat}?meeting_id=${encodeURIComponent(id)}`,
4462
+ method: "GET",
4463
+ operation,
4464
+ ...hdrs !== void 0 ? { headers: hdrs } : {}
4465
+ });
4466
+ if (!response.ok) {
4467
+ throw await refusal2(
4468
+ response,
4469
+ operation,
4470
+ "Messages sent before you joined could not be read. New messages still arrive."
4471
+ );
4472
+ }
4473
+ const payload = await response.json();
4474
+ const rows = Array.isArray(payload.messages) ? payload.messages : [];
4475
+ const messages = rows.map(
4476
+ (row) => typeof row === "object" && row !== null ? chatFromRow(row) : null
4477
+ ).filter((message) => message !== null);
4478
+ store.loadChat(messages);
4479
+ }
4480
+ };
4481
+ }
4482
+
3979
4483
  // src/core/record.ts
3980
4484
  import { formatDurationMs } from "@ai-matrx/kit/format";
3981
4485
  function activeWrapUpSummary(notes) {
@@ -4312,6 +4816,7 @@ export {
4312
4816
  CALL_INVITE_VERSION,
4313
4817
  CONSENT_NOTICE_ATTRIBUTE,
4314
4818
  DATA_TOPICS,
4819
+ DEFAULT_EFFECTS,
4315
4820
  DEFAULT_PAGE_SIZE,
4316
4821
  END_MEETING_NOT_SERVED,
4317
4822
  FILE_SESSION_PATH,
@@ -4329,6 +4834,7 @@ export {
4329
4834
  MEET_EVENTS,
4330
4835
  MEET_ROUTES,
4331
4836
  MEET_SCHEMA,
4837
+ MODERATION_ACTIONS,
4332
4838
  MeetError,
4333
4839
  NOTE_TAKER_IDENTITY_PREFIX,
4334
4840
  NOTE_TAKER_METADATA_KEY,
@@ -4368,6 +4874,7 @@ export {
4368
4874
  createCallCenter,
4369
4875
  createCallInviteHandler,
4370
4876
  createDeviceManager,
4877
+ createInRoomClient,
4371
4878
  createLiveKitDriver,
4372
4879
  createMeetAi,
4373
4880
  createMeetApi,
@@ -4380,6 +4887,7 @@ export {
4380
4887
  createRoomEngine,
4381
4888
  createTokenClient,
4382
4889
  createWebDeviceStorage,
4890
+ createWebEffectsStorage,
4383
4891
  createWebRoomTokenStorage,
4384
4892
  displayNameFor,
4385
4893
  fileDownloadPath,