Skip to content
16 changes: 12 additions & 4 deletions packages/client/src/Call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,16 +706,20 @@ export class Call {
* @param params.ring if set to true, a `call.ring` event will be sent to the call members.
* @param params.notify if set to true, a `call.notification` event will be sent to the call members.
* @param params.members_limit the total number of members to return as part of the response.
* @param params.video if set to true, in a ringing scenario, mobile SDKs will show "incoming video call", audio only otherwise.
* @param params.target_member_ids the list of members to ring. Limited to 100 members per request.
*/
get = async (params?: {
ring?: boolean;
notify?: boolean;
members_limit?: number;
}) => {
video?: boolean;
target_member_ids?: string[];
}): Promise<GetCallResponse> => {
await this.setup();
const response = await this.streamClient.get<GetCallResponse>(
this.streamClientBasePath,
params,
{ ...params, target_member_ids: params?.target_member_ids?.join(',') },
);

this.state.updateFromCallResponse(response.call);
Expand Down Expand Up @@ -790,9 +794,13 @@ export class Call {
/**
* A shortcut for {@link Call.get} with `ring` parameter set to `true`.
* Will send a `call.ring` event to the call members.
*
* @param params.member_ids the list of members to ring. Limited to 100 members per request.
*/
ring = async (): Promise<GetCallResponse> => {
return await this.get({ ring: true });
ring = async (params: {
target_member_ids?: string[];
}): Promise<GetCallResponse> => {
return await this.get({ ...params, ring: true });
};

/**
Expand Down
8 changes: 1 addition & 7 deletions packages/client/src/StreamVideoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,13 +207,6 @@ export class StreamVideoClient {
this.eventHandlersToUnregister.push(
this.on('call.ring', async (event) => {
const { call, members } = event;
if (this.state.connectedUser?.id === call.created_by.id) {
this.logger(
'debug',
'Received `call.ring` sent by the current user so ignoring the event',
);
return;
}
// if `call.created` was received before `call.ring`.
// the client already has the call instance and we just need to update the state
const theCall = this.writeableStateStore.findCall(call.type, call.id);
Expand Down Expand Up @@ -360,6 +353,7 @@ export class StreamVideoClient {
*
* @param type the type of the call.
* @param id the id of the call.
* @param options additional options for the call.
*/
call = (
type: string,
Expand Down
24 changes: 5 additions & 19 deletions packages/client/src/__tests__/StreamVideoClient.api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,20 @@ const secret = process.env.STREAM_SECRET!;

const serverClient = new StreamClient(apiKey, secret);

const tokenProvider = (userId: string) => {
return async () => {
return new Promise<string>((resolve) => {
setTimeout(() => {
const token = serverClient.createToken(
userId,
undefined,
Math.round(Date.now() / 1000 - 10),
);
resolve(token);
}, 100);
});
};
};

describe('StreamVideoClient - coordinator API', () => {
let client: StreamVideoClient;
const user = {
id: 'sara',
};

beforeAll(() => {
const user = { id: 'sara' };
client = new StreamVideoClient(apiKey, {
// tests run in node, so we have to fake being in browser env
browser: true,
timeout: 15000,
});
client.connectUser(user, tokenProvider(user.id));
client.connectUser(
user,
serverClient.generateUserToken({ user_id: user.id }),
);
});

it('query calls', { retry: 3, timeout: 20000 }, async () => {
Expand Down
185 changes: 185 additions & 0 deletions packages/client/src/__tests__/StreamVideoClient.ringing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import 'dotenv/config';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { StreamVideoClient } from '../StreamVideoClient';
import { StreamClient } from '@stream-io/node-sdk';
import { AllClientEvents } from '../coordinator/connection/types';
import { RxUtils } from '../store';
import { Call } from '../Call';

const apiKey = process.env.STREAM_API_KEY!;
const secret = process.env.STREAM_SECRET!;

describe('StreamVideoClient Ringing', () => {
const serverClient = new StreamClient(apiKey, secret);

let oliverClient: StreamVideoClient;
let sachaClient: StreamVideoClient;
let marceloClient: StreamVideoClient;

beforeAll(async () => {
const makeClient = async (userId: string) => {
const client = new StreamVideoClient(apiKey, {
// tests run in node, so we have to fake being in browser env
browser: true,
timeout: 15000,
});
await client.connectUser(
{ id: userId },
serverClient.generateUserToken({ user_id: userId }),
);
return client;
};
[oliverClient, sachaClient, marceloClient] = await Promise.all([
makeClient('oliver'),
makeClient('sacha'),
makeClient('marcelo'),
]);
});

afterAll(async () => {
await Promise.all([
oliverClient.disconnectUser(),
sachaClient.disconnectUser(),
marceloClient.disconnectUser(),
]);
});

describe('standard ringing', async () => {
it.each(['oliver', 'sara'])(
'server-side: %s should ring all members, call creator should get call.ring event if present in members',
async (creatorId: string) => {
const oliverRing = expectEvent(oliverClient, 'call.ring');
const sachaRing = expectEvent(sachaClient, 'call.ring');
const marceloRing = expectEvent(marceloClient, 'call.ring');

const call = serverClient.video.call('default', crypto.randomUUID());
await call.create({
ring: true,
data: {
created_by_id: creatorId,
members: [
{ user_id: 'oliver' },
{ user_id: 'sacha' },
{ user_id: 'marcelo' },
],
},
});

const [oliverRingEvent, sachaRingEvent, marceloRingEvent] =
await Promise.all([oliverRing, sachaRing, marceloRing]);

expect(oliverRingEvent.call.cid).toBe(call.cid);
expect(sachaRingEvent.call.cid).toBe(call.cid);
expect(marceloRingEvent.call.cid).toBe(call.cid);

const oliverCall = await expectCall(oliverClient, call.cid);
const sachaCall = await expectCall(sachaClient, call.cid);
const marceloCall = await expectCall(marceloClient, call.cid);
expect(oliverCall).toBeDefined();
expect(sachaCall).toBeDefined();
expect(marceloCall).toBeDefined();
expect(oliverCall.ringing).toBe(true);
expect(sachaCall.ringing).toBe(true);
expect(marceloCall.ringing).toBe(true);
},
);
});

describe('ringing individual members', () => {
it('should ring individual members', async () => {
const oliverCall = oliverClient.call('default', crypto.randomUUID());
await oliverCall.create({
ring: false, // don't ring all members by default
data: {
members: [
{ user_id: 'oliver' },
{ user_id: 'sacha' },
{ user_id: 'marcelo' },
],
},
});

// no one should get a ring event yet
const oliverRing = expectEvent(oliverClient, 'call.ring', 500);
const sachaRing = expectEvent(sachaClient, 'call.ring', 500);
const marceloRing = expectEvent(marceloClient, 'call.ring', 500);
await expect(
Promise.all([oliverRing, sachaRing, marceloRing]),
).rejects.toThrow();

// oliver is calling sacha. only sacha should get a ring event
const sachaIndividualRing = expectEvent(sachaClient, 'call.ring');
const marceloIndividualRing = expectEvent(marceloClient, 'call.ring');
await oliverCall.ring({ target_member_ids: ['sacha'] });
await expect(sachaIndividualRing).resolves.toHaveProperty(
'call.cid',
oliverCall.cid,
);
await expect(marceloIndividualRing).rejects.toThrow();

const sachaCall = await expectCall(sachaClient, oliverCall.cid);
expect(sachaCall).toBeDefined();

// sacha is calling marcelo. only marcelo should get a ring event
const oliverIndividualRing = expectEvent(oliverClient, 'call.ring');
const marceloIndividualRing2 = expectEvent(marceloClient, 'call.ring');
await sachaCall.ring({ target_member_ids: ['marcelo'] });
await expect(marceloIndividualRing2).resolves.toHaveProperty(
'call.cid',
sachaCall.cid,
);
await expect(oliverIndividualRing).rejects.toThrow();

const marceloCall = await expectCall(marceloClient, sachaCall.cid);
expect(marceloCall).toBeDefined();
});
});
});

const expectEvent = async <E extends keyof AllClientEvents>(
client: StreamVideoClient,
eventName: E,
timeout: number = 2500,
): Promise<AllClientEvents[E]> => {
return new Promise<AllClientEvents[E]>((resolve, reject) => {
let timeoutId: NodeJS.Timeout | undefined = undefined;
const off = client.on(eventName, (e) => {
off();
clearTimeout(timeoutId);
resolve(e);
});
timeoutId = setTimeout(() => {
off();
reject(
new Error(
`Timeout waiting for event: ${eventName}, user_id: ${client.state.connectedUser?.id}`,
),
);
}, timeout);
});
};

const expectCall = async (
client: StreamVideoClient,
cid: string,
timeout: number = 2500,
) => {
return new Promise<Call>((resolve, reject) => {
let timeoutId: NodeJS.Timeout | undefined = undefined;
const off = RxUtils.createSubscription(client.state.calls$, (calls) => {
const call = calls.find((c) => c.cid === cid);
if (call) {
clearTimeout(timeoutId);
resolve(call);
}
});
timeoutId = setTimeout(() => {
off();
reject(
new Error(
`Timeout waiting for call: ${cid}, user_id: ${client.state.connectedUser?.id}`,
),
);
}, timeout);
});
};
6 changes: 6 additions & 0 deletions packages/client/src/gen/coordinator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2118,6 +2118,12 @@ export interface CallSessionResponse {
* @memberof CallSessionResponse
*/
anonymous_participant_count: number;
/**
*
* @type {string}
* @memberof CallSessionResponse
*/
created_at: string;
/**
*
* @type {string}
Expand Down
Loading