diff --git a/packages/shared-components/.storybook/test-runner.ts b/packages/shared-components/.storybook/test-runner.ts index 5f0748115a..92f886a1da 100644 --- a/packages/shared-components/.storybook/test-runner.ts +++ b/packages/shared-components/.storybook/test-runner.ts @@ -20,7 +20,7 @@ const config: TestRunnerConfig = { // If you want to take screenshot of multiple browsers, use // page.context().browser().browserType().name() to get the browser name to prefix the file name - const image = await page.screenshot(); + const image = await page.screenshot({ animations: "disabled" }); expect(image).toMatchImageSnapshot({ customSnapshotsDir, customSnapshotIdentifier: `${context.id}-${process.platform}`, diff --git a/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-connection-lost-linux.png b/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-connection-lost-linux.png new file mode 100644 index 0000000000..59e59e4461 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-connection-lost-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-consent-link-linux.png b/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-consent-link-linux.png new file mode 100644 index 0000000000..f4be9ccdc1 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-consent-link-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-local-room-retry-linux.png b/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-local-room-retry-linux.png new file mode 100644 index 0000000000..ffe9050a23 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-local-room-retry-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-resource-limit-linux.png b/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-resource-limit-linux.png new file mode 100644 index 0000000000..7ccad17abb Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-resource-limit-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-unsent-messages-linux.png b/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-unsent-messages-linux.png new file mode 100644 index 0000000000..d30f9b0761 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-unsent-messages-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-unsent-messages-sending-linux.png b/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-unsent-messages-sending-linux.png new file mode 100644 index 0000000000..0d4c8f2885 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-roomstatusbarview--with-unsent-messages-sending-linux.png differ diff --git a/packages/shared-components/src/composer/Banner/Banner.tsx b/packages/shared-components/src/composer/Banner/Banner.tsx index 7781442ed9..377e18ac12 100644 --- a/packages/shared-components/src/composer/Banner/Banner.tsx +++ b/packages/shared-components/src/composer/Banner/Banner.tsx @@ -12,6 +12,7 @@ import React, { type ReactNode, type PropsWithChildren, useMemo, + type HTMLAttributes, } from "react"; import { Button } from "@vector-im/compound-web"; import CheckCircleIcon from "@vector-im/compound-design-tokens/assets/web/icons/check-circle"; @@ -32,8 +33,6 @@ interface BannerProps { */ avatar?: React.ReactNode; - className?: string; - /** * Actions presented to the user in the right-hand side of the banner alongside the dismiss button. */ @@ -60,21 +59,21 @@ export function Banner({ actions, onClose, ...props -}: PropsWithChildren): ReactElement { +}: PropsWithChildren>): ReactElement { const classes = classNames(styles.banner, className); - const icon = useMemo(() => { + const icon = useMemo((): ReactElement => { switch (type) { case "critical": - return ; + return ; case "info": - return ; + return ; case "success": - return ; + return ; default: - return ; + return ; } - }, [type, props]); + }, [type]); return (
diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index d5521dfcf8..ce72683f8a 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -8,6 +8,7 @@ "explore_rooms": "Explore rooms", "pause": "Pause", "play": "Play", + "retry": "Retry", "search": "Search" }, "left_panel": { @@ -15,9 +16,24 @@ }, "room": { "status_bar": { - "history_visible": "This room has been configured so that new members can read history. Learn More" + "delete_all": "Delete all", + "exceeded_resource_limit_description": "Please contact your service administrator to continue using the service.", + "exceeded_resource_limit_title": "Your message wasn't sent because this homeserver has exceeded a resource limit.", + "failed_to_create_room_title": "Could not start a chat with this user", + "history_visible": "This room has been configured so that new members can read history. Learn More", + "homeserver_blocked_title": "Your message wasn't sent because this homeserver has been blocked by its administrator.", + "monthly_user_limit_reached_title": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit.", + "requires_consent_agreement_title": "You can't send any messages until you review and agree to our terms and conditions.", + "retry_all": "Retry all", + "select_messages_to_retry": "You can select all or individual messages to retry or delete", + "server_connectivity_lost_description": "Sent messages will be stored until your connection has returned.", + "server_connectivity_lost_title": "Connectivity to the server has been lost.", + "some_messages_not_sent": "Some of your messages have not been sent" } }, + "terms": { + "tac_button": "Review terms and conditions" + }, "time": { "about_day_ago": "about a day ago", "about_hour_ago": "about an hour ago", diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 346f92ef35..7b9162a3f7 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -17,6 +17,7 @@ export * from "./event-tiles/TextualEventView"; export * from "./message-body/MediaBody"; export * from "./pill-input/Pill"; export * from "./pill-input/PillInput"; +export * from "./room/RoomStatusBar"; export * from "./rich-list/RichItem"; export * from "./rich-list/RichList"; export * from "./room-list/RoomListSearchView"; diff --git a/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.module.css b/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.module.css new file mode 100644 index 0000000000..b0e85609b6 --- /dev/null +++ b/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.module.css @@ -0,0 +1,11 @@ +.container { + color: var(--cpd-color-text-primary); + svg { + /* Ensure button icons are primary too */ + color: var(--cpd-color-text-primary) !important; + } +} + +.description { + color: var(--cpd-color-text-secondary); +} diff --git a/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.stories.tsx b/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.stories.tsx new file mode 100644 index 0000000000..38f8d86f1d --- /dev/null +++ b/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.stories.tsx @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 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 { type Meta, type StoryFn } from "@storybook/react-vite"; +import React, { type JSX } from "react"; +import { fn } from "storybook/test"; + +import { useMockedViewModel } from "../../useMockedViewModel"; +import { + RoomStatusBarState, + RoomStatusBarView, + type RoomStatusBarViewActions, + type RoomStatusBarViewSnapshot, +} from "./RoomStatusBarView"; + +type RoomStatusBarProps = RoomStatusBarViewSnapshot & RoomStatusBarViewActions; + +const RoomStatusBarViewWrapper = ({ + onResendAllClick, + onDeleteAllClick, + onRetryRoomCreationClick, + onTermsAndConditionsClicked, + ...rest +}: RoomStatusBarProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onResendAllClick, + onDeleteAllClick, + onRetryRoomCreationClick, + onTermsAndConditionsClicked, + }); + return ; +}; + +export default { + title: "room/RoomStatusBarView", + component: RoomStatusBarViewWrapper, + tags: ["autodocs"], + argTypes: {}, + args: { + onResendAllClick: fn(), + onDeleteAllClick: fn(), + onRetryRoomCreationClick: fn(), + onTermsAndConditionsClicked: fn(), + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +/** + * Rendered when the client has lost connection with the server. + */ +export const WithConnectionLost = Template.bind({}); +WithConnectionLost.args = { + state: RoomStatusBarState.ConnectionLost, +}; + +/** + * Rendered when the client needs the user to consent to some terms and conditions before + * they can perform any room actions. + */ +export const WithConsentLink = Template.bind({}); +WithConsentLink.args = { + state: RoomStatusBarState.NeedsConsent, + consentUri: "#example", +}; + +/** + * Rendered when the server has hit a usage limit and is forbidding the user from performing + * any actions in the room. There is an optional parameter to link to an admin to contact. + */ +export const WithResourceLimit = Template.bind({}); +WithResourceLimit.args = { + state: RoomStatusBarState.ResourceLimited, + resourceLimit: "hs_disabled", + adminContactHref: "#example", +}; + +/** + * Rendered when the client has some unsent messages in the room, stored locally. + */ +export const WithUnsentMessages = Template.bind({}); +WithUnsentMessages.args = { + state: RoomStatusBarState.UnsentMessages, + isResending: false, +}; + +/** + * Rendered when the client has some unsent messages in the room, stored locally and is + * trying to send them. + */ +export const WithUnsentMessagesSending = Template.bind({}); +WithUnsentMessagesSending.args = { + state: RoomStatusBarState.UnsentMessages, + isResending: true, +}; +/** + * Rendered when a local room has failed to be created. + */ +export const WithLocalRoomRetry = Template.bind({}); +WithLocalRoomRetry.args = { + state: RoomStatusBarState.LocalRoomFailed, +}; diff --git a/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.test.tsx b/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.test.tsx new file mode 100644 index 0000000000..22b9204ab0 --- /dev/null +++ b/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.test.tsx @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 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 from "react"; +import { render } from "jest-matrix-react"; +import { composeStories } from "@storybook/react-vite"; +import userEvent from "@testing-library/user-event"; + +import * as stories from "./RoomStatusBarView.stories.tsx"; + +const { WithConnectionLost, WithConsentLink, WithResourceLimit, WithUnsentMessages, WithLocalRoomRetry } = + composeStories(stories); + +describe("RoomStatusBarView", () => { + it("renders connection lost", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("renders resource limit error", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("renders consent link", () => { + const { container, getByRole } = render(); + expect(container).toMatchSnapshot(); + + const button = getByRole("link"); + expect(button.getAttribute("href")).toEqual("#example"); + }); + it("renders unsent messages", async () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + it("renders unsent messages and deletes all", async () => { + const onDeleteAllClick = jest.fn(); + const { container, getByRole } = render(); + expect(container).toMatchSnapshot(); + + const button = getByRole("button", { name: "Delete all" }); + await userEvent.click(button); + expect(onDeleteAllClick).toHaveBeenCalled(); + }); + it("renders unsent messages and resends all", async () => { + const onResendAllClick = jest.fn(); + const { container, getByRole } = render(); + expect(container).toMatchSnapshot(); + + const button = getByRole("button", { name: "Retry all" }); + await userEvent.click(button); + expect(onResendAllClick).toHaveBeenCalled(); + }); + it("renders local room error", async () => { + const onRetryRoomCreationClick = jest.fn(); + const { container, getByRole } = render( + , + ); + expect(container).toMatchSnapshot(); + + const button = getByRole("button", { name: "Retry" }); + await userEvent.click(button); + expect(onRetryRoomCreationClick).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.tsx b/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.tsx new file mode 100644 index 0000000000..2472aeeade --- /dev/null +++ b/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.tsx @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2025 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, useId, type JSX } from "react"; +import { RestartIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { Button, InlineSpinner, Text } from "@vector-im/compound-web"; + +import styles from "./RoomStatusBarView.module.css"; +import { useViewModel } from "../../useViewModel"; +import { type ViewModel } from "../../viewmodel"; +import { useI18n } from "../../utils/i18nContext"; +import { Banner } from "../../composer/Banner"; +export interface RoomStatusBarViewActions { + /** + * Called when the user clicks on the 'resend all' button in the 'unsent messages' bar. + */ + onResendAllClick?: () => Promise; + + /** + * Called when the user clicks on the 'cancel all' button in the 'unsent messages' bar. + */ + onDeleteAllClick?: () => void; + + /** + * Called when the user clicks on the 'Retry' button in the 'failed to start chat' bar. + */ + onRetryRoomCreationClick?: () => void; + + /** + * Called when the user clicks on the 'Review Terms and Conditions' button. + */ + onTermsAndConditionsClicked?: () => void; +} + +export const RoomStatusBarState = { + /** + * Connectivity to the homeserver has been lost. The user can not take any actions + * until the connection is restored. + */ + ConnectionLost: "ConnectionLost", + /** + * The homeserver has indiciated the user needs to consent to the Terms and Conditions + * before they can send a message. + */ + NeedsConsent: "NeedsConsent", + /** + * The homeserver has indiciated that messages can not be sent due to a resource limit + * being reached. The user may use the given admin contact details. + */ + ResourceLimited: "ResourceLimited", + /** + * There are messages stored locally that previously failed to send that the user + * may now retry or delete. + */ + UnsentMessages: "UnsentMessages", + /** + * There was an error creating a room. The user may retry creation. + */ + LocalRoomFailed: "LocalRoomFailed", +} as const; + +export interface RoomStatusBarNotVisible { + state: null; +} + +export interface RoomStatusBarNoConnection { + state: "ConnectionLost"; +} + +export interface RoomStatusBarConsentState { + state: "NeedsConsent"; + consentUri: string; +} + +export interface RoomStatusBarResourceLimitedState { + state: "ResourceLimited"; + resourceLimit: "monthly_active_user" | "hs_disabled" | string; + adminContactHref?: string; +} + +export interface RoomStatusBarUnsentMessagesState { + state: "UnsentMessages"; + isResending: boolean; +} +export interface RoomStatusBarLocalRoomError { + state: "LocalRoomFailed"; +} + +export type RoomStatusBarViewSnapshot = + | RoomStatusBarNoConnection + | RoomStatusBarConsentState + | RoomStatusBarResourceLimitedState + | RoomStatusBarUnsentMessagesState + | RoomStatusBarLocalRoomError + | RoomStatusBarNotVisible; + +/** + * The view model for RoomStatusBarView. + */ +export type RoomStatusBarViewModel = ViewModel & RoomStatusBarViewActions; + +interface RoomStatusBarViewProps { + /** + * The view model for the banner. + */ + vm: RoomStatusBarViewModel; +} + +/** + * A component to alert to a failure in the context of a room. + * + * @example + * ```tsx + * + * ``` + */ +export function RoomStatusBarView({ vm }: Readonly): JSX.Element | null { + const { translate: _t } = useI18n(); + const snapshot = useViewModel(vm); + const bannerTitleId = useId(); + + const deleteAllClick = useCallback>( + (ev) => { + ev.preventDefault(); + vm.onDeleteAllClick?.(); + }, + [vm], + ); + + const resendClick = useCallback>( + (ev) => { + ev.preventDefault(); + void vm.onResendAllClick?.(); + }, + [vm], + ); + + const retryRoomCreationClick = useCallback>( + (ev) => { + ev.preventDefault(); + vm.onRetryRoomCreationClick?.(); + }, + [vm], + ); + + const termsAndConditionsClicked = useCallback>(() => { + // Allow the link to go through. + vm.onTermsAndConditionsClicked?.(); + }, [vm]); + + if (snapshot.state === null) { + // Nothing to show! + return null; + } + + switch (snapshot.state) { + case RoomStatusBarState.ConnectionLost: + return ( + +
+ + {_t("room|status_bar|server_connectivity_lost_title")} + + + {_t("room|status_bar|server_connectivity_lost_description")} + +
+
+ ); + case RoomStatusBarState.NeedsConsent: + return ( + + {_t("terms|tac_button")} + + } + > +
+ + {_t("room|status_bar|requires_consent_agreement_title")} + +
+
+ ); + case RoomStatusBarState.ResourceLimited: + return ( + + Contact admin + + ) + } + > +
+ + {{ + monthly_active_user: _t("room|status_bar|monthly_user_limit_reached_title"), + hs_disabled: _t("room|status_bar|homeserver_blocked_title"), + }[snapshot.resourceLimit] || _t("room|status_bar|exceeded_resource_limit_title")} + + + {_t("room|status_bar|exceeded_resource_limit_description")} + +
+
+ ); + case RoomStatusBarState.LocalRoomFailed: + return ( + + {_t("action|retry")} + + } + > + + {_t("room|status_bar|failed_to_create_room_title")} + + + ); + case RoomStatusBarState.UnsentMessages: + return ( + + ) : ( + <> + {vm.onDeleteAllClick && ( + + )} + {vm.onResendAllClick && ( + + )} + + ) + } + aria-labelledby={bannerTitleId} + > +
+ + {_t("room|status_bar|some_messages_not_sent")} + + + {_t("room|status_bar|select_messages_to_retry")} + +
+
+ ); + default: + // We should never get into this state. + return null; + } +} diff --git a/packages/shared-components/src/room/RoomStatusBar/__snapshots__/RoomStatusBarView.test.tsx.snap b/packages/shared-components/src/room/RoomStatusBar/__snapshots__/RoomStatusBarView.test.tsx.snap new file mode 100644 index 0000000000..b25238dff3 --- /dev/null +++ b/packages/shared-components/src/room/RoomStatusBar/__snapshots__/RoomStatusBarView.test.tsx.snap @@ -0,0 +1,520 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`RoomStatusBarView renders connection lost 1`] = ` +
+ +`; + +exports[`RoomStatusBarView renders consent link 1`] = ` +
+ +
+`; + +exports[`RoomStatusBarView renders local room error 1`] = ` +
+ +
+`; + +exports[`RoomStatusBarView renders resource limit error 1`] = ` +
+ +
+`; + +exports[`RoomStatusBarView renders unsent messages 1`] = ` +
+ +
+`; + +exports[`RoomStatusBarView renders unsent messages and deletes all 1`] = ` +
+ +
+`; + +exports[`RoomStatusBarView renders unsent messages and resends all 1`] = ` +
+ +
+`; diff --git a/packages/shared-components/src/room/RoomStatusBar/index.ts b/packages/shared-components/src/room/RoomStatusBar/index.ts new file mode 100644 index 0000000000..43f1bfebc2 --- /dev/null +++ b/packages/shared-components/src/room/RoomStatusBar/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2025 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. + */ + +export * from "./RoomStatusBarView"; diff --git a/playwright/e2e/room/room-status-bar.spec.ts b/playwright/e2e/room/room-status-bar.spec.ts index 249aa6e9d5..78d5c49a30 100644 --- a/playwright/e2e/room/room-status-bar.spec.ts +++ b/playwright/e2e/room/room-status-bar.spec.ts @@ -83,6 +83,13 @@ test.describe("Room Status Bar", () => { const banner = page.getByRole("region", { name: "Room status bar" }); await expect(banner).toBeVisible({ timeout: 15000 }); await expect(banner).toMatchScreenshot("consent.png"); + + // Click consent + await banner.getByRole("link", { name: "View Terms and Conditions" }).click(); + await page.unroute("**/_matrix/client/**/send**"); + // Should now be allowed to retry. + await banner.getByRole("button", { name: "Retry all" }).click(); + await expect(banner).not.toBeVisible(); }, ); test.describe("Message fails to send", () => { @@ -161,7 +168,7 @@ test.describe("Room Status Bar", () => { await composer.fill("Hello"); await composer.press("Enter"); - const banner = page.getByText("!Some of your messages have"); + const banner = page.getByRole("status", { name: "Could not start a chat with this user" }); await expect(banner).toBeVisible(); await expect(banner).toMatchScreenshot("local_room_create_failed.png"); diff --git a/playwright/snapshots/room/room-status-bar.spec.ts/connectivity-lost-linux.png b/playwright/snapshots/room/room-status-bar.spec.ts/connectivity-lost-linux.png index 106f16403c..0568892b39 100644 Binary files a/playwright/snapshots/room/room-status-bar.spec.ts/connectivity-lost-linux.png and b/playwright/snapshots/room/room-status-bar.spec.ts/connectivity-lost-linux.png differ diff --git a/playwright/snapshots/room/room-status-bar.spec.ts/consent-linux.png b/playwright/snapshots/room/room-status-bar.spec.ts/consent-linux.png index 13aa6a4833..c528eb619a 100644 Binary files a/playwright/snapshots/room/room-status-bar.spec.ts/consent-linux.png and b/playwright/snapshots/room/room-status-bar.spec.ts/consent-linux.png differ diff --git a/playwright/snapshots/room/room-status-bar.spec.ts/local-room-create-failed-linux.png b/playwright/snapshots/room/room-status-bar.spec.ts/local-room-create-failed-linux.png index a8fe32646e..304a425466 100644 Binary files a/playwright/snapshots/room/room-status-bar.spec.ts/local-room-create-failed-linux.png and b/playwright/snapshots/room/room-status-bar.spec.ts/local-room-create-failed-linux.png differ diff --git a/playwright/snapshots/room/room-status-bar.spec.ts/message-failed-linux.png b/playwright/snapshots/room/room-status-bar.spec.ts/message-failed-linux.png index cccc5fb675..fe23d40790 100644 Binary files a/playwright/snapshots/room/room-status-bar.spec.ts/message-failed-linux.png and b/playwright/snapshots/room/room-status-bar.spec.ts/message-failed-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 9711c1b064..db922e5404 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -74,7 +74,6 @@ @import "./structures/_QuickSettingsButton.pcss"; @import "./structures/_RightPanel.pcss"; @import "./structures/_RoomSearch.pcss"; -@import "./structures/_RoomStatusBar.pcss"; @import "./structures/_RoomView.pcss"; @import "./structures/_SearchBox.pcss"; @import "./structures/_SpaceHierarchy.pcss"; diff --git a/res/css/structures/_RoomStatusBar.pcss b/res/css/structures/_RoomStatusBar.pcss deleted file mode 100644 index f3b1737812..0000000000 --- a/res/css/structures/_RoomStatusBar.pcss +++ /dev/null @@ -1,175 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2015, 2016 OpenMarket 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. -*/ - -.mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) { - margin-left: 65px; - min-height: 50px; -} - -.mx_RoomStatusBar_typingIndicatorAvatars { - width: 52px; - margin-top: -1px; - text-align: left; -} - -.mx_RoomStatusBar_typingIndicatorRemaining { - display: inline-block; - color: #acacac; - background-color: #ddd; - border: 1px solid $background; - border-radius: 40px; - width: 24px; - height: 24px; - line-height: $font-24px; - font-size: 0.8em; - vertical-align: top; - text-align: center; - position: absolute; -} - -.mx_RoomStatusBar_scrollDownIndicator { - cursor: pointer; - padding-left: 1px; -} - -.mx_RoomStatusBar_unreadMessagesBar { - padding-top: 10px; - color: $alert; - cursor: pointer; -} - -.mx_RoomStatusBar_connectionLostBar { - display: flex; - - margin-top: 19px; - min-height: 58px; -} - -.mx_RoomStatusBar_unsentMessages { - > div[role="alert"] { - /* cheat some basic alignment */ - display: flex; - align-items: center; - min-height: 70px; - margin: 12px; - padding-left: 16px; - background-color: $header-panel-bg-color; - border-radius: 4px; - } - - .mx_RoomStatusBar_unsentBadge { - margin-right: 12px; - - .mx_NotificationBadge { - /* Override sizing from the default badge */ - width: 24px !important; - height: 24px !important; - border-radius: 24px !important; - - .mx_NotificationBadge_count { - font-size: $font-16px !important; /* override default */ - } - } - } - - .mx_RoomStatusBar_unsentTitle { - color: $alert; - font-size: $font-15px; - } - - .mx_RoomStatusBar_unsentDescription { - font-size: $font-12px; - } - - .mx_RoomStatusBar_unsentButtonBar { - flex-grow: 1; - text-align: right; - margin-right: 22px; - color: $muted-fg-color; - - .mx_AccessibleButton { - padding: 5px 10px; - padding-left: 30px; /* 18px for the icon, 2px margin to text, 10px regular padding */ - display: inline-block; - position: relative; - user-select: none; - - & + .mx_AccessibleButton { - border-left: 1px solid $resend-button-divider-color; - } - - svg { - left: 10px; /* inset for regular button padding */ - - width: 18px; - height: 18px; - vertical-align: middle; - color: $muted-fg-color; - } - } - - .mx_InlineSpinner { - vertical-align: middle; - margin-right: 5px; - top: 1px; /* just to help the vertical alignment be slightly better */ - - & + span { - margin-right: 10px; /* same margin/padding as the rightmost button */ - } - } - } -} - -.mx_RoomStatusBar_connectionLostBar svg { - padding-left: 10px; - padding-right: 10px; - vertical-align: middle; - float: left; -} - -.mx_RoomStatusBar_connectionLostBar_title { - color: $alert; -} - -.mx_RoomStatusBar_connectionLostBar_desc { - color: $primary-content; - font-size: $font-13px; - opacity: 0.5; - padding-bottom: 20px; -} - -.mx_RoomStatusBar_resend_link { - color: $primary-content !important; - text-decoration: underline !important; - cursor: pointer; -} - -.mx_RoomStatusBar_typingBar { - height: 50px; - line-height: 50px; - - color: $primary-content; - opacity: 0.5; - overflow-y: hidden; - display: block; -} - -.mx_MatrixChat_useCompactLayout { - .mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) { - min-height: 40px; - } - - .mx_RoomStatusBar_indicator { - margin-top: 10px; - } - - .mx_RoomStatusBar_typingBar { - height: 40px; - line-height: 40px; - } -} diff --git a/src/RoomNotifs.ts b/src/RoomNotifs.ts index d5254d523d..2db2cf2158 100644 --- a/src/RoomNotifs.ts +++ b/src/RoomNotifs.ts @@ -13,16 +13,38 @@ import { PushRuleActionName, PushRuleKind, TweakName, + EventStatus, } from "matrix-js-sdk/src/matrix"; -import type { IPushRule, Room, MatrixClient } from "matrix-js-sdk/src/matrix"; +import type { IPushRule, Room, MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { NotificationLevel } from "./stores/notifications/NotificationLevel"; -import { getUnsentMessages } from "./components/structures/RoomStatusBar"; import { doesRoomHaveUnreadMessages, doesRoomOrThreadHaveUnreadMessages } from "./Unread"; import { EffectiveMembership, getEffectiveMembership, isKnockDenied } from "./utils/membership"; import SettingsStore from "./settings/SettingsStore"; import { getMarkedUnreadState } from "./utils/notifications"; +/** + * Gets all pending events in a room that have a status of `EventStatus.NOT_SENT` + * and belong to the current thread, if specified. + * @param room The room to check. + * @param threadId The thread to check. If not specified, no thread filtering is performed. + * @returns An array of unsent matrix events. + */ +export function getUnsentMessages(room: Room, threadId?: string): MatrixEvent[] { + if (!room) { + return []; + } + return room.getPendingEvents().filter(function (ev) { + if (ev.status !== EventStatus.NOT_SENT) { + return false; + } + if (threadId && threadId !== ev.threadRootId) { + return false; + } + return true; + }); +} + export enum RoomNotifState { AllMessagesLoud = "all_messages_loud", AllMessages = "all_messages", diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx deleted file mode 100644 index d9889342b1..0000000000 --- a/src/components/structures/RoomStatusBar.tsx +++ /dev/null @@ -1,297 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2015-2021 The Matrix.org Foundation C.I.C. - -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, { type JSX, type ReactNode } from "react"; -import { - ClientEvent, - EventStatus, - type MatrixError, - type MatrixEvent, - type Room, - RoomEvent, - type SyncState, - type SyncStateData, -} from "matrix-js-sdk/src/matrix"; -import { RestartIcon, WarningIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - -import { _t, _td } from "../../languageHandler"; -import Resend from "../../Resend"; -import dis from "../../dispatcher/dispatcher"; -import { messageForResourceLimitError } from "../../utils/ErrorUtils"; -import { Action } from "../../dispatcher/actions"; -import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState"; -import AccessibleButton from "../views/elements/AccessibleButton"; -import InlineSpinner from "../views/elements/InlineSpinner"; -import MatrixClientContext from "../../contexts/MatrixClientContext"; -import { RoomStatusBarUnsentMessages } from "./RoomStatusBarUnsentMessages"; -import ExternalLink from "../views/elements/ExternalLink"; - -const STATUS_BAR_HIDDEN = 0; -const STATUS_BAR_EXPANDED = 1; -const STATUS_BAR_EXPANDED_LARGE = 2; - -export function getUnsentMessages(room: Room, threadId?: string): MatrixEvent[] { - if (!room) { - return []; - } - return room.getPendingEvents().filter(function (ev) { - const isNotSent = ev.status === EventStatus.NOT_SENT; - const belongsToTheThread = threadId === ev.threadRootId; - return isNotSent && (!threadId || belongsToTheThread); - }); -} - -interface IProps { - // the room this statusbar is representing. - room: Room; - - // true if the room is being peeked at. This affects components that shouldn't - // logically be shown when peeking, such as a prompt to invite people to a room. - isPeeking?: boolean; - // callback for when the user clicks on the 'resend all' button in the - // 'unsent messages' bar - onResendAllClick?: () => void; - - // callback for when the user clicks on the 'cancel all' button in the - // 'unsent messages' bar - onCancelAllClick?: () => void; - - // callback for when the user clicks on the 'invite others' button in the - // 'you are alone' bar - onInviteClick?: () => void; - - // callback for when we do something that changes the size of the - // status bar. This is used to trigger a re-layout in the parent - // component. - onResize?: () => void; - - // callback for when the status bar can be hidden from view, as it is - // not displaying anything - onHidden?: () => void; - - // callback for when the status bar is displaying something and should - // be visible - onVisible?: () => void; -} - -interface IState { - syncState: SyncState | null; - syncStateData: SyncStateData | null; - unsentMessages: MatrixEvent[]; - isResending: boolean; -} - -export default class RoomStatusBar extends React.PureComponent { - private unmounted = false; - public static contextType = MatrixClientContext; - declare public context: React.ContextType; - - public constructor(props: IProps, context: React.ContextType) { - super(props, context); - - this.state = { - syncState: this.context.getSyncState(), - syncStateData: this.context.getSyncStateData(), - unsentMessages: getUnsentMessages(this.props.room), - isResending: false, - }; - } - - public componentDidMount(): void { - this.unmounted = false; - - const client = this.context; - client.on(ClientEvent.Sync, this.onSyncStateChange); - client.on(RoomEvent.LocalEchoUpdated, this.onRoomLocalEchoUpdated); - - this.checkSize(); - } - - public componentDidUpdate(): void { - this.checkSize(); - } - - public componentWillUnmount(): void { - this.unmounted = true; - // we may have entirely lost our client as we're logging out before clicking login on the guest bar... - const client = this.context; - if (client) { - client.removeListener(ClientEvent.Sync, this.onSyncStateChange); - client.removeListener(RoomEvent.LocalEchoUpdated, this.onRoomLocalEchoUpdated); - } - } - - private onSyncStateChange = (state: SyncState, prevState: SyncState | null, data?: SyncStateData): void => { - if (state === "SYNCING" && prevState === "SYNCING") { - return; - } - if (this.unmounted) return; - this.setState({ - syncState: state, - syncStateData: data ?? null, - }); - }; - - private onResendAllClick = (): void => { - Resend.resendUnsentEvents(this.props.room).then(() => { - this.setState({ isResending: false }); - }); - this.setState({ isResending: true }); - dis.fire(Action.FocusSendMessageComposer); - }; - - private onCancelAllClick = (): void => { - Resend.cancelUnsentEvents(this.props.room); - dis.fire(Action.FocusSendMessageComposer); - }; - - private onRoomLocalEchoUpdated = (ev: MatrixEvent, room: Room): void => { - if (room.roomId !== this.props.room.roomId) return; - const messages = getUnsentMessages(this.props.room); - this.setState({ - unsentMessages: messages, - isResending: messages.length > 0 && this.state.isResending, - }); - }; - - // Check whether current size is greater than 0, if yes call props.onVisible - private checkSize(): void { - if (this.getSize()) { - if (this.props.onVisible) this.props.onVisible(); - } else { - if (this.props.onHidden) this.props.onHidden(); - } - } - - // We don't need the actual height - just whether it is likely to have - // changed - so we use '0' to indicate normal size, and other values to - // indicate other sizes. - private getSize(): number { - if (this.shouldShowConnectionError()) { - return STATUS_BAR_EXPANDED; - } else if (this.state.unsentMessages.length > 0 || this.state.isResending) { - return STATUS_BAR_EXPANDED_LARGE; - } - return STATUS_BAR_HIDDEN; - } - - private shouldShowConnectionError(): boolean { - // no conn bar trumps the "some not sent" msg since you can't resend without - // a connection! - // There's one situation in which we don't show this 'no connection' bar, and that's - // if it's a resource limit exceeded error: those are shown in the top bar. - const errorIsMauError = Boolean( - this.state.syncStateData && - this.state.syncStateData.error && - this.state.syncStateData.error.name === "M_RESOURCE_LIMIT_EXCEEDED", - ); - return this.state.syncState === "ERROR" && !errorIsMauError; - } - - private getUnsentMessageContent(): JSX.Element { - const unsentMessages = this.state.unsentMessages; - - let title: ReactNode; - - let consentError: MatrixError | null = null; - let resourceLimitError: MatrixError | null = null; - for (const m of unsentMessages) { - if (m.error && m.error.errcode === "M_CONSENT_NOT_GIVEN") { - consentError = m.error; - break; - } else if (m.error && m.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED") { - resourceLimitError = m.error; - break; - } - } - if (consentError) { - title = _t( - "room|status_bar|requires_consent_agreement", - {}, - { - consentLink: (sub) => ( - - {sub} - - ), - }, - ); - } else if (resourceLimitError) { - title = messageForResourceLimitError( - resourceLimitError.data.limit_type, - resourceLimitError.data.admin_contact, - { - "monthly_active_user": _td("room|status_bar|monthly_user_limit_reached"), - "hs_disabled": _td("room|status_bar|homeserver_blocked"), - "": _td("room|status_bar|exceeded_resource_limit"), - }, - ); - } else { - title = _t("room|status_bar|some_messages_not_sent"); - } - - let buttonRow = ( - <> - - - {_t("room|status_bar|delete_all")} - - - - {_t("room|status_bar|retry_all")} - - - ); - if (this.state.isResending) { - buttonRow = ( - <> - - {/* span for css */} - {_t("forward|sending")} - - ); - } - - return ( - - ); - } - - public render(): React.ReactNode { - if (this.shouldShowConnectionError()) { - return ( -
-
-
- -
-
- {_t("room|status_bar|server_connectivity_lost_title")} -
-
- {_t("room|status_bar|server_connectivity_lost_description")} -
-
-
-
-
- ); - } - - if (this.state.unsentMessages.length > 0 || this.state.isResending) { - return this.getUnsentMessageContent(); - } - - return null; - } -} diff --git a/src/components/structures/RoomStatusBarUnsentMessages.tsx b/src/components/structures/RoomStatusBarUnsentMessages.tsx deleted file mode 100644 index 0845f2550a..0000000000 --- a/src/components/structures/RoomStatusBarUnsentMessages.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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, { type ReactElement, type ReactNode } from "react"; - -import { type StaticNotificationState } from "../../stores/notifications/StaticNotificationState"; -import NotificationBadge from "../views/rooms/NotificationBadge"; - -interface RoomStatusBarUnsentMessagesProps { - title: ReactNode; - description?: string; - notificationState: StaticNotificationState; - buttons: ReactElement; -} - -export const RoomStatusBarUnsentMessages = (props: RoomStatusBarUnsentMessagesProps): ReactElement => { - return ( -
-
-
- -
-
-
{props.title}
- {props.description &&
{props.description}
} -
-
{props.buttons}
-
-
- ); -}; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 904ada4090..7f86f5d28c 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -16,6 +16,7 @@ import React, { type ReactNode, type RefObject, type JSX, + useEffect, } from "react"; import classNames from "classnames"; import { @@ -45,7 +46,7 @@ import { debounce, throttle } from "lodash"; import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { type ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; import { type RoomViewProps } from "@element-hq/element-web-module-api"; -import { RestartIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { RoomStatusBarView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; import shouldHideEvent from "../../shouldHideEvent"; import { _t } from "../../languageHandler"; @@ -92,7 +93,6 @@ import { type IOpts } from "../../createRoom"; import EditorStateTransfer from "../../utils/EditorStateTransfer"; import ErrorDialog from "../views/dialogs/ErrorDialog"; import UploadBar from "./UploadBar"; -import RoomStatusBar from "./RoomStatusBar"; import MessageComposer from "../views/rooms/MessageComposer"; import JumpToBottomButton from "../views/rooms/JumpToBottomButton"; import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar"; @@ -112,10 +112,8 @@ import { LocalRoom, LocalRoomState } from "../../models/LocalRoom"; import { createRoomFromLocalRoom } from "../../utils/direct-messages"; import NewRoomIntro from "../views/rooms/NewRoomIntro"; import EncryptionEvent from "../views/messages/EncryptionEvent"; -import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState"; import { isLocalRoom } from "../../utils/localRoom/isLocalRoom"; import { type ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload"; -import { RoomStatusBarUnsentMessages } from "./RoomStatusBarUnsentMessages"; import { LargeLoader } from "./LargeLoader"; import { isVideoRoom } from "../../utils/video-rooms"; import { SDKContext } from "../../contexts/SDKContext"; @@ -137,6 +135,7 @@ import { DeclineAndBlockInviteDialog } from "../views/dialogs/DeclineAndBlockInv import { type FocusMessageSearchPayload } from "../../dispatcher/payloads/FocusMessageSearchPayload.ts"; import { isRoomEncrypted } from "../../hooks/useIsEncrypted"; import { type RoomViewStore } from "../../stores/RoomViewStore.tsx"; +import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts"; const DEBUG = false; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; @@ -317,33 +316,11 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { encryptionTile = ; } - const onRetryClicked = (): void => { - // eslint-disable-next-line react-compiler/react-compiler - room.state = LocalRoomState.NEW; - defaultDispatcher.dispatch({ - action: "local_room_event", - roomId: room.roomId, - }); - }; - let statusBar: ReactElement | null = null; let composer: ReactElement | null = null; if (room.isError) { - const buttons = ( - - - {_t("action|retry")} - - ); - - statusBar = ( - - ); + statusBar = ; } else { composer = ( ); } +/** + * Wrap a RoomStatusBarView and ViewModel into one component, for usage with legacy React components. + */ +function RoomStatusBarWrappedView(props: ConstructorParameters[0]): ReactElement { + const vm = useCreateAutoDisposedViewModel(() => new RoomStatusBarViewModel(props)); + useEffect(() => { + // Note: We need to tell the parent component whether the viewmodel expects to render anything + // (see onStatusBarVisible). This is ugly, but works. + if ("onVisible" in props) { + // Initial setup + if (vm.getSnapshot().state !== null) { + props.onVisible(); + } else { + props.onHidden?.(); + } + vm.subscribe(() => { + if (vm.getSnapshot().state !== null) { + props.onVisible?.(); + } else { + props.onHidden?.(); + } + }); + } + }, [vm, props]); + + return ; +} export class RoomView extends React.Component { // We cache the latest computed e2eStatus per room to show as soon as we switch rooms otherwise defaulting to @@ -1687,14 +1691,6 @@ export class RoomView extends React.Component { } } - private onInviteClick = (): void => { - // open the room inviter - defaultDispatcher.dispatch({ - action: "view_invite", - roomId: this.getRoomId(), - }); - }; - private onJoinButtonClicked = (): void => { // If the user is a ROU, allow them to transition to a PWLU if (this.context.client?.isGuest()) { @@ -2401,10 +2397,8 @@ export class RoomView extends React.Component { } else if (!this.state.search) { isStatusAreaExpanded = this.state.statusBarVisible; statusBar = ( - diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 665ca6f677..aeda2237e0 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -29,6 +29,7 @@ import { Thread, ThreadEvent, ReceiptType, + EventStatus, } from "matrix-js-sdk/src/matrix"; import { debounce } from "lodash"; import { logger } from "matrix-js-sdk/src/logger"; @@ -1004,6 +1005,7 @@ class TimelinePanel extends React.Component { lastReadEventIndex: number | null, ): lastReadEvent is MatrixEvent { if (!lastReadEvent) return false; + if (lastReadEvent.status === EventStatus.NOT_SENT) return false; // We want to avoid sending out read receipts when we are looking at // events in the past which are before the latest RR. diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 8d1ae17ea2..e431fff11d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2128,18 +2128,6 @@ }, "this_room_button": "Search this room" }, - "status_bar": { - "delete_all": "Delete all", - "exceeded_resource_limit": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.", - "homeserver_blocked": "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.", - "monthly_user_limit_reached": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.", - "requires_consent_agreement": "You can't send any messages until you review and agree to our terms and conditions.", - "retry_all": "Retry all", - "select_messages_to_retry": "You can select all or individual messages to retry or delete", - "server_connectivity_lost_description": "Sent messages will be stored until your connection has returned.", - "server_connectivity_lost_title": "Connectivity to the server has been lost.", - "some_messages_not_sent": "Some of your messages have not been sent" - }, "unknown_status_code_for_timeline_jump": "unknown status code", "unread_notifications_predecessor": { "one": "You have %(count)s unread notification in a prior version of this room.", diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index af873f712e..44a62b1307 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -141,7 +141,6 @@ export class RoomNotificationState extends NotificationState implements IDestroy private updateNotificationState(): void { const snapshot = this.snapshot(); - const { level, symbol, count, invited } = RoomNotifs.determineUnreadState( this.room, undefined, diff --git a/src/viewmodels/room/RoomStatusBar.ts b/src/viewmodels/room/RoomStatusBar.ts new file mode 100644 index 0000000000..799b3c4cec --- /dev/null +++ b/src/viewmodels/room/RoomStatusBar.ts @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025 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 { + BaseViewModel, + RoomStatusBarState, + type RoomStatusBarViewModel as RoomStatusBarViewModelInterface, + type RoomStatusBarViewSnapshot, +} from "@element-hq/web-shared-components"; +import { + ClientEvent, + SyncState, + type MatrixClient, + type Room, + type MatrixError, + RoomEvent, + EventStatus, +} from "matrix-js-sdk/src/matrix"; + +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import Resend from "../../Resend"; +import { Action } from "../../dispatcher/actions"; +import dis from "../../dispatcher/dispatcher"; +import { LocalRoom, LocalRoomState } from "../../models/LocalRoom"; + +interface PropsWithRoom { + room: Room | LocalRoom; +} +interface PropsWithVisibility extends PropsWithRoom { + /** + * Called when the bar becomes visible. + */ + onVisible: () => void; + /** + * Called when the bar becomes hidden. + */ + onHidden: () => void; +} + +type Props = PropsWithRoom | PropsWithVisibility; + +export class RoomStatusBarViewModel + extends BaseViewModel + implements RoomStatusBarViewModelInterface +{ + /** + * Check if the room has any unread messages. If it does, we should render the specific message + * depending on the kind of error encountered when sending them. + * + * Because a room can contain multiple unsent messages, the resultant state is based on the + * "most important" error to show. + * + * @param room The room being viewed. + * @param hasClickedTermsAndConditions Whether the terms and conditions button has just been pressed. + * @returns A snapshot if an error should be visible, or null if not. + */ + private static readonly determineStateForUnreadMessages = ( + room: Room, + hasClickedTermsAndConditions: boolean, + ): RoomStatusBarViewSnapshot => { + const unsentMessages = room.getPendingEvents().filter((ev) => ev.status === EventStatus.NOT_SENT); + if (unsentMessages.length === 0) { + return { + state: null, + }; + } + if (hasClickedTermsAndConditions) { + // The user has just clicked (and we *assume* accepted) the terms and contitions, so show them the retry buttons. + // If the user has not accepted the terms, we will just prompt the same error again anyway. + return { + state: RoomStatusBarState.UnsentMessages, + isResending: false, + }; + } + + // Filter through the errors and find the most important error. + let resourceLimitError: MatrixError | null = null; + for (const m of unsentMessages) { + if (m.error?.errcode === "M_CONSENT_NOT_GIVEN") { + // This is the most important thing to show, so break here if we find one. + return { + state: RoomStatusBarState.NeedsConsent, + consentUri: m.error.data.consent_uri, + }; + } + if (m.error?.errcode === "M_RESOURCE_LIMIT_EXCEEDED") { + resourceLimitError = m.error; + } + } + if (resourceLimitError) { + return { + state: RoomStatusBarState.ResourceLimited, + resourceLimit: resourceLimitError.data.limit_type ?? "", + adminContactHref: resourceLimitError.data.admin_contact, + }; + } + // Otherwise, we know there are unsent messages but the error is not special. + return { + state: RoomStatusBarState.UnsentMessages, + isResending: false, + }; + }; + + private static readonly computeSnapshot = ( + room: Room, + client: MatrixClient, + isResending: boolean, + hasClickedTermsAndConditions: boolean, + ): RoomStatusBarViewSnapshot => { + const isLocalRoomAndIsError = (room as LocalRoom)["isError"]; + if (isLocalRoomAndIsError !== undefined) { + return { + // Local room errors can only be about failed room creation. + state: isLocalRoomAndIsError ? RoomStatusBarState.LocalRoomFailed : null, + }; + } + + // If we're in the process of resending, always show a resending state so we don't flicker. + if (isResending) { + return { + state: RoomStatusBarState.UnsentMessages, + isResending, + }; + } + + const syncState = client.getSyncState(); + + // Highest priority. + // A no-connection bar trumps all else, as you won't be able to resend or do anything! + if (syncState === SyncState.Error) { + const syncData = client.getSyncStateData(); + if (syncData?.error?.name === "M_RESOURCE_LIMIT_EXCEEDED") { + // There's one situation in which we don't show this 'no connection' bar, and that's + // if it's a M_RESOURCE_LIMIT_EXCEEDED error: those are shown as a toast by LoggedInView. + return { + state: null, + }; + } + return { + state: RoomStatusBarState.ConnectionLost, + }; + } + + // Connection is good, so check room messages for any failures. + return this.determineStateForUnreadMessages(room, hasClickedTermsAndConditions); + }; + + private readonly client: MatrixClient; + + public constructor(props: Props) { + const client = MatrixClientPeg.safeGet(); + super(props, RoomStatusBarViewModel.computeSnapshot(props.room, client, false, false)); + this.client = client; + this.disposables.trackListener(client, ClientEvent.Sync, this.onClientSync); + this.disposables.trackListener(props.room, RoomEvent.LocalEchoUpdated, this.onRoomLocalEchoUpdated); + } + + private readonly onClientSync = (): void => { + this.setSnapshot(); + }; + + private readonly onRoomLocalEchoUpdated = (): void => { + this.setSnapshot(); + }; + + private isResending = false; + private hasClickedTermsAndConditions = false; + + private setSnapshot(): void { + this.snapshot.set( + RoomStatusBarViewModel.computeSnapshot( + this.props.room, + this.client, + this.isResending, + this.hasClickedTermsAndConditions, + ), + ); + // Reset `hasClickedTermsAndConditions` once the state has cleared. + if (this.hasClickedTermsAndConditions && !this.snapshot.current.state) { + this.hasClickedTermsAndConditions = false; + } + } + + public onTermsAndConditionsClicked = (): void => { + this.hasClickedTermsAndConditions = true; + this.setSnapshot(); + }; + + public onDeleteAllClick = (): void => { + Resend.cancelUnsentEvents(this.props.room); + dis.fire(Action.FocusSendMessageComposer); + this.setSnapshot(); + }; + + public onResendAllClick = async (): Promise => { + this.isResending = true; + this.setSnapshot(); + try { + await Resend.resendUnsentEvents(this.props.room); + dis.fire(Action.FocusSendMessageComposer); + } finally { + this.isResending = false; + this.setSnapshot(); + } + }; + + public onRetryRoomCreationClick = (): void => { + if (this.props.room instanceof LocalRoom === false) { + throw Error("Tried to recreate local room, but room was not local."); + } + + // This resets the local room state from error. + this.props.room.state = LocalRoomState.NEW; + dis.dispatch({ + action: "local_room_event", + roomId: this.props.room.roomId, + }); + }; +} diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 043f4590a8..4dfd5e4de8 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -32,6 +32,7 @@ import { type GroupCall, HistoryVisibility, type ICreateRoomOpts, + type EventStatus, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { normalize } from "matrix-js-sdk/src/utils"; @@ -303,6 +304,7 @@ export function createTestClient(): MatrixClient { room_id: roomId, }); }), + resendEvent: jest.fn().mockResolvedValue({}), _unstable_sendDelayedEvent: jest.fn(), _unstable_sendDelayedStateEvent: jest.fn(), @@ -402,6 +404,7 @@ type MakeEventProps = MakeEventPassThruProps & { // eslint-disable-next-line camelcase prev_content?: IContent; unsigned?: IUnsigned; + status?: EventStatus; }; export const mkRoomCreateEvent = (userId: string, roomId: string, content?: IContent): MatrixEvent => { @@ -482,6 +485,9 @@ export function mkEvent(opts: MakeEventProps): MatrixEvent { getMxcAvatarUrl: () => {}, } as unknown as RoomMember; } + if (opts.status !== undefined) { + mxEvent.status = opts.status; + } return mxEvent; } @@ -702,7 +708,7 @@ export function mkStubRoom( getMembersWithMembership: jest.fn().mockReturnValue([]), getMxcAvatarUrl: () => "mxc://avatar.url/room.png", getMyMembership: jest.fn().mockReturnValue(KnownMembership.Join), - getPendingEvents: () => [] as MatrixEvent[], + getPendingEvents: jest.fn().mockReturnValue([]), getReceiptsForEvent: jest.fn().mockReturnValue([]), getRecommendedVersion: jest.fn().mockReturnValue(Promise.resolve("")), getThreads: jest.fn().mockReturnValue([]), diff --git a/test/unit-tests/RoomNotifs-test.ts b/test/unit-tests/RoomNotifs-test.ts index cff4ecef75..e514288a6c 100644 --- a/test/unit-tests/RoomNotifs-test.ts +++ b/test/unit-tests/RoomNotifs-test.ts @@ -26,10 +26,73 @@ import { RoomNotifState, getUnreadNotificationCount, determineUnreadState, + getUnsentMessages, } from "../../src/RoomNotifs"; import { NotificationLevel } from "../../src/stores/notifications/NotificationLevel"; import SettingsStore from "../../src/settings/SettingsStore"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; +import { mkThread } from "../test-utils/threads"; + +describe("getUnsentMessages", () => { + const ROOM_ID = "!roomId"; + let room: Room; + let event: MatrixEvent; + let client: MatrixClient; + beforeEach(() => { + client = stubClient(); + room = new Room(ROOM_ID, client, client.getUserId()!, { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + event = mkEvent({ + event: true, + type: "m.room.message", + user: "@user1:server", + room: "!room1:server", + content: {}, + }); + event.status = EventStatus.NOT_SENT; + }); + + it("returns no unsent messages", () => { + expect(getUnsentMessages(room)).toHaveLength(0); + }); + + it("checks the event status", () => { + room.addPendingEvent(event, "123"); + + expect(getUnsentMessages(room)).toHaveLength(1); + event.status = EventStatus.SENT; + + expect(getUnsentMessages(room)).toHaveLength(0); + }); + + it("only returns events related to a thread", () => { + room.addPendingEvent(event, "123"); + + const { rootEvent, events } = mkThread({ + room, + client, + authorId: "@alice:example.org", + participantUserIds: ["@alice:example.org"], + length: 2, + }); + rootEvent.status = EventStatus.NOT_SENT; + room.addPendingEvent(rootEvent, rootEvent.getId()!); + for (const event of events) { + event.status = EventStatus.NOT_SENT; + room.addPendingEvent(event, Date.now() + Math.random() + ""); + } + + const pendingEvents = getUnsentMessages(room, rootEvent.getId()); + + expect(pendingEvents[0].threadRootId).toBe(rootEvent.getId()); + expect(pendingEvents[1].threadRootId).toBe(rootEvent.getId()); + expect(pendingEvents[2].threadRootId).toBe(rootEvent.getId()); + + // Filters out the non thread events + expect(pendingEvents.every((ev) => ev.getId() !== event.getId())).toBe(true); + }); +}); describe("RoomNotifs test", () => { let client: jest.Mocked; diff --git a/test/unit-tests/components/structures/RoomStatusBar-test.tsx b/test/unit-tests/components/structures/RoomStatusBar-test.tsx deleted file mode 100644 index 844ed8ab2d..0000000000 --- a/test/unit-tests/components/structures/RoomStatusBar-test.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 { render } from "jest-matrix-react"; -import { - type MatrixClient, - PendingEventOrdering, - EventStatus, - type MatrixEvent, - Room, - MatrixError, -} from "matrix-js-sdk/src/matrix"; - -import RoomStatusBar, { getUnsentMessages } from "../../../../src/components/structures/RoomStatusBar"; -import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; -import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import { mkEvent, stubClient } from "../../../test-utils/test-utils"; -import { mkThread } from "../../../test-utils/threads"; - -describe("RoomStatusBar", () => { - const ROOM_ID = "!roomId:example.org"; - let room: Room; - let client: MatrixClient; - let event: MatrixEvent; - - beforeEach(() => { - jest.clearAllMocks(); - - stubClient(); - client = MatrixClientPeg.safeGet(); - client.getSyncStateData = jest.fn().mockReturnValue({}); - room = new Room(ROOM_ID, client, client.getUserId()!, { - pendingEventOrdering: PendingEventOrdering.Detached, - }); - event = mkEvent({ - event: true, - type: "m.room.message", - user: "@user1:server", - room: "!room1:server", - content: {}, - }); - event.status = EventStatus.NOT_SENT; - }); - - const getComponent = () => - render(, { - wrapper: ({ children }) => ( - {children} - ), - }); - - describe("getUnsentMessages", () => { - it("returns no unsent messages", () => { - expect(getUnsentMessages(room)).toHaveLength(0); - }); - - it("checks the event status", () => { - room.addPendingEvent(event, "123"); - - expect(getUnsentMessages(room)).toHaveLength(1); - event.status = EventStatus.SENT; - - expect(getUnsentMessages(room)).toHaveLength(0); - }); - - it("only returns events related to a thread", () => { - room.addPendingEvent(event, "123"); - - const { rootEvent, events } = mkThread({ - room, - client, - authorId: "@alice:example.org", - participantUserIds: ["@alice:example.org"], - length: 2, - }); - rootEvent.status = EventStatus.NOT_SENT; - room.addPendingEvent(rootEvent, rootEvent.getId()!); - for (const event of events) { - event.status = EventStatus.NOT_SENT; - room.addPendingEvent(event, Date.now() + Math.random() + ""); - } - - const pendingEvents = getUnsentMessages(room, rootEvent.getId()); - - expect(pendingEvents[0].threadRootId).toBe(rootEvent.getId()); - expect(pendingEvents[1].threadRootId).toBe(rootEvent.getId()); - expect(pendingEvents[2].threadRootId).toBe(rootEvent.getId()); - - // Filters out the non thread events - expect(pendingEvents.every((ev) => ev.getId() !== event.getId())).toBe(true); - }); - }); - - describe("", () => { - it("should render nothing when room has no error or unsent messages", () => { - const { container } = getComponent(); - expect(container.firstChild).toBe(null); - }); - - describe("unsent messages", () => { - it("should render warning when messages are unsent due to consent", () => { - const unsentMessage = mkEvent({ - event: true, - type: "m.room.message", - user: "@user1:server", - room: "!room1:server", - content: {}, - }); - unsentMessage.status = EventStatus.NOT_SENT; - unsentMessage.error = new MatrixError({ - errcode: "M_CONSENT_NOT_GIVEN", - data: { consent_uri: "terms.com" }, - }); - - room.addPendingEvent(unsentMessage, "123"); - - const { container } = getComponent(); - - expect(container).toMatchSnapshot(); - }); - - it("should render warning when messages are unsent due to resource limit", () => { - const unsentMessage = mkEvent({ - event: true, - type: "m.room.message", - user: "@user1:server", - room: "!room1:server", - content: {}, - }); - unsentMessage.status = EventStatus.NOT_SENT; - unsentMessage.error = new MatrixError({ - errcode: "M_RESOURCE_LIMIT_EXCEEDED", - data: { limit_type: "monthly_active_user" }, - }); - - room.addPendingEvent(unsentMessage, "123"); - - const { container } = getComponent(); - - expect(container).toMatchSnapshot(); - }); - }); - }); -}); diff --git a/test/unit-tests/components/structures/RoomStatusBarUnsentMessages-test.tsx b/test/unit-tests/components/structures/RoomStatusBarUnsentMessages-test.tsx deleted file mode 100644 index d608cb0627..0000000000 --- a/test/unit-tests/components/structures/RoomStatusBarUnsentMessages-test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 { render, screen } from "jest-matrix-react"; - -import { RoomStatusBarUnsentMessages } from "../../../../src/components/structures/RoomStatusBarUnsentMessages"; -import { StaticNotificationState } from "../../../../src/stores/notifications/StaticNotificationState"; - -describe("RoomStatusBarUnsentMessages", () => { - const title = "test title"; - const description = "test description"; - const buttonsText = "test buttons"; - const buttons =
{buttonsText}
; - - beforeEach(() => { - render( - , - ); - }); - - it("should render the values passed as props", () => { - screen.getByText(title); - screen.getByText(description); - screen.getByText(buttonsText); - // notification state - screen.getByText("!"); - }); -}); diff --git a/test/unit-tests/components/structures/__snapshots__/RoomStatusBar-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/RoomStatusBar-test.tsx.snap deleted file mode 100644 index 78a5a19aed..0000000000 --- a/test/unit-tests/components/structures/__snapshots__/RoomStatusBar-test.tsx.snap +++ /dev/null @@ -1,182 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`RoomStatusBar unsent messages should render warning when messages are unsent due to consent 1`] = ` -
-
-
-
-
- - ! - -
-
-
-
- - You can't send any messages until you review and agree to - - our terms and conditions - - - - - - . - -
-
- You can select all or individual messages to retry or delete -
-
-
-
- - - - Delete all -
-
- - - - Retry all -
-
-
-
-
-`; - -exports[`RoomStatusBar unsent messages should render warning when messages are unsent due to resource limit 1`] = ` -
-
-
-
-
- - ! - -
-
-
-
- Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service. -
-
- You can select all or individual messages to retry or delete -
-
-
-
- - - - Delete all -
-
- - - - Retry all -
-
-
-
-
-`; diff --git a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap index 9003fbbd6d..acdb6b2be1 100644 --- a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -212,53 +212,61 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
@@ -409,7 +417,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] = >