diff --git a/src/components/views/beacon/BeaconViewDialog.tsx b/src/components/views/beacon/BeaconViewDialog.tsx index 17ba972ef9..a2fa704e8e 100644 --- a/src/components/views/beacon/BeaconViewDialog.tsx +++ b/src/components/views/beacon/BeaconViewDialog.tsx @@ -125,6 +125,9 @@ const BeaconViewDialog: React.FC = ({ initialFocusedBeacon, roomId, matr setFocusedBeaconState({ beacon, ts: Date.now() }); }; + const hasOwnBeacon = + liveBeacons.filter((beacon) => beacon?.beaconInfoOwner === matrixClient.getUserId()).length > 0; + return ( @@ -136,6 +139,7 @@ const BeaconViewDialog: React.FC = ({ initialFocusedBeacon, roomId, matr interactive onError={setMapDisplayError} className="mx_BeaconViewDialog_map" + allowGeolocate={!hasOwnBeacon} > {({ map }: { map: maplibregl.Map }) => ( <> diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index 735b5f7c03..519ca58ec2 100644 --- a/src/components/views/location/LocationPicker.tsx +++ b/src/components/views/location/LocationPicker.tsx @@ -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 { } 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."); - } -} diff --git a/src/components/views/location/LocationViewDialog.tsx b/src/components/views/location/LocationViewDialog.tsx index 8fbe7a6503..a84ec29c3b 100644 --- a/src/components/views/location/LocationViewDialog.tsx +++ b/src/components/views/location/LocationViewDialog.tsx @@ -68,6 +68,7 @@ export default class LocationViewDialog extends React.Component onError={this.onError} interactive className="mx_LocationViewDialog_map" + allowGeolocate > {({ map }) => ( <> diff --git a/src/components/views/location/Map.tsx b/src/components/views/location/Map.tsx index 87666f022b..22a4bda63e 100644 --- a/src/components/views/location/Map.tsx +++ b/src/components/views/location/Map.tsx @@ -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(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 = ({ bounds, centerGeoUri, children, className, id, interactive, onError, onClick }) => { - const { map, bodyId } = useMapWithStyle({ centerGeoUri, onError, id, interactive, bounds }); +const Map: React.FC = ({ + bounds, + centerGeoUri, + children, + className, + allowGeolocate, + id, + interactive, + onError, + onClick, +}) => { + const { map, bodyId } = useMapWithStyle({ centerGeoUri, onError, id, interactive, bounds, allowGeolocate }); const onMapClick = (event: React.MouseEvent): void => { // Eat click events when clicking the attribution button diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 382fb3e926..933b527c26 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", diff --git a/src/utils/location/index.ts b/src/utils/location/index.ts index f94c6a12dd..035fe52694 100644 --- a/src/utils/location/index.ts +++ b/src/utils/location/index.ts @@ -20,3 +20,4 @@ export * from "./locationEventGeoUri"; export * from "./LocationShareErrors"; export * from "./map"; export * from "./parseGeoUri"; +export * from "./positionFailureMessage"; diff --git a/src/utils/location/map.ts b/src/utils/location/map.ts index 8ba3d68c48..34d3d01478 100644 --- a/src/utils/location/map.ts +++ b/src/utils/location/map.ts @@ -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; diff --git a/src/utils/location/positionFailureMessage.ts b/src/utils/location/positionFailureMessage.ts new file mode 100644 index 0000000000..a5c1e6e60b --- /dev/null +++ b/src/utils/location/positionFailureMessage.ts @@ -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."); + } +}; diff --git a/src/utils/location/useMap.ts b/src/utils/location/useMap.ts index 8cdc58a83b..edc48f9e17 100644 --- a/src/utils/location/useMap.ts +++ b/src/utils/location/useMap.ts @@ -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) { diff --git a/test/components/views/location/Map-test.tsx b/test/components/views/location/Map-test.tsx index 1d92e09835..ab9f86cf98 100644 --- a/test/components/views/location/Map-test.tsx +++ b/test/components/views/location/Map-test.tsx @@ -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("", () => { const defaultProps = { @@ -52,6 +55,11 @@ describe("", () => { }); 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("", () => { 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(); + }); + }); }); diff --git a/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap b/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap index 32670dadac..115dc41203 100644 --- a/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap +++ b/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap @@ -2,6 +2,7 @@ exports[` renders map correctly 1`] = ` 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[` 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], diff --git a/test/components/views/messages/MBeaconBody-test.tsx b/test/components/views/messages/MBeaconBody-test.tsx index 5d8eb1239c..4a114eb14c 100644 --- a/test/components/views/messages/MBeaconBody-test.tsx +++ b/test/components/views/messages/MBeaconBody-test.tsx @@ -439,7 +439,7 @@ describe("", () => { 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; }); }); diff --git a/test/utils/location/positionFailureMessage-test.ts b/test/utils/location/positionFailureMessage-test.ts new file mode 100644 index 0000000000..643ebd5099 --- /dev/null +++ b/test/utils/location/positionFailureMessage-test.ts @@ -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([ + [ + 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); + }); +});