Merge branch 'livekit' into toger5/track-processor-blur

This commit is contained in:
Timo
2025-04-05 00:00:00 +02:00
375 changed files with 23054 additions and 10991 deletions

View File

@@ -0,0 +1,10 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
pre {
font-size: var(--font-size-micro);
}

View File

@@ -1,11 +1,11 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ChangeEvent, type FC, useCallback } from "react";
import { type ChangeEvent, type FC, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { FieldRow, InputField } from "../input/Input";
@@ -15,14 +15,18 @@ import {
debugTileLayout as debugTileLayoutSetting,
showNonMemberTiles as showNonMemberTilesSetting,
showConnectionStats as showConnectionStatsSetting,
useNewMembershipManagerSetting,
} from "./settings";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { MatrixClient } from "matrix-js-sdk";
import type { Room as LivekitRoom } from "livekit-client";
import styles from "./DeveloperSettingsTab.module.css";
import { useUrlParams } from "../UrlParams";
interface Props {
client: MatrixClient;
livekitRoom?: LivekitRoom;
}
export const DeveloperSettingsTab: FC<Props> = ({ client }) => {
export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRoom }) => {
const { t } = useTranslation();
const [duplicateTiles, setDuplicateTiles] = useSetting(duplicateTilesSetting);
const [debugTileLayout, setDebugTileLayout] = useSetting(
@@ -36,6 +40,22 @@ export const DeveloperSettingsTab: FC<Props> = ({ client }) => {
showConnectionStatsSetting,
);
const [useNewMembershipManager, setNewMembershipManager] = useSetting(
useNewMembershipManagerSetting,
);
const urlParams = useUrlParams();
const sfuUrl = useMemo((): URL | null => {
if (livekitRoom?.engine.client.ws?.url) {
// strip the URL params
const url = new URL(livekitRoom.engine.client.ws.url);
url.search = "";
return url;
}
return null;
}, [livekitRoom]);
return (
<>
<p>
@@ -122,6 +142,40 @@ export const DeveloperSettingsTab: FC<Props> = ({ client }) => {
)}
/>
</FieldRow>
<FieldRow>
<InputField
id="useNewMembershipManager"
type="checkbox"
label={t("developer_mode.use_new_membership_manager")}
checked={!!useNewMembershipManager}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
setNewMembershipManager(event.target.checked);
},
[setNewMembershipManager],
)}
/>
</FieldRow>
{livekitRoom ? (
<>
<p>
{t("developer_mode.livekit_sfu", {
url: sfuUrl?.href || "unknown",
})}
</p>
<p>{t("developer_mode.livekit_server_info")}</p>
<pre className={styles.pre}>
{livekitRoom.serverInfo
? JSON.stringify(livekitRoom.serverInfo, null, 2)
: "undefined"}
{livekitRoom.metadata}
</pre>
</>
) : null}
<p>{t("developer_mode.environment_variables")}</p>
<pre>{JSON.stringify(import.meta.env, null, 2)}</pre>
<p>{t("developer_mode.url_params")}</p>
<pre>{JSON.stringify(urlParams, null, 2)}</pre>
</>
);
};

View File

@@ -1,7 +1,7 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,15 +1,15 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ChangeEvent, type FC, useCallback } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/lib/randomstring";
import { Trans, useTranslation } from "react-i18next";
import { Button, Text } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { useSubmitRageshake, useRageshakeRequest } from "./submit-rageshake";
@@ -23,7 +23,8 @@ interface Props {
export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
const { t } = useTranslation();
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
const { submitRageshake, sending, sent, error, available } =
useSubmitRageshake();
const sendRageshakeRequest = useRageshakeRequest();
const onSubmitFeedback = useCallback(
@@ -36,7 +37,7 @@ export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
const description =
typeof descriptionData === "string" ? descriptionData : "";
const sendLogs = Boolean(data.get("sendLogs"));
const rageshakeRequestId = randomString(16);
const rageshakeRequestId = secureRandomString(16);
submitRageshake({
description,
@@ -66,20 +67,27 @@ export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
</Text>
);
return (
<div>
<h4>{t("common.analytics")}</h4>
<FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics ?? undefined}
description={optInDescription}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setOptInAnalytics?.(event.target.checked);
}}
/>
</FieldRow>
// in the embedded package the widget host is responsible for analytics consent
const analyticsConsentBlock =
import.meta.env.VITE_PACKAGE === "embedded" ? null : (
<>
<h4>{t("common.analytics")}</h4>
<FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics ?? undefined}
description={optInDescription}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setOptInAnalytics?.(event.target.checked);
}}
/>
</FieldRow>
</>
);
const feedbackBlock = available ? (
<>
<h4>{t("settings.feedback_tab_h4")}</h4>
<Text>{t("settings.feedback_tab_body")}</Text>
<form onSubmit={onSubmitFeedback}>
@@ -113,6 +121,13 @@ export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
{sent && <Text>{t("settings.feedback_tab_thank_you")}</Text>}
</FieldRow>
</form>
</>
) : null;
return (
<div>
{analyticsConsentBlock}
{feedbackBlock}
</div>
);
};

View File

@@ -1,7 +1,7 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,7 +1,7 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,14 +1,14 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useCallback, useEffect, useMemo, useRef } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixClient } from "matrix-js-sdk";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { useProfile } from "../profile/useProfile";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";

View File

@@ -1,7 +1,7 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,14 +1,14 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useTranslation } from "react-i18next";
import { type FC, useCallback } from "react";
import { type FC, useCallback, type JSX } from "react";
import { Button } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { Config } from "../config/Config";
import styles from "./RageshakeButton.module.css";

View File

@@ -1,7 +1,7 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,14 +1,15 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { Root as Form, Separator } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
import { Root as Form ,Separator} from "@vector-im/compound-web";
import { type Room as LivekitRoom } from "livekit-client";
import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css";
@@ -32,6 +33,7 @@ import { DeviceSelection } from "./DeviceSelection";
import { useTrackProcessor } from "../livekit/TrackProcessorContext";
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
import { FieldRow, InputField } from "../input/Input";
import { useSubmitRageshake } from "./submit-rageshake";
type SettingsTab =
| "audio"
@@ -49,6 +51,7 @@ interface Props {
onTabChange: (tab: SettingsTab) => void;
client: MatrixClient;
roomId?: string;
livekitRoom?: LivekitRoom;
}
export const defaultSettingsTab: SettingsTab = "audio";
@@ -60,6 +63,7 @@ export const SettingsModal: FC<Props> = ({
onTabChange,
client,
roomId,
livekitRoom,
}) => {
const { t } = useTranslation();
@@ -98,6 +102,8 @@ export const SettingsModal: FC<Props> = ({
const [showDeveloperSettingsTab] = useSetting(developerMode);
const { available: isRageshakeAvailable } = useSubmitRageshake();
const audioTab: Tab<SettingsTab> = {
key: "audio",
name: t("common.audio"),
@@ -173,12 +179,17 @@ export const SettingsModal: FC<Props> = ({
const developerTab: Tab<SettingsTab> = {
key: "developer",
name: t("settings.developer_tab_title"),
content: <DeveloperSettingsTab client={client} />,
content: <DeveloperSettingsTab client={client} livekitRoom={livekitRoom} />,
};
const tabs = [audioTab, videoTab];
if (widget === null) tabs.push(profileTab);
tabs.push(preferencesTab, feedbackTab);
tabs.push(preferencesTab);
if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") {
// for full package we want to show the analytics consent checkbox
// even if rageshake is not available
tabs.push(feedbackTab);
}
if (showDeveloperSettingsTab) tabs.push(developerTab);
return (

View File

@@ -2,7 +2,7 @@
Copyright 2018-2024 New Vector Ltd.
Copyright 2017 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
@@ -29,8 +29,8 @@ Please see LICENSE in the repository root for full details.
import EventEmitter from "events";
import { throttle } from "lodash-es";
import { type Logger, logger } from "matrix-js-sdk/src/logger";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { type Logger, logger } from "matrix-js-sdk/lib/logger";
import { secureRandomString } from "matrix-js-sdk/lib/randomstring";
import { type LoggingMethod } from "loglevel";
import type loglevel from "loglevel";
@@ -128,7 +128,7 @@ class IndexedDBLogStore {
private indexedDB: IDBFactory,
private loggerInstance: ConsoleLogger,
) {
this.id = "instance-" + randomString(16);
this.id = "instance-" + secureRandomString(16);
loggerInstance.on(ConsoleLoggerEvent.Log, this.onLoggerLog);
window.addEventListener("beforeunload", () => {

View File

@@ -1,11 +1,11 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { BehaviorSubject, type Observable } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
@@ -115,4 +115,8 @@ export const soundEffectVolumeSetting = new Setting<number>(
0.5,
);
export const useNewMembershipManagerSetting = new Setting<boolean>(
"new-membership-manager",
true,
);
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);

View File

@@ -0,0 +1,87 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
expect,
describe,
it,
afterEach,
vi,
type Mock,
beforeEach,
} from "vitest";
import { getRageshakeSubmitUrl } from "./submit-rageshake";
import { getUrlParams } from "../UrlParams";
import { mockConfig } from "../utils/test";
vi.mock("../UrlParams", () => ({ getUrlParams: vi.fn() }));
describe("getRageshakeSubmitUrl", () => {
beforeEach(() => {
(getUrlParams as Mock).mockReturnValue({});
mockConfig({});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
describe("embedded package", () => {
beforeEach(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
it("returns undefined no rageshakeSubmitUrl URL param", () => {
expect(getRageshakeSubmitUrl()).toBeUndefined();
});
it("returns rageshakeSubmitUrl URL param when set", () => {
(getUrlParams as Mock).mockReturnValue({
rageshakeSubmitUrl: "https://url.example.com.localhost",
});
expect(getRageshakeSubmitUrl()).toBe("https://url.example.com.localhost");
});
it("ignores config param and returns undefined", () => {
mockConfig({
rageshake: {
submit_url: "https://config.example.com.localhost",
},
});
expect(getRageshakeSubmitUrl()).toBeUndefined();
});
});
describe("full package", () => {
beforeEach(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
it("returns undefined with no config value", () => {
expect(getRageshakeSubmitUrl()).toBeUndefined();
});
it("ignores rageshakeSubmitUrl URL param and returns undefined", () => {
(getUrlParams as Mock).mockReturnValue({
rageshakeSubmitUrl: "https://url.example.com.localhost",
});
expect(getRageshakeSubmitUrl()).toBeUndefined();
});
it("returns config value when set", () => {
mockConfig({
rageshake: {
submit_url: "https://config.example.com.localhost",
},
});
expect(getRageshakeSubmitUrl()).toBe(
"https://config.example.com.localhost",
);
});
});
});

View File

@@ -1,24 +1,25 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ComponentProps, useCallback, useEffect, useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import {
ClientEvent,
type Crypto,
type MatrixClient,
type MatrixEvent,
} from "matrix-js-sdk/src/matrix";
} from "matrix-js-sdk";
import { type CryptoApi } from "matrix-js-sdk/lib/crypto-api";
import { getLogsForReport } from "./rageshake";
import { useClient } from "../ClientContext";
import { Config } from "../config/Config";
import { ElementCallOpenTelemetry } from "../otel/otel";
import { type RageshakeRequestModal } from "../room/RageshakeRequestModal";
import { getUrlParams } from "../UrlParams";
const gzip = async (text: string): Promise<Blob> => {
// pako is relatively large (200KB), so we only import it when needed
@@ -34,7 +35,7 @@ const gzip = async (text: string): Promise<Blob> => {
* Collects crypto related information.
*/
async function collectCryptoInfo(
cryptoApi: Crypto.CryptoApi,
cryptoApi: CryptoApi,
body: FormData,
): Promise<void> {
body.append("crypto_version", cryptoApi.getVersion());
@@ -82,7 +83,7 @@ async function collectCryptoInfo(
*/
async function collectRecoveryInfo(
client: MatrixClient,
cryptoApi: Crypto.CryptoApi,
cryptoApi: CryptoApi,
body: FormData,
): Promise<void> {
const secretStorage = client.secretStorage;
@@ -116,11 +117,28 @@ interface RageShakeSubmitOptions {
label?: string;
}
export function useSubmitRageshake(): {
export function getRageshakeSubmitUrl(): string | undefined {
if (import.meta.env.VITE_PACKAGE === "full") {
// in full package we always use the one configured on the server
return Config.get().rageshake?.submit_url;
}
if (import.meta.env.VITE_PACKAGE === "embedded") {
// in embedded package we always use the one provided by the widget host
return getUrlParams().rageshakeSubmitUrl ?? undefined;
}
return undefined;
}
export function useSubmitRageshake(
injectedGetRageshakeSubmitUrl = getRageshakeSubmitUrl,
): {
submitRageshake: (opts: RageShakeSubmitOptions) => Promise<void>;
sending: boolean;
sent: boolean;
error?: Error;
available: boolean;
} {
const { client } = useClient();
@@ -138,7 +156,8 @@ export function useSubmitRageshake(): {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
async (opts) => {
if (!Config.get().rageshake?.submit_url) {
const submitUrl = injectedGetRageshakeSubmitUrl();
if (!submitUrl) {
throw new Error("No rageshake URL is configured");
}
@@ -272,7 +291,7 @@ export function useSubmitRageshake(): {
);
}
const res = await fetch(Config.get().rageshake!.submit_url, {
const res = await fetch(submitUrl, {
method: "POST",
body,
});
@@ -289,7 +308,7 @@ export function useSubmitRageshake(): {
logger.error(error);
}
},
[client, sending],
[client, sending, injectedGetRageshakeSubmitUrl],
);
return {
@@ -297,6 +316,7 @@ export function useSubmitRageshake(): {
sending,
sent,
error,
available: !!injectedGetRageshakeSubmitUrl(),
};
}

View File

@@ -0,0 +1,222 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
expect,
describe,
it,
vi,
beforeEach,
afterEach,
type Mock,
} from "vitest";
import { useState, type ReactElement } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import { type MatrixClient } from "matrix-js-sdk/lib/client";
import { useSubmitRageshake, getRageshakeSubmitUrl } from "./submit-rageshake";
import { ClientContextProvider } from "../ClientContext";
import { getUrlParams } from "../UrlParams";
import { mockConfig } from "../utils/test";
vi.mock("../UrlParams", () => ({ getUrlParams: vi.fn() }));
const TestComponent = ({
sendLogs,
getRageshakeSubmitUrl,
}: {
sendLogs: boolean;
getRageshakeSubmitUrl: () => string | undefined;
}): ReactElement => {
const [clickError, setClickError] = useState<Error | null>(null);
const { available, sending, sent, submitRageshake, error } =
useSubmitRageshake(getRageshakeSubmitUrl);
const onClick = (): void => {
submitRageshake({
sendLogs,
}).catch((e) => {
setClickError(e);
});
};
return (
<div>
<p data-testid="available">{available ? "true" : "false"}</p>
<p data-testid="sending">{sending ? "true" : "false"}</p>
<p data-testid="sent">{sent ? "true" : "false"}</p>
<p data-testid="error">{error?.message}</p>
<p data-testid="clickError">{clickError?.message}</p>
<button onClick={onClick} data-testid="submit">
submit
</button>
</div>
);
};
function renderWithMockClient(
getRageshakeSubmitUrl: () => string | undefined,
sendLogs: boolean,
): void {
const client = vi.mocked<MatrixClient>({
getUserId: vi.fn().mockReturnValue("@user:localhost"),
getUser: vi.fn().mockReturnValue(null),
credentials: {
userId: "@user:localhost",
},
getCrypto: vi.fn().mockReturnValue(undefined),
} as unknown as MatrixClient);
render(
<ClientContextProvider
value={{
state: "valid",
disconnected: false,
supportedFeatures: {
reactions: true,
thumbnails: true,
},
setClient: vi.fn(),
authenticated: {
client,
isPasswordlessUser: true,
changePassword: vi.fn(),
logout: vi.fn(),
},
}}
>
<TestComponent
sendLogs={sendLogs}
getRageshakeSubmitUrl={getRageshakeSubmitUrl}
/>
</ClientContextProvider>,
);
}
describe("useSubmitRageshake", () => {
describe("available", () => {
beforeEach(() => {
(getUrlParams as Mock).mockReturnValue({});
mockConfig({});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
describe("embedded package", () => {
beforeEach(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
it("returns false with no rageshakeSubmitUrl URL param", () => {
renderWithMockClient(getRageshakeSubmitUrl, false);
expect(screen.getByTestId("available").textContent).toBe("false");
});
it("ignores config value and returns false with no rageshakeSubmitUrl URL param", () => {
mockConfig({
rageshake: {
submit_url: "https://config.example.com.localhost",
},
});
renderWithMockClient(getRageshakeSubmitUrl, false);
expect(screen.getByTestId("available").textContent).toBe("false");
});
it("returns true with rageshakeSubmitUrl URL param", () => {
(getUrlParams as Mock).mockReturnValue({
rageshakeSubmitUrl: "https://url.example.com.localhost",
});
renderWithMockClient(getRageshakeSubmitUrl, false);
expect(screen.getByTestId("available").textContent).toBe("true");
});
});
describe("full package", () => {
beforeEach(() => {
mockConfig({});
vi.stubEnv("VITE_PACKAGE", "full");
});
it("returns false with no config value", () => {
renderWithMockClient(getRageshakeSubmitUrl, false);
expect(screen.getByTestId("available").textContent).toBe("false");
});
it("ignores rageshakeSubmitUrl URL param and returns false with no config value", () => {
(getUrlParams as Mock).mockReturnValue({
rageshakeSubmitUrl: "https://url.example.com.localhost",
});
renderWithMockClient(getRageshakeSubmitUrl, false);
expect(screen.getByTestId("available").textContent).toBe("false");
});
it("returns true with config value", () => {
mockConfig({
rageshake: {
submit_url: "https://config.example.com.localhost",
},
});
renderWithMockClient(getRageshakeSubmitUrl, false);
expect(screen.getByTestId("available").textContent).toBe("true");
});
});
});
describe("when rageshake is available", () => {
beforeEach(() => {
mockConfig({});
vi.unstubAllGlobals();
});
it("starts unsent", () => {
renderWithMockClient(() => "https://rageshake.localhost/foo", false);
expect(screen.getByTestId("sending").textContent).toBe("false");
expect(screen.getByTestId("sent").textContent).toBe("false");
});
it("submitRageshake fetches expected URL", async () => {
const fetchFn = vi.fn().mockResolvedValue({
status: 200,
});
vi.stubGlobal("fetch", fetchFn);
renderWithMockClient(() => "https://rageshake.localhost/foo", false);
screen.getByTestId("submit").click();
await waitFor(() => {
expect(screen.getByTestId("sent").textContent).toBe("true");
});
expect(fetchFn).toHaveBeenCalledExactlyOnceWith(
"https://rageshake.localhost/foo",
expect.objectContaining({
method: "POST",
}),
);
expect(screen.getByTestId("clickError").textContent).toBe("");
expect(screen.getByTestId("error").textContent).toBe("");
});
});
describe("when rageshake is not available", () => {
it("starts unsent", () => {
renderWithMockClient(() => undefined, false);
expect(screen.getByTestId("sending").textContent).toBe("false");
expect(screen.getByTestId("sent").textContent).toBe("false");
});
it("submitRageshake throws error", async () => {
renderWithMockClient(() => undefined, false);
screen.getByTestId("submit").click();
await waitFor(() => {
expect(screen.getByTestId("clickError").textContent).toBe(
"No rageshake URL is configured",
);
});
});
});
});