diff --git a/__mocks__/limits.mock.ts b/__mocks__/limits.mock.ts index a2e6f49b..1595e9a7 100644 --- a/__mocks__/limits.mock.ts +++ b/__mocks__/limits.mock.ts @@ -1,8 +1,17 @@ import { ComponentLimits } from '../src/services/limits/types'; export const LIMITS_MOCK: ComponentLimits = { - videoConference: true, - presence: true, - comments: true, - transcript: true, + presence: { + canUse: true, + maxParticipants: 50, + }, + realtime: { + canUse: true, + maxParticipants: 200, + }, + videoConference: { + canUse: true, + maxParticipants: 255, + canUseTranscript: true, + }, }; diff --git a/__mocks__/participants.mock.ts b/__mocks__/participants.mock.ts index a4acdbfb..e9851742 100644 --- a/__mocks__/participants.mock.ts +++ b/__mocks__/participants.mock.ts @@ -1,5 +1,5 @@ import { Avatar, Group, Participant } from '../src'; -import { MeetingColors, MeetingColorsHex } from '../src/common/types/meeting-colors.types'; +import { MEETING_COLORS } from '../src/common/types/meeting-colors.types'; import { ParticipantByGroupApi } from '../src/common/types/participant.types'; export const MOCK_AVATAR: Avatar = { @@ -10,15 +10,14 @@ export const MOCK_AVATAR: Avatar = { export const MOCK_LOCAL_PARTICIPANT: Participant = { id: 'unit-test-local-participant-id', name: 'unit-test-local-participant-name', - color: '#000', avatar: { imageUrl: 'unit-test-avatar-thumbnail.png', model3DUrl: 'unit-test-avatar-model.glb', }, slot: { - color: MeetingColorsHex[0], + color: MEETING_COLORS.turquoise, index: 0, - colorName: MeetingColors[0], + colorName: 'turquoise', textColor: '#000', timestamp: 0, }, @@ -36,7 +35,7 @@ export const MOCK_ABLY_PARTICIPANT_DATA_1 = { avatar: MOCK_AVATAR, participantId: MOCK_LOCAL_PARTICIPANT.id, slotIndex: 0, - color: MeetingColorsHex[0], + color: MEETING_COLORS.turquoise, }; export const MOCK_ABLY_PARTICIPANT_DATA_2 = { @@ -46,7 +45,7 @@ export const MOCK_ABLY_PARTICIPANT_DATA_2 = { avatar: MOCK_AVATAR, participantId: MOCK_LOCAL_PARTICIPANT.id, slotIndex: 1, - color: MeetingColorsHex[1], + color: MEETING_COLORS.orange, }; export const MOCK_PARTICIPANT_LIST: ParticipantByGroupApi[] = [ diff --git a/src/common/types/meeting-colors.types.ts b/src/common/types/meeting-colors.types.ts index 63a06d10..1de9b662 100644 --- a/src/common/types/meeting-colors.types.ts +++ b/src/common/types/meeting-colors.types.ts @@ -1,41 +1,88 @@ -export enum MeetingColors { - 'turquoise', - 'orange', - 'blue', - 'pink', - 'purple', - 'green', +export const NAME_IS_WHITE_TEXT = [ + 'rosybrown', 'red', - 'bluedark', - 'pinklight', - 'purplelight', - 'greenlight', - 'orangelight', - 'bluelight', - 'redlight', + 'saddlebrown', + 'coral', + 'orange', 'brown', - 'yellow', - 'gray', -} + 'goldenrod', + 'olivegreen', + 'darkolivegreen', + 'seagreen', + 'lightsea', + 'teal', + 'cadetblue', + 'pastelblue', + 'mediumslateblue', + 'bluedark', + 'navy', + 'rebeccapurple', + 'purple', + 'vividorchid', + 'darkmagenta', + 'deepmagenta', + 'fuchsia', + 'violetred', + 'pink', + 'vibrantpink', + 'paleredviolet', + 'carmine', + 'wine', +]; -export const INDEX_IS_WHITE_TEXT = [1, 3, 4, 6, 7, 14, 16]; +export const MEETING_COLORS = { + turquoise: '#31E0B0', + orange: '#FF5E10', + blue: '#00ABF7', + pink: '#FF00BB', + purple: '#9C29FF', + green: '#6FDD00', + red: '#E30000', + bluedark: '#304AFF', + pinklight: '#FF89C4', + purplelight: '#D597FF', + greenlight: '#C6EC5C', + orangelight: '#FFA115', + bluelight: '#75DEFE', + redlight: '#FAA291', + brown: '#BB813F', + yellow: '#FFEF33', + olivegreen: '#93A000', + lightyellow: '#FAE391', + violetred: '#C03FA3', + rosybrown: '#B58787', + cadetblue: '#2095BB', + lightsteelblue: '#ABB5FF', + seagreen: '#04B45F', + palegreen: '#8DE990', + saddlebrown: '#964C42', + pastelblue: '#77A1CC', + palesilver: '#D2BABA', + coral: '#DF6B6B', + bisque: '#FFD9C4', + goldenrod: '#DAA520', + tan: '#D2BD93', + darkolivegreen: '#536C27', + mint: '#ADE6DF', + lightsea: '#45AFAA', + teal: '#036E6E', + wine: '#760040', + cyan: '#00FFFF', + mediumslateblue: '#6674D7', + navy: '#0013BB', + rebeccapurple: '#663399', + vividorchid: '#D429FF', + darkmagenta: '#810E81', + deepmagenta: '#C303C6', + fuchsia: '#FA00FF', + lavendermagenta: '#EE82EE', + thistle: '#EEB4DD', + vibrantpink: '#FF007A', + cottoncandy: '#FFC0DE', + paleredviolet: '#D96598', + carmine: '#B50A52', + gray: '#878291', +}; -export enum MeetingColorsHex { - '#31E0B0', - '#FF5E10', - '#00ABF7', - '#FF00BB', - '#9C29FF', - '#6FDD00', - '#E30000', - '#304AFF', - '#FF89C4', - '#D597FF', - '#C6EC5C', - '#FFA115', - '#75DEFE', - '#FAA291', - '#BB813F', - '#FFEF33', - '#878291', -} +export const MEETING_COLORS_ARRAY = Object.values(MEETING_COLORS); +export const MEETING_COLORS_KEYS = Object.keys(MEETING_COLORS); diff --git a/src/common/types/participant.types.ts b/src/common/types/participant.types.ts index 26e5efcb..05804f73 100644 --- a/src/common/types/participant.types.ts +++ b/src/common/types/participant.types.ts @@ -18,7 +18,6 @@ export interface Participant { id: string; name?: string; type?: ParticipantType; - color?: string; slot?: Slot; avatar?: Avatar; isHost?: boolean; @@ -28,6 +27,12 @@ export interface Participant { timestamp?: number; } +export interface VideoParticipant extends Participant { + participantId?: string; + color?: string; + joinedMeeting?: boolean; +} + export type ParticipantByGroupApi = { id: string; name: string; diff --git a/src/components/base/index.test.ts b/src/components/base/index.test.ts index 1ae04472..0f1d9730 100644 --- a/src/components/base/index.test.ts +++ b/src/components/base/index.test.ts @@ -13,6 +13,7 @@ import { useGlobalStore } from '../../services/stores'; import { ComponentNames } from '../types'; import { BaseComponent } from '.'; +import { LIMITS_MOCK } from '../../../__mocks__/limits.mock'; class DummyComponent extends BaseComponent { protected logger: Logger; @@ -70,6 +71,7 @@ describe('BaseComponent', () => { config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, Presence3DManagerService: Presence3DManager, + connectionLimit: LIMITS_MOCK.videoConference.maxParticipants, useStore, }); @@ -90,6 +92,7 @@ describe('BaseComponent', () => { ioc: new IOC(MOCK_LOCAL_PARTICIPANT), config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + connectionLimit: LIMITS_MOCK.videoConference.maxParticipants, useStore, }); @@ -109,6 +112,7 @@ describe('BaseComponent', () => { ioc: new IOC(MOCK_LOCAL_PARTICIPANT), config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + connectionLimit: LIMITS_MOCK.videoConference.maxParticipants, useStore, }); @@ -126,8 +130,9 @@ describe('BaseComponent', () => { config: null as unknown as Configuration, eventBus: null as unknown as EventBus, useStore: null as unknown as typeof useStore, + connectionLimit: LIMITS_MOCK.videoConference.maxParticipants, }); - }).toThrowError(); + }).toThrow(); }); }); @@ -141,6 +146,7 @@ describe('BaseComponent', () => { ioc: new IOC(MOCK_LOCAL_PARTICIPANT), config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + connectionLimit: LIMITS_MOCK.videoConference.maxParticipants, useStore, }); @@ -162,6 +168,7 @@ describe('BaseComponent', () => { ioc: new IOC(MOCK_LOCAL_PARTICIPANT), config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + connectionLimit: LIMITS_MOCK.videoConference.maxParticipants, useStore, }); diff --git a/src/components/base/index.ts b/src/components/base/index.ts index 43d0f0bd..cbcae7de 100644 --- a/src/components/base/index.ts +++ b/src/components/base/index.ts @@ -15,6 +15,7 @@ import { DefaultAttachComponentOptions } from './types'; export abstract class BaseComponent extends Observable { public abstract name: ComponentNames; protected abstract logger: Logger; + protected connectionLimit: number | 'unlimited'; protected group: Group; protected ioc: IOC; protected eventBus: EventBus; @@ -51,7 +52,8 @@ export abstract class BaseComponent extends Observable { this.eventBus = eventBus; this.isAttached = true; this.ioc = ioc; - this.room = ioc.createRoom(this.name); + this.connectionLimit = params.connectionLimit ?? 50; + this.room = ioc.createRoom(this.name, this.connectionLimit); if (!hasJoinedRoom.value) { this.logger.log(`${this.name} @ attach - not joined yet`); diff --git a/src/components/base/types.ts b/src/components/base/types.ts index fbb08e4d..a2b25522 100644 --- a/src/components/base/types.ts +++ b/src/components/base/types.ts @@ -11,6 +11,7 @@ export interface DefaultAttachComponentOptions { eventBus: EventBus; useStore: (name: T) => Store; Presence3DManagerService: typeof Presence3DManager; + connectionLimit: number | 'unlimited'; } export type GlobalStore = { diff --git a/src/components/comments/index.test.ts b/src/components/comments/index.test.ts index 758eb9b2..3b065496 100644 --- a/src/components/comments/index.test.ts +++ b/src/components/comments/index.test.ts @@ -17,6 +17,7 @@ import { ComponentNames } from '../types'; import { PinAdapter, CommentsSide, Annotation, PinCoordinates } from './types'; import { Comments } from './index'; +import { LIMITS_MOCK } from '../../../__mocks__/limits.mock'; const MOCK_PARTICIPANTS: ParticipantByGroupApi[] = [ { @@ -83,6 +84,7 @@ describe('Comments', () => { config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, Presence3DManagerService: Presence3DManager, + connectionLimit: LIMITS_MOCK.presence.maxParticipants, useStore, }); @@ -337,6 +339,7 @@ describe('Comments', () => { config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, Presence3DManagerService: Presence3DManager, + connectionLimit: LIMITS_MOCK.presence.maxParticipants, useStore, }); @@ -356,6 +359,7 @@ describe('Comments', () => { config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, Presence3DManagerService: Presence3DManager, + connectionLimit: LIMITS_MOCK.presence.maxParticipants, useStore, }); diff --git a/src/components/form-elements/index.test.ts b/src/components/form-elements/index.test.ts index 80c55b47..55534d0d 100644 --- a/src/components/form-elements/index.test.ts +++ b/src/components/form-elements/index.test.ts @@ -10,6 +10,7 @@ import { ComponentNames } from '../types'; import { FieldEvents } from './types'; import { FormElements } from '.'; +import { LIMITS_MOCK } from '../../../__mocks__/limits.mock'; describe('form elements', () => { let instance: any; @@ -32,6 +33,7 @@ describe('form elements', () => { config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, Presence3DManagerService: Presence3DManager, + connectionLimit: LIMITS_MOCK.presence.maxParticipants, useStore, }); }); diff --git a/src/components/form-elements/index.ts b/src/components/form-elements/index.ts index 8047d04f..be3e5b44 100644 --- a/src/components/form-elements/index.ts +++ b/src/components/form-elements/index.ts @@ -1,4 +1,4 @@ -import { SocketEvent } from '../../lib/socket'; +import type { SocketEvent } from '../../lib/socket'; import { Participant } from '../../common/types/participant.types'; import { StoreType } from '../../common/types/stores.types'; diff --git a/src/components/presence-mouse/canvas/index.test.ts b/src/components/presence-mouse/canvas/index.test.ts index a8ec8878..374d2cf9 100644 --- a/src/components/presence-mouse/canvas/index.test.ts +++ b/src/components/presence-mouse/canvas/index.test.ts @@ -1,6 +1,7 @@ import { MOCK_CANVAS } from '../../../../__mocks__/canvas.mock'; import { MOCK_CONFIG } from '../../../../__mocks__/config.mock'; import { EVENT_BUS_MOCK } from '../../../../__mocks__/event-bus.mock'; +import { LIMITS_MOCK } from '../../../../__mocks__/limits.mock'; import { MOCK_LOCAL_PARTICIPANT } from '../../../../__mocks__/participants.mock'; import { useStore } from '../../../common/utils/use-store'; import { IOC } from '../../../services/io'; @@ -53,6 +54,7 @@ const createMousePointers = (): PointersCanvas => { config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, Presence3DManagerService: Presence3DManager, + connectionLimit: LIMITS_MOCK.presence.maxParticipants, useStore, }); diff --git a/src/components/presence-mouse/canvas/index.ts b/src/components/presence-mouse/canvas/index.ts index c90fbc20..3d04b265 100644 --- a/src/components/presence-mouse/canvas/index.ts +++ b/src/components/presence-mouse/canvas/index.ts @@ -8,6 +8,7 @@ import { Logger } from '../../../common/utils'; import { BaseComponent } from '../../base'; import { ComponentNames } from '../../types'; import { Camera, ParticipantMouse, PresenceMouseProps, Transform } from '../types'; +import { MEETING_COLORS } from '../../../common/types/meeting-colors.types'; export class PointersCanvas extends BaseComponent { public name: ComponentNames; @@ -330,12 +331,12 @@ export class PointersCanvas extends BaseComponent { const pointerUser = divPointer.getElementsByClassName('pointer-mouse')[0] as HTMLDivElement; if (pointerUser) { - pointerUser.style.backgroundImage = `url(https://production.cdn.superviz.com/static/pointers-v2/${mouse.slot.index}.svg)`; + pointerUser.style.backgroundImage = `url(https://production.cdn.superviz.com/static/mouse-pointers/${mouse.slot?.colorName}.svg)`; } if (mouseUser) { - mouseUser.style.color = mouse.slot.textColor; - mouseUser.style.backgroundColor = mouse.slot.color; + mouseUser.style.color = mouse.slot?.textColor ?? '#fff'; + mouseUser.style.backgroundColor = mouse.slot?.color ?? MEETING_COLORS.gray; mouseUser.innerHTML = mouse.name; } diff --git a/src/components/presence-mouse/html/index.test.ts b/src/components/presence-mouse/html/index.test.ts index 2800cda5..b4090562 100644 --- a/src/components/presence-mouse/html/index.test.ts +++ b/src/components/presence-mouse/html/index.test.ts @@ -7,6 +7,7 @@ import { Presence3DManager } from '../../../services/presence-3d-manager'; import { ParticipantMouse } from '../types'; import { PointersHTML } from '.'; +import { LIMITS_MOCK } from '../../../../__mocks__/limits.mock'; const createMousePointers = (id: string = 'html'): PointersHTML => { const presenceMouseComponent = new PointersHTML(id); @@ -17,6 +18,7 @@ const createMousePointers = (id: string = 'html'): PointersHTML => { config: MOCK_CONFIG, Presence3DManagerService: Presence3DManager, eventBus: EVENT_BUS_MOCK, + connectionLimit: LIMITS_MOCK.presence.maxParticipants, useStore, }); @@ -379,6 +381,7 @@ describe('MousePointers on HTML', () => { eventBus: EVENT_BUS_MOCK, ioc: new IOC(MOCK_LOCAL_PARTICIPANT), Presence3DManagerService: Presence3DManager, + connectionLimit: LIMITS_MOCK.presence.maxParticipants, useStore, }); diff --git a/src/components/presence-mouse/html/index.ts b/src/components/presence-mouse/html/index.ts index e0e7d0d9..0b47e02e 100644 --- a/src/components/presence-mouse/html/index.ts +++ b/src/components/presence-mouse/html/index.ts @@ -16,6 +16,7 @@ import { Transform, VoidElements, } from '../types'; +import { MEETING_COLORS } from '../../../common/types/meeting-colors.types'; export class PointersHTML extends BaseComponent { public name: ComponentNames; @@ -691,12 +692,12 @@ export class PointersHTML extends BaseComponent { const pointerUser = mouseFollower.getElementsByClassName('pointer-mouse')[0] as HTMLDivElement; if (pointerUser) { - pointerUser.style.backgroundImage = `url(https://production.cdn.superviz.com/static/pointers-v2/${participant.slot.index}.svg)`; + pointerUser.style.backgroundImage = `url(https://production.cdn.superviz.com/static/mouse-pointers/${participant.slot?.colorName}.svg)`; } if (mouseUser) { - mouseUser.style.color = participant.slot.textColor; - mouseUser.style.backgroundColor = participant.slot.color; + mouseUser.style.color = participant.slot?.textColor ?? MEETING_COLORS.gray; + mouseUser.style.backgroundColor = participant.slot?.color ?? '#fff'; mouseUser.innerHTML = participant.name; } diff --git a/src/components/realtime/channel.test.ts b/src/components/realtime/channel.test.ts index 4df6d6e1..d49471a5 100644 --- a/src/components/realtime/channel.test.ts +++ b/src/components/realtime/channel.test.ts @@ -1,3 +1,4 @@ +import { LIMITS_MOCK } from '../../../__mocks__/limits.mock'; import { MOCK_OBSERVER_HELPER } from '../../../__mocks__/observer-helper.mock'; import { MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; import { IOC } from '../../services/io'; @@ -21,7 +22,8 @@ describe('Realtime Channel', () => { ChannelInstance = new Channel( 'channel', new IOC(MOCK_LOCAL_PARTICIPANT), - MOCK_LOCAL_PARTICIPANT + MOCK_LOCAL_PARTICIPANT, + LIMITS_MOCK.realtime.maxParticipants, ); ChannelInstance['state'] = RealtimeChannelState.CONNECTED; @@ -120,7 +122,7 @@ describe('Realtime Channel', () => { }); const h = await ChannelInstance.fetchHistory(); - + expect(spy).toHaveBeenCalled(); expect(h).toEqual({ 'unit-test-event-name': [ @@ -202,10 +204,10 @@ describe('Realtime Channel', () => { ChannelInstance['state'] = RealtimeChannelState.DISCONNECTED; ChannelInstance.disconnect(); - + expect(spy).not.toHaveBeenCalled(); }); - + test('Should log an error if a disconnect attempt is made when the channel is already disconnected', () => { const spy = jest.spyOn(ChannelInstance['logger'], 'log' as any); ChannelInstance['state'] = RealtimeChannelState.DISCONNECTED; @@ -213,6 +215,6 @@ describe('Realtime Channel', () => { ChannelInstance.disconnect(); expect(spy).toHaveBeenCalled(); - }) + }); }); -}) \ No newline at end of file +}); diff --git a/src/components/realtime/channel.ts b/src/components/realtime/channel.ts index 5b5dab8d..6775244f 100644 --- a/src/components/realtime/channel.ts +++ b/src/components/realtime/channel.ts @@ -20,13 +20,18 @@ export class Channel extends Observable { callback: (data: unknown) => void; }> = []; - constructor(name: string, ioc: IOC, localParticipant: Participant) { + constructor( + name: string, + ioc: IOC, + localParticipant: Participant, + connectionLimit: number | 'unlimited', + ) { super(); this.name = name; this.ioc = ioc; this.logger = new Logger('@superviz/sdk/realtime-channel'); - this.channel = this.ioc.createRoom(`realtime:${this.name}`); + this.channel = this.ioc.createRoom(`realtime:${this.name}`, connectionLimit); this.localParticipant = localParticipant; this.subscribeToRealtimeEvents(); diff --git a/src/components/realtime/index.test.ts b/src/components/realtime/index.test.ts index 0fa60c63..97d7d209 100644 --- a/src/components/realtime/index.test.ts +++ b/src/components/realtime/index.test.ts @@ -8,6 +8,7 @@ import { Presence3DManager } from '../../services/presence-3d-manager'; import { RealtimeComponentState } from './types'; import { Realtime } from '.'; +import { LIMITS_MOCK } from '../../../__mocks__/limits.mock'; jest.mock('lodash/throttle', () => jest.fn((fn) => fn)); jest.useFakeTimers(); @@ -27,6 +28,7 @@ describe('realtime component', () => { config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, Presence3DManagerService: Presence3DManager, + connectionLimit: LIMITS_MOCK.realtime.maxParticipants, useStore, }); diff --git a/src/components/realtime/index.ts b/src/components/realtime/index.ts index f034a22d..9fcc433c 100644 --- a/src/components/realtime/index.ts +++ b/src/components/realtime/index.ts @@ -55,7 +55,7 @@ export class Realtime extends BaseComponent { if (channel) return channel; - channel = new Channel(name, this.ioc, this.localParticipant); + channel = new Channel(name, this.ioc, this.localParticipant, this.connectionLimit); this.channels.set(name, channel); @@ -100,7 +100,7 @@ export class Realtime extends BaseComponent { protected start(): void { this.logger.log('started'); - this.channel = new Channel('default', this.ioc, this.localParticipant); + this.channel = new Channel('default', this.ioc, this.localParticipant, this.connectionLimit); this.channel.subscribe(RealtimeChannelEvent.REALTIME_CHANNEL_STATE_CHANGED, (state) => { if (state !== RealtimeChannelState.CONNECTED) return; diff --git a/src/components/types.ts b/src/components/types.ts index dd2cdb64..075d20dd 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -14,10 +14,10 @@ export enum ComponentNames { } export enum PresenceMap { + 'comments' = 'presence', 'presence3dMatterport' = 'presence', 'presence3dAutodesk' = 'presence', 'presence3dThreejs' = 'presence', - 'realtime' = 'presence', 'whoIsOnline' = 'presence', 'formElements' = 'presence', } diff --git a/src/components/video/index.test.ts b/src/components/video/index.test.ts index 4f83371f..1e51df9c 100644 --- a/src/components/video/index.test.ts +++ b/src/components/video/index.test.ts @@ -16,16 +16,21 @@ import { RealtimeEvent, TranscriptState, } from '../../common/types/events.types'; -import { MeetingColors, MeetingColorsHex } from '../../common/types/meeting-colors.types'; -import { Participant, ParticipantType } from '../../common/types/participant.types'; +import { + Participant, + ParticipantType, + VideoParticipant, +} from '../../common/types/participant.types'; import { StoreType } from '../../common/types/stores.types'; import { useStore } from '../../common/utils/use-store'; import { IOC } from '../../services/io'; import { Presence3DManager } from '../../services/presence-3d-manager'; import { VideoFrameState } from '../../services/video-conference-manager/types'; -import { ComponentNames } from '../types'; +import { ParticipantToFrame } from './types'; import { VideoConference } from '.'; +import { MEETING_COLORS } from '../../common/types/meeting-colors.types'; +import { LIMITS_MOCK } from '../../../__mocks__/limits.mock'; Object.assign(global, { TextDecoder, TextEncoder }); @@ -88,12 +93,13 @@ describe('VideoConference', () => { allowGuests: false, }); - VideoConferenceInstance['localParticipant'] = MOCK_LOCAL_PARTICIPANT; + VideoConferenceInstance['localParticipant'] = MOCK_LOCAL_PARTICIPANT as VideoParticipant; VideoConferenceInstance.attach({ ioc: new IOC(MOCK_LOCAL_PARTICIPANT), config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, Presence3DManagerService: Presence3DManager, + connectionLimit: LIMITS_MOCK.videoConference.maxParticipants, useStore, }); @@ -111,13 +117,14 @@ describe('VideoConference', () => { VideoConferenceInstance['localParticipant'] = { ...MOCK_LOCAL_PARTICIPANT, avatar: MOCK_AVATAR, - }; + } as VideoParticipant; VideoConferenceInstance.attach({ ioc: new IOC(MOCK_LOCAL_PARTICIPANT), Presence3DManagerService: Presence3DManager, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + connectionLimit: LIMITS_MOCK.videoConference.maxParticipants, useStore, }); @@ -144,10 +151,10 @@ describe('VideoConference', () => { avatar: MOCK_AVATAR, type: ParticipantType.HOST, slot: { - colorName: MeetingColors[0], + colorName: 'turquoise', index: 0, - color: MeetingColorsHex[0], - textColor: MeetingColors[0], + color: MEETING_COLORS.turquoise, + textColor: '#fff', timestamp: 0, }, }, @@ -191,10 +198,10 @@ describe('VideoConference', () => { avatar: MOCK_AVATAR, type: ParticipantType.HOST, slot: { - colorName: MeetingColors[0], + colorName: 'turquoise', index: 0, - color: MeetingColorsHex[0], - textColor: MeetingColors[0], + color: MEETING_COLORS.turquoise, + textColor: '#fff', timestamp: 0, }, }, @@ -212,10 +219,10 @@ describe('VideoConference', () => { avatar: MOCK_AVATAR, type: ParticipantType.HOST, slot: { - colorName: MeetingColors[0], + colorName: 'turquoise', index: 0, - color: MeetingColorsHex[0], - textColor: MeetingColors[0], + color: MEETING_COLORS.turquoise, + textColor: '#fff', timestamp: 0, }, }, @@ -248,10 +255,10 @@ describe('VideoConference', () => { avatar: MOCK_AVATAR, type: ParticipantType.HOST, slot: { - colorName: MeetingColors[0], + colorName: 'turquoise', index: 0, - color: MeetingColorsHex[0], - textColor: MeetingColors[0], + color: MEETING_COLORS.turquoise, + textColor: '#fff', timestamp: 0, }, }, @@ -308,10 +315,10 @@ describe('VideoConference', () => { avatar: MOCK_AVATAR, type: ParticipantType.HOST, slot: { - colorName: MeetingColors[0], + colorName: 'turquoise', index: 0, - color: MeetingColorsHex[0], - textColor: MeetingColors[0], + color: MEETING_COLORS.turquoise, + textColor: '#fff', timestamp: 0, }, }, @@ -481,6 +488,7 @@ describe('VideoConference', () => { expect(VideoConferenceInstance['roomState'].updateMyProperties).toHaveBeenCalledWith({ name: 'John Doe', type: ParticipantType.HOST, + joinedMeeting: true, }); }); @@ -500,6 +508,7 @@ describe('VideoConference', () => { name: 'John Doe', avatar: MOCK_AVATAR, type: ParticipantType.HOST, + joinedMeeting: true, }); }); @@ -586,15 +595,16 @@ describe('VideoConference', () => { }, }); - const participantInfoList: Participant[] = [ + const participantInfoList: VideoParticipant[] = [ { id: participants.value[MOCK_LOCAL_PARTICIPANT.id].id, - color: participants.value[MOCK_LOCAL_PARTICIPANT.id].slot?.colorName || 'gray', avatar: participants.value[MOCK_LOCAL_PARTICIPANT.id].avatar, name: participants.value[MOCK_LOCAL_PARTICIPANT.id].name, type: participants.value[MOCK_LOCAL_PARTICIPANT.id].type, isHost: participants.value[MOCK_LOCAL_PARTICIPANT.id].isHost ?? false, slot: participants.value[MOCK_LOCAL_PARTICIPANT.id].slot, + timestamp: participants.value[MOCK_LOCAL_PARTICIPANT.id].timestamp, + color: MEETING_COLORS.turquoise, }, ]; @@ -627,10 +637,10 @@ describe('VideoConference', () => { avatar: MOCK_AVATAR, type: ParticipantType.GUEST, slot: { - colorName: MeetingColors[0], + colorName: 'turquoise', index: 0, - color: MeetingColorsHex[0], - textColor: MeetingColors[0], + color: MEETING_COLORS.turquoise, + textColor: '#fff', timestamp: 0, }, }, @@ -645,12 +655,14 @@ describe('VideoConference', () => { [MOCK_LOCAL_PARTICIPANT.id]: { ...participants[MOCK_LOCAL_PARTICIPANT.id], timestamp: 0, + color: MEETING_COLORS.turquoise, }, }); VideoConferenceInstance['onParticipantListUpdate']({ [MOCK_LOCAL_PARTICIPANT.id]: { ...participants[MOCK_LOCAL_PARTICIPANT.id], timestamp: 0, + color: MEETING_COLORS.turquoise, }, }); @@ -679,12 +691,11 @@ describe('VideoConference', () => { isHost: true, avatar: MOCK_AVATAR, type: ParticipantType.HOST, - color: MeetingColors[0], slot: { - colorName: MeetingColors[0], + colorName: 'turquoise', index: 0, - color: MeetingColorsHex[0], - textColor: MeetingColors[0], + color: MEETING_COLORS.turquoise, + textColor: '#fff', timestamp: 0, }, }, @@ -692,19 +703,20 @@ describe('VideoConference', () => { VideoConferenceInstance['onRealtimeParticipantsDidChange'](participant); - const expectedParticipants = { + const expectedParticipants: ParticipantToFrame = { timestamp: 0, - name: MOCK_LOCAL_PARTICIPANT.name, + name: MOCK_LOCAL_PARTICIPANT.name as string, isHost: true, avatar: MOCK_AVATAR, type: ParticipantType.HOST, - color: MeetingColors[0], participantId: MOCK_LOCAL_PARTICIPANT.id, + color: MEETING_COLORS.turquoise, + id: MOCK_LOCAL_PARTICIPANT.id, slot: { - colorName: MeetingColors[0], + colorName: 'turquoise', index: 0, - color: MeetingColorsHex[0], - textColor: MeetingColors[0], + color: MEETING_COLORS.turquoise, + textColor: '#fff', timestamp: 0, }, }; @@ -730,10 +742,10 @@ describe('VideoConference', () => { avatar: MOCK_AVATAR, type: ParticipantType.HOST, slot: { - colorName: MeetingColors[0], + colorName: 'turquoise', index: 0, - color: MeetingColorsHex[0], - textColor: MeetingColors[0], + color: MEETING_COLORS.turquoise, + textColor: '#fff', timestamp: 0, }, }, @@ -762,10 +774,10 @@ describe('VideoConference', () => { avatar: MOCK_AVATAR, type: ParticipantType.HOST, slot: { - colorName: MeetingColors[0], + colorName: 'turquoise', index: 0, - color: MeetingColorsHex[0], - textColor: MeetingColors[0], + color: MEETING_COLORS.turquoise, + textColor: '#fff', timestamp: 0, }, }, diff --git a/src/components/video/index.ts b/src/components/video/index.ts index 253de2aa..776663f4 100644 --- a/src/components/video/index.ts +++ b/src/components/video/index.ts @@ -12,8 +12,11 @@ import { RealtimeEvent, TranscriptState, } from '../../common/types/events.types'; -import { MeetingColorsHex } from '../../common/types/meeting-colors.types'; -import { Participant, ParticipantType } from '../../common/types/participant.types'; +import { + VideoParticipant, + ParticipantType, + Participant, +} from '../../common/types/participant.types'; import { StoreType } from '../../common/types/stores.types'; import { Logger } from '../../common/utils'; import { BrowserService } from '../../services/browser'; @@ -33,15 +36,16 @@ import { import { BaseComponent } from '../base'; import { ComponentNames } from '../types'; -import { ParticipandToFrame, VideoComponentOptions } from './types'; +import { ParticipantToFrame, VideoComponentOptions } from './types'; +import { MEETING_COLORS } from '../../common/types/meeting-colors.types'; const KICK_PARTICIPANTS_TIME = 1000 * 60; let KICK_PARTICIPANTS_TIMEOUT: ReturnType | null = null; export class VideoConference extends BaseComponent { public name: ComponentNames; protected logger: Logger; - private participantsOnMeeting: Partial[] = []; - private localParticipant: Participant; + private participantsOnMeeting: Partial[] = []; + private localParticipant: VideoParticipant; private videoManager: VideoConferenceManager; private connectionService: ConnectionService; private browserService: BrowserService; @@ -86,6 +90,11 @@ export class VideoConference extends BaseComponent { * @returns {void} */ public toggleMicrophone(): void { + if (this.localParticipant.type === ParticipantType.AUDIENCE) { + console.warn('[SuperViz] Audience cannot toggle microphone'); + return; + } + return this.videoManager?.publishMessageToFrame(MeetingControlsEvent.TOGGLE_MICROPHONE); } @@ -95,6 +104,11 @@ export class VideoConference extends BaseComponent { * @returns {void} */ public toggleCam(): void { + if (this.localParticipant.type === ParticipantType.AUDIENCE) { + console.warn('[SuperViz] Audience cannot toggle camera'); + return; + } + this.videoManager?.publishMessageToFrame(MeetingControlsEvent.TOGGLE_CAM); } @@ -104,6 +118,11 @@ export class VideoConference extends BaseComponent { * @returns {void} */ public toggleScreenShare(): void { + if (this.localParticipant.type === ParticipantType.AUDIENCE) { + console.warn('[SuperViz] Audience cannot toggle screen share'); + return; + } + return this.videoManager?.publishMessageToFrame(MeetingControlsEvent.TOGGLE_SCREENSHARE); } @@ -122,6 +141,11 @@ export class VideoConference extends BaseComponent { * @returns {void} */ public toggleRecording(): void { + if (this.localParticipant.isHost) { + console.warn('[SuperViz] Only host can toggle recording'); + return; + } + return this.videoManager?.publishMessageToFrame(MeetingControlsEvent.TOGGLE_RECORDING); } @@ -178,6 +202,9 @@ export class VideoConference extends BaseComponent { * @returns {void} */ private startVideo = (): void => { + const defaultAvatars = + this.params?.userType !== ParticipantType.AUDIENCE && this.params?.defaultAvatars === true; + this.videoConfig = { language: this.params?.language, canUseRecording: !!this.params?.enableRecording, @@ -185,8 +212,7 @@ export class VideoConference extends BaseComponent { canUseChat: !this.params?.chatOff, canUseCams: !this.params?.camsOff, canUseScreenshare: !this.params?.screenshareOff, - canUseDefaultAvatars: - !!this.params?.defaultAvatars && !this.localParticipant?.avatar?.model3DUrl, + canUseDefaultAvatars: defaultAvatars && !this.localParticipant?.avatar?.model3DUrl, canUseGather: !!this.params?.enableGather, canUseFollow: !!this.params?.enableFollow, canUseGoTo: !!this.params?.enableGoTo, @@ -301,7 +327,10 @@ export class VideoConference extends BaseComponent { this.useStore(StoreType.VIDEO); localParticipant.subscribe((participant) => { - this.localParticipant = participant; + this.localParticipant = { + ...this.localParticipant, + ...participant, + }; }); drawing.subscribe(this.setDrawing); @@ -321,14 +350,17 @@ export class VideoConference extends BaseComponent { * */ private createParticipantFromPresence = ( participant: PresenceEvent, - ): Participant => { + ): VideoParticipant => { return { + participantId: participant.id, id: participant.id, - color: participant.data.slot?.color || MeetingColorsHex[16], + color: participant.data.slot?.color || MEETING_COLORS.gray, avatar: participant.data.avatar, type: participant.data.type, name: participant.data.name, isHost: participant.data.isHost, + timestamp: participant.timestamp, + slot: participant.data.slot, }; }; @@ -393,9 +425,6 @@ export class VideoConference extends BaseComponent { private onMeetingStateChange = (state: MeetingState): void => { this.logger.log('video conference @ on meeting state change', state); this.publish(MeetingEvent.MEETING_STATE_UPDATE, state); - - const { localParticipant } = this.useStore(StoreType.GLOBAL); - localParticipant.publish(localParticipant.value); }; /** @@ -493,18 +522,36 @@ export class VideoConference extends BaseComponent { * @param {Participant} participant - participant * @returns {void} */ - private onParticipantJoined = (participant: Participant): void => { + private onParticipantJoined = (participant: VideoParticipant): void => { this.logger.log('video conference @ on participant joined', participant); this.publish(MeetingEvent.MEETING_PARTICIPANT_JOINED, participant); this.publish(MeetingEvent.MY_PARTICIPANT_JOINED, participant); this.kickParticipantsOnHostLeave = !this.params?.allowGuests; + const { localParticipant, participants } = this.useStore(StoreType.GLOBAL); + + const newParticipantName = participant.name.trim(); + + localParticipant.publish({ + ...localParticipant.value, + name: newParticipantName, + }); + + participants.publish({ + ...participants.value, + [participant.id]: { + ...localParticipant.value, + name: newParticipantName, + }, + }); + if (this.videoConfig.canUseDefaultAvatars) { this.roomState.updateMyProperties({ avatar: participant.avatar, name: participant.name, type: participant.type, + joinedMeeting: true, }); return; @@ -513,6 +560,7 @@ export class VideoConference extends BaseComponent { this.roomState.updateMyProperties({ name: participant.name, type: participant.type, + joinedMeeting: true, }); }; @@ -522,7 +570,7 @@ export class VideoConference extends BaseComponent { * @param {Participant} _ - participant * @returns {void} */ - private onParticipantLeft = (_: Participant): void => { + private onParticipantLeft = (_: VideoParticipant): void => { this.logger.log('video conference @ on participant left', this.localParticipant); const { localParticipant, participants } = this.useStore(StoreType.GLOBAL); @@ -562,18 +610,19 @@ export class VideoConference extends BaseComponent { * @param {Record} participants - participants * @returns {void} */ - private onParticipantListUpdate = (participants: Record): void => { + private onParticipantListUpdate = (participants: Record): void => { this.logger.log('video conference @ on participant list update', participants); - const list: Participant[] = Object.values(participants).map((participant) => { + const list: VideoParticipant[] = Object.values(participants).map((participant) => { return { id: participant.id, - color: participant.slot?.colorName || 'gray', + slot: participant.slot, avatar: participant.avatar, name: participant.name, type: participant.type, isHost: participant.isHost ?? false, - slot: participant.slot, + timestamp: participant.timestamp, + color: participant.slot?.color || MEETING_COLORS.gray, }; }); @@ -691,11 +740,12 @@ export class VideoConference extends BaseComponent { */ private onRealtimeParticipantsDidChange = (participants: Participant[]): void => { this.logger.log('video conference @ on participants did change', participants); - const participantList: ParticipandToFrame[] = participants.map((participant) => { + const participantList: ParticipantToFrame[] = participants.map((participant) => { return { + id: participant.id, timestamp: participant.timestamp, participantId: participant.id, - color: participant.slot?.colorName ?? 'gray', + color: participant.slot?.color || MEETING_COLORS.gray, name: participant.name, isHost: participant.isHost ?? false, avatar: participant.avatar, @@ -729,7 +779,7 @@ export class VideoConference extends BaseComponent { const newHost = participant ? { id: participant.id, - color: participant.slot?.color || MeetingColorsHex[16], + color: participant.slot?.color || MEETING_COLORS.gray, avatar: participant.avatar, type: participant.type, name: participant.name, @@ -807,12 +857,6 @@ export class VideoConference extends BaseComponent { MeetingEvent.MY_PARTICIPANT_UPDATED, this.createParticipantFromPresence(participant), ); - - localParticipant.publish({ - ...localParticipant.value, - ...participant.data, - type: this.params.userType as ParticipantType, - }); } participants.publish({ @@ -898,9 +942,9 @@ export class VideoConference extends BaseComponent { } return current; - }, null) as Participant; + }, null) as VideoParticipant; - this.room.presence.update({ + this.room.presence.update({ ...this.localParticipant, isHost: host.id === this.localParticipant.id, }); diff --git a/src/components/video/types.ts b/src/components/video/types.ts index cf5957fd..040407ab 100644 --- a/src/components/video/types.ts +++ b/src/components/video/types.ts @@ -1,4 +1,4 @@ -import { Avatar, ParticipantType } from '../../common/types/participant.types'; +import { Avatar, ParticipantType, Slot } from '../../common/types/participant.types'; import { DevicesOptions } from '../../common/types/sdk-options.types'; import { CamerasPosition, @@ -46,7 +46,8 @@ export interface VideoComponentOptions { }; } -export type ParticipandToFrame = { +export type ParticipantToFrame = { + id: string; timestamp: number; participantId: string; color: string; @@ -54,4 +55,5 @@ export type ParticipandToFrame = { isHost: boolean; avatar?: Avatar; type: ParticipantType; + slot: Slot; }; diff --git a/src/components/who-is-online/index.test.ts b/src/components/who-is-online/index.test.ts index 225f01bb..4a15bfe9 100644 --- a/src/components/who-is-online/index.test.ts +++ b/src/components/who-is-online/index.test.ts @@ -6,7 +6,6 @@ import { MOCK_ABLY_PARTICIPANT_DATA_1, } from '../../../__mocks__/participants.mock'; import { RealtimeEvent, WhoIsOnlineEvent } from '../../common/types/events.types'; -import { MeetingColorsHex } from '../../common/types/meeting-colors.types'; import { StoreType } from '../../common/types/stores.types'; import { useStore } from '../../common/utils/use-store'; import { IOC } from '../../services/io'; @@ -17,6 +16,8 @@ import { ComponentNames } from '../types'; import { Avatar, WhoIsOnlineParticipant, TooltipData } from './types'; import { WhoIsOnline } from './index'; +import { MEETING_COLORS } from '../../common/types/meeting-colors.types'; +import { LIMITS_MOCK } from '../../../__mocks__/limits.mock'; const generateMockParticipant = ({ id, @@ -61,6 +62,7 @@ describe('Who Is Online', () => { config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, Presence3DManagerService: Presence3DManager, + connectionLimit: LIMITS_MOCK.presence.maxParticipants, useStore, }); @@ -68,8 +70,7 @@ describe('Who Is Online', () => { whoIsOnlineComponent['localParticipantId'] = MOCK_LOCAL_PARTICIPANT.id; - const gray = MeetingColorsHex[16]; - whoIsOnlineComponent['color'] = gray; + whoIsOnlineComponent['color'] = MEETING_COLORS.gray; }); afterEach(() => { @@ -439,7 +440,7 @@ describe('Who Is Online', () => { expect(whoIsOnlineComponent['room'].emit).toHaveBeenCalledWith( WhoIsOnlineEvent.START_FOLLOW_ME, - event.detail.id, + event.detail, ); }); }); @@ -813,7 +814,7 @@ describe('Who Is Online', () => { imageUrl: 'https://example.com/avatar.jpg', color: 'white', firstLetter: 'L', - slotIndex: 0, + letterColor: 'black', }; test('should return avatar data with image URL', () => { @@ -821,14 +822,14 @@ describe('Who Is Online', () => { avatar: mockAvatar as any, name: 'John Doe', color: '#007bff', - slotIndex: 1, + letterColor: 'black', }); expect(result).toEqual({ imageUrl: 'https://example.com/avatar.jpg', firstLetter: 'J', color: '#007bff', - slotIndex: 1, + letterColor: 'black', }); }); @@ -840,14 +841,14 @@ describe('Who Is Online', () => { }, name: 'Alice Smith', color: '#dc3545', - slotIndex: 2, + letterColor: 'black', }); expect(result).toEqual({ imageUrl: '', firstLetter: 'A', color: '#dc3545', - slotIndex: 2, + letterColor: 'black', }); }); @@ -856,14 +857,14 @@ describe('Who Is Online', () => { avatar: mockAvatar as any, name: 'User name', color: '#28a745', - slotIndex: 3, + letterColor: 'black', }); expect(result).toEqual({ imageUrl: 'https://example.com/avatar.jpg', firstLetter: 'U', color: '#28a745', - slotIndex: 3, + letterColor: 'black', }); }); @@ -875,14 +876,14 @@ describe('Who Is Online', () => { }, name: '', color: '#ffc107', - slotIndex: 4, + letterColor: 'black', }); expect(result).toEqual({ imageUrl: '', firstLetter: 'A', color: '#ffc107', - slotIndex: 4, + letterColor: 'black', }); }); }); diff --git a/src/components/who-is-online/index.ts b/src/components/who-is-online/index.ts index 691fce6a..97b7bafb 100644 --- a/src/components/who-is-online/index.ts +++ b/src/components/who-is-online/index.ts @@ -171,14 +171,27 @@ export class WhoIsOnline extends BaseComponent { this.room.presence.get((list) => { const dataList = list - .filter((participant) => participant.data['id'] && participant.data['avatar']) + .filter((participant) => participant.data['id']) .map(({ data }: { data: any }) => { + let avatar = data.avatar; + + if (!avatar) { + avatar = this.getAvatar({ + avatar: data.avatar, + color: data.slot.color, + name: data.name, + letterColor: data.slot.textColor, + }); + } + const tooltip = this.getTooltipData(data); const controls = this.getControls(data); + return { ...data, tooltip, controls, + avatar, isLocalParticipant: data.id === this.localParticipantId, }; }) as WhoIsOnlineParticipant[]; @@ -433,7 +446,7 @@ export class WhoIsOnline extends BaseComponent { private follow = ({ detail }: CustomEvent) => { const { everyoneFollowsMe } = this.useStore(StoreType.WHO_IS_ONLINE); everyoneFollowsMe.publish(!!detail?.id); - this.room.emit(WhoIsOnlineEvent.START_FOLLOW_ME, detail?.id); + this.room.emit(WhoIsOnlineEvent.START_FOLLOW_ME, detail); if (this.following) { this.publish(WhoIsOnlineEvent.START_FOLLOW_ME, this.following); @@ -531,11 +544,11 @@ export class WhoIsOnline extends BaseComponent { activeComponents, id, name, - slot: { index, color }, + slot: { color, textColor }, } = participant; const disableDropdown = this.shouldDisableDropdown({ activeComponents, participantId: id }); - const avatar = this.getAvatar({ avatar: avatarLinks, color, name, slotIndex: index }); + const avatar = this.getAvatar({ avatar: avatarLinks, color, name, letterColor: textColor }); return { id, name, @@ -613,24 +626,24 @@ export class WhoIsOnline extends BaseComponent { /** * @function getAvatar * @description Processes the info of the participant's avatar - * @param { avatar: Avatar; name: string; color: string; slotIndex: number } data Information about the participant that will take part in their avatar somehow + * @param { avatar: Avatar; name: string; color: string; letterColor: string } data Information about the participant that will take part in their avatar somehow * @returns {Avatar} Information used to decide how to construct the participant's avatar html */ private getAvatar({ avatar, color, name, - slotIndex, + letterColor, }: { avatar: Avatar; name: string; color: string; - slotIndex: number; + letterColor: string; }) { const imageUrl = avatar?.imageUrl; const firstLetter = name?.at(0)?.toUpperCase() ?? 'A'; - return { imageUrl, firstLetter, color, slotIndex }; + return { imageUrl, firstLetter, color, letterColor }; } /** diff --git a/src/components/who-is-online/types.ts b/src/components/who-is-online/types.ts index 7155c3b0..74e574d0 100644 --- a/src/components/who-is-online/types.ts +++ b/src/components/who-is-online/types.ts @@ -15,8 +15,8 @@ export interface TooltipData { export interface Avatar { imageUrl: string; firstLetter: string; - slotIndex: number; color: string; + letterColor: string; } export interface WhoIsOnlineParticipant { diff --git a/src/core/index.test.ts b/src/core/index.test.ts index ddb7bfa3..f3042fd1 100644 --- a/src/core/index.test.ts +++ b/src/core/index.test.ts @@ -149,4 +149,44 @@ describe('initialization errors', () => { 'Color sv-primary-900 is not a valid color variable value. Please check the documentation for more information.', ); }); + + test('should throw an error if room id is invalid', async () => { + await expect( + sdk(UNIT_TEST_API_KEY, { + ...SIMPLE_INITIALIZATION_MOCK, + roomId: '', + }), + ).rejects.toThrow( + '[SuperViz] Room id is invalid, it should be between 2 and 64 characters and only accept letters, numbers and special characters: -_&@+=,(){}[]/«».:|\'"', + ); + + await expect( + sdk(UNIT_TEST_API_KEY, { + ...SIMPLE_INITIALIZATION_MOCK, + roomId: '1', + }), + ).rejects.toThrow( + '[SuperViz] Room id is invalid, it should be between 2 and 64 characters and only accept letters, numbers and special characters: -_&@+=,(){}[]/«».:|\'"', + ); + }); + + test('should throw an error if participant id is invalid', async () => { + await expect( + sdk(UNIT_TEST_API_KEY, { + ...SIMPLE_INITIALIZATION_MOCK, + participant: { ...SIMPLE_INITIALIZATION_MOCK.participant, id: '' }, + }), + ).rejects.toThrow( + '[SuperViz] Participant id is invalid, it should be between 2 and 64 characters and only accept letters, numbers and special characters: -_&@+=,(){}[]/«».:|\'"', + ); + + await expect( + sdk(UNIT_TEST_API_KEY, { + ...SIMPLE_INITIALIZATION_MOCK, + participant: { ...SIMPLE_INITIALIZATION_MOCK.participant, id: '1' }, + }), + ).rejects.toThrow( + '[SuperViz] Participant id is invalid, it should be between 2 and 64 characters and only accept letters, numbers and special characters: -_&@+=,(){}[]/«».:|\'"', + ); + }); }); diff --git a/src/core/index.ts b/src/core/index.ts index 1fd25576..dfa89e42 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -10,6 +10,27 @@ import RemoteConfigService from '../services/remote-config-service'; import LauncherFacade from './launcher'; import { LauncherFacade as LauncherFacadeType } from './launcher/types'; +/** + * @function validateId + * @description validate if the id follows the constraints + * @param {string} id - id to validate + * @returns {boolean} + */ +function validateId(id: string): boolean { + const lengthConstraint = /^.{2,64}$/; + const pattern = /^[-_&@+=,(){}\[\]\/«».:|'"#a-zA-Z0-9À-ÿ\s]*$/; + + if (!lengthConstraint.test(id)) { + return false; + } + + if (!pattern.test(id)) { + return false; + } + + return true; +} + /** * @function validateOptions * @description Validate the options passed to the SDK @@ -27,15 +48,27 @@ const validateOptions = ({ } if (!group || !group.name || !group.id) { - throw new Error('Group fields is required'); + throw new Error('[SuperViz] Group fields is required'); } if (!participant || !participant.id || !participant.name) { - throw new Error('Participant name and id is required'); + throw new Error('[SuperViz] Participant name and id is required'); } if (!roomId) { - throw new Error('Room id is required'); + throw new Error('[SuperViz] Room id is required'); + } + + if (!validateId(roomId)) { + throw new Error( + '[SuperViz] Room id is invalid, it should be between 2 and 64 characters and only accept letters, numbers and special characters: -_&@+=,(){}[]/«».:|\'"', + ); + } + + if (!validateId(participant.id)) { + throw new Error( + '[SuperViz] Participant id is invalid, it should be between 2 and 64 characters and only accept letters, numbers and special characters: -_&@+=,(){}[]/«».:|\'"', + ); } }; @@ -107,10 +140,10 @@ const init = async (apiKey: string, options: SuperVizSdkOptions): Promise { throw new Error('Failed to load configuration from server'); }); @@ -141,7 +174,7 @@ const init = async (apiKey: string, options: SuperVizSdkOptions): Promise { localParticipant.value = MOCK_LOCAL_PARTICIPANT; LauncherInstance = new Launcher(DEFAULT_INITIALIZATION_MOCK); + + const { hasJoinedRoom } = useStore(StoreType.GLOBAL); + hasJoinedRoom.publish(true); }); test('should be defined', () => { @@ -57,7 +61,7 @@ describe('Launcher', () => { describe('Components', () => { test('should not add component if realtime is not joined room', () => { - LimitsService.checkComponentLimit = jest.fn().mockReturnValue(true); + LimitsService.checkComponentLimit = jest.fn().mockReturnValue(LIMITS_MOCK.videoConference); const { hasJoinedRoom } = useStore(StoreType.GLOBAL); hasJoinedRoom.publish(false); @@ -71,7 +75,7 @@ describe('Launcher', () => { }); test('should add component', () => { - LimitsService.checkComponentLimit = jest.fn().mockReturnValue(true); + LimitsService.checkComponentLimit = jest.fn().mockReturnValue(LIMITS_MOCK.videoConference); LauncherInstance.addComponent(MOCK_COMPONENT); @@ -80,18 +84,33 @@ describe('Launcher', () => { ioc: expect.any(IOC), config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + connectionLimit: LIMITS_MOCK.videoConference.maxParticipants, useStore, } as DefaultAttachComponentOptions), ); const { localParticipant } = LauncherInstance['useStore'](StoreType.GLOBAL); + LauncherInstance['onParticipantUpdatedIOC']({ + connectionId: 'connection1', + data: { + ...MOCK_LOCAL_PARTICIPANT, + activeComponents: [MOCK_COMPONENT.name], + }, + id: MOCK_LOCAL_PARTICIPANT.id, + name: MOCK_LOCAL_PARTICIPANT.name as string, + timestamp: Date.now(), + }); + expect(localParticipant.value.activeComponents?.length).toBe(1); expect(localParticipant.value.activeComponents![0]).toBe(MOCK_COMPONENT.name); }); test('should show a console message if limit reached and not add component', () => { - LimitsService.checkComponentLimit = jest.fn().mockReturnValue(false); + LimitsService.checkComponentLimit = jest.fn().mockReturnValue({ + ...LIMITS_MOCK.videoConference, + canUse: false, + }); LauncherInstance.addComponent(MOCK_COMPONENT); @@ -99,13 +118,24 @@ describe('Launcher', () => { }); test('should remove component', () => { - LimitsService.checkComponentLimit = jest.fn().mockReturnValue(true); + LimitsService.checkComponentLimit = jest.fn().mockReturnValue(LIMITS_MOCK.videoConference); LauncherInstance.addComponent(MOCK_COMPONENT); LauncherInstance.removeComponent(MOCK_COMPONENT); const { localParticipant } = LauncherInstance['useStore'](StoreType.GLOBAL); + LauncherInstance['onParticipantUpdatedIOC']({ + connectionId: 'connection1', + data: { + ...MOCK_LOCAL_PARTICIPANT, + activeComponents: [], + }, + id: MOCK_LOCAL_PARTICIPANT.id, + name: MOCK_LOCAL_PARTICIPANT.name as string, + timestamp: Date.now(), + }); + expect(MOCK_COMPONENT.detach).toHaveBeenCalled(); expect(localParticipant.value.activeComponents?.length).toBe(0); }); @@ -113,13 +143,20 @@ describe('Launcher', () => { test('should show a console message if component is not initialized yet', () => { LauncherInstance.removeComponent(MOCK_COMPONENT); - expect(MOCK_COMPONENT.detach).not.toBeCalled(); + expect(MOCK_COMPONENT.detach).not.toHaveBeenCalled(); }); test('should show a console message if component is already active', () => { - LimitsService.checkComponentLimit = jest.fn().mockReturnValue(true); + LimitsService.checkComponentLimit = jest.fn().mockReturnValue(LIMITS_MOCK.videoConference); LauncherInstance.addComponent(MOCK_COMPONENT); + + // it will be updated by IOC when the participant is updated + LauncherInstance['participant'] = { + ...MOCK_LOCAL_PARTICIPANT, + activeComponents: [MOCK_COMPONENT.name], + }; + LauncherInstance.addComponent(MOCK_COMPONENT); expect(MOCK_COMPONENT.attach).toHaveBeenCalledTimes(1); @@ -130,7 +167,7 @@ describe('Launcher', () => { LauncherInstance.addComponent(MOCK_COMPONENT); - expect(MOCK_COMPONENT.attach).not.toBeCalled(); + expect(MOCK_COMPONENT.attach).not.toHaveBeenCalled(); }); }); @@ -184,32 +221,20 @@ describe('Launcher', () => { expect(LauncherInstance['publish']).toHaveBeenCalled(); }); - test('should update activeComponentsInstances when participant list is updated', () => { - LauncherInstance.addComponent(MOCK_COMPONENT); - - LauncherInstance['onParticipantListUpdate']({ - participant1: { - id: 'unit-test-participant-ably-id', - activeComponents: [MOCK_COMPONENT.name], - }, - }); - - expect(LauncherInstance['activeComponentsInstances'].length).toBe(1); - }); - test('should remove component when participant is not usign it anymore', () => { - LauncherInstance.addComponent(MOCK_COMPONENT); - LauncherInstance['onParticipantUpdatedIOC']({ connectionId: 'connection1', id: MOCK_LOCAL_PARTICIPANT.id, name: MOCK_LOCAL_PARTICIPANT.name as string, data: { ...MOCK_LOCAL_PARTICIPANT, + activeComponents: [], }, timestamp: Date.now(), }); + LauncherInstance.addComponent(MOCK_COMPONENT); + expect(LauncherInstance['activeComponentsInstances'].length).toBe(1); LauncherInstance.removeComponent(MOCK_COMPONENT); @@ -220,6 +245,7 @@ describe('Launcher', () => { name: MOCK_LOCAL_PARTICIPANT.name as string, data: { ...MOCK_LOCAL_PARTICIPANT, + activeComponents: LauncherInstance['activeComponents'], }, timestamp: Date.now(), }); @@ -247,7 +273,7 @@ describe('Launcher', () => { LauncherInstance['onAuthentication'](false); expect(LauncherInstance.destroy).toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith( - `Room can't be initialized because this website's domain is not whitelisted. If you are the developer, please add your domain in https://dashboard.superviz.com/developer`, + `[SuperViz] Room cannot be initialized because this website's domain is not whitelisted. If you are the developer, please add your domain in https://dashboard.superviz.com/developer`, ); }); diff --git a/src/core/launcher/index.ts b/src/core/launcher/index.ts index 0571e5c6..af6d6ffb 100644 --- a/src/core/launcher/index.ts +++ b/src/core/launcher/index.ts @@ -2,7 +2,7 @@ import * as Socket from '../../lib/socket'; import { isEqual } from 'lodash'; import { ParticipantEvent } from '../../common/types/events.types'; -import { Participant } from '../../common/types/participant.types'; +import { Participant, ParticipantType } from '../../common/types/participant.types'; import { StoreType } from '../../common/types/stores.types'; import { Observable } from '../../common/utils'; import { Logger } from '../../common/utils/logger'; @@ -28,11 +28,12 @@ export class Launcher extends Observable implements DefaultLauncher { private activeComponents: ComponentNames[] = []; private componentsToAttachAfterJoin: Partial[] = []; private activeComponentsInstances: Partial[] = []; + private participant: Participant; private ioc: IOC; private room: Socket.Room; private eventBus: EventBus = new EventBus(); - private timestamp: number = 0; + private slotService: SlotService; private useStore = useStore.bind(this) as typeof useStore; @@ -40,18 +41,25 @@ export class Launcher extends Observable implements DefaultLauncher { super(); this.logger = new Logger('@superviz/sdk/launcher'); - const { localParticipant, participants, group, isDomainWhitelisted } = this.useStore( - StoreType.GLOBAL, - ); + const { localParticipant, group, isDomainWhitelisted } = this.useStore(StoreType.GLOBAL); localParticipant.publish({ ...participant }); - participants.subscribe(this.onParticipantListUpdate); isDomainWhitelisted.subscribe(this.onAuthentication); - localParticipant.subscribe(this.onLocalParticipantUpdate); + localParticipant.subscribe(this.onLocalParticipantUpdateOnStore); group.publish(participantGroup); this.ioc = new IOC(localParticipant.value); - this.room = this.ioc.createRoom('launcher'); + this.room = this.ioc.createRoom('launcher', 'unlimited'); + + // Assign a slot to the participant + this.slotService = new SlotService(this.room, this.useStore); + localParticipant.publish({ + ...localParticipant.value, + slot: this.slotService.slot, + activeComponents: [], + }); + + this.participant = localParticipant.value; // internal events without realtime this.eventBus = new EventBus(); @@ -67,10 +75,10 @@ export class Launcher extends Observable implements DefaultLauncher { * @param component - component to add * @returns {void} */ - public addComponent = (component: Partial): void => { + public addComponent = async (component: Partial): Promise => { if (!this.canAddComponent(component)) return; - const { hasJoinedRoom, localParticipant, group } = useStore(StoreType.GLOBAL); + const { hasJoinedRoom, group, localParticipant } = useStore(StoreType.GLOBAL); if (!hasJoinedRoom.value) { this.logger.log('launcher service @ addComponent - not joined yet'); @@ -78,28 +86,32 @@ export class Launcher extends Observable implements DefaultLauncher { return; } + const limit = LimitsService.checkComponentLimit(component.name); + component.attach({ ioc: this.ioc, config: config.configuration, eventBus: this.eventBus, useStore, Presence3DManagerService: Presence3DManager, + connectionLimit: limit.maxParticipants, }); this.activeComponents.push(component.name); this.activeComponentsInstances.push(component); localParticipant.publish({ - ...localParticipant.value, + ...this.participant, activeComponents: this.activeComponents, }); - ApiService.sendActivity( - localParticipant.value.id, - group.value.id, - group.value.name, - component.name, - ); + this.room.presence.update({ + ...this.participant, + slot: this.slotService.slot, + activeComponents: this.activeComponents, + }); + + ApiService.sendActivity(this.participant.id, group.value.id, group.value.name, component.name); }; /** @@ -141,11 +153,9 @@ export class Launcher extends Observable implements DefaultLauncher { return c.name !== component.name; }); - const { localParticipant } = this.useStore(StoreType.GLOBAL); - this.activeComponents.splice(this.activeComponents.indexOf(component.name), 1); - localParticipant.publish({ - ...localParticipant.value, + this.room.presence.update({ + ...this.participant, activeComponents: this.activeComponents, }); }; @@ -174,6 +184,7 @@ export class Launcher extends Observable implements DefaultLauncher { this.room?.presence.off(Socket.PresenceEvents.JOINED_ROOM); this.room?.presence.off(Socket.PresenceEvents.LEAVE); this.room?.presence.off(Socket.PresenceEvents.UPDATE); + this.ioc.stateSubject.unsubscribe(); this.ioc?.destroy(); this.isDestroyed = true; @@ -190,7 +201,7 @@ export class Launcher extends Observable implements DefaultLauncher { */ private canAddComponent = (component: Partial): boolean => { const isProvidedFeature = config.get(`features.${component.name}`); - const hasComponentLimit = LimitsService.checkComponentLimit(component.name); + const componentLimit = LimitsService.checkComponentLimit(component.name); const isComponentActive = this.activeComponents?.includes(component.name); const verifications = [ @@ -208,7 +219,7 @@ export class Launcher extends Observable implements DefaultLauncher { message: `Component ${component.name} is already active. Please remove it first`, }, { - isValid: hasComponentLimit, + isValid: componentLimit.canUse, message: `You reached the limit usage of ${component.name}`, }, ]; @@ -226,12 +237,18 @@ export class Launcher extends Observable implements DefaultLauncher { return true; }; + /** + * @function onAuthentication + * @description on authentication + * @param isAuthenticated - return if the user is authenticated + * @returns {void} + */ private onAuthentication = (isAuthenticated: boolean): void => { if (isAuthenticated) return; this.destroy(); console.error( - `Room can't be initialized because this website's domain is not whitelisted. If you are the developer, please add your domain in https://dashboard.superviz.com/developer`, + `[SuperViz] Room cannot be initialized because this website's domain is not whitelisted. If you are the developer, please add your domain in https://dashboard.superviz.com/developer`, ); }; @@ -246,55 +263,20 @@ export class Launcher extends Observable implements DefaultLauncher { }; /** - * @function onParticipantListUpdate - * @description on participant list update - * @param participants - participants list + * @function onLocalParticipantUpdateOnStore + * @description handles the update of the local participant in the store. + * @param {Participant} participant - new participant data * @returns {void} */ - private onParticipantListUpdate = (participants: Record): void => { - this.logger.log('launcher service @ onParticipantListUpdate', participants); - const { localParticipant } = useStore(StoreType.GLOBAL); - - const participant: Participant = Object.values(participants) - .filter((participant) => participant.id === localParticipant.value.id) - .map((participant) => { - return { - ...participant, - color: participant.slot?.color, - }; - })[0]; - - if (!participant || isEqual(localParticipant.value, participant)) return; - - localParticipant.publish({ - ...localParticipant.value, - ...participant, - }); - - this.activeComponentsInstances = this.activeComponentsInstances.filter((component) => { - /** - * @NOTE - Prevents removing all components when - * in the first update, activeComponents is undefined. - * It means we should keep all instances - */ - if (!participant.activeComponents) return true; - - return this.activeComponents.includes(component.name); - }); - }; - - /** - * @function onParticipantJoined - * @description on participant joined - * @param ablyParticipant - ably participant - * @returns {void} - */ - private onParticipantJoined = (participant: Socket.PresenceEvent): void => { - const { localParticipant } = useStore(StoreType.GLOBAL); - if (participant.id !== localParticipant.value.id) return; + private onLocalParticipantUpdateOnStore = (participant: Participant): void => { + this.participant = participant; + this.activeComponents = participant.activeComponents || []; - this.logger.log('launcher service @ onParticipantJoined - local participant joined'); - this.attachComponentsAfterJoin(); + if (this.activeComponents.length) { + this.activeComponentsInstances = this.activeComponentsInstances.filter((component) => { + return this.activeComponents.includes(component.name); + }); + } }; private onSameAccount = (): void => { @@ -312,14 +294,9 @@ export class Launcher extends Observable implements DefaultLauncher { private startIOC = (): void => { this.logger.log('launcher service @ startIOC'); - const { participants, localParticipant } = useStore(StoreType.GLOBAL); - // retrieve the current participants in the room + const { participants } = useStore(StoreType.GLOBAL); - this.ioc.stateSubject.subscribe((state) => { - if (state === IOCState.AUTH_ERROR) { - this.onAuthentication(false); - } - }); + this.ioc.stateSubject.subscribe(this.onConnectionStateChange); this.room.presence.get((presences) => { const participantsMap: Record = {}; @@ -332,9 +309,9 @@ export class Launcher extends Observable implements DefaultLauncher { }; }); - participantsMap[localParticipant.value.id] = { - ...participantsMap[localParticipant.value.id], - ...localParticipant.value, + participantsMap[this.participant.id] = { + ...participantsMap[this.participant.id], + ...this.participant, }; participants.publish(participantsMap); @@ -344,13 +321,26 @@ export class Launcher extends Observable implements DefaultLauncher { Socket.PresenceEvents.JOINED_ROOM, this.onParticipantJoinedIOC, ); - this.room.presence.on(Socket.PresenceEvents.LEAVE, this.onParticipantLeaveIOC); - this.room.presence.on(Socket.PresenceEvents.UPDATE, this.onParticipantUpdatedIOC); + }; - const { hasJoinedRoom } = useStore(StoreType.GLOBAL); - hasJoinedRoom.publish(true); + /** + * @function onConnectionStateChange + * @description on connection state change + * @param state - connection state + * @returns {void} + */ + private onConnectionStateChange = (state: IOCState): void => { + if (state === IOCState.AUTH_ERROR) { + this.onAuthentication(false); + return; + } + + if (state === IOCState.SAME_ACCOUNT_ERROR) { + this.onSameAccount(); + return; + } }; /** @@ -362,21 +352,18 @@ export class Launcher extends Observable implements DefaultLauncher { private onParticipantJoinedIOC = async ( presence: Socket.PresenceEvent, ): Promise => { - const { localParticipant } = useStore(StoreType.GLOBAL); - if (presence.id !== localParticipant.value.id) return; + if (presence.id !== this.participant.id) return; - // Assign a slot to the participant - const slot = new SlotService(this.room); - await slot.assignSlot(); + this.room.presence.update(this.participant); - this.timestamp = presence.timestamp; + this.logger.log('launcher service @ onParticipantJoined - local participant joined'); - this.room.presence.update(localParticipant.value); + const { hasJoinedRoom } = useStore(StoreType.GLOBAL); + hasJoinedRoom.publish(true); - this.logger.log('launcher service @ onParticipantJoined - local participant joined'); - this.onParticipantJoined(presence); - this.publish(ParticipantEvent.LOCAL_JOINED, localParticipant.value); - this.publish(ParticipantEvent.JOINED, localParticipant.value); + this.attachComponentsAfterJoin(); + this.publish(ParticipantEvent.LOCAL_JOINED, this.participant); + this.publish(ParticipantEvent.JOINED, this.participant); }; /** @@ -399,6 +386,7 @@ export class Launcher extends Observable implements DefaultLauncher { this.logger.log('launcher service @ onParticipantLeave - participant left', presence.data); this.publish(ParticipantEvent.LEFT, presence.data); + this.publish(ParticipantEvent.LIST_UPDATED, Object.values(participantsMap)); }; /** @@ -410,42 +398,36 @@ export class Launcher extends Observable implements DefaultLauncher { private onParticipantUpdatedIOC = (presence: Socket.PresenceEvent): void => { const { localParticipant } = useStore(StoreType.GLOBAL); - if ( - localParticipant.value && - presence.id === localParticipant.value.id && - !isEqual(localParticipant.value, presence.data) - ) { - if (presence.data.timestamp === this.timestamp) { - this.timestamp = 0; - return; - } - + if (localParticipant.value && presence.id === localParticipant.value.id) { localParticipant.publish({ - ...presence.data, ...localParticipant.value, + ...presence.data, timestamp: presence.timestamp, } as Participant); - this.timestamp = presence.timestamp; - this.room.presence.update(localParticipant.value); - - this.publish(ParticipantEvent.LOCAL_UPDATED, presence.data); - + this.publish(ParticipantEvent.LOCAL_UPDATED, { + ...localParticipant.value, + ...presence.data, + }); this.logger.log('Publishing ParticipantEvent.UPDATED', presence.data); } const { participants } = useStore(StoreType.GLOBAL); + const participant: Participant = { + id: presence.id, + name: presence.name, + timestamp: presence.timestamp, + ...presence.data, + }; if (!participants.value[presence.id]) { - this.publish(ParticipantEvent.JOINED, presence.data); + this.publish(ParticipantEvent.JOINED, participant); } - const participantsMap = { ...participants.value }; - participantsMap[presence.id] = { - ...presence.data, - ...participants.value[presence.id], - timestamp: presence.timestamp, - }; + const participantsMap = Object.assign({}, participants.value); + participantsMap[presence.id] = participant; + + if (isEqual(participantsMap, participants.value)) return; participants.publish(participantsMap); diff --git a/src/lib/socket/connection/index.ts b/src/lib/socket/connection/index.ts index 08087084..9affaa34 100644 --- a/src/lib/socket/connection/index.ts +++ b/src/lib/socket/connection/index.ts @@ -2,7 +2,6 @@ import { Subject } from 'rxjs'; import type { Socket } from 'socket.io-client'; import { ErrorCallback } from '../common/types/callbacks.types'; -import { RoomEvents } from '../common/types/event.types'; import { ClientState, ConnectionState, SocketErrorEvent, SocketEvent } from './types'; import { Logger } from '../../../common/utils'; @@ -116,13 +115,19 @@ export class ClientConnection { private onCustomError = (error: SocketErrorEvent) => { if (error.needsToDisconnect) { this.socket.disconnect(); + this.changeState(ClientState.DISCONNECTED, error.errorType); } + const logMessage = `[SuperViz] + - Error: ${error.errorType} + - Message: ${error.message} + `; + if (error.level === 'error') { - console.error('[SuperViz - Error]', 'Type: ', error.errorType, 'Message :', error.message); + console.error(logMessage); return; } - console.warn('[SuperViz - Warning]', 'Type: ', error.errorType, 'Message :', error.message); + console.warn(logMessage); }; } diff --git a/src/lib/socket/connection/types.ts b/src/lib/socket/connection/types.ts index dde82aab..1fdde25e 100644 --- a/src/lib/socket/connection/types.ts +++ b/src/lib/socket/connection/types.ts @@ -29,7 +29,11 @@ export interface ConnectionState { } export type SocketErrorEvent = { - errorType: 'message-size-limit' | 'rate-limit' | 'room-connections-limit'; + errorType: + | 'message-size-limit' + | 'rate-limit' + | 'room-connections-limit' + | 'user-already-in-room'; message: string; connectionId: string; needsToDisconnect: boolean; diff --git a/src/services/api/index.test.ts b/src/services/api/index.test.ts index df223cfa..7e222bb9 100644 --- a/src/services/api/index.test.ts +++ b/src/services/api/index.test.ts @@ -8,17 +8,17 @@ const INVALID_API_KEY = 'unit-test-invalid-api-key'; const MOCK_ABLY_KEY = 'unit-test-ably-key'; const CHECK_LIMITS_MOCK = { - usage: LIMITS_MOCK, + limits: LIMITS_MOCK, }; const FETCH_PARTICIPANTS_BY_GROUP_MOCK = [ { - id: "any_user_id", - name: "any_name", + id: 'any_user_id', + name: 'any_name', avatar: null, email: 'any_email', - } -] + }, +]; jest.mock('../../common/utils', () => { return { @@ -209,7 +209,7 @@ describe('ApiService', () => { const baseUrl = 'https://dev.nodeapi.superviz.com'; const response = await ApiService.fetchLimits(baseUrl, VALID_API_KEY); - expect(response).toEqual(CHECK_LIMITS_MOCK.usage); + expect(response).toEqual(CHECK_LIMITS_MOCK.limits); }); }); @@ -217,7 +217,9 @@ describe('ApiService', () => { test('should return the participants', async () => { const response = await ApiService.fetchParticipantsByGroup('any_group_id'); - expect(response).toEqual([{"avatar": null, "id": "any_user_id", "name": "any_name", "email": "any_email"}]); + expect(response).toEqual([ + { avatar: null, id: 'any_user_id', name: 'any_name', email: 'any_email' }, + ]); }); }); @@ -225,10 +227,10 @@ describe('ApiService', () => { test('should create a mention', async () => { const response = await ApiService.createMentions({ commentsId: 'any_comment_id', - participants: [] + participants: [], }); expect(response).toEqual({}); }); - }) + }); }); diff --git a/src/services/api/index.ts b/src/services/api/index.ts index f549bf3e..83a84f55 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -2,14 +2,14 @@ import { SuperVizSdkOptions } from '../../common/types/sdk-options.types'; import { doRequest } from '../../common/utils'; import { Annotation } from '../../components/comments/types'; import config from '../config'; - +import { ComponentLimits } from '../limits/types'; import { AnnotationParams, CommentParams, CreateOrUpdateParticipantParams, FetchAnnotationsParams, - MentionParams + MentionParams, } from './types'; export default class ApiService { @@ -32,11 +32,11 @@ export default class ApiService { return doRequest(url, 'POST', { apiKey }); } - static async fetchLimits(baseUrl: string, apikey: string) { - const path: string = '/user/check_limits'; + static async fetchLimits(baseUrl: string, apikey: string): Promise { + const path: string = '/user/check_limits_v2'; const url: string = this.createUrl(baseUrl, path); const result = await doRequest(url, 'GET', '', { apikey }); - return result.usage; + return result.limits; } static async fetchWaterMark(baseUrl: string, apiKey: string) { @@ -130,18 +130,14 @@ export default class ApiService { return doRequest(url, 'POST', body, { apikey }); } - static async fetchParticipantsByGroup( - groupId: string, - ) { + static async fetchParticipantsByGroup(groupId: string) { const path = `/groups/participants/${groupId}`; const baseUrl = config.get('apiUrl'); const url = this.createUrl(baseUrl, path, { take: 10000 }); return doRequest(url, 'GET', undefined, { apikey: config.get('apiKey') }); } - static async createMentions( - mentionParams: MentionParams - ) { + static async createMentions(mentionParams: MentionParams) { const path = '/mentions'; const baseUrl = config.get('apiUrl'); const url = this.createUrl(baseUrl, path); diff --git a/src/services/io/index.test.ts b/src/services/io/index.test.ts index db411355..ff024881 100644 --- a/src/services/io/index.test.ts +++ b/src/services/io/index.test.ts @@ -1,9 +1,5 @@ -import * as Socket from '../../lib/socket'; - import { MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; -import { IOCState } from './types'; - import { IOC } from '.'; describe('io', () => { @@ -30,43 +26,4 @@ describe('io', () => { expect(room).toHaveProperty('off'); expect(room).toHaveProperty('emit'); }); - - test('should force reconnect', () => { - const spy = jest.spyOn(instance as any, 'forceReconnect'); - const callback = jest.fn(); - - instance?.stateSubject.subscribe(callback); - instance?.['handleConnectionState']({ - state: Socket.ClientState.DISCONNECTED, - reason: '', - }); - - expect(spy).toHaveBeenCalled(); - expect(spy).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith(IOCState.DISCONNECTED); - }); - - test('should not force reconnect if reason is Unauthorized connection', () => { - const spy = jest.spyOn(instance as any, 'forceReconnect'); - const callback = jest.fn(); - - instance?.stateSubject.subscribe(callback); - instance?.['handleConnectionState']({ - state: Socket.ClientState.DISCONNECTED, - reason: 'Unauthorized connection', - }); - - expect(callback).toHaveBeenCalledWith(IOCState.AUTH_ERROR); - expect(spy).not.toHaveBeenCalled(); - }); - - test('should not force reconnect if state is not DISCONNECTED or RECONNECT_ERROR', () => { - const spy = jest.spyOn(instance as any, 'forceReconnect'); - - instance?.['handleConnectionState']({ - state: Socket.ClientState.CONNECTED, - }); - - expect(spy).not.toHaveBeenCalled(); - }); }); diff --git a/src/services/io/index.ts b/src/services/io/index.ts index 658e4272..fce6b783 100644 --- a/src/services/io/index.ts +++ b/src/services/io/index.ts @@ -35,18 +35,6 @@ export class IOC { } private handleConnectionState = (state: Socket.ConnectionState): void => { - const needsToReconnectStates = [ - Socket.ClientState.DISCONNECTED, - Socket.ClientState.RECONNECT_ERROR, - ]; - - if ( - needsToReconnectStates.includes(state.state) && - !['io client disconnect', 'Unauthorized connection'].includes(state.reason) - ) { - this.forceReconnect(); - } - if (state.reason === 'Unauthorized connection') { console.error( '[Superviz] Unauthorized connection. Please check your API key and if your domain is white listed.', @@ -62,22 +50,16 @@ export class IOC { return; } + if (state.reason === 'user-already-in-room') { + this.state = state; + this.stateSubject.next(IOCState.SAME_ACCOUNT_ERROR); + return; + } + this.state = state; this.stateSubject.next(state.state as unknown as IOCState); }; - /** - * @function forceReconnect - * @description force the socket to reconnect - * @returns {void} - */ - private forceReconnect(): void { - this.client?.destroy(); - this.client = null; - - this.createClient(); - } - /** * @function createClient * @description create a new socket client @@ -98,11 +80,14 @@ export class IOC { /** * @function createRoom * @description create and join realtime room - * @param roomName {string} + * @param {string} roomName - name of the room that will be created + * @param {number | 'unlimited'} connectionLimit - + * connection limit for the room, the default is 50 because it's the maximum number of slots * @returns {Room} */ - public createRoom(roomName: string): Socket.Room { + public createRoom(roomName: string, connectionLimit: number | 'unlimited' = 50): Socket.Room { const roomId = config.get('roomId'); - return this.client.connect(`${roomId}:${roomName}`); + + return this.client.connect(`${roomId}:${roomName}`, connectionLimit); } } diff --git a/src/services/io/types.ts b/src/services/io/types.ts index 61514ccf..73d0f192 100644 --- a/src/services/io/types.ts +++ b/src/services/io/types.ts @@ -6,4 +6,5 @@ export enum IOCState { RECONNECTING = 'RECONNECTING', RECONNECT_ERROR = 'RECONNECT_ERROR', AUTH_ERROR = 'AUTH_ERROR', + SAME_ACCOUNT_ERROR = 'SAME_ACCOUNT_ERROR', } diff --git a/src/services/limits/index.test.ts b/src/services/limits/index.test.ts index c8eef768..fb3c913d 100644 --- a/src/services/limits/index.test.ts +++ b/src/services/limits/index.test.ts @@ -3,6 +3,7 @@ import { ComponentNames } from '../../components/types'; import config from '../config'; import LimitsService from './index'; +import { ComponentLimits } from './types'; describe('LimitsService', () => { describe('checkComponentLimit', () => { @@ -12,20 +13,25 @@ describe('LimitsService', () => { const result = LimitsService.checkComponentLimit(componentName); - expect(result).toBe(true); + expect(result).toBe(LIMITS_MOCK.videoConference); }); it('should return false if the component limit is exceeded', () => { const componentName = ComponentNames.COMMENTS; - jest.spyOn(config, 'get').mockReturnValue({ + const expected: ComponentLimits = { ...LIMITS_MOCK, - comments: false, - }); + presence: { + ...LIMITS_MOCK.presence, + canUse: false, + }, + }; + + jest.spyOn(config, 'get').mockReturnValue(expected); const result = LimitsService.checkComponentLimit(componentName); - expect(result).toBe(false); + expect(result).toBe(expected.presence); }); }); }); diff --git a/src/services/limits/index.ts b/src/services/limits/index.ts index 12c64457..7efe84f0 100644 --- a/src/services/limits/index.ts +++ b/src/services/limits/index.ts @@ -1,12 +1,13 @@ import { ComponentNames, PresenceMap } from '../../components/types'; import config from '../config'; -import { ComponentLimits } from './types'; +import { ComponentLimits, Limit, VideoConferenceLimit } from './types'; export default class LimitsService { - static checkComponentLimit(name: ComponentNames): boolean { - const componentName = PresenceMap[name] ?? name; + static checkComponentLimit(name: ComponentNames): Limit | VideoConferenceLimit { const limits = config.get('limits'); - return limits?.[componentName] ?? false; + const componentName = PresenceMap[name] ?? name; + + return limits?.[componentName] ?? { canUse: false, maxParticipants: 50 }; } } diff --git a/src/services/limits/types.ts b/src/services/limits/types.ts index e7284153..dc84873a 100644 --- a/src/services/limits/types.ts +++ b/src/services/limits/types.ts @@ -1,6 +1,14 @@ +export type Limit = { + canUse: boolean; + maxParticipants: number; +}; + +export type VideoConferenceLimit = Limit & { + canUseTranscript: boolean; +}; + export type ComponentLimits = { - videoConference: boolean; - presence: boolean; - comments: boolean; - transcript?: boolean; + videoConference: VideoConferenceLimit; + presence: Limit; + realtime: Limit; }; diff --git a/src/services/roomState/index.ts b/src/services/roomState/index.ts index b4aa61b2..ccc5fb60 100644 --- a/src/services/roomState/index.ts +++ b/src/services/roomState/index.ts @@ -1,7 +1,7 @@ import { PresenceEvent, PresenceEvents, Room, SocketEvent } from '../../lib/socket'; import { TranscriptState } from '../../common/types/events.types'; -import { Participant, ParticipantType } from '../../common/types/participant.types'; +import { ParticipantType, VideoParticipant } from '../../common/types/participant.types'; import { RealtimeStateTypes } from '../../common/types/realtime.types'; import { StoreType } from '../../common/types/stores.types'; import { Logger, Observer } from '../../common/utils'; @@ -13,7 +13,7 @@ import { RoomPropertiesEvents, VideoRoomProperties } from './type'; export class RoomStateService { private room: Room; private logger: Logger; - private myParticipant: Partial = {}; + private myParticipant: Partial = {}; private localRoomProperties: VideoRoomProperties = null; private drawingData: DrawingData = null; private enableSync: boolean; @@ -64,12 +64,12 @@ export class RoomStateService { /** * @function updateMyProperties - * @param {Partial} Participant + * @param {Partial} newProperties * @description updates local participant properties * @returns {void} */ - public updateMyProperties = (newProperties?: Partial): void => { - const properties = newProperties ?? ({} as Participant); + public updateMyProperties = (newProperties?: Partial): void => { + const properties = newProperties ?? ({} as VideoParticipant); if (this.isMessageTooBig(properties) || this.left || !this.enableSync || this.isSyncFrozen) { return; diff --git a/src/services/slot/index.test.ts b/src/services/slot/index.test.ts index 240689af..ff24b123 100644 --- a/src/services/slot/index.test.ts +++ b/src/services/slot/index.test.ts @@ -1,4 +1,10 @@ import { SlotService } from '.'; +import { MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; +import { MEETING_COLORS, MEETING_COLORS_KEYS } from '../../common/types/meeting-colors.types'; +import { Participant } from '../../common/types/participant.types'; +import { StoreType } from '../../common/types/stores.types'; +import { useStore } from '../../common/utils/use-store'; +import { ComponentNames } from '../../components/types'; describe('slot service', () => { afterEach(() => { @@ -16,14 +22,10 @@ describe('slot service', () => { }, } as any; - const participant = { - id: '123', - } as any; - - const instance = new SlotService(room); + const instance = new SlotService(room, useStore); const result = await instance.assignSlot(); - expect(instance['slotIndex']).toBeDefined(); + expect(instance['slot']).toBeDefined(); expect(result).toEqual({ index: expect.any(Number), color: expect.any(String), @@ -33,6 +35,30 @@ describe('slot service', () => { }); }); + test('should remove the slot from the participant', async () => { + const room = { + presence: { + on: jest.fn(), + update: jest.fn(), + }, + } as any; + + const instance = new SlotService(room, useStore); + instance['slot'].index = 0; + instance.setDefaultSlot(); + + expect(instance['slot'].index).toBeNull(); + expect(room.presence.update).toHaveBeenCalledWith({ + slot: { + index: null, + color: expect.any(String), + textColor: expect.any(String), + colorName: expect.any(String), + timestamp: expect.any(Number), + }, + }); + }); + test('if there are no more slots available, it should throw an error', async () => { console.error = jest.fn(); @@ -40,16 +66,16 @@ describe('slot service', () => { presence: { on: jest.fn(), get: jest.fn((callback) => { - callback(new Array(17).fill({})); + callback(new Array(50).fill({})); }), update: jest.fn(), }, } as any; - const instance = new SlotService(room); + const instance = new SlotService(room, useStore); await instance.assignSlot(); - expect(instance['slotIndex']).toBeUndefined(); + expect(console.error).toHaveBeenCalled(); }); test('if the slot is already in use, it should assign a new slot', async () => { @@ -79,10 +105,10 @@ describe('slot service', () => { id: '123', } as any; - const instance = new SlotService(room); + const instance = new SlotService(room, useStore); const result = await instance.assignSlot(); - expect(instance['slotIndex']).toBeDefined(); + expect(instance['slot'].index).toBeDefined(); expect(result).toEqual({ index: expect.any(Number), color: expect.any(String), diff --git a/src/services/slot/index.ts b/src/services/slot/index.ts index 03f681fd..7f592a0c 100644 --- a/src/services/slot/index.ts +++ b/src/services/slot/index.ts @@ -1,20 +1,30 @@ import * as Socket from '../../lib/socket'; import { - INDEX_IS_WHITE_TEXT, - MeetingColors, - MeetingColorsHex, + NAME_IS_WHITE_TEXT, + MEETING_COLORS, + MEETING_COLORS_KEYS, } from '../../common/types/meeting-colors.types'; -import { Participant, Slot } from '../../common/types/participant.types'; -import { StoreType } from '../../common/types/stores.types'; +import { Participant, ParticipantType, Slot } from '../../common/types/participant.types'; +import { Store, StoreType } from '../../common/types/stores.types'; import { useStore } from '../../common/utils/use-store'; +import { ComponentNames } from '../../components/types'; export class SlotService { - private room: Socket.Room; - - private slotIndex: number; + private isAssigningSlot = false; + + public slot: Slot = { + index: null, + color: MEETING_COLORS.gray, + textColor: '#fff', + colorName: 'gray', + timestamp: Date.now(), + }; - constructor(room: Socket.Room) { + constructor( + private room: Socket.Room, + private useStore: (name: T) => Store, + ) { this.room = room; this.room.presence.on(Socket.PresenceEvents.UPDATE, this.onPresenceUpdate); @@ -26,8 +36,11 @@ export class SlotService { * @returns void */ public async assignSlot(): Promise { - let slots = Array.from({ length: 16 }, (_, i) => i); - let slot = Math.floor(Math.random() * 16); + if (this.isAssigningSlot) return this.slot; + + this.isAssigningSlot = true; + let slots = Array.from({ length: 50 }, (_, i) => i); + let slot = Math.floor(Math.random() * 50); const { localParticipant, participants } = useStore(StoreType.GLOBAL); try { @@ -35,7 +48,7 @@ export class SlotService { this.room.presence.get((presences) => { if (!presences || !presences.length) resolve(true); - if (presences.length >= 17) { + if (presences.length >= 50) { slots = []; reject(new Error('[SuperViz] - No more slots available')); return; @@ -52,19 +65,22 @@ export class SlotService { }); const isUsing = !slots.includes(slot); + if (isUsing) { slot = slots.shift(); } + const color = Object.keys(MEETING_COLORS)[slot]; + const slotData = { index: slot, - color: MeetingColorsHex[slot], - textColor: INDEX_IS_WHITE_TEXT.includes(slot) ? '#fff' : '#000', - colorName: MeetingColors[slot], + color: MEETING_COLORS[color], + textColor: NAME_IS_WHITE_TEXT.includes(color) ? '#fff' : '#000', + colorName: color, timestamp: Date.now(), }; - this.slotIndex = slot; + this.slot = slotData; localParticipant.publish({ ...localParticipant.value, @@ -81,6 +97,7 @@ export class SlotService { this.room.presence.update({ slot: slotData }); + this.isAssigningSlot = false; return slotData; } catch (error) { console.error(error); @@ -88,32 +105,113 @@ export class SlotService { } } - private onPresenceUpdate = async (event: Socket.PresenceEvent) => { + /** + * @function setDefaultSlot + * @description Removes the slot from the participant + * @returns void + */ + public setDefaultSlot() { const { localParticipant, participants } = useStore(StoreType.GLOBAL); - if (!event.data.slot || !localParticipant.value?.slot) return; + const slot: Slot = { + index: null, + color: MEETING_COLORS.gray, + textColor: '#fff', + colorName: 'gray', + timestamp: Date.now(), + }; + + this.slot = slot; + + localParticipant.publish({ + ...localParticipant.value, + slot: slot, + }); + + participants.publish({ + ...participants.value, + [localParticipant.value.id]: { + ...participants.value[localParticipant.value.id], + slot, + }, + }); + + this.room.presence.update({ slot }); + } + + private onPresenceUpdate = async (event: Socket.PresenceEvent) => { + const { localParticipant } = this.useStore(StoreType.GLOBAL); if (event.id === localParticipant.value.id) { + const slot = await this.validateSlotType(event.data); + localParticipant.publish({ ...localParticipant.value, - slot: event.data.slot, + slot: slot, }); - this.slotIndex = event.data.slot.index; + return; } - if (event.data.slot?.index === this.slotIndex) { - this.slotIndex = null; + if (event.data.slot?.index === null || this.slot.index === null) return; + + if (event.data.slot?.index === this.slot?.index) { + const slotData = await this.assignSlot(); + localParticipant.publish({ ...localParticipant.value, - slot: null, + slot: slotData, }); - const slotData = await this.assignSlot(); - console.debug( `[SuperViz] - Slot reassigned to ${localParticipant.value.id}, slot: ${slotData.colorName}`, ); } }; + + public participantNeedsSlot = (participant: Participant): boolean => { + const COMPONENTS_THAT_NEED_SLOT = [ + ComponentNames.FORM_ELEMENTS, + ComponentNames.WHO_IS_ONLINE, + ComponentNames.PRESENCE, + ComponentNames.PRESENCE_AUTODESK, + ComponentNames.PRESENCE_MATTERPORT, + ComponentNames.PRESENCE_THREEJS, + ]; + + const componentsNeedSlot = COMPONENTS_THAT_NEED_SLOT.some((component) => { + return participant?.activeComponents?.includes(component); + }); + + const videoNeedSlot = + participant?.activeComponents?.includes(ComponentNames.VIDEO_CONFERENCE) && + participant.type !== ParticipantType.AUDIENCE; + + const needSlot = componentsNeedSlot || videoNeedSlot; + + return needSlot; + }; + + /** + * @function validateSlotType + * @description validate if the participant needs a slot + * @param {Participant} participant - new participant data + * @returns {void} + */ + private validateSlotType = async (participant: Participant): Promise => { + if (this.isAssigningSlot) return this.slot; + + const needSlot = this.participantNeedsSlot(participant); + + if (participant.slot?.index === null && needSlot) { + const slotData = await this.assignSlot(); + this.slot = slotData; + } + + if (participant.slot?.index !== null && !needSlot) { + this.setDefaultSlot(); + } + + return this.slot; + }; } diff --git a/src/web-components/who-is-online/components/dropdown.test.ts b/src/web-components/who-is-online/components/dropdown.test.ts index 582268e2..27947374 100644 --- a/src/web-components/who-is-online/components/dropdown.test.ts +++ b/src/web-components/who-is-online/components/dropdown.test.ts @@ -1,9 +1,13 @@ import '.'; -import { MeetingColorsHex } from '../../../common/types/meeting-colors.types'; +import { MEETING_COLORS } from '../../../common/types/meeting-colors.types'; + import { StoreType } from '../../../common/types/stores.types'; import sleep from '../../../common/utils/sleep'; import { useStore } from '../../../common/utils/use-store'; -import { Participant, WIODropdownOptions } from '../../../components/who-is-online/types'; +import { + WhoIsOnlineParticipant, + WIODropdownOptions, +} from '../../../components/who-is-online/types'; interface elementProps { position: string; @@ -14,14 +18,14 @@ interface elementProps { icons?: string[]; } -const MOCK_PARTICIPANTS: Participant[] = [ +const MOCK_PARTICIPANTS: WhoIsOnlineParticipant[] = [ { name: 'John Zero', avatar: { imageUrl: 'https://example.com', - color: MeetingColorsHex[0], + color: MEETING_COLORS.turquoise, firstLetter: 'J', - slotIndex: 0, + letterColor: '#fff', }, id: '1', activeComponents: ['whoisonline', 'presence'], @@ -40,14 +44,15 @@ const MOCK_PARTICIPANTS: Participant[] = [ label: WIODropdownOptions.PRIVATE, }, ], + isPrivate: false, }, { name: 'John Uno', avatar: { imageUrl: '', - color: MeetingColorsHex[1], + color: MEETING_COLORS.orange, firstLetter: 'J', - slotIndex: 1, + letterColor: '#fff', }, id: '2', activeComponents: ['whoisonline'], @@ -63,14 +68,15 @@ const MOCK_PARTICIPANTS: Participant[] = [ label: WIODropdownOptions.LOCAL_FOLLOW, }, ], + isPrivate: false, }, { name: 'John Doe', avatar: { imageUrl: '', - color: MeetingColorsHex[2], + color: MEETING_COLORS.brown, firstLetter: 'J', - slotIndex: 2, + letterColor: '#26242A', }, id: '3', activeComponents: ['whoisonline', 'presence'], @@ -86,6 +92,7 @@ const MOCK_PARTICIPANTS: Participant[] = [ label: WIODropdownOptions.LOCAL_FOLLOW, }, ], + isPrivate: false, }, ]; @@ -236,41 +243,6 @@ describe('who-is-online-dropdown', () => { expect(spy).toHaveBeenCalled(); }); - test('should give a black color to the letter when the slotIndex is not in the textColorValues', async () => { - createEl({ position: 'bottom' }); - - const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish([MOCK_PARTICIPANTS[2]]); - await sleep(); - - const letter = element()?.shadowRoot?.querySelector('.who-is-online__participant__avatar'); - - const backgroundColor = MeetingColorsHex[MOCK_PARTICIPANTS[2].avatar.slotIndex as number]; - expect(letter?.getAttribute('style')).toBe( - `background-color: ${backgroundColor}; color: #26242A`, - ); - }); - - test('should give a white color to the letter when the slotIndex is in the textColorValues', async () => { - const participant = { - ...MOCK_PARTICIPANTS[0], - slotIndex: 1, - color: MeetingColorsHex[1], - }; - - createEl({ position: 'bottom' }); - const { extras } = useStore(StoreType.WHO_IS_ONLINE); - extras.publish([MOCK_PARTICIPANTS[1]]); - await sleep(); - - const letter = element()?.shadowRoot?.querySelector('.who-is-online__participant__avatar'); - - const backgroundColor = MeetingColorsHex[MOCK_PARTICIPANTS[1].avatar.slotIndex as number]; - expect(letter?.getAttribute('style')).toBe( - `background-color: ${backgroundColor}; color: #FFFFFF`, - ); - }); - test('should not render participants when there is no participant', async () => { createEl({ position: 'bottom' }); const { extras } = useStore(StoreType.WHO_IS_ONLINE); @@ -301,8 +273,9 @@ describe('who-is-online-dropdown', () => { imageUrl: '', color: 'red', firstLetter: 'J', - slotIndex: 0, + letterColor: '#fff', }, + isPrivate: false, id: '1', name: 'John Zero', activeComponents: ['whoisonline', 'presence'], diff --git a/src/web-components/who-is-online/components/dropdown.ts b/src/web-components/who-is-online/components/dropdown.ts index 345025d9..41c31251 100644 --- a/src/web-components/who-is-online/components/dropdown.ts +++ b/src/web-components/who-is-online/components/dropdown.ts @@ -4,7 +4,6 @@ import { customElement } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { repeat } from 'lit/directives/repeat.js'; -import { INDEX_IS_WHITE_TEXT } from '../../../common/types/meeting-colors.types'; import { StoreType } from '../../../common/types/stores.types'; import { Avatar, Participant } from '../../../components/who-is-online/types'; import { WebComponentsBase } from '../../base'; @@ -115,7 +114,7 @@ export class WhoIsOnlineDropdown extends WebComponentsBaseElement { }; }; - private getAvatar({ color, imageUrl, firstLetter, slotIndex }: Avatar) { + private getAvatar({ color, imageUrl, firstLetter, letterColor }: Avatar) { if (imageUrl) { return html` `; } - const letterColor = INDEX_IS_WHITE_TEXT.includes(slotIndex) ? '#FFFFFF' : '#26242A'; - return html`
{ - this.participantColor = participant.color; + this.participantColor = participant.slot?.color ?? MEETING_COLORS.gray; }); const { following } = this.useStore(StoreType.WHO_IS_ONLINE); diff --git a/src/web-components/who-is-online/who-is-online.test.ts b/src/web-components/who-is-online/who-is-online.test.ts index e7e119a7..1f7f4811 100644 --- a/src/web-components/who-is-online/who-is-online.test.ts +++ b/src/web-components/who-is-online/who-is-online.test.ts @@ -2,7 +2,7 @@ import '.'; import { MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; import { RealtimeEvent } from '../../common/types/events.types'; -import { MeetingColorsHex } from '../../common/types/meeting-colors.types'; +import { MEETING_COLORS } from '../../common/types/meeting-colors.types'; import { StoreType } from '../../common/types/stores.types'; import sleep from '../../common/utils/sleep'; import { useStore } from '../../common/utils/use-store'; @@ -17,9 +17,9 @@ const MOCK_PARTICIPANTS: WhoIsOnlineParticipant[] = [ name: 'John Zero', avatar: { imageUrl: 'https://example.com', - color: MeetingColorsHex[0], + color: MEETING_COLORS.turquoise, firstLetter: 'J', - slotIndex: 0, + letterColor: '#fff', }, id: '1', activeComponents: ['whoisonline', 'presence'], @@ -44,9 +44,9 @@ const MOCK_PARTICIPANTS: WhoIsOnlineParticipant[] = [ name: 'John Uno', avatar: { imageUrl: '', - color: MeetingColorsHex[1], + color: MEETING_COLORS.orange, firstLetter: 'J', - slotIndex: 1, + letterColor: '#fff', }, id: '2', activeComponents: ['whoisonline'], @@ -68,9 +68,9 @@ const MOCK_PARTICIPANTS: WhoIsOnlineParticipant[] = [ name: 'John Doe', avatar: { imageUrl: '', - color: MeetingColorsHex[2], + color: MEETING_COLORS.brown, firstLetter: 'J', - slotIndex: 2, + letterColor: '#26242A', }, id: '3', activeComponents: ['whoisonline', 'presence'], @@ -187,31 +187,6 @@ describe('Who Is Online', () => { expect(extraParticipants).toBeTruthy(); }); - test('should give a black color to the letter when the slotIndex is not in the textColorValues', async () => { - const { participants } = useStore(StoreType.WHO_IS_ONLINE); - participants.publish([MOCK_PARTICIPANTS[2]]); - await sleep(); - - const letter = element?.shadowRoot?.querySelector('.who-is-online__participant__avatar'); - const backgroundColor = MeetingColorsHex[MOCK_PARTICIPANTS[2].avatar.slotIndex as number]; - expect(letter?.getAttribute('style')).toBe( - `background-color: ${backgroundColor}; color: #26242A`, - ); - }); - - test('should give a white color to the letter when the slotIndex is in the textColorValues', async () => { - const { participants } = useStore(StoreType.WHO_IS_ONLINE); - participants.publish([MOCK_PARTICIPANTS[1]]); - await sleep(); - - const letter = element?.shadowRoot?.querySelector('.who-is-online__participant__avatar'); - - const backgroundColor = MeetingColorsHex[MOCK_PARTICIPANTS[1].avatar.slotIndex as number]; - expect(letter?.getAttribute('style')).toBe( - `background-color: ${backgroundColor}; color: #FFFFFF`, - ); - }); - test('should toggle open property', () => { expect(element['open']).toBeFalsy(); diff --git a/src/web-components/who-is-online/who-is-online.ts b/src/web-components/who-is-online/who-is-online.ts index 15d6e688..9026b7fe 100644 --- a/src/web-components/who-is-online/who-is-online.ts +++ b/src/web-components/who-is-online/who-is-online.ts @@ -4,7 +4,6 @@ import { classMap } from 'lit/directives/class-map.js'; import { repeat } from 'lit/directives/repeat.js'; import { RealtimeEvent } from '../../common/types/events.types'; -import { INDEX_IS_WHITE_TEXT } from '../../common/types/meeting-colors.types'; import { StoreType } from '../../common/types/stores.types'; import { Avatar, @@ -147,7 +146,7 @@ export class WhoIsOnline extends WebComponentsBaseElement { this.showTooltip = !this.showTooltip; }; - private getAvatar({ color, imageUrl, firstLetter, slotIndex }: Avatar) { + private getAvatar({ color, imageUrl, firstLetter, letterColor }: Avatar) { if (imageUrl) { return html` `; } - const letterColor = INDEX_IS_WHITE_TEXT.includes(slotIndex) ? '#FFFFFF' : '#26242A'; - return html`
id === participantId); this.everyoneFollowsMe = true; @@ -315,7 +312,6 @@ export class WhoIsOnline extends WebComponentsBaseElement { id, name, color, - slotIndex, }); }