diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js index 90a30968d9..599cacde13 100644 --- a/__mocks__/maplibre-gl.js +++ b/__mocks__/maplibre-gl.js @@ -1,5 +1,5 @@ const EventEmitter = require("events"); -const { LngLat, NavigationControl } = require('maplibre-gl'); +const { LngLat, NavigationControl, LngLatBounds } = require('maplibre-gl'); class MockMap extends EventEmitter { addControl = jest.fn(); @@ -8,6 +8,7 @@ class MockMap extends EventEmitter { zoomOut = jest.fn(); setCenter = jest.fn(); setStyle = jest.fn(); + fitBounds = jest.fn(); } const MockMapInstance = new MockMap(); @@ -24,5 +25,6 @@ module.exports = { GeolocateControl: jest.fn().mockReturnValue(MockGeolocateInstance), Marker: jest.fn().mockReturnValue(MockMarker), LngLat, + LngLatBounds, NavigationControl, }; diff --git a/res/css/components/views/beacon/_BeaconViewDialog.scss b/res/css/components/views/beacon/_BeaconViewDialog.scss index 901b456439..dc4d089bfe 100644 --- a/res/css/components/views/beacon/_BeaconViewDialog.scss +++ b/res/css/components/views/beacon/_BeaconViewDialog.scss @@ -55,3 +55,25 @@ limitations under the License. height: 80vh; 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; +} diff --git a/src/components/views/beacon/BeaconMarker.tsx b/src/components/views/beacon/BeaconMarker.tsx index 8c176ab9c0..f7f284b88e 100644 --- a/src/components/views/beacon/BeaconMarker.tsx +++ b/src/components/views/beacon/BeaconMarker.tsx @@ -58,6 +58,7 @@ const BeaconMarker: React.FC = ({ map, beacon }) => { id={beacon.identifier} geoUri={geoUri} roomMember={markerRoomMember} + useMemberColor />; }; diff --git a/src/components/views/beacon/BeaconViewDialog.tsx b/src/components/views/beacon/BeaconViewDialog.tsx index 052a456fe6..12f26a0a54 100644 --- a/src/components/views/beacon/BeaconViewDialog.tsx +++ b/src/components/views/beacon/BeaconViewDialog.tsx @@ -29,29 +29,43 @@ import { IDialogProps } from "../dialogs/IDialogProps"; import Map from '../location/Map'; import ZoomButtons from '../location/ZoomButtons'; 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 { roomId: Room['roomId']; matrixClient: MatrixClient; + // open the map centered on this beacon's location + focusBeacon?: Beacon; } -// TODO actual center is coming soon -// for now just center around first beacon in list -const getMapCenterUri = (beacons: Beacon[]): string => { - const firstBeaconWithLocation = beacons.find(beacon => beacon.latestLocationState); - - return firstBeaconWithLocation?.latestLocationState?.uri; +const getBoundsCenter = (bounds: Bounds): string | undefined => { + if (!bounds) { + return; + } + return getGeoUri({ + latitude: (bounds.north + bounds.south) / 2, + longitude: (bounds.east + bounds.west) / 2, + timestamp: Date.now(), + }); }; /** * Dialog to view live beacons maximised */ -const BeaconViewDialog: React.FC = ({ roomId, matrixClient, onFinished }) => { +const BeaconViewDialog: React.FC = ({ + focusBeacon, + roomId, + matrixClient, + onFinished, +}) => { const liveBeacons = useLiveBeacons(roomId, matrixClient); - const mapCenterUri = getMapCenterUri(liveBeacons); - // TODO probably show loader or placeholder when there is no location - // to center the map on + const bounds = getBeaconBounds(liveBeacons); + const centerGeoUri = focusBeacon?.latestLocationState?.uri || getBoundsCenter(bounds); return ( = ({ roomId, matrixClient, onFinished } fixedWidth={false} > - @@ -77,7 +92,22 @@ const BeaconViewDialog: React.FC = ({ roomId, matrixClient, onFinished } } - + : +
+ + { _t('No live locations') } + + { _t('Close') } + +
+ }
); diff --git a/src/components/views/location/Map.tsx b/src/components/views/location/Map.tsx index 8776e8e826..fc3bfab3eb 100644 --- a/src/components/views/location/Map.tsx +++ b/src/components/views/location/Map.tsx @@ -16,6 +16,7 @@ limitations under the License. import React, { ReactNode, useContext, useEffect } from 'react'; import classNames from 'classnames'; +import maplibregl from 'maplibre-gl'; import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/matrix'; import { logger } from 'matrix-js-sdk/src/logger'; @@ -24,8 +25,9 @@ import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { parseGeoUri } from '../../../utils/location'; import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; 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}`; // style config @@ -55,6 +57,20 @@ const useMapWithStyle = ({ id, centerGeoUri, onError, interactive }) => { } }, [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 { map, bodyId, @@ -65,6 +81,7 @@ interface MapProps { id: string; interactive?: boolean; centerGeoUri?: string; + bounds?: Bounds; className?: string; onClick?: () => void; onError?: (error: Error) => void; @@ -74,9 +91,15 @@ interface MapProps { } const Map: React.FC = ({ - 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 = ( event: React.MouseEvent, diff --git a/src/components/views/messages/MBeaconBody.tsx b/src/components/views/messages/MBeaconBody.tsx index f61ec346e4..4beac79101 100644 --- a/src/components/views/messages/MBeaconBody.tsx +++ b/src/components/views/messages/MBeaconBody.tsx @@ -32,8 +32,8 @@ import Spinner from '../elements/Spinner'; import Map from '../location/Map'; import SmartMarker from '../location/SmartMarker'; import OwnBeaconStatus from '../beacon/OwnBeaconStatus'; -import { IBodyProps } from "./IBodyProps"; import BeaconViewDialog from '../beacon/BeaconViewDialog'; +import { IBodyProps } from "./IBodyProps"; const useBeaconState = (beaconInfoEvent: MatrixEvent): { beacon?: Beacon; @@ -105,6 +105,7 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent }, ref) => { roomId: mxEvent.getRoomId(), matrixClient, + focusBeacon: beacon, }, "mx_BeaconViewDialog_wrapper", false, // isPriority diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index aacc7f577d..46632c3449 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2915,6 +2915,7 @@ "Loading live location...": "Loading live location...", "Live location ended": "Live location ended", "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", "You are sharing your live location": "You are sharing your live location", "%(timeRemaining)s left": "%(timeRemaining)s left", diff --git a/test/components/views/beacon/BeaconViewDialog-test.tsx b/test/components/views/beacon/BeaconViewDialog-test.tsx index 70dddd2710..b3573de0f9 100644 --- a/test/components/views/beacon/BeaconViewDialog-test.tsx +++ b/test/components/views/beacon/BeaconViewDialog-test.tsx @@ -26,6 +26,7 @@ import { import BeaconViewDialog from '../../../../src/components/views/beacon/BeaconViewDialog'; import { + findByTestId, getMockClientWithEventEmitter, makeBeaconEvent, makeBeaconInfoEvent, @@ -118,4 +119,37 @@ describe('', () => { // two markers now! 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(); + }); }); diff --git a/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap index cde5fd8232..e590cbcd9f 100644 --- a/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap @@ -61,6 +61,7 @@ exports[` renders marker when beacon has location 1`] = ` "_eventsCount": 0, "_maxListeners": undefined, "addControl": [MockFunction], + "fitBounds": [MockFunction], "removeControl": [MockFunction], "setCenter": [MockFunction], "setStyle": [MockFunction], @@ -79,6 +80,7 @@ exports[` renders marker when beacon has location 1`] = ` "_eventsCount": 0, "_maxListeners": undefined, "addControl": [MockFunction], + "fitBounds": [MockFunction], "removeControl": [MockFunction], "setCenter": [MockFunction], "setStyle": [MockFunction], @@ -111,6 +113,7 @@ exports[` renders marker when beacon has location 1`] = ` Symbol(kCapture): false, } } + useMemberColor={true} > renders marker when beacon has location 1`] = ` Symbol(kCapture): false, } } + useMemberColor={true} >
renders a fallback when no live beacons remain 1`] = ` +
+
+ + No live locations + + +
+ Close +
+
+
+`; diff --git a/test/components/views/location/Map-test.tsx b/test/components/views/location/Map-test.tsx index 40aaee01a0..a1e1680b18 100644 --- a/test/components/views/location/Map-test.tsx +++ b/test/components/views/location/Map-test.tsx @@ -115,6 +115,38 @@ describe('', () => { }); }); + 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', () => { it('renders without children', () => { const component = getComponent({ children: null }); diff --git a/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap b/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap index 41b4044c5a..8a1910a582 100644 --- a/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap +++ b/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap @@ -24,6 +24,7 @@ exports[` renders map correctly 1`] = ` "_eventsCount": 1, "_maxListeners": undefined, "addControl": [MockFunction], + "fitBounds": [MockFunction], "removeControl": [MockFunction], "setCenter": [MockFunction] { "calls": Array [ @@ -76,6 +77,7 @@ exports[` renders map correctly 1`] = ` "_eventsCount": 1, "_maxListeners": undefined, "addControl": [MockFunction], + "fitBounds": [MockFunction], "removeControl": [MockFunction], "setCenter": [MockFunction] { "calls": Array [ diff --git a/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap b/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap index b2da037e22..d20c9bcd6c 100644 --- a/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap +++ b/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap @@ -9,6 +9,7 @@ exports[` creates a marker on mount 1`] = ` "_eventsCount": 0, "_maxListeners": undefined, "addControl": [MockFunction], + "fitBounds": [MockFunction], "removeControl": [MockFunction], "setCenter": [MockFunction], "setStyle": [MockFunction], @@ -45,6 +46,7 @@ exports[` removes marker on unmount 1`] = ` "_eventsCount": 0, "_maxListeners": undefined, "addControl": [MockFunction], + "fitBounds": [MockFunction], "removeControl": [MockFunction], "setCenter": [MockFunction], "setStyle": [MockFunction], diff --git a/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap b/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap index 7f18eccc82..0fbc985168 100644 --- a/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap +++ b/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap @@ -8,6 +8,7 @@ exports[` renders buttons 1`] = ` "_eventsCount": 0, "_maxListeners": undefined, "addControl": [MockFunction], + "fitBounds": [MockFunction], "removeControl": [MockFunction], "setCenter": [MockFunction], "setStyle": [MockFunction], diff --git a/test/components/views/messages/MBeaconBody-test.tsx b/test/components/views/messages/MBeaconBody-test.tsx index 9ec5db5f2e..b37bf65bbd 100644 --- a/test/components/views/messages/MBeaconBody-test.tsx +++ b/test/components/views/messages/MBeaconBody-test.tsx @@ -86,7 +86,6 @@ describe('', () => { }); const modalSpy = jest.spyOn(Modal, 'createTrackedDialog').mockReturnValue(undefined); - beforeEach(() => { jest.clearAllMocks(); }); @@ -123,7 +122,6 @@ describe('', () => { ); makeRoomWithStateEvents([beaconInfoEvent]); const component = getComponent({ mxEvent: beaconInfoEvent }); - act(() => { component.find('.mx_MBeaconBody_map').simulate('click'); }); @@ -268,6 +266,40 @@ describe('', () => { 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', () => { const room = makeRoomWithStateEvents([aliceBeaconInfo]); const component = getComponent({ mxEvent: aliceBeaconInfo }); diff --git a/test/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap b/test/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap index e32cedfde4..2edfc8e22d 100644 --- a/test/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap +++ b/test/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap @@ -124,6 +124,7 @@ exports[`MLocationBody without error renders map correctly 1`] = "_eventsCount": 1, "_maxListeners": undefined, "addControl": [MockFunction], + "fitBounds": [MockFunction], "removeControl": [MockFunction], "setCenter": [MockFunction] { "calls": Array [