Repository: livekit/client-sdk-js
Pull request #1517: Add ParticipantActive event to signal data message readiness
Author: login: lukasIO
Created: 2025-05-14T13:01:55Z | Merged: 2025-05-15T15:50:04Z



Diff:
diff --git a/.changeset/nice-moons-clap.md b/.changeset/nice-moons-clap.md
new file mode 100644
index 0000000000..1f1478fab6
--- /dev/null
+++ b/.changeset/nice-moons-clap.md
@@ -0,0 +1,5 @@
+---
+"livekit-client": minor
+---
+
+Add ParticipantActive event to signal data message readiness
diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts
index 9a3043fc51..e4c211bfda 100644
--- a/examples/demo/demo.ts
+++ b/examples/demo/demo.ts
@@ -179,6 +179,10 @@ const appActions = {
           await room.engine.getConnectedServerAddress(),
         );
       })
+      .on(RoomEvent.ParticipantActive, async (participant) => {
+        appendLog(`participant ${participant.identity} is active and ready to receive messages`);
+        await sendGreetingTo(participant);
+      })
       .on(RoomEvent.ActiveDeviceChanged, handleActiveDeviceChanged)
       .on(RoomEvent.LocalTrackPublished, (pub) => {
         const track = pub.track;
@@ -652,8 +656,6 @@ async function participantConnected(participant: Participant) {
     .on(ParticipantEvent.ConnectionQualityChanged, () => {
       renderParticipant(participant);
     });
-
-  await sendGreetingTo(participant);
 }
 
 function participantDisconnected(participant: RemoteParticipant) {
diff --git a/src/room/Room.ts b/src/room/Room.ts
index b69543e04c..2d039df3b1 100644
--- a/src/room/Room.ts
+++ b/src/room/Room.ts
@@ -1644,6 +1644,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
       participant.unpublishTrack(publication.trackSid, true);
     });
     this.emit(RoomEvent.ParticipantDisconnected, participant);
+    participant.setDisconnected();
     this.localParticipant?.handleParticipantDisconnected(participant.identity);
   }
 
@@ -2240,6 +2241,9 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
           status,
           participant,
         );
+      })
+      .on(ParticipantEvent.Active, () => {
+        this.emitWhenConnected(RoomEvent.ParticipantActive, participant);
       });
 
     // update info at the end after callbacks have been set up
@@ -2714,4 +2718,5 @@ export type RoomEventCallbacks = {
   chatMessage: (message: ChatMessage, participant?: RemoteParticipant | LocalParticipant) => void;
   localTrackSubscribed: (publication: LocalTrackPublication, participant: LocalParticipant) => void;
   metricsReceived: (metrics: MetricsBatch, participant?: Participant) => void;
+  participantActive: (participant: Participant) => void;
 };
diff --git a/src/room/events.ts b/src/room/events.ts
index 84a339eee5..b9bee902f9 100644
--- a/src/room/events.ts
+++ b/src/room/events.ts
@@ -203,6 +203,13 @@ export enum RoomEvent {
    */
   ParticipantAttributesChanged = 'participantAttributesChanged',
 
+  /**
+   * Emitted when the participant's state changes to ACTIVE and is ready to send/receive data messages
+   *
+   * args: (participant: [[Participant]])
+   */
+  ParticipantActive = 'participantActive',
+
   /**
    * Room metadata is a simple way for app-specific state to be pushed to
    * all users.
@@ -540,6 +547,11 @@ export enum ParticipantEvent {
 
   /** only emitted on local participant */
   ChatMessage = 'chatMessage',
+
+  /**
+   * Emitted when the participant's state changes to ACTIVE and is ready to send/receive data messages
+   */
+  Active = 'active',
 }
 
 /** @internal */
diff --git a/src/room/participant/Participant.ts b/src/room/participant/Participant.ts
index b0f7794305..1d641c549e 100644
--- a/src/room/participant/Participant.ts
+++ b/src/room/participant/Participant.ts
@@ -1,6 +1,7 @@
 import {
   DataPacket_Kind,
   ParticipantInfo,
+  ParticipantInfo_State,
   ParticipantInfo_Kind as ParticipantKind,
   ParticipantPermission,
   ConnectionQuality as ProtoQuality,
@@ -18,7 +19,7 @@ import { Track } from '../track/Track';
 import type { TrackPublication } from '../track/TrackPublication';
 import { diffAttributes } from '../track/utils';
 import type { ChatMessage, LoggerOptions, TranscriptionSegment } from '../types';
-import { isAudioTrack } from '../utils';
+import { Future, isAudioTrack } from '../utils';
 
 export enum ConnectionQuality {
   Excellent = 'excellent',
@@ -93,6 +94,8 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
 
   protected loggerOptions?: LoggerOptions;
 
+  protected activeFuture?: Future<void>;
+
   protected get logContext() {
     return {
       ...this.loggerOptions?.loggerContextCb?.(),
@@ -110,6 +113,10 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
     return this.permissions?.agent || this.kind === ParticipantKind.AGENT;
   }
 
+  get isActive() {
+    return this.participantInfo?.state === ParticipantInfo_State.ACTIVE;
+  }
+
   get kind() {
     return this._kind;
   }
@@ -173,6 +180,28 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
     }
   }
 
+  /**
+   * Waits until the participant is active and ready to receive data messages
+   * @returns a promise that resolves when the participant is active
+   */
+  waitUntilActive(): Promise<void> {
+    if (this.isActive) {
+      return Promise.resolve();
+    }
+
+    if (this.activeFuture) {
+      return this.activeFuture.promise;
+    }
+
+    this.activeFuture = new Future<void>();
+
+    this.once(ParticipantEvent.Active, () => {
+      this.activeFuture?.resolve?.();
+      this.activeFuture = undefined;
+    });
+    return this.activeFuture.promise;
+  }
+
   get connectionQuality(): ConnectionQuality {
     return this._connectionQuality;
   }
@@ -224,12 +253,17 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
     this._setName(info.name);
     this._setMetadata(info.metadata);
     this._setAttributes(info.attributes);
+    if (
+      info.state === ParticipantInfo_State.ACTIVE &&
+      this.participantInfo?.state !== ParticipantInfo_State.ACTIVE
+    ) {
+      this.emit(ParticipantEvent.Active);
+    }
     if (info.permission) {
       this.setPermissions(info.permission);
     }
     // set this last so setMetadata can detect changes
     this.participantInfo = info;
-    this.log.trace('update participant info', { ...this.logContext, info });
     return true;
   }
 
@@ -310,6 +344,16 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
     }
   }
 
+  /**
+   * @internal
+   */
+  setDisconnected() {
+    if (this.activeFuture) {
+      this.activeFuture.reject?.(new Error('Participant disconnected'));
+      this.activeFuture = undefined;
+    }
+  }
+
   /**
    * @internal
    */
@@ -387,4 +431,5 @@ export type ParticipantEventCallbacks = {
   attributesChanged: (changedAttributes: Record<string, string>) => void;
   localTrackSubscribed: (trackPublication: LocalTrackPublication) => void;
   chatMessage: (msg: ChatMessage) => void;
+  active: () => void;
 };


Reviews:
user: login: boks1971
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T13:05:53Z
user: login: lukasIO
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T13:11:07Z
user: login: lukasIO
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T13:12:10Z
user: login: lukasIO
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T13:14:59Z
user: login: boks1971
body: lgtm!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T13:17:19Z
user: login: bcherry
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T21:57:58Z

Review comments:
diff_hunk: @@ -203,6 +203,13 @@ export enum RoomEvent {
    */
   ParticipantAttributesChanged = 'participantAttributesChanged',
 
+  /**
+   * Emitted when the participant's state changes to active and is ready to send/receive data messages
path: src/room/events.ts
user: login: boks1971
body: maybe upper case `ACTIVE` to indicate that it is a state value? Not a strong suggestion though.
created_at: 2025-05-14T13:04:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -224,12 +229,14 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
     this._setName(info.name);
     this._setMetadata(info.metadata);
     this._setAttributes(info.attributes);
+    if (info.state === ParticipantInfo_State.ACTIVE) {
+      this.emit(ParticipantEvent.Active);
+    }
     if (info.permission) {
       this.setPermissions(info.permission);
     }
     // set this last so setMetadata can detect changes
     this.participantInfo = info;
-    this.log.trace('update participant info', { ...this.logContext, info });
path: src/room/participant/Participant.ts
user: login: boks1971
body: intentional remove?
created_at: 2025-05-14T13:05:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -224,12 +229,14 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
     this._setName(info.name);
     this._setMetadata(info.metadata);
     this._setAttributes(info.attributes);
+    if (info.state === ParticipantInfo_State.ACTIVE) {
path: src/room/participant/Participant.ts
user: login: boks1971
body: I think this probably needs some prev state? This should emit on transition from not-ACTIVE -> ACTIVE only right?
created_at: 2025-05-14T13:05:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -224,12 +229,14 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
     this._setName(info.name);
     this._setMetadata(info.metadata);
     this._setAttributes(info.attributes);
+    if (info.state === ParticipantInfo_State.ACTIVE) {
+      this.emit(ParticipantEvent.Active);
+    }
     if (info.permission) {
       this.setPermissions(info.permission);
     }
     // set this last so setMetadata can detect changes
     this.participantInfo = info;
-    this.log.trace('update participant info', { ...this.logContext, info });
path: src/room/participant/Participant.ts
user: login: lukasIO
body: yeah, we added this way back to debug some unexpected participant state updates, but haven't needed it since
created_at: 2025-05-14T13:11:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -224,12 +229,14 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
     this._setName(info.name);
     this._setMetadata(info.metadata);
     this._setAttributes(info.attributes);
+    if (info.state === ParticipantInfo_State.ACTIVE) {
path: src/room/participant/Participant.ts
user: login: lukasIO
body: uff, of course, thanks!
created_at: 2025-05-14T13:12:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -224,12 +229,14 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
     this._setName(info.name);
     this._setMetadata(info.metadata);
     this._setAttributes(info.attributes);
+    if (info.state === ParticipantInfo_State.ACTIVE) {
path: src/room/participant/Participant.ts
user: login: lukasIO
body: addressed in [df8ef65](https://github.com/livekit/client-sdk-js/pull/1517/commits/df8ef6513b60fe71c2d279b4c0d29046577df6aa)
created_at: 2025-05-14T13:14:59Z
author_association: CONTRIBUTOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-05-14T13:01:59Z
body: ###  🦋  Changeset detected

Latest commit: eee3671e7106563fb1d634c861f06cddcd23a7c1

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name           | Type  |
| -------------- | ----- |
| livekit-client | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/livekit/client-sdk-js/new/lukas/participant-active?filename=.changeset/thirty-readers-design.md&value=---%0A%22livekit-client%22%3A%20patch%0A---%0A%0AAdd%20ParticipantActive%20event%20to%20signal%20data%20message%20readiness%0A)


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-14T13:03:25Z
body: ## size-limit report 📦 
| Path                        | Size                 |
| --------------------------- | -------------------- |
| dist/livekit-client.esm.mjs | 92.04 KB (+0.19% 🔺) |
| dist/livekit-client.umd.js  | 99.53 KB (+0.19% 🔺) |
author_association: CONTRIBUTOR
user: login: lukasIO
created_at: 2025-05-14T13:31:42Z
body: thanks for the review and sorry that I just pushed another commit with a little helper. 

I realised most people might want a `participant.waitUntilActive()` helper to not have to write the boiler plate each time with the event listener. 
Was slightly more involved than expected as it needs to reject the promise on participant disconnects
author_association: CONTRIBUTOR
user: login: boks1971
created_at: 2025-05-14T13:33:54Z
body: > thanks for the review and sorry that I just pushed another commit with a little helper.
> 
> I realised most people might want a `participant.waitUntilActive()` helper to not have to write the boiler plate each time with the event listener. Was slightly more involved than expected as it needs to reject the promise on participant disconnects

Nice!!!
author_association: CONTRIBUTOR