Refactor RoomStatusBar into MVVM (#31523)

* Refactor RoomStatusBar into MVVM

* cleanup

* updated snaps

* More cleanup

* fix loop

* fixup

* drop comment

* lint

* cleanup console statements

* Starting to move to a MVVM v2 component.

* extra

* Refactor as a shared-componend / MVVM v2

* some cleanup

* i18n for banner

* remove removed css

* Update playwright tests to have a two stage on the consent bar.

* Update snaps

* Update snapshots

* cleanup

* update snaps

* refactor to use enum

* fix slight differences in pw snaps

* Add unit tests

* fix snaps

* snaps updated

* more test cleanups

* fix snaps

* fixed now?

* Disable animationsq

* lint lint lint

* remove console

* lint

* fix snap

* Refactor based on review comments.

* update view model test

* oops!

* fix snap

* Update snaps

* snap snap snap

* switch to a const map of strings

* Use this.disposables

* Update translations to be inside shared-components

* fix the tac

* Also retry

* Cleanup

* update snaps

* update other snaps

* snap updates
This commit is contained in:
Will Hunt 2026-01-12 21:13:15 +00:00 committed by GitHub
parent d9be851965
commit 2e6cf8734b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1662 additions and 1022 deletions

View File

@ -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}`,

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -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<BannerProps>): ReactElement {
}: PropsWithChildren<BannerProps & HTMLAttributes<HTMLDivElement>>): ReactElement {
const classes = classNames(styles.banner, className);
const icon = useMemo(() => {
const icon = useMemo((): ReactElement => {
switch (type) {
case "critical":
return <ErrorIcon fontSize={24} {...props} />;
return <ErrorIcon fontSize={24} />;
case "info":
return <InfoIcon fontSize={24} {...props} />;
return <InfoIcon fontSize={24} />;
case "success":
return <CheckCircleIcon fontSize={24} {...props} />;
return <CheckCircleIcon fontSize={24} />;
default:
return <InfoIcon fontSize={24} {...props} />;
return <InfoIcon fontSize={24} />;
}
}, [type, props]);
}, [type]);
return (
<div {...props} className={classes} data-type={type}>

View File

@ -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. <a>Learn More</a>"
"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. <a>Learn More</a>",
"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",

View File

@ -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";

View File

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

View File

@ -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 <RoomStatusBarView vm={vm} />;
};
export default {
title: "room/RoomStatusBarView",
component: RoomStatusBarViewWrapper,
tags: ["autodocs"],
argTypes: {},
args: {
onResendAllClick: fn(),
onDeleteAllClick: fn(),
onRetryRoomCreationClick: fn(),
onTermsAndConditionsClicked: fn(),
},
} as Meta<typeof RoomStatusBarViewWrapper>;
const Template: StoryFn<typeof RoomStatusBarViewWrapper> = (args) => <RoomStatusBarViewWrapper {...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,
};

View File

@ -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(<WithConnectionLost />);
expect(container).toMatchSnapshot();
});
it("renders resource limit error", () => {
const { container } = render(<WithResourceLimit />);
expect(container).toMatchSnapshot();
});
it("renders consent link", () => {
const { container, getByRole } = render(<WithConsentLink />);
expect(container).toMatchSnapshot();
const button = getByRole("link");
expect(button.getAttribute("href")).toEqual("#example");
});
it("renders unsent messages", async () => {
const { container } = render(
<WithUnsentMessages onDeleteAllClick={jest.fn()} onRetryRoomCreationClick={jest.fn()} />,
);
expect(container).toMatchSnapshot();
});
it("renders unsent messages and deletes all", async () => {
const onDeleteAllClick = jest.fn();
const { container, getByRole } = render(<WithUnsentMessages onDeleteAllClick={onDeleteAllClick} />);
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(<WithUnsentMessages onResendAllClick={onResendAllClick} />);
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(
<WithLocalRoomRetry onRetryRoomCreationClick={onRetryRoomCreationClick} />,
);
expect(container).toMatchSnapshot();
const button = getByRole("button", { name: "Retry" });
await userEvent.click(button);
expect(onRetryRoomCreationClick).toHaveBeenCalled();
});
});

View File

@ -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<void>;
/**
* 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<RoomStatusBarViewSnapshot> & 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
* <RoomStatusBarView vm={RoomStatusBarViewModel} />
* ```
*/
export function RoomStatusBarView({ vm }: Readonly<RoomStatusBarViewProps>): JSX.Element | null {
const { translate: _t } = useI18n();
const snapshot = useViewModel(vm);
const bannerTitleId = useId();
const deleteAllClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(ev) => {
ev.preventDefault();
vm.onDeleteAllClick?.();
},
[vm],
);
const resendClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(ev) => {
ev.preventDefault();
void vm.onResendAllClick?.();
},
[vm],
);
const retryRoomCreationClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(ev) => {
ev.preventDefault();
vm.onRetryRoomCreationClick?.();
},
[vm],
);
const termsAndConditionsClicked = useCallback<React.MouseEventHandler<HTMLAnchorElement>>(() => {
// 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 (
<Banner type="critical" role="status" aria-labelledby={bannerTitleId}>
<div className={styles.container}>
<Text id={bannerTitleId} weight="semibold">
{_t("room|status_bar|server_connectivity_lost_title")}
</Text>
<Text className={styles.description} size="sm">
{_t("room|status_bar|server_connectivity_lost_description")}
</Text>
</div>
</Banner>
);
case RoomStatusBarState.NeedsConsent:
return (
<Banner
type="critical"
role="status"
aria-labelledby={bannerTitleId}
actions={
<Button
onClick={termsAndConditionsClicked}
kind="secondary"
size="sm"
as="a"
href={snapshot.consentUri}
target="_blank"
rel="noreferrer noopener"
>
{_t("terms|tac_button")}
</Button>
}
>
<div className={styles.container}>
<Text id={bannerTitleId} weight="semibold">
{_t("room|status_bar|requires_consent_agreement_title")}
</Text>
</div>
</Banner>
);
case RoomStatusBarState.ResourceLimited:
return (
<Banner
type="critical"
role="status"
aria-labelledby={bannerTitleId}
actions={
snapshot.adminContactHref && (
<Button
kind="secondary"
size="sm"
as="a"
href={snapshot.adminContactHref}
target="_blank"
rel="noreferrer noopener"
>
Contact admin
</Button>
)
}
>
<div className={styles.container}>
<Text id={bannerTitleId} weight="semibold">
{{
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")}
</Text>
<Text className={styles.description} size="sm">
{_t("room|status_bar|exceeded_resource_limit_description")}
</Text>
</div>
</Banner>
);
case RoomStatusBarState.LocalRoomFailed:
return (
<Banner
role="status"
type="critical"
aria-labelledby={bannerTitleId}
actions={
<Button
size="sm"
kind="secondary"
className={styles.container}
Icon={RestartIcon}
onClick={retryRoomCreationClick}
>
{_t("action|retry")}
</Button>
}
>
<Text id={bannerTitleId} weight="semibold" className={styles.container}>
{_t("room|status_bar|failed_to_create_room_title")}
</Text>
</Banner>
);
case RoomStatusBarState.UnsentMessages:
return (
<Banner
role="status"
type="critical"
actions={
snapshot.isResending ? (
<InlineSpinner />
) : (
<>
{vm.onDeleteAllClick && (
<Button
size="sm"
kind="destructive"
Icon={DeleteIcon}
disabled={snapshot.isResending}
onClick={deleteAllClick}
>
{_t("room|status_bar|delete_all")}
</Button>
)}
{vm.onResendAllClick && (
<Button
size="sm"
kind="secondary"
Icon={RestartIcon}
disabled={snapshot.isResending}
onClick={resendClick}
className={styles.container}
>
{_t("room|status_bar|retry_all")}
</Button>
)}
</>
)
}
aria-labelledby={bannerTitleId}
>
<div className={styles.container}>
<Text id={bannerTitleId} weight="semibold">
{_t("room|status_bar|some_messages_not_sent")}
</Text>
<Text className={styles.description} size="sm">
{_t("room|status_bar|select_messages_to_retry")}
</Text>
</div>
</Banner>
);
default:
// We should never get into this state.
return null;
}
}

View File

@ -0,0 +1,520 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RoomStatusBarView renders connection lost 1`] = `
<div>
<div
aria-labelledby="_r_0_"
class="banner"
data-type="critical"
role="status"
>
<div
class="icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
</div>
<div
class="content"
>
<div
class="container"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
id="_r_0_"
>
Connectivity to the server has been lost.
</p>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31 description"
>
Sent messages will be stored until your connection has returned.
</p>
</div>
</div>
<div
class="actions"
/>
</div>
</div>
`;
exports[`RoomStatusBarView renders consent link 1`] = `
<div>
<div
aria-labelledby="_r_2_"
class="banner"
data-type="critical"
role="status"
>
<div
class="icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
</div>
<div
class="content"
>
<div
class="container"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
id="_r_2_"
>
You can't send any messages until you review and agree to our terms and conditions.
</p>
</div>
</div>
<div
class="actions"
>
<a
class="_button_13vu4_8"
data-kind="secondary"
data-size="sm"
href="#example"
rel="noreferrer noopener"
role="link"
tabindex="0"
target="_blank"
>
Review terms and conditions
</a>
</div>
</div>
</div>
`;
exports[`RoomStatusBarView renders local room error 1`] = `
<div>
<div
aria-labelledby="_r_6_"
class="banner"
data-type="critical"
role="status"
>
<div
class="icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
</div>
<div
class="content"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 container"
id="_r_6_"
>
Could not start a chat with this user
</p>
</div>
<div
class="actions"
>
<button
class="_button_13vu4_8 container _has-icon_13vu4_60"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.93 8A8 8 0 1 1 4 12a1 1 0 1 0-2 0c0 5.523 4.477 10 10 10s10-4.477 10-10a10 10 0 0 0-.832-4A10 10 0 0 0 12 2a9.99 9.99 0 0 0-8 3.999V4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2H5.755A7.99 7.99 0 0 1 12 4a8 8 0 0 1 6.93 4"
/>
</svg>
Retry
</button>
</div>
</div>
</div>
`;
exports[`RoomStatusBarView renders resource limit error 1`] = `
<div>
<div
aria-labelledby="_r_1_"
class="banner"
data-type="critical"
role="status"
>
<div
class="icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
</div>
<div
class="content"
>
<div
class="container"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
id="_r_1_"
>
Your message wasn't sent because this homeserver has been blocked by its administrator.
</p>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31 description"
>
Please contact your service administrator to continue using the service.
</p>
</div>
</div>
<div
class="actions"
>
<a
class="_button_13vu4_8"
data-kind="secondary"
data-size="sm"
href="#example"
rel="noreferrer noopener"
role="link"
tabindex="0"
target="_blank"
>
Contact admin
</a>
</div>
</div>
</div>
`;
exports[`RoomStatusBarView renders unsent messages 1`] = `
<div>
<div
aria-labelledby="_r_3_"
class="banner"
data-type="critical"
role="status"
>
<div
class="icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
</div>
<div
class="content"
>
<div
class="container"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
id="_r_3_"
>
Some of your messages have not been sent
</p>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31 description"
>
You can select all or individual messages to retry or delete
</p>
</div>
</div>
<div
class="actions"
>
<button
aria-disabled="false"
class="_button_13vu4_8 _has-icon_13vu4_60 _destructive_13vu4_110"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
Delete all
</button>
<button
aria-disabled="false"
class="_button_13vu4_8 container _has-icon_13vu4_60"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.93 8A8 8 0 1 1 4 12a1 1 0 1 0-2 0c0 5.523 4.477 10 10 10s10-4.477 10-10a10 10 0 0 0-.832-4A10 10 0 0 0 12 2a9.99 9.99 0 0 0-8 3.999V4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2H5.755A7.99 7.99 0 0 1 12 4a8 8 0 0 1 6.93 4"
/>
</svg>
Retry all
</button>
</div>
</div>
</div>
`;
exports[`RoomStatusBarView renders unsent messages and deletes all 1`] = `
<div>
<div
aria-labelledby="_r_4_"
class="banner"
data-type="critical"
role="status"
>
<div
class="icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
</div>
<div
class="content"
>
<div
class="container"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
id="_r_4_"
>
Some of your messages have not been sent
</p>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31 description"
>
You can select all or individual messages to retry or delete
</p>
</div>
</div>
<div
class="actions"
>
<button
aria-disabled="false"
class="_button_13vu4_8 _has-icon_13vu4_60 _destructive_13vu4_110"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
Delete all
</button>
<button
aria-disabled="false"
class="_button_13vu4_8 container _has-icon_13vu4_60"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.93 8A8 8 0 1 1 4 12a1 1 0 1 0-2 0c0 5.523 4.477 10 10 10s10-4.477 10-10a10 10 0 0 0-.832-4A10 10 0 0 0 12 2a9.99 9.99 0 0 0-8 3.999V4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2H5.755A7.99 7.99 0 0 1 12 4a8 8 0 0 1 6.93 4"
/>
</svg>
Retry all
</button>
</div>
</div>
</div>
`;
exports[`RoomStatusBarView renders unsent messages and resends all 1`] = `
<div>
<div
aria-labelledby="_r_5_"
class="banner"
data-type="critical"
role="status"
>
<div
class="icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
</div>
<div
class="content"
>
<div
class="container"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
id="_r_5_"
>
Some of your messages have not been sent
</p>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31 description"
>
You can select all or individual messages to retry or delete
</p>
</div>
</div>
<div
class="actions"
>
<button
aria-disabled="false"
class="_button_13vu4_8 _has-icon_13vu4_60 _destructive_13vu4_110"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
Delete all
</button>
<button
aria-disabled="false"
class="_button_13vu4_8 container _has-icon_13vu4_60"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.93 8A8 8 0 1 1 4 12a1 1 0 1 0-2 0c0 5.523 4.477 10 10 10s10-4.477 10-10a10 10 0 0 0-.832-4A10 10 0 0 0 12 2a9.99 9.99 0 0 0-8 3.999V4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2H5.755A7.99 7.99 0 0 1 12 4a8 8 0 0 1 6.93 4"
/>
</svg>
Retry all
</button>
</div>
</div>
</div>
`;

View File

@ -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";

View File

@ -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");

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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";

View File

@ -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;
}
}

View File

@ -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",

View File

@ -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<IProps, IState> {
private unmounted = false;
public static contextType = MatrixClientContext;
declare public context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
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) => (
<ExternalLink href={consentError!.data?.consent_uri} target="_blank" rel="noreferrer noopener">
{sub}
</ExternalLink>
),
},
);
} 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 = (
<>
<AccessibleButton onClick={this.onCancelAllClick}>
<DeleteIcon />
{_t("room|status_bar|delete_all")}
</AccessibleButton>
<AccessibleButton onClick={this.onResendAllClick} className="mx_RoomStatusBar_unsentRetry">
<RestartIcon />
{_t("room|status_bar|retry_all")}
</AccessibleButton>
</>
);
if (this.state.isResending) {
buttonRow = (
<>
<InlineSpinner w={20} h={20} />
{/* span for css */}
<span>{_t("forward|sending")}</span>
</>
);
}
return (
<RoomStatusBarUnsentMessages
title={title}
description={_t("room|status_bar|select_messages_to_retry")}
notificationState={StaticNotificationState.RED_EXCLAMATION}
buttons={buttonRow}
/>
);
}
public render(): React.ReactNode {
if (this.shouldShowConnectionError()) {
return (
<div className="mx_RoomStatusBar">
<div role="alert">
<div className="mx_RoomStatusBar_connectionLostBar">
<WarningIcon width="24px" height="24px" />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{_t("room|status_bar|server_connectivity_lost_title")}
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{_t("room|status_bar|server_connectivity_lost_description")}
</div>
</div>
</div>
</div>
</div>
);
}
if (this.state.unsentMessages.length > 0 || this.state.isResending) {
return this.getUnsentMessageContent();
}
return null;
}
}

View File

@ -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 (
<div className="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages">
<div role="alert">
<div className="mx_RoomStatusBar_unsentBadge">
<NotificationBadge notification={props.notificationState} />
</div>
<div>
<div className="mx_RoomStatusBar_unsentTitle">{props.title}</div>
{props.description && <div className="mx_RoomStatusBar_unsentDescription">{props.description}</div>}
</div>
<div className="mx_RoomStatusBar_unsentButtonBar">{props.buttons}</div>
</div>
</div>
);
};

View File

@ -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 = <EncryptionEvent mxEvent={encryptionEvent} />;
}
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 = (
<AccessibleButton onClick={onRetryClicked}>
<RestartIcon />
{_t("action|retry")}
</AccessibleButton>
);
statusBar = (
<RoomStatusBarUnsentMessages
title={_t("room|status_bar|some_messages_not_sent")}
notificationState={StaticNotificationState.RED_EXCLAMATION}
buttons={buttons}
/>
);
statusBar = <RoomStatusBarWrappedView room={room} />;
} else {
composer = (
<MessageComposer
@ -400,6 +377,33 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement
</div>
);
}
/**
* Wrap a RoomStatusBarView and ViewModel into one component, for usage with legacy React components.
*/
function RoomStatusBarWrappedView(props: ConstructorParameters<typeof RoomStatusBarViewModel>[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 <RoomStatusBarView vm={vm} />;
}
export class RoomView extends React.Component<IRoomProps, IRoomState> {
// 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<IRoomProps, IRoomState> {
}
}
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<IRoomProps, IRoomState> {
} else if (!this.state.search) {
isStatusAreaExpanded = this.state.statusBarVisible;
statusBar = (
<RoomStatusBar
<RoomStatusBarWrappedView
room={this.state.room}
isPeeking={myMembership !== KnownMembership.Join}
onInviteClick={this.onInviteClick}
onVisible={this.onStatusBarVisible}
onHidden={this.onStatusBarHidden}
/>

View File

@ -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<IProps, IState> {
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.

View File

@ -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 <a>contact your service administrator</a> to continue using the service.",
"homeserver_blocked": "Your message wasn't sent because this homeserver has been blocked by its administrator. Please <a>contact your service administrator</a> 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 <a>contact your service administrator</a> to continue using the service.",
"requires_consent_agreement": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.",
"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.",

View File

@ -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,

View File

@ -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<RoomStatusBarViewSnapshot, Props>
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<void> => {
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,
});
};
}

View File

@ -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([]),

View File

@ -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<MatrixClient>;

View File

@ -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(<RoomStatusBar room={room} />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={client}>{children}</MatrixClientContext.Provider>
),
});
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("<RoomStatusBar />", () => {
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();
});
});
});
});

View File

@ -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 = <div>{buttonsText}</div>;
beforeEach(() => {
render(
<RoomStatusBarUnsentMessages
title={title}
description={description}
buttons={buttons}
notificationState={StaticNotificationState.RED_EXCLAMATION}
/>,
);
});
it("should render the values passed as props", () => {
screen.getByText(title);
screen.getByText(description);
screen.getByText(buttonsText);
// notification state
screen.getByText("!");
});
});

View File

@ -1,182 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RoomStatusBar <RoomStatusBar /> unsent messages should render warning when messages are unsent due to consent 1`] = `
<div>
<div
class="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages"
>
<div
role="alert"
>
<div
class="mx_RoomStatusBar_unsentBadge"
>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_2char cpd-theme-light"
>
<span
class="mx_NotificationBadge_count"
>
!
</span>
</div>
</div>
<div>
<div
class="mx_RoomStatusBar_unsentTitle"
>
<span>
You can't send any messages until you review and agree to
<a
class="mx_ExternalLink"
rel="noreferrer noopener"
target="_blank"
>
our terms and conditions
<svg
class="mx_ExternalLink_icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 3h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2"
/>
<path
d="M15 3h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V6.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L17.586 5H15a1 1 0 1 1 0-2"
/>
</svg>
</a>
.
</span>
</div>
<div
class="mx_RoomStatusBar_unsentDescription"
>
You can select all or individual messages to retry or delete
</div>
</div>
<div
class="mx_RoomStatusBar_unsentButtonBar"
>
<div
class="mx_AccessibleButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
Delete all
</div>
<div
class="mx_AccessibleButton mx_RoomStatusBar_unsentRetry"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.93 8A8 8 0 1 1 4 12a1 1 0 1 0-2 0c0 5.523 4.477 10 10 10s10-4.477 10-10a10 10 0 0 0-.832-4A10 10 0 0 0 12 2a9.99 9.99 0 0 0-8 3.999V4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2H5.755A7.99 7.99 0 0 1 12 4a8 8 0 0 1 6.93 4"
/>
</svg>
Retry all
</div>
</div>
</div>
</div>
</div>
`;
exports[`RoomStatusBar <RoomStatusBar /> unsent messages should render warning when messages are unsent due to resource limit 1`] = `
<div>
<div
class="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages"
>
<div
role="alert"
>
<div
class="mx_RoomStatusBar_unsentBadge"
>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_2char cpd-theme-light"
>
<span
class="mx_NotificationBadge_count"
>
!
</span>
</div>
</div>
<div>
<div
class="mx_RoomStatusBar_unsentTitle"
>
Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.
</div>
<div
class="mx_RoomStatusBar_unsentDescription"
>
You can select all or individual messages to retry or delete
</div>
</div>
<div
class="mx_RoomStatusBar_unsentButtonBar"
>
<div
class="mx_AccessibleButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
Delete all
</div>
<div
class="mx_AccessibleButton mx_RoomStatusBar_unsentRetry"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.93 8A8 8 0 1 1 4 12a1 1 0 1 0-2 0c0 5.523 4.477 10 10 10s10-4.477 10-10a10 10 0 0 0-.832-4A10 10 0 0 0 12 2a9.99 9.99 0 0 0-8 3.999V4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2H5.755A7.99 7.99 0 0 1 12 4a8 8 0 0 1 6.93 4"
/>
</svg>
Retry all
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -212,53 +212,61 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
</div>
</div>
<div
class="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages"
aria-labelledby="_r_ti_"
class="_banner_48r66_20"
data-type="critical"
role="status"
>
<div
role="alert"
class="_icon_48r66_63"
>
<div
class="mx_RoomStatusBar_unsentBadge"
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_2char cpd-theme-light"
>
<span
class="mx_NotificationBadge_count"
>
!
</span>
</div>
</div>
<div>
<div
class="mx_RoomStatusBar_unsentTitle"
>
Some of your messages have not been sent
</div>
</div>
<div
class="mx_RoomStatusBar_unsentButtonBar"
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
</div>
<div
class="_content_48r66_51"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 _container_mqidv_1"
id="_r_ti_"
>
<div
class="mx_AccessibleButton"
role="button"
tabindex="0"
Could not start a chat with this user
</p>
</div>
<div
class="_actions_48r66_83"
>
<button
class="_button_13vu4_8 _container_mqidv_1 _has-icon_13vu4_60"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.93 8A8 8 0 1 1 4 12a1 1 0 1 0-2 0c0 5.523 4.477 10 10 10s10-4.477 10-10a10 10 0 0 0-.832-4A10 10 0 0 0 12 2a9.99 9.99 0 0 0-8 3.999V4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2H5.755A7.99 7.99 0 0 1 12 4a8 8 0 0 1 6.93 4"
/>
</svg>
Retry
</div>
</div>
<path
d="M18.93 8A8 8 0 1 1 4 12a1 1 0 1 0-2 0c0 5.523 4.477 10 10 10s10-4.477 10-10a10 10 0 0 0-.832-4A10 10 0 0 0 12 2a9.99 9.99 0 0 0-8 3.999V4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2H5.755A7.99 7.99 0 0 1 12 4a8 8 0 0 1 6.93 4"
/>
</svg>
Retry
</button>
</div>
</div>
</main>
@ -409,7 +417,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
>
<svg
aria-label="Messages in this room are not end-to-end encrypted"
aria-labelledby="_r_qd_"
aria-labelledby="_r_qm_"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@ -1373,7 +1381,7 @@ exports[`RoomView should hide the header when hideHeader=true 1`] = `
>
<svg
aria-label="Messages in this room are not end-to-end encrypted"
aria-labelledby="_r_10_"
aria-labelledby="_r_12_"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@ -1682,7 +1690,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_5b_"
aria-labelledby="_r_5e_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -1709,7 +1717,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_5g_"
aria-labelledby="_r_5j_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -1724,7 +1732,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
</button>
<button
aria-label="Threads"
aria-labelledby="_r_5l_"
aria-labelledby="_r_5o_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -1751,7 +1759,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
</button>
<button
aria-label="Room info"
aria-labelledby="_r_5q_"
aria-labelledby="_r_5t_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -1781,7 +1789,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
>
<div
aria-label="0 members"
aria-labelledby="_r_5v_"
aria-labelledby="_r_62_"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@ -1848,7 +1856,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
>
<svg
aria-label="Messages in this room are not end-to-end encrypted"
aria-labelledby="_r_6a_"
aria-labelledby="_r_6e_"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@ -2157,7 +2165,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_2e_"
aria-labelledby="_r_2g_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -2184,7 +2192,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_2j_"
aria-labelledby="_r_2l_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -2199,7 +2207,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
</button>
<button
aria-label="Threads"
aria-labelledby="_r_2o_"
aria-labelledby="_r_2q_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -2226,7 +2234,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
</button>
<button
aria-label="Room info"
aria-labelledby="_r_2t_"
aria-labelledby="_r_2v_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -2256,7 +2264,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
>
<div
aria-label="0 members"
aria-labelledby="_r_32_"
aria-labelledby="_r_34_"
class="mx_AccessibleButton mx_FacePile mx_FacePile_toggled"
role="button"
tabindex="0"
@ -2323,7 +2331,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
>
<svg
aria-label="Messages in this room are not end-to-end encrypted"
aria-labelledby="_r_3d_"
aria-labelledby="_r_3g_"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@ -2632,7 +2640,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_f5_"
aria-labelledby="_r_fc_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -2659,7 +2667,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_fa_"
aria-labelledby="_r_fh_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -2674,7 +2682,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Threads"
aria-labelledby="_r_ff_"
aria-labelledby="_r_fm_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -2701,7 +2709,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Room info"
aria-labelledby="_r_fk_"
aria-labelledby="_r_fr_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -2731,7 +2739,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
>
<div
aria-label="0 members"
aria-labelledby="_r_fp_"
aria-labelledby="_r_g0_"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@ -2846,7 +2854,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_f5_"
aria-labelledby="_r_fc_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -2873,7 +2881,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_fa_"
aria-labelledby="_r_fh_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -2888,7 +2896,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Threads"
aria-labelledby="_r_ff_"
aria-labelledby="_r_fm_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -2915,7 +2923,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Room info"
aria-labelledby="_r_fk_"
aria-labelledby="_r_fr_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -2945,7 +2953,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
>
<div
aria-label="0 members"
aria-labelledby="_r_fp_"
aria-labelledby="_r_g0_"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@ -3014,7 +3022,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
tabindex="0"
>
<div
aria-labelledby="_r_g4_"
aria-labelledby="_r_gc_"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
data-testid="e2e-icon"
style="width: 12px; height: 12px;"
@ -3349,7 +3357,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</button>
<button
aria-label="Chat"
aria-labelledby="_r_k7_"
aria-labelledby="_r_kg_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -3376,7 +3384,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</button>
<button
aria-label="Threads"
aria-labelledby="_r_kc_"
aria-labelledby="_r_kl_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -3403,7 +3411,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</button>
<button
aria-label="Room info"
aria-labelledby="_r_kh_"
aria-labelledby="_r_kq_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -3433,7 +3441,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
>
<div
aria-label="0 members"
aria-labelledby="_r_km_"
aria-labelledby="_r_kv_"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@ -3518,7 +3526,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</p>
</div>
<button
aria-labelledby="_r_kv_"
aria-labelledby="_r_l8_"
class="_icon-button_1pz9o_8"
data-kind="secondary"
data-testid="base-card-close-button"
@ -3578,7 +3586,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
>
<svg
aria-label="Messages in this room are not end-to-end encrypted"
aria-labelledby="_r_l4_"
aria-labelledby="_r_ld_"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"

View File

@ -25,7 +25,6 @@ import { NotificationStateEvents } from "../../../../src/stores/notifications/No
import { NotificationLevel } from "../../../../src/stores/notifications/NotificationLevel";
import { createMessageEventContent } from "../../../test-utils/events";
import SettingsStore from "../../../../src/settings/SettingsStore";
import * as RoomStatusBarModule from "../../../../src/components/structures/RoomStatusBar";
import * as UnreadModule from "../../../../src/Unread";
describe("RoomNotificationState", () => {
@ -210,7 +209,7 @@ describe("RoomNotificationState", () => {
describe("computed attributes", () => {
beforeEach(() => {
jest.spyOn(RoomStatusBarModule, "getUnsentMessages").mockReturnValue([]);
jest.spyOn(room, "getPendingEvents").mockReturnValue([]);
jest.spyOn(UnreadModule, "doesRoomHaveUnreadMessages").mockReturnValue(false);
});
@ -221,7 +220,9 @@ describe("RoomNotificationState", () => {
});
it("should has isUnsetMessage at true", () => {
jest.spyOn(RoomStatusBarModule, "getUnsentMessages").mockReturnValue([{} as MatrixEvent]);
jest.spyOn(room, "getPendingEvents").mockReturnValue([
mkEvent({ status: EventStatus.NOT_SENT, user: "@foobar:example.org", type: "any.event", content: {} }),
]);
const roomNotifState = new RoomNotificationState(room, false);
expect(roomNotifState.isUnsentMessage).toBe(true);
});
@ -239,7 +240,9 @@ describe("RoomNotificationState", () => {
room.updateMyMembership(KnownMembership.Knock);
expect(roomNotifState.isMention).toBe(false);
jest.spyOn(RoomStatusBarModule, "getUnsentMessages").mockReturnValue([{} as MatrixEvent]);
jest.spyOn(room, "getPendingEvents").mockReturnValue([
mkEvent({ status: EventStatus.NOT_SENT, user: "@foobar:example.org", type: "any.event", content: {} }),
]);
room.updateMyMembership(KnownMembership.Join);
expect(roomNotifState.isMention).toBe(false);
});

View File

@ -0,0 +1,166 @@
/*
* 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 {
SyncState,
MatrixError,
ClientEvent,
type MatrixClient,
type Room,
type MatrixEvent,
EventStatus,
} from "matrix-js-sdk/src/matrix";
import { RoomStatusBarState } from "@element-hq/web-shared-components";
import { type MockedObject } from "jest-mock";
import { mkEvent, mkRoom, stubClient } from "../../test-utils";
import { RoomStatusBarViewModel } from "../../../src/viewmodels/room/RoomStatusBar";
import { LocalRoom, LocalRoomState } from "../../../src/models/LocalRoom";
const userId = "@example:example.org";
function mkEventWithError(error: MatrixError): MatrixEvent {
const event = mkEvent({
event: true,
user: userId,
type: "org.example.test",
content: {},
status: EventStatus.NOT_SENT,
});
event.error = error;
return event;
}
describe("RoomStatusBarViewModel", () => {
let client: MockedObject<MatrixClient>;
let vm: RoomStatusBarViewModel;
let room: MockedObject<Room>;
let roomEmitFn!: () => void;
beforeEach(() => {
client = stubClient() as MockedObject<MatrixClient>;
room = mkRoom(client, "!example");
room.on.mockImplementationOnce((_event, fn) => {
roomEmitFn = fn as any;
return room;
});
vm = new RoomStatusBarViewModel({
room,
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should not be visible by default", () => {
expect(vm.getSnapshot()).toEqual({ state: null });
});
it("should resolve state to ConnectionLost on failed sync", () => {
client.getSyncState.mockReturnValue(SyncState.Error);
client.emit(ClientEvent.Sync, SyncState.Error, null);
expect(vm.getSnapshot()).toEqual({ state: RoomStatusBarState.ConnectionLost });
});
// Because we expect LoggedInView to pop a toast
it("should resolve state to nothing if sync error is M_RESOURCE_LIMIT_EXCEEDED", () => {
client.getSyncState.mockReturnValue(SyncState.Error);
client.getSyncStateData.mockReturnValue({ error: new MatrixError({ errcode: "M_RESOURCE_LIMIT_EXCEEDED" }) });
client.emit(ClientEvent.Sync, SyncState.Error, null);
expect(vm.getSnapshot()).toEqual({ state: null });
});
it("should resolve state to NeedsConsent if a pending event has a M_CONSENT_NOT_GIVEN error", () => {
room.getPendingEvents.mockReturnValue([
mkEventWithError(new MatrixError({ errcode: "M_CONSENT_NOT_GIVEN", consent_uri: "https://example.org" })),
]);
roomEmitFn();
expect(vm.getSnapshot()).toEqual({
state: RoomStatusBarState.NeedsConsent,
consentUri: "https://example.org",
});
});
it("should resolve state to UnsentMessages once onTermsAndConditionsClicked is called", () => {
room.getPendingEvents.mockReturnValue([mkEventWithError(new MatrixError({ errcode: "M_CONSENT_NOT_GIVEN" }))]);
roomEmitFn();
expect(vm.getSnapshot()).toEqual({
state: RoomStatusBarState.NeedsConsent,
});
vm.onTermsAndConditionsClicked();
expect(vm.getSnapshot()).toEqual({
state: RoomStatusBarState.UnsentMessages,
isResending: false,
});
});
it("should resolve state to ResourceLimited if a pending event has a M_RESOURCE_LIMIT_EXCEEDED error", () => {
room.getPendingEvents.mockReturnValue([
mkEventWithError(
new MatrixError({
errcode: "M_RESOURCE_LIMIT_EXCEEDED",
limit_type: "hs_disabled",
admin_contact: "https://example.org",
}),
),
]);
roomEmitFn();
expect(vm.getSnapshot()).toEqual({
state: RoomStatusBarState.ResourceLimited,
adminContactHref: "https://example.org",
resourceLimit: "hs_disabled",
});
});
it("should resolve state to UnsentMessages if there are any other events", () => {
room.getPendingEvents.mockReturnValue([mkEventWithError(new MatrixError({ errcode: "M_UNKNOWN" }))]);
roomEmitFn();
expect(vm.getSnapshot()).toEqual({
state: RoomStatusBarState.UnsentMessages,
isResending: false,
});
});
it("should resolve state to isResending=true once onResendAllClick is called", async () => {
room.getPendingEvents.mockReturnValue([mkEventWithError(new MatrixError({ errcode: "M_UNKNOWN" }))]);
roomEmitFn();
expect(vm.getSnapshot()).toEqual({
state: RoomStatusBarState.UnsentMessages,
isResending: false,
});
const promise = vm.onResendAllClick();
expect(vm.getSnapshot()).toEqual({
state: RoomStatusBarState.UnsentMessages,
isResending: true,
});
room.getPendingEvents.mockReturnValue([]);
await promise;
expect(client.resendEvent).toHaveBeenCalledTimes(1);
expect(vm.getSnapshot()).toEqual({
state: null,
});
});
describe("Local rooms", () => {
it("should resolve state to LocalRoomFailed if room fails to be created", () => {
const localRoom = new LocalRoom("!example", client, userId);
localRoom.state = LocalRoomState.ERROR;
vm = new RoomStatusBarViewModel({
room: localRoom,
});
expect(vm.getSnapshot()).toEqual({ state: RoomStatusBarState.LocalRoomFailed });
});
it("should resolve state to nothing for any other state for localroom", () => {
const localRoom = new LocalRoom("!example", client, userId);
localRoom.state = LocalRoomState.NEW;
vm = new RoomStatusBarViewModel({
room: localRoom,
});
expect(vm.getSnapshot()).toEqual({ state: null });
});
});
});