New room list: video room and video call decoration (#29693)

* feat: add video call and EC call to room list item vm

* feat: add video call notification decoration to notification decoration component

* feat: add video call support to room list item view

* feat: add new RoomAvatarView component

* feat: deprecate `DecoratedRoomAvatar`

* feat: use `RoomAvatarView` in room list item

* feat: allow custom class for `RoomAvatar`

* test: update notification decoration

* test: update room list item view

* test: update room list snapshot

* test: add tests for room avatar vm

* test: add tests for room avatar view

* test(e2e): update snapshots

* fix: video room creation rights

* test: e2e add test for public and video room
This commit is contained in:
Florian Duros
2025-04-14 11:27:43 +02:00
committed by GitHub
parent 1430fd5af6
commit 07d5a72f26
35 changed files with 1257 additions and 278 deletions

View File

@@ -0,0 +1,139 @@
/*
* Copyright 2025 New Vector 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 {
EventType,
JoinRule,
type MatrixEvent,
type Room,
RoomEvent,
type User,
UserEvent,
} from "matrix-js-sdk/src/matrix";
import { useState } from "react";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import DMRoomMap from "../../../utils/DMRoomMap";
import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers";
import { BUSY_PRESENCE_NAME } from "../../views/rooms/PresenceLabel";
import { isPresenceEnabled } from "../../../utils/presence";
/**
* The presence of a user in a DM room.
* - "online": The user is online.
* - "offline": The user is offline.
* - "busy": The user is busy.
* - "unavailable": the presence is unavailable.
* - null: the user is not in a DM room or presence is not enabled.
*/
export type Presence = "online" | "offline" | "busy" | "unavailable" | null;
export interface RoomAvatarViewState {
/**
* Whether the room avatar has a decoration.
* A decoration can be a public or a video call icon or an indicator of presence.
*/
hasDecoration: boolean;
/**
* Whether the room is public.
*/
isPublic: boolean;
/**
* Whether the room is a video room.
*/
isVideoRoom: boolean;
/**
* The presence of the user in the DM room.
* If null, the user is not in a DM room or presence is not enabled.
*/
presence: Presence;
}
/**
* Hook to get the state of the room avatar.
* @param room
*/
export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState {
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
const presence = useDMPresence(room);
const isPublic = useIsPublic(room);
const hasDecoration = isPublic || isVideoRoom || presence !== null;
return { hasDecoration, isPublic, isVideoRoom, presence };
}
/**
* Hook listening to the room join rules.
* Return true if the room is public.
* @param room
*/
function useIsPublic(room: Room): boolean {
const [isPublic, setIsPublic] = useState(isRoomPublic(room));
// We don't use `useTypedEventEmitterState` because we don't want to update `isPublic` value at every `RoomEvent.Timeline` event.
useTypedEventEmitter(room, RoomEvent.Timeline, (ev: MatrixEvent, _room: Room) => {
if (room.roomId !== _room.roomId) return;
if (ev.getType() !== EventType.RoomJoinRules && ev.getType() !== EventType.RoomMember) return;
setIsPublic(isRoomPublic(_room));
});
return isPublic;
}
/**
* Whether the room is public.
* @param room
*/
function isRoomPublic(room: Room): boolean {
return room.getJoinRule() === JoinRule.Public;
}
/**
* Hook listening to the presence of the DM user.
* @param room
*/
function useDMPresence(room: Room): Presence {
const dmUser = getDMUser(room);
const [presence, setPresence] = useState<Presence>(getPresence(dmUser));
useTypedEventEmitter(dmUser, UserEvent.Presence, () => setPresence(getPresence(dmUser)));
useTypedEventEmitter(dmUser, UserEvent.CurrentlyActive, () => setPresence(getPresence(dmUser)));
return presence;
}
/**
* Get the DM user of the room.
* Return undefined if the room is not a DM room, if we can't find the user or if the presence is not enabled.
* @param room
* @returns found user
*/
function getDMUser(room: Room): User | undefined {
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (!otherUserId) return;
if (getJoinedNonFunctionalMembers(room).length !== 2) return;
if (!isPresenceEnabled(room.client)) return;
return room.client.getUser(otherUserId) || undefined;
}
/**
* Get the presence of the DM user.
* @param dmUser
*/
function getPresence(dmUser: User | undefined): Presence {
if (!dmUser) return null;
if (BUSY_PRESENCE_NAME.matches(dmUser.presence)) return "busy";
const isOnline = dmUser.currentlyActive || dmUser.presence === "online";
if (isOnline) return "online";
if (dmUser.presence === "offline") return "offline";
if (dmUser.presence === "unavailable") return "unavailable";
return null;
}

View File

@@ -128,8 +128,8 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
const isSpaceRoom = Boolean(activeSpace);
const canCreateRoom = hasCreateRoomRights(matrixClient, activeSpace);
const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms");
const displayComposeMenu = canCreateRoom || canCreateVideoRoom;
const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms") && canCreateRoom;
const displayComposeMenu = canCreateRoom;
const displaySpaceMenu = isSpaceRoom;
const canInviteInSpace = Boolean(
activeSpace?.getJoinRule() === JoinRule.Public || activeSpace?.canInvite(matrixClient.getSafeUserId()),

View File

@@ -18,6 +18,8 @@ import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNo
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { DefaultTagID } from "../../../stores/room-list/models";
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
import { type ConnectionState } from "../../../models/Call";
export interface RoomListItemViewState {
/**
@@ -40,6 +42,19 @@ export interface RoomListItemViewState {
* Whether the room should be bolded.
*/
isBold: boolean;
/**
* Whether the room is a video room
*/
isVideoRoom: boolean;
/**
* The connection state of the call.
* `null` if there is no call in the room.
*/
callConnectionState: ConnectionState | null;
/**
* Whether there are participants in the call.
*/
hasParticipantInCall: boolean;
}
/**
@@ -57,6 +72,14 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
const a11yLabel = getA11yLabel(room, notificationState);
const isBold = notificationState.hasAnyNotificationOrActivity;
// Video room
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
// EC video call or video room
const call = useCall(room.roomId);
const connectionState = useConnectionState(call);
const hasParticipantInCall = useParticipantCount(call) > 0;
const callConnectionState = call ? connectionState : null;
// Actions
const openRoom = useCallback((): void => {
@@ -73,6 +96,9 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
openRoom,
a11yLabel,
isBold,
isVideoRoom,
callConnectionState,
hasParticipantInCall,
};
}

View File

@@ -79,6 +79,9 @@ function tooltipText(variant: Icon): string | undefined {
}
}
/**
* @deprecated Use {@link RoomAvatarView} instead.
*/
export default class DecoratedRoomAvatar extends React.PureComponent<IProps, IState> {
private _dmUser: User | null = null;
private isUnmounted = false;

View File

@@ -144,7 +144,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
}
public render(): React.ReactNode {
const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props;
const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props;
const roomName = room?.name ?? oobData.name ?? "?";
return (

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2025 New Vector 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, { type JSX } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
import VideoIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import OnlineOrUnavailableIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-solid-8x8";
import OfflineIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-outline-8x8";
import BusyIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8";
import classNames from "classnames";
import RoomAvatar from "./RoomAvatar";
import { useRoomAvatarViewModel, type Presence } from "../../viewmodels/avatars/RoomAvatarViewModel";
import { _t } from "../../../languageHandler";
interface RoomAvatarViewProps {
/**
* The room to display the avatar for.
*/
room: Room;
}
/**
* Component to display the avatar of a room.
* Currently only 32px size is supported.
*/
export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
const vm = useRoomAvatarViewModel(room);
// No decoration, we just show the avatar
if (!vm.hasDecoration) return <RoomAvatar size="32px" room={room} />;
return (
<div className="mx_RoomAvatarView">
<RoomAvatar
className={classNames("mx_RoomAvatarView_RoomAvatar", {
// Presence indicator and video/public icons don't have the same size
// We use different masks
mx_RoomAvatarView_RoomAvatar_icon: vm.isVideoRoom || vm.isPublic,
mx_RoomAvatarView_RoomAvatar_presence: Boolean(vm.presence),
})}
size="32px"
room={room}
/>
{/* If the room is a public video room, we prefer to display only the video icon */}
{vm.isPublic && !vm.isVideoRoom && (
<PublicIcon
width="16px"
height="16px"
className="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("room|header|room_is_public")}
/>
)}
{vm.isVideoRoom && (
<VideoIcon
width="16px"
height="16px"
className="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("room|video_room")}
/>
)}
{vm.presence && <PresenceDecoration presence={vm.presence} />}
</div>
);
}
type PresenceDecorationProps = {
/**
* The presence of the user in the DM room.
*/
presence: NonNullable<Presence>;
};
/**
* Component to display the presence of a user in a DM room.
*/
function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element {
switch (presence) {
case "online":
return (
<OnlineOrUnavailableIcon
width="8px"
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-accent-primary)"
aria-label={_t("presence|online")}
/>
);
case "unavailable":
return (
<OnlineOrUnavailableIcon
width="8px"
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-quaternary)"
aria-label={_t("presence|away")}
/>
);
case "offline":
return (
<OfflineIcon
width="8px"
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("presence|offline")}
/>
);
case "busy":
return (
<BusyIcon
width="8px"
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("presence|busy")}
/>
);
}
}

View File

@@ -9,6 +9,7 @@ import React, { type HTMLProps, type JSX } from "react";
import MentionIcon from "@vector-im/compound-design-tokens/assets/web/icons/mention";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import { UnreadCounter, Unread } from "@vector-im/compound-web";
import { Flex } from "../../utils/Flex";
@@ -19,6 +20,10 @@ interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
* The notification state of the room or thread.
*/
notificationState: RoomNotificationState;
/**
* Whether the room has a video call.
*/
hasVideoCall: boolean;
}
/**
@@ -26,6 +31,7 @@ interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
*/
export function NotificationDecoration({
notificationState,
hasVideoCall,
...props
}: NotificationDecorationProps): JSX.Element | null {
const {
@@ -38,7 +44,7 @@ export function NotificationDecoration({
count,
muted,
} = notificationState;
if (!hasAnyNotificationOrActivity && !muted) return null;
if (!hasAnyNotificationOrActivity && !muted && !hasVideoCall) return null;
return (
<Flex
@@ -49,6 +55,7 @@ export function NotificationDecoration({
data-testid="notification-decoration"
>
{isUnsetMessage && <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 && <UnreadCounter count={1} />}
{isMention && <MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{(isMention || isNotification) && <UnreadCounter count={count || null} />}

View File

@@ -10,10 +10,10 @@ import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { useRoomListItemViewModel } from "../../../viewmodels/roomlist/RoomListItemViewModel";
import DecoratedRoomAvatar from "../../avatars/DecoratedRoomAvatar";
import { Flex } from "../../../utils/Flex";
import { RoomListItemMenuView } from "./RoomListItemMenuView";
import { NotificationDecoration } from "../NotificationDecoration";
import { RoomAvatarView } from "../../avatars/RoomAvatarView";
interface RoomListItemViewPropsProps extends React.HTMLAttributes<HTMLButtonElement> {
/**
@@ -39,7 +39,8 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
const showHoverDecoration = (isMenuOpen || isHover) && vm.showHoverMenu;
const isNotificationDecorationVisible =
!showHoverDecoration && (vm.notificationState.hasAnyNotificationOrActivity || vm.notificationState.muted);
!showHoverDecoration &&
(vm.notificationState.hasAnyNotificationOrActivity || vm.notificationState.muted || vm.hasParticipantInCall);
return (
<button
@@ -62,7 +63,7 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
>
{/* We need this extra div between the button and the content in order to add a padding which is not messing with the virtualized list */}
<Flex className="mx_RoomListItemView_container" gap="var(--cpd-space-3x)" align="center">
<DecoratedRoomAvatar room={room} size="32px" />
<RoomAvatarView room={room} />
<Flex
className="mx_RoomListItemView_content"
gap="var(--cpd-space-3x)"
@@ -85,7 +86,11 @@ 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} />
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
hasVideoCall={vm.hasParticipantInCall}
/>
</>
)}
</Flex>

View File

@@ -2098,6 +2098,7 @@
},
"uploading_single_file": "Uploading %(filename)s"
},
"video_room": "This room is a video room",
"waiting_for_join_subtitle": "Once invited users have joined %(brand)s, you will be able to chat and the room will be end-to-end encrypted",
"waiting_for_join_title": "Waiting for users to join %(brand)s"
},