/* 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("", () => { 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(, { wrapper: ({ children }) => ( {children} ), }); 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.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)); }