From 9ba55d1d141088e8f2a9f4542840a76ac2824c20 Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 11 Apr 2022 18:40:06 +0200 Subject: [PATCH] Live location sharing - consolidate maps (#8236) * extract location markers into generic Marker Signed-off-by: Kerry Archibald * wrap marker in smartmarker Signed-off-by: Kerry Archibald * test smartmarker Signed-off-by: Kerry Archibald * working map in location body Signed-off-by: Kerry Archibald * test Map Signed-off-by: Kerry Archibald * remove skinned sdk Signed-off-by: Kerry Archibald * update snaps with new mocks Signed-off-by: Kerry Archibald * use new ZoomButtons in MLocationBody Signed-off-by: Kerry Archibald * make LocationViewDialog map interactive Signed-off-by: Kerry Archibald * test MLocationBody Signed-off-by: Kerry Archibald * test LocationViewDialog Signed-off-by: Kerry Archibald * add copyrights, shrink snapshot Signed-off-by: Kerry Archibald * update comment Signed-off-by: Kerry Archibald * lint Signed-off-by: Kerry Archibald * lint Signed-off-by: Kerry Archibald --- __mocks__/maplibre-gl.js | 2 + .../views/dialogs/_LocationViewDialog.scss | 49 +----- .../views/location/LocationPicker.tsx | 1 + .../views/location/LocationViewDialog.tsx | 87 ++++----- src/components/views/location/Map.tsx | 101 +++++++++++ .../views/messages/MLocationBody.tsx | 156 ++++++----------- src/i18n/strings/en_EN.json | 4 +- src/utils/location/useMap.ts | 62 +++++++ .../views/location/LocationPicker-test.tsx | 2 +- .../location/LocationViewDialog-test.tsx | 56 ++++++ test/components/views/location/Map-test.tsx | 165 ++++++++++++++++++ .../LocationViewDialog-test.tsx.snap | 156 +++++++++++++++++ .../__snapshots__/SmartMarker-test.tsx.snap | 4 + .../__snapshots__/ZoomButtons-test.tsx.snap | 2 + .../views/messages/MLocationBody-test.tsx | 120 ++++++++++--- .../__snapshots__/MLocationBody-test.tsx.snap | 156 +++++++++++++++++ 16 files changed, 889 insertions(+), 234 deletions(-) create mode 100644 src/components/views/location/Map.tsx create mode 100644 src/utils/location/useMap.ts create mode 100644 test/components/views/location/LocationViewDialog-test.tsx create mode 100644 test/components/views/location/Map-test.tsx create mode 100644 test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js index 687f769a705..90a30968d9b 100644 --- a/__mocks__/maplibre-gl.js +++ b/__mocks__/maplibre-gl.js @@ -6,6 +6,8 @@ class MockMap extends EventEmitter { removeControl = jest.fn(); zoomIn = jest.fn(); zoomOut = jest.fn(); + setCenter = jest.fn(); + setStyle = jest.fn(); } const MockMapInstance = new MockMap(); diff --git a/res/css/views/dialogs/_LocationViewDialog.scss b/res/css/views/dialogs/_LocationViewDialog.scss index e7cdaf88007..600c3082657 100644 --- a/res/css/views/dialogs/_LocationViewDialog.scss +++ b/res/css/views/dialogs/_LocationViewDialog.scss @@ -48,49 +48,10 @@ limitations under the License. background-color: $dialog-close-external-color; } } +} - .mx_MLocationBody { - position: absolute; - - .mx_MLocationBody_map { - width: 80vw; - height: 80vh; - } - - .mx_MLocationBody_zoomButtons { - position: absolute; - display: grid; - grid-template-columns: auto; - grid-row-gap: 8px; - - right: 24px; - bottom: 48px; - - .mx_AccessibleButton { - background-color: $background; - box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.25); - border-radius: 4px; - width: 24px; - height: 24px; - - .mx_MLocationBody_zoomButton { - background-color: $primary-content; - margin: 4px; - width: 16px; - height: 16px; - mask-repeat: no-repeat; - mask-size: contain; - mask-position: center; - } - - .mx_MLocationBody_plusButton { - mask-image: url('$(res)/img/element-icons/plus-button.svg'); - } - - .mx_MLocationBody_minusButton { - mask-image: url('$(res)/img/element-icons/minus-button.svg'); - } - } - } - } +.mx_LocationViewDialog_map { + width: 80vw; + height: 80vh; + border-radius: 8px; } diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index ffeb40774bd..254ec335cbd 100644 --- a/src/components/views/location/LocationPicker.tsx +++ b/src/components/views/location/LocationPicker.tsx @@ -225,6 +225,7 @@ class LocationPicker extends React.Component { return (
+ { this.props.shareType === LocationShareType.Pin &&
{ this.state.position ? _t("Click to move the pin") : _t("Click to drop a pin") } diff --git a/src/components/views/location/LocationViewDialog.tsx b/src/components/views/location/LocationViewDialog.tsx index a3363e8f22a..9719013ee4c 100644 --- a/src/components/views/location/LocationViewDialog.tsx +++ b/src/components/views/location/LocationViewDialog.tsx @@ -16,13 +16,14 @@ limitations under the License. import React from 'react'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; -import { ClientEvent, IClientWellKnown, MatrixClient } from 'matrix-js-sdk/src/client'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; import BaseDialog from "../dialogs/BaseDialog"; import { IDialogProps } from "../dialogs/IDialogProps"; -import { LocationBodyContent } from '../messages/MLocationBody'; -import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; -import { parseGeoUri, locationEventGeoUri, createMapWithCoords } from '../../../utils/location'; +import { locationEventGeoUri, isSelfLocation } from '../../../utils/location'; +import Map from './Map'; +import SmartMarker from './SmartMarker'; +import ZoomButtons from './ZoomButtons'; interface IProps extends IDialogProps { matrixClient: MatrixClient; @@ -34,78 +35,54 @@ interface IState { } export default class LocationViewDialog extends React.Component { - private coords: GeolocationCoordinates; - private map?: maplibregl.Map; - constructor(props: IProps) { super(props); - this.coords = parseGeoUri(locationEventGeoUri(this.props.mxEvent)); - this.map = null; this.state = { error: undefined, }; } - componentDidMount() { - if (this.state.error) { - return; - } - - this.props.matrixClient.on(ClientEvent.ClientWellKnown, this.updateStyleUrl); - - this.map = createMapWithCoords( - this.coords, - true, - this.getBodyId(), - this.getMarkerId(), - (e: Error) => this.setState({ error: e }), - ); - } - - componentWillUnmount() { - this.props.matrixClient.off(ClientEvent.ClientWellKnown, this.updateStyleUrl); - } - - private updateStyleUrl = (clientWellKnown: IClientWellKnown) => { - const style = tileServerFromWellKnown(clientWellKnown)?.["map_style_url"]; - if (style) { - this.map?.setStyle(style); - } - }; - private getBodyId = () => { return `mx_LocationViewDialog_${this.props.mxEvent.getId()}`; }; - private getMarkerId = () => { - return `mx_MLocationViewDialog_marker_${this.props.mxEvent.getId()}`; - }; - - private onZoomIn = () => { - this.map?.zoomIn(); - }; - - private onZoomOut = () => { - this.map?.zoomOut(); + private onError = (error) => { + this.setState({ error }); }; render() { + const { mxEvent } = this.props; + + // only pass member to marker when should render avatar marker + const markerRoomMember = isSelfLocation(mxEvent.getContent()) ? mxEvent.sender : undefined; + const geoUri = locationEventGeoUri(mxEvent); return ( - + + { + ({ map }) => + <> + + + + } + ); } diff --git a/src/components/views/location/Map.tsx b/src/components/views/location/Map.tsx new file mode 100644 index 00000000000..8776e8e8264 --- /dev/null +++ b/src/components/views/location/Map.tsx @@ -0,0 +1,101 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { ReactNode, useContext, useEffect } from 'react'; +import classNames from 'classnames'; +import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/matrix'; +import { logger } from 'matrix-js-sdk/src/logger'; + +import MatrixClientContext from '../../../contexts/MatrixClientContext'; +import { useEventEmitterState } from '../../../hooks/useEventEmitter'; +import { parseGeoUri } from '../../../utils/location'; +import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; +import { useMap } from '../../../utils/location/useMap'; + +const useMapWithStyle = ({ id, centerGeoUri, onError, interactive }) => { + const bodyId = `mx_Map_${id}`; + + // style config + const context = useContext(MatrixClientContext); + const mapStyleUrl = useEventEmitterState( + context, + ClientEvent.ClientWellKnown, + (clientWellKnown: IClientWellKnown) => tileServerFromWellKnown(clientWellKnown)?.["map_style_url"], + ); + + const map = useMap({ interactive, bodyId, onError }); + + useEffect(() => { + if (mapStyleUrl && map) { + map.setStyle(mapStyleUrl); + } + }, [mapStyleUrl, map]); + + useEffect(() => { + if (map && centerGeoUri) { + try { + const coords = parseGeoUri(centerGeoUri); + map.setCenter({ lon: coords.longitude, lat: coords.latitude }); + } catch (error) { + logger.error('Could not set map center', centerGeoUri); + } + } + }, [map, centerGeoUri]); + + return { + map, + bodyId, + }; +}; + +interface MapProps { + id: string; + interactive?: boolean; + centerGeoUri?: string; + className?: string; + onClick?: () => void; + onError?: (error: Error) => void; + children?: (renderProps: { + map: maplibregl.Map; + }) => ReactNode; +} + +const Map: React.FC = ({ + centerGeoUri, className, id, onError, onClick, children, interactive, +}) => { + const { map, bodyId } = useMapWithStyle({ centerGeoUri, onError, id, interactive }); + + const onMapClick = ( + event: React.MouseEvent, + ) => { + // Eat click events when clicking the attribution button + const target = event.target as Element; + if (target.classList.contains("maplibregl-ctrl-attrib-button")) { + return; + } + + onClick && onClick(); + }; + + return
+ { !!children && !!map && children({ map }) } +
; +}; + +export default Map; diff --git a/src/components/views/messages/MLocationBody.tsx b/src/components/views/messages/MLocationBody.tsx index 8ab065695c5..94abc1c7a88 100644 --- a/src/components/views/messages/MLocationBody.tsx +++ b/src/components/views/messages/MLocationBody.tsx @@ -15,28 +15,23 @@ limitations under the License. */ import React from 'react'; -import maplibregl from 'maplibre-gl'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; -import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/client'; -import { IBodyProps } from "./IBodyProps"; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; import { - parseGeoUri, locationEventGeoUri, - createMapWithCoords, getLocationShareErrorMessage, LocationShareError, isSelfLocation, } from '../../../utils/location'; -import LocationViewDialog from '../location/LocationViewDialog'; +import MatrixClientContext from '../../../contexts/MatrixClientContext'; import TooltipTarget from '../elements/TooltipTarget'; import { Alignment } from '../elements/Tooltip'; -import AccessibleButton from '../elements/AccessibleButton'; -import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; -import MatrixClientContext from '../../../contexts/MatrixClientContext'; -import Marker from '../location/Marker'; +import LocationViewDialog from '../location/LocationViewDialog'; +import Map from '../location/Map'; +import SmartMarker from '../location/SmartMarker'; +import { IBodyProps } from "./IBodyProps"; interface IState { error: Error; @@ -45,61 +40,23 @@ interface IState { export default class MLocationBody extends React.Component { public static contextType = MatrixClientContext; public context!: React.ContextType; - private coords: GeolocationCoordinates; - private bodyId: string; - private markerId: string; - private map?: maplibregl.Map = null; + private mapId: string; constructor(props: IBodyProps) { super(props); const randomString = Math.random().toString(16).slice(2, 10); + // multiple instances of same map might be in document + // eg thread and main timeline, reply const idSuffix = `${props.mxEvent.getId()}_${randomString}`; - this.bodyId = `mx_MLocationBody_${idSuffix}`; - this.markerId = `mx_MLocationBody_marker_${idSuffix}`; - this.coords = parseGeoUri(locationEventGeoUri(this.props.mxEvent)); + this.mapId = `mx_MLocationBody_${idSuffix}`; this.state = { error: undefined, }; } - componentDidMount() { - if (this.state.error) { - return; - } - - this.context.on(ClientEvent.ClientWellKnown, this.updateStyleUrl); - - this.map = createMapWithCoords( - this.coords, - false, - this.bodyId, - this.markerId, - (e: Error) => this.setState({ error: e }), - ); - } - - componentWillUnmount() { - this.context.off(ClientEvent.ClientWellKnown, this.updateStyleUrl); - } - - private updateStyleUrl = (clientWellKnown: IClientWellKnown) => { - const style = tileServerFromWellKnown(clientWellKnown)?.["map_style_url"]; - if (style) { - this.map?.setStyle(style); - } - }; - - private onClick = ( - event: React.MouseEvent, - ) => { - // Don't open map if we clicked the attribution button - const target = event.target as Element; - if (target.classList.contains("maplibregl-ctrl-attrib-button")) { - return; - } - + private onClick = () => { Modal.createTrackedDialog( 'Location View', '', @@ -114,14 +71,17 @@ export default class MLocationBody extends React.Component { ); }; + private onError = (error) => { + this.setState({ error }); + }; + render(): React.ReactElement { return this.state.error ? : ; @@ -147,68 +107,52 @@ export const LocationBodyFallbackContent: React.FC<{ event: MatrixEvent, error: interface LocationBodyContentProps { mxEvent: MatrixEvent; - bodyId: string; - markerId: string; - error: Error; + mapId: string; tooltip?: string; - onClick?: (event: React.MouseEvent) => void; - zoomButtons?: boolean; - onZoomIn?: () => void; - onZoomOut?: () => void; + onError: (error: Error) => void; + onClick?: () => void; } -export const LocationBodyContent: React.FC = (props) => { - const mapDiv =
; - +export const LocationBodyContent: React.FC = ({ + mxEvent, + mapId, + tooltip, + onError, + onClick, +}) => { // only pass member to marker when should render avatar marker - const markerRoomMember = isSelfLocation(props.mxEvent.getContent()) ? props.mxEvent.sender : undefined; + const markerRoomMember = isSelfLocation(mxEvent.getContent()) ? mxEvent.sender : undefined; + const geoUri = locationEventGeoUri(mxEvent); + + const mapElement = ( + { + ({ map }) => + + } + ); return
{ - props.tooltip + tooltip ? - { mapDiv } + { mapElement } - : mapDiv - } - - { - props.zoomButtons - ? - : null + : mapElement }
; }; -interface IZoomButtonsProps { - onZoomIn: () => void; - onZoomOut: () => void; -} - -function ZoomButtons(props: IZoomButtonsProps): React.ReactElement { - return
- -
- - -
- -
; -} - diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 86c35a5cdd1..9afc3d58c39 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2118,8 +2118,6 @@ "Unable to load map": "Unable to load map", "Shared their location: ": "Shared their location: ", "Shared a location: ": "Shared a location: ", - "Zoom in": "Zoom in", - "Zoom out": "Zoom out", "Can't edit poll": "Can't edit poll", "Sorry, you can't edit a poll after votes have been cast.": "Sorry, you can't edit a poll after votes have been cast.", "Vote not registered": "Vote not registered", @@ -2173,6 +2171,8 @@ "My live location": "My live location", "Drop a Pin": "Drop a Pin", "What location type do you want to share?": "What location type do you want to share?", + "Zoom in": "Zoom in", + "Zoom out": "Zoom out", "Frequently Used": "Frequently Used", "Smileys & People": "Smileys & People", "Animals & Nature": "Animals & Nature", diff --git a/src/utils/location/useMap.ts b/src/utils/location/useMap.ts new file mode 100644 index 00000000000..d4083629506 --- /dev/null +++ b/src/utils/location/useMap.ts @@ -0,0 +1,62 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useEffect, useState } from 'react'; +import { Map as MapLibreMap } from 'maplibre-gl'; + +import { createMap } from "./map"; + +interface UseMapProps { + bodyId: string; + onError: (error: Error) => void; + interactive?: boolean; +} + +/** + * Create a map instance + * Add listeners for errors + * Make sure `onError` has a stable reference + * As map is recreated on changes to it + */ +export const useMap = ({ + interactive, + bodyId, + onError, +}: UseMapProps): MapLibreMap => { + const [map, setMap] = useState(); + + useEffect( + () => { + try { + setMap(createMap(interactive, bodyId, onError)); + } catch (error) { + onError(error); + } + return () => { + if (map) { + map.remove(); + setMap(undefined); + } + }; + }, + // map is excluded as a dependency + // eslint-disable-next-line react-hooks/exhaustive-deps + [interactive, bodyId, onError], + ); + + return map; +}; + diff --git a/test/components/views/location/LocationPicker-test.tsx b/test/components/views/location/LocationPicker-test.tsx index 31b781d0561..6dd25ae72d0 100644 --- a/test/components/views/location/LocationPicker-test.tsx +++ b/test/components/views/location/LocationPicker-test.tsx @@ -283,7 +283,7 @@ describe("LocationPicker", () => { }); // marker not added - expect(wrapper.find('.mx_MLocationBody_markerBorder').length).toBeFalsy(); + expect(wrapper.find('Marker').length).toBeFalsy(); }); it('sets position on click event', () => { diff --git a/test/components/views/location/LocationViewDialog-test.tsx b/test/components/views/location/LocationViewDialog-test.tsx new file mode 100644 index 00000000000..432d7107fb2 --- /dev/null +++ b/test/components/views/location/LocationViewDialog-test.tsx @@ -0,0 +1,56 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { mount } from 'enzyme'; +import { RoomMember } from 'matrix-js-sdk/src/matrix'; +import { LocationAssetType } from 'matrix-js-sdk/src/@types/location'; + +import LocationViewDialog from '../../../../src/components/views/location/LocationViewDialog'; +import { getMockClientWithEventEmitter, makeLocationEvent } from '../../../test-utils'; + +describe('', () => { + const roomId = '!room:server'; + const userId = '@user:server'; + const mockClient = getMockClientWithEventEmitter({ + getClientWellKnown: jest.fn().mockReturnValue({ + "m.tile_server": { map_style_url: 'maps.com' }, + }), + isGuest: jest.fn().mockReturnValue(false), + }); + const defaultEvent = makeLocationEvent("geo:51.5076,-0.1276", LocationAssetType.Pin); + const defaultProps = { + matrixClient: mockClient, + mxEvent: defaultEvent, + onFinished: jest.fn(), + }; + const getComponent = (props = {}) => + mount(); + + it('renders map correctly', () => { + const component = getComponent(); + expect(component.find('Map')).toMatchSnapshot(); + }); + + it('renders marker correctly for self share', () => { + const selfShareEvent = makeLocationEvent("geo:51.5076,-0.1276", LocationAssetType.Self); + const member = new RoomMember(roomId, userId); + // @ts-ignore cheat assignment to property + selfShareEvent.sender = member; + const component = getComponent({ mxEvent: selfShareEvent }); + expect(component.find('SmartMarker').props()['roomMember']).toEqual(member); + }); +}); diff --git a/test/components/views/location/Map-test.tsx b/test/components/views/location/Map-test.tsx new file mode 100644 index 00000000000..b5fce12a88d --- /dev/null +++ b/test/components/views/location/Map-test.tsx @@ -0,0 +1,165 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import maplibregl from 'maplibre-gl'; +import { ClientEvent } from 'matrix-js-sdk/src/matrix'; +import { logger } from 'matrix-js-sdk/src/logger'; + +import Map from '../../../../src/components/views/location/Map'; +import { findByTestId, getMockClientWithEventEmitter } from '../../../test-utils'; +import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; + +describe('', () => { + const defaultProps = { + centerGeoUri: 'geo:52,41', + id: 'test-123', + onError: jest.fn(), + onClick: jest.fn(), + }; + const matrixClient = getMockClientWithEventEmitter({ + getClientWellKnown: jest.fn().mockReturnValue({ + "m.tile_server": { map_style_url: 'maps.com' }, + }), + }); + const getComponent = (props = {}) => + mount(, { + wrappingComponent: MatrixClientContext.Provider, + wrappingComponentProps: { value: matrixClient }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + matrixClient.getClientWellKnown.mockReturnValue({ + "m.tile_server": { map_style_url: 'maps.com' }, + }); + + jest.spyOn(logger, 'error').mockRestore(); + }); + + const mockMap = new maplibregl.Map(); + + it('renders', () => { + const component = getComponent(); + expect(component).toBeTruthy(); + }); + + describe('onClientWellKnown emits', () => { + it('updates map style when style url is truthy', () => { + getComponent(); + + act(() => { + matrixClient.emit(ClientEvent.ClientWellKnown, { + "m.tile_server": { map_style_url: 'new.maps.com' }, + }); + }); + + expect(mockMap.setStyle).toHaveBeenCalledWith('new.maps.com'); + }); + + it('does not update map style when style url is truthy', () => { + getComponent(); + + act(() => { + matrixClient.emit(ClientEvent.ClientWellKnown, { + "m.tile_server": { map_style_url: undefined }, + }); + }); + + expect(mockMap.setStyle).not.toHaveBeenCalledWith(); + }); + }); + + describe('map centering', () => { + it('does not try to center when no center uri provided', () => { + getComponent({ centerGeoUri: null }); + expect(mockMap.setCenter).not.toHaveBeenCalled(); + }); + + it('sets map center to centerGeoUri', () => { + getComponent({ centerGeoUri: 'geo:51,42' }); + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 51, lon: 42 }); + }); + + it('handles invalid centerGeoUri', () => { + const logSpy = jest.spyOn(logger, 'error').mockImplementation(); + getComponent({ centerGeoUri: '123 Sesame Street' }); + expect(mockMap.setCenter).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith('Could not set map center', '123 Sesame Street'); + }); + + it('updates map center when centerGeoUri prop changes', () => { + const component = getComponent({ centerGeoUri: 'geo:51,42' }); + + component.setProps({ centerGeoUri: 'geo:53,45' }); + component.setProps({ centerGeoUri: 'geo:56,47' }); + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 51, lon: 42 }); + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 53, lon: 45 }); + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 56, lon: 47 }); + }); + }); + + describe('children', () => { + it('renders without children', () => { + const component = getComponent({ children: null }); + + component.setProps({}); + + // no error + expect(component).toBeTruthy(); + }); + + it('renders children with map renderProp', () => { + const children = ({ map }) =>
Hello, world
; + + const component = getComponent({ children }); + + // renders child with map instance + expect(findByTestId(component, 'test-child').props()['data-map']).toEqual(mockMap); + }); + }); + + describe('onClick', () => { + it('eats clicks to maplibre attribution button', () => { + const onClick = jest.fn(); + const component = getComponent({ onClick }); + + act(() => { + // this is added to the dom by maplibregl + // which is mocked + // just fake the target + const fakeEl = document.createElement('div'); + fakeEl.className = 'maplibregl-ctrl-attrib-button'; + component.simulate('click', { target: fakeEl }); + }); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('calls onClick', () => { + const onClick = jest.fn(); + const component = getComponent({ onClick }); + + act(() => { + component.simulate('click'); + }); + + expect(onClick).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap b/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap new file mode 100644 index 00000000000..99cd996a102 --- /dev/null +++ b/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap @@ -0,0 +1,156 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders map correctly 1`] = ` + +
+ + +
+
+
+
+
+ + + +
+ +
+
+
+ + +
+
+
+ +
+ +
+ +`; diff --git a/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap b/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap index 724e0b3fc88..064b3ccff66 100644 --- a/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap +++ b/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap @@ -10,6 +10,8 @@ exports[` creates a marker on mount 1`] = ` "_maxListeners": undefined, "addControl": [MockFunction], "removeControl": [MockFunction], + "setCenter": [MockFunction], + "setStyle": [MockFunction], "zoomIn": [MockFunction], "zoomOut": [MockFunction], Symbol(kCapture): false, @@ -42,6 +44,8 @@ exports[` removes marker on unmount 1`] = ` "_maxListeners": undefined, "addControl": [MockFunction], "removeControl": [MockFunction], + "setCenter": [MockFunction], + "setStyle": [MockFunction], "zoomIn": [MockFunction], "zoomOut": [MockFunction], Symbol(kCapture): false, diff --git a/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap b/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap index ba5a0b46992..7f18eccc82b 100644 --- a/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap +++ b/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap @@ -9,6 +9,8 @@ exports[` renders buttons 1`] = ` "_maxListeners": undefined, "addControl": [MockFunction], "removeControl": [MockFunction], + "setCenter": [MockFunction], + "setStyle": [MockFunction], "zoomIn": [MockFunction], "zoomOut": [MockFunction], Symbol(kCapture): false, diff --git a/test/components/views/messages/MLocationBody-test.tsx b/test/components/views/messages/MLocationBody-test.tsx index 11b8d707f2f..c1133e81c0a 100644 --- a/test/components/views/messages/MLocationBody-test.tsx +++ b/test/components/views/messages/MLocationBody-test.tsx @@ -16,50 +16,52 @@ limitations under the License. import React from 'react'; import { mount } from "enzyme"; -import { mocked } from 'jest-mock'; import { LocationAssetType } from "matrix-js-sdk/src/@types/location"; +import { RoomMember } from 'matrix-js-sdk/src/matrix'; import maplibregl from 'maplibre-gl'; import { logger } from 'matrix-js-sdk/src/logger'; +import { act } from 'react-dom/test-utils'; import MLocationBody from "../../../../src/components/views/messages/MLocationBody"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper"; -import { getTileServerWellKnown } from "../../../../src/utils/WellKnownUtils"; +import Modal from '../../../../src/Modal'; import SdkConfig from "../../../../src/SdkConfig"; import { makeLocationEvent } from "../../../test-utils/location"; - -jest.mock("../../../../src/utils/WellKnownUtils", () => ({ - getTileServerWellKnown: jest.fn(), -})); +import { getMockClientWithEventEmitter } from '../../../test-utils'; describe("MLocationBody", () => { describe('', () => { + const roomId = '!room:server'; + const userId = '@user:server'; + const mockClient = getMockClientWithEventEmitter({ + getClientWellKnown: jest.fn().mockReturnValue({ + "m.tile_server": { map_style_url: 'maps.com' }, + }), + isGuest: jest.fn().mockReturnValue(false), + }); + const defaultEvent = makeLocationEvent("geo:51.5076,-0.1276", LocationAssetType.Pin); + const defaultProps = { + mxEvent: defaultEvent, + highlights: [], + highlightLink: '', + onHeightChanged: jest.fn(), + onMessageAllowed: jest.fn(), + permalinkCreator: {} as RoomPermalinkCreator, + mediaEventHelper: {} as MediaEventHelper, + }; + const getComponent = (props = {}) => mount(, { + wrappingComponent: MatrixClientContext.Provider, + wrappingComponentProps: { value: mockClient }, + }); describe('with error', () => { - const mockClient = { - on: jest.fn(), - off: jest.fn(), - }; - const defaultEvent = makeLocationEvent("geo:51.5076,-0.1276", LocationAssetType.Pin); - const defaultProps = { - mxEvent: defaultEvent, - highlights: [], - highlightLink: '', - onHeightChanged: jest.fn(), - onMessageAllowed: jest.fn(), - permalinkCreator: {} as RoomPermalinkCreator, - mediaEventHelper: {} as MediaEventHelper, - }; - const getComponent = (props = {}) => mount(, { - wrappingComponent: MatrixClientContext.Provider, - wrappingComponentProps: { value: mockClient }, - }); let sdkConfigSpy; beforeEach(() => { // eat expected errors to keep console clean jest.spyOn(logger, 'error').mockImplementation(() => { }); - mocked(getTileServerWellKnown).mockReturnValue({}); + mockClient.getClientWellKnown.mockReturnValue({}); sdkConfigSpy = jest.spyOn(SdkConfig, 'get').mockReturnValue({}); }); @@ -75,7 +77,9 @@ describe("MLocationBody", () => { it('displays correct fallback content when map_style_url is misconfigured', () => { const mockMap = new maplibregl.Map(); - mocked(getTileServerWellKnown).mockReturnValue({ map_style_url: 'bad-tile-server.com' }); + mockClient.getClientWellKnown.mockReturnValue({ + "m.tile_server": { map_style_url: 'bad-tile-server.com' }, + }); const component = getComponent(); // simulate error initialising map in maplibregl @@ -85,5 +89,69 @@ describe("MLocationBody", () => { expect(component.find(".mx_EventTile_body")).toMatchSnapshot(); }); }); + + describe('without error', () => { + beforeEach(() => { + mockClient.getClientWellKnown.mockReturnValue({ + "m.tile_server": { map_style_url: 'maps.com' }, + }); + + // MLocationBody uses random number for map id + // stabilise for test + jest.spyOn(global.Math, 'random').mockReturnValue(0.123456); + }); + + afterAll(() => { + jest.spyOn(global.Math, 'random').mockRestore(); + }); + + it('renders map correctly', () => { + const mockMap = new maplibregl.Map(); + const component = getComponent(); + + expect(component).toMatchSnapshot(); + // map was centered + expect(mockMap.setCenter).toHaveBeenCalledWith({ + lat: 51.5076, lon: -0.1276, + }); + }); + + it('opens map dialog on click', () => { + const modalSpy = jest.spyOn(Modal, 'createTrackedDialog').mockReturnValue(undefined); + const component = getComponent(); + + act(() => { + component.find('Map').at(0).simulate('click'); + }); + + expect(modalSpy).toHaveBeenCalled(); + }); + + it('renders marker correctly for a non-self share', () => { + const mockMap = new maplibregl.Map(); + const component = getComponent(); + + expect(component.find('SmartMarker').at(0).props()).toEqual( + expect.objectContaining({ + map: mockMap, + geoUri: 'geo:51.5076,-0.1276', + roomMember: undefined, + }), + ); + }); + + it('renders marker correctly for a self share', () => { + const selfShareEvent = makeLocationEvent("geo:51.5076,-0.1276", LocationAssetType.Self); + const member = new RoomMember(roomId, userId); + // @ts-ignore cheat assignment to property + selfShareEvent.sender = member; + const component = getComponent({ mxEvent: selfShareEvent }); + + // render self locations with user avatars + expect(component.find('SmartMarker').at(0).props()['roomMember']).toEqual( + member, + ); + }); + }); }); }); diff --git a/test/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap b/test/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap index 7db39cdfcb6..c422d64fcad 100644 --- a/test/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap +++ b/test/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap @@ -27,3 +27,159 @@ exports[`MLocationBody with error displays correct fallback cont Shared a location: Found at geo:51.5076,-0.1276 at 2021-12-21T12:22+0000
`; + +exports[`MLocationBody without error renders map correctly 1`] = ` + + +
+ +
+ +
+ + +
+
+
+
+
+ + +
+ +
+ +
+ + +`;