Live location sharing: set map bounds to include all locations (#8324)

* open a dialog with map centered around first beacon

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

* test dialog opening from beacon body

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

* test beaconmarker

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

* add bounds to Map comp

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

* add focusBeacon to beaconviewdialog, use bounds

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

* lint

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

* use membercolor on beacon view markers

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

* add lnglatbounds to maplibre mock

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

* update snapshots for expanded maplibre Map mock

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

* test map bounds

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

* tidy copy paste comment

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

* add fallback when no more live locations

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

* accurate signature for getBoundsCenter

Signed-off-by: Kerry Archibald <kerrya@element.io>
This commit is contained in:
Kerry 2022-04-19 13:35:39 +02:00 committed by GitHub
parent 6b13988eaa
commit f70186ea9b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 246 additions and 21 deletions

View File

@ -1,5 +1,5 @@
const EventEmitter = require("events"); const EventEmitter = require("events");
const { LngLat, NavigationControl } = require('maplibre-gl'); const { LngLat, NavigationControl, LngLatBounds } = require('maplibre-gl');
class MockMap extends EventEmitter { class MockMap extends EventEmitter {
addControl = jest.fn(); addControl = jest.fn();
@ -8,6 +8,7 @@ class MockMap extends EventEmitter {
zoomOut = jest.fn(); zoomOut = jest.fn();
setCenter = jest.fn(); setCenter = jest.fn();
setStyle = jest.fn(); setStyle = jest.fn();
fitBounds = jest.fn();
} }
const MockMapInstance = new MockMap(); const MockMapInstance = new MockMap();
@ -24,5 +25,6 @@ module.exports = {
GeolocateControl: jest.fn().mockReturnValue(MockGeolocateInstance), GeolocateControl: jest.fn().mockReturnValue(MockGeolocateInstance),
Marker: jest.fn().mockReturnValue(MockMarker), Marker: jest.fn().mockReturnValue(MockMarker),
LngLat, LngLat,
LngLatBounds,
NavigationControl, NavigationControl,
}; };

View File

@ -55,3 +55,25 @@ limitations under the License.
height: 80vh; height: 80vh;
border-radius: 8px; border-radius: 8px;
} }
.mx_BeaconViewDialog_mapFallback {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: url('$(res)/img/location/map.svg');
background-size: cover;
}
.mx_BeaconViewDialog_mapFallbackIcon {
width: 65px;
margin-bottom: $spacing-16;
color: $quaternary-content;
}
.mx_BeaconViewDialog_mapFallbackMessage {
color: $secondary-content;
margin-bottom: $spacing-16;
}

View File

@ -58,6 +58,7 @@ const BeaconMarker: React.FC<Props> = ({ map, beacon }) => {
id={beacon.identifier} id={beacon.identifier}
geoUri={geoUri} geoUri={geoUri}
roomMember={markerRoomMember} roomMember={markerRoomMember}
useMemberColor
/>; />;
}; };

View File

@ -29,29 +29,43 @@ import { IDialogProps } from "../dialogs/IDialogProps";
import Map from '../location/Map'; import Map from '../location/Map';
import ZoomButtons from '../location/ZoomButtons'; import ZoomButtons from '../location/ZoomButtons';
import BeaconMarker from './BeaconMarker'; import BeaconMarker from './BeaconMarker';
import { Bounds, getBeaconBounds } from '../../../utils/beacon/bounds';
import { getGeoUri } from '../../../utils/beacon';
import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
roomId: Room['roomId']; roomId: Room['roomId'];
matrixClient: MatrixClient; matrixClient: MatrixClient;
// open the map centered on this beacon's location
focusBeacon?: Beacon;
} }
// TODO actual center is coming soon const getBoundsCenter = (bounds: Bounds): string | undefined => {
// for now just center around first beacon in list if (!bounds) {
const getMapCenterUri = (beacons: Beacon[]): string => { return;
const firstBeaconWithLocation = beacons.find(beacon => beacon.latestLocationState); }
return getGeoUri({
return firstBeaconWithLocation?.latestLocationState?.uri; latitude: (bounds.north + bounds.south) / 2,
longitude: (bounds.east + bounds.west) / 2,
timestamp: Date.now(),
});
}; };
/** /**
* Dialog to view live beacons maximised * Dialog to view live beacons maximised
*/ */
const BeaconViewDialog: React.FC<IProps> = ({ roomId, matrixClient, onFinished }) => { const BeaconViewDialog: React.FC<IProps> = ({
focusBeacon,
roomId,
matrixClient,
onFinished,
}) => {
const liveBeacons = useLiveBeacons(roomId, matrixClient); const liveBeacons = useLiveBeacons(roomId, matrixClient);
const mapCenterUri = getMapCenterUri(liveBeacons); const bounds = getBeaconBounds(liveBeacons);
// TODO probably show loader or placeholder when there is no location const centerGeoUri = focusBeacon?.latestLocationState?.uri || getBoundsCenter(bounds);
// to center the map on
return ( return (
<BaseDialog <BaseDialog
@ -60,9 +74,10 @@ const BeaconViewDialog: React.FC<IProps> = ({ roomId, matrixClient, onFinished }
fixedWidth={false} fixedWidth={false}
> >
<MatrixClientContext.Provider value={matrixClient}> <MatrixClientContext.Provider value={matrixClient}>
<Map { !!bounds ? <Map
id='mx_BeaconViewDialog' id='mx_BeaconViewDialog'
centerGeoUri={mapCenterUri} bounds={bounds}
centerGeoUri={centerGeoUri}
interactive interactive
className="mx_BeaconViewDialog_map" className="mx_BeaconViewDialog_map"
> >
@ -77,7 +92,22 @@ const BeaconViewDialog: React.FC<IProps> = ({ roomId, matrixClient, onFinished }
<ZoomButtons map={map} /> <ZoomButtons map={map} />
</> </>
} }
</Map> </Map> :
<div
data-test-id='beacon-view-dialog-map-fallback'
className='mx_BeaconViewDialog_map mx_BeaconViewDialog_mapFallback'
>
<LocationIcon className='mx_BeaconViewDialog_mapFallbackIcon' />
<span className='mx_BeaconViewDialog_mapFallbackMessage'>{ _t('No live locations') }</span>
<AccessibleButton
kind='primary'
onClick={onFinished}
data-test-id='beacon-view-dialog-fallback-close'
>
{ _t('Close') }
</AccessibleButton>
</div>
}
</MatrixClientContext.Provider> </MatrixClientContext.Provider>
</BaseDialog> </BaseDialog>
); );

View File

@ -16,6 +16,7 @@ limitations under the License.
import React, { ReactNode, useContext, useEffect } from 'react'; import React, { ReactNode, useContext, useEffect } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import maplibregl from 'maplibre-gl';
import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/matrix'; import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/matrix';
import { logger } from 'matrix-js-sdk/src/logger'; import { logger } from 'matrix-js-sdk/src/logger';
@ -24,8 +25,9 @@ import { useEventEmitterState } from '../../../hooks/useEventEmitter';
import { parseGeoUri } from '../../../utils/location'; import { parseGeoUri } from '../../../utils/location';
import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils';
import { useMap } from '../../../utils/location/useMap'; import { useMap } from '../../../utils/location/useMap';
import { Bounds } from '../../../utils/beacon/bounds';
const useMapWithStyle = ({ id, centerGeoUri, onError, interactive }) => { const useMapWithStyle = ({ id, centerGeoUri, onError, interactive, bounds }) => {
const bodyId = `mx_Map_${id}`; const bodyId = `mx_Map_${id}`;
// style config // style config
@ -55,6 +57,20 @@ const useMapWithStyle = ({ id, centerGeoUri, onError, interactive }) => {
} }
}, [map, centerGeoUri]); }, [map, centerGeoUri]);
useEffect(() => {
if (map && bounds) {
try {
const lngLatBounds = new maplibregl.LngLatBounds(
[bounds.west, bounds.south],
[bounds.east, bounds.north],
);
map.fitBounds(lngLatBounds, { padding: 100 });
} catch (error) {
logger.error('Invalid map bounds', error);
}
}
}, [map, bounds]);
return { return {
map, map,
bodyId, bodyId,
@ -65,6 +81,7 @@ interface MapProps {
id: string; id: string;
interactive?: boolean; interactive?: boolean;
centerGeoUri?: string; centerGeoUri?: string;
bounds?: Bounds;
className?: string; className?: string;
onClick?: () => void; onClick?: () => void;
onError?: (error: Error) => void; onError?: (error: Error) => void;
@ -74,9 +91,15 @@ interface MapProps {
} }
const Map: React.FC<MapProps> = ({ const Map: React.FC<MapProps> = ({
centerGeoUri, className, id, onError, onClick, children, interactive, bounds,
centerGeoUri,
children,
className,
id,
interactive,
onError, onClick,
}) => { }) => {
const { map, bodyId } = useMapWithStyle({ centerGeoUri, onError, id, interactive }); const { map, bodyId } = useMapWithStyle({ centerGeoUri, onError, id, interactive, bounds });
const onMapClick = ( const onMapClick = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>, event: React.MouseEvent<HTMLDivElement, MouseEvent>,

View File

@ -32,8 +32,8 @@ import Spinner from '../elements/Spinner';
import Map from '../location/Map'; import Map from '../location/Map';
import SmartMarker from '../location/SmartMarker'; import SmartMarker from '../location/SmartMarker';
import OwnBeaconStatus from '../beacon/OwnBeaconStatus'; import OwnBeaconStatus from '../beacon/OwnBeaconStatus';
import { IBodyProps } from "./IBodyProps";
import BeaconViewDialog from '../beacon/BeaconViewDialog'; import BeaconViewDialog from '../beacon/BeaconViewDialog';
import { IBodyProps } from "./IBodyProps";
const useBeaconState = (beaconInfoEvent: MatrixEvent): { const useBeaconState = (beaconInfoEvent: MatrixEvent): {
beacon?: Beacon; beacon?: Beacon;
@ -105,6 +105,7 @@ const MBeaconBody: React.FC<IBodyProps> = React.forwardRef(({ mxEvent }, ref) =>
{ {
roomId: mxEvent.getRoomId(), roomId: mxEvent.getRoomId(),
matrixClient, matrixClient,
focusBeacon: beacon,
}, },
"mx_BeaconViewDialog_wrapper", "mx_BeaconViewDialog_wrapper",
false, // isPriority false, // isPriority

View File

@ -2915,6 +2915,7 @@
"Loading live location...": "Loading live location...", "Loading live location...": "Loading live location...",
"Live location ended": "Live location ended", "Live location ended": "Live location ended",
"Live location error": "Live location error", "Live location error": "Live location error",
"No live locations": "No live locations",
"An error occured whilst sharing your live location": "An error occured whilst sharing your live location", "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", "You are sharing your live location": "You are sharing your live location",
"%(timeRemaining)s left": "%(timeRemaining)s left", "%(timeRemaining)s left": "%(timeRemaining)s left",

View File

@ -26,6 +26,7 @@ import {
import BeaconViewDialog from '../../../../src/components/views/beacon/BeaconViewDialog'; import BeaconViewDialog from '../../../../src/components/views/beacon/BeaconViewDialog';
import { import {
findByTestId,
getMockClientWithEventEmitter, getMockClientWithEventEmitter,
makeBeaconEvent, makeBeaconEvent,
makeBeaconInfoEvent, makeBeaconInfoEvent,
@ -118,4 +119,37 @@ describe('<BeaconViewDialog />', () => {
// two markers now! // two markers now!
expect(component.find('BeaconMarker').length).toEqual(2); expect(component.find('BeaconMarker').length).toEqual(2);
}); });
it('renders a fallback when no live beacons remain', () => {
const onFinished = jest.fn();
const room = makeRoomWithStateEvents([defaultEvent]);
const beacon = room.currentState.beacons.get(getBeaconInfoIdentifier(defaultEvent));
beacon.addLocations([location1]);
const component = getComponent({ onFinished });
expect(component.find('BeaconMarker').length).toEqual(1);
// this will replace the defaultEvent
// leading to no more live beacons
const anotherBeaconEvent = makeBeaconInfoEvent(aliceId,
roomId,
{ isLive: false },
'$bob-room1-1',
);
act(() => {
// emits RoomStateEvent.BeaconLiveness
room.currentState.setStateEvents([anotherBeaconEvent]);
});
component.setProps({});
// map placeholder
expect(findByTestId(component, 'beacon-view-dialog-map-fallback')).toMatchSnapshot();
act(() => {
findByTestId(component, 'beacon-view-dialog-fallback-close').at(0).simulate('click');
});
expect(onFinished).toHaveBeenCalled();
});
}); });

View File

@ -61,6 +61,7 @@ exports[`<BeaconMarker /> renders marker when beacon has location 1`] = `
"_eventsCount": 0, "_eventsCount": 0,
"_maxListeners": undefined, "_maxListeners": undefined,
"addControl": [MockFunction], "addControl": [MockFunction],
"fitBounds": [MockFunction],
"removeControl": [MockFunction], "removeControl": [MockFunction],
"setCenter": [MockFunction], "setCenter": [MockFunction],
"setStyle": [MockFunction], "setStyle": [MockFunction],
@ -79,6 +80,7 @@ exports[`<BeaconMarker /> renders marker when beacon has location 1`] = `
"_eventsCount": 0, "_eventsCount": 0,
"_maxListeners": undefined, "_maxListeners": undefined,
"addControl": [MockFunction], "addControl": [MockFunction],
"fitBounds": [MockFunction],
"removeControl": [MockFunction], "removeControl": [MockFunction],
"setCenter": [MockFunction], "setCenter": [MockFunction],
"setStyle": [MockFunction], "setStyle": [MockFunction],
@ -111,6 +113,7 @@ exports[`<BeaconMarker /> renders marker when beacon has location 1`] = `
Symbol(kCapture): false, Symbol(kCapture): false,
} }
} }
useMemberColor={true}
> >
<span> <span>
<ForwardRef <ForwardRef
@ -139,9 +142,10 @@ exports[`<BeaconMarker /> renders marker when beacon has location 1`] = `
Symbol(kCapture): false, Symbol(kCapture): false,
} }
} }
useMemberColor={true}
> >
<div <div
className="mx_Marker mx_Marker_defaultColor" className="mx_Marker mx_Username_color4"
id="!room:server_@alice:server" id="!room:server_@alice:server"
> >
<div <div

View File

@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<BeaconViewDialog /> renders a fallback when no live beacons remain 1`] = `
<div
className="mx_BeaconViewDialog_map mx_BeaconViewDialog_mapFallback"
data-test-id="beacon-view-dialog-map-fallback"
>
<div
className="mx_BeaconViewDialog_mapFallbackIcon"
/>
<span
className="mx_BeaconViewDialog_mapFallbackMessage"
>
No live locations
</span>
<AccessibleButton
data-test-id="beacon-view-dialog-fallback-close"
element="div"
kind="primary"
onClick={[MockFunction]}
role="button"
tabIndex={0}
>
<div
className="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-test-id="beacon-view-dialog-fallback-close"
onClick={[MockFunction]}
onKeyDown={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
>
Close
</div>
</AccessibleButton>
</div>
`;

View File

@ -115,6 +115,38 @@ describe('<Map />', () => {
}); });
}); });
describe('map bounds', () => {
it('does not try to fit map bounds when no bounds provided', () => {
getComponent({ bounds: null });
expect(mockMap.fitBounds).not.toHaveBeenCalled();
});
it('fits map to bounds', () => {
const bounds = { north: 51, south: 50, east: 42, west: 41 };
getComponent({ bounds });
expect(mockMap.fitBounds).toHaveBeenCalledWith(new maplibregl.LngLatBounds([bounds.west, bounds.south],
[bounds.east, bounds.north]), { padding: 100 });
});
it('handles invalid bounds', () => {
const logSpy = jest.spyOn(logger, 'error').mockImplementation();
const bounds = { north: 'a', south: 'b', east: 42, west: 41 };
getComponent({ bounds });
expect(mockMap.fitBounds).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith('Invalid map bounds', new Error('Invalid LngLat object: (41, NaN)'));
});
it('updates map bounds when bounds prop changes', () => {
const component = getComponent({ centerGeoUri: 'geo:51,42' });
const bounds = { north: 51, south: 50, east: 42, west: 41 };
const bounds2 = { north: 53, south: 51, east: 45, west: 44 };
component.setProps({ bounds });
component.setProps({ bounds: bounds2 });
expect(mockMap.fitBounds).toHaveBeenCalledTimes(2);
});
});
describe('children', () => { describe('children', () => {
it('renders without children', () => { it('renders without children', () => {
const component = getComponent({ children: null }); const component = getComponent({ children: null });

View File

@ -24,6 +24,7 @@ exports[`<LocationViewDialog /> renders map correctly 1`] = `
"_eventsCount": 1, "_eventsCount": 1,
"_maxListeners": undefined, "_maxListeners": undefined,
"addControl": [MockFunction], "addControl": [MockFunction],
"fitBounds": [MockFunction],
"removeControl": [MockFunction], "removeControl": [MockFunction],
"setCenter": [MockFunction] { "setCenter": [MockFunction] {
"calls": Array [ "calls": Array [
@ -76,6 +77,7 @@ exports[`<LocationViewDialog /> renders map correctly 1`] = `
"_eventsCount": 1, "_eventsCount": 1,
"_maxListeners": undefined, "_maxListeners": undefined,
"addControl": [MockFunction], "addControl": [MockFunction],
"fitBounds": [MockFunction],
"removeControl": [MockFunction], "removeControl": [MockFunction],
"setCenter": [MockFunction] { "setCenter": [MockFunction] {
"calls": Array [ "calls": Array [

View File

@ -9,6 +9,7 @@ exports[`<SmartMarker /> creates a marker on mount 1`] = `
"_eventsCount": 0, "_eventsCount": 0,
"_maxListeners": undefined, "_maxListeners": undefined,
"addControl": [MockFunction], "addControl": [MockFunction],
"fitBounds": [MockFunction],
"removeControl": [MockFunction], "removeControl": [MockFunction],
"setCenter": [MockFunction], "setCenter": [MockFunction],
"setStyle": [MockFunction], "setStyle": [MockFunction],
@ -45,6 +46,7 @@ exports[`<SmartMarker /> removes marker on unmount 1`] = `
"_eventsCount": 0, "_eventsCount": 0,
"_maxListeners": undefined, "_maxListeners": undefined,
"addControl": [MockFunction], "addControl": [MockFunction],
"fitBounds": [MockFunction],
"removeControl": [MockFunction], "removeControl": [MockFunction],
"setCenter": [MockFunction], "setCenter": [MockFunction],
"setStyle": [MockFunction], "setStyle": [MockFunction],

View File

@ -8,6 +8,7 @@ exports[`<ZoomButtons /> renders buttons 1`] = `
"_eventsCount": 0, "_eventsCount": 0,
"_maxListeners": undefined, "_maxListeners": undefined,
"addControl": [MockFunction], "addControl": [MockFunction],
"fitBounds": [MockFunction],
"removeControl": [MockFunction], "removeControl": [MockFunction],
"setCenter": [MockFunction], "setCenter": [MockFunction],
"setStyle": [MockFunction], "setStyle": [MockFunction],

View File

@ -86,7 +86,6 @@ describe('<MBeaconBody />', () => {
}); });
const modalSpy = jest.spyOn(Modal, 'createTrackedDialog').mockReturnValue(undefined); const modalSpy = jest.spyOn(Modal, 'createTrackedDialog').mockReturnValue(undefined);
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@ -123,7 +122,6 @@ describe('<MBeaconBody />', () => {
); );
makeRoomWithStateEvents([beaconInfoEvent]); makeRoomWithStateEvents([beaconInfoEvent]);
const component = getComponent({ mxEvent: beaconInfoEvent }); const component = getComponent({ mxEvent: beaconInfoEvent });
act(() => { act(() => {
component.find('.mx_MBeaconBody_map').simulate('click'); component.find('.mx_MBeaconBody_map').simulate('click');
}); });
@ -268,6 +266,40 @@ describe('<MBeaconBody />', () => {
expect(modalSpy).toHaveBeenCalled(); expect(modalSpy).toHaveBeenCalled();
}); });
it('does nothing on click when a beacon has no location', () => {
makeRoomWithStateEvents([aliceBeaconInfo]);
const component = getComponent({ mxEvent: aliceBeaconInfo });
act(() => {
component.find('.mx_MBeaconBody_map').simulate('click');
});
expect(modalSpy).not.toHaveBeenCalled();
});
it('renders a live beacon with a location correctly', () => {
const room = makeRoomWithStateEvents([aliceBeaconInfo]);
const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo));
beaconInstance.addLocations([location1]);
const component = getComponent({ mxEvent: aliceBeaconInfo });
expect(component.find('Map').length).toBeTruthy;
});
it('opens maximised map view on click when beacon has a live location', () => {
const room = makeRoomWithStateEvents([aliceBeaconInfo]);
const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo));
beaconInstance.addLocations([location1]);
const component = getComponent({ mxEvent: aliceBeaconInfo });
act(() => {
component.find('Map').simulate('click');
});
// opens modal
expect(modalSpy).toHaveBeenCalled();
});
it('updates latest location', () => { it('updates latest location', () => {
const room = makeRoomWithStateEvents([aliceBeaconInfo]); const room = makeRoomWithStateEvents([aliceBeaconInfo]);
const component = getComponent({ mxEvent: aliceBeaconInfo }); const component = getComponent({ mxEvent: aliceBeaconInfo });

View File

@ -124,6 +124,7 @@ exports[`MLocationBody <MLocationBody> without error renders map correctly 1`] =
"_eventsCount": 1, "_eventsCount": 1,
"_maxListeners": undefined, "_maxListeners": undefined,
"addControl": [MockFunction], "addControl": [MockFunction],
"fitBounds": [MockFunction],
"removeControl": [MockFunction], "removeControl": [MockFunction],
"setCenter": [MockFunction] { "setCenter": [MockFunction] {
"calls": Array [ "calls": Array [