diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js index b1f114e8ef..8686089825 100644 --- a/__mocks__/maplibre-gl.js +++ b/__mocks__/maplibre-gl.js @@ -1,20 +1,23 @@ const EventEmitter = require("events"); -const { LngLat } = require('maplibre-gl'); +const { LngLat, NavigationControl } = require('maplibre-gl'); class MockMap extends EventEmitter { addControl = jest.fn(); removeControl = jest.fn(); } -class MockGeolocateControl extends EventEmitter { +const MockMapInstance = new MockMap(); +class MockGeolocateControl extends EventEmitter { + trigger = jest.fn(); } -class MockMarker extends EventEmitter { - setLngLat = jest.fn().mockReturnValue(this); - addTo = jest.fn(); -} +const MockGeolocateInstance = new MockGeolocateControl(); +const MockMarker = {} +MockMarker.setLngLat = jest.fn().mockReturnValue(MockMarker); +MockMarker.addTo = jest.fn().mockReturnValue(MockMarker); module.exports = { - Map: MockMap, - GeolocateControl: MockGeolocateControl, - Marker: MockMarker, + Map: jest.fn().mockReturnValue(MockMapInstance), + GeolocateControl: jest.fn().mockReturnValue(MockGeolocateInstance), + Marker: jest.fn().mockReturnValue(MockMarker), LngLat, + NavigationControl }; diff --git a/res/css/views/location/_LocationPicker.scss b/res/css/views/location/_LocationPicker.scss index 76e56eedd9..be68eaf5eb 100644 --- a/res/css/views/location/_LocationPicker.scss +++ b/res/css/views/location/_LocationPicker.scss @@ -19,21 +19,25 @@ limitations under the License. height: 100%; position: relative; + overflow: hidden; #mx_LocationPicker_map { height: 100%; border-radius: 8px; + .maplibregl-ctrl.maplibregl-ctrl-group, + .maplibregl-ctrl.maplibregl-ctrl-attrib { + margin-right: $spacing-16; + } + .maplibregl-ctrl.maplibregl-ctrl-group { // place below the close button // padding-16 + 24px close button + padding-10 margin-top: 50px; - margin-right: $spacing-16; } .maplibregl-ctrl-bottom-right { - bottom: 68px; - margin-right: $spacing-16; + bottom: 80px; } .maplibregl-user-location-accuracy-circle { @@ -51,10 +55,9 @@ limitations under the License. background-color: $accent; filter: drop-shadow(0px 3px 5px rgba(0, 0, 0, 0.2)); - .mx_BaseAvatar { - margin-top: 2px; - margin-left: 2px; - } + display: flex; + align-items: center; + justify-content: center; } .mx_MLocationBody_pointer { @@ -83,19 +86,13 @@ limitations under the License. position: absolute; bottom: 0px; width: 100%; + box-sizing: border-box; + padding: $spacing-16; + display: flex; + flex-direction: column; + justify-content: stretch; - .mx_Dialog_buttons { - text-align: center; - - /* Note the `button` prefix and `not()` clauses are needed to make - these selectors more specific than those in _common.scss. */ - - button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton) { - margin: 0px 0px 16px 0px; - min-width: 328px; - min-height: 48px; - } - } + background-color: $header-panel-bg-color; } .mx_LocationPicker_error { @@ -103,3 +100,33 @@ limitations under the License. margin: auto; } } + +.mx_MLocationBody_markerIcon { + color: white; + height: 20px; +} + +.mx_LocationPicker_pinText { + position: absolute; + top: $spacing-16; + width: 100%; + box-sizing: border-box; + text-align: center; + height: 0; + pointer-events: none; + + span { + box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.15); + border-radius: 8px; + padding: $spacing-8; + background-color: $background; + color: $primary-content; + + font-size: $font-12px; + } +} + +.mx_LocationPicker_submitButton { + width: 100%; + height: 48px; +} diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index 150b8355e8..c906c9d148 100644 --- a/src/components/views/location/LocationPicker.tsx +++ b/src/components/views/location/LocationPicker.tsx @@ -15,12 +15,11 @@ limitations under the License. */ import React, { SyntheticEvent } from 'react'; -import maplibregl from 'maplibre-gl'; +import maplibregl, { MapMouseEvent } from 'maplibre-gl'; import { logger } from "matrix-js-sdk/src/logger"; import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/client'; -import DialogButtons from "../elements/DialogButtons"; import { _t } from '../../../languageHandler'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import MemberAvatar from '../avatars/MemberAvatar'; @@ -29,15 +28,26 @@ import Modal from '../../../Modal'; import ErrorDialog from '../dialogs/ErrorDialog'; import { findMapStyleUrl } from '../messages/MLocationBody'; import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; +import { LocationShareType } from './shareLocation'; +import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg'; +import AccessibleButton from '../elements/AccessibleButton'; export interface ILocationPickerProps { sender: RoomMember; + shareType: LocationShareType; onChoose(uri: string, ts: number): unknown; onFinished(ev?: SyntheticEvent): void; } +interface IPosition { + latitude: number; + longitude: number; + altitude?: number; + accuracy?: number; + timestamp: number; +} interface IState { - position?: GeolocationPosition; + position?: IPosition; error: Error; } @@ -88,15 +98,8 @@ class LocationPicker extends React.Component { }, trackUserLocation: true, }); - this.map.addControl(this.geolocate); - this.marker = new maplibregl.Marker({ - element: document.getElementById(this.getMarkerId()), - anchor: 'bottom', - offset: [0, -1], - }) - .setLngLat(new maplibregl.LngLat(0, 0)) - .addTo(this.map); + this.map.addControl(this.geolocate); this.map.on('error', (e) => { logger.error( @@ -112,7 +115,18 @@ class LocationPicker extends React.Component { }); this.geolocate.on('error', this.onGeolocateError); - this.geolocate.on('geolocate', this.onGeolocate); + + if (this.props.shareType === LocationShareType.Own) { + this.geolocate.on('geolocate', this.onGeolocate); + } + + if (this.props.shareType === LocationShareType.Pin) { + const navigationControl = new maplibregl.NavigationControl({ + showCompass: false, showZoom: true, + }); + this.map.addControl(navigationControl, 'bottom-right'); + this.map.on('click', this.onClick); + } } catch (e) { logger.error("Failed to render map", e); this.setState({ error: e }); @@ -122,9 +136,19 @@ class LocationPicker extends React.Component { componentWillUnmount() { this.geolocate?.off('error', this.onGeolocateError); this.geolocate?.off('geolocate', this.onGeolocate); + this.map?.off('click', this.onClick); this.context.off(ClientEvent.ClientWellKnown, this.updateStyleUrl); } + private addMarkerToMap = () => { + this.marker = new maplibregl.Marker({ + element: document.getElementById(this.getMarkerId()), + anchor: 'bottom', + offset: [0, -1], + }).setLngLat(new maplibregl.LngLat(0, 0)) + .addTo(this.map); + }; + private updateStyleUrl = (clientWellKnown: IClientWellKnown) => { const style = tileServerFromWellKnown(clientWellKnown)?.["map_style_url"]; if (style) { @@ -133,7 +157,10 @@ class LocationPicker extends React.Component { }; private onGeolocate = (position: GeolocationPosition) => { - this.setState({ position }); + if (!this.marker) { + this.addMarkerToMap(); + } + this.setState({ position: genericPositionFromGeolocation(position) }); this.marker?.setLngLat( new maplibregl.LngLat( position.coords.longitude, @@ -142,18 +169,40 @@ class LocationPicker extends React.Component { ); }; - private onGeolocateError = (e: GeolocationPositionError) => { - this.props.onFinished(); - logger.error("Could not fetch location", e); - Modal.createTrackedDialog( - 'Could not fetch location', - '', - ErrorDialog, - { - title: _t("Could not fetch location"), - description: positionFailureMessage(e.code), + private onClick = (event: MapMouseEvent) => { + if (!this.marker) { + this.addMarkerToMap(); + } + this.marker?.setLngLat(event.lngLat); + this.setState({ + position: { + timestamp: Date.now(), + latitude: event.lngLat.lat, + longitude: event.lngLat.lng, }, - ); + }); + }; + + private onGeolocateError = (e: GeolocationPositionError) => { + logger.error("Could not fetch location", e); + // close the dialog and show an error when trying to share own location + // pin drop location without permissions is ok + if (this.props.shareType === LocationShareType.Own) { + this.props.onFinished(); + Modal.createTrackedDialog( + 'Could not fetch location', + '', + ErrorDialog, + { + title: _t("Could not fetch location"), + description: positionFailureMessage(e.code), + }, + ); + } + + if (this.geolocate) { + this.map?.removeControl(this.geolocate); + } }; private onOk = () => { @@ -165,33 +214,46 @@ class LocationPicker extends React.Component { render() { const error = this.state.error ? -
+
{ _t("Failed to load map") }
: null; return (
+ { this.props.shareType === LocationShareType.Pin &&
+ + { this.state.position ? _t("Click to move the pin") : _t("Click to drop a pin") } + +
+ } { error }
- + + + { _t('Share location') } +
- + { this.props.shareType === LocationShareType.Own ? + + : + }
{ } } -export function getGeoUri(position: GeolocationPosition): string { - const lat = position.coords.latitude; - const lon = position.coords.longitude; +const genericPositionFromGeolocation = (geoPosition: GeolocationPosition): IPosition => { + const { + latitude, longitude, altitude, accuracy, + } = geoPosition.coords; + return { + timestamp: geoPosition.timestamp, + latitude, longitude, altitude, accuracy, + }; +}; + +export function getGeoUri(position: IPosition): string { + const lat = position.latitude; + const lon = position.longitude; const alt = ( - Number.isFinite(position.coords.altitude) - ? `,${position.coords.altitude}` + Number.isFinite(position.altitude) + ? `,${position.altitude}` : "" ); const acc = ( - Number.isFinite(position.coords.accuracy) - ? `;u=${ position.coords.accuracy }` + Number.isFinite(position.accuracy) + ? `;u=${position.accuracy}` : "" ); return `geo:${lat},${lon}${alt}${acc}`; diff --git a/src/components/views/location/LocationShareMenu.tsx b/src/components/views/location/LocationShareMenu.tsx index ab33251794..40adca86ef 100644 --- a/src/components/views/location/LocationShareMenu.tsx +++ b/src/components/views/location/LocationShareMenu.tsx @@ -23,10 +23,11 @@ import ContextMenu, { AboveLeftOf } from '../../structures/ContextMenu'; import LocationPicker, { ILocationPickerProps } from "./LocationPicker"; import { shareLocation } from './shareLocation'; import SettingsStore from '../../../settings/SettingsStore'; -import ShareType, { LocationShareType } from './ShareType'; import ShareDialogButtons from './ShareDialogButtons'; +import ShareType from './ShareType'; +import { LocationShareType } from './shareLocation'; -type Props = Omit & { +type Props = Omit & { onFinished: (ev?: SyntheticEvent) => void; menuPosition: AboveLeftOf; openMenu: () => void; @@ -70,7 +71,8 @@ const LocationShareMenu: React.FC = ({
{ shareType ? : diff --git a/src/components/views/location/ShareType.tsx b/src/components/views/location/ShareType.tsx index 8e16660dcb..45cf43d24e 100644 --- a/src/components/views/location/ShareType.tsx +++ b/src/components/views/location/ShareType.tsx @@ -25,6 +25,7 @@ import AccessibleButton from '../elements/AccessibleButton'; import Heading from '../typography/Heading'; import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg'; import { Icon as LiveLocationIcon } from '../../../../res/img/location/live-location.svg'; +import { LocationShareType } from './shareLocation'; const UserAvatar = () => { const matrixClient = useContext(MatrixClientContext); @@ -48,12 +49,6 @@ const UserAvatar = () => {
; }; -// TODO this will be defined somewhere better -export enum LocationShareType { - Own = 'Own', - Pin = 'Pin', - Live = 'Live' -} type ShareTypeOptionProps = HTMLAttributes & { label: string, shareType: LocationShareType }; const ShareTypeOption: React.FC = ({ onClick, label, shareType, ...rest @@ -62,7 +57,7 @@ const ShareTypeOption: React.FC = ({ className='mx_ShareType_option' onClick={onClick} // not yet implemented - disabled={shareType !== LocationShareType.Own} + disabled={shareType === LocationShareType.Live} {...rest}> { shareType === LocationShareType.Own && } { shareType === LocationShareType.Pin && diff --git a/src/components/views/location/shareLocation.ts b/src/components/views/location/shareLocation.ts index bd98f2ea8b..4ab8e169d0 100644 --- a/src/components/views/location/shareLocation.ts +++ b/src/components/views/location/shareLocation.ts @@ -19,15 +19,23 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; import { makeLocationContent } from "matrix-js-sdk/src/content-helpers"; import { logger } from "matrix-js-sdk/src/logger"; import { IEventRelation } from "matrix-js-sdk/src/models/event"; +import { LocationAssetType } from "matrix-js-sdk/src/@types/location"; import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; import QuestionDialog from "../dialogs/QuestionDialog"; import SdkConfig from "../../../SdkConfig"; +export enum LocationShareType { + Own = 'Own', + Pin = 'Pin', + Live = 'Live' +} + export const shareLocation = ( client: MatrixClient, roomId: string, + shareType: LocationShareType, relation: IEventRelation | undefined, openMenu: () => void, ) => async (uri: string, ts: number) => { @@ -35,7 +43,8 @@ export const shareLocation = ( try { const text = textForLocation(uri, ts, null); const threadId = relation?.rel_type === RelationType.Thread ? relation.event_id : null; - await client.sendMessage(roomId, threadId, makeLocationContent(text, uri, ts, null)); + const assetType = shareType === LocationShareType.Pin ? LocationAssetType.Pin : LocationAssetType.Self; + await client.sendMessage(roomId, threadId, makeLocationContent(text, uri, ts, null, assetType)); } catch (e) { logger.error("We couldn't send your location", e); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 96b054a83e..c0809c77b1 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2175,6 +2175,8 @@ "toggle event": "toggle event", "Location": "Location", "Could not fetch location": "Could not fetch location", + "Click to move the pin": "Click to move the pin", + "Click to drop a pin": "Click to drop a pin", "Share location": "Share location", "Element was denied permission to fetch your location. Please allow location access in your browser settings.": "Element was denied permission to fetch your location. Please allow location access in your browser settings.", "Failed to fetch your location. Please try again later.": "Failed to fetch your location. Please try again later.", diff --git a/test/components/views/location/LocationPicker-test.tsx b/test/components/views/location/LocationPicker-test.tsx index e28c8cea78..46ca04aeff 100644 --- a/test/components/views/location/LocationPicker-test.tsx +++ b/test/components/views/location/LocationPicker-test.tsx @@ -13,90 +13,305 @@ 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 maplibregl from "maplibre-gl"; +import { mount } from "enzyme"; +import { act } from 'react-dom/test-utils'; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { MatrixClient } from 'matrix-js-sdk/src/client'; +import { mocked } from 'jest-mock'; +import { logger } from 'matrix-js-sdk/src/logger'; import "../../../skinned-sdk"; // Must be first for skinning to work -import { getGeoUri } from "../../../../src/components/views/location/LocationPicker"; +import LocationPicker, { getGeoUri } from "../../../../src/components/views/location/LocationPicker"; +import { LocationShareType } from "../../../../src/components/views/location/shareLocation"; +import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; +import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; +import { findByTestId } from '../../../test-utils'; + +jest.mock('../../../../src/components/views/messages/MLocationBody', () => ({ + findMapStyleUrl: jest.fn().mockReturnValue('tileserver.com'), +})); describe("LocationPicker", () => { describe("getGeoUri", () => { it("Renders a URI with only lat and lon", () => { - const pos: GeolocationPosition = { - coords: { - latitude: 43.2, - longitude: 12.4, - altitude: undefined, - accuracy: undefined, - altitudeAccuracy: undefined, - heading: undefined, - speed: undefined, - }, + const pos = { + latitude: 43.2, + longitude: 12.4, + altitude: undefined, + accuracy: undefined, + timestamp: 12334, }; expect(getGeoUri(pos)).toEqual("geo:43.2,12.4"); }); it("Nulls in location are not shown in URI", () => { - const pos: GeolocationPosition = { - coords: { - latitude: 43.2, - longitude: 12.4, - altitude: null, - accuracy: null, - altitudeAccuracy: null, - heading: null, - speed: null, - }, + const pos = { + latitude: 43.2, + longitude: 12.4, + altitude: null, + accuracy: null, + timestamp: 12334, }; expect(getGeoUri(pos)).toEqual("geo:43.2,12.4"); }); it("Renders a URI with 3 coords", () => { - const pos: GeolocationPosition = { - coords: { - latitude: 43.2, - longitude: 12.4, - altitude: 332.54, - accuracy: undefined, - altitudeAccuracy: undefined, - heading: undefined, - speed: undefined, - }, + const pos = { + latitude: 43.2, + longitude: 12.4, + altitude: 332.54, + accuracy: undefined, timestamp: 12334, }; expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,332.54"); }); it("Renders a URI with accuracy", () => { - const pos: GeolocationPosition = { - coords: { - latitude: 43.2, - longitude: 12.4, - altitude: undefined, - accuracy: 21, - altitudeAccuracy: undefined, - heading: undefined, - speed: undefined, - }, + const pos = { + latitude: 43.2, + longitude: 12.4, + altitude: undefined, + accuracy: 21, timestamp: 12334, }; expect(getGeoUri(pos)).toEqual("geo:43.2,12.4;u=21"); }); it("Renders a URI with accuracy and altitude", () => { - const pos: GeolocationPosition = { - coords: { - latitude: 43.2, - longitude: 12.4, - altitude: 12.3, - accuracy: 21, - altitudeAccuracy: undefined, - heading: undefined, - speed: undefined, - }, + const pos = { + latitude: 43.2, + longitude: 12.4, + altitude: 12.3, + accuracy: 21, timestamp: 12334, }; expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,12.3;u=21"); }); }); + + describe('', () => { + const roomId = '!room:server.org'; + const userId = '@user:server.org'; + const sender = new RoomMember(roomId, userId); + const defaultProps = { + sender, + shareType: LocationShareType.Own, + onChoose: jest.fn(), + onFinished: jest.fn(), + }; + const mockClient = { + on: jest.fn(), + off: jest.fn(), + isGuest: jest.fn(), + getClientWellKnown: jest.fn(), + }; + const getComponent = (props = {}) => mount(, { + wrappingComponent: MatrixClientContext.Provider, + wrappingComponentProps: { value: mockClient }, + }); + + const mockMap = new maplibregl.Map(); + const mockGeolocate = new maplibregl.GeolocateControl(); + const mockMarker = new maplibregl.Marker(); + + const mockGeolocationPosition = { + coords: { + latitude: 43.2, + longitude: 12.4, + altitude: 12.3, + accuracy: 21, + }, + timestamp: 123, + }; + const mockClickEvent = { + lngLat: { + lat: 43.2, + lng: 12.4, + }, + }; + + beforeEach(() => { + jest.spyOn(logger, 'error').mockRestore(); + jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient as unknown as MatrixClient); + jest.clearAllMocks(); + mocked(mockMap).addControl.mockReset(); + }); + + it('displays error when map emits an error', () => { + // suppress expected error log + jest.spyOn(logger, 'error').mockImplementation(() => { }); + const wrapper = getComponent(); + + act(() => { + // @ts-ignore + mocked(mockMap).emit('error', { error: 'Something went wrong' }); + wrapper.setProps({}); + }); + + expect(findByTestId(wrapper, 'location-picker-error').length).toBeTruthy(); + }); + + it('displays error when map setup throws', () => { + // suppress expected error log + jest.spyOn(logger, 'error').mockImplementation(() => { }); + + // throw an error + mocked(mockMap).addControl.mockImplementation(() => { throw new Error('oups'); }); + + const wrapper = getComponent(); + wrapper.setProps({}); + + expect(findByTestId(wrapper, 'location-picker-error').length).toBeTruthy(); + }); + + it('initiates map with geolocation', () => { + getComponent(); + + expect(mockMap.addControl).toHaveBeenCalledWith(mockGeolocate); + act(() => { + // @ts-ignore + mocked(mockMap).emit('load'); + }); + + expect(mockGeolocate.trigger).toHaveBeenCalled(); + }); + + describe('for Own location share type', () => { + it('closes and displays error when geolocation errors', () => { + // suppress expected error log + jest.spyOn(logger, 'error').mockImplementation(() => { }); + const onFinished = jest.fn(); + getComponent({ onFinished }); + + expect(mockMap.addControl).toHaveBeenCalledWith(mockGeolocate); + act(() => { + // @ts-ignore + mockMap.emit('load'); + // @ts-ignore + mockGeolocate.emit('error', {}); + }); + + // dialog is closed on error + expect(onFinished).toHaveBeenCalled(); + }); + + it('sets position on geolocate event', () => { + const wrapper = getComponent(); + act(() => { + // @ts-ignore + mocked(mockGeolocate).emit('geolocate', mockGeolocationPosition); + wrapper.setProps({}); + }); + + // marker added + expect(maplibregl.Marker).toHaveBeenCalled(); + expect(mockMarker.setLngLat).toHaveBeenCalledWith(new maplibregl.LngLat( + 12.4, 43.2, + )); + // submit button is enabled when position is truthy + expect(findByTestId(wrapper, 'location-picker-submit-button').at(0).props().disabled).toBeFalsy(); + expect(wrapper.find('MemberAvatar').length).toBeTruthy(); + }); + + it('submits location', () => { + const onChoose = jest.fn(); + const wrapper = getComponent({ onChoose }); + act(() => { + // @ts-ignore + mocked(mockGeolocate).emit('geolocate', mockGeolocationPosition); + // make sure button is enabled + wrapper.setProps({}); + }); + + act(() => { + findByTestId(wrapper, 'location-picker-submit-button').at(0).simulate('click'); + }); + + // content of this call is tested in LocationShareMenu-test + expect(onChoose).toHaveBeenCalled(); + }); + }); + + describe('for Pin drop location share type', () => { + const shareType = LocationShareType.Pin; + it('initiates map with geolocation', () => { + getComponent({ shareType }); + + expect(mockMap.addControl).toHaveBeenCalledWith(mockGeolocate); + act(() => { + // @ts-ignore + mocked(mockMap).emit('load'); + }); + + expect(mockGeolocate.trigger).toHaveBeenCalled(); + }); + + it('removes geolocation control on geolocation error', () => { + // suppress expected error log + jest.spyOn(logger, 'error').mockImplementation(() => { }); + const onFinished = jest.fn(); + getComponent({ onFinished, shareType }); + act(() => { + // @ts-ignore + mockMap.emit('load'); + // @ts-ignore + mockGeolocate.emit('error', {}); + }); + + expect(mockMap.removeControl).toHaveBeenCalledWith(mockGeolocate); + // dialog is not closed + expect(onFinished).not.toHaveBeenCalled(); + }); + + it('does not set position on geolocate event', () => { + getComponent({ shareType }); + act(() => { + // @ts-ignore + mocked(mockGeolocate).emit('geolocate', mockGeolocationPosition); + }); + + // marker added + expect(maplibregl.Marker).not.toHaveBeenCalled(); + }); + + it('sets position on click event', () => { + const wrapper = getComponent({ shareType }); + act(() => { + // @ts-ignore + mocked(mockMap).emit('click', mockClickEvent); + wrapper.setProps({}); + }); + + // marker added + expect(maplibregl.Marker).toHaveBeenCalled(); + expect(mockMarker.setLngLat).toHaveBeenCalledWith(new maplibregl.LngLat( + 12.4, 43.2, + )); + + // marker is set, icon not avatar + expect(wrapper.find('.mx_MLocationBody_markerIcon').length).toBeTruthy(); + }); + + it('submits location', () => { + const onChoose = jest.fn(); + const wrapper = getComponent({ onChoose, shareType }); + act(() => { + // @ts-ignore + mocked(mockMap).emit('click', mockClickEvent); + wrapper.setProps({}); + }); + + act(() => { + findByTestId(wrapper, 'location-picker-submit-button').at(0).simulate('click'); + }); + + // content of this call is tested in LocationShareMenu-test + expect(onChoose).toHaveBeenCalled(); + }); + }); + }); }); diff --git a/test/components/views/location/LocationShareMenu-test.tsx b/test/components/views/location/LocationShareMenu-test.tsx index 8ffd80bf29..c7a840e33a 100644 --- a/test/components/views/location/LocationShareMenu-test.tsx +++ b/test/components/views/location/LocationShareMenu-test.tsx @@ -20,6 +20,7 @@ import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { mocked } from 'jest-mock'; import { act } from 'react-dom/test-utils'; +import { ASSET_NODE_TYPE, LocationAssetType } from 'matrix-js-sdk/src/@types/location'; import '../../../skinned-sdk'; import LocationShareMenu from '../../../../src/components/views/location/LocationShareMenu'; @@ -27,7 +28,7 @@ import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; import { ChevronFace } from '../../../../src/components/structures/ContextMenu'; import SettingsStore from '../../../../src/settings/SettingsStore'; import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; -import { LocationShareType } from '../../../../src/components/views/location/ShareType'; +import { LocationShareType } from '../../../../src/components/views/location/shareLocation'; import { findByTestId } from '../../../test-utils'; jest.mock('../../../../src/components/views/messages/MLocationBody', () => ({ @@ -58,6 +59,7 @@ describe('', () => { getClientWellKnown: jest.fn().mockResolvedValue({ map_style_url: 'maps.com', }), + sendMessage: jest.fn(), }; const defaultProps = { @@ -70,6 +72,17 @@ describe('', () => { roomId: '!room:server.org', sender: new RoomMember('!room:server.org', userId), }; + + const position = { + coords: { + latitude: -36.24484561954707, + longitude: 175.46884959563613, + accuracy: 10, + }, + timestamp: 1646305006802, + type: 'geolocate', + }; + const getComponent = (props = {}) => mount(, { wrappingComponent: MatrixClientContext.Provider, @@ -81,6 +94,8 @@ describe('', () => { (settingName) => settingName === "feature_location_share_pin_drop", ); + mockClient.sendMessage.mockClear(); + jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient as unknown as MatrixClient); }); @@ -88,6 +103,21 @@ describe('', () => { findByTestId(component, `share-location-option-${shareType}`); const getBackButton = component => findByTestId(component, 'share-dialog-buttons-back'); const getCancelButton = component => findByTestId(component, 'share-dialog-buttons-cancel'); + const getSubmitButton = component => findByTestId(component, 'location-picker-submit-button'); + const setLocation = (component) => { + // set the location + const locationPickerInstance = component.find('LocationPicker').instance(); + act(() => { + // @ts-ignore + locationPickerInstance.onGeolocate(position); + // make sure button gets enabled + component.setProps({}); + }); + }; + const setShareType = (component, shareType) => act(() => { + getShareTypeOption(component, shareType).at(0).simulate('click'); + component.setProps({}); + }); describe('when only Own share type is enabled', () => { beforeEach(() => { @@ -115,6 +145,28 @@ describe('', () => { expect(onFinished).toHaveBeenCalled(); }); + + it('creates static own location share event on submission', () => { + const onFinished = jest.fn(); + const component = getComponent({ onFinished }); + + setLocation(component); + + act(() => { + getSubmitButton(component).at(0).simulate('click'); + component.setProps({}); + }); + + expect(onFinished).toHaveBeenCalled(); + const [messageRoomId, relation, messageBody] = mockClient.sendMessage.mock.calls[0]; + expect(messageRoomId).toEqual(defaultProps.roomId); + expect(relation).toEqual(null); + expect(messageBody).toEqual(expect.objectContaining({ + [ASSET_NODE_TYPE.name]: { + type: LocationAssetType.Self, + }, + })); + }); }); describe('with pin drop share type enabled', () => { @@ -147,11 +199,7 @@ describe('', () => { it('selecting own location share type advances to location picker', () => { const component = getComponent(); - act(() => { - getShareTypeOption(component, LocationShareType.Own).at(0).simulate('click'); - }); - - component.setProps({}); + setShareType(component, LocationShareType.Own); expect(component.find('LocationPicker').length).toBeTruthy(); }); @@ -162,10 +210,7 @@ describe('', () => { const component = getComponent({ onFinished }); // advance to location picker - act(() => { - getShareTypeOption(component, LocationShareType.Own).at(0).simulate('click'); - component.setProps({}); - }); + setShareType(component, LocationShareType.Own); expect(component.find('LocationPicker').length).toBeTruthy(); @@ -177,5 +222,31 @@ describe('', () => { // back to share type expect(component.find('ShareType').length).toBeTruthy(); }); + + it('creates pin drop location share event on submission', () => { + // feature_location_share_pin_drop is set to enabled by default mocking + const onFinished = jest.fn(); + const component = getComponent({ onFinished }); + + // advance to location picker + setShareType(component, LocationShareType.Pin); + + setLocation(component); + + act(() => { + getSubmitButton(component).at(0).simulate('click'); + component.setProps({}); + }); + + expect(onFinished).toHaveBeenCalled(); + const [messageRoomId, relation, messageBody] = mockClient.sendMessage.mock.calls[0]; + expect(messageRoomId).toEqual(defaultProps.roomId); + expect(relation).toEqual(null); + expect(messageBody).toEqual(expect.objectContaining({ + [ASSET_NODE_TYPE.name]: { + type: LocationAssetType.Pin, + }, + })); + }); }); });