diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index e608b3470a9..7206ff1dfd2 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -18,6 +18,7 @@ import { type UploadOpts, type UploadProgress, THREAD_RELATION_TYPE, + MatrixError, } from "matrix-js-sdk/src/matrix"; import { type ImageInfo, @@ -31,6 +32,7 @@ import encrypt from "matrix-encrypt-attachment"; import extractPngChunks from "png-chunks-extract"; import { logger } from "matrix-js-sdk/src/logger"; import { removeElement } from "matrix-js-sdk/src/utils"; +import { type ReactNode } from "react"; import dis from "./dispatcher/dispatcher"; import { _t } from "./languageHandler"; @@ -57,6 +59,7 @@ import { attachMentions, attachRelation } from "./components/views/rooms/SendMes import { doMaybeLocalRoomAction } from "./utils/local-room"; import { SdkContextClass } from "./contexts/SDKContext"; import { blobIsAnimated } from "./utils/Image.ts"; +import MSC4335UserLimitExceededDialog from "./components/views/dialogs/MSC4335UserLimitExceededDialog.tsx"; // scraped out of a macOS hidpi (5660ppm) screenshot png // 5669 px (x-axis) , 5669 px (y-axis) , per metre @@ -653,16 +656,36 @@ export default class ContentMessages { } if (!upload.cancelled) { - let desc = _t("upload_failed_generic", { fileName: upload.fileName }); - if (unwrappedError instanceof HTTPError && unwrappedError.httpStatus === 413) { - desc = _t("upload_failed_size", { - fileName: upload.fileName, + if ( + unwrappedError instanceof MatrixError && + unwrappedError.errcode === "ORG.MATRIX.MSC4335_USER_LIMIT_EXCEEDED" && + typeof unwrappedError.data["org.matrix.msc4335.info_uri"] === "string" + ) { + // Support for experimental MSC4335 M_USER_LIMIT_EXCEEDED error + const softLimit = + typeof unwrappedError.data["org.matrix.msc4335.soft_limit"] === "boolean" + ? unwrappedError.data["org.matrix.msc4335.soft_limit"] + : false; + Modal.createDialog(MSC4335UserLimitExceededDialog, { + title: _t("upload_failed_title"), + error: { + infoUri: unwrappedError.data["org.matrix.msc4335.info_uri"], + increaseUri: softLimit ? unwrappedError.data["org.matrix.msc4335.increase_uri"] : undefined, + }, + }); + } else { + let desc: ReactNode = _t("upload_failed_generic", { fileName: upload.fileName }); + if (unwrappedError instanceof HTTPError && unwrappedError.httpStatus === 413) { + desc = _t("upload_failed_size", { + fileName: upload.fileName, + }); + } + Modal.createDialog(ErrorDialog, { + title: _t("upload_failed_title"), + description: desc, }); } - Modal.createDialog(ErrorDialog, { - title: _t("upload_failed_title"), - description: desc, - }); + dis.dispatch({ action: Action.UploadFailed, upload, error }); } } finally { diff --git a/src/components/views/dialogs/MSC4335UserLimitExceededDialog.tsx b/src/components/views/dialogs/MSC4335UserLimitExceededDialog.tsx new file mode 100644 index 00000000000..6dcf370ddca --- /dev/null +++ b/src/components/views/dialogs/MSC4335UserLimitExceededDialog.tsx @@ -0,0 +1,96 @@ +/* +Copyright 2025 New Vector 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 from "react"; + +import { _t } from "../../../languageHandler"; +import BaseDialog from "./BaseDialog"; +import AccessibleButton from "../elements/AccessibleButton"; + +interface MSC4335Data { + infoUri: string; + increaseUri?: string; +} + +interface IProps { + onFinished?: (success?: boolean) => void; + title?: string; + error: MSC4335Data; +} + +interface IState { + onFinished: (success: boolean) => void; +} + +export default class MSC4335UserLimitExceededDialog extends React.Component { + private onFinished = (success?: boolean): void => { + this.props.onFinished?.(success); + }; + private onClick = (): void => { + // noop as using href + }; + + public render(): React.ReactNode { + const softLimit = !!this.props.error.increaseUri; + + return ( + +
+ {softLimit + ? _t( + "msc4335_user_limit_exceeded|soft_limit", + {}, + { + a: (sub) => ( + + {sub} + + ), + }, + ) + : _t("msc4335_user_limit_exceeded|hard_limit")} +
+
+
+ {softLimit && ( + + {_t("msc4335_user_limit_exceeded|learn_more")} + + )} + + {softLimit + ? _t("msc4335_user_limit_exceeded|increase_limit") + : _t("msc4335_user_limit_exceeded|learn_more")} + +
+
+
+ ); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 00ac3eb1168..92c8dbc3657 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1676,6 +1676,13 @@ "toast_description": "%(brand)s is experimental on a mobile web browser. For a better experience and the latest features, use our free native app.", "toast_title": "Use app for a better experience" }, + "msc4335_user_limit_exceeded": { + "hard_limit": "You have exceeded a limit imposed by your homeserver.", + "increase_limit": "Increase limit", + "learn_more": "Learn more", + "soft_limit": "You have exceeded a limit imposed by your homeserver. The limit can be increased.", + "title": "Account limit exceeded" + }, "name_and_id": "%(name)s (%(userId)s)", "no_more_results": "No more results", "notif_panel": { diff --git a/test/unit-tests/ContentMessages-test.ts b/test/unit-tests/ContentMessages-test.ts index 51bb73fbfdc..51c8495cfb3 100644 --- a/test/unit-tests/ContentMessages-test.ts +++ b/test/unit-tests/ContentMessages-test.ts @@ -24,6 +24,7 @@ import { BlurhashEncoder } from "../../src/BlurhashEncoder"; import Modal from "../../src/Modal"; import ErrorDialog from "../../src/components/views/dialogs/ErrorDialog"; import { _t } from "../../src/languageHandler"; +import MSC4335UserLimitExceededDialog from "../../src/components/views/dialogs/MSC4335UserLimitExceededDialog"; jest.mock("matrix-encrypt-attachment", () => ({ encryptAttachment: jest.fn().mockResolvedValue({}) })); @@ -317,6 +318,56 @@ describe("ContentMessages", () => { ); dialogSpy.mockRestore(); }); + + it("handles MSC4335 M_USER_LIMIT_EXCEEDED error with hard limit", async () => { + mocked(client.uploadContent).mockRejectedValue( + new MatrixError({ + "errcode": "ORG.MATRIX.MSC4335_USER_LIMIT_EXCEEDED", + "error": "User limit exceeded", + "org.matrix.msc4335.info_uri": "https://example.com/info", + }), + ); + const file = new File([], "fileName", { type: "image/jpeg" }); + const dialogSpy = jest.spyOn(Modal, "createDialog"); + await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); + expect(dialogSpy).toHaveBeenCalledWith( + MSC4335UserLimitExceededDialog, + expect.objectContaining({ + title: _t("upload_failed_title"), + error: { + infoUri: "https://example.com/info", + increaseUri: undefined, + }, + }), + ); + dialogSpy.mockRestore(); + }); + + it("handles MSC4335 M_USER_LIMIT_EXCEEDED error with soft limit", async () => { + mocked(client.uploadContent).mockRejectedValue( + new MatrixError({ + "errcode": "ORG.MATRIX.MSC4335_USER_LIMIT_EXCEEDED", + "error": "User limit exceeded", + "org.matrix.msc4335.info_uri": "https://example.com/info", + "org.matrix.msc4335.soft_limit": true, + "org.matrix.msc4335.increase_uri": "https://example.com/increase", + }), + ); + const file = new File([], "fileName", { type: "image/jpeg" }); + const dialogSpy = jest.spyOn(Modal, "createDialog"); + await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); + expect(dialogSpy).toHaveBeenCalledWith( + MSC4335UserLimitExceededDialog, + expect.objectContaining({ + title: _t("upload_failed_title"), + error: { + infoUri: "https://example.com/info", + increaseUri: "https://example.com/increase", + }, + }), + ); + dialogSpy.mockRestore(); + }); }); describe("getCurrentUploads", () => {