polfan-server-js-client 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitmodules +3 -3
- package/.idea/shelf/Uncommitted_changes_before_Checkout_at_05_08_2026_17_28_[Changes]/shelved.patch +447 -0
- package/.idea/shelf/Uncommitted_changes_before_Checkout_at_05_08_2026_17_28_[Changes]1/shelved.patch +0 -0
- package/.idea/shelf/Uncommitted_changes_before_Checkout_at_05_08_2026_17_28__Changes_.xml +4 -0
- package/.idea/workspace.xml +193 -6
- package/CODE_OF_CONDUCT.md +76 -76
- package/README.md +44 -44
- package/babel.config.js +5 -5
- package/build/index.cjs.js +392 -429
- package/build/index.cjs.js.map +1 -1
- package/build/index.umd.js +1 -1
- package/build/index.umd.js.map +1 -1
- package/build/types/index.d.ts +2 -1
- package/build/types/state-tracker/RoomMessagesHistory.d.ts +4 -15
- package/build/types/state-tracker/TopicHistoryWindow.d.ts +0 -48
- package/build/types/types/src/index.d.ts +3 -3
- package/build/types/types/src/schemes/SessionData.d.ts +6 -0
- package/build/types/types/src/schemes/User.d.ts +15 -0
- package/build/types/types/src/schemes/UserData.d.ts +5 -0
- package/build/types/types/src/schemes/commands/SetSessionData.d.ts +9 -1
- package/build/types/types/src/schemes/commands/SetUserData.d.ts +1 -0
- package/jest.config.ts +199 -199
- package/package.json +43 -43
- package/scripts/getPackageJson.js +24 -24
- package/src/FilesClient.ts +42 -42
- package/src/index.ts +31 -29
- package/src/state-tracker/ChatStateTracker.ts +71 -71
- package/src/state-tracker/RoomMessagesHistory.ts +9 -27
- package/src/state-tracker/TopicHistoryWindow.ts +1 -133
- package/src/state-tracker/UsersManager.ts +51 -51
- package/src/state-tracker/functions.ts +28 -28
- package/src/types/src/index.ts +313 -311
- package/src/types/src/schemes/SessionData.ts +8 -1
- package/src/types/src/schemes/User.ts +17 -1
- package/src/types/src/schemes/UserData.ts +5 -0
- package/src/types/src/schemes/commands/SetSessionData.ts +11 -2
- package/src/types/src/schemes/commands/SetUserData.ts +1 -0
- package/tests/async-utils.test.ts +29 -29
- package/tests/history-window.test.ts +0 -131
- package/tests/space-roles.test.ts +42 -42
- package/tests/state-reconnect.test.ts +0 -260
- package/.idea/shelf/Uncommitted_changes_before_Checkout_at_26_07_2026_21_26_[Changes]/shelved.patch +0 -174
- package/.idea/shelf/Uncommitted_changes_before_Checkout_at_26_07_2026_21_26__Changes_.xml +0 -4
- package/.idea/shelf/Uncommitted_changes_before_rebase_[Changes]/shelved.patch +0 -18
- package/.idea/shelf/Uncommitted_changes_before_rebase__Changes_.xml +0 -4
|
@@ -45,7 +45,6 @@ export abstract class TraversableRemoteCollection<
|
|
|
45
45
|
fetchLimit: number,
|
|
46
46
|
lastFetchCount: number,
|
|
47
47
|
oldestId: string | null,
|
|
48
|
-
gaps: string[],
|
|
49
48
|
} = {
|
|
50
49
|
current: WindowState.LIVE,
|
|
51
50
|
ongoing: undefined,
|
|
@@ -54,7 +53,6 @@ export abstract class TraversableRemoteCollection<
|
|
|
54
53
|
fetchLimit: 50,
|
|
55
54
|
lastFetchCount: 0,
|
|
56
55
|
oldestId: null,
|
|
57
|
-
gaps: [],
|
|
58
56
|
};
|
|
59
57
|
|
|
60
58
|
/**
|
|
@@ -105,21 +103,6 @@ export abstract class TraversableRemoteCollection<
|
|
|
105
103
|
return [WindowState.LATEST, WindowState.LIVE].includes(this.state);
|
|
106
104
|
}
|
|
107
105
|
|
|
108
|
-
/**
|
|
109
|
-
* IDs of the items the window could not stitch to the ones loaded before
|
|
110
|
-
* them: there is a gap in front of each of them, i.e. the item right above
|
|
111
|
-
* it in the window is not its real predecessor and an unknown number of
|
|
112
|
-
* items in between was never fetched.
|
|
113
|
-
*
|
|
114
|
-
* Such a gap appears when the collection is resynchronised after a
|
|
115
|
-
* reconnect (see resyncToLatest) and more items than a single page arrived
|
|
116
|
-
* while the connection was down. The markers are kept in the window order
|
|
117
|
-
* and disappear together with the items they point at.
|
|
118
|
-
*/
|
|
119
|
-
public get gaps(): readonly string[] {
|
|
120
|
-
return [...this.internalState.gaps];
|
|
121
|
-
}
|
|
122
|
-
|
|
123
106
|
public get hasOldest(): boolean {
|
|
124
107
|
return this.state === WindowState.OLDEST
|
|
125
108
|
|| this.state === WindowState.LATEST && this.length < this.fetchLimit
|
|
@@ -145,68 +128,11 @@ export abstract class TraversableRemoteCollection<
|
|
|
145
128
|
}
|
|
146
129
|
|
|
147
130
|
this._items.deleteAll(); // Directly call deleteAll to prevent event emit.
|
|
148
|
-
this.internalState.gaps = []; // Whole content is replaced, old markers mean nothing.
|
|
149
131
|
this.addItems(result, 'tail');
|
|
150
132
|
this.internalState.current = WindowState.LATEST;
|
|
151
133
|
this.emitChangeWithDiff(true, originalState);
|
|
152
134
|
}
|
|
153
135
|
|
|
154
|
-
/**
|
|
155
|
-
* Refresh the window with the latest page, keeping the already loaded items
|
|
156
|
-
* instead of replacing them.
|
|
157
|
-
*
|
|
158
|
-
* This is the reconnect-friendly variant of resetToLatest: the items missed
|
|
159
|
-
* while the connection was down are pulled with a single request and merged
|
|
160
|
-
* on top of the loaded ones (items returned in both are deduplicated), so
|
|
161
|
-
* the context the application already had does not disappear. The window
|
|
162
|
-
* size limit is the only thing that pushes the oldest items out.
|
|
163
|
-
*
|
|
164
|
-
* An empty or partial page is not a reason to drop anything: it only means
|
|
165
|
-
* the collection has little (or nothing) left on the remote side, while the
|
|
166
|
-
* items loaded earlier are still valid. When the page is full and does not
|
|
167
|
-
* reach the loaded items, an unknown number of items in between was never
|
|
168
|
-
* fetched - both parts are still kept, and the seam between them is recorded
|
|
169
|
-
* in `gaps` so the application can show where the history is not continuous.
|
|
170
|
-
*/
|
|
171
|
-
public async resyncToLatest(): Promise<void> {
|
|
172
|
-
if (this.internalState.ongoing) {
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
let result;
|
|
177
|
-
const originalState = this.state;
|
|
178
|
-
this.internalState.ongoing = WindowState.LATEST;
|
|
179
|
-
|
|
180
|
-
try {
|
|
181
|
-
result = await this.fetchLatestItems();
|
|
182
|
-
this.internalState.lastFetchCount = result.length;
|
|
183
|
-
} finally {
|
|
184
|
-
this.internalState.ongoing = undefined;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const loaded = this.items;
|
|
188
|
-
const fetchedIds = new Set(result.map(item => this.getId(item)));
|
|
189
|
-
// Items present in the page are taken from it - the server copy is the
|
|
190
|
-
// up-to-date one.
|
|
191
|
-
const retained = loaded.filter(item => ! fetchedIds.has(this.getId(item)));
|
|
192
|
-
|
|
193
|
-
// Nothing can be missing in between when there is nothing to stitch,
|
|
194
|
-
// when the page is everything the remote side has (it is shorter than
|
|
195
|
-
// the requested limit), or when the page reaches the newest loaded item.
|
|
196
|
-
const isContinuous = ! retained.length
|
|
197
|
-
|| result.length < this.internalState.fetchLimit
|
|
198
|
-
|| fetchedIds.has(this.getId(loaded[loaded.length - 1]));
|
|
199
|
-
|
|
200
|
-
if (! isContinuous) {
|
|
201
|
-
this.markGapBefore(this.getId(result[0]));
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
this._items.deleteAll(); // Directly call deleteAll to prevent event emit.
|
|
205
|
-
this.addItems([...retained, ...result], 'tail');
|
|
206
|
-
this.internalState.current = WindowState.LATEST;
|
|
207
|
-
this.emitChangeWithDiff(true, originalState);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
136
|
public async fetchPrevious(): Promise<void> {
|
|
211
137
|
if (this.internalState.ongoing || this.hasOldest) {
|
|
212
138
|
return;
|
|
@@ -214,7 +140,6 @@ export abstract class TraversableRemoteCollection<
|
|
|
214
140
|
|
|
215
141
|
let result;
|
|
216
142
|
const originalState = this.state;
|
|
217
|
-
const firstItem = this.getAt(0);
|
|
218
143
|
this.internalState.ongoing = WindowState.PAST;
|
|
219
144
|
|
|
220
145
|
try {
|
|
@@ -229,6 +154,7 @@ export abstract class TraversableRemoteCollection<
|
|
|
229
154
|
}
|
|
230
155
|
|
|
231
156
|
if (! result.length) {
|
|
157
|
+
const firstItem = this.getAt(0);
|
|
232
158
|
this.internalState.oldestId = firstItem ? this.getId(firstItem) : null;
|
|
233
159
|
|
|
234
160
|
await this.refreshFetchedState();
|
|
@@ -242,13 +168,6 @@ export abstract class TraversableRemoteCollection<
|
|
|
242
168
|
return;
|
|
243
169
|
}
|
|
244
170
|
|
|
245
|
-
if (firstItem) {
|
|
246
|
-
// The fetch asked for the items right before the one that was first,
|
|
247
|
-
// so whatever came back is its real predecessor: a gap marked in
|
|
248
|
-
// front of it (it used to be the top of the window) is closed now.
|
|
249
|
-
this.clearGapBefore(this.getId(firstItem));
|
|
250
|
-
}
|
|
251
|
-
|
|
252
171
|
this.addItems(result, 'head');
|
|
253
172
|
await this.refreshFetchedState();
|
|
254
173
|
this.emitChangeWithDiff(true, originalState);
|
|
@@ -298,7 +217,6 @@ export abstract class TraversableRemoteCollection<
|
|
|
298
217
|
|
|
299
218
|
if (result) {
|
|
300
219
|
this._items.deleteAll(); // Directly call deleteAll to prevent event emit.
|
|
301
|
-
this.internalState.gaps = []; // Whole content is replaced, old markers mean nothing.
|
|
302
220
|
this.addItems(result, 'tail');
|
|
303
221
|
await this.refreshFetchedState();
|
|
304
222
|
}
|
|
@@ -309,16 +227,6 @@ export abstract class TraversableRemoteCollection<
|
|
|
309
227
|
this.emitChangeWithDiff(!!result, originalState);
|
|
310
228
|
}
|
|
311
229
|
|
|
312
|
-
public delete(...ids: string[]): void {
|
|
313
|
-
super.delete(...ids);
|
|
314
|
-
this.dropDanglingGaps();
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
public deleteAll(): void {
|
|
318
|
-
super.deleteAll();
|
|
319
|
-
this.internalState.gaps = [];
|
|
320
|
-
}
|
|
321
|
-
|
|
322
230
|
protected abstract fetchLatestItems(): Promise<ItemT[]>;
|
|
323
231
|
|
|
324
232
|
protected abstract fetchItemsBefore(): Promise<ItemT[] | null>;
|
|
@@ -347,8 +255,6 @@ export abstract class TraversableRemoteCollection<
|
|
|
347
255
|
// Directly calls to prevent event emit.
|
|
348
256
|
this._items.deleteAll();
|
|
349
257
|
this._items.set(...(result.map(item => [this.getId(item), item] as [string, ItemT])));
|
|
350
|
-
|
|
351
|
-
this.dropDanglingGaps();
|
|
352
258
|
}
|
|
353
259
|
|
|
354
260
|
protected emitChangeWithDiff(itemChanged: boolean, originalState: WindowState): void {
|
|
@@ -357,37 +263,6 @@ export abstract class TraversableRemoteCollection<
|
|
|
357
263
|
}
|
|
358
264
|
}
|
|
359
265
|
|
|
360
|
-
/**
|
|
361
|
-
* Record that the history is not continuous in front of the given item.
|
|
362
|
-
*/
|
|
363
|
-
protected markGapBefore(id: string): void {
|
|
364
|
-
if (! this.internalState.gaps.includes(id)) {
|
|
365
|
-
this.internalState.gaps = [...this.internalState.gaps, id];
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* Forget the gap in front of the given item - the items before it are known
|
|
371
|
-
* to be its real predecessors now.
|
|
372
|
-
*/
|
|
373
|
-
protected clearGapBefore(id: string): void {
|
|
374
|
-
if (this.internalState.gaps.includes(id)) {
|
|
375
|
-
this.internalState.gaps = this.internalState.gaps.filter(gapId => gapId !== id);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Forget the gap markers pointing at items that are no longer in the window
|
|
381
|
-
* (trimmed, deleted or replaced), so `gaps` never refers to nothing.
|
|
382
|
-
*/
|
|
383
|
-
protected dropDanglingGaps(): void {
|
|
384
|
-
if (! this.internalState.gaps.length) {
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
this.internalState.gaps = this.internalState.gaps.filter(id => this.has(id));
|
|
389
|
-
}
|
|
390
|
-
|
|
391
266
|
/**
|
|
392
267
|
* Return array with messages trimmed using High/Low Watermark strategy.
|
|
393
268
|
*/
|
|
@@ -472,13 +347,6 @@ export class TopicHistoryWindow extends TraversableRemoteCollection<
|
|
|
472
347
|
return super.resetToLatest(force);
|
|
473
348
|
}
|
|
474
349
|
|
|
475
|
-
public async resyncToLatest(): Promise<void> {
|
|
476
|
-
if (this.internalState.traverseLock) {
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
return super.resyncToLatest();
|
|
480
|
-
}
|
|
481
|
-
|
|
482
350
|
public async fetchNext(): Promise<void> {
|
|
483
351
|
if (this.internalState.traverseLock) {
|
|
484
352
|
return;
|
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
import {ChatStateTracker} from "./ChatStateTracker";
|
|
2
|
-
import {ObservableIndexedObjectCollection} from "../IndexedObjectCollection";
|
|
3
|
-
import {RoomMember, Session, SpaceMember, User} from "../types/src";
|
|
4
|
-
import {extractUserFromMember} from "./functions";
|
|
5
|
-
import {EventTarget} from "../EventTarget";
|
|
6
|
-
|
|
7
|
-
export class UsersManager {
|
|
8
|
-
public readonly onlineStatus = new EventTarget<{ change: User }>();
|
|
9
|
-
|
|
10
|
-
private readonly users: ObservableIndexedObjectCollection<User> = new ObservableIndexedObjectCollection('id');
|
|
11
|
-
|
|
12
|
-
public constructor(private tracker: ChatStateTracker) {
|
|
13
|
-
// RoomMemberUpdated & SpaceMemberUpdated events are not contains user object
|
|
14
|
-
tracker.client.on('UserUpdated', event => this.handleUsers([event.user]));
|
|
15
|
-
tracker.client.on('RoomMembersJoined', event => this.handleMembers(event.members));
|
|
16
|
-
tracker.client.on('SpaceMemberJoined', event => this.handleMembers([event.member]));
|
|
17
|
-
tracker.client.on('SpaceMembers', event => this.handleMembers(event.members));
|
|
18
|
-
tracker.client.on('RoomMembers', event => this.handleMembers(event.members));
|
|
19
|
-
tracker.client.on('Messages', event => this.handleUsers(event.messages.map(message => message.author.user)));
|
|
20
|
-
tracker.client.on('NewMessage', event => this.handleUsers([event.message.author.user]));
|
|
21
|
-
tracker.client.on('Session', event => this.handleSession(event));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Get all available (cached) user objects at once.
|
|
26
|
-
*/
|
|
27
|
-
public async getAvailable(): Promise<ObservableIndexedObjectCollection<User>> {
|
|
28
|
-
return this.users;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
private handleMembers(members: (RoomMember | SpaceMember)[]): void {
|
|
32
|
-
this.handleUsers(members.map(extractUserFromMember));
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
private handleSession(session: Session): void {
|
|
36
|
-
// Keep the "seen users" cache across reconnects so bound user lists do
|
|
37
|
-
// not blank out; just ensure our own user is present/updated. Stale
|
|
38
|
-
// entries are refreshed as member/message collections refetch.
|
|
39
|
-
this.handleUsers([session.user]);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
private handleUsers(users: User[]): void {
|
|
43
|
-
users.forEach(newUser => {
|
|
44
|
-
const oldUser = this.users.get(newUser.id);
|
|
45
|
-
if (oldUser && oldUser.
|
|
46
|
-
this.onlineStatus.emit('change', newUser);
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
this.users.set(...users);
|
|
51
|
-
}
|
|
1
|
+
import {ChatStateTracker} from "./ChatStateTracker";
|
|
2
|
+
import {ObservableIndexedObjectCollection} from "../IndexedObjectCollection";
|
|
3
|
+
import {RoomMember, Session, SpaceMember, User} from "../types/src";
|
|
4
|
+
import {extractUserFromMember} from "./functions";
|
|
5
|
+
import {EventTarget} from "../EventTarget";
|
|
6
|
+
|
|
7
|
+
export class UsersManager {
|
|
8
|
+
public readonly onlineStatus = new EventTarget<{ change: User }>();
|
|
9
|
+
|
|
10
|
+
private readonly users: ObservableIndexedObjectCollection<User> = new ObservableIndexedObjectCollection('id');
|
|
11
|
+
|
|
12
|
+
public constructor(private tracker: ChatStateTracker) {
|
|
13
|
+
// RoomMemberUpdated & SpaceMemberUpdated events are not contains user object
|
|
14
|
+
tracker.client.on('UserUpdated', event => this.handleUsers([event.user]));
|
|
15
|
+
tracker.client.on('RoomMembersJoined', event => this.handleMembers(event.members));
|
|
16
|
+
tracker.client.on('SpaceMemberJoined', event => this.handleMembers([event.member]));
|
|
17
|
+
tracker.client.on('SpaceMembers', event => this.handleMembers(event.members));
|
|
18
|
+
tracker.client.on('RoomMembers', event => this.handleMembers(event.members));
|
|
19
|
+
tracker.client.on('Messages', event => this.handleUsers(event.messages.map(message => message.author.user)));
|
|
20
|
+
tracker.client.on('NewMessage', event => this.handleUsers([event.message.author.user]));
|
|
21
|
+
tracker.client.on('Session', event => this.handleSession(event));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get all available (cached) user objects at once.
|
|
26
|
+
*/
|
|
27
|
+
public async getAvailable(): Promise<ObservableIndexedObjectCollection<User>> {
|
|
28
|
+
return this.users;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private handleMembers(members: (RoomMember | SpaceMember)[]): void {
|
|
32
|
+
this.handleUsers(members.map(extractUserFromMember));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private handleSession(session: Session): void {
|
|
36
|
+
// Keep the "seen users" cache across reconnects so bound user lists do
|
|
37
|
+
// not blank out; just ensure our own user is present/updated. Stale
|
|
38
|
+
// entries are refreshed as member/message collections refetch.
|
|
39
|
+
this.handleUsers([session.user]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private handleUsers(users: User[]): void {
|
|
43
|
+
users.forEach(newUser => {
|
|
44
|
+
const oldUser = this.users.get(newUser.id);
|
|
45
|
+
if (oldUser && oldUser.status !== newUser.status) {
|
|
46
|
+
this.onlineStatus.emit('change', newUser);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
this.users.set(...users);
|
|
51
|
+
}
|
|
52
52
|
}
|
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
import {Role, RoomMember, SpaceMember, User} from "../types/src";
|
|
2
|
-
|
|
3
|
-
export function reorderRolesOnPriorityUpdate(allRoles: Role[], oldRole: Role, updatedRole: Role): Role[] {
|
|
4
|
-
// If the priority has changed, adjust the rest of roles
|
|
5
|
-
const increased = (updatedRole.priority - oldRole.priority) > 0;
|
|
6
|
-
const decreased = ! increased;
|
|
7
|
-
const changedRoles: Role[] = [];
|
|
8
|
-
|
|
9
|
-
allRoles.forEach(role => {
|
|
10
|
-
if (role.id === updatedRole.id) {
|
|
11
|
-
// Skip the updated role
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
if (increased && oldRole.priority <= role.priority) {
|
|
15
|
-
role.priority--;
|
|
16
|
-
changedRoles.push(role);
|
|
17
|
-
}
|
|
18
|
-
if (decreased && updatedRole.priority <= role.priority) {
|
|
19
|
-
role.priority++;
|
|
20
|
-
changedRoles.push(role);
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
return changedRoles;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function extractUserFromMember(member: RoomMember | SpaceMember): User | null {
|
|
28
|
-
return member.user ?? (member as RoomMember).spaceMember?.user;
|
|
1
|
+
import {Role, RoomMember, SpaceMember, User} from "../types/src";
|
|
2
|
+
|
|
3
|
+
export function reorderRolesOnPriorityUpdate(allRoles: Role[], oldRole: Role, updatedRole: Role): Role[] {
|
|
4
|
+
// If the priority has changed, adjust the rest of roles
|
|
5
|
+
const increased = (updatedRole.priority - oldRole.priority) > 0;
|
|
6
|
+
const decreased = ! increased;
|
|
7
|
+
const changedRoles: Role[] = [];
|
|
8
|
+
|
|
9
|
+
allRoles.forEach(role => {
|
|
10
|
+
if (role.id === updatedRole.id) {
|
|
11
|
+
// Skip the updated role
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (increased && oldRole.priority <= role.priority) {
|
|
15
|
+
role.priority--;
|
|
16
|
+
changedRoles.push(role);
|
|
17
|
+
}
|
|
18
|
+
if (decreased && updatedRole.priority <= role.priority) {
|
|
19
|
+
role.priority++;
|
|
20
|
+
changedRoles.push(role);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return changedRoles;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function extractUserFromMember(member: RoomMember | SpaceMember): User | null {
|
|
28
|
+
return member.user ?? (member as RoomMember).spaceMember?.user;
|
|
29
29
|
}
|