Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/@webex/plugin-meetings/src/member/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,5 +439,31 @@ const MemberUtil = {

return participantUrl;
},

/**
* Collects all CSIs reported for a participant across all their devices,
* looking at both the top-level `csis` array and any `mediaSessions[].csi` values.
*
* @param {Object} participant - The locus participant object.
* @returns {Array<number>} unique CSIs for this participant
*/
extractCsis: (participant: Participant): number[] => {
const csis = new Set<number>();

participant?.devices?.forEach((device: any) => {
device?.csis?.forEach((csi: number) => {
if (typeof csi === 'number') {
csis.add(csi);
}
});
device?.mediaSessions?.forEach((mediaSession: any) => {
if (typeof mediaSession?.csi === 'number') {
csis.add(mediaSession.csi);
}
});
});

return Array.from(csis);
},
};
export default MemberUtil;
54 changes: 51 additions & 3 deletions packages/@webex/plugin-meetings/src/members/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*!
* Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
*/
import {get, isEmpty, set} from 'lodash';
import {get, isEmpty, isEqual, set} from 'lodash';
// @ts-ignore
import {StatelessWebexPlugin} from '@webex/webex-core';

Expand All @@ -15,6 +15,7 @@ import {
} from '../constants';
import Trigger from '../common/events/trigger-proxy';
import Member from '../member';
import MemberUtil from '../member/util';
import LoggerProxy from '../common/logs/logger-proxy';
import ParameterError from '../common/errors/parameter';
import {
Expand Down Expand Up @@ -99,6 +100,15 @@ export default class Members extends StatelessWebexPlugin {
selfId: any;
type: any;

/**
* Map of CSI -> memberId that previously used the CSI in a Locus update.
* Keyed by CSI so findMemberByCsi can resolve it in O(1).
* Kept here (rather than on each Member) so it survives members being removed
* and re-added (e.g. when entering/leaving a breakout session).
* @private
*/
private memberIdByHistoryCsi: Map<number, string> = new Map();

namespace = MEETINGS;

/**
Expand Down Expand Up @@ -569,6 +579,15 @@ export default class Members extends StatelessWebexPlugin {
*/
private removeMembers(removedMembers: Array<string>) {
removedMembers.forEach((memberId) => {
// capture CSIs before removal so findMemberByCsi can still resolve them
// if the member is later re-added (e.g. when leaving a breakout session)
// with a participant payload that no longer contains the original CSIs
const existingMember = this.membersCollection.get(memberId);
if (existingMember) {
MemberUtil.extractCsis(existingMember.participant).forEach((csi) =>
this.memberIdByHistoryCsi.set(csi, memberId)
);
}
this.membersCollection.remove(memberId);
});
}
Expand Down Expand Up @@ -598,6 +617,19 @@ export default class Members extends StatelessWebexPlugin {
set(member, prop, existingValue);
}
});

// remember CSIs the member used previously so findMemberByCsi can still
// resolve a CSI even after participant.devices[].csis no longer contains it.
// Skip re-extracting when devices haven't changed - existing history already covers them.
const devicesUnchanged = isEqual(
existingMember.participant?.devices,
member.participant?.devices
);
if (!devicesUnchanged) {
MemberUtil.extractCsis(existingMember.participant).forEach((csi) =>
this.memberIdByHistoryCsi.set(csi, member.id)
Comment thread
WeijuanShao marked this conversation as resolved.
);
}
}
}
this.membersCollection.set(member.id, member);
Expand Down Expand Up @@ -1161,18 +1193,34 @@ export default class Members extends StatelessWebexPlugin {
);
}

/** Finds a member that has any device with a csi matching provided value
/** Finds a member that has any device with a csi matching provided value.
* Falls back to the `memberIdByHistoryCsi` map so that a CSI a member used in
* a previous Locus update can still be resolved even if it is no longer
* present in `participant.devices[].csis`.
*
* @param {number} csi
* @returns {Member}
*/
findMemberByCsi(csi) {
return Object.values(this.membersCollection.getAll()).find((member) =>
const members = Object.values(this.membersCollection.getAll());

const currentMatch = members.find((member) =>
// @ts-ignore
member.participant?.devices?.find((device) =>
device.csis?.find((memberCsi) => memberCsi === csi)
)
);

if (currentMatch) {
return currentMatch;
}

const historyMemberId = this.memberIdByHistoryCsi.get(csi);
if (historyMemberId) {
return this.membersCollection.get(historyMemberId);
}

return undefined;
}

/**
Expand Down
65 changes: 65 additions & 0 deletions packages/@webex/plugin-meetings/test/unit/spec/member/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -699,3 +699,68 @@ describe('extractMediaStatus', () => {
assert.deepEqual(mediaStatus, {audio: 'RECVONLY', video: 'SENDRECV'});
});
});

describe('MemberUtil.extractCsis', () => {
it('returns an empty array when participant is undefined', () => {
assert.deepEqual(MemberUtil.extractCsis(undefined), []);
});

it('returns an empty array when participant has no devices', () => {
assert.deepEqual(MemberUtil.extractCsis({}), []);
});

it('returns an empty array when devices have no csis or mediaSessions', () => {
const participant = {devices: [{}, {csis: [], mediaSessions: []}]};

assert.deepEqual(MemberUtil.extractCsis(participant), []);
});

it('collects CSIs from device.csis arrays', () => {
const participant = {
devices: [{csis: [1, 2]}, {csis: [3]}],
};

assert.deepEqual(MemberUtil.extractCsis(participant), [1, 2, 3]);
});

it('collects CSIs from device.mediaSessions[].csi values', () => {
const participant = {
devices: [
{mediaSessions: [{csi: 10}, {csi: 11}]},
{mediaSessions: [{csi: 12}]},
],
};

assert.deepEqual(MemberUtil.extractCsis(participant), [10, 11, 12]);
});

it('merges CSIs from both csis and mediaSessions, deduplicated', () => {
const participant = {
devices: [
{csis: [1, 2], mediaSessions: [{csi: 2}, {csi: 3}]},
{csis: [3, 4], mediaSessions: [{csi: 5}]},
],
};

assert.deepEqual(MemberUtil.extractCsis(participant), [1, 2, 3, 4, 5]);
});

it('ignores non-numeric csi values', () => {
const participant = {
devices: [
{csis: [1, '2', null, undefined]},
{mediaSessions: [{csi: 'abc'}, {csi: null}, {csi: 3}, {}]},
],
};

assert.deepEqual(MemberUtil.extractCsis(participant), [1, 3]);
});

it('skips falsy devices and mediaSessions entries', () => {
const participant = {
devices: [null, undefined, {csis: [7], mediaSessions: [null, {csi: 8}, undefined]}],
};

assert.deepEqual(MemberUtil.extractCsis(participant), [7, 8]);
});
});
141 changes: 141 additions & 0 deletions packages/@webex/plugin-meetings/test/unit/spec/members/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,14 @@ describe('plugin-meetings', () => {
);
sinon.restore();
});

it('should not clear memberIdByHistoryCsi so it survives member removal (e.g. BO entry/exit)', () => {
const members = createMembers({url: url1});
members.memberIdByHistoryCsi.set(1000, 'test1');
members.clearMembers();
assert.strictEqual(members.memberIdByHistoryCsi.size, 1);
assert.strictEqual(members.memberIdByHistoryCsi.get(1000), 'test1');
});
});
describe('#locusParticipantsUpdate', () => {
beforeEach(() => {
Expand Down Expand Up @@ -1165,6 +1173,139 @@ describe('plugin-meetings', () => {
it('returns correct member when CSI matches the second device', () => {
assert.strictEqual(members.findMemberByCsi(2001), fakeCollection.oneWithSomeCsis);
});

it('falls back to memberIdByHistoryCsi when CSI is no longer on the current devices', () => {
members.memberIdByHistoryCsi.set(9999, 'oneWithDevicesWithoutCsis');
assert.strictEqual(
members.findMemberByCsi(9999),
fakeCollection.oneWithDevicesWithoutCsis
);
});

it('returns undefined when the memberIdByHistoryCsi entry points to a removed member', () => {
members.memberIdByHistoryCsi.set(8888, 'not-in-collection');
assert.strictEqual(members.findMemberByCsi(8888), undefined);
});

it('prefers a current device match over a memberIdByHistoryCsi match', () => {
members.memberIdByHistoryCsi.set(1001, 'oneWithDevicesWithoutCsis');
assert.strictEqual(members.findMemberByCsi(1001), fakeCollection.oneWithSomeCsis);
});
});

describe('memberIdByHistoryCsi tracking', () => {
let members;

const participantWithCsis = (id, csis) => ({
id,
type: 'USER',
person: {},
devices: [{csis}],
});

beforeEach(() => {
sinon.stub(Trigger, 'trigger');
members = createMembers({url: url1});
});

afterEach(() => {
sinon.restore();
});

it('does not populate the map when adding a brand new member', () => {
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [1000])],
});
assert.strictEqual(members.memberIdByHistoryCsi.size, 0);
});

it('does not populate the map when devices have not changed between updates', () => {
const devices = [{csis: [1000]}];

members.locusParticipantsUpdate({
participants: [{id: 'm1', type: 'USER', person: {}, devices}],
});
members.locusParticipantsUpdate({
participants: [{id: 'm1', type: 'USER', person: {}, devices}],
});

assert.strictEqual(members.memberIdByHistoryCsi.size, 0);
});

it('captures previous CSIs into the map when devices change on update', () => {
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [1000, 1001])],
});
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [2000])],
});

assert.strictEqual(members.memberIdByHistoryCsi.get(1000), 'm1');
assert.strictEqual(members.memberIdByHistoryCsi.get(1001), 'm1');
});

it('preserves history when a member is removed and re-added (breakout scenario)', () => {
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [1000])],
});
// change devices so previous CSIs are captured into history
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [2000])],
});
// remove the member
members.locusParticipantsUpdate({
participants: [],
removedParticipantIds: ['m1'],
});

assert.strictEqual(members.memberIdByHistoryCsi.get(1000), 'm1');

// re-add the member with brand new CSIs
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [3000])],
});

const member = members.findMemberByCsi(1000);
assert.isDefined(member);
assert.strictEqual(member.id, 'm1');
});

it("captures the removed member's current CSIs even without a prior device-change update", () => {
// member is added, then removed in the very next delta without any
// intermediate device-change update - their current CSIs must still be
// captured into the history map on removal
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [1000, 1001])],
});

assert.strictEqual(members.memberIdByHistoryCsi.size, 0);

members.locusParticipantsUpdate({
participants: [],
removedParticipantIds: ['m1'],
});

assert.strictEqual(members.memberIdByHistoryCsi.get(1000), 'm1');
assert.strictEqual(members.memberIdByHistoryCsi.get(1001), 'm1');
});

it('merges existing history with CSIs captured at removal time', () => {
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [1000])],
});
// device change captures 1000 into history; member now reports 2000
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [2000])],
});
// removal should additionally capture the member's current CSIs (2000)
members.locusParticipantsUpdate({
participants: [],
removedParticipantIds: ['m1'],
});

assert.strictEqual(members.memberIdByHistoryCsi.get(1000), 'm1');
assert.strictEqual(members.memberIdByHistoryCsi.get(2000), 'm1');
});
});

describe('getCsisForMember()', () => {
Expand Down
Loading