Simple static location sharing (#7135)
Adds maplibre as a dependency, and behind a labs flag, lets users send and receive [MSC3488](https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md) style location shares - with backwards compatibility with old school `m.location` `msgtype` location shares too. For this to work, you have to define a valid maptile server and API in your config.json's `map_style_url`.
This commit is contained in:
parent
eb05044bc4
commit
1262021417
17 changed files with 703 additions and 3 deletions
200
src/components/views/location/LocationPicker.tsx
Normal file
200
src/components/views/location/LocationPicker.tsx
Normal file
|
@ -0,0 +1,200 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
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 SdkConfig from '../../../SdkConfig';
|
||||
import Field from "../elements/Field";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import Dropdown from "../elements/Dropdown";
|
||||
import LocationShareType from "./LocationShareType";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
interface IDropdownProps {
|
||||
value: LocationShareType;
|
||||
label: string;
|
||||
width?: number;
|
||||
onChange(type: LocationShareType): void;
|
||||
}
|
||||
|
||||
const LocationShareTypeDropdown = ({
|
||||
value,
|
||||
label,
|
||||
width,
|
||||
onChange,
|
||||
}: IDropdownProps) => {
|
||||
const options = [
|
||||
// <div key={LocationShareType.Custom}>{ _t("Share custom location") }</div>,
|
||||
<div key={LocationShareType.OnceOff}>{ _t("Share my current location as a once off") }</div>,
|
||||
// <div key={LocationShareType.OneMin}>{ _t("Share my current location for one minute") }</div>,
|
||||
// <div key={LocationShareType.FiveMins}>{ _t("Share my current location for five minutes") }</div>,
|
||||
// <div key={LocationShareType.ThirtyMins}>{ _t("Share my current location for thirty minutes") }</div>,
|
||||
// <div key={LocationShareType.OneHour}>{ _t("Share my current location for one hour") }</div>,
|
||||
// <div key={LocationShareType.ThreeHours}>{ _t("Share my current location for three hours") }</div>,
|
||||
// <div key={LocationShareType.SixHours}>{ _t("Share my current location for six hours") }</div>,
|
||||
// <div key={LocationShareType.OneDay}>{ _t("Share my current location for one day") }</div>,
|
||||
// <div key={LocationShareType.Forever}>{ _t("Share my current location until I disable it") }</div>,
|
||||
];
|
||||
|
||||
return <Dropdown
|
||||
id="mx_LocationShareTypeDropdown"
|
||||
className="mx_LocationShareTypeDropdown"
|
||||
onOptionChange={(key: string)=>{ onChange(LocationShareType[LocationShareType[parseInt(key)]]); }}
|
||||
menuWidth={width}
|
||||
label={label}
|
||||
value={value.toString()}
|
||||
>
|
||||
{ options }
|
||||
</Dropdown>;
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
onChoose(uri: string, ts: number, type: LocationShareType, description: string): boolean;
|
||||
onFinished();
|
||||
}
|
||||
|
||||
interface IState {
|
||||
description: string;
|
||||
type: LocationShareType;
|
||||
position?: GeolocationPosition;
|
||||
manual: boolean;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.location.LocationPicker")
|
||||
class LocationPicker extends React.Component<IProps, IState> {
|
||||
private map: maplibregl.Map;
|
||||
private geolocate: maplibregl.GeolocateControl;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
description: _t("My location"),
|
||||
type: LocationShareType.OnceOff,
|
||||
position: undefined,
|
||||
manual: false,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const config = SdkConfig.get();
|
||||
this.map = new maplibregl.Map({
|
||||
container: 'mx_LocationPicker_map',
|
||||
style: config.map_style_url,
|
||||
center: [0, 0],
|
||||
zoom: 1,
|
||||
});
|
||||
|
||||
// Add geolocate control to the map.
|
||||
this.geolocate = new maplibregl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
trackUserLocation: true,
|
||||
});
|
||||
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", e.error);
|
||||
this.setState({ error: e.error });
|
||||
});
|
||||
|
||||
this.map.on('load', ()=>{
|
||||
this.geolocate.trigger();
|
||||
});
|
||||
|
||||
this.geolocate.on('geolocate', this.onGeolocate);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.geolocate.off('geolocate', this.onGeolocate);
|
||||
}
|
||||
|
||||
private onGeolocate = (position) => {
|
||||
this.setState({ position });
|
||||
};
|
||||
|
||||
private onDescriptionChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ description: ev.target.value });
|
||||
};
|
||||
|
||||
private getGeoUri = (position) => {
|
||||
return (`geo:${ position.coords.latitude },` +
|
||||
position.coords.longitude +
|
||||
( position.coords.altitude != null ?
|
||||
`,${ position.coords.altitude }` : '' ) +
|
||||
`;u=${ position.coords.accuracy }`);
|
||||
};
|
||||
|
||||
private onOk = () => {
|
||||
this.props.onChoose(
|
||||
this.state.position ? this.getGeoUri(this.state.position) : undefined,
|
||||
this.state.position ? this.state.position.timestamp : undefined,
|
||||
this.state.type,
|
||||
this.state.description,
|
||||
);
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
private onTypeChange= (type: LocationShareType) => {
|
||||
this.setState({ type });
|
||||
};
|
||||
|
||||
render() {
|
||||
const error = this.state.error ?
|
||||
<div className="mx_LocationPicker_error">
|
||||
{ _t("Failed to load map") }
|
||||
</div> : null;
|
||||
|
||||
return (
|
||||
<div className="mx_LocationPicker">
|
||||
<div id="mx_LocationPicker_map" />
|
||||
{ error }
|
||||
<div className="mx_LocationPicker_footer">
|
||||
<form onSubmit={this.onOk}>
|
||||
<LocationShareTypeDropdown
|
||||
value={this.state.type}
|
||||
label={_t("Type of location share")}
|
||||
onChange={this.onTypeChange}
|
||||
width={400}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={_t('Description')}
|
||||
onChange={this.onDescriptionChange}
|
||||
value={this.state.description}
|
||||
width={400}
|
||||
className="mx_LocationPicker_description"
|
||||
/>
|
||||
|
||||
<DialogButtons primaryButton={_t('Share')}
|
||||
onPrimaryButtonClick={this.onOk}
|
||||
onCancel={this.props.onFinished}
|
||||
disabled={!this.state.position} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LocationPicker;
|
30
src/components/views/location/LocationShareType.tsx
Normal file
30
src/components/views/location/LocationShareType.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
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.
|
||||
*/
|
||||
|
||||
enum LocationShareType {
|
||||
Custom = -1,
|
||||
OnceOff = 0,
|
||||
OneMine = 60,
|
||||
FiveMins = 5 * 60,
|
||||
ThirtyMins = 30 * 60,
|
||||
OneHour = 60 * 60,
|
||||
ThreeHours = 3 * 60 * 60,
|
||||
SixHours = 6 * 60 * 60,
|
||||
OneDay = 24 * 60 * 60,
|
||||
Forever = Number.MAX_SAFE_INTEGER,
|
||||
}
|
||||
|
||||
export default LocationShareType;
|
112
src/components/views/messages/MLocationBody.tsx
Normal file
112
src/components/views/messages/MLocationBody.tsx
Normal file
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
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 SdkConfig from '../../../SdkConfig';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
interface IState {
|
||||
error: Error;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MLocationBody")
|
||||
export default class MLocationBody extends React.Component<IBodyProps, IState> {
|
||||
private map: maplibregl.Map;
|
||||
private coords: GeolocationCoordinates;
|
||||
private description: string;
|
||||
|
||||
constructor(props: IBodyProps) {
|
||||
super(props);
|
||||
|
||||
// unfortunately we're stuck supporting legacy `content.geo_uri`
|
||||
// events until the end of days, or until we figure out mutable
|
||||
// events - so folks can read their old chat history correctly.
|
||||
// https://github.com/matrix-org/matrix-doc/issues/3516
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const uri = content['org.matrix.msc3488.location'] ?
|
||||
content['org.matrix.msc3488.location'].uri :
|
||||
content['geo_uri'];
|
||||
|
||||
this.coords = this.parseGeoUri(uri);
|
||||
this.state = {
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
this.description =
|
||||
content['org.matrix.msc3488.location']?.description ?? content['body'];
|
||||
}
|
||||
|
||||
private parseGeoUri = (uri: string): GeolocationCoordinates => {
|
||||
const m = uri.match(/^\s*geo:(.*?)\s*$/);
|
||||
if (!m) return;
|
||||
const parts = m[1].split(';');
|
||||
const coords = parts[0].split(',');
|
||||
let uncertainty: number;
|
||||
for (const param of parts.slice(1)) {
|
||||
const m = param.match(/u=(.*)/);
|
||||
if (m) uncertainty = parseFloat(m[1]);
|
||||
}
|
||||
return {
|
||||
latitude: parseFloat(coords[0]),
|
||||
longitude: parseFloat(coords[1]),
|
||||
altitude: parseFloat(coords[2]),
|
||||
accuracy: uncertainty,
|
||||
altitudeAccuracy: undefined,
|
||||
heading: undefined,
|
||||
speed: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const config = SdkConfig.get();
|
||||
this.map = new maplibregl.Map({
|
||||
container: this.getBodyId(),
|
||||
style: config.map_style_url,
|
||||
center: [this.coords.longitude, this.coords.latitude],
|
||||
zoom: 13,
|
||||
});
|
||||
|
||||
new maplibregl.Marker()
|
||||
.setLngLat([this.coords.longitude, this.coords.latitude])
|
||||
.addTo(this.map);
|
||||
|
||||
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", e.error);
|
||||
this.setState({ error: e.error });
|
||||
});
|
||||
}
|
||||
|
||||
private getBodyId = () => {
|
||||
return `mx_MLocationBody_${this.props.mxEvent.getId()}`;
|
||||
};
|
||||
|
||||
render() {
|
||||
const error = this.state.error ?
|
||||
<div className="mx_EventTile_tileError mx_EventTile_body">
|
||||
{ _t("Failed to load map") }
|
||||
</div> : null;
|
||||
|
||||
return <div className="mx_MLocationBody">
|
||||
<div id={this.getBodyId()} className="mx_MLocationBody_map" />
|
||||
{ error }
|
||||
<span className="mx_EventTile_body">{ this.description }</span>
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -125,6 +125,14 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
|||
BodyType = sdk.getComponent('messages.MPollBody');
|
||||
}
|
||||
}
|
||||
|
||||
if ((type && type === "org.matrix.msc3488.location") ||
|
||||
(type && type === EventType.RoomMessage && msgtype && msgtype === MsgType.Location)) {
|
||||
// TODO: tidy this up once location sharing is out of labs
|
||||
if (SettingsStore.getValue("feature_location_share")) {
|
||||
BodyType = sdk.getComponent('messages.MLocationBody');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("feature_mjolnir")) {
|
||||
|
|
|
@ -48,6 +48,7 @@ import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInse
|
|||
import { Action } from "../../../dispatcher/actions";
|
||||
import EditorModel from "../../../editor/model";
|
||||
import EmojiPicker from '../emojipicker/EmojiPicker';
|
||||
import LocationPicker from '../location/LocationPicker';
|
||||
import UIStore, { UI_EVENTS } from '../../../stores/UIStore';
|
||||
import Modal from "../../../Modal";
|
||||
import { RelationType } from 'matrix-js-sdk/src/@types/event';
|
||||
|
@ -55,6 +56,9 @@ import RoomContext from '../../../contexts/RoomContext';
|
|||
import { POLL_START_EVENT_TYPE } from "../../../polls/consts";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import PollCreateDialog from "../elements/PollCreateDialog";
|
||||
import { MsgType } from "matrix-js-sdk/src/@types/event";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import LocationShareType from "../location/LocationShareType";
|
||||
import { SettingUpdatedPayload } from "../../../dispatcher/payloads/SettingUpdatedPayload";
|
||||
|
||||
let instanceCount = 0;
|
||||
|
@ -77,7 +81,7 @@ function SendButton(props: ISendButtonProps) {
|
|||
|
||||
interface IEmojiButtonProps {
|
||||
addEmoji: (unicode: string) => boolean;
|
||||
menuPosition: any; // TODO: Types
|
||||
menuPosition: AboveLeftOf;
|
||||
narrowMode: boolean;
|
||||
}
|
||||
|
||||
|
@ -114,6 +118,46 @@ const EmojiButton: React.FC<IEmojiButtonProps> = ({ addEmoji, menuPosition, narr
|
|||
</React.Fragment>;
|
||||
};
|
||||
|
||||
interface ILocationButtonProps {
|
||||
room: Room;
|
||||
shareLocation: (uri: string, ts: number, type: LocationShareType, description: string) => boolean;
|
||||
menuPosition: AboveLeftOf;
|
||||
narrowMode: boolean;
|
||||
}
|
||||
|
||||
const LocationButton: React.FC<ILocationButtonProps> = ({ shareLocation, menuPosition, narrowMode }) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect());
|
||||
contextMenu = <ContextMenu {...position} onFinished={closeMenu} managed={false}>
|
||||
<LocationPicker onChoose={shareLocation} onFinished={closeMenu} />
|
||||
</ContextMenu>;
|
||||
}
|
||||
|
||||
const className = classNames(
|
||||
"mx_MessageComposer_button",
|
||||
"mx_MessageComposer_location",
|
||||
{
|
||||
"mx_MessageComposer_button_highlight": menuDisplayed,
|
||||
},
|
||||
);
|
||||
|
||||
// TODO: replace ContextMenuTooltipButton with a unified representation of
|
||||
// the header buttons and the right panel buttons
|
||||
return <React.Fragment>
|
||||
<AccessibleTooltipButton
|
||||
className={className}
|
||||
onClick={openMenu}
|
||||
title={!narrowMode && _t('Share location')}
|
||||
label={narrowMode ? _t('Share location') : null}
|
||||
/>
|
||||
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
interface IUploadButtonProps {
|
||||
roomId: string;
|
||||
relation?: IEventRelation | null;
|
||||
|
@ -447,6 +491,25 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
return true;
|
||||
};
|
||||
|
||||
private shareLocation = (uri: string, ts: number, type: LocationShareType, description: string): boolean => {
|
||||
if (!uri) return false;
|
||||
try {
|
||||
const text = `${description ? description : 'Location'} at ${uri} as of ${new Date(ts).toISOString()}`;
|
||||
// noinspection ES6MissingAwait - we don't care if it fails, it'll get queued.
|
||||
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
|
||||
"body": text,
|
||||
"msgtype": MsgType.Location,
|
||||
"geo_uri": uri,
|
||||
"org.matrix.msc3488.location": { uri, description },
|
||||
"org.matrix.msc3488.ts": ts,
|
||||
// TODO: MSC1767 fallbacks for text & thumbnail
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Error sending location:", e);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
private sendMessage = async () => {
|
||||
if (this.state.haveRecording && this.voiceRecordingButton.current) {
|
||||
// There shouldn't be any text message to send when a voice recording is active, so
|
||||
|
@ -514,6 +577,17 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
relation={this.props.relation}
|
||||
/>,
|
||||
);
|
||||
if (SettingsStore.getValue("feature_location_share")) {
|
||||
buttons.push(
|
||||
<LocationButton
|
||||
key="location"
|
||||
room={this.props.room}
|
||||
shareLocation={this.shareLocation}
|
||||
menuPosition={menuPosition}
|
||||
narrowMode={this.state.narrowMode}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
buttons.push(
|
||||
<EmojiButton key="emoji_button" addEmoji={this.addEmoji} menuPosition={menuPosition} narrowMode={this.state.narrowMode} />,
|
||||
);
|
||||
|
|
|
@ -854,6 +854,7 @@
|
|||
"Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms",
|
||||
"Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices",
|
||||
"Polls (under active development)": "Polls (under active development)",
|
||||
"Location sharing (under active development)": "Location sharing (under active development)",
|
||||
"Show info about bridges in room settings": "Show info about bridges in room settings",
|
||||
"New layout switcher (with message bubbles)": "New layout switcher (with message bubbles)",
|
||||
"Meta Spaces": "Meta Spaces",
|
||||
|
@ -1649,6 +1650,7 @@
|
|||
"Send message": "Send message",
|
||||
"Emoji picker": "Emoji picker",
|
||||
"Add emoji": "Add emoji",
|
||||
"Share location": "Share location",
|
||||
"Upload file": "Upload file",
|
||||
"You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.",
|
||||
"Create poll": "Create poll",
|
||||
|
@ -2070,6 +2072,7 @@
|
|||
"Declining …": "Declining …",
|
||||
"%(name)s wants to verify": "%(name)s wants to verify",
|
||||
"You sent a verification request": "You sent a verification request",
|
||||
"Failed to load map": "Failed to load map",
|
||||
"Vote not registered": "Vote not registered",
|
||||
"Sorry, your vote was not registered. Please try again.": "Sorry, your vote was not registered. Please try again.",
|
||||
"No votes cast": "No votes cast",
|
||||
|
@ -2100,6 +2103,9 @@
|
|||
"edited": "edited",
|
||||
"Submit logs": "Submit logs",
|
||||
"Can't load this message": "Can't load this message",
|
||||
"Share my current location as a once off": "Share my current location as a once off",
|
||||
"My location": "My location",
|
||||
"Type of location share": "Type of location share",
|
||||
"Failed to load group members": "Failed to load group members",
|
||||
"Filter community members": "Filter community members",
|
||||
"Are you sure you want to remove '%(roomName)s' from %(groupId)s?": "Are you sure you want to remove '%(roomName)s' from %(groupId)s?",
|
||||
|
|
|
@ -307,6 +307,13 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
|||
displayName: _td("Polls (under active development)"),
|
||||
default: false,
|
||||
},
|
||||
"feature_location_share": {
|
||||
isFeature: true,
|
||||
labsGroup: LabGroup.Messaging,
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
displayName: _td("Location sharing (under active development)"),
|
||||
default: false,
|
||||
},
|
||||
"doNotDisturb": {
|
||||
supportedLevels: [SettingLevel.DEVICE],
|
||||
default: false,
|
||||
|
|
|
@ -54,6 +54,8 @@ export default abstract class SettingController {
|
|||
*/
|
||||
public onChange(level: SettingLevel, roomId: string, newValue: any) {
|
||||
// do nothing by default
|
||||
|
||||
// FIXME: force a fresh on the RoomView for the roomId in question
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue