diff --git a/res/css/components/views/beacon/_LeftPanelLiveShareWarning.scss b/res/css/components/views/beacon/_LeftPanelLiveShareWarning.scss index 0ee60a65f27..04645c965ed 100644 --- a/res/css/components/views/beacon/_LeftPanelLiveShareWarning.scss +++ b/res/css/components/views/beacon/_LeftPanelLiveShareWarning.scss @@ -15,6 +15,7 @@ limitations under the License. */ .mx_LeftPanelLiveShareWarning { + @mixin ButtonResetDefault; width: 100%; box-sizing: border-box; @@ -29,3 +30,7 @@ limitations under the License. // go above to get hover for title z-index: 1; } + +.mx_LeftPanelLiveShareWarning__error { + background-color: $alert; +} diff --git a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx index 2b4b7eb70ee..07ba4cd2369 100644 --- a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx +++ b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx @@ -21,11 +21,31 @@ import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { _t } from '../../../languageHandler'; import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../stores/OwnBeaconStore'; import { Icon as LiveLocationIcon } from '../../../../res/img/location/live-location.svg'; +import { ViewRoomPayload } from '../../../dispatcher/payloads/ViewRoomPayload'; +import { Action } from '../../../dispatcher/actions'; +import dispatcher from '../../../dispatcher/dispatcher'; +import AccessibleButton from '../elements/AccessibleButton'; interface Props { isMinimized?: boolean; } +/** + * Choose the most relevant beacon + * and get its roomId + */ +const chooseBestBeaconRoomId = (liveBeaconIds, errorBeaconIds): string | undefined => { + // both lists are ordered by creation timestamp in store + // so select latest beacon + const beaconId = errorBeaconIds?.[0] ?? liveBeaconIds?.[0]; + if (!beaconId) { + return undefined; + } + const beacon = OwnBeaconStore.instance.getBeaconById(beaconId); + + return beacon?.roomId; +}; + const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { const isMonitoringLiveLocation = useEventEmitterState( OwnBeaconStore.instance, @@ -33,18 +53,48 @@ const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { () => OwnBeaconStore.instance.isMonitoringLiveLocation, ); + const beaconIdsWithWireError = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.WireError, + () => OwnBeaconStore.instance.getLiveBeaconIdsWithWireError(), + ); + + const liveBeaconIds = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.LivenessChange, + () => OwnBeaconStore.instance.getLiveBeaconIds(), + ); + + const hasWireErrors = !!beaconIdsWithWireError.length; + if (!isMonitoringLiveLocation) { return null; } - return
{ + dispatcher.dispatch({ + action: Action.ViewRoom, + room_id: relevantBeaconRoomId, + metricsTrigger: undefined, + }); + } : undefined; + + const label = hasWireErrors ? + _t('An error occured whilst sharing your live location') : + _t('You are sharing your live location'); + + return - { isMinimized ? : _t('You are sharing your live location') } -
; + { isMinimized ? : label } + ; }; export default LeftPanelLiveShareWarning; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 32d15003774..05182353e2f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2896,6 +2896,7 @@ "Beta": "Beta", "Leave the beta": "Leave the beta", "Join the beta": "Join the beta", + "An error occured whilst sharing your live location": "An error occured whilst sharing your live location", "You are sharing your live location": "You are sharing your live location", "%(timeRemaining)s left": "%(timeRemaining)s left", "An error occured whilst sharing your live location, please try again": "An error occured whilst sharing your live location, please try again", diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index 3bc6e784697..31ddd42b858 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -38,6 +38,7 @@ import { ClearWatchCallback, GeolocationError, mapGeolocationPositionToTimedGeo, + sortBeaconsByLatestCreation, TimedGeoUri, watchPosition, } from "../utils/beacon"; @@ -73,6 +74,10 @@ export class OwnBeaconStore extends AsyncStoreWithClient { * Reset on successful publish of location */ public readonly beaconWireErrorCounts = new Map(); + /** + * ids of live beacons + * ordered by creation time descending + */ private liveBeaconIds = []; private locationInterval: number; private geolocationError: GeolocationError | undefined; @@ -126,17 +131,17 @@ export class OwnBeaconStore extends AsyncStoreWithClient { // we don't actually do anything here } - public hasLiveBeacons(roomId?: string): boolean { + public hasLiveBeacons = (roomId?: string): boolean => { return !!this.getLiveBeaconIds(roomId).length; - } + }; /** * Some live beacon has a wire error * Optionally filter by room */ - public hasWireErrors(roomId?: string): boolean { + public hasWireErrors = (roomId?: string): boolean => { return this.getLiveBeaconIds(roomId).some(this.beaconHasWireError); - } + }; /** * If a beacon has failed to publish position @@ -157,16 +162,20 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.publishCurrentLocationToBeacons(); }; - public getLiveBeaconIds(roomId?: string): string[] { + public getLiveBeaconIds = (roomId?: string): string[] => { if (!roomId) { return this.liveBeaconIds; } return this.liveBeaconIds.filter(beaconId => this.beaconsByRoomId.get(roomId)?.has(beaconId)); - } + }; + + public getLiveBeaconIdsWithWireError = (roomId?: string): string[] => { + return this.getLiveBeaconIds(roomId).filter(this.beaconHasWireError); + }; - public getBeaconById(beaconId: string): Beacon | undefined { + public getBeaconById = (beaconId: string): Beacon | undefined => { return this.beacons.get(beaconId); - } + }; public stopBeacon = async (beaconInfoType: string): Promise => { const beacon = this.beacons.get(beaconInfoType); @@ -287,6 +296,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { const prevLiveBeaconIds = this.getLiveBeaconIds(); this.liveBeaconIds = [...this.beacons.values()] .filter(beacon => beacon.isLive) + .sort(sortBeaconsByLatestCreation) .map(beacon => beacon.identifier); const diff = arrayDiff(prevLiveBeaconIds, this.liveBeaconIds); diff --git a/src/utils/beacon/duration.ts b/src/utils/beacon/duration.ts index 30d5eac4852..b8338a8536f 100644 --- a/src/utils/beacon/duration.ts +++ b/src/utils/beacon/duration.ts @@ -34,3 +34,7 @@ export const getBeaconExpiryTimestamp = (beacon: Beacon): number => export const sortBeaconsByLatestExpiry = (left: Beacon, right: Beacon): number => getBeaconExpiryTimestamp(right) - getBeaconExpiryTimestamp(left); + +// aka sort by timestamp descending +export const sortBeaconsByLatestCreation = (left: Beacon, right: Beacon): number => + right.beaconInfo.timestamp - left.beaconInfo.timestamp; diff --git a/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx b/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx index 7ad06fcf12d..29e52e233e9 100644 --- a/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx +++ b/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx @@ -17,17 +17,23 @@ limitations under the License. import React from 'react'; import { mocked } from 'jest-mock'; import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { Beacon } from 'matrix-js-sdk/src/matrix'; import '../../../skinned-sdk'; import LeftPanelLiveShareWarning from '../../../../src/components/views/beacon/LeftPanelLiveShareWarning'; import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../../src/stores/OwnBeaconStore'; -import { flushPromises } from '../../../test-utils'; +import { flushPromises, makeBeaconInfoEvent } from '../../../test-utils'; +import dispatcher from '../../../../src/dispatcher/dispatcher'; +import { Action } from '../../../../src/dispatcher/actions'; jest.mock('../../../../src/stores/OwnBeaconStore', () => { // eslint-disable-next-line @typescript-eslint/no-var-requires const EventEmitter = require("events"); class MockOwnBeaconStore extends EventEmitter { - public hasLiveBeacons = jest.fn().mockReturnValue(false); + public getLiveBeaconIdsWithWireError = jest.fn().mockReturnValue([]); + public getBeaconById = jest.fn(); + public getLiveBeaconIds = jest.fn().mockReturnValue([]); } return { // @ts-ignore @@ -44,32 +50,136 @@ describe('', () => { const getComponent = (props = {}) => mount(); + const roomId1 = '!room1:server'; + const roomId2 = '!room2:server'; + const aliceId = '@alive:server'; + + const now = 1647270879403; + const HOUR_MS = 3600000; + + beforeEach(() => { + jest.spyOn(global.Date, 'now').mockReturnValue(now); + jest.spyOn(dispatcher, 'dispatch').mockClear().mockImplementation(() => { }); + }); + + afterAll(() => { + jest.spyOn(global.Date, 'now').mockRestore(); + + jest.restoreAllMocks(); + }); + // 12h old, 12h left + const beacon1 = new Beacon(makeBeaconInfoEvent(aliceId, + roomId1, + { timeout: HOUR_MS * 24, timestamp: now - 12 * HOUR_MS }, + '$1', + )); + // 10h left + const beacon2 = new Beacon(makeBeaconInfoEvent(aliceId, + roomId2, + { timeout: HOUR_MS * 10, timestamp: now }, + '$2', + )); + it('renders nothing when user has no live beacons', () => { const component = getComponent(); expect(component.html()).toBe(null); }); describe('when user has live location monitor', () => { + beforeAll(() => { + mocked(OwnBeaconStore.instance).getBeaconById.mockImplementation(beaconId => { + if (beaconId === beacon1.identifier) { + return beacon1; + } + return beacon2; + }); + }); + beforeEach(() => { mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = true; + mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([]); + mocked(OwnBeaconStore.instance).getLiveBeaconIds.mockReturnValue([beacon2.identifier, beacon1.identifier]); }); + it('renders correctly when not minimized', () => { const component = getComponent(); expect(component).toMatchSnapshot(); }); + it('goes to room of latest beacon when clicked', () => { + const component = getComponent(); + const dispatchSpy = jest.spyOn(dispatcher, 'dispatch'); + + act(() => { + component.simulate('click'); + }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + metricsTrigger: undefined, + // latest beacon's room + room_id: roomId2, + }); + }); + it('renders correctly when minimized', () => { const component = getComponent({ isMinimized: true }); expect(component).toMatchSnapshot(); }); + it('renders wire error', () => { + mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([beacon1.identifier]); + const component = getComponent(); + expect(component).toMatchSnapshot(); + }); + + it('goes to room of latest beacon with wire error when clicked', () => { + mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([beacon1.identifier]); + const component = getComponent(); + const dispatchSpy = jest.spyOn(dispatcher, 'dispatch'); + + act(() => { + component.simulate('click'); + }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + metricsTrigger: undefined, + // error beacon's room + room_id: roomId1, + }); + }); + + it('goes back to default style when wire errors are cleared', () => { + mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([beacon1.identifier]); + const component = getComponent(); + // error mode + expect(component.find('.mx_LeftPanelLiveShareWarning').at(0).text()).toEqual( + 'An error occured whilst sharing your live location', + ); + + act(() => { + mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([]); + OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.WireError, 'abc'); + }); + + component.setProps({}); + + // default mode + expect(component.find('.mx_LeftPanelLiveShareWarning').at(0).text()).toEqual( + 'You are sharing your live location', + ); + }); + it('removes itself when user stops having live beacons', async () => { const component = getComponent({ isMinimized: true }); // started out rendered expect(component.html()).toBeTruthy(); - mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = false; - OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.MonitoringLivePosition); + act(() => { + mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = false; + OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.MonitoringLivePosition); + }); await flushPromises(); component.setProps({}); diff --git a/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap b/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap index 39c8cc6b6a7..bd9f943b35e 100644 --- a/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap @@ -4,23 +4,73 @@ exports[` when user has live location monitor rende -
-
+ className="mx_AccessibleButton mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__minimized" + onClick={[Function]} + onKeyDown={[Function]} + onKeyUp={[Function]} + role="button" + tabIndex={0} + title="You are sharing your live location" + > +
+
+ `; exports[` when user has live location monitor renders correctly when not minimized 1`] = ` -
- You are sharing your live location -
+
+ You are sharing your live location +
+ +
+`; + +exports[` when user has live location monitor renders wire error 1`] = ` + + +
+ An error occured whilst sharing your live location +
+
`; diff --git a/test/utils/beacon/duration-test.ts b/test/utils/beacon/duration-test.ts index 822860097b1..e8a0d36c633 100644 --- a/test/utils/beacon/duration-test.ts +++ b/test/utils/beacon/duration-test.ts @@ -16,7 +16,11 @@ limitations under the License. import { Beacon } from "matrix-js-sdk/src/matrix"; -import { msUntilExpiry, sortBeaconsByLatestExpiry } from "../../../src/utils/beacon"; +import { + msUntilExpiry, + sortBeaconsByLatestExpiry, + sortBeaconsByLatestCreation, +} from "../../../src/utils/beacon"; import { makeBeaconInfoEvent } from "../../test-utils"; describe('beacon utils', () => { @@ -80,4 +84,35 @@ describe('beacon utils', () => { ]); }); }); + + describe('sortBeaconsByLatestCreation()', () => { + const roomId = '!room:server'; + const aliceId = '@alive:server'; + + // 12h old, 12h left + const beacon1 = new Beacon(makeBeaconInfoEvent(aliceId, + roomId, + { timeout: HOUR_MS * 24, timestamp: now - 12 * HOUR_MS }, + '$1', + )); + // 10h left + const beacon2 = new Beacon(makeBeaconInfoEvent(aliceId, + roomId, + { timeout: HOUR_MS * 10, timestamp: now }, + '$2', + )); + + // 1ms left + const beacon3 = new Beacon(makeBeaconInfoEvent(aliceId, + roomId, + { timeout: HOUR_MS + 1, timestamp: now - HOUR_MS }, + '$3', + )); + + it('sorts beacons by descending creation time', () => { + expect([beacon1, beacon2, beacon3].sort(sortBeaconsByLatestCreation)).toEqual([ + beacon2, beacon3, beacon1, + ]); + }); + }); });