diff --git a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx index d0be4f087e..2b4b7eb70e 100644 --- a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx +++ b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx @@ -27,13 +27,13 @@ interface Props { } const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { - const hasLiveBeacons = useEventEmitterState( + const isMonitoringLiveLocation = useEventEmitterState( OwnBeaconStore.instance, - OwnBeaconStoreEvent.LivenessChange, - () => OwnBeaconStore.instance.hasLiveBeacons(), + OwnBeaconStoreEvent.MonitoringLivePosition, + () => OwnBeaconStore.instance.isMonitoringLiveLocation, ); - if (!hasLiveBeacons) { + if (!isMonitoringLiveLocation) { return null; } diff --git a/src/components/views/beacon/RoomLiveShareWarning.tsx b/src/components/views/beacon/RoomLiveShareWarning.tsx index 5bbde4ccb0..24c0b345cc 100644 --- a/src/components/views/beacon/RoomLiveShareWarning.tsx +++ b/src/components/views/beacon/RoomLiveShareWarning.tsx @@ -77,6 +77,13 @@ type LiveBeaconsState = { const useLiveBeacons = (roomId: Room['roomId']): LiveBeaconsState => { const [stoppingInProgress, setStoppingInProgress] = useState(false); + // do we have an active geolocation.watchPosition + const isMonitoringLiveLocation = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.MonitoringLivePosition, + () => OwnBeaconStore.instance.isMonitoringLiveLocation, + ); + const liveBeaconIds = useEventEmitterState( OwnBeaconStore.instance, OwnBeaconStoreEvent.LivenessChange, @@ -88,7 +95,7 @@ const useLiveBeacons = (roomId: Room['roomId']): LiveBeaconsState => { setStoppingInProgress(false); }, [liveBeaconIds]); - if (!liveBeaconIds?.length) { + if (!isMonitoringLiveLocation || !liveBeaconIds?.length) { return {}; } diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index a0ec82c4b0..6f7bec93dd 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -44,6 +44,7 @@ const isOwnBeacon = (beacon: Beacon, userId: string): boolean => beacon.beaconIn export enum OwnBeaconStoreEvent { LivenessChange = 'OwnBeaconStore.LivenessChange', + MonitoringLivePosition = 'OwnBeaconStore.MonitoringLivePosition', } const MOVING_UPDATE_INTERVAL = 2000; @@ -232,18 +233,28 @@ export class OwnBeaconStore extends AsyncStoreWithClient { await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent); }; - private togglePollingLocation = async (): Promise => { + private togglePollingLocation = () => { if (!!this.liveBeaconIds.length) { - return this.startPollingLocation(); + this.startPollingLocation(); + } else { + this.stopPollingLocation(); } - return this.stopPollingLocation(); }; private startPollingLocation = async () => { // clear any existing interval this.stopPollingLocation(); - this.clearPositionWatch = await watchPosition(this.onWatchedPosition, this.onWatchedPositionError); + try { + this.clearPositionWatch = await watchPosition( + this.onWatchedPosition, + this.onGeolocationError, + ); + } catch (error) { + this.onGeolocationError(error?.message); + // don't set locationInterval if geolocation failed to setup + return; + } this.locationInterval = setInterval(() => { if (!this.lastPublishedPositionTimestamp) { @@ -255,6 +266,8 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.publishCurrentLocationToBeacons(); } }, STATIC_UPDATE_INTERVAL); + + this.emit(OwnBeaconStoreEvent.MonitoringLivePosition); }; private onWatchedPosition = (position: GeolocationPosition) => { @@ -268,11 +281,6 @@ export class OwnBeaconStore extends AsyncStoreWithClient { } }; - private onWatchedPositionError = (error: GeolocationError) => { - this.geolocationError = error; - logger.error(this.geolocationError); - }; - private stopPollingLocation = () => { clearInterval(this.locationInterval); this.locationInterval = undefined; @@ -283,6 +291,8 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.clearPositionWatch(); this.clearPositionWatch = undefined; } + + this.emit(OwnBeaconStoreEvent.MonitoringLivePosition); }; /** @@ -313,8 +323,31 @@ export class OwnBeaconStore extends AsyncStoreWithClient { * and publishes it to all live beacons */ private publishCurrentLocationToBeacons = async () => { - const position = await getCurrentPosition(); - // TODO error handling - this.publishLocationToBeacons(mapGeolocationPositionToTimedGeo(position)); + 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/components/views/beacon/LeftPanelLiveShareWarning-test.tsx b/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx index ed45228105..7ad06fcf12 100644 --- a/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx +++ b/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx @@ -49,9 +49,9 @@ describe('', () => { expect(component.html()).toBe(null); }); - describe('when user has live beacons', () => { + describe('when user has live location monitor', () => { beforeEach(() => { - mocked(OwnBeaconStore.instance).hasLiveBeacons.mockReturnValue(true); + mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = true; }); it('renders correctly when not minimized', () => { const component = getComponent(); @@ -68,8 +68,8 @@ describe('', () => { // started out rendered expect(component.html()).toBeTruthy(); - mocked(OwnBeaconStore.instance).hasLiveBeacons.mockReturnValue(false); - OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.LivenessChange, false); + mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = false; + OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.MonitoringLivePosition); await flushPromises(); component.setProps({}); diff --git a/test/components/views/beacon/RoomLiveShareWarning-test.tsx b/test/components/views/beacon/RoomLiveShareWarning-test.tsx index ad3cbdf5ff..93835c21a3 100644 --- a/test/components/views/beacon/RoomLiveShareWarning-test.tsx +++ b/test/components/views/beacon/RoomLiveShareWarning-test.tsx @@ -18,10 +18,11 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { mount } from 'enzyme'; import { Room, Beacon, BeaconEvent } from 'matrix-js-sdk/src/matrix'; +import { logger } from 'matrix-js-sdk/src/logger'; import '../../../skinned-sdk'; import RoomLiveShareWarning from '../../../../src/components/views/beacon/RoomLiveShareWarning'; -import { OwnBeaconStore } from '../../../../src/stores/OwnBeaconStore'; +import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../../src/stores/OwnBeaconStore'; import { advanceDateAndTime, findByTestId, @@ -33,7 +34,6 @@ import { } from '../../../test-utils'; jest.useFakeTimers(); -mockGeolocation(); describe('', () => { const aliceId = '@alice:server.org'; const room1Id = '$room1:server.org'; @@ -94,6 +94,7 @@ describe('', () => { }; beforeEach(() => { + mockGeolocation(); jest.spyOn(global.Date, 'now').mockReturnValue(now); mockClient.unstable_setLiveBeacon.mockClear(); }); @@ -123,7 +124,22 @@ describe('', () => { expect(component.html()).toBe(null); }); - describe('when user has live beacons', () => { + it('does not render when geolocation is not working', async () => { + jest.spyOn(logger, 'error').mockImplementation(() => { }); + // @ts-ignore + navigator.geolocation = undefined; + await act(async () => { + await makeRoomsWithStateEvents([room1Beacon1, room2Beacon1, room2Beacon2]); + await makeOwnBeaconStore(); + }); + const component = getComponent({ roomId: room1Id }); + + // beacons have generated ids that break snapshots + // assert on html + expect(component.html()).toBeNull(); + }); + + describe('when user has live beacons and geolocation is available', () => { beforeEach(async () => { await act(async () => { await makeRoomsWithStateEvents([room1Beacon1, room2Beacon1, room2Beacon2]); @@ -164,6 +180,22 @@ describe('', () => { expect(component.html()).toBe(null); }); + it('removes itself when user stops monitoring live position', async () => { + const component = getComponent({ roomId: room1Id }); + // started out rendered + expect(component.html()).toBeTruthy(); + + act(() => { + // cheat to clear this + // @ts-ignore + OwnBeaconStore.instance.clearPositionWatch = undefined; + OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.MonitoringLivePosition); + component.setProps({}); + }); + + expect(component.html()).toBe(null); + }); + it('renders when user adds a live beacon', async () => { const component = getComponent({ roomId: room3Id }); // started out not rendered diff --git a/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap b/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap index 9854191fa3..39c8cc6b6a 100644 --- a/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` when user has live beacons renders correctly when minimized 1`] = ` +exports[` when user has live location monitor renders correctly when minimized 1`] = ` @@ -15,7 +15,7 @@ exports[` when user has live beacons renders correc `; -exports[` when user has live beacons renders correctly when not minimized 1`] = ` +exports[` when user has live location monitor renders correctly when not minimized 1`] = `
when user has live beacons renders correctly with one live beacon in room 1`] = `"
You are sharing your live location1h left
"`; +exports[` when user has live beacons and geolocation is available renders correctly with one live beacon in room 1`] = `"
You are sharing your live location1h left
"`; -exports[` when user has live beacons renders correctly with two live beacons in room 1`] = `"
You are sharing your live location12h left
"`; +exports[` when user has live beacons and geolocation is available renders correctly with two live beacons in room 1`] = `"
You are sharing your live location12h left
"`; diff --git a/test/stores/OwnBeaconStore-test.ts b/test/stores/OwnBeaconStore-test.ts index 55da90a1b7..87b3c4c602 100644 --- a/test/stores/OwnBeaconStore-test.ts +++ b/test/stores/OwnBeaconStore-test.ts @@ -17,6 +17,7 @@ limitations under the License. import { Room, Beacon, BeaconEvent, MatrixEvent } 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"; import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../src/stores/OwnBeaconStore"; import { @@ -160,6 +161,7 @@ describe('OwnBeaconStore', () => { mockClient.sendEvent.mockClear().mockResolvedValue({ event_id: '1' }); jest.spyOn(global.Date, 'now').mockReturnValue(now); jest.spyOn(OwnBeaconStore.instance, 'emit').mockRestore(); + jest.spyOn(logger, 'error').mockRestore(); }); afterEach(async () => { @@ -600,32 +602,112 @@ describe('OwnBeaconStore', () => { // stop watching location expect(geolocation.clearWatch).toHaveBeenCalled(); + expect(store.isMonitoringLiveLocation).toEqual(false); }); - it('starts watching position when user starts having live beacons', async () => { - makeRoomsWithStateEvents([]); - await makeOwnBeaconStore(); - // wait for store to settle - await flushPromisesWithFakeTimers(); + describe('when store is initialised with live beacons', () => { + it('starts watching position', async () => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); - addNewBeaconAndEmit(alicesRoom1BeaconInfo); - // wait for store to settle - await flushPromisesWithFakeTimers(); + expect(geolocation.watchPosition).toHaveBeenCalled(); + expect(store.isMonitoringLiveLocation).toEqual(true); + }); - expect(geolocation.watchPosition).toHaveBeenCalled(); + it('kills live beacon when geolocation is unavailable', async () => { + const errorLogSpy = jest.spyOn(logger, 'error').mockImplementation(() => { }); + // remove the mock we set + // @ts-ignore + navigator.geolocation = undefined; + + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + expect(store.isMonitoringLiveLocation).toEqual(false); + expect(errorLogSpy).toHaveBeenCalledWith('Geolocation failed', "Unavailable"); + }); + + it('kills live beacon when geolocation permissions are not granted', async () => { + // similar case to the test above + // but these errors are handled differently + // above is thrown by element, this passed to error callback by geolocation + // return only a permission denied error + geolocation.watchPosition.mockImplementation(watchPositionMockImplementation( + [0], [1]), + ); + + const errorLogSpy = jest.spyOn(logger, 'error').mockImplementation(() => { }); + + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + expect(store.isMonitoringLiveLocation).toEqual(false); + expect(errorLogSpy).toHaveBeenCalledWith('Geolocation failed', "PermissionDenied"); + }); }); - it('publishes position for new beacon immediately', async () => { - makeRoomsWithStateEvents([]); - await makeOwnBeaconStore(); - // wait for store to settle - await flushPromisesWithFakeTimers(); + describe('adding a new beacon', () => { + it('publishes position for new beacon immediately', async () => { + makeRoomsWithStateEvents([]); + const store = await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); - addNewBeaconAndEmit(alicesRoom1BeaconInfo); - // wait for store to settle - await flushPromisesWithFakeTimers(); + addNewBeaconAndEmit(alicesRoom1BeaconInfo); + // wait for store to settle + await flushPromisesWithFakeTimers(); - expect(mockClient.sendEvent).toHaveBeenCalled(); + expect(mockClient.sendEvent).toHaveBeenCalled(); + expect(store.isMonitoringLiveLocation).toEqual(true); + }); + + it('kills live beacons when geolocation is unavailable', async () => { + jest.spyOn(logger, 'error').mockImplementation(() => { }); + // @ts-ignore + navigator.geolocation = undefined; + makeRoomsWithStateEvents([]); + const store = await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + addNewBeaconAndEmit(alicesRoom1BeaconInfo); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + // stop beacon + expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalled(); + expect(store.isMonitoringLiveLocation).toEqual(false); + }); + + it('publishes position for new beacon immediately when there were already live beacons', async () => { + makeRoomsWithStateEvents([alicesRoom2BeaconInfo]); + await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); + expect(mockClient.sendEvent).toHaveBeenCalledTimes(1); + + addNewBeaconAndEmit(alicesRoom1BeaconInfo); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + expect(geolocation.getCurrentPosition).toHaveBeenCalled(); + // once for original event, + // then both live beacons get current position published + // after new beacon is added + expect(mockClient.sendEvent).toHaveBeenCalledTimes(3); + }); }); it('publishes subsequent positions', async () => { @@ -650,6 +732,57 @@ describe('OwnBeaconStore', () => { expect(mockClient.sendEvent).toHaveBeenCalledTimes(3); }); + it('stops live beacons when geolocation permissions are revoked', async () => { + jest.spyOn(logger, 'error').mockImplementation(() => { }); + // return two good positions, then a permission denied error + geolocation.watchPosition.mockImplementation(watchPositionMockImplementation( + [0, 1000, 3000], [0, 0, 1]), + ); + + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + expect(mockClient.sendEvent).toHaveBeenCalledTimes(0); + const store = await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + jest.advanceTimersByTime(5000); + + // first two events were sent successfully + expect(mockClient.sendEvent).toHaveBeenCalledTimes(2); + + // stop beacon + expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalled(); + expect(store.isMonitoringLiveLocation).toEqual(false); + }); + + it('keeps sharing positions when geolocation has a non fatal error', async () => { + const errorLogSpy = jest.spyOn(logger, 'error').mockImplementation(() => { }); + // return good position, timeout error, good position + geolocation.watchPosition.mockImplementation(watchPositionMockImplementation( + [0, 1000, 3000], [0, 3, 0]), + ); + + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + expect(mockClient.sendEvent).toHaveBeenCalledTimes(0); + const store = await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + jest.advanceTimersByTime(5000); + + // two good locations were sent + expect(mockClient.sendEvent).toHaveBeenCalledTimes(2); + + // still sharing + expect(mockClient.unstable_setLiveBeacon).not.toHaveBeenCalled(); + expect(store.isMonitoringLiveLocation).toEqual(true); + expect(errorLogSpy).toHaveBeenCalledWith('Geolocation failed', 'error message'); + }); + it('publishes last known position after 30s of inactivity', async () => { geolocation.watchPosition.mockImplementation( watchPositionMockImplementation([0]), diff --git a/test/test-utils/beacon.ts b/test/test-utils/beacon.ts index 09326a1434..e985305ed4 100644 --- a/test/test-utils/beacon.ts +++ b/test/test-utils/beacon.ts @@ -14,11 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MockedObject } from "jest-mock"; import { makeBeaconInfoContent, makeBeaconContent } from "matrix-js-sdk/src/content-helpers"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import { M_BEACON, M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon"; import { LocationAssetType } from "matrix-js-sdk/src/@types/location"; -import { MockedObject } from "jest-mock"; + +import { getMockGeolocationPositionError } from "./location"; type InfoContentProps = { timeout: number; @@ -150,16 +152,31 @@ export const mockGeolocation = (): MockedObject => { * ``` * will call the provided handler with a mock position at * next tick, 1000ms, 6000ms, 6050ms + * + * to produce errors provide an array of error codes + * that will be applied to the delay with the same index + * eg: + * ``` + * // return two good positions, then a permission denied error + * geolocation.watchPosition.mockImplementation(watchPositionMockImplementation( + * [0, 1000, 3000], [0, 0, 1]), + * ); + * ``` + * See for error codes: https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError */ -export const watchPositionMockImplementation = (delays: number[]) => { - return (callback: PositionCallback) => { +export const watchPositionMockImplementation = (delays: number[], errorCodes: number[] = []) => { + return (callback: PositionCallback, error: PositionErrorCallback) => { const position = makeGeolocationPosition({}); let totalDelay = 0; - delays.map(delayMs => { + delays.map((delayMs, index) => { totalDelay += delayMs; const timeout = setTimeout(() => { - callback({ ...position, timestamp: position.timestamp + totalDelay }); + if (errorCodes[index]) { + error(getMockGeolocationPositionError(errorCodes[index], 'error message')); + } else { + callback({ ...position, timestamp: position.timestamp + totalDelay }); + } }, totalDelay); return timeout; }); diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 6cc8447449..49abc51598 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -1,5 +1,6 @@ export * from './beacon'; export * from './client'; +export * from './location'; export * from './platform'; export * from './test-utils'; // TODO @@TR: Export voice.ts, which currently isn't exported here because it causes all tests to depend on skinning diff --git a/test/test-utils/location.ts b/test/test-utils/location.ts index 16ebcf1020..05bba66958 100644 --- a/test/test-utils/location.ts +++ b/test/test-utils/location.ts @@ -48,3 +48,11 @@ export const makeLocationEvent = (geoUri: string, assetType?: LocationAssetType) }, ); }; + +// https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError +export const getMockGeolocationPositionError = (code: number, message: string): GeolocationPositionError => ({ + code, message, + PERMISSION_DENIED: 1, + POSITION_UNAVAILABLE: 2, + TIMEOUT: 3, +}); diff --git a/test/utils/beacon/geolocation-test.ts b/test/utils/beacon/geolocation-test.ts index 7ae430fad4..6d70183070 100644 --- a/test/utils/beacon/geolocation-test.ts +++ b/test/utils/beacon/geolocation-test.ts @@ -24,20 +24,16 @@ import { watchPosition, } from "../../../src/utils/beacon"; import { getCurrentPosition } from "../../../src/utils/beacon/geolocation"; -import { makeGeolocationPosition, mockGeolocation } from "../../test-utils/beacon"; +import { + makeGeolocationPosition, + mockGeolocation, + getMockGeolocationPositionError, +} from "../../test-utils"; describe('geolocation utilities', () => { let geolocation; const defaultPosition = makeGeolocationPosition({}); - // https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError - const getMockGeolocationPositionError = (code, message) => ({ - code, message, - PERMISSION_DENIED: 1, - POSITION_UNAVAILABLE: 2, - TIMEOUT: 3, - }); - beforeEach(() => { geolocation = mockGeolocation(); });