diff --git a/docs/config.md b/docs/config.md index 0cb3c702a3..c8773544fe 100644 --- a/docs/config.md +++ b/docs/config.md @@ -407,6 +407,7 @@ If you run your own rageshake server to collect bug reports, the following optio 1. `bug_report_endpoint_url`: URL for where to submit rageshake logs to. Rageshakes include feedback submissions and bug reports. When not present in the config, the app will disable all rageshake functionality. Set to `https://rageshakes.element.io/api/submit` to submit rageshakes to us, or use your own rageshake server. + You may also set the value to `"local"` if you wish to only store logs locally, in order to download them for debugging. 2. `uisi_autorageshake_app`: If a user has enabled the "automatically send debug logs on decryption errors" flag, this option will be sent alongside the rageshake so the rageshake server can filter them by app name. By default, this will be `element-auto-uisi` (in contrast to other rageshakes submitted by the app, which use `element-web`). diff --git a/playwright/e2e/feedback/rageshakes.spec.ts b/playwright/e2e/feedback/rageshakes.spec.ts new file mode 100644 index 0000000000..58476f04ac --- /dev/null +++ b/playwright/e2e/feedback/rageshakes.spec.ts @@ -0,0 +1,139 @@ +/* +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 { test, expect } from "../../element-web-test"; + +function formDataParser(data: string, contentType: string | null): Record { + const [, boundary] = + contentType + ?.split(";") + .map((v) => v.trim()) + .find((v) => v.startsWith("boundary=")) + ?.split("=") ?? []; + if (!boundary) { + throw Error("No boundary found in form data request"); + } + const dataMap: Record = {}; + for (const dataPart of data.split(boundary).map((p) => p.trim())) { + const lines = dataPart.split("\r\n"); + const fieldName = lines[0].match(/name="([^"]+)"/)?.[1]; + if (!fieldName) { + continue; + } + const data = lines.slice(1, -1).join("\n").trim(); + dataMap[fieldName] = data; + } + return dataMap; +} + +test.describe("Rageshakes", () => { + test.describe("visible when enabled", () => { + test.use({ + config: { + // Enable this just so the options show up. + bug_report_endpoint_url: "https://example.org/bug-report-place", + }, + }); + test("should be able to open bug report dialog via slash command", async ({ page, app, user }) => { + await app.client.createRoom({ name: "Test room" }); + await app.viewRoomByName("Test room"); + const composer = app.getComposer().locator("[contenteditable]"); + await composer.fill("/rageshake"); + await composer.press("Enter"); + await expect(page.getByRole("dialog", { name: "Submit debug logs" })).toBeVisible(); + }); + + test("should be able to open bug report dialog via feedback dialog", async ({ page, app, user }) => { + const menu = await app.openUserMenu(); + await menu.getByRole("menuitem", { name: "Feedback" }).click(); + const feedbackDialog = page.getByRole("dialog", { name: "Feedback" }); + await feedbackDialog.getByRole("button", { name: "debug logs" }).click(); + await expect(page.getByRole("dialog", { name: "Submit debug logs" })).toBeVisible(); + }); + test("should be able to open bug report dialog via Settings", async ({ page, app, user }) => { + const settings = await app.settings.openUserSettings("Help & About"); + await settings.getByRole("button", { name: "Submit debug logs" }).click(); + // Playwright can't see the dialog when both the settings and bug report dialogs are open, so key off heading. + await expect(page.locator(".mx_BugReportDialog")).toBeVisible(); + }); + }); + + test.describe("hidden when disabled", () => { + test("should NOT be able to open bug report dialog via slash command", async ({ page, app, user }) => { + await app.client.createRoom({ name: "Test room" }); + await app.viewRoomByName("Test room"); + const composer = app.getComposer().locator("[contenteditable]"); + await composer.fill("/rageshake"); + await composer.press("Enter"); + await expect(page.getByRole("dialog", { name: "Unknown command" })).toBeVisible(); + }); + + test("should NOT be able to open bug report dialog via feedback dialog", async ({ page, app, user }) => { + const menu = await app.openUserMenu(); + await expect(menu.getByRole("menuitem", { name: "Feedback" })).not.toBeVisible(); + }); + test("should NOT be able to open bug report dialog via Settings", async ({ page, app, user }) => { + const settings = await app.settings.openUserSettings("Help & About"); + await expect(settings.getByRole("menuitem", { name: "Submit debug logs" })).not.toBeVisible(); + }); + }); + + test.describe("via bug report endpoint", () => { + test.use({ + config: { + bug_report_endpoint_url: "http://example.org/bug-report-server", + }, + }); + + test("should be able to rageshake to a URL", { tag: "@screenshot" }, async ({ page, app, user }) => { + await page.route("http://example.org/bug-report-server", async (route, request) => { + if (request.method() !== "POST") { + throw Error("Expected POST"); + } + const fields = formDataParser(request.postData(), await request.headerValue("Content-Type")); + expect(fields.text).toEqual( + "These are some notes\n\nIssue: https://github.com/element-hq/element-web/12345", + ); + expect(fields.app).toEqual("element-web"); + expect(fields.user_id).toEqual(user.userId); + expect(fields.device_id).toEqual(user.deviceId); + // We don't check the logs contents, but we'd like for there to be a log. + expect(fields["compressed-log"]).toBeDefined(); + return route.fulfill({ json: {}, status: 200 }); + }); + + const settings = await app.settings.openUserSettings("Help & About"); + await settings.getByRole("button", { name: "Submit debug logs" }).click(); + const dialog = page.locator(".mx_BugReportDialog"); + await dialog + .getByRole("textbox", { name: "GitHub issue" }) + .fill("https://github.com/element-hq/element-web/12345"); + await dialog.getByRole("textbox", { name: "Notes" }).fill("These are some notes"); + await expect(dialog).toMatchScreenshot("rageshake_via_url.png"); + await dialog.getByRole("button", { name: "Send logs" }).click(); + await expect(page.getByRole("heading", { name: "Logs sent" })).toBeVisible(); + }); + }); + test.describe("via local download", () => { + test.use({ + config: { + bug_report_endpoint_url: "local", + }, + }); + + test("should be able to rageshake to local download", { tag: "@screenshot" }, async ({ page, app, user }) => { + const settings = await app.settings.openUserSettings("Help & About"); + await settings.getByRole("button", { name: "Download logs" }).click(); + const dialog = page.locator(".mx_BugReportDialog"); + await expect(dialog).toMatchScreenshot("rageshake_locally.png"); + const downloadPromise = page.waitForEvent("download"); + await dialog.getByRole("button", { name: "Download logs" }).click(); + const download = await downloadPromise; + await download.cancel(); + }); + }); +}); diff --git a/playwright/snapshots/feedback/rageshakes.spec.ts/rageshake-locally-linux.png b/playwright/snapshots/feedback/rageshakes.spec.ts/rageshake-locally-linux.png new file mode 100644 index 0000000000..c6a06a5035 Binary files /dev/null and b/playwright/snapshots/feedback/rageshakes.spec.ts/rageshake-locally-linux.png differ diff --git a/playwright/snapshots/feedback/rageshakes.spec.ts/rageshake-via-url-linux.png b/playwright/snapshots/feedback/rageshakes.spec.ts/rageshake-via-url-linux.png new file mode 100644 index 0000000000..01a70ee5c4 Binary files /dev/null and b/playwright/snapshots/feedback/rageshakes.spec.ts/rageshake-via-url-linux.png differ diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 9d43a13ca4..19bc221444 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -80,7 +80,7 @@ declare global { function setTimeout(handler: TimerHandler, timeout: number, ...arguments: any[]): number; interface Window { - mxSendRageshake: (text: string, withLogs?: boolean) => void; + mxSendRageshake: (text: string, withLogs?: boolean) => Promise; matrixLogger: typeof logger; matrixChat?: MatrixChat; mxSendSentryReport: (userText: string, issueUrl: string, error: Error) => Promise; diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index a0ff9f0d6f..8dcf5383eb 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -17,6 +17,12 @@ import { type ValidatedServerConfig } from "./utils/ValidatedServerConfig"; /* eslint-disable camelcase */ /* eslint @typescript-eslint/naming-convention: ["error", { "selector": "property", "format": ["snake_case"] } ] */ +/** + * Bug reports are enabled but must only be locally + * downloadable. + */ +export const BugReportEndpointURLLocal = "local"; + // see element-web config.md for non-developer docs export interface IConfigOptions { // dev note: while true that this is arbitrary JSON, it's valuable to enforce that all @@ -98,7 +104,10 @@ export interface IConfigOptions { show_labs_settings: boolean; features?: Record; // - bug_report_endpoint_url?: string; // omission disables bug reporting + /** + * Bug report endpoint URL. "local" means the logs should not be uploaded. + */ + bug_report_endpoint_url?: typeof BugReportEndpointURLLocal | string; // omission disables bug reporting uisi_autorageshake_app?: string; // defaults to "element-auto-uisi" sentry?: { dsn: string; diff --git a/src/components/views/dialogs/BugReportDialog.tsx b/src/components/views/dialogs/BugReportDialog.tsx index 83e6da8dfe..2977570957 100644 --- a/src/components/views/dialogs/BugReportDialog.tsx +++ b/src/components/views/dialogs/BugReportDialog.tsx @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 The Matrix.org Foundation C.I.C. @@ -10,7 +11,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { type JSX, type ReactNode } from "react"; -import { Link } from "@vector-im/compound-web"; +import { Link, Text } from "@vector-im/compound-web"; import SdkConfig from "../../../SdkConfig"; import Modal from "../../../Modal"; @@ -26,6 +27,7 @@ import { sendSentryReport } from "../../../sentry"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { getBrowserSupport } from "../../../SupportedBrowser"; +import { BugReportEndpointURLLocal } from "../../../IConfigOptions"; export interface BugReportDialogProps { onFinished: (success: boolean) => void; @@ -48,6 +50,7 @@ interface IState { export default class BugReportDialog extends React.Component { private unmounted: boolean; private issueRef: React.RefObject; + private readonly isLocalOnly: boolean; public constructor(props: BugReportDialogProps) { super(props); @@ -65,6 +68,8 @@ export default class BugReportDialog extends React.Component support === false)) || !getBrowserSupport() ) { - warning = ( -

- {_t("bug_reporting|unsupported_browser")} -

- ); + warning = {_t("bug_reporting|unsupported_browser")}; } return (
{warning} -

{_t("bug_reporting|description")}

-

- - {_t( - "bug_reporting|before_submitting", - {}, - { - a: (sub) => ( - - {sub} - - ), - }, - )} - -

+ {_t("bug_reporting|description")} + {this.isLocalOnly ? ( + <>{this.state.downloadProgress && {this.state.downloadProgress} ...} + ) : ( + <> + + {_t( + "bug_reporting|before_submitting", + {}, + { + a: (sub) => ( + + {sub} + + ), + }, + )} + -
- - {_t("bug_reporting|download_logs")} - - {this.state.downloadProgress && {this.state.downloadProgress} ...} -
+
+ + {_t("bug_reporting|download_logs")} + + {this.state.downloadProgress && {this.state.downloadProgress} ...} +
- - - {progress} - {error} + + + {progress} + {error} + + )}
); 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`] = ` +
+
+
+ > +

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