mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-08 13:46:16 +02:00
Remove old room list implementation
Remove old ViewModels, hooks, and view components that are now replaced by the shared-components implementation.
This commit is contained in:
parent
325c780577
commit
935dbb1989
@ -1,33 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_EmptyRoomList_GenericPlaceholder {
|
||||
align-self: center;
|
||||
/** It should take 2/3 of the width **/
|
||||
width: 66%;
|
||||
/** It should be positioned at 1/3 of the height **/
|
||||
padding-top: 33%;
|
||||
|
||||
.mx_EmptyRoomList_GenericPlaceholder_title {
|
||||
font: var(--cpd-font-body-lg-semibold);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mx_EmptyRoomList_GenericPlaceholder_description {
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mx_EmptyRoomList_DefaultPlaceholder {
|
||||
margin-top: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_RoomList {
|
||||
height: 100%;
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_RoomListItemMenuView {
|
||||
svg {
|
||||
fill: var(--cpd-color-icon-primary);
|
||||
}
|
||||
}
|
||||
@ -1,102 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The RoomListItemView has the following structure:
|
||||
* button--------------------------------------------------|
|
||||
* | <-12px-> container------------------------------------|
|
||||
* | | room avatar <-8px-> content----------------|
|
||||
* | | | room_name <- 20px ->|
|
||||
* | | | --------------------| <-- border
|
||||
* |-------------------------------------------------------|
|
||||
*/
|
||||
.mx_RoomListItemView {
|
||||
/* Remove button default style */
|
||||
color: inherit;
|
||||
background: unset;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: unset;
|
||||
|
||||
cursor: pointer;
|
||||
height: 48px;
|
||||
width: 100%;
|
||||
|
||||
padding-left: var(--cpd-space-3x);
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
|
||||
/* Hide the menu by default */
|
||||
.mx_RoomListItemView_menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible,
|
||||
/* When the context menu is opened */
|
||||
&[data-state="open"],
|
||||
/* When the options and notifications menu are opened */
|
||||
&:has(.mx_RoomListItemMenuView > button[data-state="open"]) {
|
||||
background-color: var(--cpd-color-bg-action-secondary-hovered);
|
||||
|
||||
.mx_RoomListItemView_menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&.mx_RoomListItemView_has_menu {
|
||||
/**
|
||||
* The figma uses 16px padding (--cpd-space-4x) but due to https://github.com/element-hq/compound-web/issues/331
|
||||
* the icon size of the menu is 18px instead of 20px with a different internal padding
|
||||
* We need to use 18px to align the icon with the others icons
|
||||
* 18px is not available in compound spacing
|
||||
*/
|
||||
.mx_RoomListItemView_content {
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
/* When the menu is visible, hide the notification decoration to avoid clutter */
|
||||
.mx_RoomListItemView_notificationDecoration {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_content {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
/* The border is only under the room name and the future hover menu */
|
||||
border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary);
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
padding-right: var(--cpd-space-5x);
|
||||
|
||||
.mx_RoomListItemView_text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_roomName {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_messagePreview {
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_selected {
|
||||
background-color: var(--cpd-color-bg-action-secondary-pressed);
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_bold .mx_RoomListItemView_roomName {
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_RoomListPrimaryFilters {
|
||||
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x);
|
||||
|
||||
.mx_RoomListPrimaryFilters_wrapping {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mx_RoomListPrimaryFilters_list {
|
||||
/**
|
||||
* The InteractionObserver needs the height to be set to work properly.
|
||||
*/
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mx_RoomListPrimaryFilters_IconButton {
|
||||
svg {
|
||||
transition: transform 0.1s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomListPrimaryFilters_IconButton[aria-expanded="true"] {
|
||||
svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_RoomListSecondaryFilters {
|
||||
font: var(--cpd-font-body-md-medium);
|
||||
margin: var(--cpd-space-2x);
|
||||
margin-left: var(--cpd-space-1x);
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_RoomListSkeleton {
|
||||
position: relative;
|
||||
margin-left: 4px;
|
||||
height: 100%;
|
||||
|
||||
&::before {
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
content: "";
|
||||
position: absolute;
|
||||
mask-repeat: repeat-y;
|
||||
mask-size: auto 96px;
|
||||
mask-image: url("$(res)/img/element-icons/roomlist/room-list-item-skeleton.svg");
|
||||
}
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
/*
|
||||
* 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 { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type MessagePreview, MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
|
||||
import { useEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
|
||||
interface MessagePreviewViewState {
|
||||
/**
|
||||
* A string representation of the message preview if available.
|
||||
*/
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* View model for rendering a message preview for a given room list item.
|
||||
* @param room The room for which we're rendering the message preview.
|
||||
* @see {@link MessagePreviewViewState} for what this view model returns.
|
||||
*/
|
||||
export function useMessagePreviewViewModel(room: Room): MessagePreviewViewState {
|
||||
const [messagePreview, setMessagePreview] = useState<MessagePreview | null>(null);
|
||||
|
||||
const updatePreview = useCallback(async (): Promise<void> => {
|
||||
/**
|
||||
* The second argument to getPreviewForRoom is a tag id which doesn't really make
|
||||
* much sense within the context of the new room list. We can pass an empty string
|
||||
* to match all tags for now but we should remember to actually change the implementation
|
||||
* in the store once we remove the legacy room list.
|
||||
*/
|
||||
const newPreview = await MessagePreviewStore.instance.getPreviewForRoom(room, "");
|
||||
setMessagePreview(newPreview);
|
||||
}, [room]);
|
||||
|
||||
/**
|
||||
* Update when the message preview has changed for this room.
|
||||
*/
|
||||
useEventEmitter(MessagePreviewStore.instance, MessagePreviewStore.getPreviewChangedEventName(room), () => {
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
/**
|
||||
* Do an initial fetch of the message preview.
|
||||
*/
|
||||
useEffect(() => {
|
||||
updatePreview();
|
||||
}, [updatePreview]);
|
||||
|
||||
return {
|
||||
message: messagePreview?.text,
|
||||
};
|
||||
}
|
||||
@ -1,226 +0,0 @@
|
||||
/*
|
||||
* 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 { useCallback } from "react";
|
||||
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications";
|
||||
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
|
||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
||||
import { UIComponent } from "../../../settings/UIFeature";
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications";
|
||||
import PosthogTrackers from "../../../PosthogTrackers";
|
||||
import { tagRoom } from "../../../utils/room/tagRoom";
|
||||
import { RoomNotifState } from "../../../RoomNotifs";
|
||||
import { useNotificationState } from "../../../hooks/useRoomNotificationState";
|
||||
|
||||
export interface RoomListItemMenuViewState {
|
||||
/**
|
||||
* Whether the more options menu should be shown.
|
||||
*/
|
||||
showMoreOptionsMenu: boolean;
|
||||
/**
|
||||
* Whether the notification menu should be shown.
|
||||
*/
|
||||
showNotificationMenu: boolean;
|
||||
/**
|
||||
* Whether the room is a favourite room.
|
||||
*/
|
||||
isFavourite: boolean;
|
||||
/**
|
||||
* Whether the room is a low priority room.
|
||||
*/
|
||||
isLowPriority: boolean;
|
||||
/**
|
||||
* Can invite other user's in the room.
|
||||
*/
|
||||
canInvite: boolean;
|
||||
/**
|
||||
* Can copy the room link.
|
||||
*/
|
||||
canCopyRoomLink: boolean;
|
||||
/**
|
||||
* Can mark the room as read.
|
||||
*/
|
||||
canMarkAsRead: boolean;
|
||||
/**
|
||||
* Can mark the room as unread.
|
||||
*/
|
||||
canMarkAsUnread: boolean;
|
||||
/**
|
||||
* Whether the notification is set to all messages.
|
||||
*/
|
||||
isNotificationAllMessage: boolean;
|
||||
/**
|
||||
* Whether the notification is set to all messages loud.
|
||||
*/
|
||||
isNotificationAllMessageLoud: boolean;
|
||||
/**
|
||||
* Whether the notification is set to mentions and keywords only.
|
||||
*/
|
||||
isNotificationMentionOnly: boolean;
|
||||
/**
|
||||
* Whether the notification is muted.
|
||||
*/
|
||||
isNotificationMute: boolean;
|
||||
/**
|
||||
* Mark the room as read.
|
||||
* @param evt
|
||||
*/
|
||||
markAsRead: (evt: Event) => void;
|
||||
/**
|
||||
* Mark the room as unread.
|
||||
* @param evt
|
||||
*/
|
||||
markAsUnread: (evt: Event) => void;
|
||||
/**
|
||||
* Toggle the room as favourite.
|
||||
* @param evt
|
||||
*/
|
||||
toggleFavorite: (evt: Event) => void;
|
||||
/**
|
||||
* Toggle the room as low priority.
|
||||
*/
|
||||
toggleLowPriority: () => void;
|
||||
/**
|
||||
* Invite other users in the room.
|
||||
* @param evt
|
||||
*/
|
||||
invite: (evt: Event) => void;
|
||||
/**
|
||||
* Copy the room link in the clipboard.
|
||||
* @param evt
|
||||
*/
|
||||
copyRoomLink: (evt: Event) => void;
|
||||
/**
|
||||
* Leave the room.
|
||||
* @param evt
|
||||
*/
|
||||
leaveRoom: (evt: Event) => void;
|
||||
/**
|
||||
* Set the room notification state.
|
||||
* @param state
|
||||
*/
|
||||
setRoomNotifState: (state: RoomNotifState) => void;
|
||||
}
|
||||
|
||||
export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewState {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
|
||||
const { level: notificationLevel } = useUnreadNotifications(room);
|
||||
|
||||
const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
|
||||
const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]);
|
||||
const isLowPriority = Boolean(roomTags[DefaultTagID.LowPriority]);
|
||||
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
|
||||
|
||||
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
|
||||
const showNotificationMenu = hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived);
|
||||
|
||||
const canMarkAsRead = notificationLevel > NotificationLevel.None;
|
||||
const canMarkAsUnread = !canMarkAsRead && !isArchived;
|
||||
|
||||
const canInvite =
|
||||
room.canInvite(matrixClient.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers);
|
||||
const canCopyRoomLink = !isDm;
|
||||
|
||||
const [roomNotifState, setRoomNotifState] = useNotificationState(room);
|
||||
const isNotificationAllMessage = roomNotifState === RoomNotifState.AllMessages;
|
||||
const isNotificationAllMessageLoud = roomNotifState === RoomNotifState.AllMessagesLoud;
|
||||
const isNotificationMentionOnly = roomNotifState === RoomNotifState.MentionsOnly;
|
||||
const isNotificationMute = roomNotifState === RoomNotifState.Mute;
|
||||
|
||||
// Actions
|
||||
|
||||
const markAsRead = useCallback(
|
||||
async (evt: Event): Promise<void> => {
|
||||
await clearRoomNotification(room, matrixClient);
|
||||
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", evt);
|
||||
},
|
||||
[room, matrixClient],
|
||||
);
|
||||
|
||||
const markAsUnread = useCallback(
|
||||
async (evt: Event): Promise<void> => {
|
||||
await setMarkedUnreadState(room, matrixClient, true);
|
||||
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread", evt);
|
||||
},
|
||||
[room, matrixClient],
|
||||
);
|
||||
|
||||
const toggleFavorite = useCallback(
|
||||
(evt: Event): void => {
|
||||
tagRoom(room, DefaultTagID.Favourite);
|
||||
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", evt);
|
||||
},
|
||||
[room],
|
||||
);
|
||||
|
||||
const toggleLowPriority = useCallback((): void => tagRoom(room, DefaultTagID.LowPriority), [room]);
|
||||
|
||||
const invite = useCallback(
|
||||
(evt: Event): void => {
|
||||
dispatcher.dispatch({
|
||||
action: "view_invite",
|
||||
roomId: room.roomId,
|
||||
});
|
||||
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem", evt);
|
||||
},
|
||||
[room],
|
||||
);
|
||||
|
||||
const copyRoomLink = useCallback(
|
||||
(evt: Event): void => {
|
||||
dispatcher.dispatch({
|
||||
action: "copy_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", evt);
|
||||
},
|
||||
[room],
|
||||
);
|
||||
|
||||
const leaveRoom = useCallback(
|
||||
(evt: Event): void => {
|
||||
dispatcher.dispatch({
|
||||
action: isArchived ? "forget_room" : "leave_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", evt);
|
||||
},
|
||||
[room, isArchived],
|
||||
);
|
||||
|
||||
return {
|
||||
showMoreOptionsMenu,
|
||||
showNotificationMenu,
|
||||
isFavourite,
|
||||
isLowPriority,
|
||||
canInvite,
|
||||
canCopyRoomLink,
|
||||
canMarkAsRead,
|
||||
canMarkAsUnread,
|
||||
isNotificationAllMessage,
|
||||
isNotificationAllMessageLoud,
|
||||
isNotificationMentionOnly,
|
||||
isNotificationMute,
|
||||
markAsRead,
|
||||
markAsUnread,
|
||||
toggleFavorite,
|
||||
toggleLowPriority,
|
||||
invite,
|
||||
copyRoomLink,
|
||||
leaveRoom,
|
||||
setRoomNotifState,
|
||||
};
|
||||
}
|
||||
@ -1,250 +0,0 @@
|
||||
/*
|
||||
* 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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
|
||||
import { CallEvent, type ConnectionState } from "../../../models/Call";
|
||||
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
|
||||
import { useMessagePreviewToggle } from "./useMessagePreviewToggle";
|
||||
|
||||
export interface RoomListItemViewState {
|
||||
/**
|
||||
* The name of the room.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Whether the context menu should be shown.
|
||||
*/
|
||||
showContextMenu: boolean;
|
||||
/**
|
||||
* Whether the hover menu should be shown.
|
||||
*/
|
||||
showHoverMenu: boolean;
|
||||
/**
|
||||
* Open the room having given roomId.
|
||||
*/
|
||||
openRoom: () => void;
|
||||
/**
|
||||
* The a11y label for the room list item.
|
||||
*/
|
||||
a11yLabel: string;
|
||||
/**
|
||||
* The notification state of the room.
|
||||
*/
|
||||
notificationState: RoomNotificationState;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Whether the call is a voice or video call.
|
||||
*/
|
||||
callType: CallType | undefined;
|
||||
/**
|
||||
* Pre-rendered and translated preview for the latest message in the room, or undefined
|
||||
* if no preview should be shown.
|
||||
*/
|
||||
messagePreview: string | undefined;
|
||||
/**
|
||||
* Whether the notification decoration should be shown.
|
||||
*/
|
||||
showNotificationDecoration: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* View model for the room list item
|
||||
* @see {@link RoomListItemViewState} for more information about what this view model returns.
|
||||
*/
|
||||
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 [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));
|
||||
});
|
||||
|
||||
// If the notification reference change due to room change, update the values
|
||||
useEffect(() => {
|
||||
setNotificationValues(getNotificationValues(notificationState));
|
||||
}, [notificationState]);
|
||||
|
||||
// We don't want to show the menus if
|
||||
// - there is an invitation for this room
|
||||
// - the user doesn't have access to notification and more options menus
|
||||
const showContextMenu = !invited && hasAccessToOptionsMenu(room);
|
||||
const showHoverMenu =
|
||||
!invited && (showContextMenu || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived));
|
||||
|
||||
const messagePreview = useRoomMessagePreview(room);
|
||||
|
||||
// Video room
|
||||
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
|
||||
// EC video call or video room
|
||||
const call = useCall(room.roomId);
|
||||
const connectionState = useConnectionState(call);
|
||||
const participantCount = useParticipantCount(call);
|
||||
const callConnectionState = call ? connectionState : null;
|
||||
|
||||
const showNotificationDecoration = hasVisibleNotification || participantCount > 0;
|
||||
|
||||
// Actions
|
||||
|
||||
const openRoom = useCallback((): void => {
|
||||
dispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
metricsTrigger: "RoomList",
|
||||
});
|
||||
}, [room]);
|
||||
|
||||
const [callType, setCallType] = useState<CallType>(CallType.Video);
|
||||
useTypedEventEmitter(call ?? undefined, CallEvent.CallTypeChanged, setCallType);
|
||||
|
||||
return {
|
||||
name,
|
||||
notificationState,
|
||||
showContextMenu,
|
||||
showHoverMenu,
|
||||
openRoom,
|
||||
a11yLabel,
|
||||
isBold,
|
||||
isVideoRoom,
|
||||
callConnectionState,
|
||||
hasParticipantInCall: participantCount > 0,
|
||||
messagePreview,
|
||||
showNotificationDecoration,
|
||||
callType: call ? callType : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 roomName
|
||||
* @param notificationState
|
||||
*/
|
||||
function getA11yLabel(roomName: string, notificationState: RoomNotificationState): string {
|
||||
if (notificationState.isUnsentMessage) {
|
||||
return _t("a11y|room_messsage_not_sent", {
|
||||
roomName,
|
||||
});
|
||||
} else if (notificationState.invited) {
|
||||
return _t("a11y|room_n_unread_invite", {
|
||||
roomName,
|
||||
});
|
||||
} else if (notificationState.isMention) {
|
||||
return _t("a11y|room_n_unread_messages_mentions", {
|
||||
roomName,
|
||||
count: notificationState.count,
|
||||
});
|
||||
} else if (notificationState.hasUnreadCount) {
|
||||
return _t("a11y|room_n_unread_messages", {
|
||||
roomName,
|
||||
count: notificationState.count,
|
||||
});
|
||||
} else {
|
||||
return _t("room_list|room|open_room", { roomName });
|
||||
}
|
||||
}
|
||||
|
||||
function useRoomMessagePreview(room: Room): string | undefined {
|
||||
const { shouldShowMessagePreview } = useMessagePreviewToggle();
|
||||
const [previewText, setPreviewText] = useState<string | undefined>(undefined);
|
||||
|
||||
const updatePreview = useCallback(async () => {
|
||||
if (!shouldShowMessagePreview) {
|
||||
setPreviewText(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const roomIsDM = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
|
||||
// For the tag, we only care about whether the room is a DM or not as we don't show
|
||||
// display names in previewsd for DMs, so anything else we just say is 'untagged'
|
||||
// (even though it could actually be have other tags: we don't care about them).
|
||||
const messagePreview = await MessagePreviewStore.instance.getPreviewForRoom(
|
||||
room,
|
||||
roomIsDM ? DefaultTagID.DM : DefaultTagID.Untagged,
|
||||
);
|
||||
setPreviewText(messagePreview?.text);
|
||||
}, [room, shouldShowMessagePreview]);
|
||||
|
||||
// MessagePreviewStore and the other AsyncStores need to be converted to TypedEventEmitter
|
||||
useEventEmitter(MessagePreviewStore.instance, MessagePreviewStore.getPreviewChangedEventName(room), () => {
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updatePreview();
|
||||
}, [updatePreview]);
|
||||
|
||||
return previewText;
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
/*
|
||||
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 { useCallback } from "react";
|
||||
|
||||
import type { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type PrimaryFilter, useFilteredRooms } from "./useFilteredRooms";
|
||||
import { createRoom as createRoomFunc, hasCreateRoomRights } from "./utils";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useStickyRoomList } from "./useStickyRoomList";
|
||||
import { useRoomListNavigation } from "./useRoomListNavigation";
|
||||
import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3";
|
||||
|
||||
export interface RoomListViewState {
|
||||
/**
|
||||
* Whether the list of rooms is being loaded.
|
||||
*/
|
||||
isLoadingRooms: boolean;
|
||||
|
||||
/**
|
||||
* The room results to be displayed (along with the spaceId and filter keys at the time of query)
|
||||
*/
|
||||
roomsResult: RoomsResult;
|
||||
|
||||
/**
|
||||
* Create a chat room
|
||||
* @param e - The click event
|
||||
*/
|
||||
createChatRoom: () => void;
|
||||
|
||||
/**
|
||||
* Whether the user can create a room in the current space
|
||||
*/
|
||||
canCreateRoom: boolean;
|
||||
|
||||
/**
|
||||
* Create a room
|
||||
* @param e - The click event
|
||||
*/
|
||||
createRoom: () => void;
|
||||
|
||||
/**
|
||||
* A list of objects that provide the view enough information
|
||||
* to render primary room filters.
|
||||
*/
|
||||
primaryFilters: PrimaryFilter[];
|
||||
|
||||
/**
|
||||
* The currently active primary filter.
|
||||
* If no primary filter is active, this will be undefined.
|
||||
*/
|
||||
activePrimaryFilter?: PrimaryFilter;
|
||||
|
||||
/**
|
||||
* The index of the active room in the room list.
|
||||
*/
|
||||
activeIndex: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* View model for the new room list
|
||||
* @see {@link RoomListViewState} for more information about what this view model returns.
|
||||
*/
|
||||
export function useRoomListViewModel(): RoomListViewState {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const { isLoadingRooms, primaryFilters, activePrimaryFilter, roomsResult: filteredRooms } = useFilteredRooms();
|
||||
const { activeIndex, roomsResult } = useStickyRoomList(filteredRooms);
|
||||
|
||||
useRoomListNavigation(roomsResult.rooms);
|
||||
|
||||
const currentSpace = useEventEmitterState<Room | null>(
|
||||
SpaceStore.instance,
|
||||
UPDATE_SELECTED_SPACE,
|
||||
() => SpaceStore.instance.activeSpaceRoom,
|
||||
);
|
||||
const canCreateRoom = hasCreateRoomRights(matrixClient, currentSpace);
|
||||
|
||||
const createChatRoom = useCallback(() => dispatcher.fire(Action.CreateChat), []);
|
||||
const createRoom = useCallback(() => createRoomFunc(currentSpace), [currentSpace]);
|
||||
|
||||
return {
|
||||
isLoadingRooms,
|
||||
roomsResult,
|
||||
canCreateRoom,
|
||||
createRoom,
|
||||
createChatRoom,
|
||||
primaryFilters,
|
||||
activePrimaryFilter,
|
||||
activeIndex,
|
||||
};
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
/*
|
||||
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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
|
||||
import { _t, _td } from "../../../languageHandler";
|
||||
import RoomListStoreV3, {
|
||||
LISTS_LOADED_EVENT,
|
||||
LISTS_UPDATE_EVENT,
|
||||
type RoomsResult,
|
||||
} from "../../../stores/room-list-v3/RoomListStoreV3";
|
||||
import { useEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
|
||||
/**
|
||||
* Provides information about a primary filter.
|
||||
* A primary filter is a commonly used filter that is given
|
||||
* more precedence in the UI. For eg, primary filters may be
|
||||
* rendered as pills above the room list.
|
||||
*/
|
||||
export interface PrimaryFilter {
|
||||
// A function to toggle this filter on and off.
|
||||
toggle: () => void;
|
||||
// Whether this filter is currently applied
|
||||
active: boolean;
|
||||
// Text that can be used in the UI to represent this filter.
|
||||
name: string;
|
||||
// The key of the filter
|
||||
key: FilterKey;
|
||||
}
|
||||
|
||||
interface FilteredRooms {
|
||||
primaryFilters: PrimaryFilter[];
|
||||
isLoadingRooms: boolean;
|
||||
roomsResult: RoomsResult;
|
||||
/**
|
||||
* The currently active primary filter.
|
||||
* If no primary filter is active, this will be undefined.
|
||||
*/
|
||||
activePrimaryFilter?: PrimaryFilter;
|
||||
}
|
||||
|
||||
const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([
|
||||
[FilterKey.UnreadFilter, _td("room_list|filters|unread")],
|
||||
[FilterKey.PeopleFilter, _td("room_list|filters|people")],
|
||||
[FilterKey.RoomsFilter, _td("room_list|filters|rooms")],
|
||||
[FilterKey.FavouriteFilter, _td("room_list|filters|favourite")],
|
||||
[FilterKey.MentionsFilter, _td("room_list|filters|mentions")],
|
||||
[FilterKey.InvitesFilter, _td("room_list|filters|invites")],
|
||||
[FilterKey.LowPriorityFilter, _td("room_list|filters|low_priority")],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Track available filters and provide a filtered list of rooms.
|
||||
*/
|
||||
export function useFilteredRooms(): FilteredRooms {
|
||||
/**
|
||||
* Primary filter refers to the pill based filters
|
||||
* rendered above the room list.
|
||||
*/
|
||||
const [primaryFilter, setPrimaryFilter] = useState<FilterKey | undefined>();
|
||||
|
||||
const [roomsResult, setRoomsResult] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
|
||||
const [isLoadingRooms, setIsLoadingRooms] = useState(() => RoomListStoreV3.instance.isLoadingRooms);
|
||||
|
||||
const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => {
|
||||
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
|
||||
setRoomsResult(newRooms);
|
||||
}, []);
|
||||
|
||||
const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] =>
|
||||
array.filter((f) => f !== undefined) as FilterKey[];
|
||||
|
||||
const getAppliedFilters = useCallback((): FilterKey[] => {
|
||||
return filterUndefined([primaryFilter]);
|
||||
}, [primaryFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update the rooms state when the primary filter changes
|
||||
const filters = getAppliedFilters();
|
||||
updateRoomsFromStore(filters);
|
||||
}, [getAppliedFilters, updateRoomsFromStore]);
|
||||
|
||||
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
|
||||
const filters = getAppliedFilters();
|
||||
updateRoomsFromStore(filters);
|
||||
});
|
||||
|
||||
useEventEmitter(RoomListStoreV3.instance, LISTS_LOADED_EVENT, () => {
|
||||
setIsLoadingRooms(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* This tells the view which primary filters are available, how to toggle them
|
||||
* and whether a given primary filter is active. @see {@link PrimaryFilter}
|
||||
*/
|
||||
const primaryFilters = useMemo(() => {
|
||||
const createPrimaryFilter = (key: FilterKey, name: string): PrimaryFilter => {
|
||||
return {
|
||||
toggle: () => {
|
||||
setPrimaryFilter((currentFilter) => {
|
||||
const filter = currentFilter === key ? undefined : key;
|
||||
updateRoomsFromStore(filterUndefined([filter]));
|
||||
return filter;
|
||||
});
|
||||
},
|
||||
active: primaryFilter === key,
|
||||
name,
|
||||
key,
|
||||
};
|
||||
};
|
||||
const filters: PrimaryFilter[] = [];
|
||||
for (const [key, name] of filterKeyToNameMap.entries()) {
|
||||
filters.push(createPrimaryFilter(key, _t(name)));
|
||||
}
|
||||
return filters;
|
||||
}, [primaryFilter, updateRoomsFromStore]);
|
||||
|
||||
const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]);
|
||||
|
||||
return {
|
||||
isLoadingRooms,
|
||||
primaryFilters,
|
||||
activePrimaryFilter,
|
||||
roomsResult,
|
||||
};
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
/*
|
||||
* 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 { useCallback } from "react";
|
||||
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
|
||||
interface MessagePreviewToggleState {
|
||||
shouldShowMessagePreview: boolean;
|
||||
toggleMessagePreview: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook:
|
||||
* - Provides a state that tracks whether message previews are turned on or off.
|
||||
* - Provides a function to toggle message previews.
|
||||
*/
|
||||
export function useMessagePreviewToggle(): MessagePreviewToggleState {
|
||||
const shouldShowMessagePreview = useSettingValue("RoomList.showMessagePreview");
|
||||
|
||||
const toggleMessagePreview = useCallback((): void => {
|
||||
const toggled = !shouldShowMessagePreview;
|
||||
SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, toggled);
|
||||
}, [shouldShowMessagePreview]);
|
||||
|
||||
return { toggleMessagePreview, shouldShowMessagePreview };
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
/*
|
||||
* 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 { type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
|
||||
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
|
||||
/**
|
||||
* Hook to navigate the room list using keyboard shortcuts.
|
||||
* It listens to the ViewRoomDelta action and updates the room list accordingly.
|
||||
* @param rooms
|
||||
*/
|
||||
export function useRoomListNavigation(rooms: Room[]): void {
|
||||
useDispatcher(dispatcher, (payload) => {
|
||||
if (payload.action !== Action.ViewRoomDelta) return;
|
||||
const roomId = SdkContextClass.instance.roomViewStore.getRoomId();
|
||||
if (!roomId) return;
|
||||
|
||||
const { delta, unread } = payload as ViewRoomDeltaPayload;
|
||||
const filteredRooms = unread
|
||||
? // Filter the rooms to only include unread ones and the active room
|
||||
rooms.filter((room) => {
|
||||
const state = RoomNotificationStateStore.instance.getRoomState(room);
|
||||
return room.roomId === roomId || state.isUnread;
|
||||
})
|
||||
: rooms;
|
||||
|
||||
const currentIndex = filteredRooms.findIndex((room) => room.roomId === roomId);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
// Get the next/previous new room according to the delta
|
||||
// Use slice to loop on the list
|
||||
// If delta is -1 at the start of the list, it will go to the end
|
||||
// If delta is 1 at the end of the list, it will go to the start
|
||||
const [newRoom] = filteredRooms.slice((currentIndex + delta) % filteredRooms.length);
|
||||
if (!newRoom) return;
|
||||
|
||||
dispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: newRoom.roomId,
|
||||
show_room_tile: true, // to make sure the room gets scrolled into view
|
||||
metricsTrigger: "WebKeyboardShortcut",
|
||||
metricsViaKeyboard: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -1,138 +0,0 @@
|
||||
/*
|
||||
* 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 { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import type { Room } from "matrix-js-sdk/src/matrix";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3";
|
||||
|
||||
function getIndexByRoomId(rooms: Room[], roomId: string): number | undefined {
|
||||
const index = rooms.findIndex((room) => room.roomId === roomId);
|
||||
return index === -1 ? undefined : index;
|
||||
}
|
||||
|
||||
function getRoomsWithStickyRoom(
|
||||
rooms: Room[],
|
||||
oldIndex: number | undefined,
|
||||
newIndex: number | undefined,
|
||||
isRoomChange: boolean,
|
||||
): { newRooms: Room[]; newIndex: number | undefined } {
|
||||
const updated = { newIndex, newRooms: rooms };
|
||||
if (isRoomChange) {
|
||||
/*
|
||||
* When opening another room, the index should obviously change.
|
||||
*/
|
||||
return updated;
|
||||
}
|
||||
if (newIndex === undefined || oldIndex === undefined) {
|
||||
/*
|
||||
* If oldIndex is undefined, then there was no active room before.
|
||||
* So nothing to do in regards to sticky room.
|
||||
* Similarly, if newIndex is undefined, there's no active room anymore.
|
||||
*/
|
||||
return updated;
|
||||
}
|
||||
if (newIndex === oldIndex) {
|
||||
/*
|
||||
* If the index hasn't changed, we have nothing to do.
|
||||
*/
|
||||
return updated;
|
||||
}
|
||||
if (oldIndex > rooms.length - 1) {
|
||||
/*
|
||||
* If the old index falls out of the bounds of the rooms array
|
||||
* (usually because rooms were removed), we can no longer place
|
||||
* the active room in the same old index.
|
||||
*/
|
||||
return updated;
|
||||
}
|
||||
|
||||
/*
|
||||
* Making the active room sticky is as simple as removing it from
|
||||
* its new index and placing it in the old index.
|
||||
*/
|
||||
const newRooms = [...rooms];
|
||||
const [newRoom] = newRooms.splice(newIndex, 1);
|
||||
newRooms.splice(oldIndex, 0, newRoom);
|
||||
|
||||
return { newIndex: oldIndex, newRooms };
|
||||
}
|
||||
|
||||
export interface StickyRoomListResult {
|
||||
/**
|
||||
* The rooms result with the active sticky room applied
|
||||
*/
|
||||
roomsResult: RoomsResult;
|
||||
/**
|
||||
* Index of the active room in the room list.
|
||||
*/
|
||||
activeIndex: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* - Provides a list of rooms such that the active room is sticky i.e the active room is kept
|
||||
* in the same index even when the order of rooms in the list changes.
|
||||
* - Provides the index of the active room.
|
||||
* @param rooms list of rooms
|
||||
* @see {@link StickyRoomListResult} details what this hook returns..
|
||||
*/
|
||||
export function useStickyRoomList(roomsResult: RoomsResult): StickyRoomListResult {
|
||||
const [listState, setListState] = useState<StickyRoomListResult>({
|
||||
activeIndex: getIndexByRoomId(roomsResult.rooms, SdkContextClass.instance.roomViewStore.getRoomId()!),
|
||||
roomsResult: roomsResult,
|
||||
});
|
||||
|
||||
const currentSpaceRef = useRef(SpaceStore.instance.activeSpace);
|
||||
|
||||
const updateRoomsAndIndex = useCallback(
|
||||
(newRoomId: string | null, isRoomChange: boolean = false) => {
|
||||
setListState((current) => {
|
||||
const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId();
|
||||
const newActiveIndex = getIndexByRoomId(roomsResult.rooms, activeRoomId!);
|
||||
const oldIndex = current.activeIndex;
|
||||
const { newIndex, newRooms } = getRoomsWithStickyRoom(
|
||||
roomsResult.rooms,
|
||||
oldIndex,
|
||||
newActiveIndex,
|
||||
isRoomChange,
|
||||
);
|
||||
return { activeIndex: newIndex, roomsResult: { ...roomsResult, rooms: newRooms } };
|
||||
});
|
||||
},
|
||||
[roomsResult],
|
||||
);
|
||||
|
||||
// Re-calculate the index when the active room has changed.
|
||||
useDispatcher(dispatcher, (payload) => {
|
||||
if (payload.action === Action.ActiveRoomChanged) updateRoomsAndIndex(payload.newRoomId, true);
|
||||
});
|
||||
|
||||
// Re-calculate the index when the list of rooms has changed.
|
||||
useEffect(() => {
|
||||
let newRoomId: string | null = null;
|
||||
let isRoomChange = false;
|
||||
if (currentSpaceRef.current !== roomsResult.spaceId) {
|
||||
/*
|
||||
If the space has changed, we check if we can immediately set the active
|
||||
index to the last opened room in that space. Otherwise, we might see a
|
||||
flicker because of the delay between the space change event and
|
||||
active room change dispatch.
|
||||
*/
|
||||
newRoomId = SpaceStore.instance.getLastSelectedRoomIdForSpace(roomsResult.spaceId);
|
||||
isRoomChange = true;
|
||||
currentSpaceRef.current = roomsResult.spaceId;
|
||||
}
|
||||
updateRoomsAndIndex(newRoomId, isRoomChange);
|
||||
}, [roomsResult, updateRoomsAndIndex]);
|
||||
|
||||
return listState;
|
||||
}
|
||||
@ -1,182 +0,0 @@
|
||||
/*
|
||||
* 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, type PropsWithChildren } from "react";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
|
||||
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
|
||||
import { Flex } from "@element-hq/web-shared-components";
|
||||
|
||||
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
|
||||
import { type PrimaryFilter } from "../../../viewmodels/roomlist/useFilteredRooms";
|
||||
|
||||
interface EmptyRoomListProps {
|
||||
/**
|
||||
* The view model for the room list
|
||||
*/
|
||||
vm: RoomListViewState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The empty state for the room list
|
||||
*/
|
||||
export function EmptyRoomList({ vm }: EmptyRoomListProps): JSX.Element | undefined {
|
||||
// If there is no active primary filter, show the default empty state
|
||||
if (!vm.activePrimaryFilter) return <DefaultPlaceholder vm={vm} />;
|
||||
|
||||
switch (vm.activePrimaryFilter.key) {
|
||||
case FilterKey.FavouriteFilter:
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_favourites")}
|
||||
description={_t("room_list|empty|no_favourites_description")}
|
||||
/>
|
||||
);
|
||||
case FilterKey.PeopleFilter:
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_people")}
|
||||
description={_t("room_list|empty|no_people_description")}
|
||||
/>
|
||||
);
|
||||
case FilterKey.RoomsFilter:
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_rooms")}
|
||||
description={_t("room_list|empty|no_rooms_description")}
|
||||
/>
|
||||
);
|
||||
case FilterKey.UnreadFilter:
|
||||
return (
|
||||
<ActionPlaceholder
|
||||
title={_t("room_list|empty|no_unread")}
|
||||
action={_t("room_list|empty|show_chats")}
|
||||
filter={vm.activePrimaryFilter}
|
||||
/>
|
||||
);
|
||||
case FilterKey.InvitesFilter:
|
||||
return (
|
||||
<ActionPlaceholder
|
||||
title={_t("room_list|empty|no_invites")}
|
||||
action={_t("room_list|empty|show_activity")}
|
||||
filter={vm.activePrimaryFilter}
|
||||
/>
|
||||
);
|
||||
case FilterKey.MentionsFilter:
|
||||
return (
|
||||
<ActionPlaceholder
|
||||
title={_t("room_list|empty|no_mentions")}
|
||||
action={_t("room_list|empty|show_activity")}
|
||||
filter={vm.activePrimaryFilter}
|
||||
/>
|
||||
);
|
||||
case FilterKey.LowPriorityFilter:
|
||||
return (
|
||||
<ActionPlaceholder
|
||||
title={_t("room_list|empty|no_lowpriority")}
|
||||
action={_t("room_list|empty|show_activity")}
|
||||
filter={vm.activePrimaryFilter}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface GenericPlaceholderProps {
|
||||
/**
|
||||
* The title of the placeholder
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* The description of the placeholder
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic placeholder for the room list
|
||||
*/
|
||||
function GenericPlaceholder({ title, description, children }: PropsWithChildren<GenericPlaceholderProps>): JSX.Element {
|
||||
return (
|
||||
<Flex
|
||||
data-testid="empty-room-list"
|
||||
className="mx_EmptyRoomList_GenericPlaceholder"
|
||||
direction="column"
|
||||
align="stretch"
|
||||
justify="center"
|
||||
gap="var(--cpd-space-2x)"
|
||||
>
|
||||
<span className="mx_EmptyRoomList_GenericPlaceholder_title">{title}</span>
|
||||
{description && <span className="mx_EmptyRoomList_GenericPlaceholder_description">{description}</span>}
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
interface DefaultPlaceholderProps {
|
||||
/**
|
||||
* The view model for the room list
|
||||
*/
|
||||
vm: RoomListViewState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default empty state for the room list when no primary filter is active
|
||||
* The user can create chat or room (if they have the permission)
|
||||
*/
|
||||
function DefaultPlaceholder({ vm }: DefaultPlaceholderProps): JSX.Element {
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_chats")}
|
||||
description={
|
||||
vm.canCreateRoom
|
||||
? _t("room_list|empty|no_chats_description")
|
||||
: _t("room_list|empty|no_chats_description_no_room_rights")
|
||||
}
|
||||
>
|
||||
<Flex
|
||||
className="mx_EmptyRoomList_DefaultPlaceholder"
|
||||
align="center"
|
||||
justify="center"
|
||||
direction="column"
|
||||
gap="var(--cpd-space-4x)"
|
||||
>
|
||||
<Button size="sm" kind="secondary" Icon={ChatIcon} onClick={vm.createChatRoom}>
|
||||
{_t("action|start_chat")}
|
||||
</Button>
|
||||
{vm.canCreateRoom && (
|
||||
<Button size="sm" kind="secondary" Icon={RoomIcon} onClick={vm.createRoom}>
|
||||
{_t("action|new_room")}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</GenericPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionPlaceholderProps {
|
||||
filter: PrimaryFilter;
|
||||
title: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A placeholder for the room list when a filter is active
|
||||
* The user can take action to toggle the filter
|
||||
*/
|
||||
function ActionPlaceholder({ filter, title, action }: ActionPlaceholderProps): JSX.Element {
|
||||
return (
|
||||
<GenericPlaceholder title={title}>
|
||||
<Button kind="tertiary" onClick={filter.toggle}>
|
||||
{action}
|
||||
</Button>
|
||||
</GenericPlaceholder>
|
||||
);
|
||||
}
|
||||
@ -1,144 +0,0 @@
|
||||
/*
|
||||
* 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, { useCallback, useRef, type JSX, useMemo } from "react";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { isEqual } from "lodash";
|
||||
import {
|
||||
type VirtualizedListContext,
|
||||
VirtualizedList,
|
||||
type ScrollIntoViewOnChange,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { RoomListItemView } from "./RoomListItemView";
|
||||
import { type FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
|
||||
import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
|
||||
import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation";
|
||||
|
||||
interface RoomListProps {
|
||||
/**
|
||||
* The view model state for the room list.
|
||||
*/
|
||||
vm: RoomListViewState;
|
||||
}
|
||||
|
||||
type Context = {
|
||||
spaceId: string;
|
||||
filterKeys: FilterKey[] | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Height of a single room list item
|
||||
*/
|
||||
const ROOM_LIST_ITEM_HEIGHT = 48;
|
||||
/**
|
||||
* Amount to extend the top and bottom of the viewport by.
|
||||
* From manual testing and user feedback 25 items is reported to be enough to avoid blank space when using the mouse wheel,
|
||||
* and the trackpad scrolling at a slow to moderate speed where you can still see/read the content.
|
||||
* Using the trackpad to sling through a large percentage of the list quickly will still show blank space.
|
||||
* We would likely need to simplify the item content to improve this case.
|
||||
*/
|
||||
const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
|
||||
/**
|
||||
* A virtualized list of rooms.
|
||||
*/
|
||||
export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): JSX.Element {
|
||||
const lastSpaceId = useRef<string | undefined>(undefined);
|
||||
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
|
||||
const roomCount = roomsResult.rooms.length;
|
||||
const getItemComponent = useCallback(
|
||||
(
|
||||
index: number,
|
||||
item: Room,
|
||||
context: VirtualizedListContext<Context>,
|
||||
onFocus: (item: Room, e: React.FocusEvent) => void,
|
||||
): JSX.Element => {
|
||||
const itemKey = item.roomId;
|
||||
const isRovingItem = itemKey === context.tabIndexKey;
|
||||
const isFocused = isRovingItem && context.focused;
|
||||
const isSelected = activeIndex === index;
|
||||
return (
|
||||
<RoomListItemView
|
||||
room={item}
|
||||
key={itemKey}
|
||||
isSelected={isSelected}
|
||||
isFocused={isFocused}
|
||||
tabIndex={isRovingItem ? 0 : -1}
|
||||
roomIndex={index}
|
||||
roomCount={roomCount}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[activeIndex, roomCount],
|
||||
);
|
||||
|
||||
const getItemKey = useCallback((item: Room): string => {
|
||||
return item.roomId;
|
||||
}, []);
|
||||
|
||||
const scrollIntoViewOnChange = useCallback<ScrollIntoViewOnChange<Room, Context>>(
|
||||
(params) => {
|
||||
const { spaceId, filterKeys } = params.context.context;
|
||||
const shouldScrollIndexIntoView =
|
||||
lastSpaceId.current !== spaceId || !isEqual(lastFilterKeys.current, filterKeys);
|
||||
lastFilterKeys.current = filterKeys;
|
||||
lastSpaceId.current = spaceId;
|
||||
|
||||
if (shouldScrollIndexIntoView) {
|
||||
return {
|
||||
align: `start`,
|
||||
index: activeIndex || 0,
|
||||
behavior: "auto",
|
||||
};
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[activeIndex],
|
||||
);
|
||||
|
||||
const keyDownCallback = useCallback((ev: React.KeyboardEvent) => {
|
||||
const navAction = getKeyBindingsManager().getNavigationAction(ev);
|
||||
if (navAction === KeyBindingAction.NextLandmark || navAction === KeyBindingAction.PreviousLandmark) {
|
||||
LandmarkNavigation.findAndFocusNextLandmark(
|
||||
Landmark.ROOM_LIST,
|
||||
navAction === KeyBindingAction.PreviousLandmark,
|
||||
);
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
const context = useMemo<Context>(
|
||||
() => ({ spaceId: roomsResult.spaceId, filterKeys: roomsResult.filterKeys }),
|
||||
[roomsResult.spaceId, roomsResult.filterKeys],
|
||||
);
|
||||
|
||||
return (
|
||||
<VirtualizedList
|
||||
context={context}
|
||||
scrollIntoViewOnChange={scrollIntoViewOnChange}
|
||||
initialTopMostItemIndex={activeIndex}
|
||||
data-testid="room-list"
|
||||
role="listbox"
|
||||
aria-label={_t("room_list|list_title")}
|
||||
fixedItemHeight={ROOM_LIST_ITEM_HEIGHT}
|
||||
items={roomsResult.rooms}
|
||||
getItemComponent={getItemComponent}
|
||||
getItemKey={getItemKey}
|
||||
isItemFocusable={() => true}
|
||||
onKeyDown={keyDownCallback}
|
||||
increaseViewportBy={{
|
||||
bottom: EXTENDED_VIEWPORT_HEIGHT,
|
||||
top: EXTENDED_VIEWPORT_HEIGHT,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type JSX, type PropsWithChildren } from "react";
|
||||
import { ContextMenu } from "@vector-im/compound-web";
|
||||
import React from "react";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { MoreOptionContent } from "./RoomListItemMenuView";
|
||||
import { useRoomListItemMenuViewModel } from "../../../viewmodels/roomlist/RoomListItemMenuViewModel";
|
||||
|
||||
interface RoomListItemContextMenuViewProps {
|
||||
/**
|
||||
* The room to display the menu for.
|
||||
*/
|
||||
room: Room;
|
||||
}
|
||||
|
||||
/**
|
||||
* A view for the room list item context menu.
|
||||
*/
|
||||
export function RoomListItemContextMenuView({
|
||||
room,
|
||||
children,
|
||||
}: PropsWithChildren<RoomListItemContextMenuViewProps>): JSX.Element {
|
||||
const vm = useRoomListItemMenuViewModel(room);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
title={_t("room_list|room|more_options")}
|
||||
showTitle={false}
|
||||
// To not mess with the roving tab index of the button
|
||||
hasAccessibleAlternative={true}
|
||||
trigger={children}
|
||||
>
|
||||
<MoreOptionContent vm={vm} />
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@ -1,242 +0,0 @@
|
||||
/*
|
||||
* 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, useState } from "react";
|
||||
import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem } from "@vector-im/compound-web";
|
||||
import {
|
||||
MarkAsReadIcon,
|
||||
MarkAsUnreadIcon,
|
||||
FavouriteIcon,
|
||||
ArrowDownIcon,
|
||||
UserAddIcon,
|
||||
LinkIcon,
|
||||
LeaveIcon,
|
||||
OverflowHorizontalIcon,
|
||||
NotificationsSolidIcon,
|
||||
NotificationsOffSolidIcon,
|
||||
CheckIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { Flex } from "@element-hq/web-shared-components";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import {
|
||||
type RoomListItemMenuViewState,
|
||||
useRoomListItemMenuViewModel,
|
||||
} from "../../../viewmodels/roomlist/RoomListItemMenuViewModel";
|
||||
import { RoomNotifState } from "../../../../RoomNotifs";
|
||||
|
||||
interface RoomListItemMenuViewProps {
|
||||
/**
|
||||
* Additional class name for the root element.
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* The room to display the menu for.
|
||||
*/
|
||||
room: Room;
|
||||
}
|
||||
|
||||
/**
|
||||
* A view for the room list item menu.
|
||||
*/
|
||||
export function RoomListItemMenuView({ room, className }: RoomListItemMenuViewProps): JSX.Element {
|
||||
const vm = useRoomListItemMenuViewModel(room);
|
||||
|
||||
return (
|
||||
<Flex className={classNames("mx_RoomListItemMenuView", className)} align="center" gap="var(--cpd-space-1x)">
|
||||
{vm.showMoreOptionsMenu && <MoreOptionsMenu vm={vm} />}
|
||||
{vm.showNotificationMenu && <NotificationMenu vm={vm} />}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoreOptionsMenuProps {
|
||||
/**
|
||||
* The view model state for the menu.
|
||||
*/
|
||||
vm: RoomListItemMenuViewState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The more options menu for the room list item.
|
||||
*/
|
||||
function MoreOptionsMenu({ vm }: MoreOptionsMenuProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={_t("room_list|room|more_options")}
|
||||
showTitle={false}
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton
|
||||
tooltip={_t("room_list|room|more_options")}
|
||||
aria-label={_t("room_list|room|more_options")}
|
||||
size="24px"
|
||||
>
|
||||
<OverflowHorizontalIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<MoreOptionContent vm={vm} />
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoreOptionContentProps {
|
||||
/**
|
||||
* The view model state for the menu.
|
||||
*/
|
||||
vm: RoomListItemMenuViewState;
|
||||
}
|
||||
|
||||
export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{vm.canMarkAsRead && (
|
||||
<MenuItem
|
||||
Icon={MarkAsReadIcon}
|
||||
label={_t("room_list|more_options|mark_read")}
|
||||
onSelect={vm.markAsRead}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
{vm.canMarkAsUnread && (
|
||||
<MenuItem
|
||||
Icon={MarkAsUnreadIcon}
|
||||
label={_t("room_list|more_options|mark_unread")}
|
||||
onSelect={vm.markAsUnread}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
<ToggleMenuItem
|
||||
checked={vm.isFavourite}
|
||||
Icon={FavouriteIcon}
|
||||
label={_t("room_list|more_options|favourited")}
|
||||
onSelect={vm.toggleFavorite}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
/>
|
||||
<ToggleMenuItem
|
||||
checked={vm.isLowPriority}
|
||||
Icon={ArrowDownIcon}
|
||||
label={_t("room_list|more_options|low_priority")}
|
||||
onSelect={vm.toggleLowPriority}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
/>
|
||||
{vm.canInvite && (
|
||||
<MenuItem
|
||||
Icon={UserAddIcon}
|
||||
label={_t("action|invite")}
|
||||
onSelect={vm.invite}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
{vm.canCopyRoomLink && (
|
||||
<MenuItem
|
||||
Icon={LinkIcon}
|
||||
label={_t("room_list|more_options|copy_link")}
|
||||
onSelect={vm.copyRoomLink}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
<Separator />
|
||||
<MenuItem
|
||||
kind="critical"
|
||||
Icon={LeaveIcon}
|
||||
label={_t("room_list|more_options|leave_room")}
|
||||
onSelect={vm.leaveRoom}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NotificationMenuProps {
|
||||
/**
|
||||
* The view model state for the menu.
|
||||
*/
|
||||
vm: RoomListItemMenuViewState;
|
||||
}
|
||||
|
||||
function NotificationMenu({ vm }: NotificationMenuProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={_t("room_list|notification_options")}
|
||||
showTitle={false}
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton
|
||||
size="24px"
|
||||
tooltip={_t("room_list|notification_options")}
|
||||
aria-label={_t("room_list|notification_options")}
|
||||
>
|
||||
{vm.isNotificationMute ? <NotificationsOffSolidIcon /> : <NotificationsSolidIcon />}
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<div
|
||||
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MenuItem
|
||||
aria-selected={vm.isNotificationAllMessage}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|default_settings")}
|
||||
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessages)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{vm.isNotificationAllMessage && checkComponent}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
aria-selected={vm.isNotificationAllMessageLoud}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|all_messages")}
|
||||
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessagesLoud)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{vm.isNotificationAllMessageLoud && checkComponent}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
aria-selected={vm.isNotificationMentionOnly}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|mentions_keywords")}
|
||||
onSelect={() => vm.setRoomNotifState(RoomNotifState.MentionsOnly)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{vm.isNotificationMentionOnly && checkComponent}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
aria-selected={vm.isNotificationMute}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|mute_room")}
|
||||
onSelect={() => vm.setRoomNotifState(RoomNotifState.Mute)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{vm.isNotificationMute && checkComponent}
|
||||
</MenuItem>
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@ -1,124 +0,0 @@
|
||||
/*
|
||||
* 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, memo, useEffect, useRef } from "react";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import classNames from "classnames";
|
||||
import { Flex } from "@element-hq/web-shared-components";
|
||||
|
||||
import { useRoomListItemViewModel } from "../../../viewmodels/roomlist/RoomListItemViewModel";
|
||||
import { RoomListItemMenuView } from "./RoomListItemMenuView";
|
||||
import { NotificationDecoration } from "../NotificationDecoration";
|
||||
import { RoomAvatarView } from "../../avatars/RoomAvatarView";
|
||||
import { RoomListItemContextMenuView } from "./RoomListItemContextMenuView";
|
||||
|
||||
interface RoomListItemViewProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, "onFocus"> {
|
||||
/**
|
||||
* The room to display
|
||||
*/
|
||||
room: Room;
|
||||
/**
|
||||
* Whether the room is selected
|
||||
*/
|
||||
isSelected: boolean;
|
||||
/**
|
||||
* Whether the room is focused
|
||||
*/
|
||||
isFocused: boolean;
|
||||
/**
|
||||
* A callback that indicates the item has received focus
|
||||
*/
|
||||
onFocus: (room: Room, e: React.FocusEvent) => void;
|
||||
/**
|
||||
* The index of the room in the list
|
||||
*/
|
||||
roomIndex: number;
|
||||
/**
|
||||
* The total number of rooms in the list
|
||||
*/
|
||||
roomCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An item in the room list
|
||||
*/
|
||||
export const RoomListItemView = memo(function RoomListItemView({
|
||||
room,
|
||||
isSelected,
|
||||
isFocused,
|
||||
onFocus,
|
||||
roomIndex: index,
|
||||
roomCount: count,
|
||||
...props
|
||||
}: RoomListItemViewProps): JSX.Element {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const vm = useRoomListItemViewModel(room);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
ref.current?.focus({ preventScroll: true, focusVisible: true });
|
||||
}
|
||||
}, [isFocused]);
|
||||
|
||||
const content = (
|
||||
<Flex
|
||||
as="button"
|
||||
ref={ref}
|
||||
className={classNames("mx_RoomListItemView", {
|
||||
mx_RoomListItemView_has_menu: vm.showHoverMenu,
|
||||
mx_RoomListItemView_selected: isSelected,
|
||||
mx_RoomListItemView_bold: vm.isBold,
|
||||
})}
|
||||
gap="var(--cpd-space-3x)"
|
||||
align="center"
|
||||
type="button"
|
||||
role="option"
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={count}
|
||||
aria-selected={isSelected}
|
||||
aria-label={vm.a11yLabel}
|
||||
onClick={() => vm.openRoom()}
|
||||
onFocus={(e: React.FocusEvent<HTMLButtonElement>) => onFocus(room, e)}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
{...props}
|
||||
>
|
||||
<RoomAvatarView room={room} />
|
||||
<Flex
|
||||
className="mx_RoomListItemView_content"
|
||||
gap="var(--cpd-space-2x)"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
>
|
||||
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
|
||||
<div className="mx_RoomListItemView_text">
|
||||
<div className="mx_RoomListItemView_roomName" title={vm.name}>
|
||||
{vm.name}
|
||||
</div>
|
||||
{vm.messagePreview && (
|
||||
<div className="mx_RoomListItemView_messagePreview" title={vm.messagePreview}>
|
||||
{vm.messagePreview}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{vm.showHoverMenu && <RoomListItemMenuView className="mx_RoomListItemView_menu" room={room} />}
|
||||
|
||||
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */}
|
||||
{vm.showNotificationDecoration && (
|
||||
<NotificationDecoration
|
||||
className="mx_RoomListItemView_notificationDecoration"
|
||||
notificationState={vm.notificationState}
|
||||
aria-hidden={true}
|
||||
callType={vm.callType}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (!vm.showContextMenu) return content;
|
||||
return <RoomListItemContextMenuView room={room}>{content}</RoomListItemContextMenuView>;
|
||||
});
|
||||
@ -1,169 +0,0 @@
|
||||
/*
|
||||
* 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, useEffect, useId, useRef, useState, type RefObject } from "react";
|
||||
import { ChatFilter, IconButton } from "@vector-im/compound-web";
|
||||
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
|
||||
import { Flex } from "@element-hq/web-shared-components";
|
||||
|
||||
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
|
||||
interface RoomListPrimaryFiltersProps {
|
||||
/**
|
||||
* The view model for the room list
|
||||
*/
|
||||
vm: RoomListViewState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary filters for the room list
|
||||
*/
|
||||
export function RoomListPrimaryFilters({ vm }: RoomListPrimaryFiltersProps): JSX.Element {
|
||||
const id = useId();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const { ref, isWrapping: displayChevron, wrappingIndex } = useCollapseFilters<HTMLUListElement>(isExpanded);
|
||||
const filters = useVisibleFilters(vm.primaryFilters, wrappingIndex);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
className="mx_RoomListPrimaryFilters"
|
||||
data-testid="primary-filters"
|
||||
gap="var(--cpd-space-3x)"
|
||||
direction="row-reverse"
|
||||
justify="space-between"
|
||||
>
|
||||
{displayChevron && (
|
||||
<IconButton
|
||||
kind="secondary"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={id}
|
||||
className="mx_RoomListPrimaryFilters_IconButton"
|
||||
aria-label={isExpanded ? _t("room_list|collapse_filters") : _t("room_list|expand_filters")}
|
||||
size="28px"
|
||||
onClick={() => setIsExpanded((_expanded) => !_expanded)}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<Flex
|
||||
id={id}
|
||||
as="div"
|
||||
role="listbox"
|
||||
aria-label={_t("room_list|primary_filters")}
|
||||
align="center"
|
||||
gap="var(--cpd-space-2x)"
|
||||
wrap="wrap"
|
||||
className="mx_RoomListPrimaryFilters_list"
|
||||
ref={ref}
|
||||
>
|
||||
{filters.map((filter, i) => (
|
||||
<ChatFilter key={i} role="option" selected={filter.active} onClick={() => filter.toggle()}>
|
||||
{filter.name}
|
||||
</ChatFilter>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook to manage the wrapping of filters in the room list.
|
||||
* It observes the filter list and hides filters that are wrapping when the list is not expanded.
|
||||
* @param isExpanded
|
||||
* @returns an object containing:
|
||||
* - `ref`: a ref to put on the filter list element
|
||||
* - `isWrapping`: a boolean indicating if the filters are wrapping
|
||||
* - `wrappingIndex`: the index of the first filter that is wrapping
|
||||
*/
|
||||
function useCollapseFilters<T extends HTMLElement>(
|
||||
isExpanded: boolean,
|
||||
): { ref: RefObject<T | null>; isWrapping: boolean; wrappingIndex: number } {
|
||||
const ref = useRef<T>(null);
|
||||
const [isWrapping, setIsWrapping] = useState(false);
|
||||
const [wrappingIndex, setWrappingIndex] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const hideFilters = (list: Element): void => {
|
||||
let isWrapping = false;
|
||||
Array.from(list.children).forEach((node, i): void => {
|
||||
const child = node as HTMLElement;
|
||||
const wrappingClass = "mx_RoomListPrimaryFilters_wrapping";
|
||||
child.setAttribute("aria-hidden", "false");
|
||||
child.classList.remove(wrappingClass);
|
||||
|
||||
// If the filter list is expanded, all filters are visible
|
||||
if (isExpanded) return;
|
||||
|
||||
// If the previous element is on the left element of the current one, it means that the filter is wrapping
|
||||
const previousSibling = child.previousElementSibling as HTMLElement | null;
|
||||
if (previousSibling && child.offsetLeft <= previousSibling.offsetLeft) {
|
||||
if (!isWrapping) setWrappingIndex(i);
|
||||
isWrapping = true;
|
||||
}
|
||||
|
||||
// If the filter is wrapping, we hide it
|
||||
child.classList.toggle(wrappingClass, isWrapping);
|
||||
child.setAttribute("aria-hidden", isWrapping.toString());
|
||||
});
|
||||
|
||||
if (!isWrapping) setWrappingIndex(-1);
|
||||
setIsWrapping(isExpanded || isWrapping);
|
||||
};
|
||||
|
||||
hideFilters(ref.current);
|
||||
const observer = new ResizeObserver((entries) => entries.forEach((entry) => hideFilters(entry.target)));
|
||||
|
||||
observer.observe(ref.current);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [isExpanded]);
|
||||
|
||||
return { ref, isWrapping, wrappingIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook to sort the filters by active state.
|
||||
* The list is sorted if the current filter index is greater than or equal to the wrapping index.
|
||||
* If the wrapping index is -1, the filters are not sorted.
|
||||
*
|
||||
* @param filters - the list of filters to sort.
|
||||
* @param wrappingIndex - the index of the first filter that is wrapping.
|
||||
*/
|
||||
export function useVisibleFilters(
|
||||
filters: RoomListViewState["primaryFilters"],
|
||||
wrappingIndex: number,
|
||||
): RoomListViewState["primaryFilters"] {
|
||||
// By default, the filters are not sorted
|
||||
const [sortedFilters, setSortedFilters] = useState(filters);
|
||||
|
||||
useEffect(() => {
|
||||
const isActiveFilterWrapping = filters.findIndex((f) => f.active) >= wrappingIndex;
|
||||
// If the active filter is not wrapping, we don't need to sort the filters
|
||||
if (!isActiveFilterWrapping || wrappingIndex === -1) {
|
||||
setSortedFilters(filters);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort the filters with the current filter at first position
|
||||
setSortedFilters(
|
||||
filters.slice().sort((filterA, filterB) => {
|
||||
// If the filter is active, it should be at the top of the list
|
||||
if (filterA.active && !filterB.active) return -1;
|
||||
if (!filterA.active && filterB.active) return 1;
|
||||
// If both filters are active or not, keep their original order
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
}, [filters, wrappingIndex]);
|
||||
|
||||
return sortedFilters;
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
/*
|
||||
* 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 { renderHook, waitFor } from "jest-matrix-react";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { createTestClient, mkStubRoom } from "../../../../test-utils";
|
||||
import { type MessagePreview, MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore";
|
||||
import { useMessagePreviewViewModel } from "../../../../../src/components/viewmodels/roomlist/MessagePreviewViewModel";
|
||||
|
||||
describe("MessagePreviewViewModel", () => {
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
const matrixClient = createTestClient();
|
||||
room = mkStubRoom("roomId", "roomName", matrixClient);
|
||||
});
|
||||
|
||||
it("should do an initial fetch of the message preview", async () => {
|
||||
// Mock the store to return some text.
|
||||
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockImplementation(async (room) => {
|
||||
return { text: "Hello world!" } as MessagePreview;
|
||||
});
|
||||
|
||||
const { result: vm } = renderHook(() => useMessagePreviewViewModel(room));
|
||||
|
||||
// Eventually, vm.message should have the text from the store.
|
||||
await waitFor(() => {
|
||||
expect(vm.current.message).toEqual("Hello world!");
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch message preview again on update from store", async () => {
|
||||
// Mock the store to return the text in variable message.
|
||||
let message = "Hello World!";
|
||||
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockImplementation(async (room) => {
|
||||
return { text: message } as MessagePreview;
|
||||
});
|
||||
jest.spyOn(MessagePreviewStore, "getPreviewChangedEventName").mockImplementation((room) => {
|
||||
return "UPDATE";
|
||||
});
|
||||
|
||||
const { result: vm } = renderHook(() => useMessagePreviewViewModel(room));
|
||||
|
||||
// Let's assume the message changed.
|
||||
message = "New message!";
|
||||
MessagePreviewStore.instance.emit("UPDATE");
|
||||
|
||||
/// vm.message should be the updated message.
|
||||
await waitFor(() => {
|
||||
expect(vm.current.message).toEqual(message);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,221 +0,0 @@
|
||||
/*
|
||||
* 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 { renderHook } from "jest-matrix-react";
|
||||
import { mocked } from "jest-mock";
|
||||
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { mkStubRoom, stubClient, withClientContextRenderOptions } from "../../../../test-utils";
|
||||
import { useRoomListItemMenuViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListItemMenuViewModel";
|
||||
import {
|
||||
hasAccessToNotificationMenu,
|
||||
hasAccessToOptionsMenu,
|
||||
} from "../../../../../src/components/viewmodels/roomlist/utils";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import { DefaultTagID } from "../../../../../src/stores/room-list/models";
|
||||
import { useUnreadNotifications } from "../../../../../src/hooks/useUnreadNotifications";
|
||||
import { NotificationLevel } from "../../../../../src/stores/notifications/NotificationLevel";
|
||||
import { clearRoomNotification, setMarkedUnreadState } from "../../../../../src/utils/notifications";
|
||||
import { tagRoom } from "../../../../../src/utils/room/tagRoom";
|
||||
import dispatcher from "../../../../../src/dispatcher/dispatcher";
|
||||
import { useNotificationState } from "../../../../../src/hooks/useRoomNotificationState";
|
||||
import { RoomNotifState } from "../../../../../src/RoomNotifs";
|
||||
|
||||
jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({
|
||||
hasAccessToOptionsMenu: jest.fn().mockReturnValue(false),
|
||||
hasAccessToNotificationMenu: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/hooks/useUnreadNotifications", () => ({
|
||||
useUnreadNotifications: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/hooks/useRoomNotificationState", () => ({
|
||||
useNotificationState: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/utils/notifications", () => ({
|
||||
clearRoomNotification: jest.fn(),
|
||||
setMarkedUnreadState: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/utils/room/tagRoom", () => ({
|
||||
tagRoom: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("RoomListItemMenuViewModel", () => {
|
||||
let matrixClient: MatrixClient;
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
matrixClient = stubClient();
|
||||
room = mkStubRoom("roomId", "roomName", matrixClient);
|
||||
|
||||
DMRoomMap.makeShared(matrixClient);
|
||||
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined);
|
||||
|
||||
mocked(useUnreadNotifications).mockReturnValue({ symbol: null, count: 0, level: NotificationLevel.None });
|
||||
mocked(useNotificationState).mockReturnValue([RoomNotifState.AllMessages, jest.fn()]);
|
||||
jest.spyOn(dispatcher, "dispatch");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
function render() {
|
||||
return renderHook(() => useRoomListItemMenuViewModel(room), withClientContextRenderOptions(matrixClient));
|
||||
}
|
||||
|
||||
it("default", () => {
|
||||
const { result } = render();
|
||||
expect(result.current.showMoreOptionsMenu).toBe(false);
|
||||
expect(result.current.canInvite).toBe(false);
|
||||
expect(result.current.isFavourite).toBe(false);
|
||||
expect(result.current.canCopyRoomLink).toBe(true);
|
||||
expect(result.current.canMarkAsRead).toBe(false);
|
||||
expect(result.current.canMarkAsUnread).toBe(true);
|
||||
});
|
||||
|
||||
it("should has showMoreOptionsMenu to be true", () => {
|
||||
mocked(hasAccessToOptionsMenu).mockReturnValue(true);
|
||||
const { result } = render();
|
||||
expect(result.current.showMoreOptionsMenu).toBe(true);
|
||||
});
|
||||
|
||||
it("should has showNotificationMenu to be true", () => {
|
||||
mocked(hasAccessToNotificationMenu).mockReturnValue(true);
|
||||
const { result } = render();
|
||||
expect(result.current.showNotificationMenu).toBe(true);
|
||||
});
|
||||
|
||||
it("should be able to invite", () => {
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(true);
|
||||
const { result } = render();
|
||||
expect(result.current.canInvite).toBe(true);
|
||||
});
|
||||
|
||||
it("should be a favourite", () => {
|
||||
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
|
||||
const { result } = render();
|
||||
expect(result.current.isFavourite).toBe(true);
|
||||
});
|
||||
|
||||
it("should not be able to copy the room link", () => {
|
||||
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue("userId");
|
||||
const { result } = render();
|
||||
expect(result.current.canCopyRoomLink).toBe(false);
|
||||
});
|
||||
|
||||
it("should be able to mark as read", () => {
|
||||
// Add a notification
|
||||
mocked(useUnreadNotifications).mockReturnValue({
|
||||
symbol: null,
|
||||
count: 1,
|
||||
level: NotificationLevel.Notification,
|
||||
});
|
||||
const { result } = render();
|
||||
expect(result.current.canMarkAsRead).toBe(true);
|
||||
expect(result.current.canMarkAsUnread).toBe(false);
|
||||
});
|
||||
|
||||
it("should has isNotificationAllMessage to be true", () => {
|
||||
const { result } = render();
|
||||
expect(result.current.isNotificationAllMessage).toBe(true);
|
||||
});
|
||||
|
||||
it("should has isNotificationAllMessageLoud to be true", () => {
|
||||
mocked(useNotificationState).mockReturnValue([RoomNotifState.AllMessagesLoud, jest.fn()]);
|
||||
const { result } = render();
|
||||
expect(result.current.isNotificationAllMessageLoud).toBe(true);
|
||||
});
|
||||
|
||||
it("should has isNotificationMentionOnly to be true", () => {
|
||||
mocked(useNotificationState).mockReturnValue([RoomNotifState.MentionsOnly, jest.fn()]);
|
||||
const { result } = render();
|
||||
expect(result.current.isNotificationMentionOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("should has isNotificationMute to be true", () => {
|
||||
mocked(useNotificationState).mockReturnValue([RoomNotifState.Mute, jest.fn()]);
|
||||
const { result } = render();
|
||||
expect(result.current.isNotificationMute).toBe(true);
|
||||
});
|
||||
|
||||
// Actions
|
||||
|
||||
it("should mark as read", () => {
|
||||
const { result } = render();
|
||||
result.current.markAsRead(new Event("click"));
|
||||
expect(mocked(clearRoomNotification)).toHaveBeenCalledWith(room, matrixClient);
|
||||
});
|
||||
|
||||
it("should mark as unread", () => {
|
||||
const { result } = render();
|
||||
result.current.markAsUnread(new Event("click"));
|
||||
expect(mocked(setMarkedUnreadState)).toHaveBeenCalledWith(room, matrixClient, true);
|
||||
});
|
||||
|
||||
it("should tag a room as favourite", () => {
|
||||
const { result } = render();
|
||||
result.current.toggleFavorite(new Event("click"));
|
||||
expect(mocked(tagRoom)).toHaveBeenCalledWith(room, DefaultTagID.Favourite);
|
||||
});
|
||||
|
||||
it("should tag a room as low priority", () => {
|
||||
const { result } = render();
|
||||
result.current.toggleLowPriority();
|
||||
expect(mocked(tagRoom)).toHaveBeenCalledWith(room, DefaultTagID.LowPriority);
|
||||
});
|
||||
|
||||
it("should dispatch invite action", () => {
|
||||
const { result } = render();
|
||||
result.current.invite(new Event("click"));
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
||||
action: "view_invite",
|
||||
roomId: room.roomId,
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch a copy room action", () => {
|
||||
const { result } = render();
|
||||
result.current.copyRoomLink(new Event("click"));
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
||||
action: "copy_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch forget room action", () => {
|
||||
// forget room is only available for archived rooms
|
||||
room.tags = { [DefaultTagID.Archived]: { order: 0 } };
|
||||
|
||||
const { result } = render();
|
||||
result.current.leaveRoom(new Event("click"));
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
||||
action: "forget_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch leave room action", () => {
|
||||
const { result } = render();
|
||||
result.current.leaveRoom(new Event("click"));
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
||||
action: "leave_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
});
|
||||
|
||||
it("should call setRoomNotifState", () => {
|
||||
const setRoomNotifState = jest.fn();
|
||||
mocked(useNotificationState).mockReturnValue([RoomNotifState.AllMessages, setRoomNotifState]);
|
||||
const { result } = render();
|
||||
result.current.setRoomNotifState(RoomNotifState.Mute);
|
||||
expect(setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.Mute);
|
||||
});
|
||||
});
|
||||
@ -1,341 +0,0 @@
|
||||
/*
|
||||
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 { range } from "lodash";
|
||||
import { act, renderHook, waitFor } from "jest-matrix-react";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import RoomListStoreV3, { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import { mkStubRoom } from "../../../../test-utils";
|
||||
import { useRoomListViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
|
||||
import { FilterKey } from "../../../../../src/stores/room-list-v3/skip-list/filters";
|
||||
import { hasCreateRoomRights, createRoom } from "../../../../../src/components/viewmodels/roomlist/utils";
|
||||
import dispatcher from "../../../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
|
||||
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
|
||||
import { UPDATE_SELECTED_SPACE } from "../../../../../src/stores/spaces";
|
||||
|
||||
jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({
|
||||
hasCreateRoomRights: jest.fn().mockReturnValue(false),
|
||||
createRoom: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("RoomListViewModel", () => {
|
||||
function mockAndCreateRooms() {
|
||||
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
|
||||
const fn = jest
|
||||
.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace")
|
||||
.mockImplementation(() => ({ spaceId: "home", rooms: [...rooms] }));
|
||||
return { rooms, fn };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should return a list of rooms", async () => {
|
||||
const { rooms } = mockAndCreateRooms();
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
|
||||
expect(vm.current.roomsResult.rooms).toHaveLength(10);
|
||||
for (const room of rooms) {
|
||||
expect(vm.current.roomsResult.rooms).toContain(room);
|
||||
}
|
||||
});
|
||||
|
||||
it("should update list of rooms on event from room list store", async () => {
|
||||
const { rooms } = mockAndCreateRooms();
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
|
||||
const newRoom = mkStubRoom("bar:matrix.org", "Bar", undefined);
|
||||
rooms.push(newRoom);
|
||||
await act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vm.current.roomsResult.rooms).toContain(newRoom);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Filters", () => {
|
||||
it("should provide list of available filters", () => {
|
||||
mockAndCreateRooms();
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
// should have 6 filters
|
||||
expect(vm.current.primaryFilters).toHaveLength(7);
|
||||
// check the order
|
||||
for (const [i, name] of [
|
||||
"Unreads",
|
||||
"People",
|
||||
"Rooms",
|
||||
"Favourites",
|
||||
"Mentions",
|
||||
"Invites",
|
||||
"Low priority",
|
||||
].entries()) {
|
||||
expect(vm.current.primaryFilters[i].name).toEqual(name);
|
||||
expect(vm.current.primaryFilters[i].active).toEqual(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should get filtered rooms from RLS on toggle", () => {
|
||||
const { fn } = mockAndCreateRooms();
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
// Let's say we toggle the People toggle
|
||||
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
|
||||
act(() => {
|
||||
vm.current.primaryFilters[i].toggle();
|
||||
});
|
||||
expect(fn).toHaveBeenCalledWith([FilterKey.PeopleFilter]);
|
||||
expect(vm.current.primaryFilters[i].active).toEqual(true);
|
||||
});
|
||||
|
||||
it("should change active property on toggle", () => {
|
||||
mockAndCreateRooms();
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
// Let's say we toggle the People filter
|
||||
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
|
||||
expect(vm.current.primaryFilters[i].active).toEqual(false);
|
||||
act(() => {
|
||||
vm.current.primaryFilters[i].toggle();
|
||||
});
|
||||
expect(vm.current.primaryFilters[i].active).toEqual(true);
|
||||
|
||||
// Let's say that we toggle the Favourite filter
|
||||
const j = vm.current.primaryFilters.findIndex((f) => f.name === "Favourites");
|
||||
act(() => {
|
||||
vm.current.primaryFilters[j].toggle();
|
||||
});
|
||||
expect(vm.current.primaryFilters[i].active).toEqual(false);
|
||||
expect(vm.current.primaryFilters[j].active).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return the current active primary filter", async () => {
|
||||
// Let's say that the user's preferred sorting is alphabetic
|
||||
mockAndCreateRooms();
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
// Toggle people filter
|
||||
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
|
||||
expect(vm.current.primaryFilters[i].active).toEqual(false);
|
||||
act(() => vm.current.primaryFilters[i].toggle());
|
||||
|
||||
// The active primary filter should be the People filter
|
||||
expect(vm.current.activePrimaryFilter).toEqual(vm.current.primaryFilters[i]);
|
||||
});
|
||||
|
||||
it("should not remove all filters when active space is changed", async () => {
|
||||
mockAndCreateRooms();
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
|
||||
// Let's first toggle the People filter
|
||||
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
|
||||
act(() => {
|
||||
vm.current.primaryFilters[i].toggle();
|
||||
});
|
||||
expect(vm.current.primaryFilters[i].active).toEqual(true);
|
||||
|
||||
// Simulate a space change
|
||||
await act(() => SpaceStore.instance.emit(UPDATE_SELECTED_SPACE));
|
||||
|
||||
// Primary filter should remain unchanged
|
||||
expect(vm.current.activePrimaryFilter?.name).toEqual("People");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Create room and chat", () => {
|
||||
it("should be canCreateRoom=false if hasCreateRoomRights=false", () => {
|
||||
mocked(hasCreateRoomRights).mockReturnValue(false);
|
||||
const { result } = renderHook(() => useRoomListViewModel());
|
||||
expect(result.current.canCreateRoom).toBe(false);
|
||||
});
|
||||
|
||||
it("should be canCreateRoom=true if hasCreateRoomRights=true", () => {
|
||||
mocked(hasCreateRoomRights).mockReturnValue(true);
|
||||
const { result } = renderHook(() => useRoomListViewModel());
|
||||
expect(result.current.canCreateRoom).toBe(true);
|
||||
});
|
||||
|
||||
it("should call createRoom", () => {
|
||||
const { result } = renderHook(() => useRoomListViewModel());
|
||||
result.current.createRoom();
|
||||
expect(mocked(createRoom)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should dispatch Action.CreateChat", () => {
|
||||
const spy = jest.spyOn(dispatcher, "fire");
|
||||
const { result } = renderHook(() => useRoomListViewModel());
|
||||
result.current.createChatRoom();
|
||||
expect(spy).toHaveBeenCalledWith(Action.CreateChat);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sticky room and active index", () => {
|
||||
function expectActiveRoom(vm: ReturnType<typeof useRoomListViewModel>, i: number, roomId: string) {
|
||||
expect(vm.activeIndex).toEqual(i);
|
||||
expect(vm.roomsResult.rooms[i].roomId).toEqual(roomId);
|
||||
}
|
||||
|
||||
it("active index is calculated with the last opened room in a space", () => {
|
||||
// Let's say there's two spaces: !space1:matrix.org and !space2:matrix.org
|
||||
// Let's also say that the current active space is !space1:matrix.org
|
||||
let currentSpace = "!space1:matrix.org";
|
||||
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => currentSpace);
|
||||
|
||||
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
|
||||
// Let's say all the rooms are in space1
|
||||
const roomsInSpace1 = { spaceId: currentSpace, rooms: [...rooms] };
|
||||
// Let's say all rooms with even index are in space 2
|
||||
const roomsInSpace2 = { spaceId: "!space2:matrix.org", rooms: [...rooms].filter((_, i) => i % 2 === 0) };
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockImplementation(() =>
|
||||
currentSpace === "!space1:matrix.org" ? roomsInSpace1 : roomsInSpace2,
|
||||
);
|
||||
|
||||
// Let's say that the room at index 4 is currently active
|
||||
const roomId = rooms[4].roomId;
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
|
||||
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
expect(vm.current.activeIndex).toEqual(4);
|
||||
|
||||
// Let's say that space is changed to "!space2:matrix.org"
|
||||
currentSpace = "!space2:matrix.org";
|
||||
// Let's say that room[6] is active in space 2
|
||||
const activeRoomIdInSpace2 = rooms[6].roomId;
|
||||
jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockImplementation(
|
||||
() => activeRoomIdInSpace2,
|
||||
);
|
||||
act(() => {
|
||||
RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT);
|
||||
});
|
||||
|
||||
// Active index should be 3 even without the room change event.
|
||||
expectActiveRoom(vm.current, 3, activeRoomIdInSpace2);
|
||||
});
|
||||
|
||||
it("active room and active index are retained on order change", () => {
|
||||
const { rooms } = mockAndCreateRooms();
|
||||
|
||||
// Let's say that the room at index 5 is active
|
||||
const roomId = rooms[5].roomId;
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
|
||||
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
expect(vm.current.activeIndex).toEqual(5);
|
||||
|
||||
// Let's say that room at index 9 moves to index 5
|
||||
const room9 = rooms[9];
|
||||
rooms.splice(9, 1);
|
||||
rooms.splice(5, 0, room9);
|
||||
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
|
||||
|
||||
// Active room index should still be 5
|
||||
expectActiveRoom(vm.current, 5, roomId);
|
||||
|
||||
// Let's add 2 new rooms from index 0
|
||||
const newRoom1 = mkStubRoom("bar1:matrix.org", "Bar 1", undefined);
|
||||
const newRoom2 = mkStubRoom("bar2:matrix.org", "Bar 2", undefined);
|
||||
rooms.unshift(newRoom1, newRoom2);
|
||||
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
|
||||
|
||||
// Active room index should still be 5
|
||||
expectActiveRoom(vm.current, 5, roomId);
|
||||
});
|
||||
|
||||
it("active room and active index are updated when another room is opened", () => {
|
||||
const { rooms } = mockAndCreateRooms();
|
||||
const roomId = rooms[5].roomId;
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
|
||||
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
expectActiveRoom(vm.current, 5, roomId);
|
||||
|
||||
// Let's say that room at index 9 becomes active
|
||||
const room = rooms[9];
|
||||
act(() => {
|
||||
dispatcher.dispatch(
|
||||
{
|
||||
action: Action.ActiveRoomChanged,
|
||||
oldRoomId: null,
|
||||
newRoomId: room.roomId,
|
||||
},
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// Active room index should change to reflect new room
|
||||
expectActiveRoom(vm.current, 9, room.roomId);
|
||||
});
|
||||
|
||||
it("active room and active index are updated when active index spills out of rooms array bounds", () => {
|
||||
const { rooms } = mockAndCreateRooms();
|
||||
// Let's say that the room at index 5 is active
|
||||
const roomId = rooms[5].roomId;
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
|
||||
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
expectActiveRoom(vm.current, 5, roomId);
|
||||
|
||||
// Let's say that we remove rooms from the start of the array
|
||||
for (let i = 0; i < 4; ++i) {
|
||||
// We should be able to do 4 deletions before we run out of rooms
|
||||
rooms.splice(0, 1);
|
||||
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
|
||||
expectActiveRoom(vm.current, 5, roomId);
|
||||
}
|
||||
|
||||
// If we remove one more room from the start, there's not going to be enough rooms
|
||||
// to maintain the active index.
|
||||
rooms.splice(0, 1);
|
||||
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
|
||||
expectActiveRoom(vm.current, 0, roomId);
|
||||
});
|
||||
|
||||
it("active room and active index are retained when rooms that appear after the active room are deleted", () => {
|
||||
const { rooms } = mockAndCreateRooms();
|
||||
// Let's say that the room at index 5 is active
|
||||
const roomId = rooms[5].roomId;
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
|
||||
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
expectActiveRoom(vm.current, 5, roomId);
|
||||
|
||||
// Let's say that we remove rooms from the start of the array
|
||||
for (let i = 0; i < 4; ++i) {
|
||||
// Deleting rooms after index 5 (active) should not update the active index
|
||||
rooms.splice(6, 1);
|
||||
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
|
||||
expectActiveRoom(vm.current, 5, roomId);
|
||||
}
|
||||
});
|
||||
|
||||
it("active room index becomes undefined when active room is deleted", () => {
|
||||
const { rooms } = mockAndCreateRooms();
|
||||
// Let's say that the room at index 5 is active
|
||||
let roomId: string | null = rooms[5].roomId;
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
|
||||
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
expectActiveRoom(vm.current, 5, roomId);
|
||||
|
||||
// Let's remove the active room (i.e room at index 5)
|
||||
rooms.splice(5, 1);
|
||||
roomId = null;
|
||||
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
|
||||
expect(vm.current.activeIndex).toBeUndefined();
|
||||
});
|
||||
|
||||
it("active room index is initially undefined", () => {
|
||||
mockAndCreateRooms();
|
||||
|
||||
// Let's say that there's no active room currently
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => null);
|
||||
|
||||
const { result: vm } = renderHook(() => useRoomListViewModel());
|
||||
expect(vm.current.activeIndex).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,152 +0,0 @@
|
||||
/*
|
||||
* 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 { renderHook } from "jest-matrix-react";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { waitFor } from "@testing-library/dom";
|
||||
|
||||
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
|
||||
import dispatcher from "../../../../../src/dispatcher/dispatcher";
|
||||
import { mkStubRoom, stubClient } from "../../../../test-utils";
|
||||
import { useRoomListNavigation } from "../../../../../src/components/viewmodels/roomlist/useRoomListNavigation";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore";
|
||||
import { type RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState";
|
||||
|
||||
describe("useRoomListNavigation", () => {
|
||||
let rooms: Room[];
|
||||
|
||||
beforeEach(() => {
|
||||
const matrixClient = stubClient();
|
||||
rooms = [
|
||||
mkStubRoom("room1", "Room 1", matrixClient),
|
||||
mkStubRoom("room2", "Room 2", matrixClient),
|
||||
mkStubRoom("room3", "Room 3", matrixClient),
|
||||
];
|
||||
|
||||
DMRoomMap.makeShared(matrixClient);
|
||||
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined);
|
||||
jest.spyOn(dispatcher, "dispatch");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should navigate to the next room based on delta", async () => {
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room1");
|
||||
|
||||
renderHook(() => useRoomListNavigation(rooms));
|
||||
dispatcher.dispatch({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: 1,
|
||||
unread: false,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: "room2",
|
||||
show_room_tile: true,
|
||||
metricsTrigger: "WebKeyboardShortcut",
|
||||
metricsViaKeyboard: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should navigate to the previous room based on delta", async () => {
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room2");
|
||||
|
||||
renderHook(() => useRoomListNavigation(rooms));
|
||||
dispatcher.dispatch({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: -1,
|
||||
unread: false,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: "room1",
|
||||
show_room_tile: true,
|
||||
metricsTrigger: "WebKeyboardShortcut",
|
||||
metricsViaKeyboard: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should wrap around to the first room when navigating past the last room", async () => {
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room3");
|
||||
|
||||
renderHook(() => useRoomListNavigation(rooms));
|
||||
dispatcher.dispatch({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: 1,
|
||||
unread: false,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: "room1",
|
||||
show_room_tile: true,
|
||||
metricsTrigger: "WebKeyboardShortcut",
|
||||
metricsViaKeyboard: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should wrap around to the last room when navigating before the first room", async () => {
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room1");
|
||||
|
||||
renderHook(() => useRoomListNavigation(rooms));
|
||||
dispatcher.dispatch({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: -1,
|
||||
unread: false,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: "room3",
|
||||
show_room_tile: true,
|
||||
metricsTrigger: "WebKeyboardShortcut",
|
||||
metricsViaKeyboard: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter rooms to only unread when unread=true", async () => {
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room1");
|
||||
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation(
|
||||
(room) =>
|
||||
({
|
||||
isUnread: room.roomId !== "room1",
|
||||
}) as RoomNotificationState,
|
||||
);
|
||||
|
||||
renderHook(() => useRoomListNavigation(rooms));
|
||||
|
||||
dispatcher.dispatch({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: 1,
|
||||
unread: true,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: "room2",
|
||||
show_room_tile: true,
|
||||
metricsTrigger: "WebKeyboardShortcut",
|
||||
metricsViaKeyboard: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,92 +0,0 @@
|
||||
/*
|
||||
* 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 from "react";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
|
||||
import { EmptyRoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/EmptyRoomList";
|
||||
import { FilterKey } from "../../../../../../src/stores/room-list-v3/skip-list/filters";
|
||||
|
||||
describe("<EmptyRoomList />", () => {
|
||||
let vm: RoomListViewState;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = {
|
||||
isLoadingRooms: false,
|
||||
roomsResult: { spaceId: "home", rooms: [] },
|
||||
primaryFilters: [],
|
||||
createRoom: jest.fn(),
|
||||
createChatRoom: jest.fn(),
|
||||
canCreateRoom: true,
|
||||
activeIndex: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
test("should render the default placeholder when there is no filter", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const { asFragment } = render(<EmptyRoomList vm={vm} />);
|
||||
expect(screen.getByText("No chats yet")).toBeInTheDocument();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Start chat" }));
|
||||
expect(vm.createChatRoom).toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "New room" }));
|
||||
expect(vm.createRoom).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not render the new room button if the user doesn't have the rights to create a room", async () => {
|
||||
const newState = { ...vm, canCreateRoom: false };
|
||||
|
||||
const { asFragment } = render(<EmptyRoomList vm={newState} />);
|
||||
expect(screen.queryByRole("button", { name: "New room" })).toBeNull();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ key: FilterKey.UnreadFilter, name: "unread", action: "Show all chats" },
|
||||
{ key: FilterKey.MentionsFilter, name: "mention", action: "See all activity" },
|
||||
{ key: FilterKey.InvitesFilter, name: "invite", action: "See all activity" },
|
||||
{ key: FilterKey.LowPriorityFilter, name: "low priority", action: "See all activity" },
|
||||
])("should display the empty state for the $name filter", async ({ key, name, action }) => {
|
||||
const user = userEvent.setup();
|
||||
const activePrimaryFilter = {
|
||||
toggle: jest.fn(),
|
||||
active: true,
|
||||
name,
|
||||
key,
|
||||
};
|
||||
const newState = {
|
||||
...vm,
|
||||
activePrimaryFilter,
|
||||
};
|
||||
|
||||
const { asFragment } = render(<EmptyRoomList vm={newState} />);
|
||||
await user.click(screen.getByRole("button", { name: action }));
|
||||
expect(activePrimaryFilter.toggle).toHaveBeenCalled();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ key: FilterKey.FavouriteFilter, name: "favourite" },
|
||||
{ key: FilterKey.PeopleFilter, name: "people" },
|
||||
{ key: FilterKey.RoomsFilter, name: "rooms" },
|
||||
])("should display empty state for filter $name", ({ name, key }) => {
|
||||
const activePrimaryFilter = {
|
||||
toggle: jest.fn(),
|
||||
active: true,
|
||||
name,
|
||||
key,
|
||||
};
|
||||
const newState = { ...vm, activePrimaryFilter };
|
||||
const { asFragment } = render(<EmptyRoomList vm={newState} />);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@ -1,78 +0,0 @@
|
||||
/*
|
||||
* 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 from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { render } from "jest-matrix-react";
|
||||
import { fireEvent } from "@testing-library/dom";
|
||||
import { VirtuosoMockContext } from "@element-hq/web-shared-components";
|
||||
|
||||
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
|
||||
import { RoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomList";
|
||||
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
|
||||
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
|
||||
import { Landmark, LandmarkNavigation } from "../../../../../../src/accessibility/LandmarkNavigation";
|
||||
import { mkRoom, stubClient } from "../../../../../test-utils";
|
||||
|
||||
describe("<RoomList />", () => {
|
||||
let matrixClient: MatrixClient;
|
||||
let vm: RoomListViewState;
|
||||
|
||||
beforeEach(() => {
|
||||
matrixClient = stubClient();
|
||||
const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`));
|
||||
vm = {
|
||||
isLoadingRooms: false,
|
||||
roomsResult: { spaceId: "home", rooms },
|
||||
primaryFilters: [],
|
||||
createRoom: jest.fn(),
|
||||
createChatRoom: jest.fn(),
|
||||
canCreateRoom: true,
|
||||
activeIndex: undefined,
|
||||
};
|
||||
|
||||
// Needed to render a room list cell
|
||||
DMRoomMap.makeShared(matrixClient);
|
||||
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it("should render a room list", () => {
|
||||
const { asFragment } = render(<RoomList vm={vm} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<MatrixClientContext.Provider value={matrixClient}>
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 56 }}>
|
||||
<>{children}</>
|
||||
</VirtuosoMockContext.Provider>
|
||||
</MatrixClientContext.Provider>
|
||||
),
|
||||
});
|
||||
// At the moment the context prop on Virtuoso gets rendered in the dom as "[object Object]".
|
||||
// This is a general issue with the react-virtuoso library.
|
||||
// TODO: Update the snapshot when the following issue is resolved: https://github.com/petyosi/react-virtuoso/issues/1281
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ shortcut: { key: "F6", ctrlKey: true, shiftKey: true }, isPreviousLandmark: true, label: "PreviousLandmark" },
|
||||
{ shortcut: { key: "F6", ctrlKey: true }, isPreviousLandmark: false, label: "NextLandmark" },
|
||||
])("should navigate to the landmark on NextLandmark.$label action", ({ shortcut, isPreviousLandmark }) => {
|
||||
const spyFindLandmark = jest.spyOn(LandmarkNavigation, "findAndFocusNextLandmark").mockReturnValue();
|
||||
const { getByTestId } = render(<RoomList vm={vm} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<MatrixClientContext.Provider value={matrixClient}>
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 56 }}>
|
||||
<>{children}</>
|
||||
</VirtuosoMockContext.Provider>
|
||||
</MatrixClientContext.Provider>
|
||||
),
|
||||
});
|
||||
const roomList = getByTestId("room-list");
|
||||
fireEvent.keyDown(roomList, shortcut);
|
||||
|
||||
expect(spyFindLandmark).toHaveBeenCalledWith(Landmark.ROOM_LIST, isPreviousLandmark);
|
||||
});
|
||||
});
|
||||
@ -1,144 +0,0 @@
|
||||
/*
|
||||
* 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 from "react";
|
||||
import { mocked } from "jest-mock";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import {
|
||||
type RoomListItemMenuViewState,
|
||||
useRoomListItemMenuViewModel,
|
||||
} from "../../../../../../src/components/viewmodels/roomlist/RoomListItemMenuViewModel";
|
||||
import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { mkRoom, stubClient } from "../../../../../test-utils";
|
||||
import { RoomListItemMenuView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListItemMenuView";
|
||||
import { RoomNotifState } from "../../../../../../src/RoomNotifs";
|
||||
|
||||
jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListItemMenuViewModel", () => ({
|
||||
useRoomListItemMenuViewModel: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("<RoomListItemMenuView />", () => {
|
||||
const defaultValue: RoomListItemMenuViewState = {
|
||||
showMoreOptionsMenu: true,
|
||||
showNotificationMenu: true,
|
||||
isFavourite: true,
|
||||
isLowPriority: true,
|
||||
canInvite: true,
|
||||
canMarkAsUnread: true,
|
||||
canMarkAsRead: true,
|
||||
canCopyRoomLink: true,
|
||||
isNotificationAllMessage: true,
|
||||
isNotificationMentionOnly: true,
|
||||
isNotificationAllMessageLoud: true,
|
||||
isNotificationMute: true,
|
||||
copyRoomLink: jest.fn(),
|
||||
markAsUnread: jest.fn(),
|
||||
markAsRead: jest.fn(),
|
||||
leaveRoom: jest.fn(),
|
||||
toggleLowPriority: jest.fn(),
|
||||
toggleFavorite: jest.fn(),
|
||||
invite: jest.fn(),
|
||||
setRoomNotifState: jest.fn(),
|
||||
};
|
||||
|
||||
let matrixClient: MatrixClient;
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
mocked(useRoomListItemMenuViewModel).mockReturnValue(defaultValue);
|
||||
matrixClient = stubClient();
|
||||
room = mkRoom(matrixClient, "room1");
|
||||
});
|
||||
|
||||
function renderMenu() {
|
||||
return render(<RoomListItemMenuView room={room} />);
|
||||
}
|
||||
|
||||
it("should render the more options menu", () => {
|
||||
const { asFragment } = renderMenu();
|
||||
expect(screen.getByRole("button", { name: "More Options" })).toBeInTheDocument();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render the notification options menu", () => {
|
||||
const { asFragment } = renderMenu();
|
||||
expect(screen.getByRole("button", { name: "Notification options" })).toBeInTheDocument();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should not render the more options menu when showMoreOptionsMenu is false", () => {
|
||||
mocked(useRoomListItemMenuViewModel).mockReturnValue({ ...defaultValue, showMoreOptionsMenu: false });
|
||||
renderMenu();
|
||||
expect(screen.queryByRole("button", { name: "More Options" })).toBeNull();
|
||||
});
|
||||
|
||||
it("should not render the notification options menu when showNotificationMenu is false", () => {
|
||||
mocked(useRoomListItemMenuViewModel).mockReturnValue({ ...defaultValue, showNotificationMenu: false });
|
||||
renderMenu();
|
||||
expect(screen.queryByRole("button", { name: "Notification options" })).toBeNull();
|
||||
});
|
||||
|
||||
it("should display all the buttons and have the actions linked for the more options menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu();
|
||||
|
||||
const openMenu = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(openMenu);
|
||||
|
||||
await user.click(screen.getByRole("menuitem", { name: "Mark as read" }));
|
||||
expect(defaultValue.markAsRead).toHaveBeenCalled();
|
||||
|
||||
await user.click(openMenu);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Mark as unread" }));
|
||||
expect(defaultValue.markAsUnread).toHaveBeenCalled();
|
||||
|
||||
await user.click(openMenu);
|
||||
await user.click(screen.getByRole("menuitemcheckbox", { name: "Favourited" }));
|
||||
expect(defaultValue.toggleFavorite).toHaveBeenCalled();
|
||||
|
||||
await user.click(openMenu);
|
||||
await user.click(screen.getByRole("menuitemcheckbox", { name: "Low priority" }));
|
||||
expect(defaultValue.toggleLowPriority).toHaveBeenCalled();
|
||||
|
||||
await user.click(openMenu);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Invite" }));
|
||||
expect(defaultValue.invite).toHaveBeenCalled();
|
||||
|
||||
await user.click(openMenu);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Copy room link" }));
|
||||
expect(defaultValue.copyRoomLink).toHaveBeenCalled();
|
||||
|
||||
await user.click(openMenu);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Leave room" }));
|
||||
expect(defaultValue.leaveRoom).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display all the buttons and have the actions linked for the notification options menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu();
|
||||
|
||||
const openMenu = screen.getByRole("button", { name: "Notification options" });
|
||||
await user.click(openMenu);
|
||||
|
||||
await user.click(screen.getByRole("menuitem", { name: "Match default settings" }));
|
||||
expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.AllMessages);
|
||||
|
||||
await user.click(openMenu);
|
||||
await user.click(screen.getByRole("menuitem", { name: "All messages" }));
|
||||
expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.AllMessagesLoud);
|
||||
|
||||
await user.click(openMenu);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Mentions and keywords" }));
|
||||
expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.MentionsOnly);
|
||||
|
||||
await user.click(openMenu);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Mute room" }));
|
||||
expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.Mute);
|
||||
});
|
||||
});
|
||||
@ -1,162 +0,0 @@
|
||||
/*
|
||||
* 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 from "react";
|
||||
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { render, screen, waitFor } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { mocked } from "jest-mock";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
import { mkRoom, stubClient, withClientContextRenderOptions } from "../../../../../test-utils";
|
||||
import { RoomListItemView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListItemView";
|
||||
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
|
||||
import {
|
||||
type RoomListItemViewState,
|
||||
useRoomListItemViewModel,
|
||||
} from "../../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel";
|
||||
import { RoomNotificationState } from "../../../../../../src/stores/notifications/RoomNotificationState";
|
||||
|
||||
jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel", () => ({
|
||||
useRoomListItemViewModel: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("<RoomListItemView />", () => {
|
||||
let defaultValue: RoomListItemViewState;
|
||||
let matrixClient: MatrixClient;
|
||||
let room: Room;
|
||||
|
||||
const renderRoomListItem = (props: Partial<React.ComponentProps<typeof RoomListItemView>> = {}) => {
|
||||
const defaultProps = {
|
||||
room,
|
||||
isSelected: false,
|
||||
isFocused: false,
|
||||
onFocus: jest.fn(),
|
||||
roomIndex: 0,
|
||||
roomCount: 1,
|
||||
listIsScrolling: false,
|
||||
};
|
||||
|
||||
return render(<RoomListItemView {...defaultProps} {...props} />, withClientContextRenderOptions(matrixClient));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
matrixClient = stubClient();
|
||||
room = mkRoom(matrixClient, "room1");
|
||||
|
||||
DMRoomMap.makeShared(matrixClient);
|
||||
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined);
|
||||
|
||||
const notificationState = new RoomNotificationState(room, false);
|
||||
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
|
||||
jest.spyOn(notificationState, "isNotification", "get").mockReturnValue(true);
|
||||
jest.spyOn(notificationState, "count", "get").mockReturnValue(1);
|
||||
|
||||
defaultValue = {
|
||||
openRoom: jest.fn(),
|
||||
showContextMenu: false,
|
||||
showHoverMenu: false,
|
||||
notificationState,
|
||||
a11yLabel: "Open room room1",
|
||||
isBold: false,
|
||||
isVideoRoom: false,
|
||||
callConnectionState: null,
|
||||
callType: CallType.Video,
|
||||
hasParticipantInCall: false,
|
||||
name: room.name,
|
||||
showNotificationDecoration: false,
|
||||
messagePreview: undefined,
|
||||
};
|
||||
|
||||
mocked(useRoomListItemViewModel).mockReturnValue(defaultValue);
|
||||
});
|
||||
|
||||
test("should render a room item", () => {
|
||||
const onClick = jest.fn();
|
||||
const { asFragment } = renderRoomListItem({
|
||||
onClick,
|
||||
roomCount: 0,
|
||||
});
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should render a room item with a message preview", () => {
|
||||
defaultValue.messagePreview = "The message looks like this";
|
||||
|
||||
const onClick = jest.fn();
|
||||
const { asFragment } = renderRoomListItem({
|
||||
onClick,
|
||||
});
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should call openRoom when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRoomListItem();
|
||||
|
||||
await user.click(screen.getByRole("option", { name: `Open room ${room.name}` }));
|
||||
expect(defaultValue.openRoom).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should be selected if isSelected=true", async () => {
|
||||
const { asFragment } = renderRoomListItem({
|
||||
isSelected: true,
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("option", { name: `Open room ${room.name}` })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should display notification decoration", async () => {
|
||||
mocked(useRoomListItemViewModel).mockReturnValue({
|
||||
...defaultValue,
|
||||
showNotificationDecoration: true,
|
||||
});
|
||||
|
||||
const { asFragment } = renderRoomListItem();
|
||||
|
||||
expect(screen.getByTestId("notification-decoration")).toBeInTheDocument();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should not display notification decoration when hovered", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mocked(useRoomListItemViewModel).mockReturnValue({
|
||||
...defaultValue,
|
||||
showNotificationDecoration: true,
|
||||
});
|
||||
|
||||
renderRoomListItem();
|
||||
|
||||
const listItem = screen.getByRole("option", { name: `Open room ${room.name}` });
|
||||
await user.hover(listItem);
|
||||
|
||||
expect(screen.queryByRole("notification-decoration")).toBeNull();
|
||||
});
|
||||
|
||||
test("should render the context menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mocked(useRoomListItemViewModel).mockReturnValue({
|
||||
...defaultValue,
|
||||
showContextMenu: true,
|
||||
});
|
||||
|
||||
renderRoomListItem();
|
||||
|
||||
const button = screen.getByRole("option", { name: `Open room ${room.name}` });
|
||||
await user.pointer([{ target: button }, { keys: "[MouseRight]", target: button }]);
|
||||
await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument());
|
||||
// Menu should close
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -1,155 +0,0 @@
|
||||
/*
|
||||
* 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, { act } from "react";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
|
||||
import { RoomListPrimaryFilters } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters";
|
||||
import { FilterKey } from "../../../../../../src/stores/room-list-v3/skip-list/filters";
|
||||
|
||||
describe("<RoomListPrimaryFilters />", () => {
|
||||
let vm: RoomListViewState;
|
||||
const filterToggleMocks = [jest.fn(), jest.fn(), jest.fn()];
|
||||
|
||||
let resizeCallback: ResizeObserverCallback;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks between tests
|
||||
filterToggleMocks.forEach((mock) => mock.mockClear());
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = jest.fn().mockImplementation((callback) => {
|
||||
resizeCallback = callback;
|
||||
return {
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vm = {
|
||||
primaryFilters: [
|
||||
{ name: "People", active: true, toggle: filterToggleMocks[0], key: FilterKey.PeopleFilter },
|
||||
{ name: "Rooms", active: false, toggle: filterToggleMocks[1], key: FilterKey.RoomsFilter },
|
||||
{ name: "Unreads", active: false, toggle: filterToggleMocks[2], key: FilterKey.UnreadFilter },
|
||||
],
|
||||
} as unknown as RoomListViewState;
|
||||
});
|
||||
|
||||
function mockFiltersOffsetLeft() {
|
||||
// Use `getByText` instead of `getByRole` to bypass the aria-hidden
|
||||
jest.spyOn(screen.getByText("People"), "offsetLeft", "get").mockReturnValue(0);
|
||||
jest.spyOn(screen.getByText("Rooms"), "offsetLeft", "get").mockReturnValue(30);
|
||||
jest.spyOn(screen.getByText("Unreads"), "offsetLeft", "get").mockReturnValue(60);
|
||||
|
||||
// @ts-ignore
|
||||
act(() => resizeCallback([{ target: screen.getByRole("listbox", { name: "Room list filters" }) }]));
|
||||
}
|
||||
|
||||
it("should renders all filters correctly", () => {
|
||||
const { asFragment } = render(<RoomListPrimaryFilters vm={vm} />);
|
||||
mockFiltersOffsetLeft();
|
||||
|
||||
// Check that all filters are rendered
|
||||
expect(screen.getByRole("option", { name: "People" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Rooms" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Unreads" })).toBeInTheDocument();
|
||||
|
||||
// Check that the active filter is marked as selected
|
||||
expect(screen.getByRole("option", { name: "People" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("option", { name: "Rooms" })).toHaveAttribute("aria-selected", "false");
|
||||
expect(screen.getByRole("option", { name: "Unreads" })).toHaveAttribute("aria-selected", "false");
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should call toggle function when a filter is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RoomListPrimaryFilters vm={vm} />);
|
||||
mockFiltersOffsetLeft();
|
||||
|
||||
// Click on an inactive filter
|
||||
await user.click(screen.getByRole("option", { name: "People" }));
|
||||
|
||||
// Check that the toggle function was called
|
||||
expect(filterToggleMocks[0]).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
function makeUnreadWrapping() {
|
||||
// Use `getByText` instead of `getByRole` to bypass the aria-hidden
|
||||
jest.spyOn(screen.getByText("People"), "offsetLeft", "get").mockReturnValue(0);
|
||||
jest.spyOn(screen.getByText("Rooms"), "offsetLeft", "get").mockReturnValue(30);
|
||||
// Unreads is wrapping
|
||||
jest.spyOn(screen.getByText("Unreads"), "offsetLeft", "get").mockReturnValue(0);
|
||||
|
||||
// @ts-ignore
|
||||
act(() => resizeCallback([{ target: screen.getByRole("listbox", { name: "Room list filters" }) }]));
|
||||
}
|
||||
|
||||
it("should hide or display filters if they are wrapping", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RoomListPrimaryFilters vm={vm} />);
|
||||
mockFiltersOffsetLeft();
|
||||
|
||||
// No filter is wrapping, so chevron shouldn't be visible
|
||||
expect(screen.queryByRole("button", { name: "Expand filter list" })).toBeNull();
|
||||
expect(screen.queryByRole("option", { name: "Unreads" })).toBeVisible();
|
||||
|
||||
makeUnreadWrapping();
|
||||
|
||||
// The Unreads filter is wrapping, it should not be visible
|
||||
expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull();
|
||||
// Now filters are wrapping, so chevron should be visible
|
||||
await user.click(screen.getByRole("button", { name: "Expand filter list" }));
|
||||
// The list is expanded, so Unreads should be visible
|
||||
expect(screen.getByRole("option", { name: "Unreads" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("should move the active filter if the list is collapsed and the filter is wrapping", async () => {
|
||||
vm = {
|
||||
primaryFilters: [
|
||||
{ name: "People", active: false, toggle: filterToggleMocks[0], key: FilterKey.PeopleFilter },
|
||||
{ name: "Rooms", active: false, toggle: filterToggleMocks[1], key: FilterKey.RoomsFilter },
|
||||
{ name: "Unreads", active: true, toggle: filterToggleMocks[2], key: FilterKey.UnreadFilter },
|
||||
],
|
||||
} as unknown as RoomListViewState;
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<RoomListPrimaryFilters vm={vm} />);
|
||||
makeUnreadWrapping();
|
||||
|
||||
// Unread filter should be moved to the first position
|
||||
expect(screen.getByRole("listbox", { name: "Room list filters" }).children[0]).toBe(
|
||||
screen.getByRole("option", { name: "Unreads" }),
|
||||
);
|
||||
|
||||
// When the list is expanded, the Unreads filter should move to its original position
|
||||
await user.click(screen.getByRole("button", { name: "Expand filter list" }));
|
||||
expect(screen.getByRole("listbox", { name: "Room list filters" }).children[0]).not.toEqual(
|
||||
screen.getByRole("option", { name: "Unreads" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should hide the filter is the previous is on the same vertical position", async () => {
|
||||
render(<RoomListPrimaryFilters vm={vm} />);
|
||||
mockFiltersOffsetLeft();
|
||||
|
||||
jest.spyOn(screen.getByRole("option", { name: "People" }), "offsetLeft", "get").mockReturnValue(0);
|
||||
// Rooms is wrapping
|
||||
jest.spyOn(screen.getByRole("option", { name: "Rooms" }), "offsetLeft", "get").mockReturnValue(0);
|
||||
|
||||
// @ts-ignore
|
||||
act(() => resizeCallback([{ target: screen.getByRole("listbox", { name: "Room list filters" }) }]));
|
||||
|
||||
// The Unreads filter is wrapping, it should not be visible
|
||||
expect(screen.queryByRole("option", { name: "Rooms" })).toBeNull();
|
||||
// Now filters are wrapping, so chevron should be visible
|
||||
expect(screen.getByRole("button", { name: "Expand filter list" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@ -1,65 +0,0 @@
|
||||
/*
|
||||
* 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 { mocked } from "jest-mock";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
type RoomListViewState,
|
||||
useRoomListViewModel,
|
||||
} from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
|
||||
import { RoomListView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListView";
|
||||
import { mkRoom, stubClient } from "../../../../../test-utils";
|
||||
|
||||
jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListViewModel", () => ({
|
||||
useRoomListViewModel: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("<RoomListView />", () => {
|
||||
const defaultValue: RoomListViewState = {
|
||||
isLoadingRooms: false,
|
||||
roomsResult: { spaceId: "home", rooms: [] },
|
||||
primaryFilters: [],
|
||||
createRoom: jest.fn(),
|
||||
createChatRoom: jest.fn(),
|
||||
canCreateRoom: true,
|
||||
activeIndex: undefined,
|
||||
};
|
||||
const matrixClient = stubClient();
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should render the loading room list", () => {
|
||||
mocked(useRoomListViewModel).mockReturnValue({
|
||||
...defaultValue,
|
||||
isLoadingRooms: true,
|
||||
});
|
||||
|
||||
const roomList = render(<RoomListView />);
|
||||
expect(roomList.container.querySelector(".mx_RoomListSkeleton")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should render an empty room list", () => {
|
||||
mocked(useRoomListViewModel).mockReturnValue(defaultValue);
|
||||
|
||||
render(<RoomListView />);
|
||||
expect(screen.getByText("No chats yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a room list", () => {
|
||||
mocked(useRoomListViewModel).mockReturnValue({
|
||||
...defaultValue,
|
||||
roomsResult: { spaceId: "home", rooms: [mkRoom(matrixClient, "testing room")] },
|
||||
});
|
||||
|
||||
render(<RoomListView />);
|
||||
expect(screen.getByRole("listbox", { name: "Room list" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -1,279 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`<EmptyRoomList /> should display empty state for filter favourite 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
|
||||
data-testid="empty-room-list"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_title"
|
||||
>
|
||||
You don't have favourite chats yet
|
||||
</span>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_description"
|
||||
>
|
||||
You can add a chat to your favourites in the chat settings
|
||||
</span>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<EmptyRoomList /> should display empty state for filter people 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
|
||||
data-testid="empty-room-list"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_title"
|
||||
>
|
||||
You don’t have direct chats with anyone yet
|
||||
</span>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_description"
|
||||
>
|
||||
You can deselect filters in order to see your other chats
|
||||
</span>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<EmptyRoomList /> should display empty state for filter rooms 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
|
||||
data-testid="empty-room-list"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_title"
|
||||
>
|
||||
You’re not in any room yet
|
||||
</span>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_description"
|
||||
>
|
||||
You can deselect filters in order to see your other chats
|
||||
</span>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<EmptyRoomList /> should display the empty state for the invite filter 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
|
||||
data-testid="empty-room-list"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_title"
|
||||
>
|
||||
You don't have any unread invites
|
||||
</span>
|
||||
<button
|
||||
class="_button_13vu4_8"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
See all activity
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<EmptyRoomList /> should display the empty state for the low priority filter 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
|
||||
data-testid="empty-room-list"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_title"
|
||||
>
|
||||
You don't have any low priority rooms
|
||||
</span>
|
||||
<button
|
||||
class="_button_13vu4_8"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
See all activity
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<EmptyRoomList /> should display the empty state for the mention filter 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
|
||||
data-testid="empty-room-list"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_title"
|
||||
>
|
||||
You don't have any unread mentions
|
||||
</span>
|
||||
<button
|
||||
class="_button_13vu4_8"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
See all activity
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<EmptyRoomList /> should display the empty state for the unread filter 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
|
||||
data-testid="empty-room-list"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_title"
|
||||
>
|
||||
Congrats! You don’t have any unread messages
|
||||
</span>
|
||||
<button
|
||||
class="_button_13vu4_8"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Show all chats
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<EmptyRoomList /> should not render the new room button if the user doesn't have the rights to create a room 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
|
||||
data-testid="empty-room-list"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_title"
|
||||
>
|
||||
No chats yet
|
||||
</span>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_description"
|
||||
>
|
||||
Get started by messaging someone
|
||||
</span>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_EmptyRoomList_DefaultPlaceholder"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60"
|
||||
data-kind="secondary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m1.5 21.25 1.45-4.95a10.2 10.2 0 0 1-.712-2.1A10.2 10.2 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 22q-1.125 0-2.2-.238a10.2 10.2 0 0 1-2.1-.712L2.75 22.5a.94.94 0 0 1-1-.25.94.94 0 0 1-.25-1m2.45-1.2 3.2-.95a1 1 0 0 1 .275-.062q.15-.013.275-.013.225 0 .438.038.212.036.412.137a7.4 7.4 0 0 0 1.675.6Q11.1 20 12 20q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12q0 .9.2 1.775t.6 1.675q.176.325.188.688t-.088.712z"
|
||||
/>
|
||||
</svg>
|
||||
Start chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<EmptyRoomList /> should render the default placeholder when there is no filter 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
|
||||
data-testid="empty-room-list"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_title"
|
||||
>
|
||||
No chats yet
|
||||
</span>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_description"
|
||||
>
|
||||
Get started by messaging someone or by creating a room
|
||||
</span>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_EmptyRoomList_DefaultPlaceholder"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60"
|
||||
data-kind="secondary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m1.5 21.25 1.45-4.95a10.2 10.2 0 0 1-.712-2.1A10.2 10.2 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 22q-1.125 0-2.2-.238a10.2 10.2 0 0 1-2.1-.712L2.75 22.5a.94.94 0 0 1-1-.25.94.94 0 0 1-.25-1m2.45-1.2 3.2-.95a1 1 0 0 1 .275-.062q.15-.013.275-.013.225 0 .438.038.212.036.412.137a7.4 7.4 0 0 0 1.675.6Q11.1 20 12 20q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12q0 .9.2 1.775t.6 1.675q.176.325.188.688t-.088.712z"
|
||||
/>
|
||||
</svg>
|
||||
Start chat
|
||||
</button>
|
||||
<button
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60"
|
||||
data-kind="secondary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.566 17-.944 4.094q-.086.406-.372.656t-.687.25q-.543 0-.887-.469a1.18 1.18 0 0 1-.2-1.031l.801-3.5H3.158q-.572 0-.916-.484a1.27 1.27 0 0 1-.2-1.078 1.12 1.12 0 0 1 1.116-.938H6.85l1.145-5h-3.12q-.57 0-.915-.484a1.27 1.27 0 0 1-.2-1.078A1.12 1.12 0 0 1 4.875 7h3.691l.945-4.094q.085-.406.372-.656.286-.25.686-.25.544 0 .887.469.345.468.2 1.031l-.8 3.5h4.578l.944-4.094q.085-.406.372-.656.286-.25.687-.25.543 0 .887.469t.2 1.031L17.723 7h3.119q.573 0 .916.484.343.485.2 1.079a1.12 1.12 0 0 1-1.116.937H17.15l-1.145 5h3.12q.57 0 .915.484.343.485.2 1.079a1.12 1.12 0 0 1-1.116.937h-3.691l-.944 4.094q-.087.406-.373.656t-.686.25q-.544 0-.887-.469a1.18 1.18 0 0 1-.2-1.031l.8-3.5zm.573-2.5h4.578l1.144-5h-4.578z"
|
||||
/>
|
||||
</svg>
|
||||
New room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,155 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`<RoomListItemMenuView /> should render the more options menu 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_RoomListItemMenuView"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="More Options"
|
||||
aria-labelledby="_r_2_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_0_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Notification options"
|
||||
aria-labelledby="_r_9_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_7_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m4.917 2.083 17 17a1 1 0 0 1-1.414 1.414L19.006 19H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-2.034 1.096-3.91L3.504 3.498a1 1 0 0 1 1.414-1.414M19 13.35 9.136 3.484C9.93 3.181 10.874 3 12 3c7 0 7 7 7 7z"
|
||||
/>
|
||||
<path
|
||||
d="M10 20h4a2 2 0 0 1-4 0"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<RoomListItemMenuView /> should render the notification options menu 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_RoomListItemMenuView"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="More Options"
|
||||
aria-labelledby="_r_i_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_g_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Notification options"
|
||||
aria-labelledby="_r_p_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_n_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m4.917 2.083 17 17a1 1 0 0 1-1.414 1.414L19.006 19H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-2.034 1.096-3.91L3.504 3.498a1 1 0 0 1 1.414-1.414M19 13.35 9.136 3.484C9.93 3.181 10.874 3 12 3c7 0 7 7 7 7z"
|
||||
/>
|
||||
<path
|
||||
d="M10 20h4a2 2 0 0 1-4 0"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@ -1,234 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`<RoomListItemView /> should be selected if isSelected=true 1`] = `
|
||||
<DocumentFragment>
|
||||
<button
|
||||
aria-label="Open room room1"
|
||||
aria-posinset="1"
|
||||
aria-selected="true"
|
||||
aria-setsize="1"
|
||||
class="_flex_4dswl_9 mx_RoomListItemView mx_RoomListItemView_selected"
|
||||
role="option"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-label="Avatar"
|
||||
class="_avatar_zysgz_8 mx_BaseAvatar"
|
||||
data-color="3"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_zysgz_43"
|
||||
data-type="round"
|
||||
height="32px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="http://this.is.a.url/avatar.url/room.png"
|
||||
width="32px"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_RoomListItemView_content"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomListItemView_text"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomListItemView_roomName"
|
||||
title="room1"
|
||||
>
|
||||
room1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<RoomListItemView /> should display notification decoration 1`] = `
|
||||
<DocumentFragment>
|
||||
<button
|
||||
aria-label="Open room room1"
|
||||
aria-posinset="1"
|
||||
aria-selected="false"
|
||||
aria-setsize="1"
|
||||
class="_flex_4dswl_9 mx_RoomListItemView"
|
||||
role="option"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-label="Avatar"
|
||||
class="_avatar_zysgz_8 mx_BaseAvatar"
|
||||
data-color="3"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_zysgz_43"
|
||||
data-type="round"
|
||||
height="32px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="http://this.is.a.url/avatar.url/room.png"
|
||||
width="32px"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_RoomListItemView_content"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomListItemView_text"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomListItemView_roomName"
|
||||
title="room1"
|
||||
>
|
||||
room1
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="_flex_4dswl_9 mx_RoomListItemView_notificationDecoration"
|
||||
data-testid="notification-decoration"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
fill="var(--cpd-color-icon-accent-primary)"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="_unread-counter_1147r_8"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<RoomListItemView /> should render a room item 1`] = `
|
||||
<DocumentFragment>
|
||||
<button
|
||||
aria-label="Open room room1"
|
||||
aria-posinset="1"
|
||||
aria-selected="false"
|
||||
aria-setsize="0"
|
||||
class="_flex_4dswl_9 mx_RoomListItemView"
|
||||
role="option"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-label="Avatar"
|
||||
class="_avatar_zysgz_8 mx_BaseAvatar"
|
||||
data-color="3"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_zysgz_43"
|
||||
data-type="round"
|
||||
height="32px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="http://this.is.a.url/avatar.url/room.png"
|
||||
width="32px"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_RoomListItemView_content"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomListItemView_text"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomListItemView_roomName"
|
||||
title="room1"
|
||||
>
|
||||
room1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<RoomListItemView /> should render a room item with a message preview 1`] = `
|
||||
<DocumentFragment>
|
||||
<button
|
||||
aria-label="Open room room1"
|
||||
aria-posinset="1"
|
||||
aria-selected="false"
|
||||
aria-setsize="1"
|
||||
class="_flex_4dswl_9 mx_RoomListItemView"
|
||||
role="option"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-label="Avatar"
|
||||
class="_avatar_zysgz_8 mx_BaseAvatar"
|
||||
data-color="3"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_zysgz_43"
|
||||
data-type="round"
|
||||
height="32px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="http://this.is.a.url/avatar.url/room.png"
|
||||
width="32px"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_RoomListItemView_content"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomListItemView_text"
|
||||
>
|
||||
<div
|
||||
class="mx_RoomListItemView_roomName"
|
||||
title="room1"
|
||||
>
|
||||
room1
|
||||
</div>
|
||||
<div
|
||||
class="mx_RoomListItemView_messagePreview"
|
||||
title="The message looks like this"
|
||||
>
|
||||
The message looks like this
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@ -1,47 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`<RoomListPrimaryFilters /> should renders all filters correctly 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_flex_4dswl_9 mx_RoomListPrimaryFilters"
|
||||
data-testid="primary-filters"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
aria-label="Room list filters"
|
||||
class="_flex_4dswl_9 mx_RoomListPrimaryFilters_list"
|
||||
id="_r_0_"
|
||||
role="listbox"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
|
||||
>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="true"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
People
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Rooms
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Unreads
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
Loading…
x
Reference in New Issue
Block a user