forked from ably/spaces
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathSpace.ts
442 lines (400 loc) · 16.4 KB
/
Space.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
import Ably, { Types } from 'ably';
import EventEmitter, { InvalidArgumentError, inspect, type EventListener } from './utilities/EventEmitter.js';
import Locations from './Locations.js';
import Cursors from './Cursors.js';
import Members from './Members.js';
import Locks from './Locks.js';
import SpaceUpdate, { type SpacePresenceData } from './SpaceUpdate.js';
import { ERR_NOT_ENTERED_SPACE } from './Errors.js';
import { isFunction, isObject } from './utilities/is.js';
import type { SpaceOptions, SpaceMember, ProfileData } from './types.js';
import type { Subset, PresenceMember } from './utilities/types.js';
import { VERSION } from './version.js';
const SPACE_CHANNEL_TAG = '::$space';
const SPACE_OPTIONS_DEFAULTS = {
offlineTimeout: 120_000,
cursors: {
outboundBatchInterval: 25,
paginationLimit: 5,
},
};
/**
* This namespace contains the types which represent the data attached to an event emitted by a {@link Space | `Space`} instance.
*/
export namespace SpaceEvents {
/**
* The data attached to an {@link SpaceEventMap | `update`} event.
*/
export interface UpdateEvent {
/**
* The members currently in the space.
*/
members: SpaceMember[];
}
}
/**
* The property names of `SpaceEventMap` are the names of the events emitted by {@link Space}.
*/
export interface SpaceEventMap {
/**
* The space state was updated.
*/
update: SpaceEvents.UpdateEvent;
}
/**
* The current state of a {@link Space | `Space`}, as described by {@link Space.getState | `Space.getState()`}.
*/
export interface SpaceState {
/**
* <!-- Copied, with edits, from getState documentation -->
* The members currently in the space. This includes members that have recently left the space, but have not yet been removed.
*/
members: SpaceMember[];
}
/**
* A function that can be passed to {@link Space.updateProfileData | `Space.updateProfileData()`}. It receives the existing profile data and returns the new profile data.
*
* @param profileData The existing profile data.
*/
export type UpdateProfileDataFunction = (profileData: ProfileData) => ProfileData;
/**
* A [space](https://ably.com/docs/spaces/space) is a virtual area of your application in which realtime collaboration between users can take place. You can have any number of virtual spaces within an application, with a single space being anything from a web page, a sheet within a spreadsheet, an individual slide in a slideshow, or the entire slideshow itself.
*
* The following features can be implemented within a space:
*
* - Avatar stack, via the {@link members | `members`} property
* - Member location, via the {@link locations | `locations`} property
* - Live cursors, via the {@link cursors | `cursors`} property
* - Component locking, via the {@link locks | `locks`} property
*
* A `Space` instance consists of a state object that represents the realtime status of all members in a given virtual space. This includes a list of which members are currently online or have recently left and each member’s location within the application. The position of members’ cursors are excluded from the space state due to their high frequency of updates. In the beta release, which UI components members have locked are also excluded from the space state.
*
*/
class Space extends EventEmitter<SpaceEventMap> {
/**
* @internal
*/
readonly client: Types.RealtimePromise;
private readonly channelName: string;
/**
* @internal
*/
readonly connectionId: string | undefined;
/**
* The options passed to {@link default.get | `Spaces.get()`}.
*/
readonly options: SpaceOptions;
/**
* An instance of {@link Locations}.
*/
readonly locations: Locations;
/**
* An instance of {@link Cursors}.
*/
readonly cursors: Cursors;
/**
* An instance of {@link Members}.
*/
readonly members: Members;
/**
* The [realtime channel instance](https://ably.com/docs/channels) that this `Space` instance uses for transmitting and receiving data.
*/
readonly channel: Types.RealtimeChannelPromise;
/**
* An instance of {@link Locks}.
*/
readonly locks: Locks;
/**
* The space name passed to {@link default.get | `Spaces.get()`}.
*/
readonly name: string;
/** @internal */
constructor(name: string, client: Types.RealtimePromise, options?: Subset<SpaceOptions>) {
super();
this.client = client;
this.options = this.setOptions(options);
this.connectionId = this.client.connection.id;
this.name = name;
this.channelName = `${name}${SPACE_CHANNEL_TAG}`;
this.channel = this.client.channels.get(this.channelName, { params: { agent: `spaces/${VERSION}` } });
this.onPresenceUpdate = this.onPresenceUpdate.bind(this);
this.channel.presence.subscribe(this.onPresenceUpdate);
this.locations = new Locations(this, this.presenceUpdate);
this.cursors = new Cursors(this);
this.members = new Members(this);
this.locks = new Locks(this, this.presenceUpdate);
}
private presenceUpdate = ({ data, extras }: SpacePresenceData) => {
if (!extras) {
return this.channel.presence.update(data);
}
return this.channel.presence.update(Ably.Realtime.PresenceMessage.fromValues({ data, extras }));
};
private presenceEnter = ({ data, extras }: SpacePresenceData) => {
if (!extras) {
return this.channel.presence.enter(data);
}
return this.channel.presence.enter(Ably.Realtime.PresenceMessage.fromValues({ data, extras }));
};
private presenceLeave = ({ data, extras }: SpacePresenceData) => {
if (!extras) {
return this.channel.presence.leave(data);
}
return this.channel.presence.leave(Ably.Realtime.PresenceMessage.fromValues({ data, extras }));
};
private setOptions(options?: Subset<SpaceOptions>): SpaceOptions {
const {
offlineTimeout,
cursors: { outboundBatchInterval, paginationLimit },
} = SPACE_OPTIONS_DEFAULTS;
return {
offlineTimeout: options?.offlineTimeout ?? offlineTimeout,
cursors: {
outboundBatchInterval: options?.cursors?.outboundBatchInterval ?? outboundBatchInterval,
paginationLimit: options?.cursors?.paginationLimit ?? paginationLimit,
},
};
}
private async onPresenceUpdate(message: PresenceMember) {
await this.members.processPresenceMessage(message);
await this.locations.processPresenceMessage(message);
await this.locks.processPresenceMessage(message);
this.emit('update', { members: await this.members.getAll() });
}
/**
* Enter a space to register a client as a member and emit an {@link MembersEventMap.enter | `enter`} event to all subscribers.
*
* `profileData` can optionally be passed when entering a space. This data can be any arbitrary JSON-serializable object which will be attached to the {@link SpaceMember | member object}.
*
* Being entered into a space is required for members to:
*
* - {@link updateProfileData | Update their profile data.}
* - {@link Locations.set | Set their location.}
* - {@link Cursors.set | Set their cursor position.}
*
* The following is an example of entering a space and setting profile data:
*
* ```javascript
* await space.enter({
* username: 'Claire Oranges',
* avatar: 'https://slides-internal.com/users/coranges.png',
* });
* ```
*
* @param profileData The data to associate with a member, such as a preferred username or profile picture.
*/
async enter(profileData: ProfileData = null): Promise<SpaceMember[]> {
return new Promise((resolve) => {
const presence = this.channel.presence;
const presenceListener = async (presenceMessage: Types.PresenceMessage) => {
if (
!(
presenceMessage.clientId == this.client.auth.clientId &&
presenceMessage.connectionId == this.client.connection.id
)
) {
return;
}
presence.unsubscribe(presenceListener);
const presenceMessages = await presence.get();
presenceMessages.forEach((msg) => this.locks.processPresenceMessage(msg));
const members = await this.members.getAll();
resolve(members);
};
presence.subscribe(['enter', 'present'], presenceListener);
const update = new SpaceUpdate({ self: null, extras: null });
this.presenceEnter(update.updateProfileData(profileData));
});
}
/**
* {@label MAIN_OVERLOAD}
*
* Update a member's profile data and emit an {@link MembersEventMap.updateProfile | `updateProfile`} event to all subscribers. This data can be any arbitrary JSON-serializable object which will be attached to the {@link SpaceMember | member object}.
*
* If the client hasn't yet entered the space, `updateProfileData()` will instead enter them into the space, with the profile data, and emit an {@link MembersEventMap.enter | `enter`} event.
*
* The following is an example of a member updating their profile data:
*
* ```javascript
* space.updateProfileData({
* username: 'Claire Lime'
* });
* ```
*
* @param profileData The updated profile data to associate with a member, such as a preferred username or profile picture.
*/
async updateProfileData(profileData: ProfileData): Promise<void>;
/**
* Update a member's profile data by passing a function, for example to update a field based on the member's existing profile data:
*
* ```javascript
* space.updateProfileData(currentProfile => {
* return { ...currentProfile, username: 'Claire Lime' }
* });
* ```
*
* @param updateFn The function which receives the existing profile data and returns the new profile data.
*/
async updateProfileData(updateFn: UpdateProfileDataFunction): Promise<void>;
async updateProfileData(profileDataOrUpdateFn: ProfileData | UpdateProfileDataFunction): Promise<void> {
const self = await this.members.getSelf();
if (!isObject(profileDataOrUpdateFn) && !isFunction(profileDataOrUpdateFn)) {
throw new InvalidArgumentError(
'Space.updateProfileData(): Invalid arguments: ' + inspect([profileDataOrUpdateFn]),
);
}
let update = new SpaceUpdate({ self, extras: self ? this.locks.getLockExtras(self.connectionId) : null });
if (!self) {
const data = update.updateProfileData(
isFunction(profileDataOrUpdateFn) ? profileDataOrUpdateFn(null) : profileDataOrUpdateFn,
);
await this.presenceEnter(data);
return;
} else {
const data = update.updateProfileData(
isFunction(profileDataOrUpdateFn) ? profileDataOrUpdateFn(self.profileData) : profileDataOrUpdateFn,
);
return this.presenceUpdate(data);
}
}
/**
* Leave a space and emit a {@link MembersEventMap.leave | `leave`} event to all subscribers. `profileData` can optionally be updated when leaving the space.
*
* The member may not immediately be removed from the space, depending on the {@link SpaceOptions.offlineTimeout | offlineTimeout} configured.
*
* Members will implicitly leave a space after 15 seconds if they abruptly disconnect. If experiencing network disruption, and they reconnect within 15 seconds, then they will remain part of the space and no `leave` event will be emitted.
*
* @param profileData The updated profile data to associate with a member.
*/
async leave(profileData: ProfileData = null) {
const self = await this.members.getSelf();
if (!self) {
throw ERR_NOT_ENTERED_SPACE();
}
const update = new SpaceUpdate({ self, extras: this.locks.getLockExtras(self.connectionId) });
let data;
// Use arguments so it's possible to deliberately nullify profileData on leave
if (arguments.length > 0) {
data = update.updateProfileData(profileData);
} else {
data = update.noop();
}
await this.presenceLeave(data);
}
/**
* Retrieve the current state of a space in a one-off call. Returns an array of all `member` objects currently in the space. This is a local call and retrieves the membership of the space retained in memory by the SDK.
*
* The following is an example of retrieving space state:
*
* ```javascript
* const spaceState = await space.getState();
* ```
*/
async getState(): Promise<SpaceState> {
const members = await this.members.getAll();
return { members };
}
/**
* {@label WITH_EVENTS}
*
* Subscribe to space state updates by registering a listener for an event name, or an array of event names.
*
* The following events will trigger a space event:
*
* - A member enters the space
* - A member leaves the space
* - A member is removed from the space state after the {@link SpaceOptions.offlineTimeout | offlineTimeout} period has elapsed
* - A member updates their profile data
* - A member sets a new location
*
* Space state contains a single object called `members`. Any events that trigger a change in space state will always return the current state of the space as an array of `member` objects.
*
* > **Note**
* >
* > Avatar stacks and member location events can be subscribed to using the space’s {@link members | `members`} and {@link locations | `locations`} properties. These events are filtered versions of space state events. Only a single [message](https://ably.com/docs/channels/messages) is published per event by Ably, irrespective of whether you register listeners for space state or individual namespaces. If you register listeners for both, it is still only a single message.
* >
* > The key difference between the subscribing to space state or to individual feature events, is that space state events return the current state of the space as an array of all members in each event payload. Individual member and location event payloads only include the relevant data for the member that triggered the event.
*
* The following is an example of subscribing to space events:
*
* ```javascript
* space.subscribe('update', (spaceState) => {
* console.log(spaceState.members);
* });
* ```
*
* @param eventOrEvents An event name, or an array of event names.
* @param listener An event listener.
*
* @typeParam K A type which allows one or more names of the properties of the {@link SpaceEventMap} type.
*/
subscribe<K extends keyof SpaceEventMap>(eventOrEvents: K | K[], listener?: EventListener<SpaceEventMap, K>): void;
/**
* Subscribe to space state updates by registering a listener for all event types.
*
* @param listener An event listener.
*/
subscribe(listener?: EventListener<SpaceEventMap, keyof SpaceEventMap>): void;
subscribe<K extends keyof SpaceEventMap>(
listenerOrEvents?: K | K[] | EventListener<SpaceEventMap, K>,
listener?: EventListener<SpaceEventMap, K>,
) {
try {
super.on(listenerOrEvents, listener);
} catch (e: unknown) {
if (e instanceof InvalidArgumentError) {
throw new InvalidArgumentError(
'Space.subscribe(): Invalid arguments: ' + inspect([listenerOrEvents, listener]),
);
} else {
throw e;
}
}
}
/**
* {@label WITH_EVENTS}
*
* Unsubscribe from specific events, or an array of event names, to remove previously registered listeners.
*
* The following is an example of removing a listener:
*
* ```javascript
* space.unsubscribe('update', listener);
* ```
*
* @param eventOrEvents An event name, or an array of event names.
* @param listener An event listener.
*
* @typeParam K A type which allows one or more names of the properties of the {@link SpaceEventMap} type.
*/
unsubscribe<K extends keyof SpaceEventMap>(eventOrEvents: K | K[], listener?: EventListener<SpaceEventMap, K>): void;
/**
* Unsubscribe from all events to remove previously registered listeners.
*
* The following is an example of removing a listener for all events:
*
* ```javascript
* space.unsubscribe(listener);
* ```
*
* @param listener An event listener.
*/
unsubscribe(listener?: EventListener<SpaceEventMap, keyof SpaceEventMap>): void;
unsubscribe<K extends keyof SpaceEventMap>(
listenerOrEvents?: K | K[] | EventListener<SpaceEventMap, K>,
listener?: EventListener<SpaceEventMap, K>,
) {
try {
super.off(listenerOrEvents, listener);
} catch (e: unknown) {
if (e instanceof InvalidArgumentError) {
throw new InvalidArgumentError(
'Space.unsubscribe(): Invalid arguments: ' + inspect([listenerOrEvents, listener]),
);
} else {
throw e;
}
}
}
}
export default Space;