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

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

Merged
merged 14 commits into from
Mar 28, 2022
16 changes: 8 additions & 8 deletions src/components/views/location/LocationPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,23 @@ import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/client';
import classNames from 'classnames';

import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg';
import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import MemberAvatar from '../avatars/MemberAvatar';
import MatrixClientContext from '../../../contexts/MatrixClientContext';
import Modal from '../../../Modal';
import ErrorDialog from '../dialogs/ErrorDialog';
import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils';
import { findMapStyleUrl } from './findMapStyleUrl';
import { LocationShareType, ShareLocationFn } from './shareLocation';
import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg';
import { LocationShareError } from './LocationShareErrors';
import AccessibleButton from '../elements/AccessibleButton';
import { MapError } from './MapError';
import { getUserNameColorClass } from '../../../utils/FormattingUtils';
import LiveDurationDropdown, { DEFAULT_DURATION_MS } from './LiveDurationDropdown';
import { GenericPosition, genericPositionFromGeolocation, getGeoUri } from '../../../utils/beacon';
import SdkConfig from '../../../SdkConfig';
import ErrorDialog from '../dialogs/ErrorDialog';
import AccessibleButton from '../elements/AccessibleButton';
import { findMapStyleUrl } from './findMapStyleUrl';
import LiveDurationDropdown, { DEFAULT_DURATION_MS } from './LiveDurationDropdown';
import { LocationShareError } from './LocationShareErrors';
import { MapError } from './MapError';
import { LocationShareType, ShareLocationFn } from './shareLocation';
export interface ILocationPickerProps {
sender: RoomMember;
shareType: LocationShareType;
Expand Down
155 changes: 142 additions & 13 deletions src/stores/OwnBeaconStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,41 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { debounce } from "lodash";
import {
Beacon,
BeaconEvent,
MatrixEvent,
Room,
} from "matrix-js-sdk/src/matrix";
import {
BeaconInfoState, makeBeaconInfoContent,
BeaconInfoState, makeBeaconContent, makeBeaconInfoContent,
} 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 { ActionPayload } from "../dispatcher/payloads";
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;

export enum OwnBeaconStoreEvent {
LivenessChange = 'OwnBeaconStore.LivenessChange',
}

const MOVING_UPDATE_INTERVAL = 2000;
const STATIC_UPDATE_INTERVAL = 30000;

type OwnBeaconStoreState = {
beacons: Map<string, Beacon>;
beaconsByRoomId: Map<Room['roomId'], Set<string>>;
Expand All @@ -46,6 +60,15 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
public readonly beacons = new Map<string, Beacon>();
public readonly beaconsByRoomId = new Map<Room['roomId'], Set<string>>();
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() {
super(defaultDispatcher);
Expand All @@ -55,12 +78,21 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
return OwnBeaconStore.internalInstance;
}

/**
* True when we have live beacons
* and geolocation.watchPosition is active
*/
public get isMonitoringLiveLocation() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we document the return type here?

return !!this.clearPositionWatch;
}

protected async onNotReady() {
this.matrixClient.removeListener(BeaconEvent.LivenessChange, this.onBeaconLiveness);
this.matrixClient.removeListener(BeaconEvent.New, this.onNewBeacon);

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

this.stopPollingLocation();
this.beacons.clear();
this.beaconsByRoomId.clear();
this.liveBeaconIds = [];
Expand Down Expand Up @@ -117,21 +149,12 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
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
if (!isLive) {
this.stopBeacon(beacon.identifier);
}

// TODO start location polling here
this.checkLiveness();

this.emit(OwnBeaconStoreEvent.LivenessChange, this.getLiveBeaconIds());
};
Expand Down Expand Up @@ -169,9 +192,29 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
.filter(beacon => beacon.isLive)
.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);
}

// 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> => {
Expand All @@ -188,4 +231,90 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {

await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent);
};

private togglePollingLocation = () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

return type?

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));
};
}
20 changes: 10 additions & 10 deletions test/components/views/beacon/RoomLiveShareWarning-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,17 @@ import '../../../skinned-sdk';
import RoomLiveShareWarning from '../../../../src/components/views/beacon/RoomLiveShareWarning';
import { OwnBeaconStore } from '../../../../src/stores/OwnBeaconStore';
import {
advanceDateAndTime,
findByTestId,
getMockClientWithEventEmitter,
makeBeaconInfoEvent,
mockGeolocation,
resetAsyncStoreWithClient,
setupAsyncStoreWithClient,
} from '../../../test-utils';

jest.useFakeTimers();
mockGeolocation();
describe('<RoomLiveShareWarning />', () => {
const aliceId = '@alice:server.org';
const room1Id = '$room1:server.org';
Expand All @@ -40,6 +43,7 @@ describe('<RoomLiveShareWarning />', () => {
getVisibleRooms: jest.fn().mockReturnValue([]),
getUserId: jest.fn().mockReturnValue(aliceId),
unstable_setLiveBeacon: jest.fn().mockResolvedValue({ event_id: '1' }),
sendEvent: jest.fn(),
});

// 14.03.2022 16:15
Expand Down Expand Up @@ -69,14 +73,6 @@ describe('<RoomLiveShareWarning />', () => {
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 store = OwnBeaconStore.instance;

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

it('renders correctly with one live beacon in room', () => {
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', () => {
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
expect(getExpiryText(component)).toEqual('12h left');
});
Expand Down
Loading