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));
|
||
|
}
|