From 7e0a82777aa830d166643ec7e884dcbd1bd95f12 Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 6 Jan 2025 16:39:22 -0300 Subject: [PATCH] feat: assiging slots to the participants --- .../room/src/common/types/colors.types.ts | 88 ++++++++ .../src/common/types/participant.types.ts | 2 +- packages/room/src/core/index.ts | 21 +- packages/room/src/services/slot/index.test.ts | 92 ++++++++ packages/room/src/services/slot/index.ts | 196 ++++++++++++++++++ 5 files changed, 384 insertions(+), 15 deletions(-) create mode 100644 packages/room/src/common/types/colors.types.ts create mode 100644 packages/room/src/services/slot/index.test.ts create mode 100644 packages/room/src/services/slot/index.ts diff --git a/packages/room/src/common/types/colors.types.ts b/packages/room/src/common/types/colors.types.ts new file mode 100644 index 00000000..1de9b662 --- /dev/null +++ b/packages/room/src/common/types/colors.types.ts @@ -0,0 +1,88 @@ +export const NAME_IS_WHITE_TEXT = [ + 'rosybrown', + 'red', + 'saddlebrown', + 'coral', + 'orange', + 'brown', + '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 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 const MEETING_COLORS_ARRAY = Object.values(MEETING_COLORS); +export const MEETING_COLORS_KEYS = Object.keys(MEETING_COLORS); diff --git a/packages/room/src/common/types/participant.types.ts b/packages/room/src/common/types/participant.types.ts index a6c63dff..4f4dc86e 100644 --- a/packages/room/src/common/types/participant.types.ts +++ b/packages/room/src/common/types/participant.types.ts @@ -10,7 +10,7 @@ export type Participant = InitialParticipant & { } export type Slot = { - index: number; + index: number | null; color: string; textColor: string; colorName: string; diff --git a/packages/room/src/core/index.ts b/packages/room/src/core/index.ts index 9501aeb7..a29ec981 100644 --- a/packages/room/src/core/index.ts +++ b/packages/room/src/core/index.ts @@ -5,6 +5,7 @@ import { InitialParticipant, Participant } from '../common/types/participant.typ import { Logger } from '../common/utils/logger'; import { IOC } from '../services/io'; import { IOCState } from '../services/io/types'; +import { SlotService } from '../services/slot'; import { GeneralEvent, ParticipantEvent, RoomEventPayload, RoomParams, Callback, EventOptions, RoomEvent } from './types'; @@ -14,6 +15,8 @@ export class Room { private io: IOC; private room: SocketRoomType; + private slotService: SlotService; + private participants: Map = new Map(); private state: IOCState = IOCState.DISCONNECTED; private logger: Logger; @@ -54,6 +57,7 @@ export class Room { this.subscriptions.clear(); this.observers.clear(); this.participants.clear(); + this.slotService; if (typeof window !== 'undefined') { delete window.SUPERVIZ_ROOM; @@ -138,6 +142,7 @@ export class Room { private init() { this.io.stateSubject.subscribe(this.onConnectionStateChange); this.room = this.io.createRoom('room', 'unlimited'); + this.slotService = new SlotService(this.room, this.participant); this.subscribeToRoomEvents(); } @@ -158,13 +163,7 @@ export class Room { name: participant?.name ? participant.name : message.name, activeComponents: participant?.activeComponents ?? [], email: participant?.email ?? null, - slot: participant?.slot ?? { - index: null, - color: '#878291', - textColor: '#fff', - colorName: 'gray', - timestamp: Date.now(), - }, + slot: participant?.slot ?? SlotService.getDefaultSlot(), }; } @@ -180,13 +179,7 @@ export class Room { name: initialData.name, email: initialData.email ?? null, activeComponents: [], - slot: { - index: null, - color: '#878291', - textColor: '#fff', - colorName: 'gray', - timestamp: Date.now(), - }, + slot: SlotService.getDefaultSlot(), }; } diff --git a/packages/room/src/services/slot/index.test.ts b/packages/room/src/services/slot/index.test.ts new file mode 100644 index 00000000..075266d1 --- /dev/null +++ b/packages/room/src/services/slot/index.test.ts @@ -0,0 +1,92 @@ +import { PresenceEvent, PresenceEvents, Room } from '@superviz/socket-client'; + +import { Participant, Slot } from '../../common/types/participant.types'; + +import { SlotService } from './index'; + +describe('SlotService', () => { + let room: Room; + let participant: Participant; + let slotService: SlotService; + + beforeEach(() => { + room = { + presence: { + on: jest.fn(), + get: jest.fn(), + update: jest.fn(), + }, + } as unknown as Room; + + participant = { + id: 'participant1', + slot: SlotService.getDefaultSlot(), + } as Participant; + + slotService = new SlotService(room, participant); + }); + + it('should initialize with default slot', () => { + expect(slotService.slot).toEqual({ + ...SlotService.getDefaultSlot(), + timestamp: expect.any(Number), + }); + }); + + it('should assign a slot to the participant', async () => { + room.presence.get = jest.fn((callback) => callback([])); + room.presence.update = jest.fn(); + + const slot = await slotService['assignSlot'](); + + expect(slot).toBeDefined(); + expect(slot.index).toBeGreaterThanOrEqual(0); + expect(slot.index).toBeLessThan(50); + expect(room.presence.update).toHaveBeenCalledWith({ slot }); + }); + + it('should handle presence update for the same participant', async () => { + const event: PresenceEvent = { + id: participant.id, + data: { slot: { index: 1 } }, + } as PresenceEvent; + + slotService['validateSlotType'] = jest.fn().mockResolvedValue({ index: 1 }); + + await slotService['onPresenceUpdate'](event); + + expect(slotService['validateSlotType']).toHaveBeenCalledWith(event.data); + expect(participant.slot.index).toBe(1); + }); + + it('should set default slot', () => { + slotService['setDefaultSlot'](); + + expect(slotService.slot).toEqual(SlotService.getDefaultSlot()); + expect(room.presence.update).toHaveBeenCalledWith({ slot: SlotService.getDefaultSlot() }); + }); + + it('should validate and assign slot type', async () => { + slotService['assignSlot'] = jest.fn().mockResolvedValue({ index: 1 }); + slotService['setDefaultSlot'] = jest.fn(); + + const slot = await slotService['validateSlotType'](participant); + + expect(slotService['assignSlot']).toHaveBeenCalled(); + expect(slot.index).toBe(1); + }); + + it('should not assign slot if already assigning', async () => { + slotService['isAssigningSlot'] = true; + + const slot = await slotService['assignSlot'](); + + expect(slot).toEqual(slotService.slot); + }); + + it('should throw error if no more slots are available', async () => { + room.presence.get = jest.fn((callback) => callback(Array(50).fill({}))); + + await expect(slotService['assignSlot']()).resolves.toEqual(null); + }); +}); diff --git a/packages/room/src/services/slot/index.ts b/packages/room/src/services/slot/index.ts new file mode 100644 index 00000000..81555c50 --- /dev/null +++ b/packages/room/src/services/slot/index.ts @@ -0,0 +1,196 @@ +import { PresenceEvent, PresenceEvents, Room } from '@superviz/socket-client'; + +import { MEETING_COLORS, NAME_IS_WHITE_TEXT } from '../../common/types/colors.types'; +import { Participant, Slot } from '../../common/types/participant.types'; + +export class SlotService { + private isAssigningSlot = false; + + public slot: Slot = SlotService.getDefaultSlot(); + + constructor( + private room: Room, + private participant: Participant, + ) { + this.room = room; + + this.room.presence.on(PresenceEvents.UPDATE, this.onPresenceUpdate); + } + + /** + * Retrieves the default slot configuration. + * + * @returns {Slot} An object representing the default slot with the following properties: + * - `index`: The index of the slot, set to `null`. + * - `color`: The color of the slot, set to `MEETING_COLORS.gray`. + * - `textColor`: The text color of the slot, set to `'#fff'`. + * - `colorName`: The name of the color, set to `'gray'`. + * - `timestamp`: The current timestamp. + */ + public static getDefaultSlot(): Slot { + return { + index: null, + color: MEETING_COLORS.gray, + textColor: '#fff', + colorName: 'gray', + timestamp: Date.now(), + }; + } + + /** + * Assigns a slot to the local participant in the room. + * + * This method ensures that the local participant is assigned a unique slot + * within the room. If the participant is already in the process of being + * assigned a slot, it returns the current slot. Otherwise, it attempts to + * assign a new slot by checking the presence of other participants and + * ensuring no duplicate slots are assigned. + * + * @returns {Promise} A promise that resolves to the assigned slot data. + * + * @throws {Error} If no more slots are available. + */ + private async assignSlot(): Promise { + if (this.isAssigningSlot) return this.slot; + + this.isAssigningSlot = true; + let slots = Array.from({ length: 50 }, (_, i) => i); + let slot = Math.floor(Math.random() * 50); + + try { + await new Promise((resolve, reject) => { + this.room.presence.get((presences) => { + if (!presences || !presences.length) resolve(true); + + if (presences.length >= 50) { + slots = []; + reject(new Error('[SuperViz] - No more slots available')); + return; + } + + presences.forEach((presence: PresenceEvent) => { + if (presence.id === this.participant.id) return; + + slots = slots.filter((s) => s !== presence.data?.slot?.index); + }); + + resolve(true); + }); + }); + + const isUsing = !slots.includes(slot); + + if (isUsing) { + slot = slots.shift(); + } + + const color = Object.keys(MEETING_COLORS)[slot]; + + const slotData = { + index: slot, + color: MEETING_COLORS[color], + textColor: NAME_IS_WHITE_TEXT.includes(color) ? '#fff' : '#000', + colorName: color, + timestamp: Date.now(), + }; + + this.slot = slotData; + + this.room.presence.update({ slot: slotData }); + + this.isAssigningSlot = false; + return slotData; + } catch (error) { + console.error(error); + return null; + } + } + + /** + * Sets the default slot for the room. + * + * This method initializes a slot with default values including: + * - `index`: null + * - `color`: MEETING_COLORS.gray + * - `textColor`: '#fff' + * - `colorName`: 'gray' + * - `timestamp`: current date and time + * + * The initialized slot is then assigned to the `slot` property of the instance + * and the room's presence is updated with the new slot information. + */ + private setDefaultSlot() { + const slot: Slot = SlotService.getDefaultSlot(); + + this.slot = slot; + + this.room.presence.update({ slot }); + } + + /** + * Handles the presence update event for a participant. + * + * @param event - The presence event containing participant data. + * @returns A promise that resolves when the presence update handling is complete. + * + * This method performs the following actions: + * - If the event ID matches the local participant's ID, + it validates the slot type with the event data. + * - If the slot index in the event data or the current slot index is null, it returns early. + * - If the slot index in the event data matches the current slot index, it assigns a new slot. + */ + private onPresenceUpdate = async (event: PresenceEvent) => { + if (event.id === this.participant.id) { + const slot = await this.validateSlotType(event.data); + + this.participant = Object.assign(this.participant, event.data, { slot }); + return; + } + + if (event.data.slot?.index === null || this.slot.index === null) return; + + if (event.data.slot?.index === this.slot?.index) { + await this.assignSlot(); + } + }; + + /** + * Determines if a participant needs a slot. + * + * @param participant - The participant to check. + * @returns `true` if the participant needs a slot, otherwise `false`. + */ + private participantNeedsSlot = (participant: Participant): boolean => { + return true; + }; + + /** + * Validates and assigns a slot type to a participant if needed. + * + * @param participant - The participant for whom the slot type needs to be validated. + * @returns A promise that resolves to the assigned or default slot. + * + * The function performs the following steps: + * 1. If a slot is currently being assigned, it returns the existing slot. + * 2. Checks if the participant needs a slot. + * 3. If the participant's slot index is null and they need a slot, assigns a new slot. + * 4. If the participant's slot index is not null and they + do not need a slot, sets the default slot. + */ + 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; + }; +}