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 EventEmitter = require("events");
const { LngLat } = require('maplibre-gl'); const { LngLat, NavigationControl } = require('maplibre-gl');
class MockMap extends EventEmitter { class MockMap extends EventEmitter {
addControl = jest.fn(); addControl = jest.fn();
removeControl = jest.fn(); removeControl = jest.fn();
} }
class MockGeolocateControl extends EventEmitter { const MockMapInstance = new MockMap();
class MockGeolocateControl extends EventEmitter {
trigger = jest.fn();
} }
class MockMarker extends EventEmitter { const MockGeolocateInstance = new MockGeolocateControl();
setLngLat = jest.fn().mockReturnValue(this); const MockMarker = {}
addTo = jest.fn(); MockMarker.setLngLat = jest.fn().mockReturnValue(MockMarker);
} MockMarker.addTo = jest.fn().mockReturnValue(MockMarker);
module.exports = { module.exports = {
Map: MockMap, Map: jest.fn().mockReturnValue(MockMapInstance),
GeolocateControl: MockGeolocateControl, GeolocateControl: jest.fn().mockReturnValue(MockGeolocateInstance),
Marker: MockMarker, Marker: jest.fn().mockReturnValue(MockMarker),
LngLat, LngLat,
NavigationControl
}; };

View file

@ -19,21 +19,25 @@ limitations under the License.
height: 100%; height: 100%;
position: relative; position: relative;
overflow: hidden;
#mx_LocationPicker_map { #mx_LocationPicker_map {
height: 100%; height: 100%;
border-radius: 8px; border-radius: 8px;
.maplibregl-ctrl.maplibregl-ctrl-group,
.maplibregl-ctrl.maplibregl-ctrl-attrib {
margin-right: $spacing-16;
}
.maplibregl-ctrl.maplibregl-ctrl-group { .maplibregl-ctrl.maplibregl-ctrl-group {
// place below the close button // place below the close button
// padding-16 + 24px close button + padding-10 // padding-16 + 24px close button + padding-10
margin-top: 50px; margin-top: 50px;
margin-right: $spacing-16;
} }
.maplibregl-ctrl-bottom-right { .maplibregl-ctrl-bottom-right {
bottom: 68px; bottom: 80px;
margin-right: $spacing-16;
} }
.maplibregl-user-location-accuracy-circle { .maplibregl-user-location-accuracy-circle {
@ -51,10 +55,9 @@ limitations under the License.
background-color: $accent; background-color: $accent;
filter: drop-shadow(0px 3px 5px rgba(0, 0, 0, 0.2)); filter: drop-shadow(0px 3px 5px rgba(0, 0, 0, 0.2));
.mx_BaseAvatar { display: flex;
margin-top: 2px; align-items: center;
margin-left: 2px; justify-content: center;
}
} }
.mx_MLocationBody_pointer { .mx_MLocationBody_pointer {
@ -83,19 +86,13 @@ limitations under the License.
position: absolute; position: absolute;
bottom: 0px; bottom: 0px;
width: 100%; width: 100%;
box-sizing: border-box;
padding: $spacing-16;
display: flex;
flex-direction: column;
justify-content: stretch;
.mx_Dialog_buttons { background-color: $header-panel-bg-color;
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;
}
}
} }
.mx_LocationPicker_error { .mx_LocationPicker_error {
@ -103,3 +100,33 @@ limitations under the License.
margin: auto; 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 React, { SyntheticEvent } from 'react';
import maplibregl from 'maplibre-gl'; import maplibregl, { MapMouseEvent } from 'maplibre-gl';
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/client'; import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/client';
import DialogButtons from "../elements/DialogButtons";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import MemberAvatar from '../avatars/MemberAvatar'; import MemberAvatar from '../avatars/MemberAvatar';
@ -29,15 +28,26 @@ import Modal from '../../../Modal';
import ErrorDialog from '../dialogs/ErrorDialog'; import ErrorDialog from '../dialogs/ErrorDialog';
import { findMapStyleUrl } from '../messages/MLocationBody'; import { findMapStyleUrl } from '../messages/MLocationBody';
import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; 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 { export interface ILocationPickerProps {
sender: RoomMember; sender: RoomMember;
shareType: LocationShareType;
onChoose(uri: string, ts: number): unknown; onChoose(uri: string, ts: number): unknown;
onFinished(ev?: SyntheticEvent): void; onFinished(ev?: SyntheticEvent): void;
} }
interface IPosition {
latitude: number;
longitude: number;
altitude?: number;
accuracy?: number;
timestamp: number;
}
interface IState { interface IState {
position?: GeolocationPosition; position?: IPosition;
error: Error; error: Error;
} }
@ -88,15 +98,8 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
}, },
trackUserLocation: true, trackUserLocation: true,
}); });
this.map.addControl(this.geolocate);
this.marker = new maplibregl.Marker({ this.map.addControl(this.geolocate);
element: document.getElementById(this.getMarkerId()),
anchor: 'bottom',
offset: [0, -1],
})
.setLngLat(new maplibregl.LngLat(0, 0))
.addTo(this.map);
this.map.on('error', (e) => { this.map.on('error', (e) => {
logger.error( logger.error(
@ -112,7 +115,18 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
}); });
this.geolocate.on('error', this.onGeolocateError); 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) { } catch (e) {
logger.error("Failed to render map", e); logger.error("Failed to render map", e);
this.setState({ error: e }); this.setState({ error: e });
@ -122,9 +136,19 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
componentWillUnmount() { componentWillUnmount() {
this.geolocate?.off('error', this.onGeolocateError); this.geolocate?.off('error', this.onGeolocateError);
this.geolocate?.off('geolocate', this.onGeolocate); this.geolocate?.off('geolocate', this.onGeolocate);
this.map?.off('click', this.onClick);
this.context.off(ClientEvent.ClientWellKnown, this.updateStyleUrl); 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) => { private updateStyleUrl = (clientWellKnown: IClientWellKnown) => {
const style = tileServerFromWellKnown(clientWellKnown)?.["map_style_url"]; const style = tileServerFromWellKnown(clientWellKnown)?.["map_style_url"];
if (style) { if (style) {
@ -133,7 +157,10 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
}; };
private onGeolocate = (position: GeolocationPosition) => { private onGeolocate = (position: GeolocationPosition) => {
this.setState({ position }); if (!this.marker) {
this.addMarkerToMap();
}
this.setState({ position: genericPositionFromGeolocation(position) });
this.marker?.setLngLat( this.marker?.setLngLat(
new maplibregl.LngLat( new maplibregl.LngLat(
position.coords.longitude, position.coords.longitude,
@ -142,18 +169,40 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
); );
}; };
private onGeolocateError = (e: GeolocationPositionError) => { private onClick = (event: MapMouseEvent) => {
this.props.onFinished(); if (!this.marker) {
logger.error("Could not fetch location", e); this.addMarkerToMap();
Modal.createTrackedDialog( }
'Could not fetch location', this.marker?.setLngLat(event.lngLat);
'', this.setState({
ErrorDialog, position: {
{ timestamp: Date.now(),
title: _t("Could not fetch location"), latitude: event.lngLat.lat,
description: positionFailureMessage(e.code), 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 = () => { private onOk = () => {
@ -165,33 +214,46 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
render() { render() {
const error = this.state.error ? 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") } { _t("Failed to load map") }
</div> : null; </div> : null;
return ( return (
<div className="mx_LocationPicker"> <div className="mx_LocationPicker">
<div id="mx_LocationPicker_map" /> <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 } { error }
<div className="mx_LocationPicker_footer"> <div className="mx_LocationPicker_footer">
<form onSubmit={this.onOk}> <form onSubmit={this.onOk}>
<DialogButtons
primaryButton={_t('Share location')} <AccessibleButton
primaryIsSubmit={true} data-test-id="location-picker-submit-button"
onPrimaryButtonClick={this.onOk} type="submit"
hasCancel={false} element='button'
primaryDisabled={!this.state.position} kind='primary'
/> className='mx_LocationPicker_submitButton'
disabled={!this.state.position}
onClick={this.onOk}>
{ _t('Share location') }
</AccessibleButton>
</form> </form>
</div> </div>
<div className="mx_MLocationBody_marker" id={this.getMarkerId()}> <div className="mx_MLocationBody_marker" id={this.getMarkerId()}>
<div className="mx_MLocationBody_markerBorder"> <div className="mx_MLocationBody_markerBorder">
<MemberAvatar { this.props.shareType === LocationShareType.Own ?
member={this.props.sender} <MemberAvatar
width={27} member={this.props.sender}
height={27} width={27}
viewUserOnClick={false} height={27}
/> viewUserOnClick={false}
/>
: <LocationIcon className="mx_MLocationBody_markerIcon" />
}
</div> </div>
<div <div
className="mx_MLocationBody_pointer" className="mx_MLocationBody_pointer"
@ -202,17 +264,27 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
} }
} }
export function getGeoUri(position: GeolocationPosition): string { const genericPositionFromGeolocation = (geoPosition: GeolocationPosition): IPosition => {
const lat = position.coords.latitude; const {
const lon = position.coords.longitude; 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 = ( const alt = (
Number.isFinite(position.coords.altitude) Number.isFinite(position.altitude)
? `,${position.coords.altitude}` ? `,${position.altitude}`
: "" : ""
); );
const acc = ( const acc = (
Number.isFinite(position.coords.accuracy) Number.isFinite(position.accuracy)
? `;u=${ position.coords.accuracy }` ? `;u=${position.accuracy}`
: "" : ""
); );
return `geo:${lat},${lon}${alt}${acc}`; return `geo:${lat},${lon}${alt}${acc}`;

View file

@ -23,10 +23,11 @@ import ContextMenu, { AboveLeftOf } from '../../structures/ContextMenu';
import LocationPicker, { ILocationPickerProps } from "./LocationPicker"; import LocationPicker, { ILocationPickerProps } from "./LocationPicker";
import { shareLocation } from './shareLocation'; import { shareLocation } from './shareLocation';
import SettingsStore from '../../../settings/SettingsStore'; import SettingsStore from '../../../settings/SettingsStore';
import ShareType, { LocationShareType } from './ShareType';
import ShareDialogButtons from './ShareDialogButtons'; 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; onFinished: (ev?: SyntheticEvent) => void;
menuPosition: AboveLeftOf; menuPosition: AboveLeftOf;
openMenu: () => void; openMenu: () => void;
@ -70,7 +71,8 @@ const LocationShareMenu: React.FC<Props> = ({
<div className="mx_LocationShareMenu"> <div className="mx_LocationShareMenu">
{ shareType ? <LocationPicker { shareType ? <LocationPicker
sender={sender} sender={sender}
onChoose={shareLocation(matrixClient, roomId, relation, openMenu)} shareType={shareType}
onChoose={shareLocation(matrixClient, roomId, shareType, relation, openMenu)}
onFinished={onFinished} onFinished={onFinished}
/> />
: :

View file

@ -25,6 +25,7 @@ import AccessibleButton from '../elements/AccessibleButton';
import Heading from '../typography/Heading'; import Heading from '../typography/Heading';
import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg'; import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg';
import { Icon as LiveLocationIcon } from '../../../../res/img/location/live-location.svg'; import { Icon as LiveLocationIcon } from '../../../../res/img/location/live-location.svg';
import { LocationShareType } from './shareLocation';
const UserAvatar = () => { const UserAvatar = () => {
const matrixClient = useContext(MatrixClientContext); const matrixClient = useContext(MatrixClientContext);
@ -48,12 +49,6 @@ const UserAvatar = () => {
</div>; </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 }; type ShareTypeOptionProps = HTMLAttributes<Element> & { label: string, shareType: LocationShareType };
const ShareTypeOption: React.FC<ShareTypeOptionProps> = ({ const ShareTypeOption: React.FC<ShareTypeOptionProps> = ({
onClick, label, shareType, ...rest onClick, label, shareType, ...rest
@ -62,7 +57,7 @@ const ShareTypeOption: React.FC<ShareTypeOptionProps> = ({
className='mx_ShareType_option' className='mx_ShareType_option'
onClick={onClick} onClick={onClick}
// not yet implemented // not yet implemented
disabled={shareType !== LocationShareType.Own} disabled={shareType === LocationShareType.Live}
{...rest}> {...rest}>
{ shareType === LocationShareType.Own && <UserAvatar /> } { shareType === LocationShareType.Own && <UserAvatar /> }
{ shareType === LocationShareType.Pin && { 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 { makeLocationContent } from "matrix-js-sdk/src/content-helpers";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { IEventRelation } from "matrix-js-sdk/src/models/event"; import { IEventRelation } from "matrix-js-sdk/src/models/event";
import { LocationAssetType } from "matrix-js-sdk/src/@types/location";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import QuestionDialog from "../dialogs/QuestionDialog"; import QuestionDialog from "../dialogs/QuestionDialog";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
export enum LocationShareType {
Own = 'Own',
Pin = 'Pin',
Live = 'Live'
}
export const shareLocation = ( export const shareLocation = (
client: MatrixClient, client: MatrixClient,
roomId: string, roomId: string,
shareType: LocationShareType,
relation: IEventRelation | undefined, relation: IEventRelation | undefined,
openMenu: () => void, openMenu: () => void,
) => async (uri: string, ts: number) => { ) => async (uri: string, ts: number) => {
@ -35,7 +43,8 @@ export const shareLocation = (
try { try {
const text = textForLocation(uri, ts, null); const text = textForLocation(uri, ts, null);
const threadId = relation?.rel_type === RelationType.Thread ? relation.event_id : 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) { } catch (e) {
logger.error("We couldn't send your location", e); logger.error("We couldn't send your location", e);

View file

@ -2175,6 +2175,8 @@
"toggle event": "toggle event", "toggle event": "toggle event",
"Location": "Location", "Location": "Location",
"Could not fetch location": "Could not fetch 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", "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.", "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.", "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 See the License for the specific language governing permissions and
limitations under the License. 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 "../../../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("LocationPicker", () => {
describe("getGeoUri", () => { describe("getGeoUri", () => {
it("Renders a URI with only lat and lon", () => { it("Renders a URI with only lat and lon", () => {
const pos: GeolocationPosition = { const pos = {
coords: { latitude: 43.2,
latitude: 43.2, longitude: 12.4,
longitude: 12.4, altitude: undefined,
altitude: undefined, accuracy: undefined,
accuracy: undefined,
altitudeAccuracy: undefined,
heading: undefined,
speed: undefined,
},
timestamp: 12334, timestamp: 12334,
}; };
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4"); expect(getGeoUri(pos)).toEqual("geo:43.2,12.4");
}); });
it("Nulls in location are not shown in URI", () => { it("Nulls in location are not shown in URI", () => {
const pos: GeolocationPosition = { const pos = {
coords: { latitude: 43.2,
latitude: 43.2, longitude: 12.4,
longitude: 12.4, altitude: null,
altitude: null, accuracy: null,
accuracy: null,
altitudeAccuracy: null,
heading: null,
speed: null,
},
timestamp: 12334, timestamp: 12334,
}; };
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4"); expect(getGeoUri(pos)).toEqual("geo:43.2,12.4");
}); });
it("Renders a URI with 3 coords", () => { it("Renders a URI with 3 coords", () => {
const pos: GeolocationPosition = { const pos = {
coords: { latitude: 43.2,
latitude: 43.2, longitude: 12.4,
longitude: 12.4, altitude: 332.54,
altitude: 332.54, accuracy: undefined,
accuracy: undefined,
altitudeAccuracy: undefined,
heading: undefined,
speed: undefined,
},
timestamp: 12334, timestamp: 12334,
}; };
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,332.54"); expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,332.54");
}); });
it("Renders a URI with accuracy", () => { it("Renders a URI with accuracy", () => {
const pos: GeolocationPosition = { const pos = {
coords: { latitude: 43.2,
latitude: 43.2, longitude: 12.4,
longitude: 12.4, altitude: undefined,
altitude: undefined, accuracy: 21,
accuracy: 21,
altitudeAccuracy: undefined,
heading: undefined,
speed: undefined,
},
timestamp: 12334, timestamp: 12334,
}; };
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4;u=21"); expect(getGeoUri(pos)).toEqual("geo:43.2,12.4;u=21");
}); });
it("Renders a URI with accuracy and altitude", () => { it("Renders a URI with accuracy and altitude", () => {
const pos: GeolocationPosition = { const pos = {
coords: { latitude: 43.2,
latitude: 43.2, longitude: 12.4,
longitude: 12.4, altitude: 12.3,
altitude: 12.3, accuracy: 21,
accuracy: 21,
altitudeAccuracy: undefined,
heading: undefined,
speed: undefined,
},
timestamp: 12334, timestamp: 12334,
}; };
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,12.3;u=21"); 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 { MatrixClient } from 'matrix-js-sdk/src/client';
import { mocked } from 'jest-mock'; import { mocked } from 'jest-mock';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { ASSET_NODE_TYPE, LocationAssetType } from 'matrix-js-sdk/src/@types/location';
import '../../../skinned-sdk'; import '../../../skinned-sdk';
import LocationShareMenu from '../../../../src/components/views/location/LocationShareMenu'; 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 { ChevronFace } from '../../../../src/components/structures/ContextMenu';
import SettingsStore from '../../../../src/settings/SettingsStore'; import SettingsStore from '../../../../src/settings/SettingsStore';
import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; 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'; import { findByTestId } from '../../../test-utils';
jest.mock('../../../../src/components/views/messages/MLocationBody', () => ({ jest.mock('../../../../src/components/views/messages/MLocationBody', () => ({
@ -58,6 +59,7 @@ describe('<LocationShareMenu />', () => {
getClientWellKnown: jest.fn().mockResolvedValue({ getClientWellKnown: jest.fn().mockResolvedValue({
map_style_url: 'maps.com', map_style_url: 'maps.com',
}), }),
sendMessage: jest.fn(),
}; };
const defaultProps = { const defaultProps = {
@ -70,6 +72,17 @@ describe('<LocationShareMenu />', () => {
roomId: '!room:server.org', roomId: '!room:server.org',
sender: new RoomMember('!room:server.org', userId), 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 = {}) => const getComponent = (props = {}) =>
mount(<LocationShareMenu {...defaultProps} {...props} />, { mount(<LocationShareMenu {...defaultProps} {...props} />, {
wrappingComponent: MatrixClientContext.Provider, wrappingComponent: MatrixClientContext.Provider,
@ -81,6 +94,8 @@ describe('<LocationShareMenu />', () => {
(settingName) => settingName === "feature_location_share_pin_drop", (settingName) => settingName === "feature_location_share_pin_drop",
); );
mockClient.sendMessage.mockClear();
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient as unknown as MatrixClient); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient as unknown as MatrixClient);
}); });
@ -88,6 +103,21 @@ describe('<LocationShareMenu />', () => {
findByTestId(component, `share-location-option-${shareType}`); findByTestId(component, `share-location-option-${shareType}`);
const getBackButton = component => findByTestId(component, 'share-dialog-buttons-back'); const getBackButton = component => findByTestId(component, 'share-dialog-buttons-back');
const getCancelButton = component => findByTestId(component, 'share-dialog-buttons-cancel'); 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', () => { describe('when only Own share type is enabled', () => {
beforeEach(() => { beforeEach(() => {
@ -115,6 +145,28 @@ describe('<LocationShareMenu />', () => {
expect(onFinished).toHaveBeenCalled(); 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', () => { describe('with pin drop share type enabled', () => {
@ -147,11 +199,7 @@ describe('<LocationShareMenu />', () => {
it('selecting own location share type advances to location picker', () => { it('selecting own location share type advances to location picker', () => {
const component = getComponent(); const component = getComponent();
act(() => { setShareType(component, LocationShareType.Own);
getShareTypeOption(component, LocationShareType.Own).at(0).simulate('click');
});
component.setProps({});
expect(component.find('LocationPicker').length).toBeTruthy(); expect(component.find('LocationPicker').length).toBeTruthy();
}); });
@ -162,10 +210,7 @@ describe('<LocationShareMenu />', () => {
const component = getComponent({ onFinished }); const component = getComponent({ onFinished });
// advance to location picker // advance to location picker
act(() => { setShareType(component, LocationShareType.Own);
getShareTypeOption(component, LocationShareType.Own).at(0).simulate('click');
component.setProps({});
});
expect(component.find('LocationPicker').length).toBeTruthy(); expect(component.find('LocationPicker').length).toBeTruthy();
@ -177,5 +222,31 @@ describe('<LocationShareMenu />', () => {
// back to share type // back to share type
expect(component.find('ShareType').length).toBeTruthy(); 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,
},
}));
});
}); });
}); });