Refactor as a shared-componend / MVVM v2

This commit is contained in:
Half-Shot 2025-12-18 18:52:59 +00:00
parent 15713604e3
commit e449a9d8d1
17 changed files with 1083 additions and 960 deletions

View File

@ -0,0 +1,12 @@
.container {
color: var(--cpd-color-text-primary);
svg {
/* Ensure button icons are primary too */
color: var(--cpd-color-text-primary) !important;
}
}
.description {
font-size: var(--cpd-font-size-body-sm);
color: var(--cpd-color-text-secondary);
}

View File

@ -6,20 +6,23 @@
*/
import { type Meta, type StoryFn } from "@storybook/react-vite";
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import { useMockedViewModel } from "../../useMockedViewModel";
import {
RoomStatusBarView,
type RoomStatusBarViewActions,
type RoomStatusBarViewSnapshot,
} from "./RoomStatusBarView";
import { RoomStatusBarView, type RoomStatusBarViewActions, type RoomStatusBarViewSnapshot } from "./RoomStatusBarView";
import { fn } from "storybook/test";
type RoomStatusBarProps = RoomStatusBarViewSnapshot & RoomStatusBarViewActions;
const RoomStatusBarViewWrapper = ({ onClose, ...rest }: RoomStatusBarProps): JSX.Element => {
const RoomStatusBarViewWrapper = ({
onResendAllClick,
onDeleteAllClick,
onRetryRoomCreationClick,
...rest
}: RoomStatusBarProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onClose,
onResendAllClick,
onDeleteAllClick,
onRetryRoomCreationClick,
});
return <RoomStatusBarView vm={vm} />;
};
@ -30,13 +33,73 @@ export default {
tags: ["autodocs"],
argTypes: {},
args: {
visible: true,
onClose: fn(),
onResendAllClick: fn(),
onDeleteAllClick: fn(),
onRetryRoomCreationClick: fn(),
},
} as Meta<typeof RoomStatusBarViewWrapper>;
const Template: StoryFn<typeof RoomStatusBarViewWrapper> = (args) => (
<RoomStatusBarViewWrapper {...args} />
);
const Template: StoryFn<typeof RoomStatusBarViewWrapper> = (args) => <RoomStatusBarViewWrapper {...args} />;
export const Default = Template.bind({});
/**
* Rendered when the client has lost connection with the server.
*/
export const WithConnectionLost = Template.bind({});
WithConnectionLost.args = {
state: {
connectionLost: true,
},
};
/**
* Rendered when the client needs the user to consent to some terms and conditions before
* they can perform any room actions.
*/
export const WithConsentLink = Template.bind({});
WithConsentLink.args = {
state: {
consentUri: "#example",
},
};
/**
* Rendered when the server has hit a usage limit and is forbidding the user from performing
* any actions in the room. There is an optional parameter to link to an admin to contact.
*/
export const WithResourceLimit = Template.bind({});
WithResourceLimit.args = {
state: {
resourceLimit: "hs_disabled",
adminContactHref: "#example",
},
};
/**
* Rendered when the client has some unsent messages in the room, stored locally.
*/
export const WithUnsentMessages = Template.bind({});
WithUnsentMessages.args = {
state: {
isResending: false,
},
};
/**
* Rendered when the client has some unsent messages in the room, stored locally and is
* trying to send them.
*/
export const WithUnsentMessagesSending = Template.bind({});
WithUnsentMessagesSending.args = {
state: {
isResending: true,
},
};
/**
* Rendered when a local room has failed to be created.
*/
export const WithLocalRoomRetry = Template.bind({});
WithLocalRoomRetry.args = {
state: {
isRetryingRoomCreation: false,
},
};

View File

@ -10,19 +10,60 @@ import { render } from "jest-matrix-react";
import { composeStories } from "@storybook/react-vite";
import * as stories from "./RoomStatusBarView.stories.tsx";
import userEvent from "@testing-library/user-event";
const { Default } = composeStories(stories);
const { WithConnectionLost, WithConsentLink, WithResourceLimit, WithUnsentMessages, WithLocalRoomRetry } =
composeStories(stories);
describe("RoomStatusBarView", () => {
it("renders a history visible banner", () => {
const dismissFn = jest.fn();
const { container } = render(<Default onClose={dismissFn} />);
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 = container.querySelector("button");
expect(button).not.toBeNull();
button?.click();
expect(dismissFn).toHaveBeenCalled();
const button = getByRole("link");
expect(button.getAttribute("href")).toEqual("#example");
});
it("renders unsent messages", async () => {
const { container } = render(
<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

@ -5,12 +5,15 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import React, { useCallback, type JSX } from "react";
import styles from "./RoomStatusBarView.module.css";
import { useViewModel } from "../../useViewModel";
import { _t } from "../../utils/i18n";
import { type ViewModel } from "../../viewmodel";
import { RestartIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useI18n } from "../../utils/i18nContext";
import { Button, InlineSpinner, Text } from "@vector-im/compound-web";
import { Banner } from "../../composer/Banner";
export interface RoomStatusBarViewActions {
/**
* Called when the user clicks on the 'resend all' button in the 'unsent messages' bar.
@ -20,11 +23,17 @@ export interface RoomStatusBarViewActions {
/**
* Called when the user clicks on the 'cancel all' button in the 'unsent messages' bar.
*/
onCancelAllClick?: () => void;
onDeleteAllClick?: () => void;
/**
* For local rooms which haven't been created yet, this will retry creating the room.
* @returns
*/
onRetryRoomCreationClick?: () => void;
}
export interface RoomStatusBarNoConnection {
connectionLost: boolean;
connectionLost: true;
}
export interface RoomStatusBarConsentState {
@ -32,27 +41,31 @@ export interface RoomStatusBarConsentState {
}
export interface RoomStatusBarResourceLimitedState {
consentUri: string;
resourceLimit: "monthly_active_user" | "hs_disabled" | string;
adminContactHref?: string;
}
export interface RoomStatusBarUnsentMessagesState {
consentUri: string;
isResending: boolean;
}
export interface RoomStatusBarLocalRoomError {
isRetryingRoomCreation: boolean;
}
export interface RoomStatusBarViewSnapshot {
/**
* Whether the banner is currently visible.
*/
visible: boolean;
state: RoomStatusBarNoConnection|RoomStatusBarConsentState|RoomStatusBarResourceLimitedState|RoomStatusBarUnsentMessagesState|null;
state:
| RoomStatusBarNoConnection
| RoomStatusBarConsentState
| RoomStatusBarResourceLimitedState
| RoomStatusBarUnsentMessagesState
| RoomStatusBarLocalRoomError
| null;
}
/**
* The view model for the banner.
*/
export type RoomStatusBarViewModel = ViewModel<RoomStatusBarViewSnapshot> &
RoomStatusBarViewActions;
export type RoomStatusBarViewModel = ViewModel<RoomStatusBarViewSnapshot> & RoomStatusBarViewActions;
interface RoomStatusBarViewProps {
/**
@ -62,7 +75,7 @@ interface RoomStatusBarViewProps {
}
/**
* A component to alert that history is shared to new members of the room.
* A component to alert to a failure in the context of a room.
*
* @example
* ```tsx
@ -70,9 +83,170 @@ interface RoomStatusBarViewProps {
* ```
*/
export function RoomStatusBarView({ vm }: Readonly<RoomStatusBarViewProps>): JSX.Element {
const { visible } = useViewModel(vm);
const { translate: _t } = useI18n();
const { state } = useViewModel(vm);
const deleteAllClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(ev) => {
ev.preventDefault();
vm.onDeleteAllClick?.();
},
[vm.onDeleteAllClick],
);
const resendClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(ev) => {
ev.preventDefault();
vm.onResendAllClick?.();
},
[vm.onResendAllClick],
);
const retryRoomCreationClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(ev) => {
ev.preventDefault();
vm.onRetryRoomCreationClick?.();
},
[vm.onRetryRoomCreationClick],
);
if (state === null) {
// Nothing to show!
return <></>;
}
if ("connectionLost" in state) {
return (
<Banner type="critical">
<div className={styles.container}>
<Text weight="semibold">{_t("room|status_bar|server_connectivity_lost_title")}</Text>
<Text className={styles.description}>
{_t("room|status_bar|server_connectivity_lost_description")}
</Text>
</div>
</Banner>
);
}
if ("consentUri" in state) {
return (
<Banner
type="critical"
actions={
<Button
kind="secondary"
size="sm"
as="a"
href={state.consentUri}
target="_blank"
rel="noreferrer noopener"
>
View Terms and Conditions
</Button>
}
>
<div className={styles.container}>
<Text weight="semibold">{_t("room|status_bar|requires_consent_agreement_title")}</Text>
</div>
</Banner>
);
}
if ("resourceLimit" in state) {
const title =
{
monthly_active_user: _t("room|status_bar|monthly_user_limit_reached_title"),
hs_disabled: _t("room|status_bar|homeserver_blocked_title"),
}[state.resourceLimit] || _t("room|status_bar|exceeded_resource_limit_title");
return (
<Banner
type="critical"
actions={
state.adminContactHref && (
<Button
kind="secondary"
size="sm"
as="a"
href={state.adminContactHref}
target="_blank"
rel="noreferrer noopener"
>
Contact admin
</Button>
)
}
>
<div className={styles.container}>
<Text weight="semibold">{title}</Text>
<Text className={styles.description}>
{_t("room|status_bar|exceeded_resource_limit_description")}
</Text>
</div>
</Banner>
);
}
if ("isRetryingRoomCreation" in state) {
return (
<Banner
type="critical"
actions={
<Button
size="sm"
kind="secondary"
className={styles.container}
Icon={RestartIcon}
disabled={state.isRetryingRoomCreation}
onClick={retryRoomCreationClick}
>
{_t("action|retry")}
</Button>
}
>
<div className={styles.container}>
<Text weight="semibold">{_t("room|status_bar|some_messages_not_sent")}</Text>
</div>
</Banner>
);
}
const actions = state.isResending ? (
<InlineSpinner />
) : (
<>
{vm.onDeleteAllClick && (
<Button
size="sm"
kind="destructive"
Icon={DeleteIcon}
disabled={state.isResending}
onClick={deleteAllClick}
>
{_t("room|status_bar|delete_all")}
</Button>
)}
{vm.onResendAllClick && (
<Button
size="sm"
kind="secondary"
Icon={RestartIcon}
disabled={state.isResending}
onClick={resendClick}
className={styles.container}
>
{_t("room|status_bar|retry_all")}
</Button>
)}
</>
);
return (
<p> Tada! </p>
<Banner type="critical" actions={actions}>
<div className={styles.container}>
<Text weight="semibold">{_t("room|status_bar|failed_to_create_room_title")}</Text>
<Text className={styles.description}>{_t("room|status_bar|select_messages_to_retry")}</Text>
</div>
</Banner>
);
}

View File

@ -0,0 +1,504 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RoomStatusBarView renders connection lost 1`] = `
<div>
<div
class="banner"
data-type="critical"
>
<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>
<span
class="content"
>
<div
class="container"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
>
Connectivity to the server has been lost.
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 description"
>
Sent messages will be stored until your connection has returned.
</p>
</div>
</span>
<div
class="actions"
/>
</div>
</div>
`;
exports[`RoomStatusBarView renders consent link 1`] = `
<div>
<div
class="banner"
data-type="critical"
>
<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>
<span
class="content"
>
<div
class="container"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
>
You can't send any messages until you review and agree to our terms and conditions.
</p>
</div>
</span>
<div
class="actions"
>
<a
class="_button_187yx_8"
data-kind="secondary"
data-size="sm"
href="#example"
rel="noreferrer noopener"
role="link"
tabindex="0"
target="_blank"
>
View Terms and Conditions
</a>
</div>
</div>
</div>
`;
exports[`RoomStatusBarView renders local room error 1`] = `
<div>
<div
class="banner"
data-type="critical"
>
<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>
<span
class="content"
>
<div
class="container"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
>
Some of your messages have not been sent
</p>
</div>
</span>
<div
class="actions"
>
<button
aria-disabled="false"
class="_button_187yx_8 container _has-icon_187yx_57"
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
class="banner"
data-type="critical"
>
<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>
<span
class="content"
>
<div
class="container"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
>
Your message wasn't sent because this homeserver has been blocked by its administrator.
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 description"
>
Please contact your service administrator to continue using the service.
</p>
</div>
</span>
<div
class="actions"
>
<a
class="_button_187yx_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
class="banner"
data-type="critical"
>
<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>
<span
class="content"
>
<div
class="container"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
>
Could not start a chat with this user
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 description"
>
You can select all or individual messages to retry or delete
</p>
</div>
</span>
<div
class="actions"
>
<button
aria-disabled="false"
class="_button_187yx_8 _has-icon_187yx_57 _destructive_187yx_107"
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_187yx_8 container _has-icon_187yx_57"
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
class="banner"
data-type="critical"
>
<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>
<span
class="content"
>
<div
class="container"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
>
Could not start a chat with this user
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 description"
>
You can select all or individual messages to retry or delete
</p>
</div>
</span>
<div
class="actions"
>
<button
aria-disabled="false"
class="_button_187yx_8 _has-icon_187yx_57 _destructive_187yx_107"
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_187yx_8 container _has-icon_187yx_57"
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
class="banner"
data-type="critical"
>
<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>
<span
class="content"
>
<div
class="container"
>
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
>
Could not start a chat with this user
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 description"
>
You can select all or individual messages to retry or delete
</p>
</div>
</span>
<div
class="actions"
>
<button
aria-disabled="false"
class="_button_187yx_8 _has-icon_187yx_57 _destructive_187yx_107"
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_187yx_8 container _has-icon_187yx_57"
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

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

@ -1,110 +0,0 @@
/*
Copyright (c) 2025 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { useEffect, type JSX } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { RestartIcon, WarningIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../languageHandler";
import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState";
import AccessibleButton from "../views/elements/AccessibleButton";
import InlineSpinner from "../views/elements/InlineSpinner";
import { RoomStatusBarUnsentMessages } from "./RoomStatusBarUnsentMessages";
import { useRoomStatusBarViewModel } from "../viewmodels/rooms/RoomStatusBarViewModel";
interface IProps {
// the room this statusbar is representing.
room: Room;
/**
* Called when the component becomes visible.
* @returns
*/
onVisible: () => void;
/**
* Called when the component becomes hidden.
* @returns
*/
onHidden: () => void;
}
export function RoomStatusBar({ room, onVisible, onHidden }: IProps): JSX.Element | null {
const vm = useRoomStatusBarViewModel({ room });
useEffect(() => {
if (vm.visible) {
onVisible();
} else {
onHidden();
}
}, [vm.visible, onVisible, onHidden]);
if (!vm.visible) {
return null;
}
if ("connectivityLost" in vm) {
return (
<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 (vm.isResending) {
return (
<RoomStatusBarUnsentMessages
title={vm.title}
description={vm.description}
notificationState={StaticNotificationState.RED_EXCLAMATION}
buttons={
<>
<InlineSpinner w={20} h={20} />
{/* span for css */}
<span>{_t("forward|sending")}</span>
</>
}
/>
);
}
return (
<RoomStatusBarUnsentMessages
title={vm.title}
description={vm.description}
notificationState={StaticNotificationState.RED_EXCLAMATION}
buttons={
<>
{vm.onCancelAllClick && (
<AccessibleButton onClick={vm.onCancelAllClick}>
<DeleteIcon />
{_t("room|status_bar|delete_all")}
</AccessibleButton>
)}
{vm.onResendAllClick && (
<AccessibleButton onClick={vm.onResendAllClick}>
<RestartIcon />
{_t("room|status_bar|retry_all")}
</AccessibleButton>
)}
</>
}
/>
);
}

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,6 @@ import { debounce, throttle } from "lodash";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { type ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
import { type RoomViewProps } from "@element-hq/element-web-module-api";
import { RestartIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import shouldHideEvent from "../../shouldHideEvent";
import { _t } from "../../languageHandler";
@ -111,10 +111,8 @@ import { LocalRoom, LocalRoomState } from "../../models/LocalRoom";
import { createRoomFromLocalRoom } from "../../utils/direct-messages";
import NewRoomIntro from "../views/rooms/NewRoomIntro";
import EncryptionEvent from "../views/messages/EncryptionEvent";
import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState";
import { isLocalRoom } from "../../utils/localRoom/isLocalRoom";
import { type ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload";
import { RoomStatusBarUnsentMessages } from "./RoomStatusBarUnsentMessages";
import { LargeLoader } from "./LargeLoader";
import { isVideoRoom } from "../../utils/video-rooms";
import { SDKContext } from "../../contexts/SDKContext";
@ -318,33 +316,11 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
encryptionTile = <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
@ -404,6 +380,24 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement
function RoomStatusBarWrappedView(props: ConstructorParameters<typeof RoomStatusBarViewModel>[0]): ReactElement {
const vm = useCreateAutoDisposedViewModel(() => new RoomStatusBarViewModel(props));
useEffect(() => {
if ("onVisible" in props) {
// Initial setup
if (vm.getSnapshot().state !== null) {
props.onVisible();
} else {
props.onHidden?.();
}
vm.subscribe(() => {
if (vm.getSnapshot().state !== null) {
props.onVisible?.();
} else {
props.onHidden?.();
}
});
}
}, [vm]);
return <RoomStatusBarView vm={vm} />;
}

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

@ -1,175 +0,0 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
ClientEvent,
EventStatus,
type MatrixError,
type Room,
RoomEvent,
SyncState,
type SyncStateData,
} from "matrix-js-sdk/src/matrix";
import React, { type ReactNode, useCallback, useMemo, useState } from "react";
import { _t, _td } from "@element-hq/web-shared-components";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import Resend from "../../../Resend";
import { messageForResourceLimitError } from "../../../utils/ErrorUtils";
import ExternalLink from "../../views/elements/ExternalLink";
interface RoomStatusBarInvisible {
visible: false;
}
interface RoomStatusBarWithError {
visible: true;
connectivityLost: boolean;
}
interface RoomStatusBarWithUnsentMessages {
visible: true;
title: ReactNode;
description?: string;
}
interface RoomStatusBarWithUnsentMessagesActions extends RoomStatusBarWithUnsentMessages {
isResending: false;
// callback for when the user clicks on the 'resend all' button in the
// 'unsent messages' bar
onResendAllClick?: () => void;
// callback for when the user clicks on the 'cancel all' button in the
// 'unsent messages' bar
onCancelAllClick?: () => void;
}
interface RoomStatusBarWithUnsentMessagesResending extends RoomStatusBarWithUnsentMessages {
isResending: true;
}
type RoomStatusBarVM =
| RoomStatusBarWithError
| RoomStatusBarWithUnsentMessagesActions
| RoomStatusBarWithUnsentMessagesResending
| RoomStatusBarInvisible;
interface IProps {
// the room this statusbar is representing.
room: Room;
}
export function useRoomStatusBarViewModel({ room }: IProps): RoomStatusBarVM {
const client = useMatrixClientContext();
const syncState = useTypedEventEmitterState(
client,
ClientEvent.Sync,
(state: SyncState, prevState: SyncState, data: SyncStateData) => {
return { state, data };
},
);
const [isResending, setResending] = useState(false);
const unsentMessages = useTypedEventEmitterState(room, RoomEvent.LocalEchoUpdated, () => {
return room.getPendingEvents().filter(function (ev) {
const isNotSent = ev.status === EventStatus.NOT_SENT;
return isNotSent;
});
});
const onResendAllClick = useCallback(() => {
setResending(true);
Resend.resendUnsentEvents(room).finally(() => {
setResending(false);
});
dis.fire(Action.FocusSendMessageComposer);
}, [room]);
const onCancelAllClick = useCallback(() => {
Resend.cancelUnsentEvents(room);
dis.fire(Action.FocusSendMessageComposer);
}, [room]);
const unsentMessagesTitle = useMemo(() => {
let consentError: MatrixError | null = null;
let resourceLimitError: MatrixError | null = null;
for (const m of unsentMessages) {
if (m.error) {
if (m.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED") {
resourceLimitError = m.error;
}
if (m.error.errcode === "M_CONSENT_NOT_GIVEN") {
consentError = m.error;
break; // This is the most important thing to show, so break here if we find one.
}
}
}
if (consentError) {
return _t(
"room|status_bar|requires_consent_agreement",
{},
{
consentLink: (sub) => (
<ExternalLink href={consentError!.data?.consent_uri} target="_blank" rel="noreferrer noopener">
{sub}
</ExternalLink>
),
},
);
} else if (resourceLimitError) {
return messageForResourceLimitError(
resourceLimitError.data.limit_type,
resourceLimitError.data.admin_contact,
{
"monthly_active_user": _td("room|status_bar|monthly_user_limit_reached"),
"hs_disabled": _td("room|status_bar|homeserver_blocked"),
"": _td("room|status_bar|exceeded_resource_limit"),
},
);
} else {
return _t("room|status_bar|some_messages_not_sent");
}
}, [unsentMessages]);
const hasConnectionError = useMemo(() => {
// no conn bar trumps the "some not sent" msg since you can't resend without
// a connection!
// There's one situation in which we don't show this 'no connection' bar, and that's
// if it's a resource limit exceeded error: those are shown in the top bar.
const errorIsMauError = Boolean(
syncState.data && syncState.data.error && syncState.data.error.name === "M_RESOURCE_LIMIT_EXCEEDED",
);
return syncState.state === SyncState.Error && !errorIsMauError;
}, [syncState]);
if (hasConnectionError) {
return { visible: true, connectivityLost: true };
}
if (unsentMessages.length) {
if (isResending) {
return {
visible: true,
title: unsentMessagesTitle,
description: _t("room|status_bar|select_messages_to_retry"),
isResending: true,
};
}
return {
visible: true,
title: unsentMessagesTitle,
description: _t("room|status_bar|select_messages_to_retry"),
isResending,
onResendAllClick,
onCancelAllClick,
};
}
return { visible: false };
}

View File

@ -2127,16 +2127,18 @@
},
"status_bar": {
"delete_all": "Delete all",
"exceeded_resource_limit": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.",
"exceeded_resource_limit_title": "Your message wasn't sent because this homeserver has exceeded a resource limit.",
"history_visible": "Messages you send will be shared with new members invited to this room. <a>Learn more</a>",
"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>.",
"homeserver_blocked_title": "Your message wasn't sent because this homeserver has been blocked by its administrator.",
"monthly_user_limit_reached_title": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit.",
"exceeded_resource_limit_description": "Please contact your service administrator to continue using the service.",
"requires_consent_agreement_title": "You can't send any messages until you review and agree to our terms and conditions.",
"retry_all": "Retry all",
"select_messages_to_retry": "You can select all or individual messages to retry or delete",
"server_connectivity_lost_description": "Sent messages will be stored until your connection has returned.",
"server_connectivity_lost_title": "Connectivity to the server has been lost.",
"some_messages_not_sent": "Some of your messages have not been sent"
"some_messages_not_sent": "Some of your messages have not been sent",
"failed_to_create_room_title": "Could not start a chat with this user"
},
"unknown_status_code_for_timeline_jump": "unknown status code",
"unread_notifications_predecessor": {

View File

@ -10,54 +10,197 @@ import {
type RoomStatusBarViewModel as RoomStatusBarViewModelInterface,
type RoomStatusBarViewSnapshot,
} from "@element-hq/web-shared-components";
import { HistoryVisibility, type Room } from "matrix-js-sdk/src/matrix";
import {
ClientEvent,
SyncState,
MatrixClient,
type Room,
MatrixError,
RoomEvent,
EventStatus,
} from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import Resend from "../../Resend";
import { Action } from "../../dispatcher/actions";
import dis from "../../dispatcher/dispatcher";
import { LocalRoom, LocalRoomState } from "../../models/LocalRoom";
import SettingsStore from "../../settings/SettingsStore";
import { SettingLevel } from "../../settings/SettingLevel";
interface Props {
interface PropsWithRoom {
room: Room;
}
interface PropsWithVisibility extends PropsWithRoom {
/**
* Called when the bar becomes visible.
*/
onVisible: () => void;
/**
* Called when the bar becomes hidden.
*/
onHidden: () => void;
}
type Props = PropsWithRoom | PropsWithVisibility;
export class RoomStatusBarViewModel
extends BaseViewModel<RoomStatusBarViewSnapshot, Props>
implements RoomStatusBarViewModelInterface
{
private static readonly computeSnapshot = (
room: Room
): RoomStatusBarViewSnapshot => {
const featureEnabled = SettingsStore.getValue("feature_share_history_on_invite");
const acknowledged = SettingsStore.getValue("acknowledgedHistoryVisibility", room.roomId);
private static readonly determineStateForUnreadMessages = (room: Room): RoomStatusBarViewSnapshot["state"] => {
const unsentMessages = room.getPendingEvents().filter((ev) => ev.status === EventStatus.NOT_SENT);
if (unsentMessages.length === 0) {
return null;
}
let resourceLimitError: MatrixError | null = null;
for (const m of unsentMessages) {
if (m.error) {
if (m.error.errcode === "M_CONSENT_NOT_GIVEN") {
// This is the most important thing to show, so break here if we find one.
return {
// This MUST exist.
consentUri: m.error.data.consent_uri,
};
}
if (m.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED") {
resourceLimitError = m.error;
}
}
}
if (resourceLimitError) {
return {
resourceLimit: resourceLimitError.data.limit_type ?? "",
adminContactHref: resourceLimitError.data.admin_contact,
};
}
return {
visible: true
isResending: false,
};
};
public constructor(props: Props) {
super(props, RoomStatusBarViewModel.computeSnapshot(props.room));
}
private setSnapshot(): void {
const acknowledged = SettingsStore.getValue("acknowledgedHistoryVisibility", this.props.room.roomId);
// Reset the acknowleded flag when the history visibility is set back to joined.
if (this.props.room.getHistoryVisibility() === HistoryVisibility.Joined && acknowledged) {
SettingsStore.setValue(
"acknowledgedHistoryVisibility",
this.props.room.roomId,
SettingLevel.ROOM_ACCOUNT,
false,
);
private static readonly computeSnapshot = (
room: Room,
client: MatrixClient,
isResending: boolean,
isRetryingRoomCreation: boolean,
): RoomStatusBarViewSnapshot => {
if (room instanceof LocalRoom) {
if (isRetryingRoomCreation) {
return {
state: {
isRetryingRoomCreation,
},
};
}
if (room.isError) {
return {
state: {
isRetryingRoomCreation,
},
};
} else {
// Local rooms do not have to worry about these other conditions :)
return { state: null };
}
}
this.snapshot.set(RoomStatusBarViewModel.computeSnapshot(this.props.room, this.props.threadId));
// If we're in the process of resending, don't flicker.
if (isResending) {
return {
state: {
isResending,
},
};
}
const syncState = client.getSyncState();
// Highest priority.
if (syncState === SyncState.Error) {
// no conn bar trumps the "some not sent" msg since you can't resend without
// a connection!
// There's one situation in which we don't show this 'no connection' bar, and that's
// if it's a resource limit exceeded error: those are shown in the top bar.
const syncData = client.getSyncStateData();
if (syncData?.error?.name === "M_RESOURCE_LIMIT_EXCEEDED") {
const error = syncData.error as MatrixError;
return {
state: {
// TODO: Correct limit
resourceLimit: error.data.limit_type ?? "",
adminContactHref: error.data.admin_contact,
},
};
} else {
return {
state: {
connectionLost: true,
},
};
}
}
// Then check messages.
return { state: this.determineStateForUnreadMessages(room) };
};
private readonly client: MatrixClient;
public constructor(props: Props) {
const client = MatrixClientPeg.safeGet();
super(props, RoomStatusBarViewModel.computeSnapshot(props.room, client, false, false));
this.client = client;
client.on(ClientEvent.Sync, this.onClientSync);
props.room.on(RoomEvent.LocalEchoUpdated, this.onRoomLocalEchoUpdated);
}
private readonly onClientSync = () => {
this.setSnapshot();
};
private readonly onRoomLocalEchoUpdated = () => {
this.setSnapshot();
};
private isResending = false;
private isRetryingRoomCreation = false;
private setSnapshot(): void {
this.snapshot.set(
RoomStatusBarViewModel.computeSnapshot(
this.props.room,
this.client,
this.isResending,
this.isRetryingRoomCreation,
),
);
}
public dispose(): void {
this.client.off(ClientEvent.Sync, this.onClientSync);
this.props.room.on(RoomEvent.LocalEchoUpdated, this.onRoomLocalEchoUpdated);
super.dispose();
}
public onDeleteAllClick = (): void => {
Resend.cancelUnsentEvents(this.props.room);
dis.fire(Action.FocusSendMessageComposer);
this.setSnapshot();
};
public onResendAllClick = (): void => {
this.isResending = true;
this.setSnapshot();
void Resend.resendUnsentEvents(this.props.room).finally(() => {
this.isResending = false;
this.setSnapshot();
});
dis.fire(Action.FocusSendMessageComposer);
};
public onRetryRoomCreationClick = (): void => {
// eslint-disable-next-line react-compiler/react-compiler
(this.props.room as LocalRoom).state = LocalRoomState.NEW;
dis.dispatch({
action: "local_room_event",
roomId: this.props.room.roomId,
});
};
}

View File

@ -1,105 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render } from "jest-matrix-react";
import {
type MatrixClient,
PendingEventOrdering,
EventStatus,
type MatrixEvent,
Room,
MatrixError,
} from "matrix-js-sdk/src/matrix";
import { RoomStatusBar } from "../../../../src/components/structures/RoomStatusBar";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { mkEvent, stubClient } from "../../../test-utils/test-utils";
describe("RoomStatusBar", () => {
const ROOM_ID = "!roomId:example.org";
let room: Room;
let client: MatrixClient;
let event: MatrixEvent;
beforeEach(() => {
jest.clearAllMocks();
stubClient();
client = MatrixClientPeg.safeGet();
client.getSyncStateData = jest.fn().mockReturnValue({});
room = new Room(ROOM_ID, client, client.getUserId()!, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
event = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!room1:server",
content: {},
});
event.status = EventStatus.NOT_SENT;
});
const getComponent = () =>
render(<RoomStatusBar room={room} onVisible={() => {}} onHidden={() => {}} />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={client}>{children}</MatrixClientContext.Provider>
),
});
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 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"
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 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"
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

@ -200,53 +200,63 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
</div>
</div>
<div
class="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages"
class="_banner_15kcv_20"
data-type="critical"
>
<div
role="alert"
class="_icon_15kcv_65"
>
<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>
<span
class="_content_15kcv_53"
>
<div
class="mx_RoomStatusBar_unsentBadge"
class="_container_sjla8_1"
>
<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"
<p
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
>
Some of your messages have not been sent
</div>
</p>
</div>
<div
class="mx_RoomStatusBar_unsentButtonBar"
</span>
<div
class="_actions_15kcv_85"
>
<button
aria-disabled="false"
class="_button_187yx_8 _has-icon_187yx_57"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<div
class="mx_AccessibleButton"
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>
actions
</button>
</div>
</div>
</main>