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:
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user