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

Send pin drop location share events #7967

Merged
merged 17 commits into from
Mar 9, 2022
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
};
65 changes: 46 additions & 19 deletions res/css/views/location/_LocationPicker.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -83,23 +86,47 @@ 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 {
color: red;
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;
}
160 changes: 116 additions & 44 deletions src/components/views/location/LocationPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
}

Expand Down Expand Up @@ -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(
Expand All @@ -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 });
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -142,18 +169,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 @@ -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"
Expand All @@ -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}`;
Expand Down
Loading