Merge pull request #10083 from matrix-org/johannes/find-myself
Add option to find own location in map views
This commit is contained in:
commit
3eee91d4ed
13 changed files with 263 additions and 37 deletions
|
@ -125,6 +125,9 @@ const BeaconViewDialog: React.FC<IProps> = ({ initialFocusedBeacon, roomId, matr
|
|||
setFocusedBeaconState({ beacon, ts: Date.now() });
|
||||
};
|
||||
|
||||
const hasOwnBeacon =
|
||||
liveBeacons.filter((beacon) => beacon?.beaconInfoOwner === matrixClient.getUserId()).length > 0;
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_BeaconViewDialog" onFinished={onFinished} fixedWidth={false}>
|
||||
<MatrixClientContext.Provider value={matrixClient}>
|
||||
|
@ -136,6 +139,7 @@ const BeaconViewDialog: React.FC<IProps> = ({ initialFocusedBeacon, roomId, matr
|
|||
interactive
|
||||
onError={setMapDisplayError}
|
||||
className="mx_BeaconViewDialog_map"
|
||||
allowGeolocate={!hasOwnBeacon}
|
||||
>
|
||||
{({ map }: { map: maplibregl.Map }) => (
|
||||
<>
|
||||
|
|
|
@ -23,10 +23,9 @@ import { ClientEvent, IClientWellKnown } from "matrix-js-sdk/src/client";
|
|||
import { _t } from "../../../languageHandler";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import Modal from "../../../Modal";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { tileServerFromWellKnown } from "../../../utils/WellKnownUtils";
|
||||
import { GenericPosition, genericPositionFromGeolocation, getGeoUri } from "../../../utils/beacon";
|
||||
import { LocationShareError, findMapStyleUrl } from "../../../utils/location";
|
||||
import { LocationShareError, findMapStyleUrl, positionFailureMessage } from "../../../utils/location";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { MapError } from "./MapError";
|
||||
|
@ -266,21 +265,3 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
|
|||
}
|
||||
|
||||
export default LocationPicker;
|
||||
|
||||
function positionFailureMessage(code: number): string {
|
||||
const brand = SdkConfig.get().brand;
|
||||
switch (code) {
|
||||
case 1:
|
||||
return _t(
|
||||
"%(brand)s was denied permission to fetch your location. " +
|
||||
"Please allow location access in your browser settings.",
|
||||
{ brand },
|
||||
);
|
||||
case 2:
|
||||
return _t("Failed to fetch your location. Please try again later.");
|
||||
case 3:
|
||||
return _t("Timed out trying to fetch your location. Please try again later.");
|
||||
case 4:
|
||||
return _t("Unknown error fetching location. Please try again later.");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -68,6 +68,7 @@ export default class LocationViewDialog extends React.Component<IProps, IState>
|
|||
onError={this.onError}
|
||||
interactive
|
||||
className="mx_LocationViewDialog_map"
|
||||
allowGeolocate
|
||||
>
|
||||
{({ map }) => (
|
||||
<>
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactNode, useContext, useEffect } from "react";
|
||||
import React, { ReactNode, useContext, useEffect, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import * as maplibregl from "maplibre-gl";
|
||||
import { ClientEvent, IClientWellKnown } from "matrix-js-sdk/src/matrix";
|
||||
|
@ -22,10 +22,13 @@ import { logger } from "matrix-js-sdk/src/logger";
|
|||
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import { parseGeoUri } from "../../../utils/location";
|
||||
import { parseGeoUri, positionFailureMessage } from "../../../utils/location";
|
||||
import { tileServerFromWellKnown } from "../../../utils/WellKnownUtils";
|
||||
import { useMap } from "../../../utils/location/useMap";
|
||||
import { Bounds } from "../../../utils/beacon/bounds";
|
||||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
||||
const useMapWithStyle = ({
|
||||
id,
|
||||
|
@ -33,14 +36,16 @@ const useMapWithStyle = ({
|
|||
onError,
|
||||
interactive,
|
||||
bounds,
|
||||
allowGeolocate,
|
||||
}: {
|
||||
id: string;
|
||||
centerGeoUri?: string;
|
||||
onError?(error: Error): void;
|
||||
interactive?: boolean;
|
||||
bounds?: Bounds;
|
||||
onError(error: Error): void;
|
||||
allowGeolocate?: boolean;
|
||||
}): {
|
||||
map: maplibregl.Map;
|
||||
map: maplibregl.Map | undefined;
|
||||
bodyId: string;
|
||||
} => {
|
||||
const bodyId = `mx_Map_${id}`;
|
||||
|
@ -86,12 +91,51 @@ const useMapWithStyle = ({
|
|||
}
|
||||
}, [map, bounds]);
|
||||
|
||||
const [geolocate, setGeolocate] = useState<maplibregl.GeolocateControl | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
if (allowGeolocate && !geolocate) {
|
||||
const geolocate = new maplibregl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
trackUserLocation: false,
|
||||
});
|
||||
setGeolocate(geolocate);
|
||||
map.addControl(geolocate);
|
||||
}
|
||||
if (!allowGeolocate && geolocate) {
|
||||
map.removeControl(geolocate);
|
||||
setGeolocate(null);
|
||||
}
|
||||
}, [map, geolocate, allowGeolocate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (geolocate) {
|
||||
geolocate.on("error", onGeolocateError);
|
||||
return () => {
|
||||
geolocate.off("error", onGeolocateError);
|
||||
};
|
||||
}
|
||||
}, [geolocate]);
|
||||
|
||||
return {
|
||||
map,
|
||||
bodyId,
|
||||
};
|
||||
};
|
||||
|
||||
const onGeolocateError = (e: GeolocationPositionError): void => {
|
||||
logger.error("Could not fetch location", e);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("Could not fetch location"),
|
||||
description: positionFailureMessage(e.code) ?? "",
|
||||
});
|
||||
};
|
||||
|
||||
interface MapProps {
|
||||
id: string;
|
||||
interactive?: boolean;
|
||||
|
@ -105,13 +149,24 @@ interface MapProps {
|
|||
centerGeoUri?: string;
|
||||
bounds?: Bounds;
|
||||
className?: string;
|
||||
allowGeolocate?: boolean;
|
||||
onClick?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
children?: (renderProps: { map: maplibregl.Map }) => ReactNode;
|
||||
}
|
||||
|
||||
const Map: React.FC<MapProps> = ({ bounds, centerGeoUri, children, className, id, interactive, onError, onClick }) => {
|
||||
const { map, bodyId } = useMapWithStyle({ centerGeoUri, onError, id, interactive, bounds });
|
||||
const Map: React.FC<MapProps> = ({
|
||||
bounds,
|
||||
centerGeoUri,
|
||||
children,
|
||||
className,
|
||||
allowGeolocate,
|
||||
id,
|
||||
interactive,
|
||||
onError,
|
||||
onClick,
|
||||
}) => {
|
||||
const { map, bodyId } = useMapWithStyle({ centerGeoUri, onError, id, interactive, bounds, allowGeolocate });
|
||||
|
||||
const onMapClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>): void => {
|
||||
// Eat click events when clicking the attribution button
|
||||
|
|
|
@ -787,6 +787,10 @@
|
|||
"Reset bearing to north": "Reset bearing to north",
|
||||
"Zoom in": "Zoom in",
|
||||
"Zoom out": "Zoom out",
|
||||
"%(brand)s was denied permission to fetch your location. Please allow location access in your browser settings.": "%(brand)s 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.",
|
||||
"Timed out trying to fetch your location. Please try again later.": "Timed out trying to fetch your location. Please try again later.",
|
||||
"Unknown error fetching location. Please try again later.": "Unknown error fetching location. Please try again later.",
|
||||
"Are you sure you want to exit during this export?": "Are you sure you want to exit during this export?",
|
||||
"Unnamed Room": "Unnamed Room",
|
||||
"Generating a ZIP": "Generating a ZIP",
|
||||
|
@ -2447,10 +2451,6 @@
|
|||
"Click to move the pin": "Click to move the pin",
|
||||
"Click to drop a pin": "Click to drop a pin",
|
||||
"Share location": "Share location",
|
||||
"%(brand)s was denied permission to fetch your location. Please allow location access in your browser settings.": "%(brand)s 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.",
|
||||
"Timed out trying to fetch your location. Please try again later.": "Timed out trying to fetch your location. Please try again later.",
|
||||
"Unknown error fetching location. Please try again later.": "Unknown error fetching location. Please try again later.",
|
||||
"You don't have permission to share locations": "You don't have permission to share locations",
|
||||
"You need to have the right permissions in order to share locations in this room.": "You need to have the right permissions in order to share locations in this room.",
|
||||
"We couldn't send your location": "We couldn't send your location",
|
||||
|
|
|
@ -20,3 +20,4 @@ export * from "./locationEventGeoUri";
|
|||
export * from "./LocationShareErrors";
|
||||
export * from "./map";
|
||||
export * from "./parseGeoUri";
|
||||
export * from "./positionFailureMessage";
|
||||
|
|
|
@ -24,7 +24,7 @@ import { parseGeoUri } from "./parseGeoUri";
|
|||
import { findMapStyleUrl } from "./findMapStyleUrl";
|
||||
import { LocationShareError } from "./LocationShareErrors";
|
||||
|
||||
export const createMap = (interactive: boolean, bodyId: string, onError: (error: Error) => void): maplibregl.Map => {
|
||||
export const createMap = (interactive: boolean, bodyId: string, onError?: (error: Error) => void): maplibregl.Map => {
|
||||
try {
|
||||
const styleUrl = findMapStyleUrl();
|
||||
|
||||
|
@ -54,7 +54,7 @@ export const createMap = (interactive: boolean, bodyId: string, onError: (error:
|
|||
"Failed to load map: check map_style_url in config.json has a " + "valid URL and API key",
|
||||
e.error,
|
||||
);
|
||||
onError(new Error(LocationShareError.MapStyleUrlNotReachable));
|
||||
onError?.(new Error(LocationShareError.MapStyleUrlNotReachable));
|
||||
});
|
||||
|
||||
return map;
|
||||
|
|
41
src/utils/location/positionFailureMessage.ts
Normal file
41
src/utils/location/positionFailureMessage.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
Copyright 2023 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 { _t } from "../../languageHandler";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
|
||||
/**
|
||||
* Get a localised error message for GeolocationPositionError error codes
|
||||
* @param code - error code from GeolocationPositionError
|
||||
* @returns
|
||||
*/
|
||||
export const positionFailureMessage = (code: number): string | undefined => {
|
||||
const brand = SdkConfig.get().brand;
|
||||
switch (code) {
|
||||
case 1:
|
||||
return _t(
|
||||
"%(brand)s was denied permission to fetch your location. " +
|
||||
"Please allow location access in your browser settings.",
|
||||
{ brand },
|
||||
);
|
||||
case 2:
|
||||
return _t("Failed to fetch your location. Please try again later.");
|
||||
case 3:
|
||||
return _t("Timed out trying to fetch your location. Please try again later.");
|
||||
case 4:
|
||||
return _t("Unknown error fetching location. Please try again later.");
|
||||
}
|
||||
};
|
|
@ -21,7 +21,7 @@ import { createMap } from "./map";
|
|||
|
||||
interface UseMapProps {
|
||||
bodyId: string;
|
||||
onError: (error: Error) => void;
|
||||
onError?: (error: Error) => void;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
|
@ -39,7 +39,7 @@ export const useMap = ({ interactive, bodyId, onError }: UseMapProps): MapLibreM
|
|||
try {
|
||||
setMap(createMap(!!interactive, bodyId, onError));
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
onError?.(error);
|
||||
}
|
||||
return () => {
|
||||
if (map) {
|
||||
|
|
|
@ -16,15 +16,18 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { fireEvent, getByTestId, render } from "@testing-library/react";
|
||||
import * as maplibregl from "maplibre-gl";
|
||||
import { ClientEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { fireEvent, getByTestId, render } from "@testing-library/react";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import Map from "../../../../src/components/views/location/Map";
|
||||
import { getMockClientWithEventEmitter } from "../../../test-utils";
|
||||
import { getMockClientWithEventEmitter, getMockGeolocationPositionError } from "../../../test-utils";
|
||||
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
|
||||
import { TILE_SERVER_WK_KEY } from "../../../../src/utils/WellKnownUtils";
|
||||
import Modal from "../../../../src/Modal";
|
||||
import ErrorDialog from "../../../../src/components/views/dialogs/ErrorDialog";
|
||||
|
||||
describe("<Map />", () => {
|
||||
const defaultProps = {
|
||||
|
@ -52,6 +55,11 @@ describe("<Map />", () => {
|
|||
});
|
||||
|
||||
jest.spyOn(logger, "error").mockRestore();
|
||||
mocked(maplibregl.GeolocateControl).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.spyOn(logger, "error").mockRestore();
|
||||
});
|
||||
|
||||
const mapOptions = { container: {} as unknown as HTMLElement, style: "" };
|
||||
|
@ -201,4 +209,70 @@ describe("<Map />", () => {
|
|||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("geolocate", () => {
|
||||
it("does not add a geolocate control when allowGeolocate is falsy", () => {
|
||||
getComponent({ allowGeolocate: false });
|
||||
|
||||
// didn't create a geolocation control
|
||||
expect(maplibregl.GeolocateControl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a geolocate control and adds it to the map when allowGeolocate is truthy", () => {
|
||||
getComponent({ allowGeolocate: true });
|
||||
|
||||
// didn't create a geolocation control
|
||||
expect(maplibregl.GeolocateControl).toHaveBeenCalledWith({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
trackUserLocation: false,
|
||||
});
|
||||
|
||||
// mocked maplibregl shares mock for each mocked instance
|
||||
// so we can assert the geolocate control was added using this static mock
|
||||
const mockGeolocate = new maplibregl.GeolocateControl({});
|
||||
expect(mockMap.addControl).toHaveBeenCalledWith(mockGeolocate);
|
||||
});
|
||||
|
||||
it("logs and opens a dialog on a geolocation error", () => {
|
||||
const mockGeolocate = new maplibregl.GeolocateControl({});
|
||||
jest.spyOn(mockGeolocate, "on");
|
||||
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
|
||||
jest.spyOn(Modal, "createDialog");
|
||||
|
||||
const { rerender } = getComponent({ allowGeolocate: true });
|
||||
|
||||
// wait for component to settle
|
||||
getComponent({ allowGeolocate: true }, rerender);
|
||||
expect(mockGeolocate.on).toHaveBeenCalledWith("error", expect.any(Function));
|
||||
const error = getMockGeolocationPositionError(1, "Test");
|
||||
|
||||
// @ts-ignore pretend to have geolocate emit an error
|
||||
mockGeolocate.emit("error", error);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith("Could not fetch location", error);
|
||||
|
||||
expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, {
|
||||
title: "Could not fetch location",
|
||||
description:
|
||||
"Element was denied permission to fetch your location. Please allow location access in your browser settings.",
|
||||
});
|
||||
});
|
||||
|
||||
it("unsubscribes from geolocate errors on destroy", () => {
|
||||
const mockGeolocate = new maplibregl.GeolocateControl({});
|
||||
jest.spyOn(mockGeolocate, "on");
|
||||
jest.spyOn(mockGeolocate, "off");
|
||||
jest.spyOn(Modal, "createDialog");
|
||||
|
||||
const { unmount } = getComponent({ allowGeolocate: true });
|
||||
|
||||
expect(mockGeolocate.on).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockGeolocate.off).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
exports[`<LocationViewDialog /> renders map correctly 1`] = `
|
||||
<Map
|
||||
allowGeolocate={true}
|
||||
centerGeoUri="geo:51.5076,-0.1276"
|
||||
className="mx_LocationViewDialog_map"
|
||||
id="mx_LocationViewDialog_$2"
|
||||
|
@ -29,12 +30,27 @@ exports[`<LocationViewDialog /> renders map correctly 1`] = `
|
|||
MockAttributionControl {},
|
||||
"top-right",
|
||||
],
|
||||
[
|
||||
MockGeolocateControl {
|
||||
"_events": {
|
||||
"error": [Function],
|
||||
},
|
||||
"_eventsCount": 1,
|
||||
"_maxListeners": undefined,
|
||||
"trigger": [MockFunction],
|
||||
Symbol(kCapture): false,
|
||||
},
|
||||
],
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"type": "return",
|
||||
"value": undefined,
|
||||
},
|
||||
{
|
||||
"type": "return",
|
||||
"value": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
"fitBounds": [MockFunction],
|
||||
|
@ -97,12 +113,27 @@ exports[`<LocationViewDialog /> renders map correctly 1`] = `
|
|||
MockAttributionControl {},
|
||||
"top-right",
|
||||
],
|
||||
[
|
||||
MockGeolocateControl {
|
||||
"_events": {
|
||||
"error": [Function],
|
||||
},
|
||||
"_eventsCount": 1,
|
||||
"_maxListeners": undefined,
|
||||
"trigger": [MockFunction],
|
||||
Symbol(kCapture): false,
|
||||
},
|
||||
],
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"type": "return",
|
||||
"value": undefined,
|
||||
},
|
||||
{
|
||||
"type": "return",
|
||||
"value": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
"fitBounds": [MockFunction],
|
||||
|
|
|
@ -439,7 +439,7 @@ describe("<MBeaconBody />", () => {
|
|||
beforeEach(() => {
|
||||
// mock map utils to raise MapStyleUrlNotConfigured error
|
||||
jest.spyOn(mapUtilHooks, "useMap").mockImplementation(({ onError }) => {
|
||||
onError(new Error(LocationShareError.MapStyleUrlNotConfigured));
|
||||
onError?.(new Error(LocationShareError.MapStyleUrlNotConfigured));
|
||||
return mockMap;
|
||||
});
|
||||
});
|
||||
|
|
38
test/utils/location/positionFailureMessage-test.ts
Normal file
38
test/utils/location/positionFailureMessage-test.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
Copyright 2023 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 { positionFailureMessage } from "../../../src/utils/location/positionFailureMessage";
|
||||
|
||||
describe("positionFailureMessage()", () => {
|
||||
// error codes from GeolocationPositionError
|
||||
// see: https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError
|
||||
// 1: PERMISSION_DENIED
|
||||
// 2: POSITION_UNAVAILABLE
|
||||
// 3: TIMEOUT
|
||||
type TestCase = [number, string | undefined];
|
||||
it.each<TestCase>([
|
||||
[
|
||||
1,
|
||||
"Element was denied permission to fetch your location. Please allow location access in your browser settings.",
|
||||
],
|
||||
[2, "Failed to fetch your location. Please try again later."],
|
||||
[3, "Timed out trying to fetch your location. Please try again later."],
|
||||
[4, "Unknown error fetching location. Please try again later."],
|
||||
[5, undefined],
|
||||
])("returns correct message for error code %s", (code, expectedMessage) => {
|
||||
expect(positionFailureMessage(code)).toEqual(expectedMessage);
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue