mirror of
https://github.com/vector-im/element-web.git
synced 2024-11-26 02:18:25 +08:00
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:
parent
288e47fd81
commit
14684c6296
@ -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
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
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<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,18 +169,40 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
|
||||
);
|
||||
};
|
||||
|
||||
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<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">
|
||||
<MemberAvatar
|
||||
member={this.props.sender}
|
||||
width={27}
|
||||
height={27}
|
||||
viewUserOnClick={false}
|
||||
/>
|
||||
{ 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}`;
|
||||
|
@ -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}
|
||||
/>
|
||||
:
|
||||
|
@ -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 &&
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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.",
|
||||
|
@ -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('<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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user