Skip to content

feat: ring individual members #1755

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
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