diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index ec68f854fe..2122d3af23 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"; @@ -56,6 +58,7 @@ import { createThumbnail } from "./utils/image-media"; import { attachMentions, attachRelation } from "./utils/messages.ts"; import { doMaybeLocalRoomAction } from "./utils/local-room"; 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 @@ -665,16 +668,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 canUpgrade = + typeof unwrappedError.data["org.matrix.msc4335.can_upgrade"] === "boolean" + ? unwrappedError.data["org.matrix.msc4335.can_upgrade"] + : false; + Modal.createDialog(MSC4335UserLimitExceededDialog, { + title: _t("upload_failed_title"), + error: { + infoUri: unwrappedError.data["org.matrix.msc4335.info_uri"], + canUpgrade, + }, + }); + } 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 0000000000..1217714610 --- /dev/null +++ b/src/components/views/dialogs/MSC4335UserLimitExceededDialog.tsx @@ -0,0 +1,79 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX, useMemo } from "react"; + +import { _t } from "../../../languageHandler"; +import BaseDialog from "./BaseDialog"; +import AccessibleButton from "../elements/AccessibleButton"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; + +interface MSC4335Data { + infoUri: string; + canUpgrade: boolean; +} + +interface IProps { + onFinished?: (success?: boolean) => void; + title?: string; + error: MSC4335Data; +} + +export default function MSC4335UserLimitExceededDialog({ onFinished: _onFinished, title, error }: IProps): JSX.Element { + function onFinished(success?: boolean): void { + _onFinished?.(success); + }; + + function onClick(): void { + // noop as using href + }; + + const matrixClient = useMatrixClientContext(); + const isMatrixDotOrg = useMemo(() => matrixClient?.getDomain() === "matrix.org", [matrixClient]); + + return ( + +
+ {error.canUpgrade + ? (isMatrixDotOrg ? _t( + "msc4335_matrix_org_user_limit_exceeded|soft_limit", + ) : _t("msc4335_user_limit_exceeded|soft_limit")) + : (isMatrixDotOrg ? _t( + "msc4335_matrix_org_user_limit_exceeded|hard_limit", + ) : _t("msc4335_user_limit_exceeded|hard_limit")) + } +
+
+
+ + {error.canUpgrade + ? (isMatrixDotOrg ? _t( + "msc4335_matrix_org_user_limit_exceeded|soft_limit_button", + ) : _t("msc4335_user_limit_exceeded|soft_limit_button")) + : (isMatrixDotOrg ? _t( + "msc4335_matrix_org_user_limit_exceeded|hard_limit_button", + ) : _t("msc4335_user_limit_exceeded|hard_limit_button")) + } + +
+
+
+ ); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c3b2e8371d..2d2b6b25d5 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1708,6 +1708,20 @@ "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_matrix_org_user_limit_exceeded": { + "hard_limit": "You have reached the usage limits of your plan on the matrix.org homeserver.", + "hard_limit_button": "Learn more", + "soft_limit": "You have reached the usage limits of your Free plan on the matrix.org homeserver. You can upgrade to Premium to increase the limits and to support the work of the Matrix.org Foundation.", + "soft_limit_button": "Get Premium", + "title": "Usage limit exceeded" + }, + "msc4335_user_limit_exceeded": { + "hard_limit": "You have exceeded a usage limit imposed by your homeserver.", + "hard_limit_button": "Learn more", + "soft_limit": "You have exceeded a usage limit imposed by your homeserver. You may be able to increase your limit.", + "soft_limit_button": "Learn more", + "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 51bb73fbfd..262420d894 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,55 @@ 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", + canUpgrade: false, + }, + }), + ); + 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.can_upgrade": true, + }), + ); + 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", + canUpgrade: true, + }, + }), + ); + dialogSpy.mockRestore(); + }); }); describe("getCurrentUploads", () => {