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
};
39 changes: 33 additions & 6 deletions res/css/views/location/_LocationPicker.scss
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,19 @@ limitations under the License.
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 +54,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 @@ -103,3 +105,28 @@ 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;
}
}
140 changes: 104 additions & 36 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 @@ -29,15 +29,25 @@ 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';

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,13 +214,19 @@ 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}>
Expand All @@ -186,12 +241,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 +260,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
8 changes: 5 additions & 3 deletions src/components/views/location/LocationShareMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
/>
:
Expand Down
9 changes: 2 additions & 7 deletions src/components/views/location/ShareType.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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 &&
Expand Down
Loading