diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index 6f7bec93dd..3404998287 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -20,6 +20,9 @@ import { BeaconEvent, MatrixEvent, Room, + RoomMember, + RoomState, + RoomStateEvent, } from "matrix-js-sdk/src/matrix"; import { BeaconInfoState, makeBeaconContent, makeBeaconInfoContent, @@ -90,6 +93,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { protected async onNotReady() { this.matrixClient.removeListener(BeaconEvent.LivenessChange, this.onBeaconLiveness); this.matrixClient.removeListener(BeaconEvent.New, this.onNewBeacon); + this.matrixClient.removeListener(RoomStateEvent.Members, this.onRoomStateMembers); this.beacons.forEach(beacon => beacon.destroy()); @@ -102,6 +106,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { protected async onReady(): Promise { this.matrixClient.on(BeaconEvent.LivenessChange, this.onBeaconLiveness); this.matrixClient.on(BeaconEvent.New, this.onNewBeacon); + this.matrixClient.on(RoomStateEvent.Members, this.onRoomStateMembers); this.initialiseBeaconState(); } @@ -136,6 +141,10 @@ export class OwnBeaconStore extends AsyncStoreWithClient { return await this.updateBeaconEvent(beacon, { live: false }); }; + /** + * Listeners + */ + private onNewBeacon = (_event: MatrixEvent, beacon: Beacon): void => { if (!isOwnBeacon(beacon, this.matrixClient.getUserId())) { return; @@ -160,6 +169,33 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.emit(OwnBeaconStoreEvent.LivenessChange, this.getLiveBeaconIds()); }; + /** + * Check for changes in membership in rooms with beacons + * and stop monitoring beacons in rooms user is no longer member of + */ + private onRoomStateMembers = (_event: MatrixEvent, roomState: RoomState, member: RoomMember): void => { + // no beacons for this room, ignore + if ( + !this.beaconsByRoomId.has(roomState.roomId) || + member.userId !== this.matrixClient.getUserId() + ) { + return; + } + + // TODO check powerlevels here + // in PSF-797 + + // stop watching beacons in rooms where user is no longer a member + if (member.membership === 'leave' || member.membership === 'ban') { + this.beaconsByRoomId.get(roomState.roomId).forEach(this.removeBeacon); + this.beaconsByRoomId.delete(roomState.roomId); + } + }; + + /** + * State management + */ + private initialiseBeaconState = () => { const userId = this.matrixClient.getUserId(); const visibleRooms = this.matrixClient.getVisibleRooms(); @@ -187,6 +223,21 @@ export class OwnBeaconStore extends AsyncStoreWithClient { beacon.monitorLiveness(); }; + /** + * Remove listeners for a given beacon + * remove from state + * and update liveness if changed + */ + private removeBeacon = (beaconId: string): void => { + if (!this.beacons.has(beaconId)) { + return; + } + this.beacons.get(beaconId).destroy(); + this.beacons.delete(beaconId); + + this.checkLiveness(); + }; + private checkLiveness = (): void => { const prevLiveBeaconIds = this.getLiveBeaconIds(); this.liveBeaconIds = [...this.beacons.values()] @@ -218,20 +269,9 @@ export class OwnBeaconStore extends AsyncStoreWithClient { } }; - private updateBeaconEvent = async (beacon: Beacon, update: Partial): Promise => { - const { description, timeout, timestamp, live, assetType } = { - ...beacon.beaconInfo, - ...update, - }; - - const updateContent = makeBeaconInfoContent(timeout, - live, - description, - assetType, - timestamp); - - await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent); - }; + /** + * Geolocation + */ private togglePollingLocation = () => { if (!!this.liveBeaconIds.length) { @@ -270,17 +310,6 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.emit(OwnBeaconStoreEvent.MonitoringLivePosition); }; - private onWatchedPosition = (position: GeolocationPosition) => { - const timedGeoPosition = mapGeolocationPositionToTimedGeo(position); - - // if this is our first position, publish immediateley - if (!this.lastPublishedPositionTimestamp) { - this.publishLocationToBeacons(timedGeoPosition); - } else { - this.debouncedPublishLocationToBeacons(timedGeoPosition); - } - }; - private stopPollingLocation = () => { clearInterval(this.locationInterval); this.locationInterval = undefined; @@ -295,6 +324,70 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.emit(OwnBeaconStoreEvent.MonitoringLivePosition); }; + private onWatchedPosition = (position: GeolocationPosition) => { + const timedGeoPosition = mapGeolocationPositionToTimedGeo(position); + + // if this is our first position, publish immediateley + if (!this.lastPublishedPositionTimestamp) { + this.publishLocationToBeacons(timedGeoPosition); + } else { + this.debouncedPublishLocationToBeacons(timedGeoPosition); + } + }; + + private onGeolocationError = async (error: GeolocationError): Promise => { + this.geolocationError = error; + logger.error('Geolocation failed', this.geolocationError); + + // other errors are considered non-fatal + // and self recovering + if (![ + GeolocationError.Unavailable, + GeolocationError.PermissionDenied, + ].includes(error)) { + return; + } + + this.stopPollingLocation(); + // kill live beacons when location permissions are revoked + // TODO may need adjustment when PSF-797 is done + await Promise.all(this.liveBeaconIds.map(this.stopBeacon)); + }; + + /** + * Gets the current location + * (as opposed to using watched location) + * and publishes it to all live beacons + */ + private publishCurrentLocationToBeacons = async () => { + try { + const position = await getCurrentPosition(); + // TODO error handling + this.publishLocationToBeacons(mapGeolocationPositionToTimedGeo(position)); + } catch (error) { + this.onGeolocationError(error?.message); + } + }; + + /** + * MatrixClient api + */ + + private updateBeaconEvent = async (beacon: Beacon, update: Partial): Promise => { + const { description, timeout, timestamp, live, assetType } = { + ...beacon.beaconInfo, + ...update, + }; + + const updateContent = makeBeaconInfoContent(timeout, + live, + description, + assetType, + timestamp); + + await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent); + }; + /** * Sends m.location events to all live beacons * Sets last published beacon @@ -316,38 +409,4 @@ export class OwnBeaconStore extends AsyncStoreWithClient { const content = makeBeaconContent(geoUri, timestamp, beacon.beaconInfoId); await this.matrixClient.sendEvent(beacon.roomId, M_BEACON.name, content); }; - - /** - * Gets the current location - * (as opposed to using watched location) - * and publishes it to all live beacons - */ - private publishCurrentLocationToBeacons = async () => { - try { - const position = await getCurrentPosition(); - // TODO error handling - this.publishLocationToBeacons(mapGeolocationPositionToTimedGeo(position)); - } catch (error) { - this.onGeolocationError(error?.message); - } - }; - - private onGeolocationError = async (error: GeolocationError): Promise => { - this.geolocationError = error; - logger.error('Geolocation failed', this.geolocationError); - - // other errors are considered non-fatal - // and self recovering - if (![ - GeolocationError.Unavailable, - GeolocationError.PermissionDenied, - ].includes(error)) { - return; - } - - this.stopPollingLocation(); - // kill live beacons when location permissions are revoked - // TODO may need adjustment when PSF-797 is done - await Promise.all(this.liveBeaconIds.map(this.stopBeacon)); - }; } diff --git a/test/stores/OwnBeaconStore-test.ts b/test/stores/OwnBeaconStore-test.ts index 87b3c4c602..d40708109e 100644 --- a/test/stores/OwnBeaconStore-test.ts +++ b/test/stores/OwnBeaconStore-test.ts @@ -14,7 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Room, Beacon, BeaconEvent, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { + Room, + Beacon, + BeaconEvent, + MatrixEvent, + RoomStateEvent, + RoomMember, +} from "matrix-js-sdk/src/matrix"; import { makeBeaconContent } from "matrix-js-sdk/src/content-helpers"; import { M_BEACON, M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon"; import { logger } from "matrix-js-sdk/src/logger"; @@ -23,6 +30,7 @@ import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../src/stores/OwnBeaconS import { advanceDateAndTime, flushPromisesWithFakeTimers, + makeMembershipEvent, resetAsyncStoreWithClient, setupAsyncStoreWithClient, } from "../test-utils"; @@ -243,6 +251,7 @@ describe('OwnBeaconStore', () => { expect(removeSpy.mock.calls[0]).toEqual(expect.arrayContaining([BeaconEvent.LivenessChange])); expect(removeSpy.mock.calls[1]).toEqual(expect.arrayContaining([BeaconEvent.New])); + expect(removeSpy.mock.calls[2]).toEqual(expect.arrayContaining([RoomStateEvent.Members])); }); it('destroys beacons', async () => { @@ -509,6 +518,112 @@ describe('OwnBeaconStore', () => { }); }); + describe('on room membership changes', () => { + it('ignores events for rooms without beacons', async () => { + const membershipEvent = makeMembershipEvent(room2Id, aliceId); + // no beacons for room2 + const [, room2] = makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + const oldLiveBeaconIds = store.getLiveBeaconIds(); + + mockClient.emit( + RoomStateEvent.Members, + membershipEvent, + room2.currentState, + new RoomMember(room2Id, aliceId), + ); + + expect(emitSpy).not.toHaveBeenCalled(); + // strictly equal + expect(store.getLiveBeaconIds()).toBe(oldLiveBeaconIds); + }); + + it('ignores events for membership changes that are not current user', async () => { + // bob joins room1 + const membershipEvent = makeMembershipEvent(room1Id, bobId); + const member = new RoomMember(room1Id, bobId); + member.setMembershipEvent(membershipEvent); + + const [room1] = makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + const oldLiveBeaconIds = store.getLiveBeaconIds(); + + mockClient.emit( + RoomStateEvent.Members, + membershipEvent, + room1.currentState, + member, + ); + + expect(emitSpy).not.toHaveBeenCalled(); + // strictly equal + expect(store.getLiveBeaconIds()).toBe(oldLiveBeaconIds); + }); + + it('ignores events for membership changes that are not leave/ban', async () => { + // alice joins room1 + const membershipEvent = makeMembershipEvent(room1Id, aliceId); + const member = new RoomMember(room1Id, aliceId); + member.setMembershipEvent(membershipEvent); + + const [room1] = makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesRoom2BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + const oldLiveBeaconIds = store.getLiveBeaconIds(); + + mockClient.emit( + RoomStateEvent.Members, + membershipEvent, + room1.currentState, + member, + ); + + expect(emitSpy).not.toHaveBeenCalled(); + // strictly equal + expect(store.getLiveBeaconIds()).toBe(oldLiveBeaconIds); + }); + + it('destroys and removes beacons when current user leaves room', async () => { + // alice leaves room1 + const membershipEvent = makeMembershipEvent(room1Id, aliceId, 'leave'); + const member = new RoomMember(room1Id, aliceId); + member.setMembershipEvent(membershipEvent); + + const [room1] = makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesRoom2BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const room1BeaconInstance = store.beacons.get(alicesRoom1BeaconInfo.getType()); + const beaconDestroySpy = jest.spyOn(room1BeaconInstance, 'destroy'); + const emitSpy = jest.spyOn(store, 'emit'); + + mockClient.emit( + RoomStateEvent.Members, + membershipEvent, + room1.currentState, + member, + ); + + expect(emitSpy).toHaveBeenCalledWith( + OwnBeaconStoreEvent.LivenessChange, + // other rooms beacons still live + [alicesRoom2BeaconInfo.getType()], + ); + expect(beaconDestroySpy).toHaveBeenCalledTimes(1); + expect(store.getLiveBeaconIds(room1Id)).toEqual([]); + }); + }); + describe('stopBeacon()', () => { beforeEach(() => { makeRoomsWithStateEvents([ diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 49abc51598..b14bda3cbb 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -2,6 +2,7 @@ export * from './beacon'; export * from './client'; export * from './location'; export * from './platform'; +export * from './room'; export * from './test-utils'; // TODO @@TR: Export voice.ts, which currently isn't exported here because it causes all tests to depend on skinning export * from './wrappers'; diff --git a/test/test-utils/room.ts b/test/test-utils/room.ts new file mode 100644 index 0000000000..022f13e6c1 --- /dev/null +++ b/test/test-utils/room.ts @@ -0,0 +1,34 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { + EventType, +} from "matrix-js-sdk/src/matrix"; + +import { mkEvent } from "./test-utils"; + +export const makeMembershipEvent = ( + roomId: string, userId: string, membership = 'join', +) => mkEvent({ + event: true, + type: EventType.RoomMember, + room: roomId, + user: userId, + skey: userId, + content: { membership }, + ts: Date.now(), +}); +