New room list: avoid extra render for room list item (#29752)

* fix: avoid extra render in the new room list

* fix: listen to room name changes

* fix: trigger render when notification state change

* test: fix room list item tests

* chore: fix typo `RoomNotificationState.isUnsentMessage`

* refactor: move `isNotificationDecorationVisible` into `useRoomListItemViewModel`

* refactor: recalculate notification values on notification state changes

* refactor: rename `isNotificationDecorationVisible` to `showNotificationDecoration`

* test: add test for room list item view

* test: add notification tests in room list item vm

* fix: listen to notification updates in `NotificationDecoration`

* test: update notification decoration tests

* refactor: display notification decoration according to vm

* test: update room list item view tests

* fix: a11y label computation after room name change

* refactor: improve notification handling
This commit is contained in:
Florian Duros
2025-04-16 23:40:36 +02:00
committed by GitHub
parent 6767e4d6ad
commit fd455179f7
9 changed files with 290 additions and 55 deletions

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import dispatcher from "../../../dispatcher/dispatcher";
@@ -16,12 +16,17 @@ import { _t } from "../../../languageHandler";
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { DefaultTagID } from "../../../stores/room-list/models";
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
import { type ConnectionState } from "../../../models/Call";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
export interface RoomListItemViewState {
/**
* The name of the room.
*/
name: string;
/**
* Whether the hover menu should be shown.
*/
@@ -55,6 +60,10 @@ export interface RoomListItemViewState {
* Whether there are participants in the call.
*/
hasParticipantInCall: boolean;
/**
* Whether the notification decoration should be shown.
*/
showNotificationDecoration: boolean;
}
/**
@@ -65,11 +74,23 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
const matrixClient = useMatrixClientContext();
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
const name = useEventEmitterState(room, RoomEvent.Name, () => room.name);
const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]);
const invited = notificationState.invited;
const a11yLabel = getA11yLabel(room, notificationState);
const isBold = notificationState.hasAnyNotificationOrActivity;
const [a11yLabel, setA11yLabel] = useState(getA11yLabel(name, notificationState));
const [{ isBold, invited, hasVisibleNotification }, setNotificationValues] = useState(
getNotificationValues(notificationState),
);
useEffect(() => {
setA11yLabel(getA11yLabel(name, notificationState));
}, [name, notificationState]);
// Listen to changes in the notification state and update the values
useTypedEventEmitter(notificationState, NotificationStateEvents.Update, () => {
setA11yLabel(getA11yLabel(name, notificationState));
setNotificationValues(getNotificationValues(notificationState));
});
// We don't want to show the hover menu if
// - there is an invitation for this room
@@ -86,6 +107,8 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
const hasParticipantInCall = useParticipantCount(call) > 0;
const callConnectionState = call ? connectionState : null;
const showNotificationDecoration = hasVisibleNotification || hasParticipantInCall;
// Actions
const openRoom = useCallback((): void => {
@@ -97,6 +120,7 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
}, [room]);
return {
name,
notificationState,
showHoverMenu,
openRoom,
@@ -105,34 +129,59 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
isVideoRoom,
callConnectionState,
hasParticipantInCall,
showNotificationDecoration,
};
}
/**
* Calculate the values from the notification state
* @param notificationState
*/
function getNotificationValues(notificationState: RoomNotificationState): {
computeA11yLabel: (name: string) => string;
isBold: boolean;
invited: boolean;
hasVisibleNotification: boolean;
} {
const invited = notificationState.invited;
const computeA11yLabel = (name: string): string => getA11yLabel(name, notificationState);
const isBold = notificationState.hasAnyNotificationOrActivity;
const hasVisibleNotification = notificationState.hasAnyNotificationOrActivity || notificationState.muted;
return {
computeA11yLabel,
isBold,
invited,
hasVisibleNotification,
};
}
/**
* Get the a11y label for the room list item
* @param room
* @param roomName
* @param notificationState
*/
function getA11yLabel(room: Room, notificationState: RoomNotificationState): string {
if (notificationState.isUnsetMessage) {
function getA11yLabel(roomName: string, notificationState: RoomNotificationState): string {
if (notificationState.isUnsentMessage) {
return _t("a11y|room_messsage_not_sent", {
roomName: room.name,
roomName,
});
} else if (notificationState.invited) {
return _t("a11y|room_n_unread_invite", {
roomName: room.name,
roomName,
});
} else if (notificationState.isMention) {
return _t("a11y|room_n_unread_messages_mentions", {
roomName: room.name,
roomName,
count: notificationState.count,
});
} else if (notificationState.hasUnreadCount) {
return _t("a11y|room_n_unread_messages", {
roomName: room.name,
roomName,
count: notificationState.count,
});
} else {
return _t("room_list|room|open_room", { roomName: room.name });
return _t("room_list|room|open_room", { roomName });
}
}

View File

@@ -15,6 +15,8 @@ import { UnreadCounter, Unread } from "@vector-im/compound-web";
import { Flex } from "../../utils/Flex";
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
/**
@@ -35,16 +37,27 @@ export function NotificationDecoration({
hasVideoCall,
...props
}: NotificationDecorationProps): JSX.Element | null {
// Listen to the notification state and update the component when it changes
const {
hasAnyNotificationOrActivity,
isUnsetMessage,
isUnsentMessage,
invited,
isMention,
isActivityNotification,
isNotification,
count,
muted,
} = notificationState;
} = useTypedEventEmitterState(notificationState, NotificationStateEvents.Update, () => ({
hasAnyNotificationOrActivity: notificationState.hasAnyNotificationOrActivity,
isUnsentMessage: notificationState.isUnsentMessage,
invited: notificationState.invited,
isMention: notificationState.isMention,
isActivityNotification: notificationState.isActivityNotification,
isNotification: notificationState.isNotification,
count: notificationState.count,
muted: notificationState.muted,
}));
if (!hasAnyNotificationOrActivity && !muted && !hasVideoCall) return null;
return (
@@ -55,7 +68,7 @@ export function NotificationDecoration({
{...props}
data-testid="notification-decoration"
>
{isUnsetMessage && <ErrorIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />}
{isUnsentMessage && <ErrorIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />}
{hasVideoCall && <VideoCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{invited && <EmailIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{isMention && <MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useState } from "react";
import React, { type JSX, memo, useState } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
@@ -29,7 +29,11 @@ interface RoomListItemViewPropsProps extends React.HTMLAttributes<HTMLButtonElem
/**
* An item in the room list
*/
export function RoomListItemView({ room, isSelected, ...props }: RoomListItemViewPropsProps): JSX.Element {
export const RoomListItemView = memo(function RoomListItemView({
room,
isSelected,
...props
}: RoomListItemViewPropsProps): JSX.Element {
const vm = useRoomListItemViewModel(room);
const [isHover, setIsHover] = useState(false);
@@ -38,9 +42,7 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
// Using display: none; and then display:flex when hovered in CSS causes the menu to be misaligned
const showHoverDecoration = (isMenuOpen || isHover) && vm.showHoverMenu;
const isNotificationDecorationVisible =
!showHoverDecoration &&
(vm.notificationState.hasAnyNotificationOrActivity || vm.notificationState.muted || vm.hasParticipantInCall);
const isNotificationDecorationVisible = !showHoverDecoration && vm.showNotificationDecoration;
return (
<button
@@ -71,8 +73,8 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
justify="space-between"
>
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<span className="mx_RoomListItemView_roomName" title={room.name}>
{room.name}
<span className="mx_RoomListItemView_roomName" title={vm.name}>
{vm.name}
</span>
{showHoverDecoration ? (
<RoomListItemMenuView
@@ -86,15 +88,17 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
) : (
<>
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */}
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
hasVideoCall={vm.hasParticipantInCall}
/>
{vm.showNotificationDecoration && (
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
hasVideoCall={vm.hasParticipantInCall}
/>
)}
</>
)}
</Flex>
</Flex>
</button>
);
}
});

View File

@@ -62,9 +62,9 @@ export class RoomNotificationState extends NotificationState implements IDestroy
}
/**
* True if the notification is an unset message.
* True if the notification is an unsent message.
*/
public get isUnsetMessage(): boolean {
public get isUnsentMessage(): boolean {
return this.level === NotificationLevel.Unsent;
}