Live location sharing - send geolocation beacon events - happy path (#8127)

* geolocation utilities

Signed-off-by: Kerry Archibald <kerrya@element.io>

* messy send events

Signed-off-by: Kerry Archibald <kerrya@element.io>

* add geolocation services

Signed-off-by: Kerry Archibald <kerrya@element.io>

* geolocation tests

Signed-off-by: Kerry Archibald <kerrya@element.io>

* debounce with backup emit every 30s

Signed-off-by: Kerry Archibald <kerrya@element.io>

* import reorder

Signed-off-by: Kerry Archibald <kerrya@element.io>

* some more working tests

Signed-off-by: Kerry Archibald <kerrya@element.io>

* complicated timeout testing

Signed-off-by: Kerry Archibald <kerrya@element.io>

* publish first location immediately

Signed-off-by: Kerry Archibald <kerrya@element.io>

* move advanceDateAndTime to utils, tidy

Signed-off-by: Kerry Archibald <kerrya@element.io>

* typos

Signed-off-by: Kerry Archibald <kerrya@element.io>

* types and lint

Signed-off-by: Kerry Archibald <kerrya@element.io>
This commit is contained in:
Kerry 2022-03-28 12:48:38 +02:00 committed by GitHub
parent f557ac9486
commit e9b2aea97b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 378 additions and 255 deletions

View file

@ -21,22 +21,22 @@ import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/client'; import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/client';
import classNames from 'classnames'; import classNames from 'classnames';
import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import MemberAvatar from '../avatars/MemberAvatar';
import MatrixClientContext from '../../../contexts/MatrixClientContext'; import MatrixClientContext from '../../../contexts/MatrixClientContext';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import ErrorDialog from '../dialogs/ErrorDialog'; import SdkConfig from '../../../SdkConfig';
import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils';
import { LocationShareType, ShareLocationFn } from './shareLocation'; import { getUserNameColorClass } from '../../../utils/FormattingUtils';
import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg'; import { GenericPosition, genericPositionFromGeolocation, getGeoUri } from '../../../utils/beacon';
import { LocationShareError, findMapStyleUrl } from '../../../utils/location';
import MemberAvatar from '../avatars/MemberAvatar';
import ErrorDialog from '../dialogs/ErrorDialog';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import { MapError } from './MapError'; import { MapError } from './MapError';
import { getUserNameColorClass } from '../../../utils/FormattingUtils';
import LiveDurationDropdown, { DEFAULT_DURATION_MS } from './LiveDurationDropdown'; import LiveDurationDropdown, { DEFAULT_DURATION_MS } from './LiveDurationDropdown';
import { GenericPosition, genericPositionFromGeolocation, getGeoUri } from '../../../utils/beacon'; import { LocationShareType, ShareLocationFn } from './shareLocation';
import SdkConfig from '../../../SdkConfig';
import { LocationShareError, findMapStyleUrl } from '../../../utils/location';
export interface ILocationPickerProps { export interface ILocationPickerProps {
sender: RoomMember; sender: RoomMember;

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { debounce } from "lodash";
import { import {
Beacon, Beacon,
BeaconEvent, BeaconEvent,
@ -21,13 +22,23 @@ import {
Room, Room,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { import {
BeaconInfoState, makeBeaconInfoContent, BeaconInfoState, makeBeaconContent, makeBeaconInfoContent,
} from "matrix-js-sdk/src/content-helpers"; } from "matrix-js-sdk/src/content-helpers";
import { M_BEACON } from "matrix-js-sdk/src/@types/beacon";
import { logger } from "matrix-js-sdk/src/logger";
import defaultDispatcher from "../dispatcher/dispatcher"; import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads"; import { ActionPayload } from "../dispatcher/payloads";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import { arrayHasDiff } from "../utils/arrays"; import { arrayDiff } from "../utils/arrays";
import {
ClearWatchCallback,
GeolocationError,
mapGeolocationPositionToTimedGeo,
TimedGeoUri,
watchPosition,
} from "../utils/beacon";
import { getCurrentPosition } from "../utils/beacon/geolocation";
const isOwnBeacon = (beacon: Beacon, userId: string): boolean => beacon.beaconInfoOwner === userId; const isOwnBeacon = (beacon: Beacon, userId: string): boolean => beacon.beaconInfoOwner === userId;
@ -35,6 +46,9 @@ export enum OwnBeaconStoreEvent {
LivenessChange = 'OwnBeaconStore.LivenessChange', LivenessChange = 'OwnBeaconStore.LivenessChange',
} }
const MOVING_UPDATE_INTERVAL = 2000;
const STATIC_UPDATE_INTERVAL = 30000;
type OwnBeaconStoreState = { type OwnBeaconStoreState = {
beacons: Map<string, Beacon>; beacons: Map<string, Beacon>;
beaconsByRoomId: Map<Room['roomId'], Set<string>>; beaconsByRoomId: Map<Room['roomId'], Set<string>>;
@ -46,6 +60,15 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
public readonly beacons = new Map<string, Beacon>(); public readonly beacons = new Map<string, Beacon>();
public readonly beaconsByRoomId = new Map<Room['roomId'], Set<string>>(); public readonly beaconsByRoomId = new Map<Room['roomId'], Set<string>>();
private liveBeaconIds = []; private liveBeaconIds = [];
private locationInterval: number;
private geolocationError: GeolocationError | undefined;
private clearPositionWatch: ClearWatchCallback | undefined;
/**
* Track when the last position was published
* So we can manually get position on slow interval
* when the target is stationary
*/
private lastPublishedPositionTimestamp: number | undefined;
public constructor() { public constructor() {
super(defaultDispatcher); super(defaultDispatcher);
@ -55,12 +78,21 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
return OwnBeaconStore.internalInstance; return OwnBeaconStore.internalInstance;
} }
/**
* True when we have live beacons
* and geolocation.watchPosition is active
*/
public get isMonitoringLiveLocation(): boolean {
return !!this.clearPositionWatch;
}
protected async onNotReady() { protected async onNotReady() {
this.matrixClient.removeListener(BeaconEvent.LivenessChange, this.onBeaconLiveness); this.matrixClient.removeListener(BeaconEvent.LivenessChange, this.onBeaconLiveness);
this.matrixClient.removeListener(BeaconEvent.New, this.onNewBeacon); this.matrixClient.removeListener(BeaconEvent.New, this.onNewBeacon);
this.beacons.forEach(beacon => beacon.destroy()); this.beacons.forEach(beacon => beacon.destroy());
this.stopPollingLocation();
this.beacons.clear(); this.beacons.clear();
this.beaconsByRoomId.clear(); this.beaconsByRoomId.clear();
this.liveBeaconIds = []; this.liveBeaconIds = [];
@ -117,21 +149,12 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
return; return;
} }
if (!isLive && this.liveBeaconIds.includes(beacon.identifier)) {
this.liveBeaconIds =
this.liveBeaconIds.filter(beaconId => beaconId !== beacon.identifier);
}
if (isLive && !this.liveBeaconIds.includes(beacon.identifier)) {
this.liveBeaconIds.push(beacon.identifier);
}
// beacon expired, update beacon to un-alive state // beacon expired, update beacon to un-alive state
if (!isLive) { if (!isLive) {
this.stopBeacon(beacon.identifier); this.stopBeacon(beacon.identifier);
} }
// TODO start location polling here this.checkLiveness();
this.emit(OwnBeaconStoreEvent.LivenessChange, this.getLiveBeaconIds()); this.emit(OwnBeaconStoreEvent.LivenessChange, this.getLiveBeaconIds());
}; };
@ -169,9 +192,29 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
.filter(beacon => beacon.isLive) .filter(beacon => beacon.isLive)
.map(beacon => beacon.identifier); .map(beacon => beacon.identifier);
if (arrayHasDiff(prevLiveBeaconIds, this.liveBeaconIds)) { const diff = arrayDiff(prevLiveBeaconIds, this.liveBeaconIds);
if (diff.added.length || diff.removed.length) {
this.emit(OwnBeaconStoreEvent.LivenessChange, this.liveBeaconIds); this.emit(OwnBeaconStoreEvent.LivenessChange, this.liveBeaconIds);
} }
// publish current location immediately
// when there are new live beacons
// and we already have a live monitor
// so first position is published quickly
// even when target is stationary
//
// when there is no existing live monitor
// it will be created below by togglePollingLocation
// and publish first position quickly
if (diff.added.length && this.isMonitoringLiveLocation) {
this.publishCurrentLocationToBeacons();
}
// if overall liveness changed
if (!!prevLiveBeaconIds?.length !== !!this.liveBeaconIds.length) {
this.togglePollingLocation();
}
}; };
private updateBeaconEvent = async (beacon: Beacon, update: Partial<BeaconInfoState>): Promise<void> => { private updateBeaconEvent = async (beacon: Beacon, update: Partial<BeaconInfoState>): Promise<void> => {
@ -188,4 +231,90 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent); await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent);
}; };
private togglePollingLocation = async (): Promise<void> => {
if (!!this.liveBeaconIds.length) {
return this.startPollingLocation();
}
return this.stopPollingLocation();
};
private startPollingLocation = async () => {
// clear any existing interval
this.stopPollingLocation();
this.clearPositionWatch = await watchPosition(this.onWatchedPosition, this.onWatchedPositionError);
this.locationInterval = setInterval(() => {
if (!this.lastPublishedPositionTimestamp) {
return;
}
// if position was last updated STATIC_UPDATE_INTERVAL ms ago or more
// get our position and publish it
if (this.lastPublishedPositionTimestamp <= Date.now() - STATIC_UPDATE_INTERVAL) {
this.publishCurrentLocationToBeacons();
}
}, STATIC_UPDATE_INTERVAL);
};
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 onWatchedPositionError = (error: GeolocationError) => {
this.geolocationError = error;
logger.error(this.geolocationError);
};
private stopPollingLocation = () => {
clearInterval(this.locationInterval);
this.locationInterval = undefined;
this.lastPublishedPositionTimestamp = undefined;
this.geolocationError = undefined;
if (this.clearPositionWatch) {
this.clearPositionWatch();
this.clearPositionWatch = undefined;
}
};
/**
* 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);
};
/**
* Gets the current location
* (as opposed to using watched location)
* and publishes it to all live beacons
*/
private publishCurrentLocationToBeacons = async () => {
const position = await getCurrentPosition();
// TODO error handling
this.publishLocationToBeacons(mapGeolocationPositionToTimedGeo(position));
};
} }

View file

@ -23,14 +23,17 @@ import '../../../skinned-sdk';
import RoomLiveShareWarning from '../../../../src/components/views/beacon/RoomLiveShareWarning'; import RoomLiveShareWarning from '../../../../src/components/views/beacon/RoomLiveShareWarning';
import { OwnBeaconStore } from '../../../../src/stores/OwnBeaconStore'; import { OwnBeaconStore } from '../../../../src/stores/OwnBeaconStore';
import { import {
advanceDateAndTime,
findByTestId, findByTestId,
getMockClientWithEventEmitter, getMockClientWithEventEmitter,
makeBeaconInfoEvent, makeBeaconInfoEvent,
mockGeolocation,
resetAsyncStoreWithClient, resetAsyncStoreWithClient,
setupAsyncStoreWithClient, setupAsyncStoreWithClient,
} from '../../../test-utils'; } from '../../../test-utils';
jest.useFakeTimers(); jest.useFakeTimers();
mockGeolocation();
describe('<RoomLiveShareWarning />', () => { describe('<RoomLiveShareWarning />', () => {
const aliceId = '@alice:server.org'; const aliceId = '@alice:server.org';
const room1Id = '$room1:server.org'; const room1Id = '$room1:server.org';
@ -40,6 +43,7 @@ describe('<RoomLiveShareWarning />', () => {
getVisibleRooms: jest.fn().mockReturnValue([]), getVisibleRooms: jest.fn().mockReturnValue([]),
getUserId: jest.fn().mockReturnValue(aliceId), getUserId: jest.fn().mockReturnValue(aliceId),
unstable_setLiveBeacon: jest.fn().mockResolvedValue({ event_id: '1' }), unstable_setLiveBeacon: jest.fn().mockResolvedValue({ event_id: '1' }),
sendEvent: jest.fn(),
}); });
// 14.03.2022 16:15 // 14.03.2022 16:15
@ -69,14 +73,6 @@ describe('<RoomLiveShareWarning />', () => {
return [room1, room2]; return [room1, room2];
}; };
const advanceDateAndTime = (ms: number) => {
// bc liveness check uses Date.now we have to advance this mock
jest.spyOn(global.Date, 'now').mockReturnValue(Date.now() + ms);
// then advance time for the interval by the same amount
jest.advanceTimersByTime(ms);
};
const makeOwnBeaconStore = async () => { const makeOwnBeaconStore = async () => {
const store = OwnBeaconStore.instance; const store = OwnBeaconStore.instance;
@ -137,12 +133,16 @@ describe('<RoomLiveShareWarning />', () => {
it('renders correctly with one live beacon in room', () => { it('renders correctly with one live beacon in room', () => {
const component = getComponent({ roomId: room1Id }); const component = getComponent({ roomId: room1Id });
expect(component).toMatchSnapshot(); // beacons have generated ids that break snapshots
// assert on html
expect(component.html()).toMatchSnapshot();
}); });
it('renders correctly with two live beacons in room', () => { it('renders correctly with two live beacons in room', () => {
const component = getComponent({ roomId: room2Id }); const component = getComponent({ roomId: room2Id });
expect(component).toMatchSnapshot(); // beacons have generated ids that break snapshots
// assert on html
expect(component.html()).toMatchSnapshot();
// later expiry displayed // later expiry displayed
expect(getExpiryText(component)).toEqual('12h left'); expect(getExpiryText(component)).toEqual('12h left');
}); });

View file

@ -1,191 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<RoomLiveShareWarning /> when user has live beacons renders correctly with one live beacon in room 1`] = ` exports[`<RoomLiveShareWarning /> when user has live beacons renders correctly with one live beacon in room 1`] = `"<div class=\\"mx_RoomLiveShareWarning\\"><div class=\\"mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon\\"></div><span class=\\"mx_RoomLiveShareWarning_label\\">You are sharing your live location</span><span data-test-id=\\"room-live-share-expiry\\" class=\\"mx_RoomLiveShareWarning_expiry\\">1h left</span><button data-test-id=\\"room-live-share-stop-sharing\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger\\">Stop sharing</button></div>"`;
<RoomLiveShareWarning
roomId="$room1:server.org"
>
<div
className="mx_RoomLiveShareWarning"
>
<StyledLiveBeaconIcon
className="mx_RoomLiveShareWarning_icon"
>
<div
className="mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon"
/>
</StyledLiveBeaconIcon>
<span
className="mx_RoomLiveShareWarning_label"
>
You are sharing your live location
</span>
<LiveTimeRemaining
beacon={
Beacon {
"_beaconInfo": Object {
"assetType": "m.self",
"description": undefined,
"live": true,
"timeout": 3600000,
"timestamp": 1647270879403,
},
"_events": Object {
"Beacon.LivenessChange": Array [
[Function],
[Function],
],
"Beacon.new": [Function],
"Beacon.update": [Function],
},
"_eventsCount": 3,
"_isLive": true,
"_maxListeners": undefined,
"livenessWatchInterval": 1000000000002,
"roomId": "$room1:server.org",
"rootEvent": Object {
"content": Object {
"org.matrix.msc3488.asset": Object {
"type": "m.self",
},
"org.matrix.msc3488.ts": 1647270879403,
"org.matrix.msc3489.beacon_info": Object {
"description": undefined,
"live": true,
"timeout": 3600000,
},
},
"event_id": "$0",
"room_id": "$room1:server.org",
"state_key": "@alice:server.org",
"type": "org.matrix.msc3489.beacon_info.@alice:server.org.2",
},
Symbol(kCapture): false,
}
}
>
<span
className="mx_RoomLiveShareWarning_expiry"
data-test-id="room-live-share-expiry"
>
1h left
</span>
</LiveTimeRemaining>
<AccessibleButton
data-test-id="room-live-share-stop-sharing"
disabled={false}
element="button"
kind="danger"
onClick={[Function]}
role="button"
tabIndex={0}
>
<button
className="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger"
data-test-id="room-live-share-stop-sharing"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
>
Stop sharing
</button>
</AccessibleButton>
</div>
</RoomLiveShareWarning>
`;
exports[`<RoomLiveShareWarning /> when user has live beacons renders correctly with two live beacons in room 1`] = ` exports[`<RoomLiveShareWarning /> when user has live beacons renders correctly with two live beacons in room 1`] = `"<div class=\\"mx_RoomLiveShareWarning\\"><div class=\\"mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon\\"></div><span class=\\"mx_RoomLiveShareWarning_label\\">You are sharing your live location</span><span data-test-id=\\"room-live-share-expiry\\" class=\\"mx_RoomLiveShareWarning_expiry\\">12h left</span><button data-test-id=\\"room-live-share-stop-sharing\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger\\">Stop sharing</button></div>"`;
<RoomLiveShareWarning
roomId="$room2:server.org"
>
<div
className="mx_RoomLiveShareWarning"
>
<StyledLiveBeaconIcon
className="mx_RoomLiveShareWarning_icon"
>
<div
className="mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon"
/>
</StyledLiveBeaconIcon>
<span
className="mx_RoomLiveShareWarning_label"
>
You are sharing your live location
</span>
<LiveTimeRemaining
beacon={
Beacon {
"_beaconInfo": Object {
"assetType": "m.self",
"description": undefined,
"live": true,
"timeout": 43200000,
"timestamp": 1647270879403,
},
"_events": Object {
"Beacon.LivenessChange": Array [
[Function],
[Function],
],
"Beacon.new": [Function],
"Beacon.update": [Function],
},
"_eventsCount": 3,
"_isLive": true,
"_maxListeners": undefined,
"livenessWatchInterval": 1000000000010,
"roomId": "$room2:server.org",
"rootEvent": Object {
"content": Object {
"org.matrix.msc3488.asset": Object {
"type": "m.self",
},
"org.matrix.msc3488.ts": 1647270879403,
"org.matrix.msc3489.beacon_info": Object {
"description": undefined,
"live": true,
"timeout": 43200000,
},
},
"event_id": "$2",
"room_id": "$room2:server.org",
"state_key": "@alice:server.org",
"type": "org.matrix.msc3489.beacon_info.@alice:server.org.4",
},
Symbol(kCapture): false,
}
}
>
<span
className="mx_RoomLiveShareWarning_expiry"
data-test-id="room-live-share-expiry"
>
12h left
</span>
</LiveTimeRemaining>
<AccessibleButton
data-test-id="room-live-share-stop-sharing"
disabled={false}
element="button"
kind="danger"
onClick={[Function]}
role="button"
tabIndex={0}
>
<button
className="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger"
data-test-id="room-live-share-stop-sharing"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
>
Stop sharing
</button>
</AccessibleButton>
</div>
</RoomLiveShareWarning>
`;

View file

@ -14,17 +14,35 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Room, Beacon, BeaconEvent } from "matrix-js-sdk/src/matrix"; import { Room, Beacon, BeaconEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon"; import { makeBeaconContent } from "matrix-js-sdk/src/content-helpers";
import { M_BEACON, M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon";
import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../src/stores/OwnBeaconStore"; import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../src/stores/OwnBeaconStore";
import { resetAsyncStoreWithClient, setupAsyncStoreWithClient } from "../test-utils"; import {
import { makeBeaconInfoEvent } from "../test-utils/beacon"; advanceDateAndTime,
flushPromisesWithFakeTimers,
resetAsyncStoreWithClient,
setupAsyncStoreWithClient,
} from "../test-utils";
import {
makeBeaconInfoEvent,
makeGeolocationPosition,
mockGeolocation,
watchPositionMockImplementation,
} from "../test-utils/beacon";
import { getMockClientWithEventEmitter } from "../test-utils/client"; import { getMockClientWithEventEmitter } from "../test-utils/client";
// modern fake timers and lodash.debounce are a faff
// short circuit it
jest.mock("lodash", () => ({
debounce: jest.fn().mockImplementation(callback => callback),
}));
jest.useFakeTimers(); jest.useFakeTimers();
describe('OwnBeaconStore', () => { describe('OwnBeaconStore', () => {
let geolocation;
// 14.03.2022 16:15 // 14.03.2022 16:15
const now = 1647270879403; const now = 1647270879403;
const HOUR_MS = 3600000; const HOUR_MS = 3600000;
@ -35,10 +53,15 @@ describe('OwnBeaconStore', () => {
getUserId: jest.fn().mockReturnValue(aliceId), getUserId: jest.fn().mockReturnValue(aliceId),
getVisibleRooms: jest.fn().mockReturnValue([]), getVisibleRooms: jest.fn().mockReturnValue([]),
unstable_setLiveBeacon: jest.fn().mockResolvedValue({ event_id: '1' }), unstable_setLiveBeacon: jest.fn().mockResolvedValue({ event_id: '1' }),
sendEvent: jest.fn().mockResolvedValue({ event_id: '1' }),
}); });
const room1Id = '$room1:server.org'; const room1Id = '$room1:server.org';
const room2Id = '$room2:server.org'; const room2Id = '$room2:server.org';
// returned by default geolocation mocks
const defaultLocation = makeGeolocationPosition({});
const defaultLocationUri = 'geo:54.001927,-8.253491;u=1';
// beacon_info events // beacon_info events
// created 'an hour ago' // created 'an hour ago'
// with timeout of 3 hours // with timeout of 3 hours
@ -89,13 +112,6 @@ describe('OwnBeaconStore', () => {
return [room1, room2]; return [room1, room2];
}; };
const advanceDateAndTime = (ms: number) => {
// bc liveness check uses Date.now we have to advance this mock
jest.spyOn(global.Date, 'now').mockReturnValue(now + ms);
// then advance time for the interval by the same amount
jest.advanceTimersByTime(ms);
};
const makeOwnBeaconStore = async () => { const makeOwnBeaconStore = async () => {
const store = OwnBeaconStore.instance; const store = OwnBeaconStore.instance;
@ -103,20 +119,53 @@ describe('OwnBeaconStore', () => {
return store; return store;
}; };
const expireBeaconAndEmit = (store, beaconInfoEvent: MatrixEvent): void => {
const beacon = store.getBeaconById(beaconInfoEvent.getType());
// time travel until beacon is expired
advanceDateAndTime(beacon.beaconInfo.timeout + 100);
// force an update on the beacon
// @ts-ignore
beacon.setBeaconInfo(beaconInfoEvent);
mockClient.emit(BeaconEvent.LivenessChange, false, beacon);
};
const updateBeaconLivenessAndEmit = (store, beaconInfoEvent: MatrixEvent, isLive: boolean): void => {
const beacon = store.getBeaconById(beaconInfoEvent.getType());
// matches original state of event content
// except for live property
const updateEvent = makeBeaconInfoEvent(
beaconInfoEvent.getSender(),
beaconInfoEvent.getRoomId(),
{ isLive, timeout: beacon.beaconInfo.timeout },
undefined,
);
updateEvent.event.type = beaconInfoEvent.getType();
beacon.update(updateEvent);
mockClient.emit(BeaconEvent.Update, beaconInfoEvent, beacon);
mockClient.emit(BeaconEvent.LivenessChange, false, beacon);
};
const addNewBeaconAndEmit = (beaconInfoEvent: MatrixEvent): void => {
const beacon = new Beacon(beaconInfoEvent);
mockClient.emit(BeaconEvent.New, beaconInfoEvent, beacon);
};
beforeEach(() => { beforeEach(() => {
geolocation = mockGeolocation();
mockClient.getVisibleRooms.mockReturnValue([]); mockClient.getVisibleRooms.mockReturnValue([]);
mockClient.unstable_setLiveBeacon.mockClear().mockResolvedValue({ event_id: '1' }); mockClient.unstable_setLiveBeacon.mockClear().mockResolvedValue({ event_id: '1' });
mockClient.sendEvent.mockClear().mockResolvedValue({ event_id: '1' });
jest.spyOn(global.Date, 'now').mockReturnValue(now); jest.spyOn(global.Date, 'now').mockReturnValue(now);
jest.spyOn(OwnBeaconStore.instance, 'emit').mockRestore(); jest.spyOn(OwnBeaconStore.instance, 'emit').mockRestore();
}); });
afterEach(async () => { afterEach(async () => {
await resetAsyncStoreWithClient(OwnBeaconStore.instance); await resetAsyncStoreWithClient(OwnBeaconStore.instance);
});
it('works', async () => { jest.clearAllTimers();
const store = await makeOwnBeaconStore();
expect(store.hasLiveBeacons()).toBe(false);
}); });
describe('onReady()', () => { describe('onReady()', () => {
@ -149,6 +198,38 @@ describe('OwnBeaconStore', () => {
alicesRoom2BeaconInfo.getType(), alicesRoom2BeaconInfo.getType(),
]); ]);
}); });
it('does not do any geolocation when user has no live beacons', async () => {
makeRoomsWithStateEvents([bobsRoom1BeaconInfo, bobsOldRoom1BeaconInfo]);
const store = await makeOwnBeaconStore();
expect(store.hasLiveBeacons()).toBe(false);
await flushPromisesWithFakeTimers();
expect(geolocation.watchPosition).not.toHaveBeenCalled();
expect(mockClient.sendEvent).not.toHaveBeenCalled();
});
it('does geolocation and sends location immediatley when user has live beacons', async () => {
makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
alicesRoom2BeaconInfo,
]);
await makeOwnBeaconStore();
await flushPromisesWithFakeTimers();
expect(geolocation.watchPosition).toHaveBeenCalled();
expect(mockClient.sendEvent).toHaveBeenCalledWith(
room1Id,
M_BEACON.name,
makeBeaconContent(defaultLocationUri, defaultLocation.timestamp, alicesRoom1BeaconInfo.getId()),
);
expect(mockClient.sendEvent).toHaveBeenCalledWith(
room2Id,
M_BEACON.name,
makeBeaconContent(defaultLocationUri, defaultLocation.timestamp, alicesRoom2BeaconInfo.getId()),
);
});
}); });
describe('onNotReady()', () => { describe('onNotReady()', () => {
@ -372,12 +453,8 @@ describe('OwnBeaconStore', () => {
// live before // live before
expect(store.hasLiveBeacons()).toBe(true); expect(store.hasLiveBeacons()).toBe(true);
const emitSpy = jest.spyOn(store, 'emit'); const emitSpy = jest.spyOn(store, 'emit');
const alicesBeacon = new Beacon(alicesRoom1BeaconInfo);
// time travel until beacon is expired await expireBeaconAndEmit(store, alicesRoom1BeaconInfo);
advanceDateAndTime(HOUR_MS * 3);
mockClient.emit(BeaconEvent.LivenessChange, false, alicesBeacon);
expect(store.hasLiveBeacons()).toBe(false); expect(store.hasLiveBeacons()).toBe(false);
expect(store.hasLiveBeacons(room1Id)).toBe(false); expect(store.hasLiveBeacons(room1Id)).toBe(false);
@ -388,14 +465,10 @@ describe('OwnBeaconStore', () => {
makeRoomsWithStateEvents([ makeRoomsWithStateEvents([
alicesRoom1BeaconInfo, alicesRoom1BeaconInfo,
]); ]);
await makeOwnBeaconStore(); const store = await makeOwnBeaconStore();
const alicesBeacon = new Beacon(alicesRoom1BeaconInfo);
const prevEventContent = alicesRoom1BeaconInfo.getContent(); const prevEventContent = alicesRoom1BeaconInfo.getContent();
// time travel until beacon is expired await expireBeaconAndEmit(store, alicesRoom1BeaconInfo);
advanceDateAndTime(HOUR_MS * 3);
mockClient.emit(BeaconEvent.LivenessChange, false, alicesBeacon);
// matches original state of event content // matches original state of event content
// except for live property // except for live property
@ -422,15 +495,8 @@ describe('OwnBeaconStore', () => {
// not live before // not live before
expect(store.hasLiveBeacons()).toBe(false); expect(store.hasLiveBeacons()).toBe(false);
const emitSpy = jest.spyOn(store, 'emit'); const emitSpy = jest.spyOn(store, 'emit');
const alicesBeacon = new Beacon(alicesOldRoomIdBeaconInfo);
const liveUpdate = makeBeaconInfoEvent(
aliceId, room1Id, { isLive: true }, alicesOldRoomIdBeaconInfo.getId(), '$alice-room1-2',
);
// bring the beacon back to life updateBeaconLivenessAndEmit(store, alicesOldRoomIdBeaconInfo, true);
alicesBeacon.update(liveUpdate);
mockClient.emit(BeaconEvent.LivenessChange, true, alicesBeacon);
expect(store.hasLiveBeacons()).toBe(true); expect(store.hasLiveBeacons()).toBe(true);
expect(store.hasLiveBeacons(room1Id)).toBe(true); expect(store.hasLiveBeacons(room1Id)).toBe(true);
@ -512,4 +578,120 @@ describe('OwnBeaconStore', () => {
); );
}); });
}); });
describe('sending positions', () => {
it('stops watching position when user has no more live beacons', async () => {
// geolocation is only going to emit 1 position
geolocation.watchPosition.mockImplementation(
watchPositionMockImplementation([0]),
);
makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
const store = await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
// two locations were published
expect(mockClient.sendEvent).toHaveBeenCalledTimes(1);
// expire the beacon
// user now has no live beacons
await expireBeaconAndEmit(store, alicesRoom1BeaconInfo);
// stop watching location
expect(geolocation.clearWatch).toHaveBeenCalled();
});
it('starts watching position when user starts having live beacons', async () => {
makeRoomsWithStateEvents([]);
await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
addNewBeaconAndEmit(alicesRoom1BeaconInfo);
// wait for store to settle
await flushPromisesWithFakeTimers();
expect(geolocation.watchPosition).toHaveBeenCalled();
});
it('publishes position for new beacon immediately', async () => {
makeRoomsWithStateEvents([]);
await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
addNewBeaconAndEmit(alicesRoom1BeaconInfo);
// wait for store to settle
await flushPromisesWithFakeTimers();
expect(mockClient.sendEvent).toHaveBeenCalled();
});
it('publishes subsequent positions', async () => {
// modern fake timers + debounce + promises are not friends
// just testing that positions are published
// not that the debounce works
geolocation.watchPosition.mockImplementation(
watchPositionMockImplementation([0, 1000, 3000]),
);
makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
expect(mockClient.sendEvent).toHaveBeenCalledTimes(0);
await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
jest.advanceTimersByTime(5000);
expect(mockClient.sendEvent).toHaveBeenCalledTimes(3);
});
it('publishes last known position after 30s of inactivity', async () => {
geolocation.watchPosition.mockImplementation(
watchPositionMockImplementation([0]),
);
makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
// published first location
expect(mockClient.sendEvent).toHaveBeenCalledTimes(1);
advanceDateAndTime(31000);
// wait for store to settle
await flushPromisesWithFakeTimers();
// republished latest location
expect(mockClient.sendEvent).toHaveBeenCalledTimes(2);
});
it('does not try to publish anything if there is no known position after 30s of inactivity', async () => {
// no position ever returned from geolocation
geolocation.watchPosition.mockImplementation(
watchPositionMockImplementation([]),
);
geolocation.getCurrentPosition.mockImplementation(
watchPositionMockImplementation([]),
);
makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
advanceDateAndTime(31000);
// no locations published
expect(mockClient.sendEvent).not.toHaveBeenCalled();
});
});
}); });

View file

@ -229,8 +229,6 @@ describe('geolocation utilities', () => {
}); });
it('resolves with current location', async () => { it('resolves with current location', async () => {
jest.spyOn(logger, 'error').mockImplementation(() => { });
geolocation.getCurrentPosition.mockImplementation((callback, error) => callback(defaultPosition)); geolocation.getCurrentPosition.mockImplementation((callback, error) => callback(defaultPosition));
const result = await getCurrentPosition(); const result = await getCurrentPosition();