Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 2adc972

Browse files
author
Kerry
authored
Live location sharing - stop sharing to beacons in rooms you left (#8187)
* remove beacons on membership changes * add addMembershipToMockedRoom test util Signed-off-by: Kerry Archibald <[email protected]> * test remove beacons on membership changes Signed-off-by: Kerry Archibald <[email protected]> * removelistener Signed-off-by: Kerry Archibald <[email protected]>
1 parent e161f0b commit 2adc972

File tree

4 files changed

+267
-58
lines changed

4 files changed

+267
-58
lines changed

src/stores/OwnBeaconStore.ts

Lines changed: 116 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import {
2020
BeaconEvent,
2121
MatrixEvent,
2222
Room,
23+
RoomMember,
24+
RoomState,
25+
RoomStateEvent,
2326
} from "matrix-js-sdk/src/matrix";
2427
import {
2528
BeaconInfoState, makeBeaconContent, makeBeaconInfoContent,
@@ -90,6 +93,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
9093
protected async onNotReady() {
9194
this.matrixClient.removeListener(BeaconEvent.LivenessChange, this.onBeaconLiveness);
9295
this.matrixClient.removeListener(BeaconEvent.New, this.onNewBeacon);
96+
this.matrixClient.removeListener(RoomStateEvent.Members, this.onRoomStateMembers);
9397

9498
this.beacons.forEach(beacon => beacon.destroy());
9599

@@ -102,6 +106,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
102106
protected async onReady(): Promise<void> {
103107
this.matrixClient.on(BeaconEvent.LivenessChange, this.onBeaconLiveness);
104108
this.matrixClient.on(BeaconEvent.New, this.onNewBeacon);
109+
this.matrixClient.on(RoomStateEvent.Members, this.onRoomStateMembers);
105110

106111
this.initialiseBeaconState();
107112
}
@@ -136,6 +141,10 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
136141
return await this.updateBeaconEvent(beacon, { live: false });
137142
};
138143

144+
/**
145+
* Listeners
146+
*/
147+
139148
private onNewBeacon = (_event: MatrixEvent, beacon: Beacon): void => {
140149
if (!isOwnBeacon(beacon, this.matrixClient.getUserId())) {
141150
return;
@@ -160,6 +169,33 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
160169
this.emit(OwnBeaconStoreEvent.LivenessChange, this.getLiveBeaconIds());
161170
};
162171

172+
/**
173+
* Check for changes in membership in rooms with beacons
174+
* and stop monitoring beacons in rooms user is no longer member of
175+
*/
176+
private onRoomStateMembers = (_event: MatrixEvent, roomState: RoomState, member: RoomMember): void => {
177+
// no beacons for this room, ignore
178+
if (
179+
!this.beaconsByRoomId.has(roomState.roomId) ||
180+
member.userId !== this.matrixClient.getUserId()
181+
) {
182+
return;
183+
}
184+
185+
// TODO check powerlevels here
186+
// in PSF-797
187+
188+
// stop watching beacons in rooms where user is no longer a member
189+
if (member.membership === 'leave' || member.membership === 'ban') {
190+
this.beaconsByRoomId.get(roomState.roomId).forEach(this.removeBeacon);
191+
this.beaconsByRoomId.delete(roomState.roomId);
192+
}
193+
};
194+
195+
/**
196+
* State management
197+
*/
198+
163199
private initialiseBeaconState = () => {
164200
const userId = this.matrixClient.getUserId();
165201
const visibleRooms = this.matrixClient.getVisibleRooms();
@@ -187,6 +223,21 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
187223
beacon.monitorLiveness();
188224
};
189225

226+
/**
227+
* Remove listeners for a given beacon
228+
* remove from state
229+
* and update liveness if changed
230+
*/
231+
private removeBeacon = (beaconId: string): void => {
232+
if (!this.beacons.has(beaconId)) {
233+
return;
234+
}
235+
this.beacons.get(beaconId).destroy();
236+
this.beacons.delete(beaconId);
237+
238+
this.checkLiveness();
239+
};
240+
190241
private checkLiveness = (): void => {
191242
const prevLiveBeaconIds = this.getLiveBeaconIds();
192243
this.liveBeaconIds = [...this.beacons.values()]
@@ -218,20 +269,9 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
218269
}
219270
};
220271

221-
private updateBeaconEvent = async (beacon: Beacon, update: Partial<BeaconInfoState>): Promise<void> => {
222-
const { description, timeout, timestamp, live, assetType } = {
223-
...beacon.beaconInfo,
224-
...update,
225-
};
226-
227-
const updateContent = makeBeaconInfoContent(timeout,
228-
live,
229-
description,
230-
assetType,
231-
timestamp);
232-
233-
await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent);
234-
};
272+
/**
273+
* Geolocation
274+
*/
235275

236276
private togglePollingLocation = () => {
237277
if (!!this.liveBeaconIds.length) {
@@ -270,17 +310,6 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
270310
this.emit(OwnBeaconStoreEvent.MonitoringLivePosition);
271311
};
272312

273-
private onWatchedPosition = (position: GeolocationPosition) => {
274-
const timedGeoPosition = mapGeolocationPositionToTimedGeo(position);
275-
276-
// if this is our first position, publish immediateley
277-
if (!this.lastPublishedPositionTimestamp) {
278-
this.publishLocationToBeacons(timedGeoPosition);
279-
} else {
280-
this.debouncedPublishLocationToBeacons(timedGeoPosition);
281-
}
282-
};
283-
284313
private stopPollingLocation = () => {
285314
clearInterval(this.locationInterval);
286315
this.locationInterval = undefined;
@@ -295,26 +324,34 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
295324
this.emit(OwnBeaconStoreEvent.MonitoringLivePosition);
296325
};
297326

298-
/**
299-
* Sends m.location events to all live beacons
300-
* Sets last published beacon
301-
*/
302-
private publishLocationToBeacons = async (position: TimedGeoUri) => {
303-
this.lastPublishedPositionTimestamp = Date.now();
304-
// TODO handle failure in individual beacon without rejecting rest
305-
await Promise.all(this.liveBeaconIds.map(beaconId =>
306-
this.sendLocationToBeacon(this.beacons.get(beaconId), position)),
307-
);
327+
private onWatchedPosition = (position: GeolocationPosition) => {
328+
const timedGeoPosition = mapGeolocationPositionToTimedGeo(position);
329+
330+
// if this is our first position, publish immediateley
331+
if (!this.lastPublishedPositionTimestamp) {
332+
this.publishLocationToBeacons(timedGeoPosition);
333+
} else {
334+
this.debouncedPublishLocationToBeacons(timedGeoPosition);
335+
}
308336
};
309337

310-
private debouncedPublishLocationToBeacons = debounce(this.publishLocationToBeacons, MOVING_UPDATE_INTERVAL);
338+
private onGeolocationError = async (error: GeolocationError): Promise<void> => {
339+
this.geolocationError = error;
340+
logger.error('Geolocation failed', this.geolocationError);
311341

312-
/**
313-
* Sends m.location event to referencing given beacon
314-
*/
315-
private sendLocationToBeacon = async (beacon: Beacon, { geoUri, timestamp }: TimedGeoUri) => {
316-
const content = makeBeaconContent(geoUri, timestamp, beacon.beaconInfoId);
317-
await this.matrixClient.sendEvent(beacon.roomId, M_BEACON.name, content);
342+
// other errors are considered non-fatal
343+
// and self recovering
344+
if (![
345+
GeolocationError.Unavailable,
346+
GeolocationError.PermissionDenied,
347+
].includes(error)) {
348+
return;
349+
}
350+
351+
this.stopPollingLocation();
352+
// kill live beacons when location permissions are revoked
353+
// TODO may need adjustment when PSF-797 is done
354+
await Promise.all(this.liveBeaconIds.map(this.stopBeacon));
318355
};
319356

320357
/**
@@ -332,22 +369,44 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
332369
}
333370
};
334371

335-
private onGeolocationError = async (error: GeolocationError): Promise<void> => {
336-
this.geolocationError = error;
337-
logger.error('Geolocation failed', this.geolocationError);
372+
/**
373+
* MatrixClient api
374+
*/
338375

339-
// other errors are considered non-fatal
340-
// and self recovering
341-
if (![
342-
GeolocationError.Unavailable,
343-
GeolocationError.PermissionDenied,
344-
].includes(error)) {
345-
return;
346-
}
376+
private updateBeaconEvent = async (beacon: Beacon, update: Partial<BeaconInfoState>): Promise<void> => {
377+
const { description, timeout, timestamp, live, assetType } = {
378+
...beacon.beaconInfo,
379+
...update,
380+
};
347381

348-
this.stopPollingLocation();
349-
// kill live beacons when location permissions are revoked
350-
// TODO may need adjustment when PSF-797 is done
351-
await Promise.all(this.liveBeaconIds.map(this.stopBeacon));
382+
const updateContent = makeBeaconInfoContent(timeout,
383+
live,
384+
description,
385+
assetType,
386+
timestamp);
387+
388+
await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent);
389+
};
390+
391+
/**
392+
* Sends m.location events to all live beacons
393+
* Sets last published beacon
394+
*/
395+
private publishLocationToBeacons = async (position: TimedGeoUri) => {
396+
this.lastPublishedPositionTimestamp = Date.now();
397+
// TODO handle failure in individual beacon without rejecting rest
398+
await Promise.all(this.liveBeaconIds.map(beaconId =>
399+
this.sendLocationToBeacon(this.beacons.get(beaconId), position)),
400+
);
401+
};
402+
403+
private debouncedPublishLocationToBeacons = debounce(this.publishLocationToBeacons, MOVING_UPDATE_INTERVAL);
404+
405+
/**
406+
* Sends m.location event to referencing given beacon
407+
*/
408+
private sendLocationToBeacon = async (beacon: Beacon, { geoUri, timestamp }: TimedGeoUri) => {
409+
const content = makeBeaconContent(geoUri, timestamp, beacon.beaconInfoId);
410+
await this.matrixClient.sendEvent(beacon.roomId, M_BEACON.name, content);
352411
};
353412
}

test/stores/OwnBeaconStore-test.ts

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { Room, Beacon, BeaconEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
17+
import {
18+
Room,
19+
Beacon,
20+
BeaconEvent,
21+
MatrixEvent,
22+
RoomStateEvent,
23+
RoomMember,
24+
} from "matrix-js-sdk/src/matrix";
1825
import { makeBeaconContent } from "matrix-js-sdk/src/content-helpers";
1926
import { M_BEACON, M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon";
2027
import { logger } from "matrix-js-sdk/src/logger";
@@ -23,6 +30,7 @@ import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../src/stores/OwnBeaconS
2330
import {
2431
advanceDateAndTime,
2532
flushPromisesWithFakeTimers,
33+
makeMembershipEvent,
2634
resetAsyncStoreWithClient,
2735
setupAsyncStoreWithClient,
2836
} from "../test-utils";
@@ -243,6 +251,7 @@ describe('OwnBeaconStore', () => {
243251

244252
expect(removeSpy.mock.calls[0]).toEqual(expect.arrayContaining([BeaconEvent.LivenessChange]));
245253
expect(removeSpy.mock.calls[1]).toEqual(expect.arrayContaining([BeaconEvent.New]));
254+
expect(removeSpy.mock.calls[2]).toEqual(expect.arrayContaining([RoomStateEvent.Members]));
246255
});
247256

248257
it('destroys beacons', async () => {
@@ -509,6 +518,112 @@ describe('OwnBeaconStore', () => {
509518
});
510519
});
511520

521+
describe('on room membership changes', () => {
522+
it('ignores events for rooms without beacons', async () => {
523+
const membershipEvent = makeMembershipEvent(room2Id, aliceId);
524+
// no beacons for room2
525+
const [, room2] = makeRoomsWithStateEvents([
526+
alicesRoom1BeaconInfo,
527+
]);
528+
const store = await makeOwnBeaconStore();
529+
const emitSpy = jest.spyOn(store, 'emit');
530+
const oldLiveBeaconIds = store.getLiveBeaconIds();
531+
532+
mockClient.emit(
533+
RoomStateEvent.Members,
534+
membershipEvent,
535+
room2.currentState,
536+
new RoomMember(room2Id, aliceId),
537+
);
538+
539+
expect(emitSpy).not.toHaveBeenCalled();
540+
// strictly equal
541+
expect(store.getLiveBeaconIds()).toBe(oldLiveBeaconIds);
542+
});
543+
544+
it('ignores events for membership changes that are not current user', async () => {
545+
// bob joins room1
546+
const membershipEvent = makeMembershipEvent(room1Id, bobId);
547+
const member = new RoomMember(room1Id, bobId);
548+
member.setMembershipEvent(membershipEvent);
549+
550+
const [room1] = makeRoomsWithStateEvents([
551+
alicesRoom1BeaconInfo,
552+
]);
553+
const store = await makeOwnBeaconStore();
554+
const emitSpy = jest.spyOn(store, 'emit');
555+
const oldLiveBeaconIds = store.getLiveBeaconIds();
556+
557+
mockClient.emit(
558+
RoomStateEvent.Members,
559+
membershipEvent,
560+
room1.currentState,
561+
member,
562+
);
563+
564+
expect(emitSpy).not.toHaveBeenCalled();
565+
// strictly equal
566+
expect(store.getLiveBeaconIds()).toBe(oldLiveBeaconIds);
567+
});
568+
569+
it('ignores events for membership changes that are not leave/ban', async () => {
570+
// alice joins room1
571+
const membershipEvent = makeMembershipEvent(room1Id, aliceId);
572+
const member = new RoomMember(room1Id, aliceId);
573+
member.setMembershipEvent(membershipEvent);
574+
575+
const [room1] = makeRoomsWithStateEvents([
576+
alicesRoom1BeaconInfo,
577+
alicesRoom2BeaconInfo,
578+
]);
579+
const store = await makeOwnBeaconStore();
580+
const emitSpy = jest.spyOn(store, 'emit');
581+
const oldLiveBeaconIds = store.getLiveBeaconIds();
582+
583+
mockClient.emit(
584+
RoomStateEvent.Members,
585+
membershipEvent,
586+
room1.currentState,
587+
member,
588+
);
589+
590+
expect(emitSpy).not.toHaveBeenCalled();
591+
// strictly equal
592+
expect(store.getLiveBeaconIds()).toBe(oldLiveBeaconIds);
593+
});
594+
595+
it('destroys and removes beacons when current user leaves room', async () => {
596+
// alice leaves room1
597+
const membershipEvent = makeMembershipEvent(room1Id, aliceId, 'leave');
598+
const member = new RoomMember(room1Id, aliceId);
599+
member.setMembershipEvent(membershipEvent);
600+
601+
const [room1] = makeRoomsWithStateEvents([
602+
alicesRoom1BeaconInfo,
603+
alicesRoom2BeaconInfo,
604+
]);
605+
const store = await makeOwnBeaconStore();
606+
const room1BeaconInstance = store.beacons.get(alicesRoom1BeaconInfo.getType());
607+
const beaconDestroySpy = jest.spyOn(room1BeaconInstance, 'destroy');
608+
const emitSpy = jest.spyOn(store, 'emit');
609+
610+
mockClient.emit(
611+
RoomStateEvent.Members,
612+
membershipEvent,
613+
room1.currentState,
614+
member,
615+
);
616+
617+
expect(emitSpy).toHaveBeenCalledWith(
618+
OwnBeaconStoreEvent.LivenessChange,
619+
// other rooms beacons still live
620+
[alicesRoom2BeaconInfo.getType()],
621+
);
622+
expect(beaconDestroySpy).toHaveBeenCalledTimes(1);
623+
expect(store.getLiveBeaconIds(room1Id)).toEqual([]);
624+
});
625+
});
626+
512627
describe('stopBeacon()', () => {
513628
beforeEach(() => {
514629
makeRoomsWithStateEvents([

test/test-utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * from './beacon';
22
export * from './client';
33
export * from './location';
44
export * from './platform';
5+
export * from './room';
56
export * from './test-utils';
67
// TODO @@TR: Export voice.ts, which currently isn't exported here because it causes all tests to depend on skinning
78
export * from './wrappers';

0 commit comments

Comments
 (0)