diff --git a/docs/class-definitions.md b/docs/class-definitions.md index c5870942..c55ced27 100644 --- a/docs/class-definitions.md +++ b/docs/class-definitions.md @@ -136,6 +136,24 @@ Leave the space. Can optionally take `profileData`. This triggers the `leave` ev type leave = (profileData?: Record) => Promise; ``` +### updateProfileData + +Update `profileData`. This data can be an arbitrary JSON-serializable object which is attached to the [member object](#spacemember). If the connection +has not entered the space, calling `updateProfileData` will call `enter` instead. + +```ts +type updateProfileData = (profileDataOrUpdateFn?: unknown| (unknown) => unknown) => Promise; +``` + +A function can also be passed in. This function will receive the existing `profileData` and lets you update based on the existing value of `profileData`: + +```ts +await space.updateProfileData((oldProfileData) => { + const newProfileData = getNewProfileData(); + return { ...oldProfileData, ...newProfileData }; +}) +``` + ### on Listen to events for the space. See [EventEmitter](/docs/usage.md#event-emitters) for overloading usage. diff --git a/docs/usage.md b/docs/usage.md index f49993fc..0200ccd9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -168,6 +168,19 @@ space.leave({ }); ``` +### Update profileData + +To update `profileData` provided when entering the space, use the `updateProfileData` method. Pass new `profileData` or a function to base the new `profileData` of the existing value: + +```ts +await space.updateProfileData((oldProfileData) => { + return { + ...oldProfileData, + username: 'Clara Lemons' + } +}); +``` + ## Location Each member can set a location for themselves: diff --git a/src/Space.mockClient.test.ts b/src/Space.mockClient.test.ts index 8f405765..4b65d27c 100644 --- a/src/Space.mockClient.test.ts +++ b/src/Space.mockClient.test.ts @@ -91,6 +91,66 @@ describe('Space (mockClient)', () => { }); }); + describe('updateProfileData', () => { + describe('did not enter', () => { + it('enter & update profileData successfully', async ({ presence, space }) => { + const enterSpy = vi.spyOn(presence, 'enter'); + const updateSpy = vi.spyOn(presence, 'update'); + await space.updateProfileData({ a: 1 }); + expect(enterSpy).toHaveBeenNthCalledWith(1, { profileData: { a: 1 } }); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('enter & update profileData with function successfully', async ({ presence, space }) => { + const enterSpy = vi.spyOn(presence, 'enter'); + const updateSpy = vi.spyOn(presence, 'update'); + await space.updateProfileData((profileData) => ({ ...profileData, a: 1 })); + expect(enterSpy).toHaveBeenNthCalledWith(1, { profileData: { a: 1 } }); + expect(updateSpy).not.toHaveBeenCalled(); + }); + }); + + describe('did enter', () => { + it('update profileData successfully', async ({ presence, space }) => { + vi.spyOn(space, 'getSelf').mockResolvedValueOnce({ + clientId: '1', + connectionId: 'testConnectionId', + isConnected: true, + location: null, + lastEvent: { + name: 'enter', + timestamp: 1, + }, + profileData: { + a: 1, + }, + }); + const updateSpy = vi.spyOn(presence, 'update'); + await space.updateProfileData({ a: 2 }); + expect(updateSpy).toHaveBeenNthCalledWith(1, { profileData: { a: 2 } }); + }); + + it('enter & update profileData with function successfully', async ({ presence, space }) => { + vi.spyOn(space, 'getSelf').mockResolvedValueOnce({ + clientId: '1', + connectionId: 'testConnectionId', + isConnected: true, + location: null, + lastEvent: { + name: 'enter', + timestamp: 1, + }, + profileData: { + a: 1, + }, + }); + const updateSpy = vi.spyOn(presence, 'update'); + await space.updateProfileData((profileData) => ({ ...profileData, a: 2 })); + expect(updateSpy).toHaveBeenNthCalledWith(1, { profileData: { a: 2 } }); + }); + }); + }); + describe('leave', () => { it('leaves a space successfully', async ({ presence, space }) => { const spy = vi.spyOn(presence, 'leave'); diff --git a/src/Space.ts b/src/Space.ts index 0934dd4c..0dcbab2a 100644 --- a/src/Space.ts +++ b/src/Space.ts @@ -7,6 +7,7 @@ import Cursors from './Cursors.js'; // Unique prefix to avoid conflicts with channels import { LOCATION_UPDATE, MEMBERS_UPDATE, SPACE_CHANNEL_PREFIX } from './utilities/Constants.js'; +import { isFunction } from './utilities/TypeOf.js'; export type SpaceMember = { clientId: string; @@ -30,7 +31,7 @@ const SPACE_OPTIONS_DEFAULTS = { offlineTimeout: 120_000, }; -type SpaceEventsMap = { membersUpdate: SpaceMember[]; leave: SpaceMember; enter: SpaceMember }; +type SpaceEventsMap = { membersUpdate: SpaceMember[]; leave: SpaceMember; enter: SpaceMember; update: SpaceMember }; class Space extends EventEmitter { private channelName: string; @@ -153,6 +154,7 @@ class Space extends EventEmitter { const spaceMember = this.updateOrCreateMember(message); if (index >= 0) { + this.emit('update', spaceMember); this.members[index] = spaceMember; } else { this.emit('enter', spaceMember); @@ -191,6 +193,26 @@ class Space extends EventEmitter { }); } + async updateProfileData(profileDataOrUpdateFn: unknown | ((unknown) => unknown)): Promise { + const self = this.getSelf(); + + if (isFunction(profileDataOrUpdateFn) && !self) { + const update = profileDataOrUpdateFn(); + await this.enter(update); + return; + } else if (!self) { + await this.enter(profileDataOrUpdateFn); + return; + } else if (isFunction(profileDataOrUpdateFn) && self) { + const update = profileDataOrUpdateFn(self.profileData); + await this.channel.presence.update({ profileData: update }); + return; + } + + await this.channel.presence.update({ profileData: profileDataOrUpdateFn }); + return; + } + leave(profileData?: unknown) { return this.channel.presence.leave({ profileData }); } diff --git a/src/utilities/EventEmitter.ts b/src/utilities/EventEmitter.ts index bdfc59f6..f0c32e42 100644 --- a/src/utilities/EventEmitter.ts +++ b/src/utilities/EventEmitter.ts @@ -1,3 +1,5 @@ +import { isArray, isFunction, isObject, isString } from './TypeOf.js'; + function callListener(eventThis: { event: string }, listener: Function, args: unknown[]) { try { listener.apply(eventThis, args); @@ -55,27 +57,6 @@ function inspect(args: any): string { return JSON.stringify(args); } -function typeOf(arg: unknown): string { - return Object.prototype.toString.call(arg).slice(8, -1); -} - -// Equivalent of Util.isObject from ably-js -function isObject(arg: unknown): arg is Record { - return typeOf(arg) === 'Object'; -} - -function isFunction(arg: unknown): arg is Function { - return typeOf(arg) === 'Function'; -} - -function isString(arg: unknown): arg is String { - return typeOf(arg) === 'String'; -} - -function isArray(arg: unknown): arg is Array { - return Array.isArray(arg); -} - type EventMap = Record; // extract all the keys of an event map and use them as a type type EventKey = string & keyof T; diff --git a/src/utilities/TypeOf.ts b/src/utilities/TypeOf.ts new file mode 100644 index 00000000..1a0a3df6 --- /dev/null +++ b/src/utilities/TypeOf.ts @@ -0,0 +1,22 @@ +function typeOf(arg: unknown): string { + return Object.prototype.toString.call(arg).slice(8, -1); +} + +// Equivalent of Util.isObject from ably-js +function isObject(arg: unknown): arg is Record { + return typeOf(arg) === 'Object'; +} + +function isFunction(arg: unknown): arg is Function { + return typeOf(arg) === 'Function'; +} + +function isString(arg: unknown): arg is String { + return typeOf(arg) === 'String'; +} + +function isArray(arg: unknown): arg is Array { + return Array.isArray(arg); +} + +export { isArray, isFunction, isObject, isString };