);
diff --git a/src/components/views/dialogs/FeedbackDialog.tsx b/src/components/views/dialogs/FeedbackDialog.tsx
index 72321eeba1..40814d615a 100644
--- a/src/components/views/dialogs/FeedbackDialog.tsx
+++ b/src/components/views/dialogs/FeedbackDialog.tsx
@@ -45,6 +45,7 @@ const FeedbackDialog: React.FC = (props: IProps) => {
const onFinished = (sendFeedback: boolean): void => {
if (hasFeedback && sendFeedback) {
const label = props.feature ? `${props.feature}-feedback` : "feedback";
+ // TODO: Handle rejection.
submitFeedback(label, comment, canContact);
Modal.createDialog(InfoDialog, {
diff --git a/src/components/views/dialogs/GenericFeatureFeedbackDialog.tsx b/src/components/views/dialogs/GenericFeatureFeedbackDialog.tsx
index 3967de7c4d..8df5b8dbb4 100644
--- a/src/components/views/dialogs/GenericFeatureFeedbackDialog.tsx
+++ b/src/components/views/dialogs/GenericFeatureFeedbackDialog.tsx
@@ -38,7 +38,7 @@ const GenericFeatureFeedbackDialog: React.FC = ({
const sendFeedback = async (ok: boolean): Promise => {
if (!ok) return onFinished(false);
-
+ // TODO: Handle rejection.
submitFeedback(rageshakeLabel, comment, canContact, rageshakeData);
onFinished(true);
diff --git a/src/components/views/elements/BugReportDialogButton.tsx b/src/components/views/elements/BugReportDialogButton.tsx
new file mode 100644
index 0000000000..284501e7b9
--- /dev/null
+++ b/src/components/views/elements/BugReportDialogButton.tsx
@@ -0,0 +1,43 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import React, { useCallback } from "react";
+import { Button } from "@vector-im/compound-web";
+
+import SdkConfig from "../../../SdkConfig";
+import { _t } from "../../../languageHandler";
+import Modal from "../../../Modal";
+import BugReportDialog, { type BugReportDialogProps } from "../dialogs/BugReportDialog";
+import { BugReportEndpointURLLocal } from "../../../IConfigOptions";
+
+/**
+ * Renders a button to open the BugReportDialog *if* the configuration
+ * supports it.
+ */
+export function BugReportDialogButton({
+ label,
+ error,
+}: Pick): React.ReactElement | null {
+ const bugReportUrl = SdkConfig.get().bug_report_endpoint_url;
+ const onClick = useCallback(() => {
+ Modal.createDialog(BugReportDialog, {
+ label,
+ error,
+ });
+ }, [label, error]);
+
+ if (!bugReportUrl) {
+ return null;
+ }
+ return (
+
+ );
+}
diff --git a/src/components/views/elements/ErrorBoundary.tsx b/src/components/views/elements/ErrorBoundary.tsx
index 7fd2ba9712..ae5e3f93bf 100644
--- a/src/components/views/elements/ErrorBoundary.tsx
+++ b/src/components/views/elements/ErrorBoundary.tsx
@@ -12,10 +12,9 @@ import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import PlatformPeg from "../../../PlatformPeg";
-import Modal from "../../../Modal";
import SdkConfig from "../../../SdkConfig";
-import BugReportDialog from "../dialogs/BugReportDialog";
import AccessibleButton from "./AccessibleButton";
+import { BugReportDialogButton } from "./BugReportDialogButton";
interface Props {
children: ReactNode;
@@ -60,13 +59,6 @@ export default class ErrorBoundary extends React.PureComponent {
});
};
- private onBugReport = (): void => {
- Modal.createDialog(BugReportDialog, {
- label: "react-soft-crash",
- error: this.state.error,
- });
- };
-
public render(): ReactNode {
if (this.state.error) {
const newIssueUrl = SdkConfig.get().feedback.new_issue_url;
@@ -95,9 +87,7 @@ export default class ErrorBoundary extends React.PureComponent {
{_t("bug_reporting|description")}
-
- {_t("bug_reporting|submit_debug_logs")}
-
+
);
}
diff --git a/src/components/views/messages/TileErrorBoundary.tsx b/src/components/views/messages/TileErrorBoundary.tsx
index 2ccac0f288..2d11371166 100644
--- a/src/components/views/messages/TileErrorBoundary.tsx
+++ b/src/components/views/messages/TileErrorBoundary.tsx
@@ -12,12 +12,11 @@ import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
-import SdkConfig from "../../../SdkConfig";
-import BugReportDialog from "../dialogs/BugReportDialog";
import AccessibleButton from "../elements/AccessibleButton";
import SettingsStore from "../../../settings/SettingsStore";
import ViewSource from "../../structures/ViewSource";
import { type Layout } from "../../../settings/enums/Layout";
+import { BugReportDialogButton } from "../elements/BugReportDialogButton";
interface IProps {
mxEvent: MatrixEvent;
@@ -42,13 +41,6 @@ export default class TileErrorBoundary extends React.Component {
return { error };
}
- private onBugReport = (): void => {
- Modal.createDialog(BugReportDialog, {
- label: "react-soft-crash-tile",
- error: this.state.error,
- });
- };
-
private onViewSource = (): void => {
Modal.createDialog(
ViewSource,
@@ -69,18 +61,6 @@ export default class TileErrorBoundary extends React.Component {
mx_EventTile_tileError: true,
};
- let submitLogsButton;
- if (SdkConfig.get().bug_report_endpoint_url) {
- submitLogsButton = (
- <>
-
-
- {_t("bug_reporting|submit_debug_logs")}
-
- >
- );
- }
-
let viewSourceButton;
if (mxEvent && SettingsStore.getValue("developerMode")) {
viewSourceButton = (
@@ -99,7 +79,7 @@ export default class TileErrorBoundary extends React.Component {
{_t("timeline|error_rendering_message")}
{mxEvent && ` (${mxEvent.getType()})`}
- {submitLogsButton}
+
{viewSourceButton}
diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx
index ef0d6e57fc..f4f67e57d3 100644
--- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx
+++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx
@@ -13,16 +13,15 @@ import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import AccessibleButton from "../../../elements/AccessibleButton";
import { _t } from "../../../../../languageHandler";
import SdkConfig from "../../../../../SdkConfig";
-import Modal from "../../../../../Modal";
import PlatformPeg from "../../../../../PlatformPeg";
import UpdateCheckButton from "../../UpdateCheckButton";
-import BugReportDialog from "../../../dialogs/BugReportDialog";
import CopyableText from "../../../elements/CopyableText";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import { SettingsSubsection, SettingsSubsectionText } from "../../shared/SettingsSubsection";
import ExternalLink from "../../../elements/ExternalLink";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
+import { BugReportDialogButton } from "../../../elements/BugReportDialogButton";
interface IState {
appVersion: string | null;
@@ -80,10 +79,6 @@ export default class HelpUserSettingsTab extends React.Component {
- Modal.createDialog(BugReportDialog, {});
- };
-
private renderLegal(): ReactNode {
const tocLinks = SdkConfig.get().terms_and_conditions_links;
if (!tocLinks) return null;
@@ -231,9 +226,7 @@ export default class HelpUserSettingsTab extends React.Component
}
>
-
- {_t("bug_reporting|submit_debug_logs")}
-
+
{_t(
"bug_reporting|matrix_security_issue",
diff --git a/src/models/Call.ts b/src/models/Call.ts
index 39e4aa4b9a..cb9041bfec 100644
--- a/src/models/Call.ts
+++ b/src/models/Call.ts
@@ -44,6 +44,7 @@ import { type JitsiCallMemberContent, JitsiCallMemberEventType } from "../call-t
import SdkConfig from "../SdkConfig.ts";
import DMRoomMap from "../utils/DMRoomMap.ts";
import { type WidgetMessaging, WidgetMessagingEvent } from "../stores/widgets/WidgetMessaging.ts";
+import { BugReportEndpointURLLocal } from "../IConfigOptions.ts";
const TIMEOUT_MS = 16000;
const logger = rootLogger.getChild("models/Call");
@@ -769,7 +770,7 @@ export class ElementCall extends Call {
}
const rageshakeSubmitUrl = SdkConfig.get("bug_report_endpoint_url");
- if (rageshakeSubmitUrl) {
+ if (rageshakeSubmitUrl && rageshakeSubmitUrl !== BugReportEndpointURLLocal) {
params.append("rageshakeSubmitUrl", rageshakeSubmitUrl);
}
diff --git a/src/rageshake/submit-rageshake.ts b/src/rageshake/submit-rageshake.ts
index 2815b50a0c..0101686548 100644
--- a/src/rageshake/submit-rageshake.ts
+++ b/src/rageshake/submit-rageshake.ts
@@ -20,6 +20,8 @@ import * as rageshake from "./rageshake";
import SettingsStore from "../settings/SettingsStore";
import SdkConfig from "../SdkConfig";
import { getServerVersionFromFederationApi } from "../components/views/dialogs/devtools/ServerInfo";
+import type * as Tar from "tar-js";
+import { BugReportEndpointURLLocal } from "../IConfigOptions";
interface IOpts {
labels?: string[];
@@ -342,7 +344,7 @@ async function collectLogs(
* the server does not respond with an expected body format.
*/
export default async function sendBugReport(bugReportEndpoint?: string, opts: IOpts = {}): Promise {
- if (!bugReportEndpoint) {
+ if (!bugReportEndpoint || bugReportEndpoint === BugReportEndpointURLLocal) {
throw new Error("No bug report endpoint has been set.");
}
@@ -354,20 +356,12 @@ export default async function sendBugReport(bugReportEndpoint?: string, opts: IO
}
/**
- * Downloads the files from a bug report. This is the same as sendBugReport,
- * but instead causes the browser to download the files locally.
+ * Loads a bug report into a tarball.
*
- * @param {object} opts optional dictionary of options
- *
- * @param {string} opts.userText Any additional user input.
- *
- * @param {boolean} opts.sendLogs True to send logs
- *
- * @param {function(string)} opts.progressCallback Callback to call with progress updates
- *
- * @return {Promise} Resolved when the bug report is downloaded (or started).
+ * @param opts optional dictionary of options
+ * @return Resolves with a Tarball object.
*/
-export async function downloadBugReport(opts: IOpts = {}): Promise {
+export async function loadBugReport(opts: IOpts = {}): Promise {
const Tar = (await import("tar-js")).default;
const progressCallback = opts.progressCallback || ((): void => {});
const body = await collectBugReport(opts, false);
@@ -391,7 +385,18 @@ export async function downloadBugReport(opts: IOpts = {}): Promise {
}
}
tape.append("issue.txt", metadata);
+ return tape;
+}
+/**
+ * Downloads the files from a bug report. This is the same as sendBugReport,
+ * but instead causes the browser to download the files locally.
+ *
+ * @param opts optional dictionary of options
+ * @return Resolved when the bug report is downloaded (or started).
+ */
+export async function downloadBugReport(opts: IOpts = {}): Promise {
+ const tape = await loadBugReport(opts);
// We have to create a new anchor to download if we want a filename. Otherwise we could
// just use window.open.
const dl = document.createElement("a");
@@ -417,6 +422,10 @@ export async function submitFeedback(
canContact = false,
extraData: Record = {},
): Promise {
+ const bugReportEndpointUrl = SdkConfig.get().bug_report_endpoint_url;
+ if (!bugReportEndpointUrl || bugReportEndpointUrl === BugReportEndpointURLLocal) {
+ throw new Error("Bug report URL is not set or local");
+ }
let version: string | undefined;
try {
version = await PlatformPeg.get()?.getAppVersion();
@@ -436,11 +445,7 @@ export async function submitFeedback(
body.append(k, JSON.stringify(extraData[k]));
}
- const bugReportEndpointUrl = SdkConfig.get().bug_report_endpoint_url;
-
- if (bugReportEndpointUrl) {
- await submitReport(bugReportEndpointUrl, body, () => {});
- }
+ await submitReport(bugReportEndpointUrl, body, () => {});
}
/**
diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx
index 0e1c269f10..e59d935883 100644
--- a/src/settings/Settings.tsx
+++ b/src/settings/Settings.tsx
@@ -405,15 +405,14 @@ export const SETTINGS: Settings = {
>
),
- faq: () =>
- SdkConfig.get().bug_report_endpoint_url && (
- <>
-
{_t("labs|video_rooms_faq1_question")}
-
{_t("labs|video_rooms_faq1_answer")}
-
{_t("labs|video_rooms_faq2_question")}
-
{_t("labs|video_rooms_faq2_answer")}
- >
- ),
+ faq: () => (
+ <>
+
{_t("labs|video_rooms_faq1_question")}
+
{_t("labs|video_rooms_faq1_answer")}
+
{_t("labs|video_rooms_faq2_question")}
+
{_t("labs|video_rooms_faq2_answer")}
+ >
+ ),
feedbackLabel: "video-room-feedback",
feedbackSubheading: _td("labs|video_rooms_feedbackSubheading"),
// eslint-disable-next-line @typescript-eslint/no-require-imports
diff --git a/src/utils/Feedback.ts b/src/utils/Feedback.ts
index 2c2e55fb41..d75e65b75d 100644
--- a/src/utils/Feedback.ts
+++ b/src/utils/Feedback.ts
@@ -6,10 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
+import { BugReportEndpointURLLocal } from "../IConfigOptions";
import SdkConfig from "../SdkConfig";
import SettingsStore from "../settings/SettingsStore";
import { UIFeature } from "../settings/UIFeature";
export function shouldShowFeedback(): boolean {
- return !!SdkConfig.get().bug_report_endpoint_url && SettingsStore.getValue(UIFeature.Feedback);
+ const url = SdkConfig.get().bug_report_endpoint_url;
+ return !!url && url !== BugReportEndpointURLLocal && SettingsStore.getValue(UIFeature.Feedback);
}
diff --git a/src/vector/rageshakesetup.ts b/src/vector/rageshakesetup.ts
index ef104e9a5b..395be79243 100644
--- a/src/vector/rageshakesetup.ts
+++ b/src/vector/rageshakesetup.ts
@@ -22,7 +22,8 @@ import { logger } from "matrix-js-sdk/src/logger";
import * as rageshake from "../rageshake/rageshake";
import SdkConfig from "../SdkConfig";
-import sendBugReport from "../rageshake/submit-rageshake";
+import sendBugReport, { loadBugReport } from "../rageshake/submit-rageshake";
+import { BugReportEndpointURLLocal } from "../IConfigOptions";
export function initRageshake(): Promise {
// we manually check persistence for rageshakes ourselves
@@ -54,28 +55,40 @@ export function initRageshakeStore(): Promise {
return rageshake.tryInitStorage();
}
-window.mxSendRageshake = function (text: string, withLogs?: boolean): void {
+window.mxSendRageshake = async function (text: string, withLogs = true): Promise {
const url = SdkConfig.get().bug_report_endpoint_url;
if (!url) {
logger.error("Cannot send a rageshake - no bug_report_endpoint_url configured");
return;
}
- if (withLogs === undefined) withLogs = true;
if (!text || !text.trim()) {
logger.error("Cannot send a rageshake without a message - please tell us what went wrong");
return;
}
- sendBugReport(url, {
- userText: text,
- sendLogs: withLogs,
- progressCallback: logger.log.bind(console),
- }).then(
- () => {
+ if (url === BugReportEndpointURLLocal) {
+ try {
+ const tape = await loadBugReport({
+ userText: text,
+ sendLogs: withLogs,
+ progressCallback: logger.log.bind(console),
+ });
+ const blob = new Blob([new Uint8Array(tape.out)], { type: "application/gzip" });
+ const url = URL.createObjectURL(blob);
+ logger.log(`Your logs are available at ${url}`);
+ } catch (err) {
+ logger.error("Failed to load bug report", err);
+ }
+ } else {
+ try {
+ await sendBugReport(url, {
+ userText: text,
+ sendLogs: withLogs,
+ progressCallback: logger.log.bind(console),
+ });
logger.log("Bug report sent!");
- },
- (err) => {
- logger.error(err);
- },
- );
+ } catch (err) {
+ logger.error("Failed to send bug report", err);
+ }
+ }
};
diff --git a/test/unit-tests/components/views/dialogs/BugReportDialog-test.tsx b/test/unit-tests/components/views/dialogs/BugReportDialog-test.tsx
index 6a8506887e..78257711c2 100644
--- a/test/unit-tests/components/views/dialogs/BugReportDialog-test.tsx
+++ b/test/unit-tests/components/views/dialogs/BugReportDialog-test.tsx
@@ -17,6 +17,7 @@ import BugReportDialog, {
import SdkConfig from "../../../../../src/SdkConfig";
import { type ConsoleLogger } from "../../../../../src/rageshake/rageshake";
import SettingsStore from "../../../../../src/settings/SettingsStore";
+import { BugReportEndpointURLLocal } from "../../../../../src/IConfigOptions";
const BUG_REPORT_URL = "https://example.org/submit";
@@ -69,7 +70,7 @@ describe("BugReportDialog", () => {
it("can submit a bug report", async () => {
const { getByLabelText, getByText } = renderComponent();
- fetchMock.postOnce(BUG_REPORT_URL, { report_url: "https://exmaple.org/report/url" });
+ fetchMock.postOnce(BUG_REPORT_URL, { report_url: "https://example.org/report/url" });
await userEvent.type(getByLabelText("GitHub issue"), "https://example.org/some/issue");
await userEvent.type(getByLabelText("Notes"), "Additional text");
await userEvent.click(getByText("Send logs"));
@@ -78,6 +79,14 @@ describe("BugReportDialog", () => {
expect(fetchMock).toHaveFetched(BUG_REPORT_URL);
});
+ it("renders when the config only allows local downloads", async () => {
+ SdkConfig.put({
+ bug_report_endpoint_url: BugReportEndpointURLLocal,
+ });
+ const { container } = renderComponent();
+ expect(container).toMatchSnapshot("local-bug-reporter");
+ });
+
it.each([
{
errcode: undefined,
diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/BugReportDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/BugReportDialog-test.tsx.snap
new file mode 100644
index 0000000000..2b8749c863
--- /dev/null
+++ b/test/unit-tests/components/views/dialogs/__snapshots__/BugReportDialog-test.tsx.snap
@@ -0,0 +1,77 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`BugReportDialog renders when the config only allows local downloads: local-bug-reporter 1`] = `
+
+
+
+
+
+ Download logs
+
+
+
+
+ Reminder: Your browser is unsupported, so your experience may be unpredictable.
+
+
+ Debug logs contain application usage data including your username, the IDs or aliases of the rooms you have visited, which UI elements you last interacted with, and the usernames of other users. They do not contain messages.
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/test/unit-tests/components/views/elements/BugReportDialogButton-test.tsx b/test/unit-tests/components/views/elements/BugReportDialogButton-test.tsx
new file mode 100644
index 0000000000..34b2067653
--- /dev/null
+++ b/test/unit-tests/components/views/elements/BugReportDialogButton-test.tsx
@@ -0,0 +1,53 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { render } from "jest-matrix-react";
+import React, { type ComponentProps } from "react";
+import { afterEach } from "node:test";
+import userEvent from "@testing-library/user-event";
+
+import { BugReportDialogButton } from "../../../../../src/components/views/elements/BugReportDialogButton";
+import SdkConfig from "../../../../../src/SdkConfig";
+import Modal from "../../../../../src/Modal";
+import BugReportDialog from "../../../../../src/components/views/dialogs/BugReportDialog";
+import { BugReportEndpointURLLocal } from "../../../../../src/IConfigOptions";
+
+describe("", () => {
+ const getComponent = (props: ComponentProps = {}) =>
+ render();
+
+ afterEach(() => {
+ SdkConfig.reset();
+ jest.restoreAllMocks();
+ });
+
+ it("renders nothing if the bug reporter is disabled", () => {
+ SdkConfig.put({ bug_report_endpoint_url: undefined });
+ const { container } = getComponent({});
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders 'submit' label if a URL is configured", () => {
+ SdkConfig.put({ bug_report_endpoint_url: "https://example.org" });
+ const { container } = getComponent({});
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders 'download' label if 'loca' is configured", () => {
+ SdkConfig.put({ bug_report_endpoint_url: BugReportEndpointURLLocal });
+ const { container } = getComponent({});
+ expect(container).toMatchSnapshot();
+ });
+
+ it("passes through props to dialog", async () => {
+ SdkConfig.put({ bug_report_endpoint_url: BugReportEndpointURLLocal });
+ const spy = jest.spyOn(Modal, "createDialog");
+ const { getByRole } = getComponent({ label: "a label", error: "an error" });
+ await userEvent.click(getByRole("button"));
+ expect(spy).toHaveBeenCalledWith(BugReportDialog, { error: "an error", label: "a label" });
+ });
+});
diff --git a/test/unit-tests/components/views/elements/__snapshots__/BugReportDialogButton-test.tsx.snap b/test/unit-tests/components/views/elements/__snapshots__/BugReportDialogButton-test.tsx.snap
new file mode 100644
index 0000000000..9d3f37db66
--- /dev/null
+++ b/test/unit-tests/components/views/elements/__snapshots__/BugReportDialogButton-test.tsx.snap
@@ -0,0 +1,29 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[` renders 'download' label if 'loca' is configured 1`] = `
+
+
+
+`;
+
+exports[` renders 'submit' label if a URL is configured 1`] = `
+
+
+
+`;
diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/LabsUserSettingsTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/LabsUserSettingsTab-test.tsx.snap
index 5bcba0e2dc..dbc24462de 100644
--- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/LabsUserSettingsTab-test.tsx.snap
+++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/LabsUserSettingsTab-test.tsx.snap
@@ -66,7 +66,20 @@ exports[` renders settings marked as beta as beta cards 1
+ >
+
+ How can I create a video room?
+
+
+ Use the “+” button in the room section of the left panel.
+
+
+ Can I use text chat alongside the video call?
+
+
+ Yes, the chat timeline is displayed alongside the video.
+
+
{
beforeEach(() => {
jest.useFakeTimers();
({ client, room, alice, roomSession } = setUpClientRoomAndStores());
- SdkConfig.reset();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
+ SdkConfig.reset();
cleanUpClientRoomAndStores(client, room);
});
@@ -699,6 +700,24 @@ describe("ElementCall", () => {
SettingsStore.getValue = originalGetValue;
});
+ it.each([
+ [undefined, null],
+ [BugReportEndpointURLLocal, null],
+ ["other-value", "other-value"],
+ ])("passes rageshake URL through widget URL", async (configSetting, expectedValue) => {
+ // Test with the preference set to false
+ SdkConfig.put({
+ bug_report_endpoint_url: configSetting,
+ });
+ ElementCall.create(room);
+ const call1 = Call.get(room);
+ if (!(call1 instanceof ElementCall)) throw new Error("Failed to create call");
+
+ const urlParams1 = new URLSearchParams(new URL(call1.widget.url).hash.slice(1));
+ expect(urlParams1.get("rageshakeSubmitUrl")).toBe(expectedValue);
+ call1.destroy();
+ });
+
it("passes analyticsID and posthog params through widget URL", async () => {
SdkConfig.put({
posthog: {
diff --git a/test/unit-tests/submit-rageshake-test.ts b/test/unit-tests/submit-rageshake-test.ts
index 54392b58e5..e3f623ef6c 100644
--- a/test/unit-tests/submit-rageshake-test.ts
+++ b/test/unit-tests/submit-rageshake-test.ts
@@ -18,11 +18,13 @@ import {
import fetchMock from "@fetch-mock/jest";
import { getMockClientWithEventEmitter, mockClientMethodsCrypto, mockPlatformPeg } from "../test-utils";
-import { collectBugReport } from "../../src/rageshake/submit-rageshake";
+import { collectBugReport, downloadBugReport, submitFeedback } from "../../src/rageshake/submit-rageshake";
import SettingsStore from "../../src/settings/SettingsStore";
import { type ConsoleLogger } from "../../src/rageshake/rageshake";
import { type FeatureSettingKey, type SettingKey } from "../../src/settings/Settings.tsx";
import { SettingLevel } from "../../src/settings/SettingLevel.ts";
+import SdkConfig from "../../src/SdkConfig.ts";
+import { BugReportEndpointURLLocal } from "../../src/IConfigOptions.ts";
describe("Rageshakes", () => {
let mockClient: Mocked;
@@ -53,6 +55,10 @@ describe("Rageshakes", () => {
jest.spyOn(window, "matchMedia").mockReturnValue({ matches: false } as any);
});
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
describe("Basic Information", () => {
it("should include app version", async () => {
mockPlatformPeg({ getAppVersion: jest.fn().mockReturnValue("1.11.58") });
@@ -493,7 +499,7 @@ describe("Rageshakes", () => {
expect(settingsData.showHiddenEventsInTimeline).toEqual(true);
});
- it("should collect logs", async () => {
+ it("should collect logs for collectBugReport", async () => {
const mockConsoleLogger = {
flush: jest.fn(),
consume: jest.fn(),
@@ -511,6 +517,37 @@ describe("Rageshakes", () => {
}
});
+ it("should collect logs for downloadBugReport", async () => {
+ const mockConsoleLogger = {
+ flush: jest.fn(),
+ consume: jest.fn(),
+ warn: jest.fn(),
+ } as unknown as Mocked;
+ mockConsoleLogger.flush.mockReturnValue("line 1\nline 2\n");
+
+ const prevLogger = global.mx_rage_logger;
+ global.mx_rage_logger = mockConsoleLogger;
+ const mockElement = {
+ href: "",
+ download: "",
+ click: jest.fn(),
+ };
+ jest.spyOn(document, "createElement").mockReturnValue(mockElement as any);
+ jest.spyOn(document, "body", "get").mockReturnValue({
+ appendChild: jest.fn(),
+ removeChild: jest.fn(),
+ } as any);
+ try {
+ await downloadBugReport({ sendLogs: true });
+ } finally {
+ global.mx_rage_logger = prevLogger;
+ }
+ expect(document.createElement).toHaveBeenCalledWith("a");
+ expect(mockElement.href).toMatch(/^data:application\/octet-stream;base64,.+/);
+ expect(mockElement.download).toEqual("rageshake.tar");
+ expect(mockElement.click).toHaveBeenCalledWith();
+ });
+
it("should notify progress", () => {
const progressCallback = jest.fn();
@@ -518,4 +555,22 @@ describe("Rageshakes", () => {
expect(progressCallback).toHaveBeenCalled();
});
+
+ describe("submitFeedback", () => {
+ afterEach(() => {
+ SdkConfig.reset();
+ });
+ it("fails if the URL is not defined", async () => {
+ SdkConfig.put({ bug_report_endpoint_url: undefined });
+ await expect(() => submitFeedback("label", "comment")).rejects.toThrow(
+ "Bug report URL is not set or local",
+ );
+ });
+ it("fails if the URL is 'local'", async () => {
+ SdkConfig.put({ bug_report_endpoint_url: BugReportEndpointURLLocal });
+ await expect(() => submitFeedback("label", "comment")).rejects.toThrow(
+ "Bug report URL is not set or local",
+ );
+ });
+ });
});
diff --git a/test/unit-tests/utils/Feedback-test.ts b/test/unit-tests/utils/Feedback-test.ts
index a0e04df2e8..089b1cefc1 100644
--- a/test/unit-tests/utils/Feedback-test.ts
+++ b/test/unit-tests/utils/Feedback-test.ts
@@ -6,33 +6,54 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
-import { mocked } from "jest-mock";
-
import SdkConfig from "../../../src/SdkConfig";
import { shouldShowFeedback } from "../../../src/utils/Feedback";
import SettingsStore from "../../../src/settings/SettingsStore";
+import { UIFeature } from "../../../src/settings/UIFeature";
+import { BugReportEndpointURLLocal } from "../../../src/IConfigOptions";
-jest.mock("../../../src/SdkConfig");
-jest.mock("../../../src/settings/SettingsStore");
+const realGetValue = SettingsStore.getValue;
describe("shouldShowFeedback", () => {
+ afterEach(() => {
+ SdkConfig.reset();
+ jest.restoreAllMocks();
+ });
+
it("should return false if bug_report_endpoint_url is falsey", () => {
- mocked(SdkConfig).get.mockReturnValue({
- bug_report_endpoint_url: null,
+ SdkConfig.put({
+ bug_report_endpoint_url: undefined,
});
- expect(shouldShowFeedback()).toBeFalsy();
+ expect(shouldShowFeedback()).toEqual(false);
+ });
+
+ it("should return false if bug_report_endpoint_url is 'test'", () => {
+ SdkConfig.put({
+ bug_report_endpoint_url: BugReportEndpointURLLocal,
+ });
+ expect(shouldShowFeedback()).toEqual(false);
});
it("should return false if UIFeature.Feedback is disabled", () => {
- mocked(SettingsStore).getValue.mockReturnValue(false);
- expect(shouldShowFeedback()).toBeFalsy();
+ jest.spyOn(SettingsStore, "getValue").mockImplementation((key, ...params) => {
+ if (key === UIFeature.Feedback) {
+ return false;
+ }
+ return realGetValue(key, ...params);
+ });
+ expect(shouldShowFeedback()).toEqual(false);
});
it("should return true if bug_report_endpoint_url is set and UIFeature.Feedback is true", () => {
- mocked(SdkConfig).get.mockReturnValue({
+ SdkConfig.put({
bug_report_endpoint_url: "https://rageshake.server",
});
- mocked(SettingsStore).getValue.mockReturnValue(true);
- expect(shouldShowFeedback()).toBeTruthy();
+ jest.spyOn(SettingsStore, "getValue").mockImplementation((key, ...params) => {
+ if (key === UIFeature.Feedback) {
+ return true;
+ }
+ return realGetValue(key, ...params);
+ });
+ expect(shouldShowFeedback()).toEqual(true);
});
});
diff --git a/test/unit-tests/vector/rageshakesetup-test.ts b/test/unit-tests/vector/rageshakesetup-test.ts
new file mode 100644
index 0000000000..81b5064c71
--- /dev/null
+++ b/test/unit-tests/vector/rageshakesetup-test.ts
@@ -0,0 +1,65 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import fetchMock from "@fetch-mock/jest";
+
+import type { Mocked } from "jest-mock";
+import type { ConsoleLogger } from "../../../src/rageshake/rageshake";
+import SdkConfig from "../../../src/SdkConfig";
+import "../../../src/vector/rageshakesetup";
+import { BugReportEndpointURLLocal } from "../../../src/IConfigOptions";
+
+const RAGESHAKE_URL = "https://logs.example.org/logtome";
+
+describe("mxSendRageshake", () => {
+ let prevLogger: ConsoleLogger;
+ beforeEach(() => {
+ fetchMock.mockGlobal();
+ SdkConfig.put({ bug_report_endpoint_url: RAGESHAKE_URL });
+ fetchMock.postOnce(RAGESHAKE_URL, { status: 200, body: {} });
+
+ const mockConsoleLogger = {
+ flush: jest.fn(),
+ consume: jest.fn(),
+ warn: jest.fn(),
+ } as unknown as Mocked;
+ prevLogger = global.mx_rage_logger;
+ mockConsoleLogger.flush.mockReturnValue("line 1\nline 2\n");
+ global.mx_rage_logger = mockConsoleLogger;
+ });
+
+ afterEach(() => {
+ global.mx_rage_logger = prevLogger;
+ jest.restoreAllMocks();
+ fetchMock.unmockGlobal();
+ SdkConfig.reset();
+ });
+
+ it("Does not send a rageshake if the URL is not configured", async () => {
+ SdkConfig.put({ bug_report_endpoint_url: undefined });
+ await window.mxSendRageshake("test");
+ expect(fetchMock).not.toHaveFetched();
+ });
+
+ it.each(["", " ", undefined, null])("Does not send a rageshake if text is '%s'", async (text) => {
+ await window.mxSendRageshake(text as string);
+ expect(fetchMock).not.toHaveFetched();
+ });
+
+ it("Sends a rageshake via URL", async () => {
+ await window.mxSendRageshake("Hello world");
+ expect(fetchMock).toHaveFetched(RAGESHAKE_URL);
+ });
+
+ it("Provides a rageshake locally", async () => {
+ SdkConfig.put({ bug_report_endpoint_url: BugReportEndpointURLLocal });
+ const urlSpy = jest.spyOn(URL, "createObjectURL");
+ await window.mxSendRageshake("Hello world");
+ expect(fetchMock).not.toHaveFetched(RAGESHAKE_URL);
+ expect(urlSpy).toHaveBeenCalledTimes(1);
+ });
+});