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

Live location sharing - stop sharing to beacons in rooms you left #8187

Merged
merged 4 commits into from
Mar 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 116 additions & 57 deletions src/stores/OwnBeaconStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import {
BeaconEvent,
MatrixEvent,
Room,
RoomMember,
RoomState,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import {
BeaconInfoState, makeBeaconContent, makeBeaconInfoContent,
Expand Down Expand Up @@ -90,6 +93,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
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());

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

this.initialiseBeaconState();
}
Expand Down Expand Up @@ -136,6 +141,10 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
return await this.updateBeaconEvent(beacon, { live: false });
};

/**
* Listeners
*/

private onNewBeacon = (_event: MatrixEvent, beacon: Beacon): void => {
if (!isOwnBeacon(beacon, this.matrixClient.getUserId())) {
return;
Expand All @@ -160,6 +169,33 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
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 => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is new, all others have just been shuffled

// 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();
Expand Down Expand Up @@ -187,6 +223,21 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
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()]
Expand Down Expand Up @@ -218,20 +269,9 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
}
};

private updateBeaconEvent = async (beacon: Beacon, update: Partial<BeaconInfoState>): Promise<void> => {
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) {
Expand Down Expand Up @@ -270,17 +310,6 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
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;
Expand All @@ -295,26 +324,34 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
this.emit(OwnBeaconStoreEvent.MonitoringLivePosition);
};

/**
* Sends m.location events to all live beacons
* Sets last published beacon
*/
private publishLocationToBeacons = async (position: TimedGeoUri) => {
this.lastPublishedPositionTimestamp = Date.now();
// TODO handle failure in individual beacon without rejecting rest
await Promise.all(this.liveBeaconIds.map(beaconId =>
this.sendLocationToBeacon(this.beacons.get(beaconId), position)),
);
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 debouncedPublishLocationToBeacons = debounce(this.publishLocationToBeacons, MOVING_UPDATE_INTERVAL);
private onGeolocationError = async (error: GeolocationError): Promise<void> => {
this.geolocationError = error;
logger.error('Geolocation failed', this.geolocationError);

/**
* Sends m.location event to referencing given beacon
*/
private sendLocationToBeacon = async (beacon: Beacon, { geoUri, timestamp }: TimedGeoUri) => {
const content = makeBeaconContent(geoUri, timestamp, beacon.beaconInfoId);
await this.matrixClient.sendEvent(beacon.roomId, M_BEACON.name, content);
// 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));
};

/**
Expand All @@ -332,22 +369,44 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
}
};

private onGeolocationError = async (error: GeolocationError): Promise<void> => {
this.geolocationError = error;
logger.error('Geolocation failed', this.geolocationError);
/**
* MatrixClient api
*/

// other errors are considered non-fatal
// and self recovering
if (![
GeolocationError.Unavailable,
GeolocationError.PermissionDenied,
].includes(error)) {
return;
}
private updateBeaconEvent = async (beacon: Beacon, update: Partial<BeaconInfoState>): Promise<void> => {
const { description, timeout, timestamp, live, assetType } = {
...beacon.beaconInfo,
...update,
};

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));
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
*/
private publishLocationToBeacons = async (position: TimedGeoUri) => {
this.lastPublishedPositionTimestamp = Date.now();
// TODO handle failure in individual beacon without rejecting rest
await Promise.all(this.liveBeaconIds.map(beaconId =>
this.sendLocationToBeacon(this.beacons.get(beaconId), position)),
);
};

private debouncedPublishLocationToBeacons = debounce(this.publishLocationToBeacons, MOVING_UPDATE_INTERVAL);

/**
* Sends m.location event to referencing given beacon
*/
private sendLocationToBeacon = async (beacon: Beacon, { geoUri, timestamp }: TimedGeoUri) => {
const content = makeBeaconContent(geoUri, timestamp, beacon.beaconInfoId);
await this.matrixClient.sendEvent(beacon.roomId, M_BEACON.name, content);
};
}
117 changes: 116 additions & 1 deletion test/stores/OwnBeaconStore-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -23,6 +30,7 @@ import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../src/stores/OwnBeaconS
import {
advanceDateAndTime,
flushPromisesWithFakeTimers,
makeMembershipEvent,
resetAsyncStoreWithClient,
setupAsyncStoreWithClient,
} from "../test-utils";
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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([
Expand Down
1 change: 1 addition & 0 deletions test/test-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading