mirror of https://github.com/vector-im/riot-web
				
				
				
			
		
			
				
	
	
		
			466 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			TypeScript
		
	
	
			
		
		
	
	
			466 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			TypeScript
		
	
	
| /*
 | |
| Copyright 2024 New Vector Ltd.
 | |
| Copyright 2022 The Matrix.org Foundation C.I.C.
 | |
| 
 | |
| SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
 | |
| Please see LICENSE files in the repository root for full details.
 | |
| */
 | |
| 
 | |
| import React from "react";
 | |
| import { mocked } from "jest-mock";
 | |
| import { RoomMember, RelationType, MatrixClient, M_ASSET, LocationAssetType } from "matrix-js-sdk/src/matrix";
 | |
| import { logger } from "matrix-js-sdk/src/logger";
 | |
| import { act, fireEvent, render, RenderResult } from "jest-matrix-react";
 | |
| import * as maplibregl from "maplibre-gl";
 | |
| 
 | |
| import LocationShareMenu from "../../../../../src/components/views/location/LocationShareMenu";
 | |
| import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
 | |
| import { ChevronFace } from "../../../../../src/components/structures/ContextMenu";
 | |
| import SettingsStore from "../../../../../src/settings/SettingsStore";
 | |
| import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
 | |
| import { LocationShareType } from "../../../../../src/components/views/location/shareLocation";
 | |
| import {
 | |
|     flushPromisesWithFakeTimers,
 | |
|     getMockClientWithEventEmitter,
 | |
|     mockClientMethodsUser,
 | |
|     setupAsyncStoreWithClient,
 | |
| } from "../../../../test-utils";
 | |
| import Modal from "../../../../../src/Modal";
 | |
| import { DEFAULT_DURATION_MS } from "../../../../../src/components/views/location/LiveDurationDropdown";
 | |
| import { OwnBeaconStore } from "../../../../../src/stores/OwnBeaconStore";
 | |
| import { SettingLevel } from "../../../../../src/settings/SettingLevel";
 | |
| import QuestionDialog from "../../../../../src/components/views/dialogs/QuestionDialog";
 | |
| 
 | |
| jest.useFakeTimers();
 | |
| 
 | |
| jest.mock("../../../../../src/utils/location/findMapStyleUrl", () => ({
 | |
|     findMapStyleUrl: jest.fn().mockReturnValue("test"),
 | |
| }));
 | |
| 
 | |
| jest.mock("../../../../../src/settings/SettingsStore", () => ({
 | |
|     getValue: jest.fn(),
 | |
|     setValue: jest.fn(),
 | |
|     monitorSetting: jest.fn(),
 | |
|     watchSetting: jest.fn(),
 | |
|     unwatchSetting: jest.fn(),
 | |
| }));
 | |
| 
 | |
| jest.mock("../../../../../src/stores/OwnProfileStore", () => ({
 | |
|     OwnProfileStore: {
 | |
|         instance: {
 | |
|             displayName: "Ernie",
 | |
|             getHttpAvatarUrl: jest.fn().mockReturnValue("image.com/img"),
 | |
|         },
 | |
|     },
 | |
| }));
 | |
| 
 | |
| jest.mock("../../../../../src/Modal", () => ({
 | |
|     createDialog: jest.fn(),
 | |
|     on: jest.fn(),
 | |
|     off: jest.fn(),
 | |
|     ModalManagerEvent: { Opened: "opened" },
 | |
| }));
 | |
| 
 | |
| describe("<LocationShareMenu />", () => {
 | |
|     const userId = "@ernie:server.org";
 | |
|     const mockClient = getMockClientWithEventEmitter({
 | |
|         ...mockClientMethodsUser(userId),
 | |
|         getClientWellKnown: jest.fn().mockResolvedValue({
 | |
|             map_style_url: "maps.com",
 | |
|         }),
 | |
|         sendMessage: jest.fn(),
 | |
|         unstable_createLiveBeacon: jest.fn().mockResolvedValue({ event_id: "1" }),
 | |
|         unstable_setLiveBeacon: jest.fn().mockResolvedValue({ event_id: "1" }),
 | |
|         getVisibleRooms: jest.fn().mockReturnValue([]),
 | |
|     });
 | |
| 
 | |
|     const defaultProps = {
 | |
|         menuPosition: {
 | |
|             top: 1,
 | |
|             left: 1,
 | |
|             chevronFace: ChevronFace.Bottom,
 | |
|         },
 | |
|         onFinished: jest.fn(),
 | |
|         openMenu: jest.fn(),
 | |
|         roomId: "!room:server.org",
 | |
|         sender: new RoomMember("!room:server.org", userId),
 | |
|     };
 | |
| 
 | |
|     const mockGeolocate = new maplibregl.GeolocateControl({});
 | |
|     jest.spyOn(mockGeolocate, "on");
 | |
|     const mapOptions = { container: {} as unknown as HTMLElement, style: "" };
 | |
|     const mockMap = new maplibregl.Map(mapOptions);
 | |
|     jest.spyOn(mockMap, "on");
 | |
| 
 | |
|     const position = {
 | |
|         coords: {
 | |
|             latitude: -36.24484561954707,
 | |
|             longitude: 175.46884959563613,
 | |
|             accuracy: 10,
 | |
|         },
 | |
|         timestamp: 1646305006802,
 | |
|         type: "geolocate",
 | |
|     };
 | |
| 
 | |
|     const makeOwnBeaconStore = async () => {
 | |
|         const store = OwnBeaconStore.instance;
 | |
| 
 | |
|         await setupAsyncStoreWithClient(store, mockClient);
 | |
|         return store;
 | |
|     };
 | |
| 
 | |
|     const getComponent = (props = {}): RenderResult =>
 | |
|         render(<LocationShareMenu {...defaultProps} {...props} />, {
 | |
|             wrapper: ({ children }) => (
 | |
|                 <MatrixClientContext.Provider value={mockClient}>{children}</MatrixClientContext.Provider>
 | |
|             ),
 | |
|         });
 | |
| 
 | |
|     beforeEach(async () => {
 | |
|         jest.spyOn(logger, "error").mockRestore();
 | |
|         mocked(SettingsStore).getValue.mockReturnValue(false);
 | |
|         mockClient.sendMessage.mockClear();
 | |
|         mockClient.unstable_createLiveBeacon.mockClear().mockResolvedValue({ event_id: "1" });
 | |
|         jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient as unknown as MatrixClient);
 | |
|         mocked(Modal).createDialog.mockClear();
 | |
| 
 | |
|         jest.clearAllMocks();
 | |
| 
 | |
|         await makeOwnBeaconStore();
 | |
|     });
 | |
| 
 | |
|     const getBackButton = (getByLabelText: RenderResult["getByLabelText"]) => getByLabelText("Back");
 | |
| 
 | |
|     const getCancelButton = (getByLabelText: RenderResult["getByLabelText"]) => getByLabelText("Close");
 | |
| 
 | |
|     const setLocationGeolocate = () => {
 | |
|         // get the callback LocationShareMenu registered for geolocate
 | |
|         expect(mocked(mockGeolocate.on)).toHaveBeenCalledWith("geolocate", expect.any(Function));
 | |
|         const [, onGeolocateCallback] = mocked(mockGeolocate.on).mock.calls.find(([event]) => event === "geolocate")!;
 | |
| 
 | |
|         // set the location
 | |
|         onGeolocateCallback(position);
 | |
|     };
 | |
| 
 | |
|     const setLocationClick = () => {
 | |
|         // get the callback LocationShareMenu registered for geolocate
 | |
|         expect(mocked(mockMap.on)).toHaveBeenCalledWith("click", expect.any(Function));
 | |
|         const [, onMapClickCallback] = mocked(mockMap.on).mock.calls.find(([event]) => event === "click")!;
 | |
| 
 | |
|         const event = {
 | |
|             lngLat: { lng: position.coords.longitude, lat: position.coords.latitude },
 | |
|         } as unknown as maplibregl.MapMouseEvent;
 | |
|         // set the location
 | |
|         onMapClickCallback(event);
 | |
|     };
 | |
| 
 | |
|     const shareTypeLabels: Record<LocationShareType, string> = {
 | |
|         [LocationShareType.Own]: "My current location",
 | |
|         [LocationShareType.Live]: "My live location",
 | |
|         [LocationShareType.Pin]: "Drop a Pin",
 | |
|     };
 | |
|     const setShareType = (getByText: RenderResult["getByText"], shareType: LocationShareType) => {
 | |
|         fireEvent.click(getByText(shareTypeLabels[shareType]));
 | |
|     };
 | |
| 
 | |
|     describe("when only Own share type is enabled", () => {
 | |
|         beforeEach(() => enableSettings([]));
 | |
| 
 | |
|         it("renders own and live location options", () => {
 | |
|             const { getByText } = getComponent();
 | |
|             expect(getByText(shareTypeLabels[LocationShareType.Own])).toBeInTheDocument();
 | |
|             expect(getByText(shareTypeLabels[LocationShareType.Live])).toBeInTheDocument();
 | |
|         });
 | |
| 
 | |
|         it("renders back button from location picker screen", () => {
 | |
|             const { getByText, getByLabelText } = getComponent();
 | |
|             setShareType(getByText, LocationShareType.Own);
 | |
| 
 | |
|             expect(getBackButton(getByLabelText)).toBeInTheDocument();
 | |
|         });
 | |
| 
 | |
|         it("clicking cancel button from location picker closes dialog", () => {
 | |
|             const onFinished = jest.fn();
 | |
|             const { getByLabelText } = getComponent({ onFinished });
 | |
| 
 | |
|             fireEvent.click(getCancelButton(getByLabelText));
 | |
| 
 | |
|             expect(onFinished).toHaveBeenCalled();
 | |
|         });
 | |
| 
 | |
|         it("creates static own location share event on submission", () => {
 | |
|             const onFinished = jest.fn();
 | |
|             const { getByText } = getComponent({ onFinished });
 | |
| 
 | |
|             setShareType(getByText, LocationShareType.Own);
 | |
| 
 | |
|             setLocationGeolocate();
 | |
| 
 | |
|             fireEvent.click(getByText("Share location"));
 | |
| 
 | |
|             expect(onFinished).toHaveBeenCalled();
 | |
|             const [messageRoomId, relation, messageBody] = mockClient.sendMessage.mock.calls[0];
 | |
|             expect(messageRoomId).toEqual(defaultProps.roomId);
 | |
|             expect(relation).toEqual(null);
 | |
|             expect(messageBody).toEqual(
 | |
|                 expect.objectContaining({
 | |
|                     [M_ASSET.name]: {
 | |
|                         type: LocationAssetType.Self,
 | |
|                     },
 | |
|                 }),
 | |
|             );
 | |
|         });
 | |
|     });
 | |
| 
 | |
|     describe("with pin drop share type enabled", () => {
 | |
|         it("renders share type switch with own and pin drop options", () => {
 | |
|             const { getByText } = getComponent();
 | |
|             expect(document.querySelector(".mx_LocationPicker")).not.toBeInTheDocument();
 | |
| 
 | |
|             expect(getByText(shareTypeLabels[LocationShareType.Own])).toBeInTheDocument();
 | |
|             expect(getByText(shareTypeLabels[LocationShareType.Pin])).toBeInTheDocument();
 | |
|         });
 | |
| 
 | |
|         it("does not render back button on share type screen", () => {
 | |
|             const { queryByLabelText } = getComponent();
 | |
|             expect(queryByLabelText("Back")).not.toBeInTheDocument();
 | |
|         });
 | |
| 
 | |
|         it("clicking cancel button from share type screen closes dialog", () => {
 | |
|             const onFinished = jest.fn();
 | |
|             const { getByLabelText } = getComponent({ onFinished });
 | |
| 
 | |
|             fireEvent.click(getCancelButton(getByLabelText));
 | |
| 
 | |
|             expect(onFinished).toHaveBeenCalled();
 | |
|         });
 | |
| 
 | |
|         it("selecting own location share type advances to location picker", () => {
 | |
|             const { getByText } = getComponent();
 | |
| 
 | |
|             setShareType(getByText, LocationShareType.Own);
 | |
| 
 | |
|             expect(document.querySelector(".mx_LocationPicker")).toBeInTheDocument();
 | |
|         });
 | |
| 
 | |
|         it("clicking back button from location picker screen goes back to share screen", () => {
 | |
|             const onFinished = jest.fn();
 | |
|             const { getByText, getByLabelText } = getComponent({ onFinished });
 | |
| 
 | |
|             // advance to location picker
 | |
|             setShareType(getByText, LocationShareType.Own);
 | |
| 
 | |
|             expect(document.querySelector(".mx_LocationPicker")).toBeInTheDocument();
 | |
| 
 | |
|             fireEvent.click(getBackButton(getByLabelText));
 | |
| 
 | |
|             // back to share type
 | |
|             expect(getByText("What location type do you want to share?")).toBeInTheDocument();
 | |
|         });
 | |
| 
 | |
|         it("creates pin drop location share event on submission", () => {
 | |
|             const onFinished = jest.fn();
 | |
|             const { getByText } = getComponent({ onFinished });
 | |
| 
 | |
|             // advance to location picker
 | |
|             setShareType(getByText, LocationShareType.Pin);
 | |
| 
 | |
|             setLocationClick();
 | |
| 
 | |
|             fireEvent.click(getByText("Share location"));
 | |
| 
 | |
|             expect(onFinished).toHaveBeenCalled();
 | |
|             const [messageRoomId, relation, messageBody] = mockClient.sendMessage.mock.calls[0];
 | |
|             expect(messageRoomId).toEqual(defaultProps.roomId);
 | |
|             expect(relation).toEqual(null);
 | |
|             expect(messageBody).toEqual(
 | |
|                 expect.objectContaining({
 | |
|                     [M_ASSET.name]: {
 | |
|                         type: LocationAssetType.Pin,
 | |
|                     },
 | |
|                 }),
 | |
|             );
 | |
|         });
 | |
|     });
 | |
| 
 | |
|     describe("with live location disabled", () => {
 | |
|         beforeEach(() => enableSettings([]));
 | |
| 
 | |
|         it("goes to labs flag screen after live options is clicked", () => {
 | |
|             const onFinished = jest.fn();
 | |
|             const { getByText, getByTestId } = getComponent({ onFinished });
 | |
| 
 | |
|             setShareType(getByText, LocationShareType.Live);
 | |
| 
 | |
|             expect(getByTestId("location-picker-enable-live-share")).toMatchSnapshot();
 | |
|         });
 | |
| 
 | |
|         it("disables OK button when labs flag is not enabled", () => {
 | |
|             const { getByText } = getComponent();
 | |
| 
 | |
|             setShareType(getByText, LocationShareType.Live);
 | |
| 
 | |
|             expect(getByText("OK").hasAttribute("disabled")).toBeTruthy();
 | |
|         });
 | |
| 
 | |
|         it("enables OK button when labs flag is toggled to enabled", () => {
 | |
|             const { getByText, getByLabelText } = getComponent();
 | |
| 
 | |
|             setShareType(getByText, LocationShareType.Live);
 | |
| 
 | |
|             fireEvent.click(getByLabelText("Enable live location sharing"));
 | |
| 
 | |
|             expect(getByText("OK").hasAttribute("disabled")).toBeFalsy();
 | |
|         });
 | |
| 
 | |
|         it("enables live share setting on ok button submit", () => {
 | |
|             const { getByText, getByLabelText } = getComponent();
 | |
| 
 | |
|             setShareType(getByText, LocationShareType.Live);
 | |
| 
 | |
|             fireEvent.click(getByLabelText("Enable live location sharing"));
 | |
| 
 | |
|             fireEvent.click(getByText("OK"));
 | |
| 
 | |
|             expect(SettingsStore.setValue).toHaveBeenCalledWith(
 | |
|                 "feature_location_share_live",
 | |
|                 null,
 | |
|                 SettingLevel.DEVICE,
 | |
|                 true,
 | |
|             );
 | |
|         });
 | |
| 
 | |
|         it("navigates to location picker when live share is enabled in settings store", () => {
 | |
|             // @ts-ignore
 | |
|             mocked(SettingsStore.watchSetting).mockImplementation((featureName, roomId, callback) => {
 | |
|                 callback(featureName, roomId, SettingLevel.DEVICE, "", "");
 | |
|                 window.setTimeout(() => {
 | |
|                     callback(featureName, roomId, SettingLevel.DEVICE, "", "");
 | |
|                 }, 1000);
 | |
|             });
 | |
|             mocked(SettingsStore.getValue).mockReturnValue(false);
 | |
|             const { getByText, getByLabelText } = getComponent();
 | |
| 
 | |
|             setShareType(getByText, LocationShareType.Live);
 | |
| 
 | |
|             // we're on enable live share screen
 | |
|             expect(getByLabelText("Enable live location sharing")).toBeInTheDocument();
 | |
| 
 | |
|             act(() => {
 | |
|                 mocked(SettingsStore.getValue).mockReturnValue(true);
 | |
|                 // advance so watchSetting will update the value
 | |
|                 jest.runAllTimers();
 | |
|             });
 | |
| 
 | |
|             // advanced to location picker
 | |
|             expect(document.querySelector(".mx_LocationPicker")).toBeInTheDocument();
 | |
|         });
 | |
|     });
 | |
| 
 | |
|     describe("Live location share", () => {
 | |
|         beforeEach(() => enableSettings(["feature_location_share_live"]));
 | |
| 
 | |
|         it("does not display live location share option when composer has a relation", () => {
 | |
|             const relation = {
 | |
|                 rel_type: RelationType.Thread,
 | |
|                 event_id: "12345",
 | |
|             };
 | |
|             const { queryByText } = getComponent({ relation });
 | |
| 
 | |
|             expect(queryByText(shareTypeLabels[LocationShareType.Live])).not.toBeInTheDocument();
 | |
|         });
 | |
| 
 | |
|         it("creates beacon info event on submission", async () => {
 | |
|             const onFinished = jest.fn();
 | |
|             const { getByText } = getComponent({ onFinished });
 | |
| 
 | |
|             // advance to location picker
 | |
|             setShareType(getByText, LocationShareType.Live);
 | |
|             setLocationGeolocate();
 | |
| 
 | |
|             fireEvent.click(getByText("Share location"));
 | |
| 
 | |
|             // flush stopping existing beacons promises
 | |
|             await flushPromisesWithFakeTimers();
 | |
| 
 | |
|             expect(onFinished).toHaveBeenCalled();
 | |
|             const [eventRoomId, eventContent] = mockClient.unstable_createLiveBeacon.mock.calls[0];
 | |
|             expect(eventRoomId).toEqual(defaultProps.roomId);
 | |
|             expect(eventContent).toEqual(
 | |
|                 expect.objectContaining({
 | |
|                     // default timeout
 | |
|                     timeout: DEFAULT_DURATION_MS,
 | |
|                     description: `Ernie's live location`,
 | |
|                     live: true,
 | |
|                     [M_ASSET.name]: {
 | |
|                         type: LocationAssetType.Self,
 | |
|                     },
 | |
|                 }),
 | |
|             );
 | |
|         });
 | |
| 
 | |
|         it("opens error dialog when beacon creation fails", async () => {
 | |
|             // stub logger to keep console clean from expected error
 | |
|             const logSpy = jest.spyOn(logger, "error").mockReturnValue(undefined);
 | |
|             const error = new Error("oh no");
 | |
|             mockClient.unstable_createLiveBeacon.mockRejectedValue(error);
 | |
|             const { getByText } = getComponent();
 | |
| 
 | |
|             // advance to location picker
 | |
|             setShareType(getByText, LocationShareType.Live);
 | |
|             setLocationGeolocate();
 | |
| 
 | |
|             fireEvent.click(getByText("Share location"));
 | |
| 
 | |
|             await flushPromisesWithFakeTimers();
 | |
|             await flushPromisesWithFakeTimers();
 | |
|             await flushPromisesWithFakeTimers();
 | |
| 
 | |
|             expect(logSpy).toHaveBeenCalledWith("We couldn't start sharing your live location", error);
 | |
|             expect(mocked(Modal).createDialog).toHaveBeenCalledWith(
 | |
|                 QuestionDialog,
 | |
|                 expect.objectContaining({
 | |
|                     button: "Try again",
 | |
|                     description: "Element could not send your location. Please try again later.",
 | |
|                     title: `We couldn't send your location`,
 | |
|                     cancelButton: "Cancel",
 | |
|                 }),
 | |
|             );
 | |
|         });
 | |
| 
 | |
|         it("opens error dialog when beacon creation fails with permission error", async () => {
 | |
|             // stub logger to keep console clean from expected error
 | |
|             const logSpy = jest.spyOn(logger, "error").mockReturnValue(undefined);
 | |
|             const error = { errcode: "M_FORBIDDEN" } as unknown as Error;
 | |
|             mockClient.unstable_createLiveBeacon.mockRejectedValue(error);
 | |
|             const { getByText } = getComponent();
 | |
| 
 | |
|             // advance to location picker
 | |
|             setShareType(getByText, LocationShareType.Live);
 | |
|             setLocationGeolocate();
 | |
| 
 | |
|             fireEvent.click(getByText("Share location"));
 | |
| 
 | |
|             await flushPromisesWithFakeTimers();
 | |
|             await flushPromisesWithFakeTimers();
 | |
|             await flushPromisesWithFakeTimers();
 | |
| 
 | |
|             expect(logSpy).toHaveBeenCalledWith("Insufficient permissions to start sharing your live location", error);
 | |
|             expect(mocked(Modal).createDialog).toHaveBeenCalledWith(
 | |
|                 QuestionDialog,
 | |
|                 expect.objectContaining({
 | |
|                     button: "OK",
 | |
|                     description: "You need to have the right permissions in order to share locations in this room.",
 | |
|                     title: `You don't have permission to share locations`,
 | |
|                     hasCancelButton: false,
 | |
|                 }),
 | |
|             );
 | |
|         });
 | |
|     });
 | |
| });
 | |
| 
 | |
| function enableSettings(settings: string[]) {
 | |
|     mocked(SettingsStore).getValue.mockReturnValue(false);
 | |
|     mocked(SettingsStore).getValue.mockImplementation((settingName: string): any => settings.includes(settingName));
 | |
| }
 |