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

Commit e9b2aea

Browse files
author
Kerry
authored
Live location sharing - send geolocation beacon events - happy path (#8127)
* geolocation utilities Signed-off-by: Kerry Archibald <[email protected]> * messy send events Signed-off-by: Kerry Archibald <[email protected]> * add geolocation services Signed-off-by: Kerry Archibald <[email protected]> * geolocation tests Signed-off-by: Kerry Archibald <[email protected]> * debounce with backup emit every 30s Signed-off-by: Kerry Archibald <[email protected]> * import reorder Signed-off-by: Kerry Archibald <[email protected]> * some more working tests Signed-off-by: Kerry Archibald <[email protected]> * complicated timeout testing Signed-off-by: Kerry Archibald <[email protected]> * publish first location immediately Signed-off-by: Kerry Archibald <[email protected]> * move advanceDateAndTime to utils, tidy Signed-off-by: Kerry Archibald <[email protected]> * typos Signed-off-by: Kerry Archibald <[email protected]> * types and lint Signed-off-by: Kerry Archibald <[email protected]>
1 parent f557ac9 commit e9b2aea

File tree

6 files changed

+378
-255
lines changed

6 files changed

+378
-255
lines changed

src/components/views/location/LocationPicker.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,22 @@ import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
2121
import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/client';
2222
import classNames from 'classnames';
2323

24+
import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg';
2425
import { _t } from '../../../languageHandler';
2526
import { replaceableComponent } from "../../../utils/replaceableComponent";
26-
import MemberAvatar from '../avatars/MemberAvatar';
2727
import MatrixClientContext from '../../../contexts/MatrixClientContext';
2828
import Modal from '../../../Modal';
29-
import ErrorDialog from '../dialogs/ErrorDialog';
29+
import SdkConfig from '../../../SdkConfig';
3030
import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils';
31-
import { LocationShareType, ShareLocationFn } from './shareLocation';
32-
import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg';
33-
import AccessibleButton from '../elements/AccessibleButton';
34-
import { MapError } from './MapError';
3531
import { getUserNameColorClass } from '../../../utils/FormattingUtils';
36-
import LiveDurationDropdown, { DEFAULT_DURATION_MS } from './LiveDurationDropdown';
3732
import { GenericPosition, genericPositionFromGeolocation, getGeoUri } from '../../../utils/beacon';
38-
import SdkConfig from '../../../SdkConfig';
3933
import { LocationShareError, findMapStyleUrl } from '../../../utils/location';
34+
import MemberAvatar from '../avatars/MemberAvatar';
35+
import ErrorDialog from '../dialogs/ErrorDialog';
36+
import AccessibleButton from '../elements/AccessibleButton';
37+
import { MapError } from './MapError';
38+
import LiveDurationDropdown, { DEFAULT_DURATION_MS } from './LiveDurationDropdown';
39+
import { LocationShareType, ShareLocationFn } from './shareLocation';
4040

4141
export interface ILocationPickerProps {
4242
sender: RoomMember;

src/stores/OwnBeaconStore.ts

Lines changed: 142 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,41 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17+
import { debounce } from "lodash";
1718
import {
1819
Beacon,
1920
BeaconEvent,
2021
MatrixEvent,
2122
Room,
2223
} from "matrix-js-sdk/src/matrix";
2324
import {
24-
BeaconInfoState, makeBeaconInfoContent,
25+
BeaconInfoState, makeBeaconContent, makeBeaconInfoContent,
2526
} from "matrix-js-sdk/src/content-helpers";
27+
import { M_BEACON } from "matrix-js-sdk/src/@types/beacon";
28+
import { logger } from "matrix-js-sdk/src/logger";
2629

2730
import defaultDispatcher from "../dispatcher/dispatcher";
2831
import { ActionPayload } from "../dispatcher/payloads";
2932
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
30-
import { arrayHasDiff } from "../utils/arrays";
33+
import { arrayDiff } from "../utils/arrays";
34+
import {
35+
ClearWatchCallback,
36+
GeolocationError,
37+
mapGeolocationPositionToTimedGeo,
38+
TimedGeoUri,
39+
watchPosition,
40+
} from "../utils/beacon";
41+
import { getCurrentPosition } from "../utils/beacon/geolocation";
3142

3243
const isOwnBeacon = (beacon: Beacon, userId: string): boolean => beacon.beaconInfoOwner === userId;
3344

3445
export enum OwnBeaconStoreEvent {
3546
LivenessChange = 'OwnBeaconStore.LivenessChange',
3647
}
3748

49+
const MOVING_UPDATE_INTERVAL = 2000;
50+
const STATIC_UPDATE_INTERVAL = 30000;
51+
3852
type OwnBeaconStoreState = {
3953
beacons: Map<string, Beacon>;
4054
beaconsByRoomId: Map<Room['roomId'], Set<string>>;
@@ -46,6 +60,15 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
4660
public readonly beacons = new Map<string, Beacon>();
4761
public readonly beaconsByRoomId = new Map<Room['roomId'], Set<string>>();
4862
private liveBeaconIds = [];
63+
private locationInterval: number;
64+
private geolocationError: GeolocationError | undefined;
65+
private clearPositionWatch: ClearWatchCallback | undefined;
66+
/**
67+
* Track when the last position was published
68+
* So we can manually get position on slow interval
69+
* when the target is stationary
70+
*/
71+
private lastPublishedPositionTimestamp: number | undefined;
4972

5073
public constructor() {
5174
super(defaultDispatcher);
@@ -55,12 +78,21 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
5578
return OwnBeaconStore.internalInstance;
5679
}
5780

81+
/**
82+
* True when we have live beacons
83+
* and geolocation.watchPosition is active
84+
*/
85+
public get isMonitoringLiveLocation(): boolean {
86+
return !!this.clearPositionWatch;
87+
}
88+
5889
protected async onNotReady() {
5990
this.matrixClient.removeListener(BeaconEvent.LivenessChange, this.onBeaconLiveness);
6091
this.matrixClient.removeListener(BeaconEvent.New, this.onNewBeacon);
6192

6293
this.beacons.forEach(beacon => beacon.destroy());
6394

95+
this.stopPollingLocation();
6496
this.beacons.clear();
6597
this.beaconsByRoomId.clear();
6698
this.liveBeaconIds = [];
@@ -117,21 +149,12 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
117149
return;
118150
}
119151

120-
if (!isLive && this.liveBeaconIds.includes(beacon.identifier)) {
121-
this.liveBeaconIds =
122-
this.liveBeaconIds.filter(beaconId => beaconId !== beacon.identifier);
123-
}
124-
125-
if (isLive && !this.liveBeaconIds.includes(beacon.identifier)) {
126-
this.liveBeaconIds.push(beacon.identifier);
127-
}
128-
129152
// beacon expired, update beacon to un-alive state
130153
if (!isLive) {
131154
this.stopBeacon(beacon.identifier);
132155
}
133156

134-
// TODO start location polling here
157+
this.checkLiveness();
135158

136159
this.emit(OwnBeaconStoreEvent.LivenessChange, this.getLiveBeaconIds());
137160
};
@@ -169,9 +192,29 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
169192
.filter(beacon => beacon.isLive)
170193
.map(beacon => beacon.identifier);
171194

172-
if (arrayHasDiff(prevLiveBeaconIds, this.liveBeaconIds)) {
195+
const diff = arrayDiff(prevLiveBeaconIds, this.liveBeaconIds);
196+
197+
if (diff.added.length || diff.removed.length) {
173198
this.emit(OwnBeaconStoreEvent.LivenessChange, this.liveBeaconIds);
174199
}
200+
201+
// publish current location immediately
202+
// when there are new live beacons
203+
// and we already have a live monitor
204+
// so first position is published quickly
205+
// even when target is stationary
206+
//
207+
// when there is no existing live monitor
208+
// it will be created below by togglePollingLocation
209+
// and publish first position quickly
210+
if (diff.added.length && this.isMonitoringLiveLocation) {
211+
this.publishCurrentLocationToBeacons();
212+
}
213+
214+
// if overall liveness changed
215+
if (!!prevLiveBeaconIds?.length !== !!this.liveBeaconIds.length) {
216+
this.togglePollingLocation();
217+
}
175218
};
176219

177220
private updateBeaconEvent = async (beacon: Beacon, update: Partial<BeaconInfoState>): Promise<void> => {
@@ -188,4 +231,90 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
188231

189232
await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent);
190233
};
234+
235+
private togglePollingLocation = async (): Promise<void> => {
236+
if (!!this.liveBeaconIds.length) {
237+
return this.startPollingLocation();
238+
}
239+
return this.stopPollingLocation();
240+
};
241+
242+
private startPollingLocation = async () => {
243+
// clear any existing interval
244+
this.stopPollingLocation();
245+
246+
this.clearPositionWatch = await watchPosition(this.onWatchedPosition, this.onWatchedPositionError);
247+
248+
this.locationInterval = setInterval(() => {
249+
if (!this.lastPublishedPositionTimestamp) {
250+
return;
251+
}
252+
// if position was last updated STATIC_UPDATE_INTERVAL ms ago or more
253+
// get our position and publish it
254+
if (this.lastPublishedPositionTimestamp <= Date.now() - STATIC_UPDATE_INTERVAL) {
255+
this.publishCurrentLocationToBeacons();
256+
}
257+
}, STATIC_UPDATE_INTERVAL);
258+
};
259+
260+
private onWatchedPosition = (position: GeolocationPosition) => {
261+
const timedGeoPosition = mapGeolocationPositionToTimedGeo(position);
262+
263+
// if this is our first position, publish immediateley
264+
if (!this.lastPublishedPositionTimestamp) {
265+
this.publishLocationToBeacons(timedGeoPosition);
266+
} else {
267+
this.debouncedPublishLocationToBeacons(timedGeoPosition);
268+
}
269+
};
270+
271+
private onWatchedPositionError = (error: GeolocationError) => {
272+
this.geolocationError = error;
273+
logger.error(this.geolocationError);
274+
};
275+
276+
private stopPollingLocation = () => {
277+
clearInterval(this.locationInterval);
278+
this.locationInterval = undefined;
279+
this.lastPublishedPositionTimestamp = undefined;
280+
this.geolocationError = undefined;
281+
282+
if (this.clearPositionWatch) {
283+
this.clearPositionWatch();
284+
this.clearPositionWatch = undefined;
285+
}
286+
};
287+
288+
/**
289+
* Sends m.location events to all live beacons
290+
* Sets last published beacon
291+
*/
292+
private publishLocationToBeacons = async (position: TimedGeoUri) => {
293+
this.lastPublishedPositionTimestamp = Date.now();
294+
// TODO handle failure in individual beacon without rejecting rest
295+
await Promise.all(this.liveBeaconIds.map(beaconId =>
296+
this.sendLocationToBeacon(this.beacons.get(beaconId), position)),
297+
);
298+
};
299+
300+
private debouncedPublishLocationToBeacons = debounce(this.publishLocationToBeacons, MOVING_UPDATE_INTERVAL);
301+
302+
/**
303+
* Sends m.location event to referencing given beacon
304+
*/
305+
private sendLocationToBeacon = async (beacon: Beacon, { geoUri, timestamp }: TimedGeoUri) => {
306+
const content = makeBeaconContent(geoUri, timestamp, beacon.beaconInfoId);
307+
await this.matrixClient.sendEvent(beacon.roomId, M_BEACON.name, content);
308+
};
309+
310+
/**
311+
* Gets the current location
312+
* (as opposed to using watched location)
313+
* and publishes it to all live beacons
314+
*/
315+
private publishCurrentLocationToBeacons = async () => {
316+
const position = await getCurrentPosition();
317+
// TODO error handling
318+
this.publishLocationToBeacons(mapGeolocationPositionToTimedGeo(position));
319+
};
191320
}

test/components/views/beacon/RoomLiveShareWarning-test.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,17 @@ import '../../../skinned-sdk';
2323
import RoomLiveShareWarning from '../../../../src/components/views/beacon/RoomLiveShareWarning';
2424
import { OwnBeaconStore } from '../../../../src/stores/OwnBeaconStore';
2525
import {
26+
advanceDateAndTime,
2627
findByTestId,
2728
getMockClientWithEventEmitter,
2829
makeBeaconInfoEvent,
30+
mockGeolocation,
2931
resetAsyncStoreWithClient,
3032
setupAsyncStoreWithClient,
3133
} from '../../../test-utils';
3234

3335
jest.useFakeTimers();
36+
mockGeolocation();
3437
describe('<RoomLiveShareWarning />', () => {
3538
const aliceId = '@alice:server.org';
3639
const room1Id = '$room1:server.org';
@@ -40,6 +43,7 @@ describe('<RoomLiveShareWarning />', () => {
4043
getVisibleRooms: jest.fn().mockReturnValue([]),
4144
getUserId: jest.fn().mockReturnValue(aliceId),
4245
unstable_setLiveBeacon: jest.fn().mockResolvedValue({ event_id: '1' }),
46+
sendEvent: jest.fn(),
4347
});
4448

4549
// 14.03.2022 16:15
@@ -69,14 +73,6 @@ describe('<RoomLiveShareWarning />', () => {
6973
return [room1, room2];
7074
};
7175

72-
const advanceDateAndTime = (ms: number) => {
73-
// bc liveness check uses Date.now we have to advance this mock
74-
jest.spyOn(global.Date, 'now').mockReturnValue(Date.now() + ms);
75-
76-
// then advance time for the interval by the same amount
77-
jest.advanceTimersByTime(ms);
78-
};
79-
8076
const makeOwnBeaconStore = async () => {
8177
const store = OwnBeaconStore.instance;
8278

@@ -137,12 +133,16 @@ describe('<RoomLiveShareWarning />', () => {
137133

138134
it('renders correctly with one live beacon in room', () => {
139135
const component = getComponent({ roomId: room1Id });
140-
expect(component).toMatchSnapshot();
136+
// beacons have generated ids that break snapshots
137+
// assert on html
138+
expect(component.html()).toMatchSnapshot();
141139
});
142140

143141
it('renders correctly with two live beacons in room', () => {
144142
const component = getComponent({ roomId: room2Id });
145-
expect(component).toMatchSnapshot();
143+
// beacons have generated ids that break snapshots
144+
// assert on html
145+
expect(component.html()).toMatchSnapshot();
146146
// later expiry displayed
147147
expect(getExpiryText(component)).toEqual('12h left');
148148
});

0 commit comments

Comments
 (0)