Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

fallback to event text in location body when map unavailable #7982

Merged
merged 27 commits into from
Mar 11, 2022
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions __mocks__/maplibre-gl.js
Original file line number Diff line number Diff line change
@@ -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
};
59 changes: 50 additions & 9 deletions res/css/views/location/_LocationPicker.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,33 @@ limitations under the License.
height: 100%;
position: relative;

// when there are errors loading the map
// the canvas is still inserted
// and can overlap error message/close buttons
// hide it
&.mx_LocationPicker_hasError {
.maplibregl-canvas-container, .maplibregl-control-container {
display: none;
}
}

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

.maplibregl-user-location-accuracy-circle {
Expand All @@ -51,10 +64,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 {
Expand Down Expand Up @@ -97,9 +109,38 @@ limitations under the License.
}
}
}
}

.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_error {
padding: 60px;
text-align: center;

.mx_LocationPicker_error {
color: red;
margin: auto;
p {
margin: $spacing-24 0;
}
}
171 changes: 125 additions & 46 deletions src/components/views/location/LocationPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ 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';
Expand All @@ -27,18 +27,30 @@ import MemberAvatar from '../avatars/MemberAvatar';
import MatrixClientContext from '../../../contexts/MatrixClientContext';
import Modal from '../../../Modal';
import ErrorDialog from '../dialogs/ErrorDialog';
import { findMapStyleUrl } from '../messages/MLocationBody';
import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils';

import { findMapStyleUrl } from './findMapStyleUrl';
import { LocationShareType } from './shareLocation';
import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg';
import { getLocationShareErrorMessage, LocationShareError } from './LocationShareErrors';
import { Icon as WarningBadge } from '../../../../res/img/element-icons/warning-badge.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;
error: Error;
position?: IPosition;
error?: LocationShareError;
}

/*
Expand Down Expand Up @@ -88,43 +100,60 @@ 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(
"Failed to load map: check map_style_url in config.json "
+ "has a valid URL and API key",
+ "has a valid URL and API key",
e.error,
);
this.setState({ error: e.error });
this.setState({ error: LocationShareError.MapStyleUrlNotReachable });
});

this.map.on('load', () => {
this.geolocate.trigger();
});

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 });
const errorType = e?.message === LocationShareError.MapStyleUrlNotConfigured ?
LocationShareError.MapStyleUrlNotConfigured :
LocationShareError.Default;
this.setState({ error: errorType });
}
}

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) {
Expand All @@ -133,7 +162,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,
Expand All @@ -142,18 +174,40 @@ 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);
Modal.createTrackedDialog(
'Could not fetch location',
'',
ErrorDialog,
{
title: _t("Could not fetch location"),
description: positionFailureMessage(e.code),
},
);
// 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 = () => {
Expand All @@ -164,15 +218,27 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
};

render() {
const error = this.state.error ?
<div className="mx_LocationPicker_error">
{ _t("Failed to load map") }
</div> : null;
if (this.state.error) {
return <div className="mx_LocationPicker mx_LocationPicker_hasError">
<div data-test-id='location-picker-error' className="mx_LocationPicker_error">
<WarningBadge height={36} />
<p>
{ getLocationShareErrorMessage(this.state.error) }
</p>
<AccessibleButton kind="primary_outline" onClick={this.props.onFinished}>{ _t("Cancel") }</AccessibleButton>
</div>
</div>;
}

return (
<div className="mx_LocationPicker">
<div id="mx_LocationPicker_map" />
{ error }
{ 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>
}
<div className="mx_LocationPicker_footer">
<form onSubmit={this.onOk}>
<DialogButtons
Expand All @@ -186,12 +252,15 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
</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"
Expand All @@ -202,17 +271,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}`;
Expand Down
Loading