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..dd758917ad --- /dev/null +++ b/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.module.css @@ -0,0 +1,12 @@ +.container { + color: var(--cpd-color-text-primary); + svg { + /* Ensure button icons are primary too */ + color: var(--cpd-color-text-primary) !important; + } +} + +.description { + font-size: var(--cpd-font-size-body-sm); + 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 index baae5ff3c6..d50a9ebdfc 100644 --- a/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.stories.tsx +++ b/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.stories.tsx @@ -6,20 +6,23 @@ */ 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 { - RoomStatusBarView, - type RoomStatusBarViewActions, - type RoomStatusBarViewSnapshot, -} from "./RoomStatusBarView"; +import { RoomStatusBarView, type RoomStatusBarViewActions, type RoomStatusBarViewSnapshot } from "./RoomStatusBarView"; +import { fn } from "storybook/test"; type RoomStatusBarProps = RoomStatusBarViewSnapshot & RoomStatusBarViewActions; -const RoomStatusBarViewWrapper = ({ onClose, ...rest }: RoomStatusBarProps): JSX.Element => { +const RoomStatusBarViewWrapper = ({ + onResendAllClick, + onDeleteAllClick, + onRetryRoomCreationClick, + ...rest +}: RoomStatusBarProps): JSX.Element => { const vm = useMockedViewModel(rest, { - onClose, + onResendAllClick, + onDeleteAllClick, + onRetryRoomCreationClick, }); return ; }; @@ -30,13 +33,73 @@ export default { tags: ["autodocs"], argTypes: {}, args: { - visible: true, - onClose: fn(), + onResendAllClick: fn(), + onDeleteAllClick: fn(), + onRetryRoomCreationClick: fn(), }, } as Meta; -const Template: StoryFn = (args) => ( - -); +const Template: StoryFn = (args) => ; -export const Default = Template.bind({}); +/** + * Rendered when the client has lost connection with the server. + */ +export const WithConnectionLost = Template.bind({}); +WithConnectionLost.args = { + state: { + connectionLost: true, + }, +}; + +/** + * 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: { + 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: { + 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: { + 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: { + isResending: true, + }, +}; +/** + * Rendered when a local room has failed to be created. + */ +export const WithLocalRoomRetry = Template.bind({}); +WithLocalRoomRetry.args = { + state: { + isRetryingRoomCreation: false, + }, +}; diff --git a/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.test.tsx b/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.test.tsx index 4e902bfcc4..3ac63a691c 100644 --- a/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.test.tsx +++ b/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.test.tsx @@ -10,19 +10,60 @@ import { render } from "jest-matrix-react"; import { composeStories } from "@storybook/react-vite"; import * as stories from "./RoomStatusBarView.stories.tsx"; +import userEvent from "@testing-library/user-event"; -const { Default } = composeStories(stories); +const { WithConnectionLost, WithConsentLink, WithResourceLimit, WithUnsentMessages, WithLocalRoomRetry } = + composeStories(stories); describe("RoomStatusBarView", () => { - it("renders a history visible banner", () => { - const dismissFn = jest.fn(); - - const { container } = render(); + 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 = container.querySelector("button"); - expect(button).not.toBeNull(); - button?.click(); - expect(dismissFn).toHaveBeenCalled(); + 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 index 55bc08f689..5635c08db7 100644 --- a/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.tsx +++ b/packages/shared-components/src/room/RoomStatusBar/RoomStatusBarView.tsx @@ -5,12 +5,15 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; +import React, { useCallback, type JSX } from "react"; +import styles from "./RoomStatusBarView.module.css"; import { useViewModel } from "../../useViewModel"; -import { _t } from "../../utils/i18n"; import { type ViewModel } from "../../viewmodel"; - +import { RestartIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { useI18n } from "../../utils/i18nContext"; +import { Button, InlineSpinner, Text } from "@vector-im/compound-web"; +import { Banner } from "../../composer/Banner"; export interface RoomStatusBarViewActions { /** * Called when the user clicks on the 'resend all' button in the 'unsent messages' bar. @@ -20,11 +23,17 @@ export interface RoomStatusBarViewActions { /** * Called when the user clicks on the 'cancel all' button in the 'unsent messages' bar. */ - onCancelAllClick?: () => void; + onDeleteAllClick?: () => void; + + /** + * For local rooms which haven't been created yet, this will retry creating the room. + * @returns + */ + onRetryRoomCreationClick?: () => void; } export interface RoomStatusBarNoConnection { - connectionLost: boolean; + connectionLost: true; } export interface RoomStatusBarConsentState { @@ -32,27 +41,31 @@ export interface RoomStatusBarConsentState { } export interface RoomStatusBarResourceLimitedState { - consentUri: string; + resourceLimit: "monthly_active_user" | "hs_disabled" | string; + adminContactHref?: string; } export interface RoomStatusBarUnsentMessagesState { - consentUri: string; isResending: boolean; } +export interface RoomStatusBarLocalRoomError { + isRetryingRoomCreation: boolean; +} export interface RoomStatusBarViewSnapshot { - /** - * Whether the banner is currently visible. - */ - visible: boolean; - state: RoomStatusBarNoConnection|RoomStatusBarConsentState|RoomStatusBarResourceLimitedState|RoomStatusBarUnsentMessagesState|null; + state: + | RoomStatusBarNoConnection + | RoomStatusBarConsentState + | RoomStatusBarResourceLimitedState + | RoomStatusBarUnsentMessagesState + | RoomStatusBarLocalRoomError + | null; } /** * The view model for the banner. */ -export type RoomStatusBarViewModel = ViewModel & - RoomStatusBarViewActions; +export type RoomStatusBarViewModel = ViewModel & RoomStatusBarViewActions; interface RoomStatusBarViewProps { /** @@ -62,7 +75,7 @@ interface RoomStatusBarViewProps { } /** - * A component to alert that history is shared to new members of the room. + * A component to alert to a failure in the context of a room. * * @example * ```tsx @@ -70,9 +83,170 @@ interface RoomStatusBarViewProps { * ``` */ export function RoomStatusBarView({ vm }: Readonly): JSX.Element { - const { visible } = useViewModel(vm); - + const { translate: _t } = useI18n(); + const { state } = useViewModel(vm); + + const deleteAllClick = useCallback>( + (ev) => { + ev.preventDefault(); + vm.onDeleteAllClick?.(); + }, + [vm.onDeleteAllClick], + ); + + const resendClick = useCallback>( + (ev) => { + ev.preventDefault(); + vm.onResendAllClick?.(); + }, + [vm.onResendAllClick], + ); + + const retryRoomCreationClick = useCallback>( + (ev) => { + ev.preventDefault(); + vm.onRetryRoomCreationClick?.(); + }, + [vm.onRetryRoomCreationClick], + ); + + if (state === null) { + // Nothing to show! + return <>; + } + + if ("connectionLost" in state) { + return ( + +
+ {_t("room|status_bar|server_connectivity_lost_title")} + + {_t("room|status_bar|server_connectivity_lost_description")} + +
+
+ ); + } + + if ("consentUri" in state) { + return ( + + View Terms and Conditions + + } + > +
+ {_t("room|status_bar|requires_consent_agreement_title")} +
+
+ ); + } + + if ("resourceLimit" in state) { + const title = + { + monthly_active_user: _t("room|status_bar|monthly_user_limit_reached_title"), + hs_disabled: _t("room|status_bar|homeserver_blocked_title"), + }[state.resourceLimit] || _t("room|status_bar|exceeded_resource_limit_title"); + + return ( + + Contact admin + + ) + } + > +
+ {title} + + {_t("room|status_bar|exceeded_resource_limit_description")} + +
+
+ ); + } + + if ("isRetryingRoomCreation" in state) { + return ( + + {_t("action|retry")} + + } + > +
+ {_t("room|status_bar|some_messages_not_sent")} +
+
+ ); + } + + const actions = state.isResending ? ( + + ) : ( + <> + {vm.onDeleteAllClick && ( + + )} + {vm.onResendAllClick && ( + + )} + + ); + return ( -

Tada!

+ +
+ {_t("room|status_bar|failed_to_create_room_title")} + {_t("room|status_bar|select_messages_to_retry")} +
+
); } 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..646126afdc --- /dev/null +++ b/packages/shared-components/src/room/RoomStatusBar/__snapshots__/RoomStatusBarView.test.tsx.snap @@ -0,0 +1,504 @@ +// 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/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/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx deleted file mode 100644 index fb7df5938b..0000000000 --- a/src/components/structures/RoomStatusBar.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* -Copyright (c) 2025 Element Creations Ltd. -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, { useEffect, type JSX } from "react"; -import { type Room } from "matrix-js-sdk/src/matrix"; -import { RestartIcon, WarningIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - -import { _t } from "../../languageHandler"; -import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState"; -import AccessibleButton from "../views/elements/AccessibleButton"; -import InlineSpinner from "../views/elements/InlineSpinner"; -import { RoomStatusBarUnsentMessages } from "./RoomStatusBarUnsentMessages"; -import { useRoomStatusBarViewModel } from "../viewmodels/rooms/RoomStatusBarViewModel"; - -interface IProps { - // the room this statusbar is representing. - room: Room; - /** - * Called when the component becomes visible. - * @returns - */ - onVisible: () => void; - /** - * Called when the component becomes hidden. - * @returns - */ - onHidden: () => void; -} - -export function RoomStatusBar({ room, onVisible, onHidden }: IProps): JSX.Element | null { - const vm = useRoomStatusBarViewModel({ room }); - - useEffect(() => { - if (vm.visible) { - onVisible(); - } else { - onHidden(); - } - }, [vm.visible, onVisible, onHidden]); - - if (!vm.visible) { - return null; - } - if ("connectivityLost" in vm) { - return ( -
-
-
- -
-
- {_t("room|status_bar|server_connectivity_lost_title")} -
-
- {_t("room|status_bar|server_connectivity_lost_description")} -
-
-
-
-
- ); - } - - if (vm.isResending) { - return ( - - - {/* span for css */} - {_t("forward|sending")} - - } - /> - ); - } - - return ( - - {vm.onCancelAllClick && ( - - - {_t("room|status_bar|delete_all")} - - )} - {vm.onResendAllClick && ( - - - {_t("room|status_bar|retry_all")} - - )} - - } - /> - ); -} 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 82e3f3ff16..1221ffa74e 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,6 @@ 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 shouldHideEvent from "../../shouldHideEvent"; import { _t } from "../../languageHandler"; @@ -111,10 +111,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"; @@ -318,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 = ( [0]): ReactElement { const vm = useCreateAutoDisposedViewModel(() => new RoomStatusBarViewModel(props)); + useEffect(() => { + 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]); + return ; } 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/components/viewmodels/rooms/RoomStatusBarViewModel.tsx b/src/components/viewmodels/rooms/RoomStatusBarViewModel.tsx deleted file mode 100644 index 5a39d0066a..0000000000 --- a/src/components/viewmodels/rooms/RoomStatusBarViewModel.tsx +++ /dev/null @@ -1,175 +0,0 @@ -/* -Copyright 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 { - ClientEvent, - EventStatus, - type MatrixError, - type Room, - RoomEvent, - SyncState, - type SyncStateData, -} from "matrix-js-sdk/src/matrix"; -import React, { type ReactNode, useCallback, useMemo, useState } from "react"; -import { _t, _td } from "@element-hq/web-shared-components"; - -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; -import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; -import dis from "../../../dispatcher/dispatcher"; -import { Action } from "../../../dispatcher/actions"; -import Resend from "../../../Resend"; -import { messageForResourceLimitError } from "../../../utils/ErrorUtils"; -import ExternalLink from "../../views/elements/ExternalLink"; - -interface RoomStatusBarInvisible { - visible: false; -} - -interface RoomStatusBarWithError { - visible: true; - connectivityLost: boolean; -} - -interface RoomStatusBarWithUnsentMessages { - visible: true; - title: ReactNode; - description?: string; -} - -interface RoomStatusBarWithUnsentMessagesActions extends RoomStatusBarWithUnsentMessages { - isResending: false; - // 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; -} - -interface RoomStatusBarWithUnsentMessagesResending extends RoomStatusBarWithUnsentMessages { - isResending: true; -} - -type RoomStatusBarVM = - | RoomStatusBarWithError - | RoomStatusBarWithUnsentMessagesActions - | RoomStatusBarWithUnsentMessagesResending - | RoomStatusBarInvisible; - -interface IProps { - // the room this statusbar is representing. - room: Room; -} - -export function useRoomStatusBarViewModel({ room }: IProps): RoomStatusBarVM { - const client = useMatrixClientContext(); - const syncState = useTypedEventEmitterState( - client, - ClientEvent.Sync, - (state: SyncState, prevState: SyncState, data: SyncStateData) => { - return { state, data }; - }, - ); - const [isResending, setResending] = useState(false); - const unsentMessages = useTypedEventEmitterState(room, RoomEvent.LocalEchoUpdated, () => { - return room.getPendingEvents().filter(function (ev) { - const isNotSent = ev.status === EventStatus.NOT_SENT; - return isNotSent; - }); - }); - - const onResendAllClick = useCallback(() => { - setResending(true); - Resend.resendUnsentEvents(room).finally(() => { - setResending(false); - }); - dis.fire(Action.FocusSendMessageComposer); - }, [room]); - - const onCancelAllClick = useCallback(() => { - Resend.cancelUnsentEvents(room); - dis.fire(Action.FocusSendMessageComposer); - }, [room]); - - const unsentMessagesTitle = useMemo(() => { - let consentError: MatrixError | null = null; - let resourceLimitError: MatrixError | null = null; - for (const m of unsentMessages) { - if (m.error) { - if (m.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED") { - resourceLimitError = m.error; - } - if (m.error.errcode === "M_CONSENT_NOT_GIVEN") { - consentError = m.error; - break; // This is the most important thing to show, so break here if we find one. - } - } - } - if (consentError) { - return _t( - "room|status_bar|requires_consent_agreement", - {}, - { - consentLink: (sub) => ( - - {sub} - - ), - }, - ); - } else if (resourceLimitError) { - return 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 { - return _t("room|status_bar|some_messages_not_sent"); - } - }, [unsentMessages]); - - const hasConnectionError = useMemo(() => { - // 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( - syncState.data && syncState.data.error && syncState.data.error.name === "M_RESOURCE_LIMIT_EXCEEDED", - ); - return syncState.state === SyncState.Error && !errorIsMauError; - }, [syncState]); - - if (hasConnectionError) { - return { visible: true, connectivityLost: true }; - } - - if (unsentMessages.length) { - if (isResending) { - return { - visible: true, - title: unsentMessagesTitle, - description: _t("room|status_bar|select_messages_to_retry"), - isResending: true, - }; - } - return { - visible: true, - title: unsentMessagesTitle, - description: _t("room|status_bar|select_messages_to_retry"), - isResending, - onResendAllClick, - onCancelAllClick, - }; - } - - return { visible: false }; -} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 509a255e1e..f9f23ff950 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2127,16 +2127,18 @@ }, "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.", + "exceeded_resource_limit_title": "Your message wasn't sent because this homeserver has exceeded a resource limit.", "history_visible": "Messages you send will be shared with new members invited to this room. Learn more", - "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.", + "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.", + "exceeded_resource_limit_description": "Please contact your service administrator to continue using the service.", + "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" + "some_messages_not_sent": "Some of your messages have not been sent", + "failed_to_create_room_title": "Could not start a chat with this user" }, "unknown_status_code_for_timeline_jump": "unknown status code", "unread_notifications_predecessor": { diff --git a/src/viewmodels/room/RoomStatusBar.ts b/src/viewmodels/room/RoomStatusBar.ts index 957b8f3f10..2e9b6ebca3 100644 --- a/src/viewmodels/room/RoomStatusBar.ts +++ b/src/viewmodels/room/RoomStatusBar.ts @@ -10,54 +10,197 @@ import { type RoomStatusBarViewModel as RoomStatusBarViewModelInterface, type RoomStatusBarViewSnapshot, } from "@element-hq/web-shared-components"; -import { HistoryVisibility, type Room } from "matrix-js-sdk/src/matrix"; +import { + ClientEvent, + SyncState, + MatrixClient, + type Room, + 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"; -import SettingsStore from "../../settings/SettingsStore"; -import { SettingLevel } from "../../settings/SettingLevel"; - -interface Props { +interface PropsWithRoom { room: Room; +} +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 { - - private static readonly computeSnapshot = ( - room: Room - ): RoomStatusBarViewSnapshot => { - const featureEnabled = SettingsStore.getValue("feature_share_history_on_invite"); - const acknowledged = SettingsStore.getValue("acknowledgedHistoryVisibility", room.roomId); - + private static readonly determineStateForUnreadMessages = (room: Room): RoomStatusBarViewSnapshot["state"] => { + const unsentMessages = room.getPendingEvents().filter((ev) => ev.status === EventStatus.NOT_SENT); + if (unsentMessages.length === 0) { + return null; + } + let resourceLimitError: MatrixError | null = null; + for (const m of unsentMessages) { + if (m.error) { + if (m.error.errcode === "M_CONSENT_NOT_GIVEN") { + // This is the most important thing to show, so break here if we find one. + return { + // This MUST exist. + consentUri: m.error.data.consent_uri, + }; + } + if (m.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED") { + resourceLimitError = m.error; + } + } + } + if (resourceLimitError) { + return { + resourceLimit: resourceLimitError.data.limit_type ?? "", + adminContactHref: resourceLimitError.data.admin_contact, + }; + } return { - visible: true + isResending: false, }; }; - public constructor(props: Props) { - super(props, RoomStatusBarViewModel.computeSnapshot(props.room)); - } - - private setSnapshot(): void { - const acknowledged = SettingsStore.getValue("acknowledgedHistoryVisibility", this.props.room.roomId); - - // Reset the acknowleded flag when the history visibility is set back to joined. - if (this.props.room.getHistoryVisibility() === HistoryVisibility.Joined && acknowledged) { - SettingsStore.setValue( - "acknowledgedHistoryVisibility", - this.props.room.roomId, - SettingLevel.ROOM_ACCOUNT, - false, - ); + private static readonly computeSnapshot = ( + room: Room, + client: MatrixClient, + isResending: boolean, + isRetryingRoomCreation: boolean, + ): RoomStatusBarViewSnapshot => { + if (room instanceof LocalRoom) { + if (isRetryingRoomCreation) { + return { + state: { + isRetryingRoomCreation, + }, + }; + } + if (room.isError) { + return { + state: { + isRetryingRoomCreation, + }, + }; + } else { + // Local rooms do not have to worry about these other conditions :) + return { state: null }; + } } - this.snapshot.set(RoomStatusBarViewModel.computeSnapshot(this.props.room, this.props.threadId)); + // If we're in the process of resending, don't flicker. + if (isResending) { + return { + state: { + isResending, + }, + }; + } + const syncState = client.getSyncState(); + + // Highest priority. + if (syncState === SyncState.Error) { + // 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 syncData = client.getSyncStateData(); + if (syncData?.error?.name === "M_RESOURCE_LIMIT_EXCEEDED") { + const error = syncData.error as MatrixError; + return { + state: { + // TODO: Correct limit + resourceLimit: error.data.limit_type ?? "", + adminContactHref: error.data.admin_contact, + }, + }; + } else { + return { + state: { + connectionLost: true, + }, + }; + } + } + + // Then check messages. + return { state: this.determineStateForUnreadMessages(room) }; + }; + + private readonly client: MatrixClient; + + public constructor(props: Props) { + const client = MatrixClientPeg.safeGet(); + super(props, RoomStatusBarViewModel.computeSnapshot(props.room, client, false, false)); + this.client = client; + client.on(ClientEvent.Sync, this.onClientSync); + props.room.on(RoomEvent.LocalEchoUpdated, this.onRoomLocalEchoUpdated); + } + + private readonly onClientSync = () => { + this.setSnapshot(); + }; + + private readonly onRoomLocalEchoUpdated = () => { + this.setSnapshot(); + }; + + private isResending = false; + private isRetryingRoomCreation = false; + + private setSnapshot(): void { + this.snapshot.set( + RoomStatusBarViewModel.computeSnapshot( + this.props.room, + this.client, + this.isResending, + this.isRetryingRoomCreation, + ), + ); } public dispose(): void { + this.client.off(ClientEvent.Sync, this.onClientSync); + this.props.room.on(RoomEvent.LocalEchoUpdated, this.onRoomLocalEchoUpdated); super.dispose(); } + + public onDeleteAllClick = (): void => { + Resend.cancelUnsentEvents(this.props.room); + dis.fire(Action.FocusSendMessageComposer); + this.setSnapshot(); + }; + + public onResendAllClick = (): void => { + this.isResending = true; + this.setSnapshot(); + void Resend.resendUnsentEvents(this.props.room).finally(() => { + this.isResending = false; + this.setSnapshot(); + }); + dis.fire(Action.FocusSendMessageComposer); + }; + + public onRetryRoomCreationClick = (): void => { + // eslint-disable-next-line react-compiler/react-compiler + (this.props.room as LocalRoom).state = LocalRoomState.NEW; + dis.dispatch({ + action: "local_room_event", + roomId: this.props.room.roomId, + }); + }; } 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 cc08089312..0000000000 --- a/test/unit-tests/components/structures/RoomStatusBar-test.tsx +++ /dev/null @@ -1,105 +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 } from "../../../../src/components/structures/RoomStatusBar"; -import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; -import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import { mkEvent, stubClient } from "../../../test-utils/test-utils"; - -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( {}} onHidden={() => {}} />, { - wrapper: ({ children }) => ( - {children} - ), - }); - - 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 6b474c8ffe..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 31cae614d7..21abd42fd4 100644 --- a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -200,53 +200,63 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
+
-
- - ! - -
-
-
-
Some of your messages have not been sent -
+

-
+
+
+ + + actions +