Send pin drop location share events (#7967)

* center icon better

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

* remove debug

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

* retrigger all builds

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

* set assetType on share event

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

* use pin marker on map for pin drop share

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

* lint

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

* test events

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

* pin drop helper text

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

* use generic location type

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

* add navigationcontrol when in pin mode

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

* allow pin drop without location permissions

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

* remove geolocate control when pin dropping without geo perms

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

* test locationpicker

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

* test marker type, tidy

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

* tweak style

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

* lint

Signed-off-by: Kerry Archibald <kerrya@element.io>
This commit is contained in:
Kerry 2022-03-09 18:14:07 +01:00 committed by GitHub
parent 288e47fd81
commit 14684c6296
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 541 additions and 145 deletions

View file

@ -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
};

View file

@ -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;
}

View file

@ -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<ILocationPickerProps, IState> {
},
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<ILocationPickerProps, IState> {
});
this.geolocate.on('error', this.onGeolocateError);
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<ILocationPickerProps, IState> {
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<ILocationPickerProps, IState> {
};
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,9 +169,26 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
);
};
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) => {
this.props.onFinished();
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',
'',
@ -154,6 +198,11 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
description: positionFailureMessage(e.code),
},
);
}
if (this.geolocate) {
this.map?.removeControl(this.geolocate);
}
};
private onOk = () => {
@ -165,33 +214,46 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
render() {
const error = this.state.error ?
<div className="mx_LocationPicker_error">
<div data-test-id='location-picker-error' className="mx_LocationPicker_error">
{ _t("Failed to load map") }
</div> : null;
return (
<div className="mx_LocationPicker">
<div id="mx_LocationPicker_map" />
{ this.props.shareType === LocationShareType.Pin && <div className="mx_LocationPicker_pinText">
<span>
{ this.state.position ? _t("Click to move the pin") : _t("Click to drop a pin") }
</span>
</div>
}
{ error }
<div className="mx_LocationPicker_footer">
<form onSubmit={this.onOk}>
<DialogButtons
primaryButton={_t('Share location')}
primaryIsSubmit={true}
onPrimaryButtonClick={this.onOk}
hasCancel={false}
primaryDisabled={!this.state.position}
/>
<AccessibleButton
data-test-id="location-picker-submit-button"
type="submit"
element='button'
kind='primary'
className='mx_LocationPicker_submitButton'
disabled={!this.state.position}
onClick={this.onOk}>
{ _t('Share location') }
</AccessibleButton>
</form>
</div>
<div className="mx_MLocationBody_marker" id={this.getMarkerId()}>
<div className="mx_MLocationBody_markerBorder">
{ this.props.shareType === LocationShareType.Own ?
<MemberAvatar
member={this.props.sender}
width={27}
height={27}
viewUserOnClick={false}
/>
: <LocationIcon className="mx_MLocationBody_markerIcon" />
}
</div>
<div
className="mx_MLocationBody_pointer"
@ -202,17 +264,27 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
}
}
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}`;

View file

@ -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<ILocationPickerProps, 'onChoose'> & {
type Props = Omit<ILocationPickerProps, 'onChoose' | 'shareType'> & {
onFinished: (ev?: SyntheticEvent) => void;
menuPosition: AboveLeftOf;
openMenu: () => void;
@ -70,7 +71,8 @@ const LocationShareMenu: React.FC<Props> = ({
<div className="mx_LocationShareMenu">
{ shareType ? <LocationPicker
sender={sender}
onChoose={shareLocation(matrixClient, roomId, relation, openMenu)}
shareType={shareType}
onChoose={shareLocation(matrixClient, roomId, shareType, relation, openMenu)}
onFinished={onFinished}
/>
:

View file

@ -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 = () => {
</div>;
};
// TODO this will be defined somewhere better
export enum LocationShareType {
Own = 'Own',
Pin = 'Pin',
Live = 'Live'
}
type ShareTypeOptionProps = HTMLAttributes<Element> & { label: string, shareType: LocationShareType };
const ShareTypeOption: React.FC<ShareTypeOptionProps> = ({
onClick, label, shareType, ...rest
@ -62,7 +57,7 @@ const ShareTypeOption: React.FC<ShareTypeOptionProps> = ({
className='mx_ShareType_option'
onClick={onClick}
// not yet implemented
disabled={shareType !== LocationShareType.Own}
disabled={shareType === LocationShareType.Live}
{...rest}>
{ shareType === LocationShareType.Own && <UserAvatar /> }
{ shareType === LocationShareType.Pin &&

View file

@ -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);

View file

@ -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.",

View file

@ -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: {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: undefined,
accuracy: undefined,
altitudeAccuracy: undefined,
heading: undefined,
speed: 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: {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: null,
accuracy: null,
altitudeAccuracy: null,
heading: null,
speed: null,
},
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4");
});
it("Renders a URI with 3 coords", () => {
const pos: GeolocationPosition = {
coords: {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: 332.54,
accuracy: undefined,
altitudeAccuracy: undefined,
heading: undefined,
speed: undefined,
},
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,332.54");
});
it("Renders a URI with accuracy", () => {
const pos: GeolocationPosition = {
coords: {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: undefined,
accuracy: 21,
altitudeAccuracy: undefined,
heading: undefined,
speed: undefined,
},
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: {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: 12.3,
accuracy: 21,
altitudeAccuracy: undefined,
heading: undefined,
speed: undefined,
},
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,12.3;u=21");
});
});
describe('<LocationPicker />', () => {
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(<LocationPicker {...defaultProps} {...props} />, {
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();
});
});
});
});

View file

@ -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('<LocationShareMenu />', () => {
getClientWellKnown: jest.fn().mockResolvedValue({
map_style_url: 'maps.com',
}),
sendMessage: jest.fn(),
};
const defaultProps = {
@ -70,6 +72,17 @@ describe('<LocationShareMenu />', () => {
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(<LocationShareMenu {...defaultProps} {...props} />, {
wrappingComponent: MatrixClientContext.Provider,
@ -81,6 +94,8 @@ describe('<LocationShareMenu />', () => {
(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('<LocationShareMenu />', () => {
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('<LocationShareMenu />', () => {
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('<LocationShareMenu />', () => {
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('<LocationShareMenu />', () => {
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('<LocationShareMenu />', () => {
// 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,
},
}));
});
});
});