diff --git a/.esbuild/build.js b/.esbuild/build.js index 9f7f2fd6..5c6e8dbb 100644 --- a/.esbuild/build.js +++ b/.esbuild/build.js @@ -6,7 +6,23 @@ const config = Object.assign({}, baseConfig, { }); require('esbuild') - .build(config) + .build({ + ...config, + platform: 'node', // for CJS + outfile: 'lib/index.js', + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); + +require('esbuild') + .build({ + ...config, + platform: 'neutral', // for ESM + format: 'esm', + outfile: 'lib/index.esm.js', + }) .catch((error) => { console.error(error); process.exit(1); diff --git a/.esbuild/config.js b/.esbuild/config.js index e1f2dfb2..85c16c5b 100644 --- a/.esbuild/config.js +++ b/.esbuild/config.js @@ -1,16 +1,12 @@ require('dotenv').config(); const { style } = require('./plugins/style-loader'); +const { dependencies } = require('../package.json'); const entries = Object.entries(process.env).filter((key) => key[0].startsWith('SDK_')); const env = Object.fromEntries(entries); module.exports = { - entryPoints: [ - './src/index.ts', - './src/core/index.ts', - './src/components/index.ts', - './src/web-components/index.ts', - ], + entryPoints: ['./src/index.ts'], loader: { '.png': 'file', '.svg': 'file', @@ -23,7 +19,7 @@ module.exports = { bundle: true, target: 'es6', format: 'esm', - outdir: 'lib', + external: Object.keys(dependencies), define: { 'process.env': JSON.stringify(env), }, diff --git a/.esbuild/watch.js b/.esbuild/watch.js index b193c0c9..45551a36 100644 --- a/.esbuild/watch.js +++ b/.esbuild/watch.js @@ -2,11 +2,26 @@ const baseConfig = require('./config'); const config = Object.assign({}, baseConfig, { watch: true, - outdir: 'dist', }); require('esbuild') - .build(config) + .build({ + ...config, + platform: 'node', // for CJS + outfile: 'dist/index.js', + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); + +require('esbuild') + .build({ + ...config, + platform: 'neutral', // for ESM + format: 'esm', + outfile: 'dist/index.esm.js', + }) .catch((error) => { console.error(error); process.exit(1); diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index efb5dc3c..7b1c5b19 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -6,11 +6,11 @@ on: - opened - synchronize jobs: - delete-comments: + delete-comments: runs-on: ubuntu-latest steps: - uses: izhangzhihao/delete-comment@master - with: + with: github_token: ${{ secrets.GITHUB_TOKEN }} delete_user_name: SuperViz-Dev issue_number: ${{ github.event.number }} @@ -28,7 +28,7 @@ jobs: touch .version.js && echo "echo \"export const version = 'test'\" > .version.js" | bash - - name: Create a .remote-config.js file run: | - touch .remote-config.js && echo "echo \"export default { apiUrl: 'https://localhost:3000', conferenceLayerUrl: 'https://localhost:8080' }\" > .remote-config.js" | bash - + touch .remote-config.js && echo "echo \"module.exports = { remoteConfig: { apiUrl: 'https://dev.nodeapi.superviz.com', conferenceLayerUrl: 'https://video-frame.superviz.com/lab/index.html'}};\" > .remote-config.js" | bash - - name: Run tests run: yarn test:unit:ci --coverage - name: Code Coverage Report @@ -70,6 +70,6 @@ jobs: touch .version.js && echo "echo \"export const version = 'test'\" > .version.js" | bash - - name: Create a .remote-config.js file run: | - touch .remote-config.js && echo "echo \"export default { apiUrl: 'https://localhost:3000', conferenceLayerUrl: 'https://localhost:8080' }\" > .remote-config.js" | bash - + touch .remote-config.js && echo "echo \"module.exports = { remoteConfig: { apiUrl: 'https://dev.nodeapi.superviz.com', conferenceLayerUrl: 'https://video-frame.superviz.com/lab/index.html'}};\" > .remote-config.js" | bash - - name: Build run: yarn build diff --git a/.github/workflows/publish-beta-release.yml b/.github/workflows/publish-beta-release.yml index b7c56c68..6cad78c7 100644 --- a/.github/workflows/publish-beta-release.yml +++ b/.github/workflows/publish-beta-release.yml @@ -19,7 +19,7 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create a .remote-config.js file run: | - touch .remote-config.js && echo "echo \"export default { apiUrl: 'https://localhost:3000', conferenceLayerUrl: 'https://localhost:8080' }\" > .remote-config.js" | bash - + touch .remote-config.js && echo "echo \"module.exports = { remoteConfig: { apiUrl: 'https://dev.nodeapi.superviz.com', conferenceLayerUrl: 'https://video-frame.superviz.com/lab/index.html'}};\" > .remote-config.js" | bash - - run: git config --global user.name SuperViz - run: git config --global user.email ci@superviz.com - name: Publish npm package diff --git a/.github/workflows/publish-lab-release.yml b/.github/workflows/publish-lab-release.yml index bc1ccfd0..778e8934 100644 --- a/.github/workflows/publish-lab-release.yml +++ b/.github/workflows/publish-lab-release.yml @@ -19,7 +19,7 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create a .remote-config.js file run: | - touch .remote-config.js && echo "echo \"export default { apiUrl: 'https://localhost:3000', conferenceLayerUrl: 'https://localhost:8080' }\" > .remote-config.js" | bash - + touch .remote-config.js && echo "echo \"module.exports = { remoteConfig: { apiUrl: 'https://dev.nodeapi.superviz.com', conferenceLayerUrl: 'https://video-frame.superviz.com/lab/index.html'}};\" > .remote-config.js" | bash - - run: git config --global user.name SuperViz - run: git config --global user.email ci@superviz.com - name: Publish npm package diff --git a/.github/workflows/publish-prod-release.yml b/.github/workflows/publish-prod-release.yml index 81f3aec5..b91356a5 100644 --- a/.github/workflows/publish-prod-release.yml +++ b/.github/workflows/publish-prod-release.yml @@ -19,7 +19,7 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create a .remote-config.js file run: | - touch .remote-config.js && echo "echo \"export default { apiUrl: 'https://localhost:3000', conferenceLayerUrl: 'https://localhost:8080' }\" > .remote-config.js" | bash - + touch .remote-config.js && echo "echo \"module.exports = { remoteConfig: { apiUrl: 'https://dev.nodeapi.superviz.com', conferenceLayerUrl: 'https://video-frame.superviz.com/lab/index.html'}};\" > .remote-config.js" | bash - - run: git config --global user.name SuperViz - run: git config --global user.email ci@superviz.com - name: Publish npm package diff --git a/.remote-config.d.ts b/.remote-config.d.ts new file mode 100644 index 00000000..6f7c2f33 --- /dev/null +++ b/.remote-config.d.ts @@ -0,0 +1,4 @@ +export namespace remoteConfig { + let apiUrl: string; + let conferenceLayerUrl: string; +} diff --git a/.version.d.ts b/.version.d.ts new file mode 100644 index 00000000..4b2c8905 --- /dev/null +++ b/.version.d.ts @@ -0,0 +1 @@ +export const version: "lab"; diff --git a/__mocks__/io.mock.ts b/__mocks__/io.mock.ts new file mode 100644 index 00000000..eaf00978 --- /dev/null +++ b/__mocks__/io.mock.ts @@ -0,0 +1,32 @@ +import { jest } from '@jest/globals'; +import * as Socket from '@superviz/socket-client'; + +export const MOCK_IO = { + Realtime: class { + public connection: { + on: (state: string) => void; + off: () => void; + }; + + constructor(apiKey: string, environment: string, participant: any) { + this.connection = { + on: jest.fn(), + off: jest.fn(), + }; + } + + public connect() { + return { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + presence: { + on: jest.fn(), + off: jest.fn(), + }, + }; + } + + public destroy() {} + }, +}; diff --git a/__mocks__/realtime.mock.ts b/__mocks__/realtime.mock.ts index e00deafa..662f8858 100644 --- a/__mocks__/realtime.mock.ts +++ b/__mocks__/realtime.mock.ts @@ -29,7 +29,6 @@ export const ABLY_REALTIME_MOCK: AblyRealtimeService = { setDrawing: jest.fn(), freezeSync: jest.fn(), setParticipantData: jest.fn(), - setSyncProperty: jest.fn(), setKickParticipant: jest.fn(), setTranscript: jest.fn(), start: jest.fn(), @@ -39,13 +38,6 @@ export const ABLY_REALTIME_MOCK: AblyRealtimeService = { setGatherWIOParticipant: jest.fn(), domainWhitelisted: true, isDomainWhitelisted: jest.fn().mockReturnValue(true), - fetchSyncClientProperty: jest.fn((key?: string) => { - if (key) { - return createRealtimeMessage(key); - } - - return createRealtimeHistory(); - }), getSlotColor: jest.fn().mockReturnValue({ color: MeetingColorsHex[0], name: MeetingColors[0], @@ -61,7 +53,6 @@ export const ABLY_REALTIME_MOCK: AblyRealtimeService = { participantsObserver: MOCK_OBSERVER_HELPER, participantJoinedObserver: MOCK_OBSERVER_HELPER, participantLeaveObserver: MOCK_OBSERVER_HELPER, - syncPropertiesObserver: MOCK_OBSERVER_HELPER, kickAllParticipantsObserver: MOCK_OBSERVER_HELPER, kickParticipantObserver: MOCK_OBSERVER_HELPER, authenticationObserver: MOCK_OBSERVER_HELPER, diff --git a/jest.config.js b/jest.config.js index a67d5ac9..13f1e82b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,5 +20,10 @@ module.exports = { '/e2e/', '/src/web-components', ], + transformIgnorePatterns: ['node_modules/(?!@superviz/socket-client)'], + transform: { + '^.+\\.ts$': 'ts-jest', + '^.+\\.js$': 'ts-jest', + }, setupFiles: ['/jest.setup.js'], }; diff --git a/lib.md b/lib.md deleted file mode 100644 index a2a87472..00000000 --- a/lib.md +++ /dev/null @@ -1,80 +0,0 @@ -## Initialization example - -```ts -import { SuperViz } from '@superviz/sdk'; -import { - VideoComponent, - Realtime, - PresenceComponent, - CommentsComponent, - CanvasAdapter, -} from '@superviz/sdk/components'; -import { Presence3D, CommentsAdapter } from '@superviz/matterport'; -import { Presence3D, CommentsAdapter } from '@superviz/three'; - -const SuperViz = await Manager(DEVELOPER_KEY, { - roomId: this.roomId, - participant: { - id: this.userId, - name: 'John Doe', - avatar: { - thumbnail: 'https://production.storage.superviz.com/readyplayerme/1.png', - }, - }, -}); - -const video = new VideoComponent({ - userType: 'host' | 'guest' | 'audience', -}); -SuperViz.addComponent(video); - -const realtime = new Realtime(); -SuperViz.addComponent(realtime); - -const mpPresence = new Presence3D(mpInstance); -SuperViz.addComponent(mpPresence); - -// Initialize comments with canvas -const canvasAdapter = new CanvasAdapter('canvas-id'); -const comments = new CommentsComponent(canvasAdapter); -SuperViz.addComponent(comments); - -// Initialize coments with matterport 3d component -const mpCommentsAdapter = new CommentsAdapter(mpInstance); -const comments = new CommentsComponent(mpCommentsAdapter); -SuperViz.addComponent(comments); -SuperViz.addComponent(mpPresence); - -// PubSub -realtime.unsubscribe('presence-updated', () => {}); -realtime.subscribe('presence-updated', () => {}); -realtime.publish('presence-updated', {}); - -// removing component -SuperViz.removeComponent(mpPresence); -``` - -### Folder strucuture - -``` -- sdk - - src - - common - - utils - - types - - components - - comments - - presence - - video - - web components - - comments - - presence - - services - - realtime - - api - - auth - - core - - manager.ts - - pubsub.ts - - index.ts -``` diff --git a/package.json b/package.json index 4df129f3..2bea9c7a 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.0.0-development", "description": "SuperViz SDK", "main": "./lib/index.js", + "module": "./lib/index.esm.js", "types": "./lib/index.d.ts", "files": [ "lib" @@ -10,10 +11,10 @@ "scripts": { "prepare": "husky install", "build": "node ./.esbuild/build.js", - "postbuild": "./node_modules/typescript/bin/tsc --emitDeclarationOnly --declaration", + "postbuild": "./node_modules/typescript/bin/tsc", "watch": "concurrently -n code,types \"yarn watch:code\" \"yarn watch:types\"", "watch:code": "node ./.esbuild/watch.js", - "watch:types": "./node_modules/typescript/bin/tsc --watch --outDir dist", + "watch:types": "./node_modules/typescript/bin/tsc --watch --outDir ./dist", "test:unit": "jest", "test:unit:watch": "jest --watch", "test:unit:coverage": "jest --coverage", @@ -71,12 +72,13 @@ "jest-fetch-mock": "^3.0.3", "rimraf": "^5.0.5", "semantic-release": "^22.0.5", - "ts-jest": "^29.1.1", + "ts-jest": "^29.1.2", "tsc": "^2.0.4", "typescript": "^5.2.2", "yargs": "^17.7.2" }, "dependencies": { + "@superviz/socket-client": "1.2.2", "ably": "^1.2.45", "bowser": "^2.11.0", "bowser-jr": "^1.0.6", @@ -86,6 +88,7 @@ "lodash.debounce": "^4.0.8", "lodash.isequal": "^4.5.0", "luxon": "^3.4.3", + "rxjs": "^7.8.1", "semantic-release-version-file": "^1.0.2" }, "config": { diff --git a/src/common/styles/global.css b/src/common/styles/global.css index a7ae530b..46ce494f 100644 --- a/src/common/styles/global.css +++ b/src/common/styles/global.css @@ -1,4 +1,4 @@ -@import url('https://unpkg.com/@superviz/sv-icons@0.8.12/css/style.css'); +@import url('https://unpkg.com/@superviz/sv-icons@0.8.13/css/style.css'); @import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;700&family=Roboto:wght@400;500;700&display=swap'); :root { @@ -17,7 +17,7 @@ html, body { margin: 0; padding: 0; overflow: hidden; - z-index: 5; + z-index: 1000; } #sv-video-wrapper iframe.sv-video-frame--right { @@ -75,5 +75,5 @@ html, body { position: absolute; display: block; z-index: 2; - transition: all 300ms ease-in-out; + transition: all 150ms linear, opacity 100s ease-in; } \ No newline at end of file diff --git a/src/common/types/participant.types.ts b/src/common/types/participant.types.ts index ac6825ad..0622e82c 100644 --- a/src/common/types/participant.types.ts +++ b/src/common/types/participant.types.ts @@ -6,11 +6,20 @@ export enum ParticipantType { AUDIENCE = 'audience', } +export type Slot = { + index: number; + color: string; + textColor: string; + colorName: string; + timestamp: number; +}; + export interface Participant { id: string; name?: string; type?: ParticipantType; color?: string; + slot?: Slot; avatar?: Avatar; isHost?: boolean; // @NOTE - this is a hack to make the participant info work with the 3D avatar diff --git a/src/common/types/stores.types.ts b/src/common/types/stores.types.ts new file mode 100644 index 00000000..01cf29f1 --- /dev/null +++ b/src/common/types/stores.types.ts @@ -0,0 +1,28 @@ +import { useGlobalStore } from '../../services/stores'; +import { PublicSubject } from '../../services/stores/common/types'; +import { useWhoIsOnlineStore } from '../../services/stores/who-is-online/index'; + +export enum StoreType { + GLOBAL = 'global-store', + COMMENTS = 'comments-store', + WHO_IS_ONLINE = 'who-is-online-store', +} + +type StoreApi any> = { + [K in keyof ReturnType]: { + subscribe(callback?: (value: keyof T) => void): void; + subject: PublicSubject; + publish(value: T): void; + value: any; + }; +}; + +// When creating new Stores, expand the ternary with the new Store. For example: +// ...T extends StoreType.GLOBAL ? StoreApi : T extends StoreType.WHO_IS_ONLINE ? StoreApi : never; +// Yes, it will be a little bit verbose, but it's not like we'll be creating more and more Stores just for. Rarely will someone need to come here +export type Store = T extends StoreType.GLOBAL + ? StoreApi + : T extends StoreType.WHO_IS_ONLINE + ? StoreApi + : never; +export type StoresTypes = typeof StoreType; diff --git a/src/common/utils/observer.ts b/src/common/utils/observer.ts index f96c5a6a..cc1155e2 100644 --- a/src/common/utils/observer.ts +++ b/src/common/utils/observer.ts @@ -59,7 +59,7 @@ export class Observer { 'superviz-sdk:observer-helper:publish:error', ` Failed to execute callback on publish value. - Callback: ${callback} + Callback: ${callback.name} Event: ${JSON.stringify(event)} Error: ${error} `, diff --git a/src/common/utils/use-store.test.ts b/src/common/utils/use-store.test.ts new file mode 100644 index 00000000..3d5f3454 --- /dev/null +++ b/src/common/utils/use-store.test.ts @@ -0,0 +1,13 @@ +import { StoreType } from '../types/stores.types'; + +import { useStore } from './use-store'; + +describe('useStore', () => { + test('should return an api to use a store', () => { + const result = useStore.call(this, StoreType.GLOBAL); + + expect(result).toHaveProperty('subscribe'); + expect(result).toHaveProperty('subject'); + expect(result).toHaveProperty('publish'); + }); +}); diff --git a/src/common/utils/use-store.ts b/src/common/utils/use-store.ts new file mode 100644 index 00000000..85fc90f4 --- /dev/null +++ b/src/common/utils/use-store.ts @@ -0,0 +1,63 @@ +import { PublicSubject } from '../../services/stores/common/types'; +import { useGlobalStore } from '../../services/stores/global'; +import { useWhoIsOnlineStore } from '../../services/stores/who-is-online'; +import { Store, StoreType } from '../types/stores.types'; + +const stores = { + [StoreType.GLOBAL]: useGlobalStore, + [StoreType.WHO_IS_ONLINE]: useWhoIsOnlineStore, +}; + +/** + * @function subscribe + * @description Subscribes to a subject and either update the value of the property each time there is a change, or call the callback that provides a custom behavior to the subscription + * @param name The name of the property to be updated in case there isn't a callback + * @param subject The subject to be subscribed + * @param callback The callback to be called each time there is a change + */ +function subscribeTo( + name: string, + subject: PublicSubject, + callback?: (value: T) => void, +): void { + subject.subscribe(this, () => { + if (callback) { + callback(subject.value); + } else { + this[name] = subject.value; + } + + if (this.requestUpdate) this.requestUpdate(); + }); + + this.unsubscribeFrom.push(subject.unsubscribe); +} + +/** + * @function useGlobalStore + * @description Returns a proxy of the global store data and a subscribe function to be used in the components + */ +export function useStore(name: T): Store { + // @TODO - Improve types to get better sugestions when writing code + const storeData = stores[name as StoreType](); + const bindedSubscribeTo = subscribeTo.bind(this); + + const proxy = new Proxy(storeData, { + get(store: Store, valueName: string) { + return { + subscribe>(callback?: (value: K) => void) { + bindedSubscribeTo(valueName, store[valueName], callback); + }, + subject: store[valueName] as typeof storeData, + get value() { + return this.subject.value; + }, + publish(newValue: keyof Store) { + this.subject.value = newValue; + }, + }; + }, + }); + + return proxy; +} diff --git a/src/components/base/index.test.ts b/src/components/base/index.test.ts index 5821f44a..0ccf1d46 100644 --- a/src/components/base/index.test.ts +++ b/src/components/base/index.test.ts @@ -3,11 +3,13 @@ import { EVENT_BUS_MOCK } from '../../../__mocks__/event-bus.mock'; import { MOCK_OBSERVER_HELPER } from '../../../__mocks__/observer-helper.mock'; import { MOCK_GROUP, MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; import { ABLY_REALTIME_MOCK } from '../../../__mocks__/realtime.mock'; -import { Group, Participant } from '../../common/types/participant.types'; import { Logger } from '../../common/utils'; +import { useStore } from '../../common/utils/use-store'; import { Configuration } from '../../services/config/types'; import { EventBus } from '../../services/event-bus'; +import { IOC } from '../../services/io'; import { AblyRealtimeService } from '../../services/realtime'; +import { useGlobalStore } from '../../services/stores'; import { ComponentNames } from '../types'; import { BaseComponent } from '.'; @@ -48,6 +50,10 @@ describe('BaseComponent', () => { console.error = jest.fn(); jest.clearAllMocks(); + const { localParticipant, group } = useGlobalStore(); + localParticipant.value = MOCK_LOCAL_PARTICIPANT; + group.value = MOCK_GROUP; + DummyComponentInstance = new DummyComponent(); }); @@ -58,11 +64,11 @@ describe('BaseComponent', () => { describe('attach', () => { test('should not call start if realtime is not joined room', () => { DummyComponentInstance.attach({ + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), realtime: ABLY_REALTIME_MOCK, - localParticipant: MOCK_LOCAL_PARTICIPANT, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + useStore, }); DummyComponentInstance['start'] = jest.fn(DummyComponentInstance['start']); @@ -82,11 +88,11 @@ describe('BaseComponent', () => { ablyMock['isDomainWhitelisted'] = false; DummyComponentInstance.attach({ + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), realtime: ablyMock as AblyRealtimeService, - localParticipant: MOCK_LOCAL_PARTICIPANT, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + useStore, }); DummyComponentInstance['start'] = jest.fn(); @@ -101,29 +107,28 @@ describe('BaseComponent', () => { expect(DummyComponentInstance.attach).toBeDefined(); DummyComponentInstance.attach({ - localParticipant: MOCK_LOCAL_PARTICIPANT, + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), realtime: REALTIME_MOCK, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + useStore, }); - expect(DummyComponentInstance['localParticipant']).toEqual(MOCK_LOCAL_PARTICIPANT); expect(DummyComponentInstance['realtime']).toEqual(REALTIME_MOCK); expect(DummyComponentInstance['isAttached']).toBeTruthy(); expect(DummyComponentInstance['start']).toBeCalled(); }); - test('should throw error if realtime or localParticipant are not provided', () => { + test('should throw error if realtime is not provided', () => { expect(DummyComponentInstance.attach).toBeDefined(); expect(() => { DummyComponentInstance.attach({ - localParticipant: null as unknown as Participant, + ioc: null as unknown as IOC, realtime: null as unknown as AblyRealtimeService, - group: null as unknown as Group, config: null as unknown as Configuration, eventBus: null as unknown as EventBus, + useStore: null as unknown as typeof useStore, }); }).toThrowError(); }); @@ -135,11 +140,11 @@ describe('BaseComponent', () => { expect(DummyComponentInstance.detach).toBeDefined(); DummyComponentInstance.attach({ - localParticipant: MOCK_LOCAL_PARTICIPANT, + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), realtime: REALTIME_MOCK, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + useStore, }); DummyComponentInstance.detach(); @@ -156,11 +161,11 @@ describe('BaseComponent', () => { DummyComponentInstance['destroy'] = jest.fn(); DummyComponentInstance.attach({ - localParticipant: MOCK_LOCAL_PARTICIPANT, + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), realtime: REALTIME_MOCK, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + useStore, }); DummyComponentInstance.subscribe('test', callback); diff --git a/src/components/base/index.ts b/src/components/base/index.ts index 34aa3ee8..1b50943c 100644 --- a/src/components/base/index.ts +++ b/src/components/base/index.ts @@ -1,8 +1,12 @@ +import * as Socket from '@superviz/socket-client'; + import { ComponentLifeCycleEvent } from '../../common/types/events.types'; -import { Group, Participant } from '../../common/types/participant.types'; +import { Group } from '../../common/types/participant.types'; import { Logger, Observable } from '../../common/utils'; +import { useStore } from '../../common/utils/use-store'; import config from '../../services/config'; import { EventBus } from '../../services/event-bus'; +import { IOC } from '../../services/io'; import { AblyRealtimeService } from '../../services/realtime'; import { ComponentNames } from '../types'; @@ -11,12 +15,14 @@ import { DefaultAttachComponentOptions } from './types'; export abstract class BaseComponent extends Observable { public abstract name: ComponentNames; protected abstract logger: Logger; - protected localParticipant: Participant; protected group: Group; + protected ioc: IOC; protected realtime: AblyRealtimeService; protected eventBus: EventBus; - protected isAttached = false; + protected unsubscribeFrom: Array<(id: unknown) => void> = []; + protected useStore = useStore.bind(this) as typeof useStore; + protected room: Socket.Room; /** * @function attach @@ -32,7 +38,8 @@ export abstract class BaseComponent extends Observable { throw new Error(message); } - const { realtime, localParticipant, group, config: globalConfig, eventBus } = params; + const { realtime, config: globalConfig, eventBus, ioc } = params; + if (!realtime.isDomainWhitelisted) { const message = `Component ${this.name} can't be used because this website's domain is not whitelisted. Please add your domain in https://dashboard.superviz.com/developer`; this.logger.log(message); @@ -42,10 +49,10 @@ export abstract class BaseComponent extends Observable { config.setConfig(globalConfig); this.realtime = realtime; - this.localParticipant = localParticipant; - this.group = group; this.eventBus = eventBus; this.isAttached = true; + this.ioc = ioc; + this.room = ioc.createRoom(this.name); if (!this.realtime.isJoinedRoom) { this.logger.log(`${this.name} @ attach - not joined yet`); @@ -78,6 +85,7 @@ export abstract class BaseComponent extends Observable { this.logger.log('detached'); this.publish(ComponentLifeCycleEvent.UNMOUNT); this.destroy(); + this.unsubscribeFrom.forEach((unsubscribe) => unsubscribe(this)); Object.values(this.observers).forEach((observer) => { observer.reset(); @@ -86,7 +94,6 @@ export abstract class BaseComponent extends Observable { this.observers = undefined; this.realtime = undefined; - this.localParticipant = undefined; this.isAttached = false; }; diff --git a/src/components/base/types.ts b/src/components/base/types.ts index 59bfb861..2840803d 100644 --- a/src/components/base/types.ts +++ b/src/components/base/types.ts @@ -1,12 +1,20 @@ -import { Group, Participant } from '../../common/types/participant.types'; +import { Store, StoreType } from '../../common/types/stores.types'; import { Configuration } from '../../services/config/types'; import { EventBus } from '../../services/event-bus'; +import { IOC } from '../../services/io'; import { AblyRealtimeService } from '../../services/realtime'; +import { useGlobalStore } from '../../services/stores'; export interface DefaultAttachComponentOptions { + ioc: IOC; realtime: AblyRealtimeService; - localParticipant: Participant; - group: Group; config: Configuration; eventBus: EventBus; + useStore: (name: T) => Store; } + +export type GlobalStore = { + [K in keyof ReturnType]: { + subscribe(callback?: (value: unknown) => void): void; + }; +}; diff --git a/src/components/comments/canvas-pin-adapter/index.ts b/src/components/comments/canvas-pin-adapter/index.ts index 217cbc0d..33954b8c 100644 --- a/src/components/comments/canvas-pin-adapter/index.ts +++ b/src/components/comments/canvas-pin-adapter/index.ts @@ -3,7 +3,7 @@ import { Logger, Observer } from '../../../common/utils'; import { PinMode } from '../../../web-components/comments/components/types'; import { Annotation, PinAdapter, PinCoordinates } from '../types'; -import { CanvasSides, SimpleParticipant } from './types'; +import { CanvasSides } from './types'; export class CanvasPin implements PinAdapter { private logger: Logger; @@ -22,7 +22,6 @@ export class CanvasPin implements PinAdapter { private temporaryPinCoordinates: { x: number; y: number } | null = null; private commentsSide: 'left' | 'right' = 'left'; private movedTemporaryPin: boolean; - private localParticipant: SimpleParticipant = {}; private originalCanvasCursor: string; declare participants: ParticipantByGroupApi[]; @@ -164,9 +163,7 @@ export class CanvasPin implements PinAdapter { temporaryPin.setAttribute('commentsSide', this.commentsSide); temporaryPin.setAttribute('position', JSON.stringify(this.temporaryPinCoordinates)); temporaryPin.setAttribute('annotation', JSON.stringify({})); - temporaryPin.setAttribute('localAvatar', this.localParticipant.avatar ?? ''); temporaryPin.setAttribute('participantsList', JSON.stringify(this.participants)); - temporaryPin.setAttribute('localName', this.localParticipant.name ?? ''); temporaryPin.setAttributeNode(document.createAttribute('active')); this.divWrapper.appendChild(temporaryPin); } @@ -201,10 +198,8 @@ export class CanvasPin implements PinAdapter { document.body.addEventListener('click', this.hideTemporaryPin); } - public setCommentsMetadata = (side: 'left' | 'right', avatar: string, name: string): void => { + public setCommentsMetadata = (side: 'left' | 'right'): void => { this.commentsSide = side; - this.localParticipant.avatar = avatar; - this.localParticipant.name = name; }; /** diff --git a/src/components/comments/canvas-pin-adapter/types.ts b/src/components/comments/canvas-pin-adapter/types.ts index b0b5e849..4f56e5e6 100644 --- a/src/components/comments/canvas-pin-adapter/types.ts +++ b/src/components/comments/canvas-pin-adapter/types.ts @@ -8,8 +8,3 @@ export interface CanvasSides { right: number; bottom: number; } - -export interface SimpleParticipant { - name?: string; - avatar?: string; -} diff --git a/src/components/comments/html-pin-adapter/index.test.ts b/src/components/comments/html-pin-adapter/index.test.ts index 4e2e968c..4aeda658 100644 --- a/src/components/comments/html-pin-adapter/index.test.ts +++ b/src/components/comments/html-pin-adapter/index.test.ts @@ -487,18 +487,6 @@ describe('HTMLPinAdapter', () => { }); }); - describe('setCommentsMetadata', () => { - test('should store updated data about comments and local participant', () => { - instance.setCommentsMetadata('right', 'user-avatar', 'user name'); - - expect(instance['commentsSide']).toEqual('right'); - expect(instance['localParticipant']).toEqual({ - avatar: 'user-avatar', - name: 'user name', - }); - }); - }); - describe('resetPins', () => { test('should remove active on Escape key', () => { instance.updateAnnotations([MOCK_ANNOTATION_HTML]); diff --git a/src/components/comments/html-pin-adapter/index.ts b/src/components/comments/html-pin-adapter/index.ts index 05c6b972..9ca11f91 100644 --- a/src/components/comments/html-pin-adapter/index.ts +++ b/src/components/comments/html-pin-adapter/index.ts @@ -5,13 +5,7 @@ import { Logger, Observer } from '../../../common/utils'; import { PinMode } from '../../../web-components/comments/components/types'; import { Annotation, PinAdapter, PinCoordinates } from '../types'; -import { - HorizontalSide, - Simple2DPoint, - SimpleParticipant, - TemporaryPinData, - HTMLPinOptions, -} from './types'; +import { HorizontalSide, Simple2DPoint, TemporaryPinData, HTMLPinOptions } from './types'; export class HTMLPin implements PinAdapter { // Public properties @@ -21,7 +15,6 @@ export class HTMLPin implements PinAdapter { // Private properties // Comments data private annotations: Annotation[]; - private localParticipant: SimpleParticipant = {}; declare participants: ParticipantByGroupApi[]; @@ -339,10 +332,8 @@ export class HTMLPin implements PinAdapter { * @param {string} name the name of the local participant * @returns {void} */ - public setCommentsMetadata = (side: HorizontalSide, avatar: string, name: string): void => { + public setCommentsMetadata = (side: HorizontalSide): void => { this.commentsSide = side; - this.localParticipant.avatar = avatar; - this.localParticipant.name = name; }; /** @@ -415,8 +406,6 @@ export class HTMLPin implements PinAdapter { temporaryPin.setAttribute('commentsSide', this.commentsSide); temporaryPin.setAttribute('position', JSON.stringify(this.temporaryPinCoordinates)); temporaryPin.setAttribute('annotation', JSON.stringify({})); - temporaryPin.setAttribute('localAvatar', this.localParticipant.avatar ?? ''); - temporaryPin.setAttribute('localName', this.localParticipant.name ?? ''); temporaryPin.setAttribute('participantsList', JSON.stringify(this.participants)); temporaryPin.setAttribute('keepPositionRatio', ''); diff --git a/src/components/comments/html-pin-adapter/types.ts b/src/components/comments/html-pin-adapter/types.ts index 70694d3d..c24ca33c 100644 --- a/src/components/comments/html-pin-adapter/types.ts +++ b/src/components/comments/html-pin-adapter/types.ts @@ -1,8 +1,3 @@ -export interface SimpleParticipant { - name?: string; - avatar?: string; -} - export interface Simple2DPoint { x: number; y: number; diff --git a/src/components/comments/index.test.ts b/src/components/comments/index.test.ts index 598157fa..e87a06f3 100644 --- a/src/components/comments/index.test.ts +++ b/src/components/comments/index.test.ts @@ -7,7 +7,10 @@ import { ABLY_REALTIME_MOCK } from '../../../__mocks__/realtime.mock'; import { CommentEvent } from '../../common/types/events.types'; import { ParticipantByGroupApi } from '../../common/types/participant.types'; import sleep from '../../common/utils/sleep'; +import { useStore } from '../../common/utils/use-store'; import ApiService from '../../services/api'; +import { IOC } from '../../services/io'; +import { useGlobalStore } from '../../services/stores'; import { CommentsFloatButton } from '../../web-components'; import { ComponentNames } from '../types'; @@ -68,14 +71,18 @@ describe('Comments', () => { beforeEach(() => { jest.clearAllMocks(); + const { localParticipant, group } = useGlobalStore(); + localParticipant.value = MOCK_LOCAL_PARTICIPANT; + group.value = MOCK_GROUP; + commentsComponent = new Comments(DummiePinAdapter); commentsComponent.attach({ + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), realtime: Object.assign({}, ABLY_REALTIME_MOCK, { isJoinedRoom: true }), - localParticipant: MOCK_LOCAL_PARTICIPANT, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + useStore, }); commentsComponent['element'].updateAnnotations = jest.fn(); @@ -325,11 +332,11 @@ describe('Comments', () => { const spy = jest.spyOn(commentsComponent['logger'], 'log'); commentsComponent.attach({ + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), realtime: Object.assign({}, ABLY_REALTIME_MOCK, { isJoinedRoom: true }), - localParticipant: MOCK_LOCAL_PARTICIPANT, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + useStore, }); await sleep(1); @@ -344,11 +351,11 @@ describe('Comments', () => { (ApiService.fetchAnnotation as jest.Mock).mockReturnValueOnce([MOCK_ANNOTATION]); commentsComponent.attach({ + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), realtime: Object.assign({}, ABLY_REALTIME_MOCK, { isJoinedRoom: true }), - localParticipant: MOCK_LOCAL_PARTICIPANT, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + useStore, }); await sleep(1); diff --git a/src/components/comments/index.ts b/src/components/comments/index.ts index 1404805c..f3aabd3b 100644 --- a/src/components/comments/index.ts +++ b/src/components/comments/index.ts @@ -1,8 +1,10 @@ import { CommentEvent } from '../../common/types/events.types'; -import { ParticipantByGroupApi } from '../../common/types/participant.types'; +import { Participant, ParticipantByGroupApi } from '../../common/types/participant.types'; +import { StoreType } from '../../common/types/stores.types'; import { Logger } from '../../common/utils'; import ApiService from '../../services/api'; import config from '../../services/config'; +import subject from '../../services/stores/subject'; import type { Comments as CommentElement } from '../../web-components'; import { CommentsFloatButton } from '../../web-components/comments/components/float-button'; import { BaseComponent } from '../base'; @@ -32,6 +34,7 @@ export class Comments extends BaseComponent { private coordinates: AnnotationPositionInfo; private hideDefaultButton: boolean; private pinActive: boolean; + private localParticipantId: string; private offset: Offset; constructor(pinAdapter: PinAdapter, options?: CommentsOptions) { @@ -50,14 +53,17 @@ export class Comments extends BaseComponent { this.offset = options?.offset; setTimeout(() => { - pinAdapter.setCommentsMetadata( - this.layoutOptions?.position ?? 'left', - this.localParticipant?.avatar?.imageUrl, - this.localParticipant?.name, - ); + pinAdapter.setCommentsMetadata(this.layoutOptions?.position ?? 'left'); }); this.pinAdapter = pinAdapter; + + const { group, localParticipant } = this.useStore(StoreType.GLOBAL); + group.subscribe(); + + localParticipant.subscribe((participant: Participant) => { + this.localParticipantId = participant.id; + }); } /** @@ -397,7 +403,7 @@ export class Comments extends BaseComponent { roomId: config.get('roomId'), position: JSON.stringify(position), url, - userId: this.localParticipant.id, + userId: this.localParticipantId, }, ); @@ -466,7 +472,7 @@ export class Comments extends BaseComponent { config.get('apiKey'), { annotationId, - userId: this.localParticipant.id, + userId: this.localParticipantId, text, }, ); @@ -724,7 +730,7 @@ export class Comments extends BaseComponent { private onAnnotationListUpdate = (message): void => { const { data, clientId } = message; - if (this.localParticipant.id === clientId) return; + if (this.localParticipantId === clientId) return; this.annotations = data; this.element.updateAnnotations(this.annotations); diff --git a/src/components/comments/types.ts b/src/components/comments/types.ts index 2f007dd2..8c4e4db4 100644 --- a/src/components/comments/types.ts +++ b/src/components/comments/types.ts @@ -36,7 +36,7 @@ export interface PinAdapter { updateAnnotations(annotations: Annotation[]): void; removeAnnotationPin(uuid: string): void; onPinFixedObserver: Observer; - setCommentsMetadata(side: 'left' | 'right', localUserAvatar: string, name: string): void; + setCommentsMetadata(side: 'left' | 'right'): void; participantsList: ParticipantByGroupApi[]; } diff --git a/src/components/presence-mouse/canvas/index.test.ts b/src/components/presence-mouse/canvas/index.test.ts index d30efa3f..b2a01cfa 100644 --- a/src/components/presence-mouse/canvas/index.test.ts +++ b/src/components/presence-mouse/canvas/index.test.ts @@ -1,8 +1,10 @@ import { MOCK_CANVAS } from '../../../../__mocks__/canvas.mock'; import { MOCK_CONFIG } from '../../../../__mocks__/config.mock'; import { EVENT_BUS_MOCK } from '../../../../__mocks__/event-bus.mock'; -import { MOCK_GROUP, MOCK_LOCAL_PARTICIPANT } from '../../../../__mocks__/participants.mock'; +import { MOCK_LOCAL_PARTICIPANT } from '../../../../__mocks__/participants.mock'; import { ABLY_REALTIME_MOCK } from '../../../../__mocks__/realtime.mock'; +import { useStore } from '../../../common/utils/use-store'; +import { IOC } from '../../../services/io'; import { ParticipantMouse } from '../types'; import { PointersCanvas } from './index'; @@ -11,7 +13,13 @@ const MOCK_MOUSE: ParticipantMouse = { ...MOCK_LOCAL_PARTICIPANT, x: 30, y: 30, - slotIndex: 0, + slot: { + index: 7, + color: '#304AFF', + textColor: '#fff', + colorName: 'bluedark', + timestamp: 1710448079918, + }, visible: true, }; @@ -32,11 +40,11 @@ const createMousePointers = (): PointersCanvas => { const presenceMouseComponent = new PointersCanvas('canvas'); presenceMouseComponent.attach({ + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), realtime: ABLY_REALTIME_MOCK, - localParticipant: MOCK_LOCAL_PARTICIPANT, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + useStore, }); presenceMouseComponent['canvas'] = { @@ -44,6 +52,8 @@ const createMousePointers = (): PointersCanvas => { ...MOCK_CANVAS, } as unknown as HTMLCanvasElement; + presenceMouseComponent['localParticipant'] = MOCK_LOCAL_PARTICIPANT; + return presenceMouseComponent; }; @@ -77,48 +87,10 @@ describe('MousePointers on Canvas', () => { }); }); - describe('destroy', () => { - test('should unsubscribe from realtime events', () => { - presenceMouseComponent = createMousePointers(); - presenceMouseComponent['canvas'].removeEventListener = jest.fn(); - - presenceMouseComponent['destroy'](); - - expect(ABLY_REALTIME_MOCK.participantLeaveObserver.unsubscribe).toHaveBeenCalled(); - expect(ABLY_REALTIME_MOCK.participantsObserver.unsubscribe).toHaveBeenCalled(); - expect(presenceMouseComponent['divWrapper'].hasChildNodes()).toBeFalsy(); - expect(presenceMouseComponent['canvas'].removeEventListener).toBeCalled(); - }); - }); - - describe('subscribeToRealtimeEvents', () => { - test('should subscribe to realtime events', () => { - presenceMouseComponent['subscribeToRealtimeEvents'](); - - expect(ABLY_REALTIME_MOCK.participantLeaveObserver.subscribe).toHaveBeenCalledWith( - presenceMouseComponent['onParticipantLeftOnRealtime'], - ); - expect(ABLY_REALTIME_MOCK.participantsObserver.subscribe).toHaveBeenCalledWith( - presenceMouseComponent['onParticipantsDidChange'], - ); - }); - }); - - describe('unsubscribeFromRealtimeEvents', () => { - test('should subscribe to realtime events', () => { - presenceMouseComponent['unsubscribeFromRealtimeEvents'](); - - expect(ABLY_REALTIME_MOCK.participantLeaveObserver.unsubscribe).toHaveBeenCalledWith( - presenceMouseComponent['onParticipantLeftOnRealtime'], - ); - expect(ABLY_REALTIME_MOCK.participantsObserver.unsubscribe).toHaveBeenCalledWith( - presenceMouseComponent['onParticipantsDidChange'], - ); - }); - }); - describe('onMyParticipantMouseMove', () => { test('should update my participant mouse position', () => { + const spy = jest.spyOn(presenceMouseComponent['room']['presence'], 'update'); + const presenceContainerId = document.createElement('div'); presenceMouseComponent['containerId'] = 'container'; document.getElementById = jest.fn().mockReturnValue(presenceContainerId); @@ -126,7 +98,7 @@ describe('MousePointers on Canvas', () => { const event = { x: 10, y: 20 }; presenceMouseComponent['onMyParticipantMouseMove'](event as unknown as MouseEvent); - expect(ABLY_REALTIME_MOCK.updatePresenceMouse).toHaveBeenCalledWith({ + expect(spy).toHaveBeenCalledWith({ ...MOCK_LOCAL_PARTICIPANT, x: event.x, y: event.y, @@ -146,12 +118,17 @@ describe('MousePointers on Canvas', () => { test('should update presence mouse element for external participants', () => { presenceMouseComponent['updatePresenceMouseParticipant'] = jest.fn(); - presenceMouseComponent['onParticipantsDidChange'](participants); + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant2-ably-id', + data: participant2, + id: 'unit-test-participant2-ably-id', + name: participant2.name as string, + timestamp: 1, + }); // participant1 is local, so we don't want to update it const expected = new Map(); expected.set(participant2.id, participant2); - expected.set(participant3.id, participant3); expect(presenceMouseComponent['presences']).toEqual(expected); }); @@ -160,8 +137,22 @@ describe('MousePointers on Canvas', () => { presenceMouseComponent['goToMouse'] = jest .fn() .mockImplementation(presenceMouseComponent['goToMouse']); + + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant2-ably-id', + data: participant2, + id: 'unit-test-participant2-ably-id', + name: participant2.name as string, + timestamp: 1, + }); presenceMouseComponent['following'] = participant2.id; - presenceMouseComponent['onParticipantsDidChange'](participants); + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant2-ably-id', + data: participant2, + id: 'unit-test-participant2-ably-id', + name: participant2.name as string, + timestamp: 1, + }); expect(presenceMouseComponent['goToMouse']).toHaveBeenCalledWith(participant2.id); }); @@ -169,20 +160,33 @@ describe('MousePointers on Canvas', () => { test('should only update mouse being followed', () => { const firstExpected = new Map(); firstExpected.set(participant2.id, participant2); - firstExpected.set(participant3.id, participant3); - presenceMouseComponent['onParticipantsDidChange'](participants); + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant2-ably-id', + data: participant2, + id: 'unit-test-participant2-ably-id', + name: participant2.name as string, + timestamp: 1, + }); + expect(presenceMouseComponent['presences']).toEqual(firstExpected); const secondExpected = new Map(); secondExpected.set(participant2.id, participant2); + presenceMouseComponent['presences'] = new Map(); presenceMouseComponent['following'] = participant2.id; presenceMouseComponent['presences'].set(participant2.id, participant2); - presenceMouseComponent['presences'].set(participant3.id, participant3); - presenceMouseComponent['onParticipantsDidChange'](participants); + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant2-ably-id', + data: participant2, + id: 'unit-test-participant2-ably-id', + name: participant2.name as string, + timestamp: 1, + }); + expect(presenceMouseComponent['presences']).toEqual(secondExpected); }); }); @@ -194,11 +198,23 @@ describe('MousePointers on Canvas', () => { ...MOCK_LOCAL_PARTICIPANT, x: 1000, y: 1000, - slotIndex: 0, + slot: { + index: 7, + color: '#304AFF', + textColor: '#fff', + colorName: 'bluedark', + timestamp: 1710448079918, + }, visible: true, }; - presenceMouseComponent['onParticipantLeftOnRealtime'](MOCK_MOUSE); + presenceMouseComponent['onPresenceLeftRoom']({ + connectionId: MOCK_MOUSE.id, + id: MOCK_MOUSE.id, + name: MOCK_MOUSE.name as string, + timestamp: 1, + data: MOCK_MOUSE, + }); expect(spy).toHaveBeenCalledWith(MOCK_MOUSE.id); }); @@ -210,19 +226,26 @@ describe('MousePointers on Canvas', () => { ...MOCK_LOCAL_PARTICIPANT, x: 1000, y: 1000, - slotIndex: 0, + slot: { + index: 7, + color: '#304AFF', + textColor: '#fff', + colorName: 'bluedark', + timestamp: 1710448079918, + }, visible: true, }; const participant2 = MOCK_MOUSE; participant2.id = 'unit-test-participant2-ably-id'; - const participants: Record = { - participant: MOCK_MOUSE, - participant2, - }; - - presenceMouseComponent['onParticipantsDidChange'](participants); + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant2-ably-id', + data: participant2, + id: 'unit-test-participant2-ably-id', + name: participant2.name as string, + timestamp: 1, + }); presenceMouseComponent['goToMouseCallback'] = jest.fn(); presenceMouseComponent['goToMouse']('not-found-user-id'); @@ -231,7 +254,13 @@ describe('MousePointers on Canvas', () => { }); test('should call callback if user id is found', () => { - presenceMouseComponent['onParticipantsDidChange'](participants); + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant2-ably-id', + data: participant2, + id: 'unit-test-participant2-ably-id', + name: participant2.name as string, + timestamp: 1, + }); presenceMouseComponent['goToMouseCallback'] = jest.fn(); presenceMouseComponent['goToMouse'](participant2.id); @@ -274,13 +303,11 @@ describe('MousePointers on Canvas', () => { jest.restoreAllMocks(); }); test('should publish own mouse visibility as false to realtime', () => { + const spy = jest.spyOn(presenceMouseComponent['room']['presence'], 'update'); const event = new MouseEvent('mouseout'); presenceMouseComponent['onMyParticipantMouseOut'](event); - expect(ABLY_REALTIME_MOCK.updatePresenceMouse).toHaveBeenCalledWith({ - ...MOCK_LOCAL_PARTICIPANT, - visible: false, - } as ParticipantMouse); + expect(spy).toHaveBeenCalledWith({ visible: false }); }); }); @@ -325,13 +352,11 @@ describe('MousePointers on Canvas', () => { }); test('should update presenceMouse in realtime', () => { + const spy = jest.spyOn(presenceMouseComponent['room']['presence'], 'update'); const isPrivate = true; presenceMouseComponent['setParticipantPrivate'](isPrivate); - expect(presenceMouseComponent['realtime'].updatePresenceMouse).toBeCalledWith({ - ...presenceMouseComponent['localParticipant'], - visible: !isPrivate, - }); + expect(spy).toBeCalledWith({ visible: !isPrivate }); }); }); diff --git a/src/components/presence-mouse/canvas/index.ts b/src/components/presence-mouse/canvas/index.ts index e239160f..12963c50 100644 --- a/src/components/presence-mouse/canvas/index.ts +++ b/src/components/presence-mouse/canvas/index.ts @@ -1,4 +1,9 @@ +import * as Socket from '@superviz/socket-client'; +import { throttle } from 'lodash'; + import { RealtimeEvent } from '../../../common/types/events.types'; +import { Participant } from '../../../common/types/participant.types'; +import { StoreType } from '../../../common/types/stores.types'; import { Logger } from '../../../common/utils'; import { BaseComponent } from '../../base'; import { ComponentNames } from '../../types'; @@ -14,6 +19,7 @@ export class PointersCanvas extends BaseComponent { private goToMouseCallback: PresenceMouseProps['callbacks']['onGoToPresence']; private following: string; private isPrivate: boolean; + private localParticipant: Participant; private transformation: Transform = { translate: { x: 0, y: 0 }, scale: 1 }; constructor(canvasId: string, options?: PresenceMouseProps) { @@ -33,10 +39,9 @@ export class PointersCanvas extends BaseComponent { this.animateFrame = requestAnimationFrame(this.animate); this.goToMouseCallback = options?.callbacks?.onGoToPresence; - } - private get textColorValues(): number[] { - return [2, 4, 5, 7, 8, 16]; + const { localParticipant } = this.useStore(StoreType.GLOBAL); + localParticipant.subscribe(); } /** @@ -53,7 +58,6 @@ export class PointersCanvas extends BaseComponent { this.eventBus.subscribe(RealtimeEvent.REALTIME_LOCAL_FOLLOW_PARTICIPANT, this.followMouse); this.eventBus.subscribe(RealtimeEvent.REALTIME_PRIVATE_MODE, this.setParticipantPrivate); this.subscribeToRealtimeEvents(); - this.realtime.enterPresenceMouseChannel(this.localParticipant); } /** @@ -66,7 +70,6 @@ export class PointersCanvas extends BaseComponent { this.eventBus.unsubscribe(RealtimeEvent.REALTIME_GO_TO_PARTICIPANT, this.goToMouse); this.eventBus.unsubscribe(RealtimeEvent.REALTIME_LOCAL_FOLLOW_PARTICIPANT, this.followMouse); this.eventBus.unsubscribe(RealtimeEvent.REALTIME_PRIVATE_MODE, this.setParticipantPrivate); - this.realtime.leavePresenceMouseChannel(); this.unsubscribeFromRealtimeEvents(); cancelAnimationFrame(this.animateFrame); @@ -82,8 +85,12 @@ export class PointersCanvas extends BaseComponent { */ private subscribeToRealtimeEvents = (): void => { this.logger.log('presence-mouse component @ subscribe to realtime events'); - this.realtime.presenceMouseParticipantLeaveObserver.subscribe(this.onParticipantLeftOnRealtime); - this.realtime.presenceMouseObserver.subscribe(this.onParticipantsDidChange); + this.room.presence.on( + Socket.PresenceEvents.JOINED_ROOM, + this.onPresenceJoinedRoom, + ); + this.room.presence.on(Socket.PresenceEvents.LEAVE, this.onPresenceLeftRoom); + this.room.presence.on(Socket.PresenceEvents.UPDATE, this.onPresenceUpdate); }; /** @@ -93,10 +100,9 @@ export class PointersCanvas extends BaseComponent { */ private unsubscribeFromRealtimeEvents = (): void => { this.logger.log('presence-mouse component @ unsubscribe from realtime events'); - this.realtime.presenceMouseParticipantLeaveObserver.unsubscribe( - this.onParticipantLeftOnRealtime, - ); - this.realtime.presenceMouseObserver.unsubscribe(this.onParticipantsDidChange); + this.room.presence.off(Socket.PresenceEvents.JOINED_ROOM); + this.room.presence.off(Socket.PresenceEvents.LEAVE); + this.room.presence.off(Socket.PresenceEvents.UPDATE); }; /** @@ -106,7 +112,51 @@ export class PointersCanvas extends BaseComponent { */ private setParticipantPrivate = (isPrivate: boolean): void => { this.isPrivate = isPrivate; - this.realtime.updatePresenceMouse({ ...this.localParticipant, visible: !isPrivate }); + this.room.presence.update({ visible: !isPrivate }); + }; + + /** + * @function onPresenceJoinedRoom + * @description handler for presence joined room event + * @param {PresenceEvent} presence + * @returns {void} + */ + private onPresenceJoinedRoom = (presence: Socket.PresenceEvent): void => { + if (presence.id !== this.localParticipant.id) return; + + this.room.presence.update(this.localParticipant); + }; + + /** + * @function onPresenceLeftRoom + * @description handler for presence left room event + * @param {PresenceEvent} presence + * @returns {void} + */ + private onPresenceLeftRoom = (presence: Socket.PresenceEvent): void => { + this.removePresenceMouseParticipant(presence.id); + }; + + /** + * @function onPresenceUpdate + * @description handler for presence update event + * @param {PresenceEvent} presence + * @returns {void} + */ + private onPresenceUpdate = (presence: Socket.PresenceEvent): void => { + if (this.following && this.presences.get(this.following)) { + this.goToMouse(this.following); + } + + if (presence.id === this.localParticipant.id) return; + const participant = presence.data; + + if (this.following && participant.id !== this.following && this.presences.has(participant.id)) { + this.removePresenceMouseParticipant(participant.id); + return; + } + + this.presences.set(participant.id, participant); }; /** @@ -152,7 +202,7 @@ export class PointersCanvas extends BaseComponent { * @description event to update my participant mouse position to others participants * @returns {void} */ - private onMyParticipantMouseMove = (event: MouseEvent): void => { + private onMyParticipantMouseMove = throttle((event: MouseEvent): void => { const context = this.canvas.getContext('2d'); const rect = this.canvas.getBoundingClientRect(); const x = event.x - rect.left; @@ -167,59 +217,19 @@ export class PointersCanvas extends BaseComponent { y: (transformedPoint.y - this.transformation.translate.y) / this.transformation.scale, }; - this.realtime.updatePresenceMouse({ + this.room.presence.update({ ...this.localParticipant, ...coordinates, visible: !this.isPrivate, }); - }; - - /** - * @function onParticipantsDidChange - * @description handler for participant list update event - * @param {Record} participants - participants - * @returns {void} - */ - private onParticipantsDidChange = (participants: Record): void => { - this.logger.log('presence-mouse component @ on participants did change', participants); - - Object.values(participants).forEach((participant: ParticipantMouse) => { - if (participant.id === this.localParticipant.id) return; - if ( - this.following && - participant.id !== this.following && - this.presences.has(participant.id) - ) { - this.removePresenceMouseParticipant(participant.id); - return; - } - - this.presences.set(participant.id, participant); - }); - - if (this.following) { - const mouse = this.presences.get(this.following); - if (mouse) { - this.goToMouse(this.following); - } - } - }; + }, 30); private onMyParticipantMouseOut = (event: MouseEvent): void => { const { x, y, width, height } = this.canvas.getBoundingClientRect(); - if (event.x > 0 && event.y > 0 && event.x < x + width && event.y < y + height) return; - this.realtime.updatePresenceMouse({ visible: false, ...this.localParticipant }); - }; + if (event.x > 0 && event.y > 0 && event.x < x + width && event.y < y + height) return; - /** - * @function onParticipantLeftOnRealtime - * @description handler for participant left event - * @param {AblyParticipant} participant - * @returns {void} - */ - private onParticipantLeftOnRealtime = (participant: ParticipantMouse): void => { - this.removePresenceMouseParticipant(participant.id); + this.room.presence.update({ visible: false }); }; /** @@ -252,7 +262,7 @@ export class PointersCanvas extends BaseComponent { private updateParticipantsMouses = (): void => { this.presences.forEach((mouse) => { - if (!mouse?.visible) { + if (!mouse?.visible || (this.following && this.following !== mouse.id)) { this.removePresenceMouseParticipant(mouse.id); return; } @@ -283,14 +293,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.slotIndex}.svg)`; + pointerUser.style.backgroundImage = `url(https://production.cdn.superviz.com/static/pointers-v2/${mouse.slot.index}.svg)`; } if (mouseUser) { - mouseUser.style.color = this.textColorValues.includes(mouse.slotIndex) - ? '#FFFFFF' - : '#26242A'; - mouseUser.style.backgroundColor = mouse.color; + mouseUser.style.color = mouse.slot.textColor; + mouseUser.style.backgroundColor = mouse.slot.color; mouseUser.innerHTML = mouse.name; } @@ -322,6 +330,7 @@ export class PointersCanvas extends BaseComponent { * */ private removePresenceMouseParticipant(participantId: string): void { const userMouseIdExist = document.getElementById(`mouse-${participantId}`); + if (userMouseIdExist) { userMouseIdExist.remove(); this.presences.delete(participantId); @@ -358,6 +367,7 @@ export class PointersCanvas extends BaseComponent { private followMouse = (id: string) => { this.following = id; + this.goToMouse(id); }; /** diff --git a/src/components/presence-mouse/html/index.test.ts b/src/components/presence-mouse/html/index.test.ts index cfaa4a81..2b2a3d06 100644 --- a/src/components/presence-mouse/html/index.test.ts +++ b/src/components/presence-mouse/html/index.test.ts @@ -1,21 +1,23 @@ import { MOCK_CONFIG } from '../../../../__mocks__/config.mock'; import { EVENT_BUS_MOCK } from '../../../../__mocks__/event-bus.mock'; -import { MOCK_GROUP, MOCK_LOCAL_PARTICIPANT } from '../../../../__mocks__/participants.mock'; +import { MOCK_LOCAL_PARTICIPANT } from '../../../../__mocks__/participants.mock'; import { ABLY_REALTIME_MOCK } from '../../../../__mocks__/realtime.mock'; -import { INDEX_IS_WHITE_TEXT } from '../../../common/types/meeting-colors.types'; +import { useStore } from '../../../common/utils/use-store'; +import { IOC } from '../../../services/io'; import { ParticipantMouse } from '../types'; import { PointersHTML } from '.'; const createMousePointers = (id: string = 'html'): PointersHTML => { const presenceMouseComponent = new PointersHTML(id); + presenceMouseComponent['localParticipant'] = MOCK_LOCAL_PARTICIPANT; presenceMouseComponent.attach({ + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), realtime: ABLY_REALTIME_MOCK, - localParticipant: MOCK_LOCAL_PARTICIPANT, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + useStore, }); return presenceMouseComponent; @@ -33,15 +35,21 @@ describe('MousePointers on HTML', () => { ...MOCK_LOCAL_PARTICIPANT, x: 30, y: 30, - slotIndex: 0, + slot: { + index: 7, + color: '#304AFF', + textColor: '#fff', + colorName: 'bluedark', + timestamp: 1710448079918, + }, visible: true, }; const participant1 = { ...MOCK_MOUSE }; const participant2 = { ...MOCK_MOUSE }; const participant3 = { ...MOCK_MOUSE }; - participant2.id = 'unit-test-participant2-ably-id'; - participant3.id = 'unit-test-participant3-ably-id'; + participant2.id = 'unit-test-participant2-id'; + participant3.id = 'unit-test-participant3-id'; participants[participant1.id] = { ...participant1 }; participants[participant2.id] = { ...participant2 }; @@ -74,16 +82,6 @@ describe('MousePointers on HTML', () => { jest.resetAllMocks(); }); - test('should call enterPresenceMouseChannel', () => { - const enterPresenceMouseChannelSpy = jest.spyOn( - ABLY_REALTIME_MOCK, - 'enterPresenceMouseChannel', - ); - presenceMouseComponent['start'](); - - expect(enterPresenceMouseChannelSpy).toHaveBeenCalledWith(MOCK_LOCAL_PARTICIPANT); - }); - test('should call renderWrapper', () => { const renderWrapperSpy = jest.spyOn(presenceMouseComponent as any, 'renderWrapper'); @@ -137,16 +135,6 @@ describe('MousePointers on HTML', () => { ); }); - test('should call realtime.leavePresenceMouseChannel', () => { - const leavePresenceMouseChannelSpy = jest.spyOn( - ABLY_REALTIME_MOCK, - 'leavePresenceMouseChannel', - ); - presenceMouseComponent['destroy'](); - - expect(leavePresenceMouseChannelSpy).toHaveBeenCalled(); - }); - test('should remove wrapper from the DOM', () => { const wrapperSpy = jest.spyOn(presenceMouseComponent['wrapper'] as any, 'remove'); @@ -228,8 +216,7 @@ describe('MousePointers on HTML', () => { presenceMouseComponent['addListeners'](); - expect(addEventListenerSpy).toHaveBeenCalledWith('pointermove', expect.any(Function)); - expect(addEventListenerSpy).toHaveBeenCalledWith('mouseleave', expect.any(Function)); + expect(addEventListenerSpy).toHaveBeenCalledWith('pointerleave', expect.any(Function)); }); }); @@ -242,8 +229,7 @@ describe('MousePointers on HTML', () => { presenceMouseComponent['removeListeners'](); - expect(removeEventListenerSpy).toHaveBeenCalledWith('pointermove', expect.any(Function)); - expect(removeEventListenerSpy).toHaveBeenCalledWith('mouseleave', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('pointerleave', expect.any(Function)); }); }); @@ -381,10 +367,10 @@ describe('MousePointers on HTML', () => { presenceMouseComponent.attach({ realtime: ABLY_REALTIME_MOCK, - localParticipant: MOCK_LOCAL_PARTICIPANT, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), + useStore, }); presenceMouseComponent['start'](); @@ -407,10 +393,10 @@ describe('MousePointers on HTML', () => { presenceMouseComponent = createMousePointers(); }); - test('should call realtime.updatePresenceMouse', () => { + test('should call room.presence.update', () => { const updatePresenceMouseSpy = jest.spyOn( - presenceMouseComponent['realtime'], - 'updatePresenceMouse', + presenceMouseComponent['room']['presence'], + 'update', ); presenceMouseComponent['transform']({ translate: { x: 10, y: 10 }, scale: 1 }); @@ -438,10 +424,10 @@ describe('MousePointers on HTML', () => { }); }); - test('should not call realtime.updatePresenceMouse if isPrivate', () => { + test('should not call room.presence.update if isPrivate', () => { const updatePresenceMouseSpy = jest.spyOn( - presenceMouseComponent['realtime'], - 'updatePresenceMouse', + presenceMouseComponent['room']['presence'], + 'update', ); presenceMouseComponent['isPrivate'] = true; @@ -463,28 +449,63 @@ describe('MousePointers on HTML', () => { }); describe('onMyParticipantMouseLeave', () => { - test('should call realtime.updatePresenceMouse', () => { + test('should only call room.presence.update if mouse is out of container boundaries', () => { const updatePresenceMouseSpy = jest.spyOn( - presenceMouseComponent['realtime'], - 'updatePresenceMouse', + presenceMouseComponent['room']['presence'], + 'update', ); - presenceMouseComponent['onMyParticipantMouseLeave'](); + presenceMouseComponent['container'].getBoundingClientRect = jest.fn( + () => + ({ + x: 10, + y: 10, + width: 10, + height: 10, + } as any), + ); - expect(updatePresenceMouseSpy).toHaveBeenCalledWith({ - ...MOCK_LOCAL_PARTICIPANT, - visible: false, - }); + const mouseEvent1 = { + x: -50, + y: -50, + } as any; + + presenceMouseComponent['onMyParticipantMouseLeave'](mouseEvent1); + + expect(updatePresenceMouseSpy).toHaveBeenCalledWith({ visible: false }); + updatePresenceMouseSpy.mockClear(); + + const mouseEvent2 = { + x: 5, + y: 5, + } as any; + + presenceMouseComponent['onMyParticipantMouseLeave'](mouseEvent2); + + expect(updatePresenceMouseSpy).not.toHaveBeenCalled(); }); }); - describe('onParticipantsDidChange', () => { + describe('on participant updated', () => { test('should set presences', () => { - presenceMouseComponent['onParticipantsDidChange'](participants); - const map = new Map(Object.entries(participants)); - map.delete(MOCK_LOCAL_PARTICIPANT.id); + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant2-id', + data: { + ...MOCK_MOUSE, + id: 'unit-test-participant2-id', + }, + id: 'unit-test-participant2-id', + name: MOCK_MOUSE.name as string, + timestamp: 1, + }); + + const ex = new Map(); + ex.set('unit-test-participant2-id', { + ...MOCK_MOUSE, + id: 'unit-test-participant2-id', + }); - expect(presenceMouseComponent['presences']).toEqual(map); + expect(presenceMouseComponent['presences']).toEqual(ex); }); test('should call removePresenceMouseParticipant', () => { @@ -493,9 +514,40 @@ describe('MousePointers on HTML', () => { 'removePresenceMouseParticipant', ); - presenceMouseComponent['onParticipantsDidChange'](participants); - presenceMouseComponent['userBeingFollowedId'] = 'unit-test-participant2-ably-id'; - presenceMouseComponent['onParticipantsDidChange'](participants); + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant3-id', + data: { + ...MOCK_MOUSE, + id: 'unit-test-participant3-id', + }, + id: 'unit-test-participant3-id', + name: MOCK_MOUSE.name as string, + timestamp: 1, + }); + + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant2-id', + data: { + ...MOCK_MOUSE, + id: 'unit-test-participant2-id', + }, + id: 'unit-test-participant2-id', + name: MOCK_MOUSE.name as string, + timestamp: 1, + }); + + presenceMouseComponent['userBeingFollowedId'] = 'unit-test-participant2-id'; + + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant3-id', + data: { + ...MOCK_MOUSE, + id: 'unit-test-participant3-id', + }, + id: 'unit-test-participant3-id', + name: MOCK_MOUSE.name as string, + timestamp: 1, + }); expect(removePresenceMouseParticipantSpy).toHaveBeenCalledTimes(1); }); @@ -506,7 +558,16 @@ describe('MousePointers on HTML', () => { 'updateParticipantsMouses', ); - presenceMouseComponent['onParticipantsDidChange'](participants); + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant2-id', + data: { + ...MOCK_MOUSE, + id: 'unit-test-participant2-id', + }, + id: 'unit-test-participant2-id', + name: MOCK_MOUSE.name as string, + timestamp: 1, + }); expect(updateParticipantsMousesSpy).toHaveBeenCalled(); }); @@ -514,25 +575,30 @@ describe('MousePointers on HTML', () => { describe('goToMouse', () => { beforeEach(() => { - presenceMouseComponent['onParticipantsDidChange'](participants); + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant2-id', + data: { + ...MOCK_MOUSE, + id: 'unit-test-participant2-id', + }, + id: 'unit-test-participant2-id', + name: MOCK_MOUSE.name as string, + timestamp: 1, + }); }); test('should call scrollIntoView', () => { presenceMouseComponent['start'](); presenceMouseComponent['createMouseElement']( - presenceMouseComponent['presences'].get('unit-test-participant2-ably-id')!, + presenceMouseComponent['presences'].get('unit-test-participant2-id')!, ); - - presenceMouseComponent['mouses'].get('unit-test-participant2-ably-id')!.scrollIntoView = - jest.fn(); - + presenceMouseComponent['mouses'].get('unit-test-participant2-id')!.scrollIntoView = jest.fn(); presenceMouseComponent['start'](); - - presenceMouseComponent['goToMouse']('unit-test-participant2-ably-id'); + presenceMouseComponent['goToMouse']('unit-test-participant2-id'); expect( - presenceMouseComponent['mouses'].get('unit-test-participant2-ably-id')!.scrollIntoView, + presenceMouseComponent['mouses'].get('unit-test-participant2-id')!.scrollIntoView, ).toHaveBeenCalledWith({ block: 'center', inline: 'center', @@ -544,16 +610,15 @@ describe('MousePointers on HTML', () => { presenceMouseComponent['start'](); presenceMouseComponent['createMouseElement']( - presenceMouseComponent['presences'].get('unit-test-participant2-ably-id')!, + presenceMouseComponent['presences'].get('unit-test-participant2-id')!, ); - presenceMouseComponent['mouses'].get('unit-test-participant2-ably-id')!.scrollIntoView = - jest.fn(); + presenceMouseComponent['mouses'].get('unit-test-participant2-id')!.scrollIntoView = jest.fn(); presenceMouseComponent['goToMouse']('not-found'); expect( - presenceMouseComponent['mouses'].get('unit-test-participant2-ably-id')!.scrollIntoView, + presenceMouseComponent['mouses'].get('unit-test-participant2-id')!.scrollIntoView, ).not.toHaveBeenCalled(); }); @@ -561,19 +626,18 @@ describe('MousePointers on HTML', () => { presenceMouseComponent['start'](); presenceMouseComponent['createMouseElement']( - presenceMouseComponent['presences'].get('unit-test-participant2-ably-id')!, + presenceMouseComponent['presences'].get('unit-test-participant2-id')!, ); - presenceMouseComponent['mouses'].get('unit-test-participant2-ably-id')!.scrollIntoView = - jest.fn(); + presenceMouseComponent['mouses'].get('unit-test-participant2-id')!.scrollIntoView = jest.fn(); const { x, y } = presenceMouseComponent['mouses'] - .get('unit-test-participant2-ably-id')! + .get('unit-test-participant2-id')! .getBoundingClientRect(); const callback = jest.fn(); presenceMouseComponent['goToPresenceCallback'] = callback; - presenceMouseComponent['goToMouse']('unit-test-participant2-ably-id'); + presenceMouseComponent['goToMouse']('unit-test-participant2-id'); expect(callback).toHaveBeenCalledWith({ x, y }); }); @@ -581,39 +645,40 @@ describe('MousePointers on HTML', () => { describe('followMouse', () => { test('should set userBeingFollowedId', () => { - presenceMouseComponent['followMouse']('unit-test-participant2-ably-id'); - expect(presenceMouseComponent['userBeingFollowedId']).toEqual( - 'unit-test-participant2-ably-id', - ); + presenceMouseComponent['followMouse']('unit-test-participant2-id'); + expect(presenceMouseComponent['userBeingFollowedId']).toEqual('unit-test-participant2-id'); }); }); - describe('onParticipantLeftOnRealtime', () => { + describe('onPresenceLeftRoom', () => { test('should call removePresenceMouseParticipant', () => { const removePresenceMouseParticipantSpy = jest.spyOn( presenceMouseComponent as any, 'removePresenceMouseParticipant', ); - presenceMouseComponent['onParticipantLeftOnRealtime'](MOCK_MOUSE); + presenceMouseComponent['onPresenceLeftRoom']({ + connectionId: 'unit-test-participant2-id', + data: MOCK_MOUSE, + id: MOCK_MOUSE.id, + name: MOCK_MOUSE.name as string, + timestamp: 1, + }); expect(removePresenceMouseParticipantSpy).toHaveBeenCalledWith(MOCK_MOUSE.id); }); }); describe('setParticipantPrivate', () => { - test('should call realtime.updatePresenceMouse', () => { + test('should call room.presence.update', () => { const updatePresenceMouseSpy = jest.spyOn( - presenceMouseComponent['realtime'], - 'updatePresenceMouse', + presenceMouseComponent['room']['presence'], + 'update', ); presenceMouseComponent['setParticipantPrivate'](true); - expect(updatePresenceMouseSpy).toHaveBeenCalledWith({ - ...MOCK_LOCAL_PARTICIPANT, - visible: false, - }); + expect(updatePresenceMouseSpy).toHaveBeenCalledWith({ visible: false }); }); test('should set isPrivate', () => { @@ -647,15 +712,15 @@ describe('MousePointers on HTML', () => { test('should create a mouse element and append it to the wrapper', () => { const mouse = presenceMouseComponent['createMouseElement']( - participants['unit-test-participant2-ably-id'], + participants['unit-test-participant2-id'], ); expect(mouse).toBeTruthy(); - expect(mouse.getAttribute('id')).toEqual('mouse-unit-test-participant2-ably-id'); + expect(mouse.getAttribute('id')).toEqual('mouse-unit-test-participant2-id'); expect(mouse.getAttribute('class')).toEqual('mouse-follower'); expect(mouse.querySelector('.pointer-mouse')).toBeTruthy(); expect(mouse.querySelector('.mouse-user-name')).toBeTruthy(); - expect(presenceMouseComponent['mouses'].get('unit-test-participant2-ably-id')).toEqual(mouse); + expect(presenceMouseComponent['mouses'].get('unit-test-participant2-id')).toEqual(mouse); }); test('should not create a mouse element if wrapper is not found', () => { @@ -663,37 +728,13 @@ describe('MousePointers on HTML', () => { presenceMouseComponent['wrapper'] = undefined; const mouse = presenceMouseComponent['createMouseElement']( - participants['unit-test-participant3-ably-id'], + participants['unit-test-participant3-id'], ); expect(mouse).toBeFalsy(); }); }); - /** - * private getTextColorValue = (slotIndex: number): string => { - return [2, 4, 5, 7, 8, 16].includes(slotIndex) ? '#FFFFFF' : '#26242A'; - }; - - */ - describe('getTextColorValue', () => { - test('should return the correct colors', () => { - const indexArray: number[] = []; - for (let i = 0; i < 17; i++) { - indexArray.push(i); - } - - indexArray.forEach((index, i) => { - if (INDEX_IS_WHITE_TEXT.includes(i)) { - expect(presenceMouseComponent['getTextColorValue'](index)).toBe('#FFFFFF'); - return; - } - - expect(presenceMouseComponent['getTextColorValue'](index)).toBe('#26242A'); - }); - }); - }); - describe('updateSVGPosition', () => { beforeEach(() => { presenceMouseComponent['start'](); @@ -820,7 +861,16 @@ describe('MousePointers on HTML', () => { describe('updateParticipantsMouses', () => { beforeEach(() => { - presenceMouseComponent['onParticipantsDidChange'](participants); + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant2-id', + data: { + ...MOCK_MOUSE, + id: 'unit-test-participant2-id', + }, + id: 'unit-test-participant2-id', + name: MOCK_MOUSE.name as string, + timestamp: 1, + }); }); test('should call removePresenceMouseParticipant if mouse is not visible', () => { @@ -829,12 +879,10 @@ describe('MousePointers on HTML', () => { 'removePresenceMouseParticipant', ); - presenceMouseComponent['presences'].get('unit-test-participant2-ably-id')!.visible = false; + presenceMouseComponent['presences'].get('unit-test-participant2-id')!.visible = false; presenceMouseComponent['updateParticipantsMouses'](); - expect(removePresenceMouseParticipantSpy).toHaveBeenCalledWith( - 'unit-test-participant2-ably-id', - ); + expect(removePresenceMouseParticipantSpy).toHaveBeenCalledWith('unit-test-participant2-id'); }); test('should call renderPresenceMouses', () => { @@ -846,17 +894,17 @@ describe('MousePointers on HTML', () => { presenceMouseComponent['updateParticipantsMouses'](); expect(renderPresenceMousesSpy).toHaveBeenCalledWith( - presenceMouseComponent['presences'].get('unit-test-participant2-ably-id')!, + presenceMouseComponent['presences'].get('unit-test-participant2-id')!, ); }); test('should call goToMouse', () => { const goToMouseSpy = jest.spyOn(presenceMouseComponent as any, 'goToMouse'); - presenceMouseComponent['userBeingFollowedId'] = 'unit-test-participant2-ably-id'; + presenceMouseComponent['userBeingFollowedId'] = 'unit-test-participant2-id'; presenceMouseComponent['updateParticipantsMouses'](); - expect(goToMouseSpy).toHaveBeenCalledWith('unit-test-participant2-ably-id'); + expect(goToMouseSpy).toHaveBeenCalledWith('unit-test-participant2-id'); }); }); @@ -1046,23 +1094,32 @@ describe('MousePointers on HTML', () => { describe('removePresenceMouseParticipant', () => { beforeEach(() => { - presenceMouseComponent['onParticipantsDidChange'](participants); + presenceMouseComponent['onPresenceUpdate']({ + connectionId: 'unit-test-participant2-id', + data: { + ...MOCK_MOUSE, + id: 'unit-test-participant2-id', + }, + id: 'unit-test-participant2-id', + name: MOCK_MOUSE.name as string, + timestamp: 1, + }); presenceMouseComponent['updateParticipantsMouses'](); }); test('should remove the mouse element and the participant from the presences and mouses', () => { const mouse = document.createElement('div'); - mouse.setAttribute('id', 'mouse-unit-test-participant2-ably-id'); + mouse.setAttribute('id', 'mouse-unit-test-participant2-id'); document.body.appendChild(mouse); - presenceMouseComponent['presences'].set('unit-test-participant2-ably-id', MOCK_MOUSE); - presenceMouseComponent['mouses'].set('unit-test-participant2-ably-id', mouse); + presenceMouseComponent['presences'].set('unit-test-participant2-id', MOCK_MOUSE); + presenceMouseComponent['mouses'].set('unit-test-participant2-id', mouse); - presenceMouseComponent['removePresenceMouseParticipant']('unit-test-participant2-ably-id'); + presenceMouseComponent['removePresenceMouseParticipant']('unit-test-participant2-id'); - expect(document.getElementById('mouse-unit-test-participant2-ably-id')).toBeFalsy(); - expect(presenceMouseComponent['presences'].get('unit-test-participant2-ably-id')).toBeFalsy(); - expect(presenceMouseComponent['mouses'].get('unit-test-participant2-ably-id')).toBeFalsy(); + expect(document.getElementById('mouse-unit-test-participant2-id')).toBeFalsy(); + expect(presenceMouseComponent['presences'].get('unit-test-participant2-id')).toBeFalsy(); + expect(presenceMouseComponent['mouses'].get('unit-test-participant2-id')).toBeFalsy(); }); test('should not remove the mouse element if it does not exist', () => { @@ -1113,25 +1170,17 @@ describe('MousePointers on HTML', () => { test('should create the mouse element if it does not exist', () => { const createMouseElementSpy = jest.spyOn(presenceMouseComponent as any, 'createMouseElement'); - presenceMouseComponent['renderPresenceMouses']( - participants['unit-test-participant2-ably-id'], - ); + presenceMouseComponent['renderPresenceMouses'](participants['unit-test-participant2-id']); - expect(createMouseElementSpy).toHaveBeenCalledWith( - participants['unit-test-participant2-ably-id'], - ); + expect(createMouseElementSpy).toHaveBeenCalledWith(participants['unit-test-participant2-id']); }); test('should not create the mouse element if it already exists', () => { const createMouseElementSpy = jest.spyOn(presenceMouseComponent as any, 'createMouseElement'); - presenceMouseComponent['renderPresenceMouses']( - participants['unit-test-participant2-ably-id'], - ); + presenceMouseComponent['renderPresenceMouses'](participants['unit-test-participant2-id']); - presenceMouseComponent['renderPresenceMouses']( - participants['unit-test-participant2-ably-id'], - ); + presenceMouseComponent['renderPresenceMouses'](participants['unit-test-participant2-id']); expect(createMouseElementSpy).toHaveBeenCalledTimes(1); }); diff --git a/src/components/presence-mouse/html/index.ts b/src/components/presence-mouse/html/index.ts index c513210c..4a17da03 100644 --- a/src/components/presence-mouse/html/index.ts +++ b/src/components/presence-mouse/html/index.ts @@ -1,7 +1,10 @@ +import * as Socket from '@superviz/socket-client'; import { isEqual } from 'lodash'; +import { Subscription, fromEvent, throttleTime } from 'rxjs'; import { RealtimeEvent } from '../../../common/types/events.types'; -import { INDEX_IS_WHITE_TEXT } from '../../../common/types/meeting-colors.types'; +import { Participant } from '../../../common/types/participant.types'; +import { StoreType } from '../../../common/types/stores.types'; import { Logger } from '../../../common/utils'; import { BaseComponent } from '../../base'; import { ComponentNames } from '../../types'; @@ -19,6 +22,7 @@ export class PointersHTML extends BaseComponent { // Realtime data private presences: Map = new Map(); + private localParticipant: Participant; // Elements private container: HTMLElement | SVGElement; @@ -32,6 +36,7 @@ export class PointersHTML extends BaseComponent { private isPrivate: boolean; private containerTagname: string; private transformation: Transform = { translate: { x: 0, y: 0 }, scale: 1 }; + private pointerMoveObserver: Subscription; // callbacks private goToPresenceCallback: PresenceMouseProps['callbacks']['onGoToPresence']; @@ -55,6 +60,9 @@ export class PointersHTML extends BaseComponent { } this.name = ComponentNames.PRESENCE; + const { localParticipant } = this.useStore(StoreType.GLOBAL); + localParticipant.subscribe(); + this.containerTagname = this.container.tagName.toUpperCase(); this.goToPresenceCallback = options?.callbacks?.onGoToPresence; } @@ -68,7 +76,6 @@ export class PointersHTML extends BaseComponent { */ protected start(): void { this.logger.log('presence-mouse component @ start'); - this.realtime.enterPresenceMouseChannel(this.localParticipant); this.renderWrapper(); this.addListeners(); @@ -89,7 +96,6 @@ export class PointersHTML extends BaseComponent { cancelAnimationFrame(this.animationFrame); this.logger.log('presence-mouse component @ destroy'); - this.realtime.leavePresenceMouseChannel(); this.removeListeners(); this.wrapper.remove(); @@ -115,8 +121,12 @@ export class PointersHTML extends BaseComponent { */ private subscribeToRealtimeEvents(): void { this.logger.log('presence-mouse component @ subscribe to realtime events'); - this.realtime.presenceMouseParticipantLeaveObserver.subscribe(this.onParticipantLeftOnRealtime); - this.realtime.presenceMouseObserver.subscribe(this.onParticipantsDidChange); + this.room.presence.on( + Socket.PresenceEvents.JOINED_ROOM, + this.onPresenceJoinedRoom, + ); + this.room.presence.on(Socket.PresenceEvents.LEAVE, this.onPresenceLeftRoom); + this.room.presence.on(Socket.PresenceEvents.UPDATE, this.onPresenceUpdate); } /** @@ -126,21 +136,21 @@ export class PointersHTML extends BaseComponent { */ private unsubscribeFromRealtimeEvents(): void { this.logger.log('presence-mouse component @ unsubscribe from realtime events'); - this.realtime.presenceMouseParticipantLeaveObserver.unsubscribe( - this.onParticipantLeftOnRealtime, - ); - this.realtime.presenceMouseObserver.unsubscribe(this.onParticipantsDidChange); + this.room.presence.off(Socket.PresenceEvents.JOINED_ROOM); + this.room.presence.off(Socket.PresenceEvents.LEAVE); + this.room.presence.off(Socket.PresenceEvents.UPDATE); } /** * @function addListeners * @description adds the mousemove and mouseout listeners to the wrapper with the specified id - * @param {string} id the id of the wrapper * @returns {void} */ private addListeners(): void { - this.container.addEventListener('pointermove', this.onMyParticipantMouseMove); - this.container.addEventListener('mouseleave', this.onMyParticipantMouseLeave); + this.pointerMoveObserver = fromEvent(this.container, 'pointermove') + .pipe(throttleTime(30)) + .subscribe(this.onMyParticipantMouseMove); + this.container.addEventListener('pointerleave', this.onMyParticipantMouseLeave); } /** @@ -149,8 +159,8 @@ export class PointersHTML extends BaseComponent { * @returns {void} */ private removeListeners(): void { - this.container.removeEventListener('pointermove', this.onMyParticipantMouseMove); - this.container.removeEventListener('mouseleave', this.onMyParticipantMouseLeave); + this.pointerMoveObserver?.unsubscribe(); + this.container.removeEventListener('pointerleave', this.onMyParticipantMouseLeave); } // ---------- CALLBACKS ---------- @@ -168,7 +178,7 @@ export class PointersHTML extends BaseComponent { const x = (event.x - left - this.transformation.translate.x) / this.transformation.scale; const y = (event.y - top - this.transformation.translate.y) / this.transformation.scale; - this.realtime.updatePresenceMouse({ + this.room.presence.update({ ...this.localParticipant, x, y, @@ -178,39 +188,12 @@ export class PointersHTML extends BaseComponent { /** * @function onMyParticipantMouseLeave - * @param {MouseEvent} event - The MouseEvent object * @returns {void} */ - private onMyParticipantMouseLeave = (): void => { - this.realtime.updatePresenceMouse({ visible: false, ...this.localParticipant }); - }; - - /** - * @function onParticipantsDidChange - * @description handler for participant list update event - * @param {Record} participants - participants - * @returns {void} - */ - private onParticipantsDidChange = (participants: Record): void => { - this.logger.log('presence-mouse component @ on participants did change', participants); - Object.values(participants).forEach((participant: ParticipantMouse) => { - if (participant.id === this.localParticipant.id) return; - const followingAnotherParticipant = - this.userBeingFollowedId && - participant.id !== this.userBeingFollowedId && - this.presences.has(participant.id); - - // When the user is following a participant, every other mouse pointer is removed - if (followingAnotherParticipant) { - this.removePresenceMouseParticipant(participant.id); - return; - } - - this.presences.set(participant.id, participant); - }); - - this.animate(); - this.updateParticipantsMouses(); + private onMyParticipantMouseLeave = (event: MouseEvent): void => { + const { x, y, width, height } = this.container.getBoundingClientRect(); + if (event.x > 0 && event.y > 0 && event.x < x + width && event.y < y + height) return; + this.room.presence.update({ visible: false }); }; /** @@ -224,7 +207,10 @@ export class PointersHTML extends BaseComponent { if (!pointer) return; if (this.goToPresenceCallback) { - const { x, y } = this.mouses.get(id).getBoundingClientRect(); + const mouse = this.mouses.get(id); + const x = Number(mouse.style.left.replace('px', '')); + const y = Number(mouse.style.top.replace('px', '')); + this.goToPresenceCallback({ x, y }); return; } @@ -239,26 +225,65 @@ export class PointersHTML extends BaseComponent { */ private followMouse = (id: string) => { this.userBeingFollowedId = id; + this.goToMouse(id); }; /** - * @function onParticipantLeftOnRealtime - * @description handler for participant left event - * @param {AblyParticipant} participant + * @function setParticipantPrivate + * @description perform animation in presence mouse * @returns {void} */ - private onParticipantLeftOnRealtime = (participant: ParticipantMouse): void => { - this.removePresenceMouseParticipant(participant.id); + private setParticipantPrivate = (isPrivate: boolean): void => { + this.isPrivate = isPrivate; + this.room.presence.update({ visible: !isPrivate }); }; /** - * @function setParticipantPrivate - * @description perform animation in presence mouse + * @function onPresenceJoinedRoom + * @description handler for presence joined room event + * @param {PresenceEvent} presence * @returns {void} */ - private setParticipantPrivate = (isPrivate: boolean): void => { - this.isPrivate = isPrivate; - this.realtime.updatePresenceMouse({ ...this.localParticipant, visible: !isPrivate }); + private onPresenceJoinedRoom = (presence: Socket.PresenceEvent): void => { + if (presence.id !== this.localParticipant.id) return; + + this.room.presence.update(this.localParticipant); + }; + + /** + * @function onPresenceLeftRoom + * @description handler for presence left room event + * @param {PresenceEvent} presence + * @returns {void} + */ + private onPresenceLeftRoom = (presence: Socket.PresenceEvent): void => { + this.removePresenceMouseParticipant(presence.id); + }; + + /** + * @function onPresenceUpdate + * @description handler for presence update event + * @param {PresenceEvent} presence + * @returns {void} + */ + private onPresenceUpdate = (presence: Socket.PresenceEvent): void => { + if (presence.id === this.localParticipant.id) return; + const participant = presence.data; + + const followingAnotherParticipant = + this.userBeingFollowedId && + participant.id !== this.userBeingFollowedId && + this.presences.has(participant.id); + + // When the user is following a participant, every other mouse pointer is removed + if (followingAnotherParticipant) { + this.removePresenceMouseParticipant(participant.id); + return; + } + + this.presences.set(participant.id, participant); + this.animate(); + this.updateParticipantsMouses(); }; // ---------- HELPERS ---------- @@ -305,15 +330,6 @@ export class PointersHTML extends BaseComponent { return mouseFollower; } - /** - * @function getTextColorValue - * @param slotIndex - slot index - * @returns {string} - The color of the text in hex format - * */ - private getTextColorValue = (slotIndex: number): string => { - return INDEX_IS_WHITE_TEXT.includes(slotIndex) ? '#FFFFFF' : '#26242A'; - }; - /** * @function updateSVGPosition * @description - Updates the position of the wrapper of a element @@ -481,6 +497,7 @@ export class PointersHTML extends BaseComponent { */ public transform(transformation: Transform) { this.transformation = transformation; + this.updateParticipantsMouses(true); } /** @@ -500,7 +517,7 @@ export class PointersHTML extends BaseComponent { this.animationFrame = requestAnimationFrame(this.animate); }; - private updateParticipantsMouses = (): void => { + private updateParticipantsMouses = (haltFollow?: boolean): void => { this.presences.forEach((mouse) => { if (mouse.id === this.localParticipant.id) return; @@ -512,6 +529,8 @@ export class PointersHTML extends BaseComponent { this.renderPresenceMouses(mouse); }); + if (haltFollow) return; + const isFollowingSomeone = this.presences.has(this.userBeingFollowedId); if (isFollowingSomeone) { this.goToMouse(this.userBeingFollowedId); @@ -631,12 +650,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.slotIndex}.svg)`; + pointerUser.style.backgroundImage = `url(https://production.cdn.superviz.com/static/pointers-v2/${participant.slot.index}.svg)`; } if (mouseUser) { - mouseUser.style.color = this.getTextColorValue(participant.slotIndex); - mouseUser.style.backgroundColor = participant.color; + mouseUser.style.color = participant.slot.textColor; + mouseUser.style.backgroundColor = participant.slot.color; mouseUser.innerHTML = participant.name; } @@ -646,7 +665,8 @@ export class PointersHTML extends BaseComponent { scale, } = this.transformation; - mouseFollower.style.transform = `translate(${baseX + x * scale}px, ${baseY + y * scale}px)`; + mouseFollower.style.left = `${baseX + x * scale}px`; + mouseFollower.style.top = `${baseY + y * scale}px`; }; /** diff --git a/src/components/presence-mouse/index.ts b/src/components/presence-mouse/index.ts index 7f18f3cd..2d3efae2 100644 --- a/src/components/presence-mouse/index.ts +++ b/src/components/presence-mouse/index.ts @@ -1,17 +1,29 @@ /* eslint-disable no-constructor-return */ import { PointersCanvas } from './canvas'; import { PointersHTML } from './html'; -import { PresenceMouseProps } from './types'; +import { PresenceMouseProps, Transform } from './types'; export class MousePointers { constructor(containerId: string, options?: PresenceMouseProps) { const container = document.getElementById(containerId); + + if (!container) { + throw new Error(`[Superviz] Container with id ${containerId} not found`); + } + const tagName = container.tagName.toLowerCase(); if (tagName === 'canvas') { + // @ts-ignore return new PointersCanvas(containerId, options); } + // @ts-ignore return new PointersHTML(containerId, options); } + + public transform(transform: Transform) { + // this is here to give a signature to the method + // the real implementation occurs in each Pointer + } } diff --git a/src/components/presence-mouse/types.ts b/src/components/presence-mouse/types.ts index c4571fd1..d21752f8 100644 --- a/src/components/presence-mouse/types.ts +++ b/src/components/presence-mouse/types.ts @@ -1,7 +1,6 @@ import { Participant } from '../../common/types/participant.types'; export interface ParticipantMouse extends Participant { - slotIndex: number; x: number; y: number; visible: boolean; diff --git a/src/components/realtime/index.test.ts b/src/components/realtime/index.test.ts index 9c2c5870..d6dd7d7a 100644 --- a/src/components/realtime/index.test.ts +++ b/src/components/realtime/index.test.ts @@ -1,23 +1,18 @@ +import type * as Socket from '@superviz/socket-client'; + import { MOCK_CONFIG } from '../../../__mocks__/config.mock'; import { EVENT_BUS_MOCK } from '../../../__mocks__/event-bus.mock'; -import { MOCK_GROUP, MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; +import { MOCK_OBSERVER_HELPER } from '../../../__mocks__/observer-helper.mock'; +import { MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; import { ABLY_REALTIME_MOCK } from '../../../__mocks__/realtime.mock'; +import { useStore } from '../../common/utils/use-store'; +import { IOC } from '../../services/io'; -import { Realtime } from '.'; - -const PUB_SUB_MOCK = { - destroy: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - publish: jest.fn(), - fetchHistory: jest.fn(), - publishEventToClient: jest.fn(), -}; +import { RealtimeComponentState } from './types'; -jest.mock('../../services/pubsub', () => ({ - PubSub: jest.fn().mockImplementation(() => PUB_SUB_MOCK), -})); +import { Realtime } from '.'; +jest.mock('lodash/throttle', () => jest.fn((fn) => fn)); jest.useFakeTimers(); describe('realtime component', () => { @@ -26,113 +21,224 @@ describe('realtime component', () => { beforeEach(() => { jest.clearAllMocks(); + console.error = jest.fn(); + console.debug = jest.fn(); + RealtimeComponentInstance = new Realtime(); RealtimeComponentInstance.attach({ + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), realtime: Object.assign({}, ABLY_REALTIME_MOCK, { isJoinedRoom: true }), - localParticipant: MOCK_LOCAL_PARTICIPANT, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + useStore, }); - }); - test('should be subscribe to event', () => { - const callback = jest.fn(); + RealtimeComponentInstance['state'] = RealtimeComponentState.STARTED; + }); - RealtimeComponentInstance.subscribe('test', callback); + afterEach(() => { + jest.clearAllMocks(); + RealtimeComponentInstance.detach(); + }); - expect(PUB_SUB_MOCK.subscribe).toHaveBeenCalledWith('test', callback); + test('should create a new instance of Realtime', () => { + expect(RealtimeComponentInstance).toBeInstanceOf(Realtime); }); - test('should be unsubscribe from event', () => { - const callback = jest.fn(); + describe('start', () => { + test('should subscribe to realtiem events', () => { + const spy = jest.spyOn(RealtimeComponentInstance, 'subscribeToRealtimeEvents' as any); + RealtimeComponentInstance['start'](); - RealtimeComponentInstance.unsubscribe('test', callback); + expect(spy).toHaveBeenCalled(); + }); + + test('should subscribe to callbacks when joined', () => { + const spy = jest.spyOn(RealtimeComponentInstance['room'], 'on' as any); + RealtimeComponentInstance['start'](); - expect(PUB_SUB_MOCK.unsubscribe).toHaveBeenCalledWith('test', callback); + expect(spy).toHaveBeenCalled(); + }); }); - test('should be publish event to realtime', () => { - RealtimeComponentInstance.publish('test', 'test'); + describe('destroy', () => { + test('should disconnect from the room', () => { + const spy = jest.spyOn(RealtimeComponentInstance['room'], 'disconnect' as any); + RealtimeComponentInstance['destroy'](); - expect(PUB_SUB_MOCK.publish).toHaveBeenCalledWith('test', 'test'); - }); + expect(spy).toHaveBeenCalled(); + }); - test('should be fetch history', async () => { - RealtimeComponentInstance.fetchHistory('test'); + test('should change state to stopped', () => { + RealtimeComponentInstance['destroy'](); - expect(PUB_SUB_MOCK.fetchHistory).toHaveBeenCalledWith('test'); + expect(RealtimeComponentInstance['state']).toBe('STOPPED'); + }); }); - test('should destroy pubsub when destroy realtime component', () => { - RealtimeComponentInstance.detach(); + describe('publish', () => { + test('should log an error when trying to publish an event before start', () => { + RealtimeComponentInstance['state'] = RealtimeComponentState.STOPPED; + + const spy = jest.spyOn(RealtimeComponentInstance['logger'], 'log' as any); + RealtimeComponentInstance.publish('test'); + + expect(spy).toHaveBeenCalled(); + }); - expect(PUB_SUB_MOCK.destroy).toHaveBeenCalled(); + test('should publish an event', () => { + const spy = jest.spyOn(RealtimeComponentInstance['room'], 'emit' as any); + RealtimeComponentInstance['start'](); + RealtimeComponentInstance.publish('test'); + + expect(spy).toHaveBeenCalled(); + }); }); - test('when realtime is not joined room should store callback to subscribe', () => { - const callback = jest.fn(); - const RealtimeComponentInstance = new Realtime(); + describe('subscribe', () => { + test('should subscribe to an event', () => { + RealtimeComponentInstance['observers']['test'] = MOCK_OBSERVER_HELPER; + const spy = jest.spyOn(RealtimeComponentInstance['observers']['test'], 'subscribe' as any); + RealtimeComponentInstance['start'](); + RealtimeComponentInstance.subscribe('test', () => {}); - RealtimeComponentInstance.attach({ - realtime: Object.assign({}, ABLY_REALTIME_MOCK, { isJoinedRoom: false }), - localParticipant: MOCK_LOCAL_PARTICIPANT, - group: MOCK_GROUP, - config: MOCK_CONFIG, - eventBus: EVENT_BUS_MOCK, + expect(spy).toHaveBeenCalled(); }); - RealtimeComponentInstance.subscribe('test', callback); + test('should subscribe to an event when joined', () => { + RealtimeComponentInstance['state'] = RealtimeComponentState.STOPPED; + RealtimeComponentInstance.subscribe('test', () => {}); - expect(PUB_SUB_MOCK.subscribe).not.toHaveBeenCalled(); - expect(RealtimeComponentInstance['callbacksToSubscribeWhenJoined']).toEqual([ - { event: 'test', callback }, - ]); + expect(RealtimeComponentInstance['callbacksToSubscribeWhenJoined']).toHaveLength(1); + }); }); - test('should subscribe to events when joined room', async () => { - const callback = jest.fn(); - const RealtimeComponentInstance = new Realtime(); + describe('unsubscribe', () => { + test('should unsubscribe from an event', () => { + RealtimeComponentInstance['observers']['test'] = MOCK_OBSERVER_HELPER; + const spy = jest.spyOn(RealtimeComponentInstance['observers']['test'], 'unsubscribe' as any); + RealtimeComponentInstance['start'](); + RealtimeComponentInstance.unsubscribe('test', () => {}); - RealtimeComponentInstance.attach({ - realtime: Object.assign({}, ABLY_REALTIME_MOCK, { isJoinedRoom: false }), - localParticipant: MOCK_LOCAL_PARTICIPANT, - group: MOCK_GROUP, - config: MOCK_CONFIG, - eventBus: EVENT_BUS_MOCK, + expect(spy).toHaveBeenCalled(); }); + }); - RealtimeComponentInstance.subscribe('test', callback); + describe('subscribeToRealtimeEvents', () => { + test('should subscribe to all realtime events', () => { + const spy = jest.spyOn(RealtimeComponentInstance['room'], 'on' as any); + RealtimeComponentInstance['start'](); + RealtimeComponentInstance['subscribeToRealtimeEvents'](); - expect(PUB_SUB_MOCK.subscribe).not.toHaveBeenCalled(); + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('fetchHistory', () => { + test('should return null when the realtime is not started', async () => { + RealtimeComponentInstance['state'] = RealtimeComponentState.STOPPED; + const h = await RealtimeComponentInstance.fetchHistory(); - RealtimeComponentInstance['realtime'] = Object.assign({}, ABLY_REALTIME_MOCK, { - isJoinedRoom: true, + expect(h).toEqual(null); }); - // mock start call - RealtimeComponentInstance['start'](); + test('should return null when the history is empty', async () => { + const spy = jest + .spyOn(RealtimeComponentInstance['room'], 'history' as any) + .mockImplementationOnce((...args: unknown[]) => { + const next = args[0] as (data: any) => void; + next({ events: [] }); + }); - expect(PUB_SUB_MOCK.subscribe).toHaveBeenCalledWith('test', callback); - }); + RealtimeComponentInstance['start'](); + const h = await RealtimeComponentInstance.fetchHistory(); - test('should not publish event when realtime is not started', () => { - console.error = jest.fn(); - const RealtimeComponentInstance = new Realtime(); + expect(spy).toHaveBeenCalled(); + expect(h).toEqual(null); + }); - RealtimeComponentInstance.attach({ - realtime: Object.assign({}, ABLY_REALTIME_MOCK, { isJoinedRoom: false }), - localParticipant: MOCK_LOCAL_PARTICIPANT, - group: MOCK_GROUP, - config: MOCK_CONFIG, - eventBus: EVENT_BUS_MOCK, + test('should return the history', async () => { + const spy = jest + .spyOn(RealtimeComponentInstance['room'], 'history' as any) + .mockImplementationOnce((...args: unknown[]) => { + const next = args[0] as (data: any) => void; + next({ + events: [ + { + timestamp: 1710336284652, + presence: { id: 'unit-test-presence-id', name: 'unit-test-presence-name' }, + data: { name: 'unit-test-event-name', payload: 'unit-test-event-payload' }, + }, + ], + }); + }); + + RealtimeComponentInstance['start'](); + const h = await RealtimeComponentInstance.fetchHistory(); + + expect(spy).toHaveBeenCalled(); + expect(h).toEqual({ + 'unit-test-event-name': [ + { + data: 'unit-test-event-payload', + name: 'unit-test-event-name', + participantId: 'unit-test-presence-id', + timestamp: 1710336284652, + }, + ], + }); }); - RealtimeComponentInstance.publish('test', 'test'); + test('should return the history for a specific event', async () => { + const spy = jest + .spyOn(RealtimeComponentInstance['room'], 'history' as any) + .mockImplementationOnce((...args: unknown[]) => { + const next = args[0] as (data: any) => void; + next({ + events: [ + { + timestamp: 1710336284652, + presence: { id: 'unit-test-presence-id', name: 'unit-test-presence-name' }, + data: { name: 'unit-test-event-name', payload: 'unit-test-event-payload' }, + }, + ], + }); + }); + + RealtimeComponentInstance['start'](); + const h = await RealtimeComponentInstance.fetchHistory('unit-test-event-name'); + + expect(spy).toHaveBeenCalled(); + expect(h).toEqual([ + { + data: 'unit-test-event-payload', + name: 'unit-test-event-name', + participantId: 'unit-test-presence-id', + timestamp: 1710336284652, + }, + ]); + }); - expect(console.error).toHaveBeenCalledWith( - "Realtime component is not started yet. You can't publish event test before start", - ); - expect(PUB_SUB_MOCK.publish).not.toHaveBeenCalledWith('test', 'test'); + test('should reject when the event is not found', async () => { + const spy = jest + .spyOn(RealtimeComponentInstance['room'], 'history' as any) + .mockImplementationOnce((...args: unknown[]) => { + const next = args[0] as (data: any) => void; + next({ + events: [ + { + timestamp: 1710336284652, + presence: { id: 'unit-test-presence-id', name: 'unit-test-presence-name' }, + data: { name: 'unit-test-event-name', payload: 'unit-test-event-payload' }, + }, + ], + }); + }); + + RealtimeComponentInstance['start'](); + const h = RealtimeComponentInstance.fetchHistory('unit-test-event-wrong-name'); + + await expect(h).rejects.toThrow('Event unit-test-event-wrong-name not found in the history'); + }); }); }); diff --git a/src/components/realtime/index.ts b/src/components/realtime/index.ts index 99d40829..260df168 100644 --- a/src/components/realtime/index.ts +++ b/src/components/realtime/index.ts @@ -1,14 +1,21 @@ -import { Logger } from '../../common/utils'; -import { PubSub } from '../../services/pubsub'; -import { RealtimeMessage } from '../../services/realtime/ably/types'; +import * as Socket from '@superviz/socket-client'; +import throttle from 'lodash/throttle'; + +import { ComponentLifeCycleEvent } from '../../common/types/events.types'; +import { StoreType } from '../../common/types/stores.types'; +import { Logger, Observer } from '../../common/utils'; import { BaseComponent } from '../base'; import { ComponentNames } from '../types'; +import { Participant } from '../who-is-online/types'; -import { RealtimeComponentEvent, RealtimeComponentState } from './types'; +import { + RealtimeComponentEvent, + RealtimeComponentState, + RealtimeData, + RealtimeMessage, +} from './types'; export class Realtime extends BaseComponent { - private pubsub: PubSub; - private callbacksToSubscribeWhenJoined: Array<{ event: string; callback: (data: unknown) => void; @@ -17,29 +24,27 @@ export class Realtime extends BaseComponent { protected logger: Logger; public name: ComponentNames; private state: RealtimeComponentState = RealtimeComponentState.STOPPED; + private declare localParticipant: Participant; constructor() { super(); this.name = ComponentNames.REALTIME; this.logger = new Logger('@superviz/sdk/realtime-component'); + const { localParticipant } = this.useStore(StoreType.GLOBAL); + localParticipant.subscribe(); } protected destroy(): void { this.logger.log('destroyed'); this.changeState(RealtimeComponentState.STOPPED); - this.pubsub.destroy(); + this.room.disconnect(); } protected start(): void { this.logger.log('started'); - this.pubsub = new PubSub(this.realtime); - this.callbacksToSubscribeWhenJoined.forEach(({ event, callback }) => { - this.pubsub.subscribe(event, callback); - }); - - this.changeState(RealtimeComponentState.STARTED); + this.subscribeToRealtimeEvents(); } /** @@ -49,16 +54,21 @@ export class Realtime extends BaseComponent { * @param data - event data * @returns {void} */ - public publish = (event: string, data?: unknown): void => { - if (!this.pubsub) { + public publish = throttle((event: string, data?: unknown): void => { + if (Object.values(ComponentLifeCycleEvent).includes(event as ComponentLifeCycleEvent)) { + this.publishEventToClient(event, data); + return; + } + + if (this.state !== RealtimeComponentState.STARTED) { const message = `Realtime component is not started yet. You can't publish event ${event} before start`; this.logger.log(message); - console.error(message); + console.warn(`[SuperViz] ${message}`); return; } - this.pubsub.publish(event, data); - }; + this.room.emit('message', { name: event, payload: data }); + }, 30); /** * @function subscribe @@ -68,12 +78,16 @@ export class Realtime extends BaseComponent { * @returns {void} */ public subscribe = (event: string, callback: (data: unknown) => void): void => { - if (!this.pubsub) { + if (this.state !== RealtimeComponentState.STARTED) { this.callbacksToSubscribeWhenJoined.push({ event, callback }); return; } - this.pubsub.subscribe(event, callback); + if (!this.observers[event]) { + this.observers[event] = new Observer(); + } + + this.observers[event].subscribe(callback); }; /** @@ -84,19 +98,70 @@ export class Realtime extends BaseComponent { * @returns {void} */ public unsubscribe = (event: string, callback?: (data: unknown) => void): void => { - this.pubsub.unsubscribe(event, callback); + if (!this.observers[event]) return; + + this.observers[event].unsubscribe(callback); }; /** - * @function fetchSyncClientProperty + * @function fetchHistory * @description get realtime client data history * @returns {RealtimeMessage | Record} */ - public fetchHistory( + public fetchHistory = async ( eventName?: string, - ): Promise> { - return this.pubsub.fetchHistory(eventName); - } + ): Promise | null> => { + if (this.state !== RealtimeComponentState.STARTED) { + const message = `Realtime component is not started yet. You can't retrieve history before start`; + + this.logger.log(message); + console.warn(`[SuperViz] ${message}`); + return null; + } + + const history: RealtimeMessage[] | Record = await new Promise( + (resolve, reject) => { + const next = (data: Socket.RoomHistory) => { + if (!data.events?.length) { + resolve(null); + } + + const groupMessages = data.events.reduce( + (group: Record, event: Socket.SocketEvent) => { + if (!group[event.data.name]) { + // eslint-disable-next-line no-param-reassign + group[event.data.name] = []; + } + + group[event.data.name].push({ + data: event.data.payload, + name: event.data.name, + participantId: event.presence.id, + timestamp: event.timestamp, + }); + + return group; + }, + {}, + ); + + if (eventName && !groupMessages[eventName]) { + reject(new Error(`Event ${eventName} not found in the history`)); + } + + if (eventName) { + resolve(groupMessages[eventName]); + } + + resolve(groupMessages); + }; + + this.room.history(next); + }, + ); + + return history; + }; /** * @function changeState @@ -108,6 +173,47 @@ export class Realtime extends BaseComponent { this.logger.log('realtime component @ changeState - state changed', state); this.state = state; - this.pubsub.publishEventToClient(RealtimeComponentEvent.REALTIME_STATE_CHANGED, this.state); + this.publishEventToClient(RealtimeComponentEvent.REALTIME_STATE_CHANGED, this.state); } + + private subscribeToRealtimeEvents(): void { + this.room.presence.on(Socket.PresenceEvents.JOINED_ROOM, (event) => { + if (event.id !== this.localParticipant.id) return; + + this.changeState(RealtimeComponentState.STARTED); + + this.callbacksToSubscribeWhenJoined.forEach(({ event, callback }) => { + this.subscribe(event, callback); + }); + + this.logger.log('joined room'); + // publishing again to make sure all clients know that we are connected + this.changeState(RealtimeComponentState.STARTED); + }); + + this.room.on('message', (event) => { + this.logger.log('message received', event); + this.publishEventToClient(event.data.name, { + data: event.data.payload, + participantId: event.presence.id, + name: event.data.name, + timestamp: event.timestamp, + } as RealtimeMessage); + }); + } + + /** + * @function publishEventToClient + * @description - publish event to client + * @param event - event name + * @param data - data to publish + * @returns {void} + */ + public publishEventToClient = (event: string, data?: unknown): void => { + this.logger.log('pubsub service @ publishEventToClient', { event, data }); + + if (!this.observers[event]) return; + + this.observers[event].publish(data); + }; } diff --git a/src/components/realtime/types.ts b/src/components/realtime/types.ts index 80dc15b1..497acccf 100644 --- a/src/components/realtime/types.ts +++ b/src/components/realtime/types.ts @@ -6,3 +6,15 @@ export enum RealtimeComponentState { export enum RealtimeComponentEvent { REALTIME_STATE_CHANGED = 'realtime-component.state-changed', } + +export type RealtimeData = { + name: string; + payload: any; +}; + +export type RealtimeMessage = { + name: string; + participantId: string; + data: unknown; + timestamp: number; +}; diff --git a/src/components/video/index.test.ts b/src/components/video/index.test.ts index cf48888f..0b112e88 100644 --- a/src/components/video/index.test.ts +++ b/src/components/video/index.test.ts @@ -1,11 +1,7 @@ import { MOCK_CONFIG } from '../../../__mocks__/config.mock'; import { EVENT_BUS_MOCK } from '../../../__mocks__/event-bus.mock'; import { MOCK_OBSERVER_HELPER } from '../../../__mocks__/observer-helper.mock'; -import { - MOCK_AVATAR, - MOCK_GROUP, - MOCK_LOCAL_PARTICIPANT, -} from '../../../__mocks__/participants.mock'; +import { MOCK_AVATAR, MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; import { ABLY_REALTIME_MOCK } from '../../../__mocks__/realtime.mock'; import { DeviceEvent, @@ -19,6 +15,8 @@ import { } from '../../common/types/events.types'; import { MeetingColors } from '../../common/types/meeting-colors.types'; import { ParticipantType } from '../../common/types/participant.types'; +import { useStore } from '../../common/utils/use-store'; +import { IOC } from '../../services/io'; import { AblyParticipant, AblyRealtimeData } from '../../services/realtime/ably/types'; import { VideoFrameState } from '../../services/video-conference-manager/types'; import { ComponentNames } from '../types'; @@ -96,12 +94,13 @@ describe('VideoConference', () => { allowGuests: false, }); + VideoConferenceInstance['localParticipant'] = MOCK_LOCAL_PARTICIPANT; VideoConferenceInstance.attach({ + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), realtime: MOCK_REALTIME, - localParticipant: MOCK_LOCAL_PARTICIPANT, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + useStore, }); }); @@ -116,15 +115,17 @@ describe('VideoConference', () => { defaultAvatars: true, }); + VideoConferenceInstance['localParticipant'] = { + ...MOCK_LOCAL_PARTICIPANT, + avatar: MOCK_AVATAR, + }; + VideoConferenceInstance.attach({ + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), realtime: MOCK_REALTIME, - localParticipant: { - ...MOCK_LOCAL_PARTICIPANT, - avatar: MOCK_AVATAR, - }, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + useStore, }); expect(VideoConferenceInstance['videoConfig'].canUseDefaultAvatars).toBeFalsy(); diff --git a/src/components/video/index.ts b/src/components/video/index.ts index a1057e17..ecf6fbc1 100644 --- a/src/components/video/index.ts +++ b/src/components/video/index.ts @@ -13,6 +13,7 @@ import { TranscriptState, } from '../../common/types/events.types'; import { Participant, ParticipantType } from '../../common/types/participant.types'; +import { StoreType } from '../../common/types/stores.types'; import { Logger } from '../../common/utils'; import { BrowserService } from '../../services/browser'; import config from '../../services/config'; @@ -40,6 +41,7 @@ export class VideoConference extends BaseComponent { protected logger: Logger; private participantToFrameList: ParticipandToFrame[] = []; private participantsOnMeeting: Partial[] = []; + private localParticipant: Participant; private videoManager: VideoConfereceManager; private connectionService: ConnectionService; @@ -59,6 +61,10 @@ export class VideoConference extends BaseComponent { }; this.name = ComponentNames.VIDEO_CONFERENCE; + const { localParticipant, group } = this.useStore(StoreType.GLOBAL); + localParticipant.subscribe(); + group.subscribe(); + this.logger = new Logger(`@superviz/sdk/${ComponentNames.VIDEO_CONFERENCE}`); this.browserService = new BrowserService(); @@ -140,10 +146,6 @@ export class VideoConference extends BaseComponent { protected start(): void { this.logger.log('video conference @ start'); - if (this.params.userType !== ParticipantType.GUEST) { - this.localParticipant.type = this.params.userType as ParticipantType; - } - this.suscribeToRealtimeEvents(); this.startVideo(); } @@ -409,6 +411,16 @@ export class VideoConference extends BaseComponent { if (state !== VideoFrameState.INITIALIZED) return; + if (this.params.userType !== ParticipantType.GUEST) { + this.localParticipant = Object.assign(this.localParticipant, { + type: this.params.userType, + }); + + this.realtime.updateMyProperties({ + ...this.localParticipant, + }); + } + this.videoManager.start({ group: this.group, participant: this.localParticipant, @@ -635,10 +647,7 @@ export class VideoConference extends BaseComponent { return participant.id === hostId; }); - if (this.realtime.isLocalParticipantHost) { - this.realtime.setSyncProperty(MeetingEvent.MEETING_HOST_CHANGE, newHost); - this.publish(MeetingEvent.MEETING_HOST_CHANGE, newHost); - } + this.publish(MeetingEvent.MEETING_HOST_CHANGE, newHost); }; /** diff --git a/src/components/who-is-online/index.test.ts b/src/components/who-is-online/index.test.ts index fcd9daca..13f20c4c 100644 --- a/src/components/who-is-online/index.test.ts +++ b/src/components/who-is-online/index.test.ts @@ -3,13 +3,18 @@ import { EVENT_BUS_MOCK } from '../../../__mocks__/event-bus.mock'; import { MOCK_ABLY_PARTICIPANT, MOCK_ABLY_PARTICIPANT_DATA_2, - MOCK_GROUP, MOCK_LOCAL_PARTICIPANT, MOCK_ABLY_PARTICIPANT_DATA_1, } from '../../../__mocks__/participants.mock'; import { ABLY_REALTIME_MOCK } from '../../../__mocks__/realtime.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'; +import { Following } from '../../services/stores/who-is-online/types'; + +import { Avatar, Participant, TooltipData } from './types'; import { WhoIsOnline } from './index'; @@ -21,14 +26,14 @@ describe('Who Is Online', () => { whoIsOnlineComponent = new WhoIsOnline(); whoIsOnlineComponent.attach({ + ioc: new IOC(MOCK_LOCAL_PARTICIPANT), realtime: Object.assign({}, ABLY_REALTIME_MOCK, { isJoinedRoom: true }), - localParticipant: MOCK_LOCAL_PARTICIPANT, - group: MOCK_GROUP, config: MOCK_CONFIG, eventBus: EVENT_BUS_MOCK, + useStore, }); - whoIsOnlineComponent['element'].updateParticipants = jest.fn(); + whoIsOnlineComponent['localParticipantId'] = MOCK_LOCAL_PARTICIPANT.id; const gray = MeetingColorsHex[16]; whoIsOnlineComponent['color'] = gray; @@ -95,12 +100,18 @@ describe('Who Is Online', () => { }); describe('onParticipantListUpdate', () => { + let participants; + + beforeEach(() => { + participants = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE)['participants']; + }); + test('should correctly update participant list', () => { whoIsOnlineComponent['onParticipantListUpdate']({ 'unit-test-participant1-ably-id': MOCK_ABLY_PARTICIPANT, }); - expect(whoIsOnlineComponent['participants'].length).toBe(1); + expect(participants.value.length).toBe(1); whoIsOnlineComponent['onParticipantListUpdate']({ 'unit-test-participant2-ably-id': { @@ -111,7 +122,7 @@ describe('Who Is Online', () => { 'unit-test-participant1-ably-id': MOCK_ABLY_PARTICIPANT, }); - expect(whoIsOnlineComponent['participants'].length).toBe(2); + expect(participants.value.length).toBe(2); }); test('should not update participant list if participant is does not have whoIsOnline activated', () => { @@ -122,7 +133,7 @@ describe('Who Is Online', () => { }, }); - expect(whoIsOnlineComponent['participants'].length).toBe(0); + expect(participants.value.length).toBe(0); }); test('should not add the same participant twice', () => { @@ -130,13 +141,13 @@ describe('Who Is Online', () => { 'unit-test-participant1-ably-id': MOCK_ABLY_PARTICIPANT, }); - expect(whoIsOnlineComponent['participants'].length).toBe(1); + expect(participants.value.length).toBe(1); whoIsOnlineComponent['onParticipantListUpdate']({ 'unit-test-participant1-ably-id': MOCK_ABLY_PARTICIPANT, }); - expect(whoIsOnlineComponent['participants'].length).toBe(1); + expect(participants.value.length).toBe(1); }); test('should not display private participants', () => { @@ -144,7 +155,7 @@ describe('Who Is Online', () => { 'unit-test-participant1-ably-id': MOCK_ABLY_PARTICIPANT, }); - expect(whoIsOnlineComponent['participants'].length).toBe(1); + expect(participants.value.length).toBe(1); const privateParticipant = { ...MOCK_ABLY_PARTICIPANT, @@ -158,7 +169,7 @@ describe('Who Is Online', () => { 'unit-test-participant1-ably-id': privateParticipant, }); - expect(whoIsOnlineComponent['participants'].length).toBe(0); + expect(participants.value.length).toBe(0); }); test('should display private local participant', () => { @@ -174,30 +185,13 @@ describe('Who Is Online', () => { whoIsOnlineComponent['onParticipantListUpdate'](participantsData); - expect(whoIsOnlineComponent['participants'].length).toBe(1); + expect(participants.value.length).toBe(1); participantsData[MOCK_LOCAL_PARTICIPANT.id].data.isPrivate = true; whoIsOnlineComponent['onParticipantListUpdate'](participantsData); - expect(whoIsOnlineComponent['participants'].length).toBe(1); - }); - - test('should call stopFollowing if previously followed participant leaves', () => { - whoIsOnlineComponent['element'].following = MOCK_ABLY_PARTICIPANT_DATA_1; - whoIsOnlineComponent['following'] = MOCK_ABLY_PARTICIPANT_DATA_2.id; - - whoIsOnlineComponent['stopFollowing'] = jest - .fn() - .mockImplementation(whoIsOnlineComponent['stopFollowing']); - - whoIsOnlineComponent['onParticipantListUpdate']({ - 'unit-test-participant1-ably-id': MOCK_ABLY_PARTICIPANT, - }); - - expect(whoIsOnlineComponent['stopFollowing']).toHaveBeenCalledWith({ - clientId: MOCK_ABLY_PARTICIPANT_DATA_2.id, - }); + expect(participants.value.length).toBe(1); }); }); @@ -271,21 +265,36 @@ describe('Who Is Online', () => { }); test('should set element.data to following.data', () => { + const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + whoIsOnlineComponent['setFollow']({ ...MOCK_ABLY_PARTICIPANT, clientId: 'ably-id' }); - expect(whoIsOnlineComponent['element'].following).toBe(MOCK_ABLY_PARTICIPANT_DATA_1); + expect(following.value).toBe(MOCK_ABLY_PARTICIPANT_DATA_1); }); test('should early return if following the local participant', () => { - whoIsOnlineComponent['element'].following = MOCK_ABLY_PARTICIPANT_DATA_2; + const followingData: Following = { + color: MOCK_ABLY_PARTICIPANT_DATA_2.color, + id: MOCK_ABLY_PARTICIPANT_DATA_2.id, + name: MOCK_ABLY_PARTICIPANT_DATA_2.name, + }; + const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + following.publish(followingData); whoIsOnlineComponent['setFollow'](MOCK_ABLY_PARTICIPANT); expect(whoIsOnlineComponent['followMousePointer']).not.toHaveBeenCalled(); - expect(whoIsOnlineComponent['element'].following).toBe(MOCK_ABLY_PARTICIPANT_DATA_2); + expect(following.value).toBe(followingData); }); - test('should set element.following to undefiend return if no id is passed', () => { - whoIsOnlineComponent['element'].following = MOCK_ABLY_PARTICIPANT_DATA_2; + test('should set following to undefined if no id is passed', () => { + const followingData: Following = { + color: MOCK_ABLY_PARTICIPANT_DATA_2.color, + id: MOCK_ABLY_PARTICIPANT_DATA_2.id, + name: MOCK_ABLY_PARTICIPANT_DATA_2.name, + }; + + const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + following.publish(followingData); whoIsOnlineComponent['setFollow']({ ...MOCK_ABLY_PARTICIPANT, @@ -294,11 +303,11 @@ describe('Who Is Online', () => { }); const event = { - detail: { id: '' }, + detail: { id: undefined }, }; expect(whoIsOnlineComponent['followMousePointer']).toHaveBeenCalledWith(event); - expect(whoIsOnlineComponent['element'].following).toBe(undefined); + expect(following.value).toBe(undefined); }); }); @@ -320,25 +329,32 @@ describe('Who Is Online', () => { describe('stopFollowing', () => { test('should do nothing if participant leaving is not being followed', () => { - whoIsOnlineComponent['element'].following = MOCK_ABLY_PARTICIPANT_DATA_2; - whoIsOnlineComponent['following'] = 'ably-id'; + const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + following.publish({ + color: MOCK_ABLY_PARTICIPANT_DATA_2.color, + id: MOCK_ABLY_PARTICIPANT_DATA_2.id, + name: MOCK_ABLY_PARTICIPANT_DATA_2.name, + }); whoIsOnlineComponent['stopFollowing'](MOCK_ABLY_PARTICIPANT); - expect(whoIsOnlineComponent['element'].following).toBe(MOCK_ABLY_PARTICIPANT_DATA_2); - expect(whoIsOnlineComponent['following']).toBe('ably-id'); + expect(following.value).toBeDefined(); + expect(following.value.id).toBe(MOCK_ABLY_PARTICIPANT_DATA_2.id); }); - test('should set element.following to undefined if following the participant who is leaving', () => { - whoIsOnlineComponent['element'].following = MOCK_ABLY_PARTICIPANT_DATA_1; - whoIsOnlineComponent['following'] = MOCK_ABLY_PARTICIPANT_DATA_1.id; + test('should set following to undefined if following the participant who is leaving', () => { + const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + following.publish({ + color: MOCK_ABLY_PARTICIPANT_DATA_1.color, + id: MOCK_ABLY_PARTICIPANT_DATA_1.id, + name: MOCK_ABLY_PARTICIPANT_DATA_1.name, + }); whoIsOnlineComponent['stopFollowing']({ - ...MOCK_ABLY_PARTICIPANT, clientId: MOCK_ABLY_PARTICIPANT_DATA_1.id, }); - expect(whoIsOnlineComponent['element'].following).toBe(undefined); + expect(following.value).toBe(undefined); expect(whoIsOnlineComponent['following']).toBe(undefined); }); }); @@ -384,17 +400,33 @@ describe('Who Is Online', () => { }); test('should publish "follow" event when followMousePointer is called', () => { + const followingData: Following = { + color: MOCK_ABLY_PARTICIPANT_DATA_2.color, + id: MOCK_ABLY_PARTICIPANT_DATA_2.id, + name: MOCK_ABLY_PARTICIPANT_DATA_2.name, + }; + const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + following.publish(followingData); + whoIsOnlineComponent['followMousePointer']({ detail: { id: 'unit-test-id' }, } as CustomEvent); expect(whoIsOnlineComponent['publish']).toHaveBeenCalledWith( WhoIsOnlineEvent.START_FOLLOWING_PARTICIPANT, - 'unit-test-id', + followingData, ); }); test('should publish "stop following" if follow is called with undefined id', () => { + const followingData: Following = { + color: MOCK_ABLY_PARTICIPANT_DATA_2.color, + id: MOCK_ABLY_PARTICIPANT_DATA_2.id, + name: MOCK_ABLY_PARTICIPANT_DATA_2.name, + }; + const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + following.publish(undefined); + whoIsOnlineComponent['followMousePointer']({ detail: { id: undefined }, } as CustomEvent); @@ -405,14 +437,15 @@ describe('Who Is Online', () => { }); test('should publish "stop following" event when stopFollowing is called', () => { - whoIsOnlineComponent['element'].following = { - id: 'unit-test-id', - color: 'unit-test-color', - name: 'unit-test-name', - }; + const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + following.publish({ + color: MOCK_ABLY_PARTICIPANT_DATA_2.color, + id: MOCK_ABLY_PARTICIPANT_DATA_2.id, + name: MOCK_ABLY_PARTICIPANT_DATA_2.name, + }); whoIsOnlineComponent['stopFollowing']({ - clientId: 'unit-test-id', + clientId: MOCK_ABLY_PARTICIPANT_DATA_2.id, }); expect(whoIsOnlineComponent['publish']).toHaveBeenCalledWith( @@ -441,17 +474,29 @@ describe('Who Is Online', () => { }); test('should publish "follow me" event when follow is called with defined id', () => { + const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + const followingData: Following = { + color: MOCK_ABLY_PARTICIPANT_DATA_2.color, + id: MOCK_ABLY_PARTICIPANT_DATA_2.id, + name: MOCK_ABLY_PARTICIPANT_DATA_2.name, + }; + + following.publish(followingData); + whoIsOnlineComponent['follow']({ detail: { id: 'unit-test-id' }, } as CustomEvent); expect(whoIsOnlineComponent['publish']).toHaveBeenCalledWith( WhoIsOnlineEvent.START_FOLLOW_ME, - 'unit-test-id', + followingData, ); }); test('should publish "stop follow me" event when follow is called with undefined id', () => { + const { following } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + following.publish(undefined); + whoIsOnlineComponent['follow']({ detail: { id: undefined }, } as CustomEvent); @@ -478,4 +523,555 @@ describe('Who Is Online', () => { expect(styleElement).toBeFalsy(); }); }); + + describe('followMousePointer', () => { + test('should highlight participant being followed if they are an extra', () => { + whoIsOnlineComponent['highlightParticipantBeingFollowed'] = jest.fn(); + + whoIsOnlineComponent['followMousePointer']({ + detail: { id: 'test-id', source: 'extras' }, + } as any); + + expect(whoIsOnlineComponent['highlightParticipantBeingFollowed']).toHaveBeenCalled(); + }); + }); + + describe('shouldDisableDropdown', () => { + test('should disable dropdown when joinedPresence is false', () => { + whoIsOnlineComponent['useStore'] = jest.fn().mockReturnValue({ + joinedPresence: { value: false }, + disablePresenceControls: { value: false }, + disableFollowMe: { value: false }, + disableFollowParticipant: { value: false }, + disableGoToParticipant: { value: false }, + disableGatherAll: { value: false }, + disablePrivateMode: { value: false }, + }); + + expect( + whoIsOnlineComponent['shouldDisableDropdown']({ + activeComponents: ['PresenceButton'], + participantId: 'someId', + }), + ).toEqual(true); + }); + + test('should disable dropdown when disablePresenceControls is true', () => { + whoIsOnlineComponent['useStore'] = jest.fn().mockReturnValue({ + joinedPresence: { value: true }, + disablePresenceControls: { value: true }, + disableFollowMe: { value: false }, + disableFollowParticipant: { value: false }, + disableGoToParticipant: { value: false }, + disableGatherAll: { value: false }, + disablePrivateMode: { value: false }, + }); + + expect( + whoIsOnlineComponent['shouldDisableDropdown']({ + activeComponents: ['PresenceButton'], + participantId: 'someId', + }), + ).toEqual(true); + }); + + test('should disable dropdown for local participant with specific conditions', () => { + whoIsOnlineComponent['useStore'] = jest.fn().mockReturnValue({ + joinedPresence: { value: true }, + disablePresenceControls: { value: false }, + disableFollowMe: { value: true }, + disablePrivateMode: { value: true }, + disableGatherAll: { value: true }, + disableFollowParticipant: { value: true }, + disableGoToParticipant: { value: true }, + }); + + expect( + whoIsOnlineComponent['shouldDisableDropdown']({ + activeComponents: ['PresenceButton'], + participantId: 'localParticipantId', + }), + ).toEqual(true); + }); + + test('should not disable dropdown when conditions are not met', () => { + whoIsOnlineComponent['useStore'] = jest.fn().mockReturnValue({ + joinedPresence: { value: true }, + disablePresenceControls: { value: false }, + disableFollowMe: { value: false }, + disableFollowParticipant: { value: false }, + disableGoToParticipant: { value: false }, + disableGatherAll: { value: false }, + disablePrivateMode: { value: false }, + }); + + expect( + whoIsOnlineComponent['shouldDisableDropdown']({ + activeComponents: ['presence'], + participantId: 'someId', + }), + ).toEqual(false); + }); + + test('should not disable dropdown when activeComponents do not match', () => { + whoIsOnlineComponent['useStore'] = jest.fn().mockReturnValue({ + joinedPresence: { value: true }, + disablePresenceControls: { value: false }, + disableFollowMe: { value: false }, + disableFollowParticipant: { value: false }, + disableGoToParticipant: { value: false }, + disableGatherAll: { value: false }, + disablePrivateMode: { value: false }, + }); + + expect( + whoIsOnlineComponent['shouldDisableDropdown']({ + activeComponents: ['OtherComponent'], + participantId: 'someId', + }), + ).toEqual(true); + }); + }); + + describe('getTooltipData', () => { + test('should return tooltip data for local participant', () => { + const tooltipData = whoIsOnlineComponent['getTooltipData']({ + isLocalParticipant: true, + name: 'John', + presenceEnabled: true, + }); + + expect(tooltipData).toEqual({ + name: 'John (You)', + }); + }); + + test('should return tooltip data for remote participant with presence enabled', () => { + const tooltipData = whoIsOnlineComponent['getTooltipData']({ + isLocalParticipant: false, + name: 'Alice', + presenceEnabled: true, + }); + + expect(tooltipData).toEqual({ + name: 'Alice', + info: 'Click to follow', + }); + }); + + test('should return tooltip data for remote participant with presence disabled', () => { + const tooltipData = whoIsOnlineComponent['getTooltipData']({ + isLocalParticipant: false, + name: 'Bob', + presenceEnabled: false, + }); + + expect(tooltipData).toEqual({ + name: 'Bob', + }); + }); + + test('should return tooltip data for local participant with presence disabled', () => { + const tooltipData = whoIsOnlineComponent['getTooltipData']({ + isLocalParticipant: true, + name: 'Jane', + presenceEnabled: false, + }); + + expect(tooltipData).toEqual({ + name: 'Jane (You)', + }); + }); + }); + + describe('getAvatar', () => { + const mockAvatar: Avatar = { + imageUrl: 'https://example.com/avatar.jpg', + color: 'white', + firstLetter: 'L', + slotIndex: 0, + }; + + test('should return avatar data with image URL', () => { + const result = whoIsOnlineComponent['getAvatar']({ + avatar: mockAvatar as any, + name: 'John Doe', + color: '#007bff', + slotIndex: 1, + }); + + expect(result).toEqual({ + imageUrl: 'https://example.com/avatar.jpg', + firstLetter: 'J', + color: '#007bff', + slotIndex: 1, + }); + }); + + test('should return avatar data with default first letter', () => { + const result = whoIsOnlineComponent['getAvatar']({ + avatar: { + imageUrl: '', + model3DUrl: '', + }, + name: 'Alice Smith', + color: '#dc3545', + slotIndex: 2, + }); + + expect(result).toEqual({ + imageUrl: '', + firstLetter: 'A', + color: '#dc3545', + slotIndex: 2, + }); + }); + + test('should handle empty name by defaulting to "A"', () => { + const result = whoIsOnlineComponent['getAvatar']({ + avatar: mockAvatar as any, + name: 'User name', + color: '#28a745', + slotIndex: 3, + }); + + expect(result).toEqual({ + imageUrl: 'https://example.com/avatar.jpg', + firstLetter: 'U', + color: '#28a745', + slotIndex: 3, + }); + }); + + test('should handle undefined name by defaulting to "A"', () => { + const result = whoIsOnlineComponent['getAvatar']({ + avatar: { + imageUrl: '', + model3DUrl: '', + }, + name: '', + color: '#ffc107', + slotIndex: 4, + }); + + expect(result).toEqual({ + imageUrl: '', + firstLetter: 'A', + color: '#ffc107', + slotIndex: 4, + }); + }); + }); + + describe('getControls', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return undefined when presence controls are disabled', () => { + const { disablePresenceControls } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + disablePresenceControls.publish(true); + + const controls = whoIsOnlineComponent['getControls']({ + participantId: 'remoteParticipant123', + presenceEnabled: true, + }); + + expect(controls).toBeUndefined(); + }); + + test('should return controls for local participant', () => { + const { disablePresenceControls } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + disablePresenceControls.publish(false); + + const controls = whoIsOnlineComponent['getControls']({ + participantId: MOCK_LOCAL_PARTICIPANT.id, + presenceEnabled: true, + }); + + expect(controls).toEqual([ + { label: 'gather all', icon: 'gather' }, + { icon: 'send', label: 'everyone follows me' }, + { icon: 'eye', label: 'private mode' }, + ]); + }); + + test('should return controls for other participants', () => { + const { disablePresenceControls } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + disablePresenceControls.publish(false); + + const getOtherParticipantsControlsMock = jest.fn().mockReturnValue([ + { label: 'gather all', icon: 'gather' }, + { icon: 'send', label: 'everyone follows me' }, + { icon: 'eye', label: 'private mode' }, + ]); + + const controls = whoIsOnlineComponent['getControls']({ + participantId: 'remoteParticipant456', + presenceEnabled: true, + }); + + expect(controls).toEqual([ + { icon: 'place', label: 'go to' }, + { label: 'follow', icon: 'send' }, + ]); + }); + }); + + describe('getOtherParticipantsControls', () => { + test('should return controls without "Go To" option when disableGoToParticipant is true', () => { + const { disableGoToParticipant, disableFollowParticipant, following } = whoIsOnlineComponent[ + 'useStore' + ](StoreType.WHO_IS_ONLINE); + disableGoToParticipant.publish(true); + disableFollowParticipant.publish(false); + following.publish(undefined); + + const controls = whoIsOnlineComponent['getOtherParticipantsControls']('participant123'); + + expect(controls).toEqual([ + { + label: 'follow', + icon: 'send', + }, + ]); + }); + + test('should return controls with "Go To" option when disableGoToParticipant is false', () => { + const { disableGoToParticipant, disableFollowParticipant, following } = whoIsOnlineComponent[ + 'useStore' + ](StoreType.WHO_IS_ONLINE); + disableGoToParticipant.publish(false); + disableFollowParticipant.publish(false); + following.publish(undefined); + + const controls = whoIsOnlineComponent['getOtherParticipantsControls']('participant123'); + + expect(controls).toEqual([ + { + label: 'go to', + icon: 'place', + }, + { + label: 'follow', + icon: 'send', + }, + ]); + }); + + test('should return controls for when following a participant', () => { + const { disableGoToParticipant, disableFollowParticipant, following } = whoIsOnlineComponent[ + 'useStore' + ](StoreType.WHO_IS_ONLINE); + disableGoToParticipant.publish(false); + disableFollowParticipant.publish(false); + following.publish({ color: 'red', id: 'participant123', name: 'name' }); + + const controls = whoIsOnlineComponent['getOtherParticipantsControls']('participant123'); + + expect(controls).toEqual([ + { + label: 'go to', + icon: 'place', + }, + { + label: 'unfollow', + icon: 'send-off', + }, + ]); + }); + + test('should return controls when disableFollowParticipant is true', () => { + const { disableGoToParticipant, disableFollowParticipant, following } = whoIsOnlineComponent[ + 'useStore' + ](StoreType.WHO_IS_ONLINE); + disableGoToParticipant.publish(false); + disableFollowParticipant.publish(true); + following.publish(undefined); + + const controls = whoIsOnlineComponent['getOtherParticipantsControls']('participant123'); + + expect(controls).toEqual([ + { + label: 'go to', + icon: 'place', + }, + ]); + }); + }); + + describe('getLocalParticipantControls', () => { + test('should return controls without "Gather" option when disableGatherAll is true', () => { + const { + disableFollowMe, + disableGatherAll, + disablePrivateMode, + everyoneFollowsMe, + privateMode, + } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + disableFollowMe.publish(false); + disableGatherAll.publish(true); + disablePrivateMode.publish(false); + everyoneFollowsMe.publish(false); + privateMode.publish(false); + + const controls = whoIsOnlineComponent['getLocalParticipantControls'](); + + expect(controls).toEqual([ + { + label: 'everyone follows me', + icon: 'send', + }, + { + label: 'private mode', + icon: 'eye', + }, + ]); + }); + + test('should return controls with "Unfollow" and "Leave Private" options when everyoneFollowsMe and privateMode are true', () => { + const { + disableFollowMe, + disableGatherAll, + disablePrivateMode, + everyoneFollowsMe, + privateMode, + } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + disableFollowMe.publish(false); + disableGatherAll.publish(false); + disablePrivateMode.publish(false); + everyoneFollowsMe.publish(true); + privateMode.publish(true); + + const controls = whoIsOnlineComponent['getLocalParticipantControls'](); + + expect(controls).toEqual([ + { + icon: 'gather', + label: 'gather all', + }, + { + icon: 'send-off', + label: 'stop followers', + }, + { + icon: 'eye_inative', + label: 'leave private mode', + }, + ]); + }); + + test('should return controls with "Follow" and "Private" options when all flags are false', () => { + const { + disableFollowMe, + disableGatherAll, + disablePrivateMode, + everyoneFollowsMe, + privateMode, + } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + disableFollowMe.publish(false); + disableGatherAll.publish(false); + disablePrivateMode.publish(false); + everyoneFollowsMe.publish(false); + privateMode.publish(false); + + const controls = whoIsOnlineComponent['getLocalParticipantControls'](); + + expect(controls).toEqual([ + { + label: 'gather all', + icon: 'gather', + }, + { + label: 'everyone follows me', + icon: 'send', + }, + { + label: 'private mode', + icon: 'eye', + }, + ]); + }); + + test('should return controls without "Follow" option when disableFollowMe is true', () => { + const { + disableFollowMe, + disableGatherAll, + disablePrivateMode, + everyoneFollowsMe, + privateMode, + } = whoIsOnlineComponent['useStore'](StoreType.WHO_IS_ONLINE); + disableFollowMe.publish(true); + disableGatherAll.publish(false); + disablePrivateMode.publish(false); + everyoneFollowsMe.publish(false); + privateMode.publish(false); + + const controls = whoIsOnlineComponent['getLocalParticipantControls'](); + + expect(controls).toEqual([ + { + label: 'gather all', + icon: 'gather', + }, + { + label: 'private mode', + icon: 'eye', + }, + ]); + }); + }); + + describe('highlightParticipantBeingFollowed', () => { + test('should put participant being followed from extras in second position over all', () => { + const participant1 = { + activeComponents: [], + avatar: {} as Avatar, + id: 'test id 1', + isLocalParticipant: false, + name: 'participant', + tooltip: {} as TooltipData, + controls: {} as any, + disableDropdown: false, + }; + + const participant2 = { ...participant1, id: 'test id 2' }; + const participant3 = { ...participant1, id: 'test id 3' }; + const participant4 = { ...participant1, id: 'test id 4' }; + const participant5 = { ...participant1, id: 'test id 5' }; + + const participantsList: Participant[] = [ + participant1, + participant2, + participant3, + participant4, + ]; + + const { participants, extras, following } = whoIsOnlineComponent['useStore']( + StoreType.WHO_IS_ONLINE, + ); + + participants.publish(participantsList); + extras.publish([participant5]); + following.publish({ + color: 'red', + id: 'test id 5', + name: 'participant 5', + }); + + expect(participants.value[0]).toBe(participant1); + expect(participants.value[1]).toBe(participant2); + expect(participants.value[2]).toBe(participant3); + expect(participants.value[3]).toBe(participant4); + expect(extras.value[0]).toBe(participant5); + + whoIsOnlineComponent['highlightParticipantBeingFollowed'](); + + expect(participants.value[0]).toBe(participant1); + expect(participants.value[1]).toBe(participant5); + expect(participants.value[2]).toBe(participant2); + expect(participants.value[3]).toBe(participant3); + expect(extras.value[0]).toBe(participant4); + }); + }); }); diff --git a/src/components/who-is-online/index.ts b/src/components/who-is-online/index.ts index a911e691..60fd5480 100644 --- a/src/components/who-is-online/index.ts +++ b/src/components/who-is-online/index.ts @@ -1,21 +1,33 @@ +import { PresenceEvents } from '@superviz/socket-client'; import { isEqual } from 'lodash'; import { RealtimeEvent, WhoIsOnlineEvent } from '../../common/types/events.types'; +import { Avatar } from '../../common/types/participant.types'; +import { StoreType } from '../../common/types/stores.types'; import { Logger } from '../../common/utils'; import { AblyParticipant } from '../../services/realtime/ably/types'; +import { Following } from '../../services/stores/who-is-online/types'; import { WhoIsOnline as WhoIsOnlineElement } from '../../web-components'; +import { DropdownOption } from '../../web-components/dropdown/types'; import { BaseComponent } from '../base'; import { ComponentNames } from '../types'; -import { WhoIsOnlinePosition, Position, Participant, WhoIsOnlineOptions } from './types'; +import { + WhoIsOnlinePosition, + Position, + Participant, + WhoIsOnlineOptions, + TooltipData, + WIODropdownOptions, +} from './types'; export class WhoIsOnline extends BaseComponent { public name: ComponentNames; protected logger: Logger; private element: WhoIsOnlineElement; private position: WhoIsOnlinePosition; - private participants: Participant[] = []; - private following: string; + private following: Following; + private localParticipantId: string; constructor(options?: WhoIsOnlinePosition | WhoIsOnlineOptions) { super(); @@ -23,13 +35,34 @@ export class WhoIsOnline extends BaseComponent { this.name = ComponentNames.WHO_IS_ONLINE; this.logger = new Logger('@superviz/sdk/who-is-online-component'); + const { + disablePresenceControls, + disableGoToParticipant, + disableFollowParticipant, + disablePrivateMode, + disableGatherAll, + disableFollowMe, + following, + } = this.useStore(StoreType.WHO_IS_ONLINE); + + following.subscribe(); + if (typeof options !== 'object') { this.position = options ?? Position.TOP_RIGHT; return; } - this.position = options.position ?? Position.TOP_RIGHT; - this.setStyles(options.styles); + if (typeof options === 'object') { + this.position = options.position ?? Position.TOP_RIGHT; + this.setStyles(options.styles); + + disablePresenceControls.publish(options.disablePresenceControls); + disableGoToParticipant.publish(options.disableGoToParticipant); + disableFollowParticipant.publish(options.disableFollowParticipant); + disablePrivateMode.publish(options.disablePrivateMode); + disableGatherAll.publish(options.disableGatherAll); + disableFollowMe.publish(options.disableFollowMe); + } } /** @@ -38,10 +71,16 @@ export class WhoIsOnline extends BaseComponent { * @returns {void} */ protected start(): void { + const { localParticipant } = this.useStore(StoreType.GLOBAL); + localParticipant.subscribe((value: { id: string }) => { + this.localParticipantId = value.id; + }); + this.subscribeToRealtimeEvents(); this.positionWhoIsOnline(); this.addListeners(); - this.realtime.enterWIOChannel(this.localParticipant); + + this.realtime.enterWIOChannel(localParticipant.value); } /** @@ -55,7 +94,6 @@ export class WhoIsOnline extends BaseComponent { this.removeListeners(); this.element.remove(); this.element = null; - this.participants = null; } /** @@ -91,6 +129,7 @@ export class WhoIsOnline extends BaseComponent { this.element.removeEventListener(RealtimeEvent.REALTIME_PRIVATE_MODE, this.setPrivate); this.element.removeEventListener(RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, this.follow); this.element.removeEventListener(RealtimeEvent.REALTIME_GATHER, this.gather); + this.room.presence.off(PresenceEvents.UPDATE); } /** @@ -127,45 +166,14 @@ export class WhoIsOnline extends BaseComponent { * @returns {void} */ private onParticipantListUpdate = (data: Record): void => { - const updatedParticipants = Object.values(data).filter(({ data }) => { - return data.activeComponents?.includes('whoIsOnline'); - }); - - const participants = updatedParticipants - .filter(({ data: { isPrivate, id } }) => { - return !isPrivate || (isPrivate && id === this.localParticipant.id); - }) - .map(({ data }) => { - const { slotIndex, id, name, avatar, activeComponents } = data as Participant; - const { color } = this.realtime.getSlotColor(slotIndex); - const isLocal = this.localParticipant.id === id; - const joinedPresence = activeComponents.some((component) => component.includes('presence')); - this.setLocalData(isLocal, !joinedPresence, color, joinedPresence); - - return { name, id, slotIndex, color, isLocal, joinedPresence, avatar }; - }); - - if (isEqual(participants, this.participants)) return; - - if (this.following) { - const participantBeingFollowed = participants.find(({ id }) => id === this.following); - if (!participantBeingFollowed) this.stopFollowing({ clientId: this.following }); - } - - this.participants = participants; - this.element.updateParticipants(this.participants); - }; + const updatedParticipants = this.filterParticipants(Object.values(data)); - private setLocalData = ( - local: boolean, - disable: boolean, - color: string, - joinedPresence: boolean, - ) => { - if (!local) return; + const mappedParticipants = updatedParticipants.map((participant) => + this.getParticipant(participant), + ); - this.element.disableDropdown = disable; - this.element.localParticipantData = { color, id: this.localParticipant.id, joinedPresence }; + const remainingParticipants = this.setParticipants(mappedParticipants); + this.setExtras(remainingParticipants); }; /** @@ -220,7 +228,7 @@ export class WhoIsOnline extends BaseComponent { * @returns {void} */ private goToMousePointer = ({ detail: { id } }: CustomEvent) => { - if (id === this.localParticipant.id) return; + if (id === this.localParticipantId) return; this.eventBus.publish(RealtimeEvent.REALTIME_GO_TO_PARTICIPANT, id); this.publish(WhoIsOnlineEvent.GO_TO_PARTICIPANT, id); @@ -234,14 +242,20 @@ export class WhoIsOnline extends BaseComponent { */ private followMousePointer = ({ detail }: CustomEvent) => { this.eventBus.publish(RealtimeEvent.REALTIME_LOCAL_FOLLOW_PARTICIPANT, detail.id); - this.following = detail.id; if (this.following) { this.publish(WhoIsOnlineEvent.START_FOLLOWING_PARTICIPANT, this.following); - return; } - this.publish(WhoIsOnlineEvent.STOP_FOLLOWING_PARTICIPANT); + if (!this.following) { + this.publish(WhoIsOnlineEvent.STOP_FOLLOWING_PARTICIPANT); + } + + if (detail.source === 'extras') { + this.highlightParticipantBeingFollowed(); + } + + this.updateParticipantsControls(detail.id); }; /** @@ -251,54 +265,407 @@ export class WhoIsOnline extends BaseComponent { * @returns {void} */ private setPrivate = ({ detail: { isPrivate, id } }: CustomEvent) => { + const { privateMode } = this.useStore(StoreType.WHO_IS_ONLINE); + privateMode.publish(isPrivate); + this.eventBus.publish(RealtimeEvent.REALTIME_PRIVATE_MODE, isPrivate); this.realtime.setPrivateWIOParticipant(id, isPrivate); if (isPrivate) { this.publish(WhoIsOnlineEvent.ENTER_PRIVATE_MODE); - return; } - this.publish(WhoIsOnlineEvent.LEAVE_PRIVATE_MODE); + if (!isPrivate) { + this.publish(WhoIsOnlineEvent.LEAVE_PRIVATE_MODE); + } }; - private setFollow = (following) => { - if (following.clientId === this.localParticipant.id) return; - - this.followMousePointer({ detail: { id: following?.data?.id } } as CustomEvent); + /** + * @function setFollow + * @description Sets participant being followed after someone used Everyone Follows Me + * @param followingData + * @returns + */ + private setFollow = (followingData: AblyParticipant) => { + if (followingData.clientId === this.localParticipantId) return; - if (!following.data.id) { - this.element.following = undefined; - return; - } + const data = followingData.data?.id ? followingData.data : undefined; + const { following } = this.useStore(StoreType.WHO_IS_ONLINE); + following.publish(data); - this.following = following.data.id; - this.element.following = following.data; + this.followMousePointer({ detail: { id: data?.id } } as CustomEvent); }; - private follow = (data: CustomEvent) => { - this.realtime.setFollowWIOParticipant({ ...data.detail }); - this.following = data.detail?.id; + private follow = ({ detail }: CustomEvent) => { + const { everyoneFollowsMe } = this.useStore(StoreType.WHO_IS_ONLINE); + everyoneFollowsMe.publish(!!detail?.id); + + this.realtime.setFollowWIOParticipant({ ...detail }); if (this.following) { this.publish(WhoIsOnlineEvent.START_FOLLOW_ME, this.following); - return; } - this.publish(WhoIsOnlineEvent.STOP_FOLLOW_ME); + if (!this.following) { + this.publish(WhoIsOnlineEvent.STOP_FOLLOW_ME); + } + + this.updateParticipantsControls(detail?.id); }; - private stopFollowing = (participant: { clientId: string }) => { - if (participant.clientId !== this.element.following?.id) return; + /** + * @function stopFollowing + * @description Stops following a participant + * @param {AblyParticipant} participant The message sent from Ably (in case of being called as a callback) + * @param {boolean} stopEvent A flag that stops the "stop following" event from being published to the user + * @returns + */ + private stopFollowing = (participant: { clientId: string }, stopEvent?: boolean) => { + if (participant.clientId !== this.following?.id) return; + + const { following } = this.useStore(StoreType.WHO_IS_ONLINE); + following.publish(undefined); - this.element.following = undefined; - this.following = undefined; this.eventBus.publish(RealtimeEvent.REALTIME_LOCAL_FOLLOW_PARTICIPANT, undefined); + + if (stopEvent) return; + this.publish(WhoIsOnlineEvent.STOP_FOLLOWING_PARTICIPANT); }; + /** + * @function gather + * @description Propagates the gather all event in the room + * @param {CustomEvent} data The custom event object containing data about the participant calling for the gather all + */ private gather = (data: CustomEvent) => { this.realtime.setGatherWIOParticipant({ ...data.detail }); this.publish(WhoIsOnlineEvent.GATHER_ALL, data.detail.id); }; + + /** + * @function filterParticipants + * @description Removes all participants in the room that shouldn't be shown as part of the Who Is Online component, either because they are private, or because they don't have the component active + * @param {AblyParticipant[]} participants The list of participants that will be filtered + * @returns {AblyParticipant[]} + */ + private filterParticipants(participants: AblyParticipant[]): AblyParticipant[] { + return participants.filter(({ data: { activeComponents, id, isPrivate } }) => { + if (isPrivate && this.localParticipantId !== id) { + this.stopFollowing(id, true); + return false; + } + + const isLocal = id === this.localParticipantId; + + if (isLocal) { + const hasPresenceComponent = activeComponents?.some((component) => + component.includes('presence'), + ); + const { joinedPresence } = this.useStore(StoreType.WHO_IS_ONLINE); + joinedPresence.publish(hasPresenceComponent); + } + + return activeComponents?.includes('whoIsOnline') || isLocal; + }); + } + + /** + * @function getParticipant + * @description Accomodates the data from a participant coming from Ably to something used in Who Is Online. + * @param {AblyParticipant} participant The participant that will be analyzed + * @returns {Participant} The data that will be used in the Who Is Online component the most + */ + private getParticipant(participant: AblyParticipant): Participant { + const { avatar: avatarLinks, activeComponents, participantId, name } = participant.data; + const isLocalParticipant = participant.clientId === this.localParticipantId; + const { slotIndex } = participant.data; + const { color } = this.realtime.getSlotColor(slotIndex); + const disableDropdown = this.shouldDisableDropdown({ activeComponents, participantId }); + const presenceEnabled = !disableDropdown; + + const tooltip = this.getTooltipData({ isLocalParticipant, name, presenceEnabled }); + const avatar = this.getAvatar({ avatar: avatarLinks, color, name, slotIndex }); + const controls = this.getControls({ participantId, presenceEnabled }) ?? []; + + return { + id: participantId, + name, + avatar, + disableDropdown, + tooltip, + controls, + activeComponents, + isLocalParticipant, + }; + } + + /** + * @function shouldDisableDropdown + * @description Decides whether the dropdown with presence controls should be available in a given participant, varying whether they have a presence control enabled or not + * @param {activeComponents: string[] | undefined; participantId: string;} data Info regarding the participant that will be used to decide if their avatar will be clickable + * @returns {boolean} True or false depending if should disable the participant dropdown or not + */ + private shouldDisableDropdown({ + activeComponents, + participantId, + }: { + activeComponents: string[] | undefined; + participantId: string; + }) { + const { + joinedPresence: { value: joinedPresence }, + disablePresenceControls: { value: disablePresenceControls }, + disableFollowMe: { value: disableFollowMe }, + disableFollowParticipant: { value: disableFollowParticipant }, + disableGoToParticipant: { value: disableGoToParticipant }, + disableGatherAll: { value: disableGatherAll }, + disablePrivateMode: { value: disablePrivateMode }, + } = this.useStore(StoreType.WHO_IS_ONLINE); + + if ( + joinedPresence === false || + disablePresenceControls === true || + (participantId === this.localParticipantId && + disableFollowMe && + disablePrivateMode && + disableGatherAll) || + (participantId !== this.localParticipantId && + disableFollowParticipant && + disableGoToParticipant) + ) + return true; + + return !activeComponents?.some((component) => component.toLowerCase().includes('presence')); + } + + /** + * @function getTooltipData + * @description Processes the participant info and discovers how the tooltip message should looking when hovering over their avatars + * @param {isLocalParticipant: boolean; name: string; presenceEnabled: boolean } data Relevant info about the participant that will be used to decide + * @returns {TooltipData} What the participant tooltip will look like + */ + private getTooltipData({ + isLocalParticipant, + name, + presenceEnabled, + }: { + isLocalParticipant: boolean; + name: string; + presenceEnabled: boolean; + }): TooltipData { + const data: TooltipData = { name }; + + if (isLocalParticipant) { + data.name += ' (You)'; + } + + if (presenceEnabled && !isLocalParticipant) { + data.info = 'Click to follow'; + } + + return data; + } + + /** + * @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 + * @returns {Avatar} Information used to decide how to construct the participant's avatar html + */ + private getAvatar({ + avatar, + color, + name, + slotIndex, + }: { + avatar: Avatar; + name: string; + color: string; + slotIndex: number; + }) { + const imageUrl = avatar?.imageUrl; + const firstLetter = name?.at(0)?.toUpperCase() ?? 'A'; + + return { imageUrl, firstLetter, color, slotIndex }; + } + + /** + * @function getControls + * @description Decides which presence controls the user should see when opening a participant dropdown + * @param { participantId: string; presenceEnabled: boolean } data Relevant info about the participant that will be used to decide + * @returns {DropdownOption[]} The presence controls enabled for a given participant + */ + private getControls({ + participantId, + presenceEnabled, + }: { + participantId: string; + presenceEnabled: boolean; + }): DropdownOption[] | undefined { + const { disablePresenceControls } = this.useStore(StoreType.WHO_IS_ONLINE); + + if (disablePresenceControls.value || !presenceEnabled) return; + + if (participantId === this.localParticipantId) { + return this.getLocalParticipantControls(); + } + + return this.getOtherParticipantsControls(participantId); + } + + /** + * @function getOtherParticipantsControls + * @description Decides which presence controls the user should see when opening the dropdown of a participant that is not the local participant + * @param {string} participantId Which participant is being analyzed + * @returns {DropdownOption[]} The presence controls enabled for the participant + */ + private getOtherParticipantsControls(participantId: string): DropdownOption[] { + const { disableGoToParticipant, disableFollowParticipant, following } = this.useStore( + StoreType.WHO_IS_ONLINE, + ); + + const controls: DropdownOption[] = []; + + if (!disableGoToParticipant.value) { + controls.push({ label: WIODropdownOptions['GOTO'], icon: 'place' }); + } + + if (!disableFollowParticipant.value) { + const isBeingFollowed = following.value?.id === participantId; + const label = isBeingFollowed + ? WIODropdownOptions['LOCAL_UNFOLLOW'] + : WIODropdownOptions['LOCAL_FOLLOW']; + const icon = isBeingFollowed ? 'send-off' : 'send'; + controls.push({ label, icon }); + } + + return controls; + } + + /** + * @function getLocalParticipantControls + * @description Decides which presence controls the user should see when opening the dropdown of the local participant + * @returns {DropdownOption[]} The presence controls enabled for the local participant + */ + private getLocalParticipantControls(): DropdownOption[] { + const { + disableFollowMe: { value: disableFollowMe }, + disableGatherAll: { value: disableGatherAll }, + disablePrivateMode: { value: disablePrivateMode }, + everyoneFollowsMe: { value: everyoneFollowsMe }, + privateMode: { value: privateMode }, + } = this.useStore(StoreType.WHO_IS_ONLINE); + + const controls: DropdownOption[] = []; + + if (!disableGatherAll) { + controls.push({ label: WIODropdownOptions['GATHER'], icon: 'gather' }); + } + + if (!disableFollowMe) { + const icon = everyoneFollowsMe ? 'send-off' : 'send'; + const label = everyoneFollowsMe + ? WIODropdownOptions['UNFOLLOW'] + : WIODropdownOptions['FOLLOW']; + + controls.push({ label, icon }); + } + + if (!disablePrivateMode) { + const icon = privateMode ? 'eye_inative' : 'eye'; + const label = privateMode + ? WIODropdownOptions['LEAVE_PRIVATE'] + : WIODropdownOptions['PRIVATE']; + + controls.push({ label, icon }); + } + + return controls; + } + + /** + * @function setParticipants + * @description Adds participants to the main participants (the 4 that are shown without opening any dropdown) until the list is full + * @param {Participant[]} participantsList The total participants list + * @returns {Participant[]} The participants that did not fit the main list and will be inserted in the extras participants list + */ + private setParticipants = (participantsList: Participant[]): Participant[] => { + const { participants } = this.useStore(StoreType.WHO_IS_ONLINE); + + const localParticipantIndex = participantsList.findIndex(({ id }) => { + return id === this.localParticipantId; + }); + + const localParticipant = participantsList.splice(localParticipantIndex, 1); + const otherParticipants = participantsList.splice(0, 3); + participants.publish([...localParticipant, ...otherParticipants]); + return participantsList; + }; + + /** + * @function setExtras + * @description Adds remaining participants to extras participants (those who are shown without opening any dropdown) + * @param {Participant[]} participantsList The remaining participants list + * @returns {void} + */ + private setExtras = (participantsList: Participant[]): void => { + const { extras } = this.useStore(StoreType.WHO_IS_ONLINE); + extras.publish(participantsList); + }; + + /** + * @function updateParticipantsControls + * @description Updated what the presence controls of a single participant should look like now that something about them was updated + * @param {string | undefined} participantId The participant that suffered some update + * @returns {void} The participants that did not fit the main list and will be inserted in the extras participants list + */ + private updateParticipantsControls(participantId: string | undefined): void { + const { participants } = this.useStore(StoreType.WHO_IS_ONLINE); + + participants.publish( + participants.value.map((participant: Participant) => { + if (participantId && participant.id !== participantId) return participant; + + const { id } = participant; + const disableDropdown = this.shouldDisableDropdown({ + activeComponents: participant.activeComponents, + participantId: id, + }); + const presenceEnabled = !disableDropdown; + const controls = this.getControls({ participantId: id, presenceEnabled }) ?? []; + + return { + ...participant, + controls, + }; + }), + ); + } + + /** + * @function highlightParticipantBeingFollowed + * @description Brings a participant that is in the list of extra participants to the front, in the second place of the list of main participants, so they are visible while being followed + * @returns {void} + */ + private highlightParticipantBeingFollowed(): void { + const { + extras, + participants, + following: { value: following }, + } = this.useStore(StoreType.WHO_IS_ONLINE); + + const firstParticipant = participants.value[0]; + + const participantId = extras.value.findIndex((participant) => participant.id === following.id); + const participant = extras.value.splice(participantId, 1)[0]; + + participants.value.unshift(firstParticipant); + participants.value[1] = participant; + const lastParticipant = participants.value.pop(); + + extras.value.push(lastParticipant); + extras.publish(extras.value); + participants.publish(participants.value); + } } diff --git a/src/components/who-is-online/types.ts b/src/components/who-is-online/types.ts index 9d72991c..49bc30a1 100644 --- a/src/components/who-is-online/types.ts +++ b/src/components/who-is-online/types.ts @@ -1,5 +1,4 @@ -import { Avatar, Participant as GeneralParticipant } from '../../common/types/participant.types'; -import { ComponentNames } from '../types'; +import { DropdownOption } from '../../web-components/dropdown/types'; export enum Position { TOP_LEFT = 'top-left', @@ -8,11 +7,27 @@ export enum Position { BOTTOM_RIGHT = 'bottom-right', } -export interface Participant extends GeneralParticipant { +export interface TooltipData { + name: string; + info?: string; +} + +export interface Avatar { + imageUrl: string; + firstLetter: string; slotIndex: number; - isLocal?: boolean; - joinedPresence?: boolean; - isPrivate?: boolean; + color: string; +} + +export interface Participant { + id: string; + name: string; + disableDropdown?: boolean; + controls?: DropdownOption[]; + tooltip: TooltipData; + avatar: Avatar; + activeComponents: string[]; + isLocalParticipant: boolean; } export type WhoIsOnlinePosition = Position | `${Position}` | string | ''; @@ -20,4 +35,22 @@ export type WhoIsOnlinePosition = Position | `${Position}` | string | ''; export interface WhoIsOnlineOptions { position?: WhoIsOnlinePosition; styles?: string; + disablePresenceControls?: boolean; + disableGoToParticipant?: boolean; + disableFollowParticipant?: boolean; + disablePrivateMode?: boolean; + disableGatherAll?: boolean; + disableFollowMe?: boolean; +} + +export enum WIODropdownOptions { + GOTO = 'go to', + LOCAL_FOLLOW = 'follow', + LOCAL_UNFOLLOW = 'unfollow', + FOLLOW = 'everyone follows me', + UNFOLLOW = 'stop followers', + PRIVATE = 'private mode', + LEAVE_PRIVATE = 'leave private mode', + GATHER = 'gather all', + STOP_GATHER = 'stop gather all', } diff --git a/src/core/index.ts b/src/core/index.ts index 66d3b03a..db885bed 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -126,7 +126,7 @@ const init = async (apiKey: string, options: SuperVizSdkOptions): Promise { - console.log('attach'); - }), + attach: jest.fn(), detach: jest.fn(), } as unknown as BaseComponent; @@ -44,11 +46,14 @@ describe('Launcher', () => { beforeEach(() => { console.warn = jest.fn(); console.error = jest.fn(); - console.log = jest.fn(); + console.log = jest.spyOn(console, 'log').mockImplementation(console.log) as any; jest.clearAllMocks(); jest.restoreAllMocks(); + const { localParticipant } = useGlobalStore(); + localParticipant.value = MOCK_LOCAL_PARTICIPANT; + LauncherInstance = new Launcher(DEFAULT_INITIALIZATION_MOCK); }); @@ -56,7 +61,7 @@ describe('Launcher', () => { expect(Launcher).toBeDefined(); }); - test('should be inicialize realtime service', () => { + test('should initialize realtime service', () => { expect(ABLY_REALTIME_MOCK.start).toHaveBeenCalled(); }); @@ -86,18 +91,20 @@ describe('Launcher', () => { expect(spy).toHaveBeenCalledWith(MOCK_COMPONENT); }); - test('should be add component', () => { + test('should add component', () => { LimitsService.checkComponentLimit = jest.fn().mockReturnValue(true); LauncherInstance.addComponent(MOCK_COMPONENT); - expect(MOCK_COMPONENT.attach).toHaveBeenCalledWith({ - localParticipant: { ...MOCK_LOCAL_PARTICIPANT, type: ParticipantType.GUEST }, - realtime: ABLY_REALTIME_MOCK, - group: MOCK_GROUP, - config: MOCK_CONFIG, - eventBus: EVENT_BUS_MOCK, - }); + expect(MOCK_COMPONENT.attach).toHaveBeenCalledWith( + expect.objectContaining({ + ioc: expect.any(IOC), + realtime: ABLY_REALTIME_MOCK, + config: MOCK_CONFIG, + eventBus: EVENT_BUS_MOCK, + useStore, + } as DefaultAttachComponentOptions), + ); expect(ABLY_REALTIME_MOCK.updateMyProperties).toHaveBeenCalledWith({ activeComponents: [MOCK_COMPONENT.name], @@ -139,7 +146,7 @@ describe('Launcher', () => { expect(MOCK_COMPONENT.attach).toHaveBeenCalledTimes(1); }); - test('should show a console message if the laucher is destroyed', () => { + test('should show a console message if the launcher is destroyed', () => { LauncherInstance.destroy(); LauncherInstance.addComponent(MOCK_COMPONENT); @@ -150,51 +157,20 @@ describe('Launcher', () => { describe('Participant Events', () => { test('should publish ParticipantEvent.JOINED event', () => { - const callback = jest.fn(); - const spy = jest.spyOn(LauncherInstance, 'subscribe'); - LauncherInstance['publish'] = jest.fn(); - - LauncherInstance.subscribe(ParticipantEvent.LIST_UPDATED, callback); - - LauncherInstance['onParticipantListUpdate']({ - participant1: { - extras: null, - clientId: 'client1', - action: 'present', - connectionId: 'connection1', - encoding: 'h264', - id: 'unit-test-participant-ably-id', - timestamp: new Date().getTime(), - data: { - participantId: 'participant1', - }, - }, - }); - - expect(spy).toHaveBeenCalledWith(ParticipantEvent.LIST_UPDATED, callback); - expect(LauncherInstance['publish']).toHaveBeenCalled(); - }); - - test('should publish ParticipantEvent.LOCAL_UPDATED event', () => { + LauncherInstance['participants'].value = new Map(); + LauncherInstance['participants'].value.set(MOCK_LOCAL_PARTICIPANT.id, MOCK_LOCAL_PARTICIPANT); const spy = jest.spyOn(LauncherInstance, 'subscribe'); LauncherInstance['publish'] = jest.fn(); const callback = jest.fn(); LauncherInstance.subscribe(ParticipantEvent.JOINED, callback); - LauncherInstance['onParticipantListUpdate']({ - [MOCK_LOCAL_PARTICIPANT.id]: { - extras: null, - clientId: 'client1', - action: 'present', - connectionId: 'connection1', - encoding: 'h264', - id: 'unit-test-participant-ably-id', - timestamp: new Date().getTime(), - data: { - id: MOCK_LOCAL_PARTICIPANT.id, - }, - }, + LauncherInstance['onParticipantUpdatedIOC']({ + connectionId: 'connection1', + data: MOCK_LOCAL_PARTICIPANT, + id: MOCK_LOCAL_PARTICIPANT.id, + name: MOCK_LOCAL_PARTICIPANT.name as string, + timestamp: Date.now(), }); expect(spy).toHaveBeenCalledWith(ParticipantEvent.JOINED, callback); @@ -202,137 +178,25 @@ describe('Launcher', () => { }); test('should publish ParticipantEvent.LEFT event', () => { + LauncherInstance['participants'].value = new Map(); + LauncherInstance['participants'].value.set(MOCK_LOCAL_PARTICIPANT.id, MOCK_LOCAL_PARTICIPANT); const callback = jest.fn(); const spy = jest.spyOn(LauncherInstance, 'subscribe'); LauncherInstance['publish'] = jest.fn(); LauncherInstance.subscribe(ParticipantEvent.LEFT, callback); - const participant = { - clientId: 'client1', - action: 'absent', - connectionId: 'connection1', - encoding: 'h264', - id: 'unit-test-participant-ably-id', - timestamp: new Date().getTime(), - data: { - participantId: 'participant1', - }, - }; - - LauncherInstance['onParticipantListUpdate']({ - [participant.data.participantId]: participant as AblyParticipant, - }); - LauncherInstance['onParticipantLeave'](participant as AblyParticipant); - - expect(spy).toHaveBeenCalledWith(ParticipantEvent.LEFT, callback); - expect(LauncherInstance['publish']).toHaveBeenCalled(); - }); - - test('should skip and not publish ParticipantEvent.LEFT event', () => { - const callback = jest.fn(); - const spy = jest.spyOn(LauncherInstance, 'subscribe'); - LauncherInstance['publish'] = jest.fn(); - LauncherInstance.subscribe(ParticipantEvent.LEFT, callback); - - const participant = { - clientId: 'client1', - action: 'absent', - connectionId: 'connection1', - encoding: 'h264', - id: 'unit-test-participant-ably-id', - timestamp: new Date().getTime(), - data: { - participantId: 'participant1', - }, - }; - - LauncherInstance['onParticipantLeave'](participant as AblyParticipant); - - expect(spy).toHaveBeenCalledWith(ParticipantEvent.LEFT, callback); - expect(LauncherInstance['publish']).not.toHaveBeenCalled(); - }); - - test('should publish ParticipantEvent.LOCAL_LEFT event', () => { - const callback = jest.fn(); - const spy = jest.spyOn(LauncherInstance, 'subscribe'); - LauncherInstance['publish'] = jest.fn(); - LauncherInstance.subscribe(ParticipantEvent.LEFT, callback); - - const participant = { - clientId: 'client1', - action: 'absent', + LauncherInstance['onParticipantLeaveIOC']({ connectionId: 'connection1', - encoding: 'h264', - id: 'unit-test-participant-ably-id', - timestamp: new Date().getTime(), - data: { - id: MOCK_LOCAL_PARTICIPANT.id, - participantId: MOCK_LOCAL_PARTICIPANT.id, - }, - }; - - LauncherInstance['onParticipantListUpdate']({ - [MOCK_LOCAL_PARTICIPANT.id]: participant as AblyParticipant, + data: MOCK_LOCAL_PARTICIPANT, + id: MOCK_LOCAL_PARTICIPANT.id, + name: MOCK_LOCAL_PARTICIPANT.name as string, + timestamp: Date.now(), }); - LauncherInstance['onParticipantLeave'](participant as AblyParticipant); expect(spy).toHaveBeenCalledWith(ParticipantEvent.LEFT, callback); expect(LauncherInstance['publish']).toHaveBeenCalled(); }); - test('should publish ParticipantEvent.JOINED event', () => { - const callback = jest.fn(); - const spy = jest.spyOn(LauncherInstance, 'subscribe'); - LauncherInstance['publish'] = jest.fn(); - LauncherInstance.subscribe(ParticipantEvent.JOINED, callback); - - const participant = { - clientId: 'client1', - action: 'absent', - connectionId: 'connection1', - encoding: 'h264', - id: 'unit-test-participant-ably-id', - timestamp: new Date().getTime(), - data: { - id: MOCK_LOCAL_PARTICIPANT.id, - participantId: MOCK_LOCAL_PARTICIPANT.id, - }, - }; - - LauncherInstance['onParticipantListUpdate']({ - [MOCK_LOCAL_PARTICIPANT.id]: participant as AblyParticipant, - }); - LauncherInstance['onParticipantJoined'](participant as AblyParticipant); - - expect(spy).toHaveBeenCalledWith(ParticipantEvent.JOINED, callback); - expect(LauncherInstance['publish']).toHaveBeenCalled(); - }); - - test('should skip and publish ParticipantEvent.JOINED event', () => { - const callback = jest.fn(); - const spy = jest.spyOn(LauncherInstance, 'subscribe'); - LauncherInstance['publish'] = jest.fn(); - LauncherInstance.subscribe(ParticipantEvent.JOINED, callback); - - const participant = { - clientId: 'client1', - action: 'absent', - connectionId: 'connection1', - encoding: 'h264', - id: 'unit-test-participant-ably-id', - timestamp: new Date().getTime(), - data: { - id: MOCK_LOCAL_PARTICIPANT.id, - participantId: MOCK_LOCAL_PARTICIPANT.id, - }, - }; - - LauncherInstance['onParticipantJoined'](participant as AblyParticipant); - - expect(spy).toHaveBeenCalledWith(ParticipantEvent.JOINED, callback); - expect(LauncherInstance['publish']).not.toHaveBeenCalled(); - }); - test('should update activeComponentsInstances when participant list is updated', () => { LauncherInstance.addComponent(MOCK_COMPONENT); @@ -358,21 +222,24 @@ describe('Launcher', () => { test('should remove component when participant is not usign it anymore', () => { LauncherInstance.addComponent(MOCK_COMPONENT); - LauncherInstance['onParticipantListUpdate']({ - participant1: { - extras: null, - clientId: MOCK_LOCAL_PARTICIPANT.id, - action: 'present', - connectionId: 'connection1', - encoding: 'h264', - id: 'unit-test-participant-ably-id', - timestamp: new Date().getTime(), - data: { - ...MOCK_LOCAL_PARTICIPANT, - participantId: MOCK_LOCAL_PARTICIPANT.id, - activeComponents: [], - }, - }, + LauncherInstance['onParticipantUpdatedIOC']({ + connectionId: 'connection1', + id: MOCK_LOCAL_PARTICIPANT.id, + name: MOCK_LOCAL_PARTICIPANT.name as string, + data: MOCK_LOCAL_PARTICIPANT as Participant, + timestamp: Date.now(), + }); + + expect(LauncherInstance['activeComponentsInstances'].length).toBe(1); + + LauncherInstance.removeComponent(MOCK_COMPONENT); + + LauncherInstance['onParticipantUpdatedIOC']({ + connectionId: 'connection1', + id: MOCK_LOCAL_PARTICIPANT.id, + name: MOCK_LOCAL_PARTICIPANT.name as string, + data: MOCK_LOCAL_PARTICIPANT as Participant, + timestamp: Date.now(), }); expect(LauncherInstance['activeComponentsInstances'].length).toBe(0); diff --git a/src/core/launcher/index.ts b/src/core/launcher/index.ts index 3c682a97..f37d71fe 100644 --- a/src/core/launcher/index.ts +++ b/src/core/launcher/index.ts @@ -1,17 +1,23 @@ +import * as Socket from '@superviz/socket-client'; import { isEqual } from 'lodash'; import { ParticipantEvent, RealtimeEvent } from '../../common/types/events.types'; import { Group, Participant, ParticipantType } from '../../common/types/participant.types'; import { Observable } from '../../common/utils'; import { Logger } from '../../common/utils/logger'; +import { useStore } from '../../common/utils/use-store'; import { BaseComponent } from '../../components/base'; import { ComponentNames } from '../../components/types'; import ApiService from '../../services/api'; import config from '../../services/config'; import { EventBus } from '../../services/event-bus'; +import { IOC } from '../../services/io'; import LimitsService from '../../services/limits'; import { AblyRealtimeService } from '../../services/realtime'; import { AblyParticipant } from '../../services/realtime/ably/types'; +import { SlotService } from '../../services/slot'; +import { useGlobalStore } from '../../services/stores'; +import { PublicSubject } from '../../services/stores/common/types'; import { DefaultLauncher, LauncherFacade, LauncherOptions } from './types'; @@ -22,35 +28,49 @@ export class Launcher extends Observable implements DefaultLauncher { private activeComponents: ComponentNames[] = []; private componentsToAttachAfterJoin: Partial[] = []; private activeComponentsInstances: Partial[] = []; - private participant: Participant; - private group: Group; + private ioc: IOC; + private LauncherRealtimeRoom: Socket.Room; private realtime: AblyRealtimeService; private eventBus: EventBus = new EventBus(); - private participants: Participant[] = []; - constructor({ participant, group }: LauncherOptions) { + private participant: PublicSubject; + private participants: PublicSubject>; + private group: PublicSubject; + + constructor({ participant, group: participantGroup }: LauncherOptions) { super(); + const { localParticipant, participants, group } = useGlobalStore(); + + this.participant = localParticipant; + this.participants = participants; + this.group = group; - this.participant = { + this.participant.value = { ...participant, type: ParticipantType.GUEST, }; - - this.group = group; + this.group.value = participantGroup; this.logger = new Logger('@superviz/sdk/launcher'); + + // Ably realtime service this.realtime = new AblyRealtimeService( config.get('apiUrl'), config.get('ablyKey'), ); + // SuperViz IO Room + this.ioc = new IOC(this.participant.value); + this.LauncherRealtimeRoom = this.ioc.createRoom('launcher'); + // internal events without realtime this.eventBus = new EventBus(); this.logger.log('launcher created'); - this.startRealtime(); + this.startAbly(); + this.startIOC(); } /** @@ -59,7 +79,7 @@ export class Launcher extends Observable implements DefaultLauncher { * @param component - component to add * @returns {void} */ - public addComponent = (component: Partial): void => { + public addComponent = (component: any): void => { if (!this.canAddComponent(component)) return; if (!this.realtime.isJoinedRoom) { @@ -69,18 +89,23 @@ export class Launcher extends Observable implements DefaultLauncher { } component.attach({ - localParticipant: this.participant, + ioc: this.ioc, realtime: this.realtime, - group: this.group, config: config.configuration, eventBus: this.eventBus, + useStore, }); this.activeComponents.push(component.name); this.activeComponentsInstances.push(component); this.realtime.updateMyProperties({ activeComponents: this.activeComponents }); - ApiService.sendActivity(this.participant.id, this.group.id, this.group.name, component.name); + ApiService.sendActivity( + this.participant.value.id, + this.group.value.id, + this.group.value.name, + component.name, + ); }; /** @@ -108,7 +133,7 @@ export class Launcher extends Observable implements DefaultLauncher { * @param component - component to remove * @returns {void} */ - public removeComponent = (component: Partial): void => { + public removeComponent = (component: any): void => { if (!this.activeComponents.includes(component.name)) { const message = `Component ${component.name} is not initialized yet.`; this.logger.log(message); @@ -122,6 +147,7 @@ export class Launcher extends Observable implements DefaultLauncher { return c.name !== component.name; }); this.activeComponents.splice(this.activeComponents.indexOf(component.name), 1); + this.realtime.updateMyProperties({ activeComponents: this.activeComponents }); }; @@ -141,14 +167,20 @@ export class Launcher extends Observable implements DefaultLauncher { this.activeComponents = []; this.activeComponentsInstances = []; this.participant = undefined; + useGlobalStore().destroy(); this.eventBus.destroy(); this.eventBus = undefined; + this.LauncherRealtimeRoom.presence.off(Socket.PresenceEvents.JOINED_ROOM); + this.LauncherRealtimeRoom.presence.off(Socket.PresenceEvents.LEAVE); + this.LauncherRealtimeRoom.presence.off(Socket.PresenceEvents.UPDATE); + + this.ioc.destroy(); + this.realtime.authenticationObserver.unsubscribe(this.onAuthentication); this.realtime.sameAccountObserver.unsubscribe(this.onSameAccount); this.realtime.participantJoinedObserver.unsubscribe(this.onParticipantJoined); - this.realtime.participantLeaveObserver.unsubscribe(this.onParticipantLeave); this.realtime.participantsObserver.unsubscribe(this.onParticipantListUpdate); this.realtime.leave(); this.realtime = undefined; @@ -198,15 +230,15 @@ export class Launcher extends Observable implements DefaultLauncher { }; /** - * @function startRealtime + * @function startAbly * @description start realtime service and join to room * @returns {void} */ - private startRealtime = (): void => { - this.logger.log('launcher service @ startRealtime'); + private startAbly = (): void => { + this.logger.log('launcher service @ startAbly'); this.realtime.start({ - participant: this.participant, + participant: this.participant.value, apiKey: config.get('apiKey'), roomId: config.get('roomId'), }); @@ -214,23 +246,22 @@ export class Launcher extends Observable implements DefaultLauncher { this.realtime.join(); // subscribe to realtime events - this.subscribeToRealtimeEvents(); + this.subscribeToAblyEvents(); }; /** - * @function subscribeToRealtimeEvents + * @function subscribeToAblyEvents * @description subscribe to realtime events * @returns {void} */ - private subscribeToRealtimeEvents = (): void => { + private subscribeToAblyEvents = (): void => { this.realtime.authenticationObserver.subscribe(this.onAuthentication); this.realtime.sameAccountObserver.subscribe(this.onSameAccount); this.realtime.participantJoinedObserver.subscribe(this.onParticipantJoined); - this.realtime.participantLeaveObserver.subscribe(this.onParticipantLeave); this.realtime.participantsObserver.subscribe(this.onParticipantListUpdate); }; - /** Realtime Listeners */ + /** Ably Listeners */ private onAuthentication = (event: RealtimeEvent): void => { if (event !== RealtimeEvent.REALTIME_AUTHENTICATION_FAILED) return; @@ -262,18 +293,12 @@ export class Launcher extends Observable implements DefaultLauncher { })); const localParticipant = participantList.find((participant) => { - return participant?.id === this.participant?.id; + return participant?.id === this.participant.value?.id; }); - if (!isEqual(this.participants, participantList)) { - this.participants = participantList; - this.publish(ParticipantEvent.LIST_UPDATED, participantList); + if (localParticipant && !isEqual(this.participant.value, localParticipant)) { + this.LauncherRealtimeRoom.presence.update(localParticipant); - this.logger.log('Publishing ParticipantEvent.LIST_UPDATED', participantList); - } - - if (localParticipant && !isEqual(this.participant, localParticipant)) { - this.activeComponents = localParticipant.activeComponents ?? []; this.activeComponentsInstances = this.activeComponentsInstances.filter((component) => { /** * @NOTE - Prevents removing all components when @@ -284,16 +309,7 @@ export class Launcher extends Observable implements DefaultLauncher { return this.activeComponents.includes(component.name); }); - this.participant = localParticipant; - this.publish(ParticipantEvent.LOCAL_UPDATED, localParticipant); - - this.logger.log('Publishing ParticipantEvent.UPDATED', localParticipant); } - - this.logger.log( - 'launcher service @ onParticipantListUpdate - participants updated', - participantList, - ); }; /** @@ -303,51 +319,130 @@ export class Launcher extends Observable implements DefaultLauncher { * @returns {void} */ private onParticipantJoined = (ablyParticipant: AblyParticipant): void => { - this.logger.log('launcher service @ onParticipantJoined'); + if (ablyParticipant.clientId !== this.participant.value.id) return; - const participant = this.participants.find( - (participant) => participant.id === ablyParticipant.data.id, - ); + this.logger.log('launcher service @ onParticipantJoined - local participant joined'); + this.attachComponentsAfterJoin(); + }; - if (!participant) return; + private onSameAccount = (): void => { + this.publish(ParticipantEvent.SAME_ACCOUNT_ERROR); + this.destroy(); + }; - if (participant.id === this.participant.id) { - this.logger.log('launcher service @ onParticipantJoined - local participant joined'); - this.publish(ParticipantEvent.LOCAL_JOINED, participant); - this.attachComponentsAfterJoin(); - } + /** New IO */ + + /** + * @function startIOC + * @description start IO service + * @returns {void} + */ + + private startIOC = (): void => { + this.logger.log('launcher service @ startIOC'); + + // retrieve the current participants in the room + this.LauncherRealtimeRoom.presence.get((presences) => { + presences.forEach((presence) => { + this.participants.value.set(presence.id, presence.data as Participant); + }); + }); + + this.LauncherRealtimeRoom.presence.on( + Socket.PresenceEvents.JOINED_ROOM, + this.onParticipantJoinedIOC, + ); + + this.LauncherRealtimeRoom.presence.on( + Socket.PresenceEvents.LEAVE, + this.onParticipantLeaveIOC, + ); - this.logger.log('launcher service @ onParticipantJoined - participant joined', participant); - this.publish(ParticipantEvent.JOINED, participant); + this.LauncherRealtimeRoom.presence.on( + Socket.PresenceEvents.UPDATE, + this.onParticipantUpdatedIOC, + ); }; /** - * @function onParticipantLeave - * @description on participant leave - * @param ablyParticipant - ably participant + * @function onParticipantJoinedIOC + * @description on participant joined + * @param presence - participant presence * @returns {void} */ - private onParticipantLeave = (ablyParticipant: AblyParticipant): void => { - this.logger.log('launcher service @ onParticipantLeave'); + private onParticipantJoinedIOC = async ( + presence: Socket.PresenceEvent, + ): Promise => { + if (presence.id !== this.participant.value.id) return; + + // Assign a slot to the participant + const slot = new SlotService(this.LauncherRealtimeRoom, this.participant.value); + const slotData = await slot.assignSlot(); + + this.participant.value = { + ...this.participant.value, + slot: slotData, + }; - const participant = this.participants.find((participant) => { - return participant.id === ablyParticipant.data.id; - }); + this.LauncherRealtimeRoom.presence.update(this.participant.value); + + this.logger.log('launcher service @ onParticipantJoined - local participant joined'); - if (!participant) return; + this.publish(ParticipantEvent.LOCAL_JOINED, this.participant.value); + this.publish(ParticipantEvent.JOINED, this.participant.value); + }; + + /** + * @function onParticipantLeaveIOC + * @description on participant leave + * @param presence - participant presence + * @returns {void} + */ + private onParticipantLeaveIOC = (presence: Socket.PresenceEvent): void => { + this.participants.value.delete(presence.id); - if (participant.id === this.participant.id) { + if (presence.id === this.participant.value.id) { this.logger.log('launcher service @ onParticipantLeave - local participant left'); - this.publish(ParticipantEvent.LOCAL_LEFT, participant); + this.publish(ParticipantEvent.LOCAL_LEFT, presence.data); } - this.logger.log('launcher service @ onParticipantLeave - participant left', participant); - this.publish(ParticipantEvent.LEFT, participant); + this.logger.log('launcher service @ onParticipantLeave - participant left', presence.data); + this.publish(ParticipantEvent.LEFT, presence.data); }; - private onSameAccount = (): void => { - this.publish(ParticipantEvent.SAME_ACCOUNT_ERROR); - this.destroy(); + /** + * @function onParticipantUpdatedIOC + * @description on participant updated + * @param presence - participant presence + * @returns {void} + */ + private onParticipantUpdatedIOC = (presence: Socket.PresenceEvent): void => { + if ( + presence.id === this.participant.value.id && + !isEqual(this.participant.value, presence.data) + ) { + this.participant.value = presence.data; + this.publish(ParticipantEvent.LOCAL_UPDATED, presence.data); + + this.logger.log('Publishing ParticipantEvent.UPDATED', presence.data); + + if ( + presence.data?.slot?.index !== undefined && + presence.data?.slot?.index !== this.realtime.participant.data.slotIndex + ) { + this.realtime.updateMyProperties({ slotIndex: presence.data.slot.index }); + } + } + + if (!this.participants.value.has(presence.id)) { + this.publish(ParticipantEvent.JOINED, presence.data); + } + + this.participants.value.set(presence.id, presence.data); + const participantList = Array.from(this.participants.value.values()); + + this.logger.log('Publishing ParticipantEvent.LIST_UPDATED', this.participants.value); + this.publish(ParticipantEvent.LIST_UPDATED, participantList); }; } diff --git a/src/index.ts b/src/index.ts index a8e7b191..890f42bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,12 @@ import { HTMLPin, WhoIsOnline, } from './components'; -import { RealtimeComponentEvent, RealtimeComponentState } from './components/realtime/types'; +import { Transform } from './components/presence-mouse/types'; +import { + RealtimeComponentEvent, + RealtimeComponentState, + RealtimeMessage, +} from './components/realtime/types'; import init from './core'; import './web-components'; import './common/styles/global.css'; @@ -37,8 +42,6 @@ export { Participant, Group, Avatar } from './common/types/participant.types'; export { SuperVizSdkOptions, DevicesOptions } from './common/types/sdk-options.types'; export { BrowserService } from './services/browser'; export { BrowserStats } from './services/browser/types'; - -export { RealtimeMessage } from './services/realtime/ably/types'; export { LauncherFacade } from './core/launcher/types'; export { Observer } from './common/utils/observer'; export { @@ -47,6 +50,7 @@ export { PinAdapter, PinCoordinates, AnnotationPositionInfo, + Offset, } from './components/comments/types'; if (window) { @@ -97,6 +101,15 @@ export { CommentEvent, ComponentLifeCycleEvent, WhoIsOnlineEvent, + Transform, + Comments, + CanvasPin, + HTMLPin, + MousePointers, + WhoIsOnline, + VideoConference, + Realtime, + RealtimeMessage, }; export default init; diff --git a/src/services/io/index.test.ts b/src/services/io/index.test.ts new file mode 100644 index 00000000..914f85d8 --- /dev/null +++ b/src/services/io/index.test.ts @@ -0,0 +1,37 @@ +import { MOCK_IO } from '../../../__mocks__/io.mock'; +import { MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; + +import { IOC } from '.'; + +jest.mock('@superviz/socket-client', () => MOCK_IO); + +describe('io', () => { + let instance: IOC | null = null; + + beforeEach(() => { + instance = new IOC(MOCK_LOCAL_PARTICIPANT); + }); + + afterEach(() => { + instance?.destroy(); + instance = null; + }); + + test('should create an instance', () => { + expect(instance).toBeInstanceOf(IOC); + }); + + test('should create a room', () => { + const room = instance?.createRoom('test'); + + expect(room).toBeDefined(); + expect(room).toHaveProperty('on'); + expect(room).toHaveProperty('off'); + expect(room).toHaveProperty('emit'); + }); + + test('should publish state changes', () => { + const next = jest.fn(); + instance?.onStateChange(next); + }); +}); diff --git a/src/services/io/index.ts b/src/services/io/index.ts new file mode 100644 index 00000000..addbc116 --- /dev/null +++ b/src/services/io/index.ts @@ -0,0 +1,64 @@ +import * as Socket from '@superviz/socket-client'; +import { Subject } from 'rxjs'; + +import { Participant } from '../../common/types/participant.types'; +import config from '../config/index'; + +export class IOC { + public state: Socket.ConnectionState; + public client: Socket.Realtime; + + private stateSubject: Subject = new Subject(); + + constructor(participant: Participant) { + this.client = new Socket.Realtime(config.get('apiKey'), config.get('environment'), { + id: participant.id, + name: participant.name, + }); + + this.subscribeToDefaultEvents(); + } + + /** + * @function destroy + * @description Destroys the socket connection + * @returns {void} + */ + public destroy(): void { + this.client.destroy(); + this.client.connection.off(); + } + + /** + * @function onStateChange + * @description Subscribe to the socket connection state changes + * @param next {Function} + * @returns {void} + */ + public onStateChange(next: (state: Socket.ConnectionState) => void): void { + this.stateSubject.subscribe(next); + } + + /** + * @function subscribeToDefaultEvents + * @description subscribe to the default socket events + * @returns {void} + */ + private subscribeToDefaultEvents(): void { + this.client.connection.on((state) => { + this.state = state; + this.stateSubject.next(state); + }); + } + + /** + * @function createRoom + * @description create and join realtime room + * @param roomName {string} + * @returns {Room} + */ + public createRoom(roomName: string): Socket.Room { + const roomId = config.get('roomId'); + return this.client.connect(`${roomId}:${roomName}`); + } +} diff --git a/src/services/pubsub/types.ts b/src/services/io/types.ts similarity index 100% rename from src/services/pubsub/types.ts rename to src/services/io/types.ts diff --git a/src/services/pubsub/index.test.ts b/src/services/pubsub/index.test.ts deleted file mode 100644 index 05050ae0..00000000 --- a/src/services/pubsub/index.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { MOCK_OBSERVER_HELPER } from '../../../__mocks__/observer-helper.mock'; -import { - ABLY_REALTIME_MOCK, - createRealtimeHistory, - createRealtimeMessage, -} from '../../../__mocks__/realtime.mock'; -import { AblyRealtimeService } from '../realtime'; - -import { PubSub } from '.'; - -jest.mock('../realtime', () => ({ - AblyRealtimeService: jest.fn().mockImplementation(() => ABLY_REALTIME_MOCK), -})); - -describe('PubSub', () => { - let PubSubInstance: PubSub; - - beforeEach(() => { - jest.clearAllMocks(); - const realtime = new AblyRealtimeService('apiUrl', 'ablyKey'); - - PubSubInstance = new PubSub(realtime); - }); - - test('should be defined', () => { - expect(PubSub).toBeDefined(); - }); - - test('should be subscribe to event', () => { - const callback = jest.fn(); - - PubSubInstance.subscribe('test', callback); - - expect(PubSubInstance['observers'].get('test')).toBeDefined(); - }); - - test('should be unsubscribe from event', () => { - const callback = jest.fn(); - - PubSubInstance.subscribe('test', callback); - PubSubInstance.unsubscribe('test', callback); - - expect(PubSubInstance['observers'].get('test')).not.toBeDefined(); - }); - - test('should be skip unsubscribe event not exists', () => { - const callback = jest.fn(); - - PubSubInstance.unsubscribe('test', callback); - - expect(PubSubInstance['observers'].get('test')).not.toBeDefined(); - }); - - test('should be publish event to realtime', () => { - PubSubInstance.publish('test', 'test'); - - expect(PubSubInstance['realtime'].setSyncProperty).toHaveBeenCalledWith('test', 'test'); - }); - - test('should be publish event to client', () => { - const callback = jest.fn(); - - PubSubInstance.subscribe('test', callback); - PubSubInstance.publish('test', 'test'); - PubSubInstance['onSyncPropertiesChange']({ test: 'test' }); - - expect(callback).toHaveBeenCalledWith('test'); - }); - - test('should be skip publish event to client if event not exists', () => { - const callback = jest.fn(); - - PubSubInstance.subscribe('test', callback); - PubSubInstance.publish('test', 'test'); - PubSubInstance['onSyncPropertiesChange']({ test1: 'test' }); - - expect(callback).not.toHaveBeenCalled(); - }); - - test('should call the fetchHistory and return the last message of type test', () => { - jest.spyOn(PubSubInstance, 'fetchHistory'); - const history = PubSubInstance.fetchHistory('test'); - - expect(PubSubInstance.fetchHistory).toBeCalledWith('test'); - expect(ABLY_REALTIME_MOCK.fetchSyncClientProperty).toBeCalledWith('test'); - expect(history).toEqual(createRealtimeMessage('test')); - }); - - test('should call the fetchHistory and return the realtime history', () => { - jest.spyOn(PubSubInstance, 'fetchHistory'); - const history = PubSubInstance.fetchHistory(); - - expect(PubSubInstance.fetchHistory).toBeCalled(); - expect(ABLY_REALTIME_MOCK.fetchSyncClientProperty).toBeCalled(); - expect(history).toEqual(createRealtimeHistory()); - }); - - test('should destroy service', () => { - PubSubInstance.destroy(); - - expect(PubSubInstance['observers']).toEqual(new Map()); - expect(ABLY_REALTIME_MOCK.syncPropertiesObserver.unsubscribe).toHaveBeenCalled(); - }); -}); diff --git a/src/services/pubsub/index.ts b/src/services/pubsub/index.ts deleted file mode 100644 index 29575828..00000000 --- a/src/services/pubsub/index.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Logger, Observer } from '../../common/utils'; -import { AblyRealtimeService } from '../realtime'; -import { RealtimeMessage } from '../realtime/ably/types'; - -export class PubSub { - private readonly realtime: AblyRealtimeService; - private readonly logger: Logger; - - private observers: Map = new Map(); - - constructor(realtime: AblyRealtimeService) { - this.logger = new Logger('@superviz/sdk/pubsub'); - this.realtime = realtime; - - this.realtime.syncPropertiesObserver.subscribe(this.onSyncPropertiesChange); - - this.logger.log('PubSub created'); - } - - /** - * @function subscribe - * @description subscribe to event - * @param event - event name - * @param callback - callback function - * @returns {void} - */ - public subscribe = (event: string, callback: (data: unknown) => void): void => { - this.logger.log('pubsub service @ subscribe', { event, callback }); - - if (!this.observers.has(event)) { - this.observers.set(event, new Observer()); - } - - this.observers.get(event).subscribe(callback); - }; - - /** - * @function unsubscribe - * @description - unsubscribe from event - * @param event - event name - * @param callback - callback function - * @returns {void} - */ - public unsubscribe = (event: string, callback: (data: unknown) => void): void => { - this.logger.log('pubsub service @ unsubscribe', { event, callback }); - - if (!this.observers.has(event)) return; - - this.observers.get(event).reset(); - this.observers.delete(event); - }; - - /** - * @function publish - * @description - publish event to realtime - * @param event - event name - * @param data - data to publish - * @returns {void} - */ - public publish = (event: string, data: unknown): void => { - this.logger.log('pubsub service @ publish', { event, data }); - - this.realtime.setSyncProperty(event, data); - }; - - /** - * @function fetchSyncClientProperty - * @description get realtime client data history - * @returns {RealtimeMessage | Record} - */ - public fetchHistory( - eventName?: string, - ): Promise> { - return this.realtime.fetchSyncClientProperty(eventName); - } - - /** - * @function publishEventToClient - * @description - publish event to client - * @param event - event name - * @param data - data to publish - * @returns {void} - */ - public publishEventToClient = (event: string, data?: unknown): void => { - this.logger.log('pubsub service @ publishEventToClient', { event, data }); - - if (!this.observers.has(event)) return; - - this.observers.get(event).publish(data); - }; - - public destroy = (): void => { - this.logger.log('pubsub service @ destroy'); - - this.realtime.syncPropertiesObserver.unsubscribe(this.onSyncPropertiesChange); - this.observers.forEach((observer) => observer.destroy()); - this.observers.clear(); - }; - - /** - * @function onSyncPropertiesChange - * @description - sync properties change handler - * @param properties - properties - * @returns {void} - */ - private onSyncPropertiesChange = (properties: Record): void => { - this.logger.log('pubsub service @ onSyncPropertiesChange', { properties }); - - Object.entries(properties).forEach(([key, value]) => { - this.publishEventToClient(key, value); - }); - }; -} diff --git a/src/services/realtime/ably/index.test.ts b/src/services/realtime/ably/index.test.ts index 40719023..b3301ac1 100644 --- a/src/services/realtime/ably/index.test.ts +++ b/src/services/realtime/ably/index.test.ts @@ -25,27 +25,6 @@ const mockTokenRequest: Ably.Types.TokenRequest = { timestamp: new Date().getTime(), }; -const AblyClientRoomStateHistoryMock = { - items: [ - { - data: { - fizz: { - name: 'fizz', - data: "999 is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.", - participantId: '123', - timestamp: new Date().getTime(), - }, - buzz: { - name: 'buzz', - data: "999 Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.", - participantId: '123', - timestamp: new Date().getTime(), - }, - }, - }, - ], -}; - const AblyRealtimeMock = { channels: { get: jest.fn().mockImplementation(() => { @@ -155,13 +134,10 @@ describe('AblyRealtimeService', () => { AblyRealtimeServiceInstance.join(); - expect(AblyRealtimeMock.channels.get).toHaveBeenCalledTimes(5); + expect(AblyRealtimeMock.channels.get).toHaveBeenCalledTimes(4); expect(AblyRealtimeMock.channels.get).toHaveBeenCalledWith( 'superviz:unit-test-room-id-unit-test-api-key:client-sync', ); - expect(AblyRealtimeMock.channels.get).toHaveBeenCalledWith( - 'superviz:unit-test-room-id-unit-test-api-key:client-state', - ); expect(AblyRealtimeMock.channels.get).toHaveBeenCalledWith( 'superviz:unit-test-room-id-unit-test-api-key:broadcast', ); @@ -187,13 +163,10 @@ describe('AblyRealtimeService', () => { const spy = jest.spyOn(AblyRealtimeServiceInstance['broadcastChannel'], 'subscribe'); - expect(AblyRealtimeMock.channels.get).toHaveBeenCalledTimes(5); + expect(AblyRealtimeMock.channels.get).toHaveBeenCalledTimes(4); expect(AblyRealtimeMock.channels.get).toHaveBeenCalledWith( 'superviz:unit-test-room-id-unit-test-api-key:client-sync', ); - expect(AblyRealtimeMock.channels.get).toHaveBeenCalledWith( - 'superviz:unit-test-room-id-unit-test-api-key:client-state', - ); expect(AblyRealtimeMock.channels.get).toHaveBeenCalledWith( 'superviz:unit-test-room-id-unit-test-api-key:broadcast', ); @@ -454,7 +427,7 @@ describe('AblyRealtimeService', () => { expect(AblyRealtimeServiceInstance['isJoinedRoom']).toBe(true); expect(AblyRealtimeServiceInstance['fetchRoomProperties']).toHaveBeenCalledTimes(2); - expect(AblyRealtimeServiceInstance['updateParticipants']).toHaveBeenCalledTimes(1); + expect(AblyRealtimeServiceInstance['updateParticipants']).toHaveBeenCalledTimes(2); expect(AblyRealtimeServiceInstance['updateLocalRoomState']).toHaveBeenCalledTimes(1); expect(AblyRealtimeServiceInstance['publishStateUpdate']).toHaveBeenCalledWith( RealtimeStateTypes.CONNECTED, @@ -492,7 +465,6 @@ describe('AblyRealtimeService', () => { expect(AblyRealtimeServiceInstance['isJoinedRoom']).toBe(true); expect(AblyRealtimeServiceInstance['fetchRoomProperties']).toHaveBeenCalledTimes(1); - expect(AblyRealtimeServiceInstance['updateParticipants']).not.toBeCalled(); expect(AblyRealtimeServiceInstance['updateLocalRoomState']).not.toBeCalled(); expect(AblyRealtimeServiceInstance['publishStateUpdate']).toHaveBeenCalledWith( RealtimeStateTypes.CONNECTED, @@ -810,176 +782,6 @@ describe('AblyRealtimeService', () => { }); }); - describe('client message handlers', () => { - beforeEach(() => { - AblyRealtimeServiceInstance.start({ - apiKey: 'unit-test-api-key', - participant: MOCK_LOCAL_PARTICIPANT, - roomId: 'unit-test-room-id', - }); - - AblyRealtimeServiceInstance.join(); - - AblyRealtimeServiceInstance['state'] = RealtimeStateTypes.CONNECTED; - }); - - /** - * IsMessageTooBig - */ - - test('should return true if message size is bigger than 60kb', () => { - const message = { - data: 'a'.repeat(60000), - }; - - expect(AblyRealtimeServiceInstance['isMessageTooBig'](message)).toBeTruthy(); - }); - - test('should return false if message size is smaller than 60kb', () => { - const message = { - data: 'a'.repeat(60000 - 11), - }; - - expect(AblyRealtimeServiceInstance['isMessageTooBig'](message)).toBeFalsy(); - }); - - /** - * setSyncProperty - */ - - test('should add the event to the queue', () => { - const publishClientSyncPropertiesSpy = jest.spyOn( - AblyRealtimeServiceInstance as any, - 'publishClientSyncProperties', - ); - const name = 'test'; - const property = { test: true }; - - AblyRealtimeServiceInstance.setSyncProperty(name, property); - - expect(publishClientSyncPropertiesSpy).not.toHaveBeenCalled(); - expect(AblyRealtimeServiceInstance['clientSyncPropertiesQueue'][name]).toHaveLength(1); - }); - - test('should throw an error if the message is too big', () => { - const publishClientSyncPropertiesSpy = jest.spyOn( - AblyRealtimeServiceInstance as any, - 'publishClientSyncProperties', - ); - const throwSpy = jest.spyOn(AblyRealtimeServiceInstance as any, 'throw'); - const name = 'test'; - const property = { test: true, tooBig: new Array(10000).fill('a').join('') }; - - expect(() => AblyRealtimeServiceInstance.setSyncProperty(name, property)).toThrowError( - 'Message too long, the message limit size is 10kb.', - ); - expect(publishClientSyncPropertiesSpy).not.toHaveBeenCalled(); - expect(throwSpy).toHaveBeenCalledWith('Message too long, the message limit size is 10kb.'); - }); - - /** - * publishClientSyncProperties - * */ - - test('should not publish if the queue is empty', () => { - AblyRealtimeServiceInstance['clientSyncPropertiesQueue'] = { - test: [], - }; - - AblyRealtimeServiceInstance['publishClientSyncProperties'](); - - expect(AblyRealtimeServiceInstance['clientSyncChannel'].publish).not.toHaveBeenCalled(); - }); - - test('should not publish if the state is different than connected', () => { - AblyRealtimeServiceInstance['clientSyncPropertiesQueue'] = { - test: [], - }; - - AblyRealtimeServiceInstance['state'] = RealtimeStateTypes.DISCONNECTED; - - AblyRealtimeServiceInstance['publishClientSyncProperties'](); - - expect(AblyRealtimeServiceInstance['clientSyncChannel'].publish).not.toHaveBeenCalled(); - }); - - test('should publish the queue', () => { - AblyRealtimeServiceInstance['clientSyncPropertiesQueue'] = { - test: [ - { - data: { test: true }, - name: 'test', - participantId: 'unit-test-participant-id', - timestamp: new Date().getTime(), - }, - ], - }; - - AblyRealtimeServiceInstance['publishClientSyncProperties'](); - - expect(AblyRealtimeServiceInstance['clientSyncChannel'].publish).toHaveBeenCalled(); - }); - - /** - * fetchClientSyncProperties - */ - - test('should return the client sync properties history', async () => { - // @ts-ignore - AblyRealtimeServiceInstance['clientRoomStateChannel'].history = jest.fn((callback) => { - (callback as any)(null, AblyClientRoomStateHistoryMock); - }); - - const expected = AblyClientRoomStateHistoryMock.items[0].data; - - await expect(AblyRealtimeServiceInstance.fetchSyncClientProperty()).resolves.toEqual( - expected, - ); - }); - - test('should return the client sync properties history for a specific key', async () => { - // @ts-ignore - AblyRealtimeServiceInstance['clientRoomStateChannel'].history = jest.fn((callback) => { - (callback as any)(null, AblyClientRoomStateHistoryMock); - }); - - const expected = AblyClientRoomStateHistoryMock.items[0].data.buzz; - - await expect(AblyRealtimeServiceInstance.fetchSyncClientProperty('buzz')).resolves.toEqual( - expected, - ); - }); - - test('should return null if the history is empty', async () => { - // @ts-ignore - AblyRealtimeServiceInstance['clientRoomStateChannel'].history = jest.fn((callback) => { - (callback as any)(null, null); - }); - - await expect(AblyRealtimeServiceInstance.fetchSyncClientProperty()).resolves.toEqual(null); - }); - - test('should return throw an error if a event is not found', async () => { - // @ts-ignore - AblyRealtimeServiceInstance['clientRoomStateChannel'].history = jest.fn((callback) => { - (callback as any)(null, AblyClientRoomStateHistoryMock); - }); - - await expect( - AblyRealtimeServiceInstance.fetchSyncClientProperty('not-found'), - ).rejects.toThrow('Event not-found not found in the history'); - }); - - test('should throw an error if ably dont responds', async () => { - // @ts-ignore - AblyRealtimeServiceInstance['clientRoomStateChannel'].history = jest.fn((callback) => { - (callback as any)('error', null); - }); - - await expect(AblyRealtimeServiceInstance.fetchSyncClientProperty()).rejects.toThrow(); - }); - }); - describe('kick participant event', () => { test('should update the kickParticipant in the room properties', async () => { const participantId = 'participant1'; @@ -1407,7 +1209,6 @@ describe('AblyRealtimeService', () => { AblyRealtimeServiceInstance.freezeSync(false); expect(AblyRealtimeServiceInstance['supervizChannel'].subscribe).toBeCalled(); - expect(AblyRealtimeServiceInstance['clientSyncChannel'].subscribe).toBeCalled(); expect(AblyRealtimeServiceInstance['isSyncFrozen']).toBe(false); }); diff --git a/src/services/realtime/ably/index.ts b/src/services/realtime/ably/index.ts index 5a6c8a55..bd32b938 100644 --- a/src/services/realtime/ably/index.ts +++ b/src/services/realtime/ably/index.ts @@ -2,13 +2,10 @@ import Ably from 'ably'; import throttle from 'lodash/throttle'; import { RealtimeEvent, TranscriptState } from '../../../common/types/events.types'; -import { Nullable } from '../../../common/types/global.types'; import { MeetingColors } from '../../../common/types/meeting-colors.types'; import { Participant, ParticipantType } from '../../../common/types/participant.types'; import { RealtimeStateTypes } from '../../../common/types/realtime.types'; import { Annotation } from '../../../components/comments/types'; -import { ParticipantMouse } from '../../../components/presence-mouse/types'; -import { ComponentNames } from '../../../components/types'; import { DrawingData } from '../../video-conference-manager/types'; import { RealtimeService } from '../base'; import { ParticipantInfo, StartRealtimeType } from '../base/types'; @@ -19,32 +16,23 @@ import { AblyRealtimeData, AblyTokenCallBack, ParticipantDataInput, - RealtimeMessage, } from './types'; const MESSAGE_SIZE_LIMIT = 60000; -const CLIENT_MESSAGE_SIZE_LIMIT = 10000; const SYNC_PROPERTY_INTERVAL = 1000; -const SYNC_MOUSE_INTERVAL = 100; export default class AblyRealtimeService extends RealtimeService implements AblyRealtime { private client: Ably.Realtime; private participants: Record = {}; - private participantsWIO: Record = {}; - private participantsMouse: Record = {}; private participantsOn3d: Record = {}; private hostParticipantId: string = null; private myParticipant: AblyParticipant = null; private commentsChannel: Ably.Types.RealtimeChannelCallbacks = null; private supervizChannel: Ably.Types.RealtimeChannelCallbacks = null; private clientSyncChannel: Ably.Types.RealtimeChannelCallbacks = null; - private clientRoomStateChannel: Ably.Types.RealtimeChannelCallbacks = null; private broadcastChannel: Ably.Types.RealtimeChannelCallbacks = null; private presenceWIOChannel: Ably.Types.RealtimeChannelCallbacks = null; - private presenceMouseChannel: Ably.Types.RealtimeChannelCallbacks = null; private presence3DChannel: Ably.Types.RealtimeChannelCallbacks = null; - private clientRoomState: Record = {}; - private clientSyncPropertiesQueue: Record = {}; private isReconnecting: boolean = false; @@ -77,16 +65,11 @@ export default class AblyRealtimeService extends RealtimeService implements Ably this.onAblyPresenceUpdate = this.onAblyPresenceUpdate.bind(this); this.onAblyPresenceLeave = this.onAblyPresenceLeave.bind(this); this.onAblyRoomUpdate = this.onAblyRoomUpdate.bind(this); - this.onClientSyncChannelUpdate = this.onClientSyncChannelUpdate.bind(this); this.onAblyChannelStateChange = this.onAblyChannelStateChange.bind(this); this.onAblyConnectionStateChange = this.onAblyConnectionStateChange.bind(this); this.onReceiveBroadcastSync = this.onReceiveBroadcastSync.bind(this); this.getParticipantSlot = this.getParticipantSlot.bind(this); this.auth = this.auth.bind(this); - - setInterval(() => { - this.publishClientSyncProperties(); - }, 1000); } public get roomProperties() { @@ -200,9 +183,6 @@ export default class AblyRealtimeService extends RealtimeService implements Ably // join custom sync channel this.clientSyncChannel = this.client.channels.get(`${this.roomId}:client-sync`); - this.clientSyncChannel.subscribe(this.onClientSyncChannelUpdate); - - this.clientRoomStateChannel = this.client.channels.get(`${this.roomId}:client-state`); this.broadcastChannel = this.client.channels.get(`${this.roomId}:broadcast`); if (!this.enableSync) { @@ -305,39 +285,8 @@ export default class AblyRealtimeService extends RealtimeService implements Ably */ public setTranscript(state: TranscriptState): void { const roomProperties = this.localRoomProperties; - this.updateRoomProperties(Object.assign({}, roomProperties, { transcript: state })); - } - - /** - * @function setSyncProperty - * @param {string} name - * @param {unknown} property - * @description add/change and sync a property in the room - * @returns {void} - */ - public setSyncProperty(name: string, property: T): void { - // closure to create the event - const createEvent = (name: string, data: T): RealtimeMessage => { - return { - name, - data, - participantId: this.myParticipant.data.participantId, - timestamp: Date.now(), - }; - }; - // if the property is too big, don't add to the queue - if (this.isMessageTooBig(createEvent(name, property), CLIENT_MESSAGE_SIZE_LIMIT)) { - this.logger.log('REALTIME', 'Message too big, not sending'); - this.throw('Message too long, the message limit size is 10kb.'); - } - - this.logger.log('adding to queue', name, property); - if (!this.clientSyncPropertiesQueue[name]) { - this.clientSyncPropertiesQueue[name] = []; - } - - this.clientSyncPropertiesQueue[name].push(createEvent(name, property)); + this.updateRoomProperties(Object.assign({}, roomProperties, { transcript: state })); } /** @@ -414,36 +363,6 @@ export default class AblyRealtimeService extends RealtimeService implements Ably this.updatePresence3D(this.myParticipant.data); }; - /** - * @function publishClientSyncProperties - * @description publish client sync props - * @returns {void} - */ - private publishClientSyncProperties = (): void => { - if (this.state !== RealtimeStateTypes.CONNECTED) return; - - Object.keys(this.clientSyncPropertiesQueue).forEach((name) => { - this.clientSyncPropertiesQueue[name] = this.clientSyncPropertiesQueue[name].sort( - (a, b) => a.timestamp - b.timestamp, - ); - - if (!this.clientSyncPropertiesQueue[name].length) return; - - const { data, lengthToBeSplitted } = this.spliceArrayBySize( - this.clientSyncPropertiesQueue[name], - ); - - const eventQueue = data; - this.clientSyncPropertiesQueue[name].splice(0, lengthToBeSplitted); - - this.clientSyncChannel.publish(name, eventQueue, (error) => { - if (!error) return; - - this.logger.log('REALTIME', 'Error in publish client sync properties', error.message); - }); - }); - }; - /** * @function onAblyPresenceEnter * @description callback that receives the event that a participant has entered the room @@ -497,44 +416,6 @@ export default class AblyRealtimeService extends RealtimeService implements Ably this.onParticipantLeave(presenceMessage); } - /** - * @function onClientSyncChannelUpdate - * @description callback that receives the update event from ably's channel - * @param {Ably.Types.Message} message - * @returns {void} - */ - private onClientSyncChannelUpdate(message: Ably.Types.Message): void { - const { name, data } = message; - const property = {}; - - property[name] = data; - this.syncPropertiesObserver.publish(property); - - if (message.clientId === this.myParticipant.data.participantId) { - this.saveClientRoomState(name, data); - } - } - - /** - * @function saveClientRoomState - * @description - Saves the latest state of the room for the client - and publishes it to the client room state channel. - * @param {string} name - The name of the room state to save. - * @param {RealtimeMessage[]} data - The data to save as the latest state of the room. - * @returns {void} - */ - private saveClientRoomState = async (name: string, data: RealtimeMessage[]): Promise => { - const previusHistory = await this.fetchSyncClientProperty(); - - this.clientRoomState = Object.assign({}, previusHistory, { - [name]: data[data.length - 1], - }); - this.clientRoomStateChannel.publish('update', this.clientRoomState); - - this.logger.log('REALTIME', 'setting new room state backup', this.clientRoomState); - }; - /** * @function onReceiveBroadcastSync * @description receive the info of all participants from the host @@ -699,6 +580,8 @@ export default class AblyRealtimeService extends RealtimeService implements Ably followParticipantId: null, gather: false, drawing: null, + transcript: TranscriptState.TRANSCRIPT_STOP, + kickParticipant: null, }; this.updateParticipants(); @@ -771,14 +654,14 @@ export default class AblyRealtimeService extends RealtimeService implements Ably * @function fetchRoomProperties * @returns {AblyRealtimeData | null} */ - private fetchRoomProperties(): Promise { - return new Promise((resolve, reject) => { + private async fetchRoomProperties(): Promise { + const lastMessage: Ably.Types.Message | null = await new Promise((resolve, reject) => { this.supervizChannel.history((error, resultPage) => { if (error) { reject(error); } - const lastMessage = resultPage.items[0]?.data; + const lastMessage = resultPage.items[0]; if (lastMessage) { resolve(lastMessage); @@ -787,138 +670,10 @@ export default class AblyRealtimeService extends RealtimeService implements Ably } }); }); - } - - /** - * @function fetchSyncClientProperty - * @description - * @param {string} eventName - name event to be fetched - * @returns {Promise} - */ - public async fetchSyncClientProperty( - eventName?: string, - ): Promise> { - try { - const clientHistory: Record = await new Promise( - (resolve, reject) => { - this.clientRoomStateChannel.history((error, resultPage) => { - if (error) reject(error); - - const lastMessage = resultPage?.items[0]?.data; - - if (lastMessage) { - resolve(lastMessage); - } else { - resolve(null); - } - }); - }, - ); - - if (eventName && !clientHistory[eventName]) { - throw new Error(`Event ${eventName} not found in the history`); - } - - if (eventName) { - return clientHistory[eventName]; - } - - return clientHistory; - } catch (error) { - this.logger.log('REALTIME', 'Error in fetch client realtime data', error.message); - this.throw(error.message); - } - } - - /** - * @function findSlotIndex - * @description Finds an available slot index for the participant and confirms it. - * @returns {void} - */ - private findSlotIndex = async (): Promise => { - const slot = Math.floor(Math.random() * 16); - - const hasAnyOneUsingMySlot = await new Promise((resolve) => { - this.supervizChannel.presence.get((error, presences) => { - if (error) { - resolve(true); - return; - } - - presences.forEach((presence) => { - if (presence.clientId === this.myParticipant.clientId) return; - - if (presence.data.slotIndex === slot) resolve(true); - }); - - resolve(false); - }); - }); - - if (hasAnyOneUsingMySlot) { - this.logger.log( - 'slot already taken by someone else, trying again', - this.myParticipant.clientId, - ); - this.findSlotIndex(); - return; - } - - this.updateMyProperties({ slotIndex: slot }); - }; - - /** - * @function validateSlots - * @description Validates the slot index of all participants and resolves conflicts. - * @returns {void} - */ - private async validateSlots(): Promise { - const slots = []; - await new Promise((resolve) => { - this.supervizChannel.presence.get((_, presences) => { - presences.forEach((presence) => { - const hasValidSlot = - presence.data.slotIndex !== undefined && presence.data.slotIndex !== null; - - if (hasValidSlot) { - slots.push({ - slotIndex: presence.data.slotIndex, - clientId: presence.clientId, - timestamp: presence.timestamp, - }); - } - }); - resolve(true); - }); - }); - const duplicatesMap: Record< - string, - { - slotIndex: number; - clientId: string; - timestamp: number; - }[] - > = {}; - - slots.forEach((a) => { - if (!duplicatesMap[a.slotIndex]) { - duplicatesMap[a.slotIndex] = []; - } - - duplicatesMap[a.slotIndex].push(a); - }); + if (lastMessage?.timestamp < Date.now() - 1000 * 60 * 60) return null; - Object.values(duplicatesMap).forEach((arr) => { - const ordered = arr.sort((a, b) => a.timestamp - b.timestamp); - ordered.shift(); - - ordered.forEach((slot) => { - if (slot.clientId !== this.myParticipant.clientId) return; - - this.findSlotIndex(); - }); - }); + return lastMessage?.data || null; } /** @@ -1009,10 +764,9 @@ export default class AblyRealtimeService extends RealtimeService implements Ably ): Promise { this.isJoinedRoom = true; this.localRoomProperties = await this.fetchRoomProperties(); + this.updateParticipants(); this.myParticipant = myPresence; - if (this.enableSync) this.findSlotIndex(); - if (!this.localRoomProperties) { this.initializeRoomProperties(); } else { @@ -1037,7 +791,6 @@ export default class AblyRealtimeService extends RealtimeService implements Ably this.updateParticipants(); this.participantJoinedObserver.publish(presence); this.updateMyProperties(); // send a sync - this.validateSlots(); }; /** @@ -1148,71 +901,6 @@ export default class AblyRealtimeService extends RealtimeService implements Ably this.commentsChannel.publish('update', annotations); }; - /** Presence Mouse */ - - public enterPresenceMouseChannel = (participant: Participant): void => { - if (!this.presenceMouseChannel) { - this.presenceMouseChannel = this.client.channels.get( - `${this.roomId.toLowerCase()}-presence-mouse`, - ); - this.presenceMouseChannel.attach(); - - this.presenceMouseChannel.presence.subscribe('enter', this.onPresenceMouseChannelEnter); - this.presenceMouseChannel.presence.subscribe('leave', this.onPresenceMouseChannelLeave); - this.presenceMouseChannel.presence.subscribe('update', this.publishPresenceMouseUpdate); - } - - this.presenceMouseChannel.presence.enter(participant); - }; - - public leavePresenceMouseChannel = (): void => { - if (!this.presenceMouseChannel) return; - - this.presenceMouseChannel.presence.leave(); - this.presenceMouseChannel = null; - }; - - public updatePresenceMouse = throttle((data: Partial): void => { - const participant = Object.assign({}, this.participantsMouse[data.id] || {}, data); - - this.presenceMouseChannel.presence.update(participant); - }, SYNC_MOUSE_INTERVAL); - - private onPresenceMouseChannelEnter = (presence: Ably.Types.PresenceMessage): void => { - const slot = this.getParticipantSlot(presence.clientId); - - this.participantsMouse[presence.clientId] = { - ...presence.data, - slotIndex: slot, - color: this.getSlotColor(slot).color, - }; - - this.presenceMouseParticipantJoinedObserver.publish(this.participantsMouse[presence.clientId]); - }; - - private onPresenceMouseChannelLeave = (presence: Ably.Types.PresenceMessage): void => { - this.presenceMouseParticipantLeaveObserver.publish(this.participantsMouse[presence.clientId]); - delete this.participantsMouse[presence.clientId]; - }; - - /** - * @function publishPresenceMouseUpdate - * @param {AblyParticipant} participant - * @description publish a participant's changes to observer - * @returns {void} - */ - private publishPresenceMouseUpdate = (presence: Ably.Types.PresenceMessage): void => { - const slot = this.getParticipantSlot(presence.clientId); - - this.participantsMouse[presence.clientId] = { - ...presence.data, - slotIndex: slot, - color: this.getSlotColor(slot).color, - }; - - this.presenceMouseObserver.publish(this.participantsMouse); - }; - /** Who Is Online */ public enterWIOChannel = (participant: Participant): void => { diff --git a/src/services/realtime/ably/types.ts b/src/services/realtime/ably/types.ts index e36268a9..b4d0f379 100644 --- a/src/services/realtime/ably/types.ts +++ b/src/services/realtime/ably/types.ts @@ -35,10 +35,3 @@ export type AblyTokenCallBack = ( export interface ParticipantDataInput { [key: string]: string | number | Array | Object; } - -export type RealtimeMessage = { - name: string; - participantId: string; - data: unknown; - timestamp: number; -}; diff --git a/src/services/realtime/base/index.test.ts b/src/services/realtime/base/index.test.ts index b5d9f36c..1af5d3fd 100644 --- a/src/services/realtime/base/index.test.ts +++ b/src/services/realtime/base/index.test.ts @@ -23,7 +23,6 @@ describe('RealtimeService', () => { expect(RealtimeServiceInstance.roomInfoUpdatedObserver).toBeDefined(); expect(RealtimeServiceInstance.roomListUpdatedObserver).toBeDefined(); expect(RealtimeServiceInstance.realtimeStateObserver).toBeDefined(); - expect(RealtimeServiceInstance.syncPropertiesObserver).toBeDefined(); expect(RealtimeServiceInstance.kickAllParticipantsObserver).toBeDefined(); expect(RealtimeServiceInstance.authenticationObserver).toBeDefined(); }); diff --git a/src/services/realtime/base/index.ts b/src/services/realtime/base/index.ts index 29818a26..98b9aa53 100644 --- a/src/services/realtime/base/index.ts +++ b/src/services/realtime/base/index.ts @@ -15,7 +15,6 @@ export class RealtimeService implements DefaultRealtimeService { public roomInfoUpdatedObserver: Observer; public roomListUpdatedObserver: Observer; public realtimeStateObserver: Observer; - public syncPropertiesObserver: Observer; public kickAllParticipantsObserver: Observer; public kickParticipantObserver: Observer; public authenticationObserver: Observer; @@ -24,9 +23,6 @@ export class RealtimeService implements DefaultRealtimeService { public privateModeWIOObserver: Observer; public followWIOObserver: Observer; public gatherWIOObserver: Observer; - public presenceMouseParticipantLeaveObserver: Observer; - public presenceMouseParticipantJoinedObserver: Observer; - public presenceSlotsInfosObserver: Observer; public presence3dObserver: Observer; public presence3dLeaveObserver: Observer; public presence3dJoinedObserver: Observer; @@ -41,7 +37,6 @@ export class RealtimeService implements DefaultRealtimeService { this.participantsObserver = new Observer({ logger: this.logger }); this.participantJoinedObserver = new Observer({ logger: this.logger }); this.participantLeaveObserver = new Observer({ logger: this.logger }); - this.syncPropertiesObserver = new Observer({ logger: this.logger }); this.reconnectObserver = new Observer({ logger: this.logger }); this.sameAccountObserver = new Observer({ logger: this.logger }); @@ -64,10 +59,6 @@ export class RealtimeService implements DefaultRealtimeService { this.followWIOObserver = new Observer({ logger: this.logger }); this.gatherWIOObserver = new Observer({ logger: this.logger }); - this.presenceMouseParticipantLeaveObserver = new Observer({ logger: this.logger }); - this.presenceMouseParticipantJoinedObserver = new Observer({ logger: this.logger }); - this.presenceSlotsInfosObserver = new Observer({ logger: this.logger }); - // presence 3d this.presence3dObserver = new Observer({ logger: this.logger }); diff --git a/src/services/realtime/base/types.ts b/src/services/realtime/base/types.ts index 4b04815d..5944e75b 100644 --- a/src/services/realtime/base/types.ts +++ b/src/services/realtime/base/types.ts @@ -12,7 +12,6 @@ export interface DefaultRealtimeService { roomInfoUpdatedObserver: Observer; roomListUpdatedObserver: Observer; realtimeStateObserver: Observer; - syncPropertiesObserver: Observer; kickAllParticipantsObserver: Observer; kickParticipantObserver: Observer; authenticationObserver: Observer; @@ -22,7 +21,6 @@ export interface DefaultRealtimeMethods { start: (options: StartRealtimeType) => void; leave: () => void; join: (participant?: Participant) => void; - setSyncProperty: (name: string, property: T) => void; setHost: (masterParticipantId: string) => void; setGridMode: (value: boolean) => void; setDrawing: (drawing: DrawingData) => void; diff --git a/src/services/slot/index.test.ts b/src/services/slot/index.test.ts new file mode 100644 index 00000000..f4305061 --- /dev/null +++ b/src/services/slot/index.test.ts @@ -0,0 +1,101 @@ +import { SlotService } from '.'; + +describe('slot service', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + test('should assign a slot to the participant', async () => { + const room = { + presence: { + on: jest.fn(), + get: jest.fn((callback) => { + callback([]); + }), + update: jest.fn(), + }, + } as any; + + const participant = { + id: '123', + } as any; + + const instance = new SlotService(room, participant); + const result = await instance.assignSlot(); + + expect(instance['slotIndex']).toBeDefined(); + expect(instance['participant'].slot).toBeDefined(); + expect(result).toEqual({ + index: expect.any(Number), + 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(); + + const room = { + presence: { + on: jest.fn(), + get: jest.fn((callback) => { + callback(new Array(16).fill({})); + }), + update: jest.fn(), + }, + } as any; + + const participant = { + id: '123', + } as any; + + const instance = new SlotService(room, participant); + const result = await instance.assignSlot(); + + expect(instance['slotIndex']).toBeUndefined(); + expect(instance['participant'].slot).toBeUndefined(); + }); + + test('if the slot is already in use, it should assign a new slot', async () => { + const room = { + presence: { + on: jest.fn(), + get: jest.fn((callback) => { + callback([ + { + data: { + slot: { + index: 0, + }, + }, + }, + ]); + }), + update: jest.fn(), + }, + } as any; + + const mockMath = Object.create(global.Math); + mockMath.random = jest.fn().mockReturnValueOnce(0).mockReturnValueOnce(1); + global.Math = mockMath; + + const participant = { + id: '123', + } as any; + + const instance = new SlotService(room, participant); + const result = await instance.assignSlot(); + + expect(instance['slotIndex']).toBeDefined(); + expect(instance['participant'].slot).toBeDefined(); + expect(result).toEqual({ + index: expect.any(Number), + color: expect.any(String), + textColor: expect.any(String), + colorName: expect.any(String), + timestamp: expect.any(Number), + }); + }); +}); diff --git a/src/services/slot/index.ts b/src/services/slot/index.ts new file mode 100644 index 00000000..df4fc876 --- /dev/null +++ b/src/services/slot/index.ts @@ -0,0 +1,96 @@ +import * as Socket from '@superviz/socket-client'; + +import { + INDEX_IS_WHITE_TEXT, + MeetingColors, + MeetingColorsHex, +} from '../../common/types/meeting-colors.types'; +import { Participant, Slot } from '../../common/types/participant.types'; +import { AblyRealtimeService } from '../realtime'; + +export class SlotService { + private room: Socket.Room; + private participant: Participant; + private slotIndex: number; + private static instance: SlotService; + + // @NOTE - reciving old realtime service instance until we migrate to new IO + constructor(room: Socket.Room, participant: Participant) { + this.room = room; + this.participant = participant; + + this.room.presence.on(Socket.PresenceEvents.UPDATE, this.onPresenceUpdate); + } + + /** + * @function assignSlot + * @description Assigns a slot to the participant + * @returns void + */ + public async assignSlot(): Promise { + try { + const slot = Math.floor(Math.random() * 16); + + const isUsing = await new Promise((resolve, reject) => { + this.room.presence.get((presences) => { + if (!presences || !presences.length) resolve(false); + + if (presences.length >= 16) { + reject(new Error('[SuperViz] - No more slots available')); + return; + } + + presences.forEach((presence: Socket.PresenceEvent) => { + if (presence.id === this.participant.id) return; + + if (presence.data?.slot?.index === slot) resolve(true); + }); + + resolve(false); + }); + }); + + if (isUsing) { + const slotData = await this.assignSlot(); + return slotData; + } + + const slotData = { + index: slot, + color: MeetingColorsHex[slot], + textColor: INDEX_IS_WHITE_TEXT.includes(slot) ? '#fff' : '#000', + colorName: MeetingColors[slot], + timestamp: Date.now(), + }; + + this.slotIndex = slot; + this.participant = { + ...this.participant, + slot: slotData, + }; + + return slotData; + } catch (error) { + console.error(error); + return null; + } + } + + private onPresenceUpdate = async (event: Socket.PresenceEvent) => { + if (!event.data.slot || !this.participant?.slot) return; + + if (event.id === this.participant.id) { + this.participant = event.data; + this.slotIndex = event.data.slot.index; + return; + } + + const slotOccupied = event.data.slot.timestamp < this.participant?.slot?.timestamp; + + // if someone else has the same slot as me, and they were assigned first, I should reassign + if (event.data.slot?.index === this.slotIndex && slotOccupied) { + const slotData = await this.assignSlot(); + this.room.presence.update({ slot: slotData }); + } + }; +} diff --git a/src/services/slot/type.ts b/src/services/slot/type.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/services/stores/common/types.ts b/src/services/stores/common/types.ts new file mode 100644 index 00000000..11cace99 --- /dev/null +++ b/src/services/stores/common/types.ts @@ -0,0 +1,20 @@ +type Callback = (a: T, b?: K) => void; + +export type SimpleSubject = { + value: T; + publish: Callback; + subscribe: Callback>; + unsubscribe: Callback; +}; + +export type Singleton = { + set value(value: T); + get value(): T; +}; + +export type PublicSubject = { + get value(): T; + set value(T); + subscribe: Callback>; + unsubscribe: Callback; +}; diff --git a/src/services/stores/common/utils.ts b/src/services/stores/common/utils.ts new file mode 100644 index 00000000..c5815777 --- /dev/null +++ b/src/services/stores/common/utils.ts @@ -0,0 +1,13 @@ +import { Singleton } from './types'; + +export function CreateSingleton(): Singleton { + return { + set value(value: T) { + this.instance = value; + setTimeout(() => Object.freeze(this)); + }, + get value() { + return this.instance; + }, + }; +} diff --git a/src/services/stores/global/index.ts b/src/services/stores/global/index.ts new file mode 100644 index 00000000..7b1306c0 --- /dev/null +++ b/src/services/stores/global/index.ts @@ -0,0 +1,43 @@ +import { Group, Participant } from '../../../common/types/participant.types'; +import { Singleton } from '../common/types'; +import { CreateSingleton } from '../common/utils'; +import subject from '../subject'; + +const instance: Singleton = CreateSingleton(); + +export class GlobalStore { + public localParticipant = subject(null, true); + public participants = subject>(new Map()); + public group = subject(null); + + constructor() { + if (instance.value) { + throw new Error('CommentsStore is a singleton. There can only be one instance of it.'); + } + + instance.value = this; + } + + public destroy() { + this.localParticipant.destroy(); + instance.value = null; + } +} + +const store = new GlobalStore(); +const destroy = store.destroy.bind(store); + +const group = store.group.expose(); +const participants = store.participants.expose(); +const localParticipant = store.localParticipant.expose(); + +export function useGlobalStore() { + return { + localParticipant, + participants, + destroy, + group, + }; +} + +export type GlobalStoreReturnType = ReturnType; diff --git a/src/services/stores/index.ts b/src/services/stores/index.ts new file mode 100644 index 00000000..7b3e2335 --- /dev/null +++ b/src/services/stores/index.ts @@ -0,0 +1 @@ +export { useGlobalStore } from './global'; diff --git a/src/services/stores/subject/index.test.ts b/src/services/stores/subject/index.test.ts new file mode 100644 index 00000000..db09bfec --- /dev/null +++ b/src/services/stores/subject/index.test.ts @@ -0,0 +1,129 @@ +import subject, { Subject } from '.'; + +const testValues = { + str1: 'string-value-1', + str2: 'string-value-2', + num: 0, + obj: { + property: 'object-value', + }, + id: 'test-id', +}; + +class TestStore { + public property = subject(testValues.str1); +} + +const testStore = new TestStore(); +const { value } = testStore.property.expose(); + +describe('base subject for all stores', () => { + let instance: Subject; + + beforeEach(() => { + instance = subject(testValues.str1); + }); + + describe('should accept multiple types', () => { + test('should instantiate a string subject', () => { + const instance = subject(testValues.str1); + expect(instance.state).toBe(testValues.str1); + expect(typeof instance.state).toBe('string'); + }); + + test('should instantiate a number subject', () => { + const instance = subject(testValues.num); + expect(instance.state).toBe(testValues.num); + expect(typeof instance.state).toBe('number'); + }); + + test('should instantiate an object subject', () => { + const instance = subject(testValues.obj); + expect(instance.state).toBe(testValues.obj); + expect(typeof instance.state).toBe('object'); + }); + }); + + describe('getters & setters', () => { + test('should get the current value', () => { + instance = subject(testValues.str1); + expect(instance['getValue']()).toBe(testValues.str1); + }); + + test('should set a new value', () => { + instance = subject(testValues.str1); + instance['subject'].next = jest.fn(); + instance['setValue'](testValues.str2); + + expect(instance.state).toBe(testValues.str2); + expect(instance['subject'].next).toHaveBeenCalledWith(testValues.str2); + }); + }); + + describe('subscribe & unsubscribe', () => { + test('should expand subscriptions list and subscribe to subject', () => { + instance = subject(testValues.str1); + expect(instance['subscriptions'].size).toBe(0); + + const callback = jest.fn(); + instance['subject'].subscribe = jest.fn().mockImplementation(instance['subject'].subscribe); + + instance['subscribe'](testValues.id, callback); + + expect(instance['subscriptions'].size).toBe(1); + expect(instance['subscriptions'].get(testValues.id)).toBeDefined(); + expect(instance['subject'].subscribe).toHaveBeenCalledWith(callback); + }); + + test('should unsubscribe a subscription', () => { + instance = subject(testValues.str1); + + const callback = jest.fn(); + const unsubscribe = jest.fn(); + + instance['subscribe'](testValues.id, callback); + instance['subscriptions'].get(testValues.id)!.unsubscribe = unsubscribe; + + instance['unsubscribe'](testValues.id); + + expect(instance['subscriptions'].size).toBe(0); + expect(instance['subscriptions'].get(testValues.id)).toBeUndefined(); + expect(unsubscribe).toHaveBeenCalled(); + }); + }); + + describe('destroy', () => { + test('should unsubscribe all subscriptions', () => { + instance = subject(testValues.str1); + + const callback = jest.fn(); + const unsubscribe = jest.fn(); + + instance['subscribe'](testValues.id, callback); + instance['subscriptions'].get(testValues.id)!.unsubscribe = unsubscribe; + + instance['destroy'](); + + expect(instance['subscriptions'].size).toBe(0); + expect(instance['subscriptions'].get(testValues.id)).toBeUndefined(); + expect(unsubscribe).toHaveBeenCalled(); + }); + }); + + describe('expose', () => { + test('should return an object with the public API', () => { + instance = subject(testValues.str1); + + const publicSubject = instance['expose'](); + + expect(publicSubject.value).toBe(testValues.str1); + expect(typeof publicSubject.value).toBe('string'); + expect(publicSubject.subscribe).toBeInstanceOf(Function); + expect(publicSubject.unsubscribe).toBeInstanceOf(Function); + }); + }); + + describe('reactivity', () => { + test('should update the value when the subject is updated', () => {}); + }); +}); diff --git a/src/services/stores/subject/index.ts b/src/services/stores/subject/index.ts new file mode 100644 index 00000000..957f6fb8 --- /dev/null +++ b/src/services/stores/subject/index.ts @@ -0,0 +1,68 @@ +import { BehaviorSubject, distinctUntilChanged, shareReplay } from 'rxjs'; +import type { Subscription } from 'rxjs'; + +import { PublicSubject } from '../common/types'; + +export class Subject { + public state: T; + private subject: BehaviorSubject; + private subscriptions: Map = new Map(); + private showLog: boolean; + + constructor(state: T, subject: BehaviorSubject, showLog?: boolean) { + this.state = state; + this.showLog = !!showLog; + this.subject = subject.pipe( + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ) as BehaviorSubject; + } + + private getValue(): T { + return this.state; + } + + private setValue = (newValue: T): void => { + this.state = newValue; + this.subject.next(this.state); + }; + + public subscribe = (subscriptionId: string | this, callback: (value: T) => void) => { + const subscription = this.subject.subscribe(callback); + this.subscriptions.set(subscriptionId, subscription); + }; + + public unsubscribe(subscriptionId: string) { + this.subscriptions.get(subscriptionId)?.unsubscribe(); + this.subscriptions.delete(subscriptionId); + } + + public destroy() { + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + this.subscriptions.clear(); + } + + public expose(): PublicSubject { + const subscribe = this.subscribe.bind(this); + const unsubscribe = this.unsubscribe.bind(this); + const getter = this.getValue.bind(this); + const setter = this.setValue.bind(this); + + return { + get value() { + return getter(); + }, + set value(newValue: T) { + setter(newValue); + }, + subscribe, + unsubscribe, + }; + } +} + +export default function subject(initialState: T, showLog?: boolean): Subject { + const subject = new BehaviorSubject(initialState); + + return new Subject(initialState, subject, showLog); +} diff --git a/src/services/stores/who-is-online/index.ts b/src/services/stores/who-is-online/index.ts new file mode 100644 index 00000000..271cf65a --- /dev/null +++ b/src/services/stores/who-is-online/index.ts @@ -0,0 +1,81 @@ +import { Participant } from '../../../components/who-is-online/types'; +import { Singleton } from '../common/types'; +import { CreateSingleton } from '../common/utils'; +import subject from '../subject'; + +import { Following } from './types'; + +const instance: Singleton = CreateSingleton(); + +export class WhoIsOnlineStore { + public disablePresenceControls = subject(false); + public disableGoToParticipant = subject(false); + public disableFollowParticipant = subject(false); + public disablePrivateMode = subject(false); + public disableGatherAll = subject(false); + public disableFollowMe = subject(false); + + public participants = subject([]); + public extras = subject([]); + + public joinedPresence = subject(undefined); + public everyoneFollowsMe = subject(false); + public privateMode = subject(false); + + public following = subject(undefined); + + constructor() { + if (instance.value) { + throw new Error('WhoIsOnlineStore is a singleton. There can only be one instance of it.'); + } + + instance.value = this; + } + + public destroy() { + this.disablePresenceControls.destroy(); + instance.value = null; + } +} + +const store = new WhoIsOnlineStore(); +const destroy = store.destroy.bind(store) as () => void; + +const disablePresenceControls = store.disablePresenceControls.expose(); +const disableGoToParticipant = store.disableGoToParticipant.expose(); +const disableFollowParticipant = store.disableFollowParticipant.expose(); +const disablePrivateMode = store.disablePrivateMode.expose(); +const disableGatherAll = store.disableGatherAll.expose(); +const disableFollowMe = store.disableFollowMe.expose(); +const joinedPresence = store.joinedPresence.expose(); +const participants = store.participants.expose(); +const extras = store.extras.expose(); + +const everyoneFollowsMe = store.everyoneFollowsMe.expose(); +const privateMode = store.privateMode.expose(); + +const following = store.following.expose(); + +export function useWhoIsOnlineStore() { + return { + disablePresenceControls, + disableGoToParticipant, + disableFollowParticipant, + disablePrivateMode, + disableGatherAll, + disableFollowMe, + + participants, + extras, + + joinedPresence, + everyoneFollowsMe, + privateMode, + + following, + + destroy, + }; +} + +export type WhoIsOnlineStoreReturnType = ReturnType; diff --git a/src/services/stores/who-is-online/types.ts b/src/services/stores/who-is-online/types.ts new file mode 100644 index 00000000..10128f50 --- /dev/null +++ b/src/services/stores/who-is-online/types.ts @@ -0,0 +1,5 @@ +export interface Following { + id: string; + name: string; + color: string; +} diff --git a/src/web-components/base/index.ts b/src/web-components/base/index.ts index 9849bb5f..9b9b450a 100644 --- a/src/web-components/base/index.ts +++ b/src/web-components/base/index.ts @@ -1,5 +1,6 @@ import { LitElement } from 'lit'; +import { useStore } from '../../common/utils/use-store'; import config from '../../services/config'; import { variableStyle, typography, svHr, iconButtonStyle } from './styles'; @@ -7,6 +8,9 @@ import { Constructor, WebComponentsBaseInterface } from './types'; export const WebComponentsBase = >(superClass: T) => { class WebComponentsBaseClass extends superClass { + private unsubscribeFrom: Array<(id: unknown) => void> = []; + protected useStore = useStore.bind(this) as typeof useStore; + static styles = [ variableStyle, typography, @@ -33,6 +37,16 @@ export const WebComponentsBase = >(superClass: super.connectedCallback(); } + /** + * @function disconnectedCallback + * @description Unsubscribes from all the subjects + * @returns {void} + */ + public disconnectedCallback() { + super.disconnectedCallback(); + this.unsubscribeFrom.forEach((unsubscribe) => unsubscribe(this)); + } + /** * @function createCustomColors * @description Creates a custom style tag with the colors from the configuration diff --git a/src/web-components/base/types.ts b/src/web-components/base/types.ts index a1f14df5..8a6dfeca 100644 --- a/src/web-components/base/types.ts +++ b/src/web-components/base/types.ts @@ -1,5 +1,8 @@ +import { Store, StoreType } from '../../common/types/stores.types'; + export type Constructor = new (...args: any[]) => T; export interface WebComponentsBaseInterface { emitEvent(name: string, detail: object, configs?: object): unknown; + useStore(name: T): Store; } diff --git a/src/web-components/comments/comments.test.ts b/src/web-components/comments/comments.test.ts index dde42b77..487beeed 100644 --- a/src/web-components/comments/comments.test.ts +++ b/src/web-components/comments/comments.test.ts @@ -63,11 +63,6 @@ describe('comments', () => { expect(app?.classList.contains('superviz-comments')).toBe(true); }); - // FIXME: Need refactor should listen event toggle - test('should toggle superviz comments', async () => { - // ! WIP ! - }); - test('should update annotations', async () => { const annotations = [{ id: '1', x: 0, y: 0, width: 0, height: 0, text: 'test' }]; @@ -79,14 +74,14 @@ describe('comments', () => { }); test('should set filter', async () => { - const filter = 'test'; - const detail = { filter }; + const label = 'test'; + const detail = { filter: { label } }; element['setFilter']({ detail }); await sleep(); - expect(element['annotationFilter']).toEqual(filter); + expect(element['annotationFilter']).toEqual(label); }); test('should show water mark', async () => { diff --git a/src/web-components/comments/comments.ts b/src/web-components/comments/comments.ts index b30064a5..d5835194 100644 --- a/src/web-components/comments/comments.ts +++ b/src/web-components/comments/comments.ts @@ -84,8 +84,10 @@ export class Comments extends WebComponentsBaseElement { } private setFilter({ detail }) { - const { filter } = detail; - this.annotationFilter = filter; + const { + filter: { label }, + } = detail; + this.annotationFilter = label; } private getOffset(offset: number) { diff --git a/src/web-components/comments/components/annotation-filter.ts b/src/web-components/comments/components/annotation-filter.ts index 89a2232e..861afafc 100644 --- a/src/web-components/comments/components/annotation-filter.ts +++ b/src/web-components/comments/components/annotation-filter.ts @@ -4,6 +4,7 @@ import { classMap } from 'lit/directives/class-map.js'; import { WebComponentsBase } from '../../base'; import importStyle from '../../base/utils/importStyle'; +import { DropdownOption } from '../../dropdown/types'; import { annotationFilterStyle } from '../css'; import { AnnotationFilter } from './types'; @@ -11,14 +12,12 @@ import { AnnotationFilter } from './types'; const WebComponentsBaseElement = WebComponentsBase(LitElement); const styles: CSSResultGroup[] = [WebComponentsBaseElement.styles, annotationFilterStyle]; -const options = [ +const options: DropdownOption[] = [ { - label: 'All comments', - code: AnnotationFilter.ALL, + label: AnnotationFilter.ALL, }, { - label: 'Resolved comments', - code: AnnotationFilter.RESOLVED, + label: AnnotationFilter.RESOLVED, }, ]; @@ -48,20 +47,21 @@ export class CommentsAnnotationFilter extends WebComponentsBaseElement { }); } + private selectClick = () => { + this.caret = this.caret === 'down' ? 'up' : 'down'; + }; + + private dropdownOptionsHandler = ({ detail }: CustomEvent) => { + this.emitEvent('select', { filter: detail }); + this.selectClick(); + }; + protected render() { const selectedLabel = this.filter === AnnotationFilter.ALL ? options[0].label : options[1].label; - const dropdownOptionsHandler = ({ detail }: CustomEvent) => { - this.emitEvent('select', { filter: detail }); - selectClick(); - }; - - const selectClick = () => { - this.caret = this.caret === 'down' ? 'up' : 'down'; - }; - - const active = this.filter === AnnotationFilter.ALL ? options[0].code : options[1].code; + options[0].active = this.filter === AnnotationFilter.ALL; + options[1].active = this.filter === AnnotationFilter.RESOLVED; const textClasses = { text: true, @@ -77,14 +77,11 @@ export class CommentsAnnotationFilter extends WebComponentsBaseElement {
diff --git a/src/web-components/comments/components/annotation-item.ts b/src/web-components/comments/components/annotation-item.ts index f758e21d..a1ac6a1f 100644 --- a/src/web-components/comments/components/annotation-item.ts +++ b/src/web-components/comments/components/annotation-item.ts @@ -131,7 +131,7 @@ export class CommentsAnnotationItem extends WebComponentsBaseElement { } } - private selectAnnotation = () => { + private selectAnnotation = (event: PointerEvent): void => { const { uuid } = this.annotation; document.body.dispatchEvent(new CustomEvent('select-annotation', { detail: { uuid } })); }; diff --git a/src/web-components/comments/components/annotation-pin.test.ts b/src/web-components/comments/components/annotation-pin.test.ts index 0c7b491d..858f654d 100644 --- a/src/web-components/comments/components/annotation-pin.test.ts +++ b/src/web-components/comments/components/annotation-pin.test.ts @@ -1,5 +1,7 @@ import { MOCK_ANNOTATION } from '../../../../__mocks__/comments.mock'; +import { MOCK_LOCAL_PARTICIPANT } from '../../../../__mocks__/participants.mock'; import sleep from '../../../common/utils/sleep'; +import { useGlobalStore } from '../../../services/stores'; import type { CommentsAnnotationPin } from './annotation-pin'; import { PinMode } from './types'; @@ -40,6 +42,11 @@ function createAnnotationPin({ */ describe('annotation-pin', () => { + beforeEach(() => { + const { localParticipant } = useGlobalStore(); + localParticipant.value = MOCK_LOCAL_PARTICIPANT; + }); + afterEach(() => { const element = document.getElementsByTagName('superviz-comments-annotation-pin')[0]; diff --git a/src/web-components/comments/components/annotation-pin.ts b/src/web-components/comments/components/annotation-pin.ts index dddb3717..f17edaee 100644 --- a/src/web-components/comments/components/annotation-pin.ts +++ b/src/web-components/comments/components/annotation-pin.ts @@ -2,7 +2,8 @@ import { CSSResultGroup, LitElement, PropertyValueMap, html } from 'lit'; import { customElement } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { ParticipantByGroupApi } from '../../../common/types/participant.types'; +import { Participant, ParticipantByGroupApi } from '../../../common/types/participant.types'; +import { StoreType } from '../../../common/types/stores.types'; import { Annotation, PinCoordinates } from '../../../components/comments/types'; import { WebComponentsBase } from '../../base'; import importStyle from '../../base/utils/importStyle'; @@ -150,6 +151,12 @@ export class CommentsAnnotationPin extends WebComponentsBaseElement { if (this.type !== PinMode.ADD) return; + const { localParticipant } = this.useStore(StoreType.GLOBAL); + localParticipant.subscribe((participant: Participant) => { + this.localAvatar = participant?.avatar?.imageUrl; + this.localName = participant?.name; + }); + window.document.body.addEventListener( 'close-temporary-annotation', this.cancelTemporaryAnnotation, diff --git a/src/web-components/comments/components/comment-input.ts b/src/web-components/comments/components/comment-input.ts index 38284d60..0d4bc44a 100644 --- a/src/web-components/comments/components/comment-input.ts +++ b/src/web-components/comments/components/comment-input.ts @@ -10,6 +10,8 @@ import { commentInputStyle } from '../css'; import { AutoCompleteHandler } from '../utils/autocomplete-handler'; import mentionHandler from '../utils/mention-handler'; +import { CommentMode } from './types'; + const WebComponentsBaseElement = WebComponentsBase(LitElement); const styles: CSSResultGroup[] = [WebComponentsBaseElement.styles, commentInputStyle]; @@ -25,6 +27,7 @@ export class CommentsCommentInput extends WebComponentsBaseElement { declare mentions: CommentMention[]; declare participantsList: ParticipantByGroupApi[]; declare hideInput: boolean; + declare mode: CommentMode; private pinCoordinates: AnnotationPositionInfo | null = null; @@ -36,6 +39,7 @@ export class CommentsCommentInput extends WebComponentsBaseElement { this.text = ''; this.mentionList = []; this.mentions = []; + this.mode = CommentMode.READONLY; } static styles = styles; @@ -50,6 +54,7 @@ export class CommentsCommentInput extends WebComponentsBaseElement { mentionList: { type: Object }, participantsList: { type: Object }, hideInput: { type: Boolean }, + mode: { type: String }, }; private addAtSymbolInCaretPosition = () => { @@ -139,6 +144,13 @@ export class CommentsCommentInput extends WebComponentsBaseElement { } updated(changedProperties: Map) { + if (changedProperties.has('mode') && this.mode === CommentMode.EDITABLE) { + this.focusInput() + this.updateHeight(); + this.sendBtn.disabled = false; + this.btnActive = true; + } + if (changedProperties.has('text') && this.text.length > 0) { const commentsInput = this.commentInput; commentsInput.value = this.text; @@ -381,7 +393,7 @@ export class CommentsCommentInput extends WebComponentsBaseElement { event.stopPropagation()} + @click=${(event: Event) => { + event.stopPropagation(); + }} classesPrefix="comments__dropdown" parentComponent="comments" > diff --git a/src/web-components/comments/components/types.ts b/src/web-components/comments/components/types.ts index 5e22b741..8fbd640e 100644 --- a/src/web-components/comments/components/types.ts +++ b/src/web-components/comments/components/types.ts @@ -19,8 +19,8 @@ export enum PinMode { } export enum AnnotationFilter { - ALL = 'all', - RESOLVED = 'resolved', + ALL = 'All comments', + RESOLVED = 'Resolved comments', } export type HorizontalSide = 'left' | 'right'; diff --git a/src/web-components/comments/css/annotation-item.style.ts b/src/web-components/comments/css/annotation-item.style.ts index ff70d61a..fecb34c2 100644 --- a/src/web-components/comments/css/annotation-item.style.ts +++ b/src/web-components/comments/css/annotation-item.style.ts @@ -82,6 +82,7 @@ export const annotationItemStyle = css` .hidden { overflow: hidden; + opacity: 0; } .comments__thread { @@ -111,7 +112,6 @@ export const annotationItemStyle = css` grid-template-rows: 0fr; opacity: 0; width: 100%; - overflow: hidden; transition: grid-template-rows 0.3s linear, opacity 0.3s linear; } diff --git a/src/web-components/comments/css/comment-item.style.ts b/src/web-components/comments/css/comment-item.style.ts index d0a71afc..6537cf53 100644 --- a/src/web-components/comments/css/comment-item.style.ts +++ b/src/web-components/comments/css/comment-item.style.ts @@ -66,6 +66,7 @@ export const commentItemStyle = css` .comments__comment-item__content { width: 100%; + word-wrap: break-word; } .line-clamp { diff --git a/src/web-components/dropdown/index.test.ts b/src/web-components/dropdown/index.test.ts index 4e24ab52..d3a6267b 100644 --- a/src/web-components/dropdown/index.test.ts +++ b/src/web-components/dropdown/index.test.ts @@ -2,26 +2,26 @@ import '.'; import sleep from '../../common/utils/sleep'; +import { DropdownOption } from './types'; + interface elementProps { position: string; align: string; label?: string; - returnTo?: string; - options?: Record; + options?: DropdownOption[]; name?: string; - icons?: string[]; showTooltip?: boolean; + returnData?: any; } export const createEl = ({ position, align, label, - returnTo, options, name, - icons, showTooltip, + returnData, }: elementProps): HTMLElement => { const element: HTMLElement = document.createElement('superviz-dropdown'); @@ -29,28 +29,24 @@ export const createEl = ({ element.setAttribute('label', label); } - if (returnTo) { - element.setAttribute('returnTo', returnTo); - } - if (options) { element.setAttribute('options', JSON.stringify(options)); } - if (name) { - element.setAttribute('name', name); + if (returnData) { + element.setAttribute('returnData', JSON.stringify(returnData)); } - if (icons) { - element.setAttribute('icons', JSON.stringify(icons)); + if (name) { + element.setAttribute('name', name); } if (showTooltip) { - const onHoverData = { + const tooltipData = { name: 'onHover', action: 'Click to see more', }; - element.setAttribute('onHoverData', JSON.stringify(onHoverData)); + element.setAttribute('tooltipData', JSON.stringify(tooltipData)); element.setAttribute('canShowTooltip', 'true'); } @@ -137,7 +133,7 @@ describe('dropdown', () => { }); test('should close dropdown when click on it', async () => { - const el = createEl({ position: 'bottom-left', align: 'left', icons: ['left', 'right'] }); + const el = createEl({ position: 'bottom-left', align: 'left' }); await sleep(); dropdownContent()?.click(); @@ -213,8 +209,8 @@ describe('dropdown', () => { expect(spy).toHaveBeenCalled(); }); - test('should emit event with all content when returnTo not specified', async () => { - createEl({ position: 'bottom-right', align: 'left' }); + test('should emit event with returnData when returnData specified', async () => { + createEl({ position: 'bottom-right', align: 'left', returnData: { value: 1 } }); await sleep(); @@ -235,10 +231,7 @@ describe('dropdown', () => { expect(spy).toHaveBeenCalledWith( expect.objectContaining({ detail: { - name: 'EDIT', - value: { - uuid: 'any_uuid', - }, + value: 1, }, }), ); @@ -273,7 +266,14 @@ describe('dropdown', () => { }); test('should show icons if icons is specified', async () => { - createEl({ position: 'bottom-right', align: 'left', icons: ['left', 'right'] }); + createEl({ + position: 'bottom-right', + align: 'left', + options: [ + { label: 'left', icon: 'left' }, + { label: 'right', icon: 'right' }, + ], + }); await sleep(); @@ -282,36 +282,6 @@ describe('dropdown', () => { expect(icons).not.toBeNull(); }); - test('should emit event with returnTo content when returnTo specified', async () => { - createEl({ position: 'bottom-right', align: 'left', label: 'name', returnTo: 'value' }); - - await sleep(); - - element()!['emitEvent'] = jest.fn(); - - dropdownContent()?.click(); - - await sleep(); - - const option = dropdownListUL()?.querySelector('li'); - - option?.click(); - - await sleep(); - - expect(element()!['emitEvent']).toHaveBeenNthCalledWith( - 3, - 'selected', - { - uuid: 'any_uuid', - }, - { - bubbles: false, - composed: true, - }, - ); - }); - describe('tooltip', () => { test('should render tooltip if can show it', async () => { createEl({ position: 'bottom-right', align: 'left', showTooltip: true }); diff --git a/src/web-components/dropdown/index.ts b/src/web-components/dropdown/index.ts index 8a7c3808..4189b1d1 100644 --- a/src/web-components/dropdown/index.ts +++ b/src/web-components/dropdown/index.ts @@ -6,6 +6,7 @@ import { WebComponentsBase } from '../base'; import importStyle from '../base/utils/importStyle'; import { dropdownStyle } from './index.style'; +import { DropdownOption } from './types'; const WebComponentsBaseElement = WebComponentsBase(LitElement); const styles: CSSResultGroup[] = [WebComponentsBaseElement.styles, dropdownStyle]; @@ -17,19 +18,19 @@ export class Dropdown extends WebComponentsBaseElement { declare open: boolean; declare disabled: boolean; declare align: 'left' | 'right'; - declare options: object[]; - declare label: string; - declare returnTo: string; - declare active: string | object; + declare options: DropdownOption[]; + declare returnData: { [k: string]: any }; + declare icons?: string[]; declare name?: string; - declare onHoverData: { name: string; action: string }; + declare tooltipData: { name: string; action: string }; declare shiftTooltipLeft: boolean; declare lastParticipant: boolean; declare classesPrefix: string; declare parentComponent: string; declare tooltipPrefix: string; declare dropdown: HTMLElement; + // true when the dropdown is hovered (pass to tooltip element) declare showTooltip: boolean; // true if the tooltip should be shown when hovering (use in this element) @@ -43,12 +44,9 @@ export class Dropdown extends WebComponentsBaseElement { disabled: { type: Boolean }, align: { type: String }, options: { type: Array }, - label: { type: String }, - returnTo: { type: String }, - active: { type: [String, Object] }, icons: { type: Array }, name: { type: String }, - onHoverData: { type: Object }, + tooltipData: { type: Object }, showTooltip: { type: Boolean }, dropdown: { type: Object }, canShowTooltip: { type: Boolean }, @@ -58,11 +56,13 @@ export class Dropdown extends WebComponentsBaseElement { classesPrefix: { type: String }, parentComponent: { type: String }, tooltipPrefix: { type: String }, + returnData: { type: Object }, }; constructor() { super(); this.showTooltip = false; + this.returnData = {}; } protected firstUpdated( @@ -132,15 +132,17 @@ export class Dropdown extends WebComponentsBaseElement { }); }; - private callbackSelected = (option) => { + private callbackSelected = ({ label }: DropdownOption) => { this.open = false; - const returnTo = this.returnTo ? option[this.returnTo] : option; - - this.emitEvent('selected', returnTo, { - bubbles: false, - composed: true, - }); + this.emitEvent( + 'selected', + { ...this.returnData, label }, + { + bubbles: false, + composed: true, + }, + ); }; private setHorizontalPosition() { @@ -223,24 +225,31 @@ export class Dropdown extends WebComponentsBaseElement { this.emitEvent('open', { open: this.open }); } - private get supervizIcons() { - return this.icons?.map((icon) => { - return html``; - }); + private getIcon(icon: string) { + if (!icon) return; + + return html` + + + + `; + } + + private getLabel(option: string) { + return html`${option}`; } private get listOptions() { - return this.options.map((option, index) => { + return this.options.map(({ label, icon, active }) => { const liClasses = { text: true, [this.getClass('item')]: true, 'text-bold': true, - active: option?.[this.returnTo] && this.active === option?.[this.returnTo], + active, }; - return html`
  • this.callbackSelected(option)} class=${classMap(liClasses)}> - ${this.supervizIcons?.at(index)} - ${option[this.label]} + return html`
  • this.callbackSelected({ label })} class=${classMap(liClasses)}> + ${this.getIcon(icon)} ${this.getLabel(label)}
  • `; }); } @@ -251,7 +260,7 @@ export class Dropdown extends WebComponentsBaseElement { const tooltipVerticalPosition = this.lastParticipant ? 'tooltip-top' : 'tooltip-bottom'; return html` { test('should render tooltip with the correct data', async () => { const element = createEl({ - tooltipData: { name: 'test name', action: 'test action' }, + tooltipData: { name: 'test name', info: 'test action' }, }); await sleep(); diff --git a/src/web-components/tooltip/index.ts b/src/web-components/tooltip/index.ts index a8e061f7..a4b9b896 100644 --- a/src/web-components/tooltip/index.ts +++ b/src/web-components/tooltip/index.ts @@ -15,7 +15,7 @@ const styles: CSSResultGroup[] = [WebComponentsBaseElement.styles, dropdownStyle export class Tooltip extends WebComponentsBaseElement { static styles = styles; - declare tooltipData: { name: string; action: string }; + declare tooltipData: { name: string; info: string }; declare tooltip: HTMLElement; declare tooltipOnLeft: boolean; @@ -255,8 +255,8 @@ export class Tooltip extends WebComponentsBaseElement { ?.width}px;" >

    ${this.tooltipData?.name}

    - ${this.tooltipData?.action - ? html`

    ${this.tooltipData?.action}

    ` + ${this.tooltipData?.info + ? html`

    ${this.tooltipData?.info}

    ` : ''}
    `; 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 1d8bdd26..9139aa68 100644 --- a/src/web-components/who-is-online/components/dropdown.test.ts +++ b/src/web-components/who-is-online/components/dropdown.test.ts @@ -1,11 +1,12 @@ import '.'; import { MeetingColorsHex } from '../../../common/types/meeting-colors.types'; +import { StoreType } from '../../../common/types/stores.types'; import sleep from '../../../common/utils/sleep'; -import { Participant } from '../../../components/who-is-online/types'; +import { useStore } from '../../../common/utils/use-store'; +import { Participant, WIODropdownOptions } from '../../../components/who-is-online/types'; interface elementProps { position: string; - participants?: Participant[]; label?: string; returnTo?: string; options?: any; @@ -13,27 +14,82 @@ interface elementProps { icons?: string[]; } -const mockParticipants: Participant[] = [ +const MOCK_PARTICIPANTS: Participant[] = [ { + name: 'John Zero', avatar: { - imageUrl: '', - model3DUrl: '', + imageUrl: 'https://example.com', + color: MeetingColorsHex[0], + firstLetter: 'J', + slotIndex: 0, }, - color: MeetingColorsHex[0], id: '1', - name: 'John Zero', - slotIndex: 0, + activeComponents: ['whoisonline', 'presence'], + isLocalParticipant: true, + tooltip: { + name: 'John Zero (you)', + }, + controls: [ + { + label: WIODropdownOptions.GATHER, + }, + { + label: WIODropdownOptions.FOLLOW, + }, + { + label: WIODropdownOptions.PRIVATE, + }, + ], + }, + { + name: 'John Uno', + avatar: { + imageUrl: '', + color: MeetingColorsHex[1], + firstLetter: 'J', + slotIndex: 1, + }, + id: '2', + activeComponents: ['whoisonline'], + isLocalParticipant: false, + tooltip: { + name: 'John Uno', + }, + controls: [ + { + label: WIODropdownOptions.GOTO, + }, + { + label: WIODropdownOptions.LOCAL_FOLLOW, + }, + ], + }, + { + name: 'John Doe', + avatar: { + imageUrl: '', + color: MeetingColorsHex[2], + firstLetter: 'J', + slotIndex: 2, + }, + id: '3', + activeComponents: ['whoisonline', 'presence'], + isLocalParticipant: true, + tooltip: { + name: 'John Doe', + }, + controls: [ + { + label: WIODropdownOptions.GOTO, + }, + { + label: WIODropdownOptions.LOCAL_FOLLOW, + }, + ], }, ]; -const createEl = ({ - position, - label, - returnTo, - name, - icons, - participants, -}: elementProps): HTMLElement => { +const createEl = ({ position, label, returnTo, name, icons }: elementProps): HTMLElement => { const element: HTMLElement = document.createElement('superviz-who-is-online-dropdown'); /* eslint-disable no-unused-expressions */ @@ -41,7 +97,6 @@ const createEl = ({ returnTo && element.setAttribute('returnTo', returnTo); name && element.setAttribute('name', name); icons && element.setAttribute('icons', JSON.stringify(icons)); - !!participants?.length && element.setAttribute('participants', JSON.stringify(participants)); /* eslint-enable no-unused-expressions */ element.setAttribute('position', position); @@ -91,14 +146,19 @@ describe('who-is-online-dropdown', () => { }); test('should render dropdown', () => { - createEl({ position: 'bottom', participants: mockParticipants }); + createEl({ position: 'bottom' }); + const { extras } = useStore(StoreType.WHO_IS_ONLINE); + extras.publish(MOCK_PARTICIPANTS); + const element = document.querySelector('superviz-who-is-online-dropdown'); expect(element).not.toBeNull(); }); test('should open dropdown when click on it', async () => { - createEl({ position: 'bottom', participants: mockParticipants }); + createEl({ position: 'bottom' }); + const { extras } = useStore(StoreType.WHO_IS_ONLINE); + extras.publish(MOCK_PARTICIPANTS); await sleep(); @@ -110,7 +170,9 @@ describe('who-is-online-dropdown', () => { }); test('should close dropdown when click on it', async () => { - createEl({ position: 'bottom', participants: mockParticipants }); + createEl({ position: 'bottom' }); + const { extras } = useStore(StoreType.WHO_IS_ONLINE); + extras.publish(MOCK_PARTICIPANTS); await sleep(); dropdownContent()?.click(); @@ -130,7 +192,9 @@ describe('who-is-online-dropdown', () => { }); test('should open another dropdown when click on participant', async () => { - createEl({ position: 'bottom', participants: mockParticipants }); + createEl({ position: 'bottom' }); + const { extras } = useStore(StoreType.WHO_IS_ONLINE); + extras.publish(MOCK_PARTICIPANTS); await sleep(); @@ -151,7 +215,9 @@ describe('who-is-online-dropdown', () => { }); test('should listen click event when click out', async () => { - createEl({ position: 'bottom', participants: mockParticipants }); + createEl({ position: 'bottom' }); + const { extras } = useStore(StoreType.WHO_IS_ONLINE); + extras.publish(MOCK_PARTICIPANTS); await sleep(); @@ -171,13 +237,15 @@ describe('who-is-online-dropdown', () => { }); test('should give a black color to the letter when the slotIndex is not in the textColorValues', async () => { - createEl({ position: 'bottom', participants: mockParticipants }); + 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[mockParticipants[0].slotIndex]; + const backgroundColor = MeetingColorsHex[MOCK_PARTICIPANTS[2].avatar.slotIndex as number]; expect(letter?.getAttribute('style')).toBe( `background-color: ${backgroundColor}; color: #26242A`, ); @@ -185,18 +253,19 @@ describe('who-is-online-dropdown', () => { test('should give a white color to the letter when the slotIndex is in the textColorValues', async () => { const participant = { - ...mockParticipants[0], + ...MOCK_PARTICIPANTS[0], slotIndex: 1, color: MeetingColorsHex[1], }; - createEl({ position: 'bottom', participants: [participant] }); - + 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[1]; + const backgroundColor = MeetingColorsHex[MOCK_PARTICIPANTS[1].avatar.slotIndex as number]; expect(letter?.getAttribute('style')).toBe( `background-color: ${backgroundColor}; color: #FFFFFF`, ); @@ -204,13 +273,17 @@ describe('who-is-online-dropdown', () => { test('should not render participants when there is no participant', async () => { createEl({ position: 'bottom' }); + const { extras } = useStore(StoreType.WHO_IS_ONLINE); + extras.publish([]); await sleep(); expect(dropdownMenu()?.children?.length).toBe(0); }); test('should render participants when there is participant', async () => { - createEl({ position: 'bottom', participants: mockParticipants }); + createEl({ position: 'bottom' }); + const { extras } = useStore(StoreType.WHO_IS_ONLINE); + extras.publish([MOCK_PARTICIPANTS[0]]); await sleep(); @@ -218,7 +291,27 @@ describe('who-is-online-dropdown', () => { }); test('should change selected participant when click on it', async () => { - createEl({ position: 'bottom', participants: mockParticipants }); + createEl({ position: 'bottom' }); + + createEl({ position: 'bottom' }); + const { extras } = useStore(StoreType.WHO_IS_ONLINE); + extras.publish([ + { + avatar: { + imageUrl: '', + color: 'red', + firstLetter: 'J', + slotIndex: 0, + }, + id: '1', + name: 'John Zero', + activeComponents: ['whoisonline', 'presence'], + isLocalParticipant: false, + tooltip: { + name: 'John', + }, + }, + ]); await sleep(); @@ -230,12 +323,38 @@ describe('who-is-online-dropdown', () => { await sleep(); - expect(element()?.['selected']).toBe(mockParticipants[0].id); + expect(element()?.['selected']).toBe(MOCK_PARTICIPANTS[0].id); + }); + + test('should not change selected participant when click on it if disableDropdown is true', async () => { + createEl({ position: 'bottom' }); + const { extras } = useStore(StoreType.WHO_IS_ONLINE); + extras.publish([ + { + ...MOCK_PARTICIPANTS[0], + disableDropdown: true, + }, + ]); + + await sleep(); + + const participant = element()?.shadowRoot?.querySelector( + '.who-is-online__extra-participant', + ) as HTMLElement; + + participant.click(); + + await sleep(); + + expect(element()?.['selected']).not.toBe(MOCK_PARTICIPANTS[0].id); }); describe('repositionDropdown', () => { test('should call reposition methods if is open', () => { - const el = createEl({ position: 'bottom', participants: mockParticipants }); + const el = createEl({ position: 'bottom' }); + const { extras } = useStore(StoreType.WHO_IS_ONLINE); + extras.publish(MOCK_PARTICIPANTS); + el['open'] = true; el['repositionInVerticalDirection'] = jest.fn(); @@ -248,7 +367,10 @@ describe('who-is-online-dropdown', () => { }); test('should do nothing if is not open', () => { - const el = createEl({ position: 'bottom', participants: mockParticipants }); + const el = createEl({ position: 'bottom' }); + const { extras } = useStore(StoreType.WHO_IS_ONLINE); + extras.publish(MOCK_PARTICIPANTS); + el['open'] = false; el['repositionInVerticalDirection'] = jest.fn(); @@ -261,22 +383,6 @@ describe('who-is-online-dropdown', () => { }); }); - /** - * private repositionInVerticalDirection = () => { - const { bottom, top, height } = this.parentElement.getBoundingClientRect(); - const windowVerticalMidpoint = window.innerHeight / 2; - const dropdownVerticalMidpoint = top + height / 2; - - if (dropdownVerticalMidpoint > windowVerticalMidpoint) { - this.dropdownList.style.setProperty('bottom', `${window.innerHeight - top + 8}px`); - this.dropdownList.style.setProperty('top', ''); - return; - } - - this.dropdownList.style.setProperty('top', `${bottom + 8}px`); - this.dropdownList.style.setProperty('bottom', ''); - }; - */ describe('repositionInVerticalDirection', () => { beforeEach(() => { document.body.innerHTML = ''; @@ -284,7 +390,9 @@ describe('who-is-online-dropdown', () => { }); test('should set bottom and top styles when dropdownVerticalMidpoint is greater than windowVerticalMidpoint', async () => { - const el = createEl({ position: 'bottom', participants: mockParticipants }); + const el = createEl({ position: 'bottom' }); + const { extras } = useStore(StoreType.WHO_IS_ONLINE); + extras.publish(MOCK_PARTICIPANTS); await sleep(); @@ -305,7 +413,9 @@ describe('who-is-online-dropdown', () => { }); test('should set top and bottom styles when dropdownVerticalMidpoint is less than windowVerticalMidpoint', async () => { - const el = createEl({ position: 'bottom', participants: mockParticipants }); + const el = createEl({ position: 'bottom' }); + const { extras } = useStore(StoreType.WHO_IS_ONLINE); + extras.publish(MOCK_PARTICIPANTS); await sleep(); diff --git a/src/web-components/who-is-online/components/dropdown.ts b/src/web-components/who-is-online/components/dropdown.ts index 2f1789e3..74cecc26 100644 --- a/src/web-components/who-is-online/components/dropdown.ts +++ b/src/web-components/who-is-online/components/dropdown.ts @@ -1,15 +1,17 @@ +// @ts-nocheck import { CSSResultGroup, LitElement, PropertyValueMap, html } from 'lit'; 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 { Participant } from '../../../components/who-is-online/types'; +import { StoreType } from '../../../common/types/stores.types'; +import { Avatar, Participant } from '../../../components/who-is-online/types'; import { WebComponentsBase } from '../../base'; import importStyle from '../../base/utils/importStyle'; import { dropdownStyle } from '../css'; -import { Following, WIODropdownOptions, TooltipData, VerticalSide, HorizontalSide } from './types'; +import { Following, VerticalSide, HorizontalSide } from './types'; const WebComponentsBaseElement = WebComponentsBase(LitElement); const styles: CSSResultGroup[] = [WebComponentsBaseElement.styles, dropdownStyle]; @@ -21,25 +23,22 @@ export class WhoIsOnlineDropdown extends WebComponentsBaseElement { declare open: boolean; declare align: HorizontalSide; declare position: VerticalSide; - declare participants: Participant[]; declare selected: string; declare disableDropdown: boolean; declare showSeeMoreTooltip: boolean; declare showParticipantTooltip: boolean; - declare following: Following; declare localParticipantJoinedPresence: boolean; declare dropdownList: HTMLElement; + private participants: Participant[]; private animationFrame: number; static properties = { open: { type: Boolean }, align: { type: String }, position: { type: String }, - participants: { type: Array }, selected: { type: String }, disableDropdown: { type: Boolean }, - following: { type: Object }, showSeeMoreTooltip: { type: Boolean }, showParticipantTooltip: { type: Boolean }, localParticipantJoinedPresence: { type: Boolean }, @@ -48,9 +47,13 @@ export class WhoIsOnlineDropdown extends WebComponentsBaseElement { constructor() { super(); - // should match presence-mouse textColorValues this.selected = ''; this.showParticipantTooltip = true; + + const { extras } = this.useStore(StoreType.WHO_IS_ONLINE); + extras.subscribe((participants) => { + this.participants = participants; + }); } protected firstUpdated( @@ -104,28 +107,30 @@ export class WhoIsOnlineDropdown extends WebComponentsBaseElement { }); }; - private selectParticipant = (participantId: string) => { + private selectParticipant = (participantId: string, disableDropdown: boolean) => { return () => { + if (disableDropdown) return; + this.selected = participantId; }; }; - private getAvatar(participant: Participant) { - if (participant.avatar?.imageUrl) { + private getAvatar({ color, imageUrl, firstLetter, slotIndex }: Avatar) { + if (imageUrl) { return html` `; } - const letterColor = INDEX_IS_WHITE_TEXT.includes(participant.slotIndex) ? '#FFFFFF' : '#26242A'; + const letterColor = INDEX_IS_WHITE_TEXT.includes(slotIndex) ? '#FFFFFF' : '#26242A'; return html`
    - ${participant.name?.at(0).toUpperCase()} + ${firstLetter}
    `; } @@ -140,22 +145,18 @@ export class WhoIsOnlineDropdown extends WebComponentsBaseElement { private renderParticipants() { if (!this.participants) return; - const icons = ['place', 'send']; const numberOfParticipants = this.participants.length - 1; return repeat( this.participants, (participant) => participant.id, (participant, index) => { - const { id, slotIndex, joinedPresence, isLocal, color, name } = participant; - - const disableDropdown = !joinedPresence || isLocal || this.disableDropdown; + const { disableDropdown, id, avatar, controls, tooltip, name } = participant; const contentClasses = { 'who-is-online__extra-participant': true, 'who-is-online__extra-participant--selected': this.selected === id, 'disable-dropdown': disableDropdown, - followed: this.following?.id === id, }; const iconClasses = { @@ -163,51 +164,30 @@ export class WhoIsOnlineDropdown extends WebComponentsBaseElement { 'hide-icon': disableDropdown, }; - const participantIsFollowed = this.following?.id === id; - - const options = Object.values(WIODropdownOptions) - .map((label, index) => ({ - label: participantIsFollowed && index ? 'UNFOLLOW' : label, - id, - name, - color, - slotIndex, - })) - .slice(0, 2); - - const tooltipData: TooltipData = { - name, - }; - - if (this.localParticipantJoinedPresence && joinedPresence) { - tooltipData.action = 'Click to Follow'; - } - const isLastParticipant = index === numberOfParticipants; return html`
    + @click=${this.selectParticipant(id, disableDropdown)} slot="dropdown">
    - ${this.getAvatar(participant)} + ${avatar.color}"> + ${this.getAvatar(avatar)}
    ${name} { beforeEach(() => { document.body.innerHTML = ''; + const { localParticipant } = useGlobalStore(); + localParticipant.value = MOCK_LOCAL_PARTICIPANT; + element = createEl(); }); diff --git a/src/web-components/who-is-online/components/messages.ts b/src/web-components/who-is-online/components/messages.ts index d2c87c86..f54c8d7a 100644 --- a/src/web-components/who-is-online/components/messages.ts +++ b/src/web-components/who-is-online/components/messages.ts @@ -2,11 +2,14 @@ import { CSSResultGroup, LitElement, PropertyDeclaration, PropertyValueMap, html import { customElement } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; +import { Participant } from '../../../common/types/participant.types'; +import { StoreType } from '../../../common/types/stores.types'; +import { Following } from '../../../services/stores/who-is-online/types'; import { WebComponentsBase } from '../../base'; import importStyle from '../../base/utils/importStyle'; import { messagesStyle } from '../css'; -import { HorizontalSide, Following, VerticalSide } from './types'; +import { HorizontalSide, VerticalSide } from './types'; const WebComponentsBaseElement = WebComponentsBase(LitElement); const styles: CSSResultGroup[] = [WebComponentsBaseElement.styles, messagesStyle]; @@ -15,24 +18,33 @@ const styles: CSSResultGroup[] = [WebComponentsBaseElement.styles, messagesStyle export class WhoIsOnlineMessages extends WebComponentsBaseElement { static styles = styles; - declare following: Following | undefined; declare everyoneFollowsMe: boolean; declare isPrivate: boolean; - declare participantColor: string; declare verticalSide: VerticalSide; declare horizontalSide: HorizontalSide; + private following: Following | undefined; + private participantColor: string; private animationFrame: number | undefined; static properties = { - following: { type: Object }, everyoneFollowsMe: { type: Boolean }, isPrivate: { type: Boolean }, - participantColor: { type: String }, verticalSide: { type: String }, horizontalSide: { type: String }, }; + constructor() { + super(); + const { localParticipant } = this.useStore(StoreType.GLOBAL); + localParticipant.subscribe((participant: Participant) => { + this.participantColor = participant.color; + }); + + const { following } = this.useStore(StoreType.WHO_IS_ONLINE); + following.subscribe(); + } + protected firstUpdated( _changedProperties: PropertyValueMap | Map, ): void { diff --git a/src/web-components/who-is-online/components/types.ts b/src/web-components/who-is-online/components/types.ts index c4f90b29..dea427e9 100644 --- a/src/web-components/who-is-online/components/types.ts +++ b/src/web-components/who-is-online/components/types.ts @@ -1,21 +1,3 @@ -export enum WIODropdownOptions { - GOTO = 'go to', - LOCAL_FOLLOW = 'follow', - LOCAL_UNFOLLOW = 'unfollow', - FOLLOW = 'everyone follows me', - UNFOLLOW = 'stop followers', - PRIVATE = 'private mode', - LEAVE_PRIVATE = 'leave private mode', - GATHER = 'gather all', - STOP_GATHER = 'stop gather all', -} - -export interface Following { - id: string; - name: string; - color: string; -} - export interface Options { label: string; id: string; @@ -26,15 +8,9 @@ export interface Options { export interface LocalParticipantData { id: string; - color: string; joinedPresence: boolean; } -export interface TooltipData { - name: string; - action?: string; -} - export enum VerticalSide { TOP = 'top-side', BOTTOM = 'bottom-side', diff --git a/src/web-components/who-is-online/css/dropdown.style.ts b/src/web-components/who-is-online/css/dropdown.style.ts index 8a398d55..9ae006aa 100644 --- a/src/web-components/who-is-online/css/dropdown.style.ts +++ b/src/web-components/who-is-online/css/dropdown.style.ts @@ -84,6 +84,11 @@ export const dropdownStyle = css` overflow: auto; } + .who-is-online__extras-dropdown superviz-dropdown:hover { + z-index: 999; + position: relative; + } + .who-is-online__extras__arrow-icon { display: flex; align-items: center; 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 ff5d59e1..55975087 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 @@ -1,14 +1,14 @@ -import { html } from 'lit'; - import '.'; -import { MOCK_ABLY_PARTICIPANT_DATA_1 } from '../../../__mocks__/participants.mock'; + +import { MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; import { RealtimeEvent } from '../../common/types/events.types'; import { MeetingColorsHex } from '../../common/types/meeting-colors.types'; +import { StoreType } from '../../common/types/stores.types'; import sleep from '../../common/utils/sleep'; -import { Participant } from '../../components/who-is-online/types'; -import { Dropdown } from '../dropdown/index'; - -import { WIODropdownOptions } from './components/types'; +import { useStore } from '../../common/utils/use-store'; +import { Participant, WIODropdownOptions } from '../../components/who-is-online/types'; +import { useGlobalStore } from '../../services/stores'; +import { Following } from '../../services/stores/who-is-online/types'; let element: HTMLElement; @@ -16,39 +16,84 @@ const MOCK_PARTICIPANTS: Participant[] = [ { name: 'John Zero', avatar: { - imageUrl: '', - model3DUrl: '', + imageUrl: 'https://example.com', + color: MeetingColorsHex[0], + firstLetter: 'J', + slotIndex: 0, }, - color: MeetingColorsHex[0], id: '1', - slotIndex: 0, - joinedPresence: true, + activeComponents: ['whoisonline', 'presence'], + isLocalParticipant: true, + tooltip: { + name: 'John Zero (you)', + }, + controls: [ + { + label: WIODropdownOptions.GATHER, + }, + { + label: WIODropdownOptions.FOLLOW, + }, + { + label: WIODropdownOptions.PRIVATE, + }, + ], }, { name: 'John Uno', avatar: { - imageUrl: 'https://link.com/image', - model3DUrl: '', + imageUrl: '', + color: MeetingColorsHex[1], + firstLetter: 'J', + slotIndex: 1, }, - color: MeetingColorsHex[1], id: '2', - slotIndex: 1, - isLocal: true, + activeComponents: ['whoisonline'], + isLocalParticipant: false, + tooltip: { + name: 'John Uno', + }, + controls: [ + { + label: WIODropdownOptions.GOTO, + }, + { + label: WIODropdownOptions.LOCAL_FOLLOW, + }, + ], }, { name: 'John Doe', avatar: { imageUrl: '', - model3DUrl: '', + color: MeetingColorsHex[2], + firstLetter: 'J', + slotIndex: 2, }, - color: MeetingColorsHex[2], id: '3', - slotIndex: 2, + activeComponents: ['whoisonline', 'presence'], + isLocalParticipant: true, + tooltip: { + name: 'John Doe', + }, + controls: [ + { + label: WIODropdownOptions.GOTO, + }, + { + label: WIODropdownOptions.LOCAL_FOLLOW, + }, + ], }, ]; describe('Who Is Online', () => { beforeEach(async () => { + const { localParticipant } = useGlobalStore(); + localParticipant.value = MOCK_LOCAL_PARTICIPANT; + const { participants } = useStore(StoreType.WHO_IS_ONLINE); + participants.publish([]); + element = document.createElement('superviz-who-is-online'); element['localParticipantData'] = { color: '#fff', @@ -64,10 +109,10 @@ describe('Who Is Online', () => { }); test('should render a participants with class "who-is-online__participant-list"', async () => { - element['updateParticipants'](MOCK_PARTICIPANTS); - await sleep(); - const participants = element?.shadowRoot?.querySelector('.who-is-online__participant-list'); - expect(participants).not.toBeFalsy(); + const { participants } = useStore(StoreType.WHO_IS_ONLINE); + participants.publish(MOCK_PARTICIPANTS); + const participantsDivs = element?.shadowRoot?.querySelector('.who-is-online__participant-list'); + expect(participantsDivs).not.toBeFalsy(); }); test('should have default positioning style', () => { @@ -102,33 +147,36 @@ describe('Who Is Online', () => { }); test('should update participants list', async () => { - let participants = element?.shadowRoot?.querySelectorAll('.who-is-online__participant'); + const { participants } = useStore(StoreType.WHO_IS_ONLINE); + let participantsDivs = element?.shadowRoot?.querySelectorAll('.who-is-online__participant'); + expect(participantsDivs?.length).toBe(0); - expect(participants?.length).toBe(0); + participants.publish(MOCK_PARTICIPANTS); - element['updateParticipants'](MOCK_PARTICIPANTS); await sleep(); - participants = element.shadowRoot?.querySelectorAll('.who-is-online__participant'); - expect(participants?.length).toBe(3); + participantsDivs = element.shadowRoot?.querySelectorAll('.who-is-online__participant'); + expect(participantsDivs?.length).toBe(3); - element['updateParticipants'](MOCK_PARTICIPANTS.slice(0, 2)); + participants.publish(MOCK_PARTICIPANTS.slice(0, 2)); await sleep(); - participants = element.shadowRoot?.querySelectorAll('.who-is-online__participant'); + participantsDivs = element.shadowRoot?.querySelectorAll('.who-is-online__participant'); - expect(participants?.length).toBe(2); + expect(participantsDivs?.length).toBe(2); }); test('should render excess participants dropdown icon', async () => { - element['updateParticipants'](MOCK_PARTICIPANTS); + const { participants, extras } = useStore(StoreType.WHO_IS_ONLINE); + participants.publish(MOCK_PARTICIPANTS); + await sleep(); let extraParticipants = element?.shadowRoot?.querySelector('.superviz-who-is-online__excess'); - expect(extraParticipants).toBeFalsy(); + expect(extraParticipants).toBe(null); - element['updateParticipants']([...MOCK_PARTICIPANTS, ...MOCK_PARTICIPANTS]); + extras.publish(MOCK_PARTICIPANTS); await sleep(); extraParticipants = element?.shadowRoot?.querySelector('.superviz-who-is-online__excess'); @@ -137,30 +185,25 @@ describe('Who Is Online', () => { }); test('should give a black color to the letter when the slotIndex is not in the textColorValues', async () => { - element['updateParticipants'](MOCK_PARTICIPANTS.slice(0, 1)); + 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[0].slotIndex]; - + 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], - }; - - element['updateParticipants']([participant]); + 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[participant.slotIndex]; + const backgroundColor = MeetingColorsHex[MOCK_PARTICIPANTS[1].avatar.slotIndex as number]; expect(letter?.getAttribute('style')).toBe( `background-color: ${backgroundColor}; color: #FFFFFF`, ); @@ -177,14 +220,15 @@ describe('Who Is Online', () => { }); test('should update open property when clicking outside', async () => { + const { participants } = useStore(StoreType.WHO_IS_ONLINE); + const event = new CustomEvent('clickout', { detail: { open: false, }, }); - element['updateParticipants']([...MOCK_PARTICIPANTS, ...MOCK_PARTICIPANTS]); - + participants.publish([...MOCK_PARTICIPANTS, ...MOCK_PARTICIPANTS]); await sleep(); const dropdown = element.shadowRoot?.querySelector( 'superviz-who-is-online-dropdown', @@ -195,69 +239,56 @@ describe('Who Is Online', () => { }); test('should correctly display either name letter or image', () => { - const letter = element['getAvatar'](MOCK_PARTICIPANTS[0]); + const letter = element['getAvatar'](MOCK_PARTICIPANTS[1].avatar); expect(letter.strings[0]).not.toContain('img'); const participant = { ...MOCK_PARTICIPANTS[0], - avatar: { - imageUrl: 'https://link.com/image', - model3DUrl: '', - }, }; - const avatar = element['getAvatar'](participant); + const avatar = element['getAvatar'](participant.avatar); expect(avatar.strings[0]).toContain('img'); }); - test('should stop following if already following', async () => { - const event = new CustomEvent(RealtimeEvent.REALTIME_LOCAL_FOLLOW_PARTICIPANT, { - detail: { id: 1, label: WIODropdownOptions.LOCAL_FOLLOW, slotIndex: 0 }, - }); - - element['dropdownOptionsHandler'](event); - expect(element['following']).toBeTruthy(); - - element['dropdownOptionsHandler'](event); - expect(element['following']).toBeFalsy(); - }); + test('should start and stop following', async () => { + const { following, participants } = useStore(StoreType.WHO_IS_ONLINE); + participants.publish(MOCK_PARTICIPANTS); + await sleep(); - test('should bring hidden participant to second position when following them', async () => { - const participants = [ - ...MOCK_PARTICIPANTS, - ...MOCK_PARTICIPANTS, - { - name: 'Test participant', - avatar: { - imageUrl: '', - model3DUrl: '', - }, - color: MeetingColorsHex[10], - id: 'test', - slotIndex: 10, + const event1 = new CustomEvent(RealtimeEvent.REALTIME_LOCAL_FOLLOW_PARTICIPANT, { + detail: { + participantId: '1', + label: WIODropdownOptions.LOCAL_FOLLOW, + source: 'participants', }, - ]; + }); - element['updateParticipants'](participants); + element['dropdownOptionsHandler'](event1); + await sleep(); - expect(element['participants'].findIndex((participant) => participant.id === 'test')).not.toBe( - 1, - ); + expect(following.value).toBeTruthy(); - const event = new CustomEvent(RealtimeEvent.REALTIME_LOCAL_FOLLOW_PARTICIPANT, { - detail: { id: 'test', label: WIODropdownOptions.LOCAL_FOLLOW, slotIndex: 2 }, + const event2 = new CustomEvent(RealtimeEvent.REALTIME_LOCAL_FOLLOW_PARTICIPANT, { + detail: { + label: WIODropdownOptions.LOCAL_UNFOLLOW, + }, }); - element['dropdownOptionsHandler'](event); + element['dropdownOptionsHandler'](event2); + await sleep(); - expect(element['participants'].findIndex((participant) => participant.id === 'test')).toBe(1); + expect(following.value).toBeFalsy(); }); describe('dropdownOptionsHandler', () => { let dropdown: HTMLElement; + const { participants, extras } = useStore(StoreType.WHO_IS_ONLINE); + beforeEach(async () => { - element['updateParticipants']([...MOCK_PARTICIPANTS, ...MOCK_PARTICIPANTS]); + participants.publish(MOCK_PARTICIPANTS); + extras.publish(MOCK_PARTICIPANTS); await sleep(); + dropdown = element.shadowRoot?.querySelector( 'superviz-who-is-online-dropdown', ) as HTMLElement; @@ -277,19 +308,26 @@ describe('Who Is Online', () => { }); test('should emit event when selecting follow option in dropdown', async () => { + const { following } = useStore(StoreType.WHO_IS_ONLINE); + const event = new CustomEvent('selected', { - detail: { id: 1, label: WIODropdownOptions.LOCAL_FOLLOW, slotIndex: 1 }, + detail: { + participantId: '1', + label: WIODropdownOptions.LOCAL_FOLLOW, + source: 'participants', + }, }); const spy = jest.fn(); element.addEventListener(RealtimeEvent.REALTIME_LOCAL_FOLLOW_PARTICIPANT, spy); - expect(element['following']).toBeUndefined(); + expect(following.value).toBeUndefined(); dropdown.dispatchEvent(event); + await sleep(); expect(spy).toHaveBeenCalledWith(event); - expect(element['following']).toBeDefined(); + expect(following.value).toBeDefined(); }); test('should emit event when selecting unfollow option in dropdown', async () => { @@ -308,15 +346,18 @@ describe('Who Is Online', () => { expect(element['following']).toBeUndefined(); }); - test('should emit event when selecting follow option in dropdown', async () => { + test('should emit event when selecting follow me option in dropdown', async () => { const event = new CustomEvent('selected', { - detail: { id: 1, label: WIODropdownOptions.FOLLOW, slotIndex: 1 }, + detail: { participantId: '1', label: WIODropdownOptions.FOLLOW, source: 'participants' }, }); const spy = jest.fn(); element.addEventListener(RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, spy); - element['following'] = { id: 1, slotIndex: 1 }; + const { following } = useStore(StoreType.WHO_IS_ONLINE); + following.publish({ color: 'red', id: '1', name: 'John' }); + element['following'] = { participantId: 1, slotIndex: 1 }; + await sleep(); dropdown.dispatchEvent(event); 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 2d5b20a6..77b9488f 100644 --- a/src/web-components/who-is-online/who-is-online.ts +++ b/src/web-components/who-is-online/who-is-online.ts @@ -1,16 +1,18 @@ -import { CSSResultGroup, LitElement, PropertyValueMap, html } from 'lit'; +// @ts-nocheck +import { CSSResultGroup, LitElement, Part, PropertyValueMap, html } from 'lit'; import { customElement } from 'lit/decorators.js'; 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 { Participant } from '../../components/who-is-online/types'; +import { StoreType } from '../../common/types/stores.types'; +import { Avatar, Participant, WIODropdownOptions } from '../../components/who-is-online/types'; +import { Following } from '../../services/stores/who-is-online/types'; import { WebComponentsBase } from '../base'; import importStyle from '../base/utils/importStyle'; import type { LocalParticipantData, TooltipData } from './components/types'; -import { Following, WIODropdownOptions } from './components/types'; import { whoIsOnlineStyle } from './css/index'; const WebComponentsBaseElement = WebComponentsBase(LitElement); @@ -20,22 +22,20 @@ const styles: CSSResultGroup[] = [WebComponentsBaseElement.styles, whoIsOnlineSt export class WhoIsOnline extends WebComponentsBaseElement { static styles = styles; declare position: string; - declare participants: Participant[]; declare open: boolean; - declare disableDropdown: boolean; - declare following: Following | undefined; - declare localParticipantData: LocalParticipantData; declare isPrivate: boolean; declare everyoneFollowsMe: boolean; - declare showTooltip: boolean; + private following: Following | undefined; + private localParticipantData: LocalParticipantData; + private amountOfExtras: number; + private disableDropdown: boolean; + private participants: Participant[]; + static properties = { position: { type: String }, - participants: { type: Object }, open: { type: Boolean }, - disableDropdown: { type: Boolean }, - following: { type: Object }, localParticipantColor: { type: String }, isPrivate: { type: Boolean }, everyoneFollowsMe: { type: Boolean }, @@ -47,6 +47,32 @@ export class WhoIsOnline extends WebComponentsBaseElement { this.position = 'top: 20px; right: 40px;'; this.showTooltip = true; this.open = false; + + const { localParticipant } = this.useStore(StoreType.GLOBAL); + const { participants, following, extras } = this.useStore(StoreType.WHO_IS_ONLINE); + participants.subscribe(); + following.subscribe(); + extras.subscribe((participants: Participant[]) => { + this.amountOfExtras = participants.length; + }); + + localParticipant.subscribe((value: Participant) => { + const joinedPresence = value.activeComponents?.some((component) => + component.toLowerCase().includes('presence'), + ); + + this.localParticipantData = { + id: value.id, + joinedPresence: value.activeComponents?.some((component) => + component.toLowerCase().includes('presence'), + ), + }; + + this.disableDropdown = !joinedPresence; + }); + + const { disablePresenceControls } = this.useStore(StoreType.WHO_IS_ONLINE); + disablePresenceControls.subscribe(); } protected firstUpdated( @@ -56,10 +82,6 @@ export class WhoIsOnline extends WebComponentsBaseElement { importStyle.call(this, 'who-is-online'); } - public updateParticipants(data: Participant[]) { - this.participants = data; - } - private toggleOpen() { this.open = !this.open; } @@ -83,21 +105,8 @@ export class WhoIsOnline extends WebComponentsBaseElement { return 'bottom-left'; } - private renderExcessParticipants() { - const excess = this.participants.length - 4; - if (excess <= 0) return html``; - - const participants = this.participants - .slice(4) - .map(({ name, color, id, slotIndex, isLocal, avatar, joinedPresence }) => ({ - name, - color, - id, - slotIndex, - avatar, - isLocal, - joinedPresence, - })); + private renderExtras() { + if (!this.amountOfExtras) return; const classes = { 'who-is-online__participant': true, @@ -105,16 +114,11 @@ export class WhoIsOnline extends WebComponentsBaseElement { 'excess_participants--open': this.open, }; - const dropdown = html` + return html`
    -
    +${excess}
    +
    + +${this.amountOfExtras} +
    `; - - return dropdown; } - private dropdownOptionsHandler = ({ detail }: CustomEvent) => { - const { id, label, name, color, slotIndex } = detail; - - if (label === WIODropdownOptions['GOTO']) { - this.emitEvent(RealtimeEvent.REALTIME_GO_TO_PARTICIPANT, { id }); - } - - if ([WIODropdownOptions.LOCAL_FOLLOW, WIODropdownOptions.LOCAL_UNFOLLOW].includes(label)) { - if (this.following?.id === id) { - this.stopFollowing(); - return; - } - - if (this.everyoneFollowsMe) { - this.stopEveryoneFollowsMe(); - } - - this.following = { name, id, color }; - this.swapParticipantBeingFollowedPosition(); - this.emitEvent(RealtimeEvent.REALTIME_LOCAL_FOLLOW_PARTICIPANT, { id }); - } - - if ([WIODropdownOptions.PRIVATE, WIODropdownOptions.LEAVE_PRIVATE].includes(label)) { - this.isPrivate = label === WIODropdownOptions.PRIVATE; - - if (this.everyoneFollowsMe) { - this.stopEveryoneFollowsMe(); - } - - this.emitEvent(RealtimeEvent.REALTIME_PRIVATE_MODE, { id, isPrivate: this.isPrivate }); - this.everyoneFollowsMe = false; - } - - if ([WIODropdownOptions.FOLLOW, WIODropdownOptions.UNFOLLOW].includes(label)) { - if (this.everyoneFollowsMe) { - this.stopEveryoneFollowsMe(); - return; - } - - if (this.following) { - this.stopFollowing(); - } - - if (this.isPrivate) { - this.cancelPrivate(); - } - - this.everyoneFollowsMe = true; - this.following = undefined; - this.emitEvent(RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, { id, name, color, slotIndex }); - } - - if (label === WIODropdownOptions.GATHER) { - this.emitEvent(RealtimeEvent.REALTIME_GATHER, { id }); - } - }; - private toggleShowTooltip = () => { this.showTooltip = !this.showTooltip; }; - private stopEveryoneFollowsMe() { - this.everyoneFollowsMe = false; - this.emitEvent(RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, undefined); - } - - private getAvatar(participant: Participant) { - if (participant.avatar?.imageUrl) { + private getAvatar({ color, imageUrl, firstLetter, slotIndex }: Avatar) { + if (imageUrl) { return html` `; } - const letterColor = INDEX_IS_WHITE_TEXT.includes(participant.slotIndex) ? '#FFFFFF' : '#26242A'; + const letterColor = INDEX_IS_WHITE_TEXT.includes(slotIndex) ? '#FFFFFF' : '#26242A'; return html`
    - ${participant.name?.at(0).toUpperCase()} + ${firstLetter}
    `; } - private getOptions(participant: Participant, isBeingFollowed: boolean, isLocal: boolean) { - const { id, slotIndex, name, color } = participant; - const baseOption = { id, name, color, slotIndex }; - const { isPrivate } = this; - - const labels = isLocal - ? [ - 'GATHER', - this.everyoneFollowsMe ? 'UNFOLLOW' : 'FOLLOW', - isPrivate ? 'LEAVE_PRIVATE' : 'PRIVATE', - ] - : ['GOTO', isBeingFollowed ? 'LOCAL_UNFOLLOW' : 'LOCAL_FOLLOW']; - - const options = labels.map((label) => ({ - ...baseOption, - label: WIODropdownOptions[label], - })); - - return options; - } - - private getIcons(isLocal: boolean, isBeingFollowed: boolean) { - return isLocal - ? ['gather', this.everyoneFollowsMe ? 'send-off' : 'send', 'eye_inative'] - : ['place', isBeingFollowed ? 'send-off' : 'send']; - } - - private putLocalParticipationFirst() { - if (this.participants[0].isLocal) return; - - const localParticipant = this.participants?.find(({ isLocal }) => isLocal); - if (!localParticipant) return; - - const participants = [...this.participants]; - const localParticipantIndex = participants.indexOf(localParticipant); - participants.splice(localParticipantIndex, 1); - participants.unshift(localParticipant); - this.participants = participants; - } - - private swapParticipantBeingFollowedPosition() { - const a = this.participants?.findIndex(({ id }) => id === this.following?.id); - const b = 1; - - if (a < 4 || !a) return; - - const participants = [...this.participants]; - const temp = participants[a]; - participants[a] = participants[b]; - participants[b] = temp; - this.participants = participants; - } - - private stopFollowing() { - this.following = undefined; - this.emitEvent(RealtimeEvent.REALTIME_LOCAL_FOLLOW_PARTICIPANT, { id: undefined }); - } - private cancelPrivate() { this.isPrivate = undefined; this.emitEvent(RealtimeEvent.REALTIME_PRIVATE_MODE, { id: this.localParticipantData.id }); } private renderParticipants() { - if (!this.participants) return html``; + if (!this.participants.length) return html``; - this.putLocalParticipationFirst(); - this.swapParticipantBeingFollowedPosition(); - - return html`
    + return html` ${repeat( - this.participants.slice(0, 4), + this.participants, (participant) => participant.id, (participant, index) => { - const { joinedPresence, isLocal, id, name, color } = participant; - - const participantIsFollowed = this.following?.id === id; - const options = this.getOptions(participant, participantIsFollowed, isLocal); - const icons = this.getIcons(isLocal, participantIsFollowed); + const { avatar, id, name, tooltip, controls, disableDropdown, isLocalParticipant } = + participant; const position = this.dropdownPosition(index); - const disableDropdown = !joinedPresence || this.disableDropdown; + const participantIsFollowed = this.following?.id === id; const classList = { 'who-is-online__participant': true, 'disable-dropdown': disableDropdown, - followed: participantIsFollowed || (isLocal && this.everyoneFollowsMe), - private: isLocal && this.isPrivate, - }; - - const append = isLocal ? ' (you)' : ''; - const participantName = name + append; - - const tooltipData: TooltipData = { - name, + followed: participantIsFollowed || (this.everyoneFollowsMe && isLocalParticipant), + private: isLocalParticipant && this.isPrivate, }; - if (this.localParticipantData?.joinedPresence && joinedPresence && !isLocal) { - tooltipData.action = 'Click to Follow'; - } - - if (isLocal) { - tooltipData.action = 'You'; - } - return html` -
    - ${this.getAvatar(participant)} +
    + ${this.getAvatar(avatar)}
    `; }, )} - ${this.renderExcessParticipants()} -
    `; + `; + } + + // ----- handle presence controls options ----- + private dropdownOptionsHandler = ({ detail: { label, participantId, source } }: CustomEvent) => { + switch (label) { + case WIODropdownOptions.GOTO: + this.handleGoTo(participantId); + break; + case WIODropdownOptions.LOCAL_FOLLOW: + this.handleLocalFollow(participantId, source); + break; + case WIODropdownOptions.LOCAL_UNFOLLOW: + this.handleLocalUnfollow(); + break; + case WIODropdownOptions.PRIVATE: + this.handlePrivate(participantId); + break; + case WIODropdownOptions.LEAVE_PRIVATE: + this.handleCancelPrivate(participantId); + break; + case WIODropdownOptions.FOLLOW: + this.handleFollow(participantId, source); + break; + case WIODropdownOptions.UNFOLLOW: + this.handleStopFollow(); + break; + case WIODropdownOptions.GATHER: + this.handleGatherAll(participantId); + break; + default: + break; + } + }; + + private handleGoTo(participantId: string) { + this.emitEvent(RealtimeEvent.REALTIME_GO_TO_PARTICIPANT, { id: participantId }); + } + + private handleLocalFollow(participantId: string, source: string) { + const { following } = this.useStore(StoreType.WHO_IS_ONLINE); + const participants = this.useStore(StoreType.WHO_IS_ONLINE)[source].value; + + const { + id, + name, + avatar: { color }, + } = participants.find(({ id }) => id === participantId) as Participant; + + if (this.everyoneFollowsMe) { + this.handleStopFollow(); + } + following.publish({ name, id, color }); + this.emitEvent(RealtimeEvent.REALTIME_LOCAL_FOLLOW_PARTICIPANT, { id, source }); + } + + private handleLocalUnfollow() { + const { following } = this.useStore(StoreType.WHO_IS_ONLINE); + following.publish(undefined); + this.emitEvent(RealtimeEvent.REALTIME_LOCAL_FOLLOW_PARTICIPANT, { id: undefined }); + } + + private handlePrivate(id: string) { + if (this.everyoneFollowsMe) { + this.handleStopFollow(); + } + + this.emitEvent(RealtimeEvent.REALTIME_PRIVATE_MODE, { id, isPrivate: true }); + this.isPrivate = true; + } + + private handleCancelPrivate(id: string) { + this.emitEvent(RealtimeEvent.REALTIME_PRIVATE_MODE, { id, isPrivate: false }); + this.isPrivate = false; + } + + private handleFollow(participantId: string, source: string) { + if (this.isPrivate) { + this.cancelPrivate(); + } + + const participants: Participant[] = this.useStore(StoreType.WHO_IS_ONLINE)[source].value; + + const { + id, + name, + avatar: { color, slotIndex }, + } = participants.find(({ id }) => id === participantId); + + this.everyoneFollowsMe = true; + + const { following } = this.useStore(StoreType.WHO_IS_ONLINE); + following.publish(undefined); + + this.emitEvent(RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, { + id, + name, + color, + slotIndex, + }); + } + + private handleStopFollow() { + this.everyoneFollowsMe = false; + this.emitEvent(RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, undefined); + } + + private handleGatherAll(id: string) { + if (this.isPrivate) { + this.cancelPrivate(); + } + + this.emitEvent(RealtimeEvent.REALTIME_GATHER, { id }); } updated(changedProperties) { @@ -363,15 +340,15 @@ export class WhoIsOnline extends WebComponentsBaseElement { protected render() { return html`
    - ${this.renderParticipants()} +
    + ${this.renderParticipants()} ${this.renderExtras()} +
    `; } diff --git a/tsconfig.json b/tsconfig.json index 90a58f96..c2f8412e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "rootDirs": ["./src", "."], "target": "ES2020", "module": "ES2020", "outDir": "./lib", @@ -10,7 +11,8 @@ "esModuleInterop": true, "moduleResolution": "Node", "experimentalDecorators": true, - "skipLibCheck": true + "skipLibCheck": true, + "allowJs": true, }, "include": [ "./src" @@ -19,4 +21,4 @@ "./src/**/*.test.ts", "node_modules" ] -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 6e13cc34..b9ca051e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2315,6 +2315,13 @@ unbzip2-stream "1.4.3" yargs "17.7.1" +"@reactivex/rxjs@^6.6.7": + version "6.6.7" + resolved "https://registry.yarnpkg.com/@reactivex/rxjs/-/rxjs-6.6.7.tgz#52ab48f989aba9cda2b995acc904a43e6e1b3b40" + integrity sha512-xZIV2JgHhWoVPm3uVcFbZDRVJfx2hgqmuTX7J4MuKaZ+j5jN29agniCPBwrlCmpA15/zLKcPi7/bogt0ZwOFyA== + dependencies: + tslib "^1.9.0" + "@rollup/plugin-node-resolve@^15.0.1": version "15.1.0" resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.1.0.tgz#9ffcd8e8c457080dba89bb9fcb583a6778dc757e" @@ -2469,6 +2476,24 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@socket.io/component-emitter@~3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" + integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg== + +"@superviz/socket-client@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@superviz/socket-client/-/socket-client-1.2.2.tgz#6f228e84588051ca092e827c172af6c8f18182f5" + integrity sha512-JDvKBtyamCe00Tf0KM5Uaac73LVafcLKlByPxXBc5wTmcEo/cuLeaQ4sbpQzUUwJaS2ptnbvZMmYkhL3SnW1AQ== + dependencies: + "@reactivex/rxjs" "^6.6.7" + debug "^4.3.4" + lodash "^4.17.21" + rxjs "^7.8.1" + semantic-release-version-file "^1.0.2" + socket.io-client "^4.7.4" + zod "^3.22.4" + "@szmarczak/http-timer@^4.0.5": version "4.0.6" resolved "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz" @@ -4818,7 +4843,7 @@ debounce@^1.2.0: resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== -debug@4, debug@4.3.4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: +debug@4, debug@4.3.4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2: version "4.3.4" resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -5127,6 +5152,22 @@ end-of-stream@^1.1.0: dependencies: once "^1.4.0" +engine.io-client@~6.5.2: + version "6.5.3" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.5.3.tgz#4cf6fa24845029b238f83c628916d9149c399bc5" + integrity sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + engine.io-parser "~5.2.1" + ws "~8.11.0" + xmlhttprequest-ssl "~2.0.0" + +engine.io-parser@~5.2.1: + version "5.2.2" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.2.tgz#37b48e2d23116919a3453738c5720455e64e1c49" + integrity sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw== + entities@^4.4.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" @@ -9983,6 +10024,13 @@ rxjs@^7.0.0, rxjs@^7.5.5: dependencies: tslib "^2.1.0" +rxjs@^7.8.1: + version "7.8.1" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" + integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== + dependencies: + tslib "^2.1.0" + safe-array-concat@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.0.1.tgz#91686a63ce3adbea14d61b14c99572a8ff84754c" @@ -10241,6 +10289,24 @@ smart-buffer@^4.2.0: resolved "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz" integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== +socket.io-client@^4.7.4: + version "4.7.4" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.4.tgz#5f0e060ff34ac0a4b4c5abaaa88e0d1d928c64c8" + integrity sha512-wh+OkeF0rAVCrABWQBaEjLfb7DVPotMbu0cgWgyR0v6eA4EoVnAwcIeIbcdTE3GT/H3kbdLl7OoH2+asoDRIIg== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.2" + engine.io-client "~6.5.2" + socket.io-parser "~4.2.4" + +socket.io-parser@~4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" + integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + socks-proxy-agent@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz" @@ -10811,10 +10877,10 @@ ts-api-utils@^1.0.1: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.0.3.tgz#f12c1c781d04427313dbac808f453f050e54a331" integrity sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg== -ts-jest@^29.1.1: - version "29.1.1" - resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.1.tgz#f58fe62c63caf7bfcc5cc6472082f79180f0815b" - integrity sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA== +ts-jest@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.2.tgz#7613d8c81c43c8cb312c6904027257e814c40e09" + integrity sha512-br6GJoH/WUX4pu7FbZXuWGKGNDuU7b8Uj77g/Sp7puZV6EXzuByl6JrECvm0MzVzSTkSHWTihsXt+5XYER5b+g== dependencies: bs-logger "0.x" fast-json-stable-stringify "2.x" @@ -10859,6 +10925,11 @@ tsconfig-paths@^3.14.2: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@^1.9.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + tslib@^2.0.1, tslib@^2.4.0: version "2.6.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.1.tgz#fd8c9a0ff42590b25703c0acb3de3d3f4ede0410" @@ -11447,6 +11518,11 @@ ws@^8.11.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f" integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== +ws@~8.11.0: + version "8.11.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143" + integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg== + xml-name-validator@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz" @@ -11457,6 +11533,11 @@ xmlchars@^2.2.0: resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xmlhttprequest-ssl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67" + integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A== + xtend@~4.0.1: version "4.0.2" resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz" @@ -11571,3 +11652,8 @@ yocto-queue@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== + +zod@^3.22.4: + version "3.22.4" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" + integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==