diff --git a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png index f0e14ee55cf..8e99fefd875 100644 Binary files a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png and b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index e0f9a5788de..88e4617b132 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -283,6 +283,7 @@ @import "./views/rooms/_EventTile.pcss"; @import "./views/rooms/_HistoryTile.pcss"; @import "./views/rooms/_IRCLayout.pcss"; +@import "./views/rooms/_InvitedIconView.pcss"; @import "./views/rooms/_JumpToBottomButton.pcss"; @import "./views/rooms/_LinkPreviewGroup.pcss"; @import "./views/rooms/_LinkPreviewWidget.pcss"; diff --git a/res/css/views/rooms/_InvitedIconView.pcss b/res/css/views/rooms/_InvitedIconView.pcss new file mode 100644 index 00000000000..504f4498af9 --- /dev/null +++ b/res/css/views/rooms/_InvitedIconView.pcss @@ -0,0 +1,10 @@ +/* +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_InvitedIconView { + color: var(--cpd-color-icon-tertiary); +} diff --git a/res/css/views/rooms/_MemberListView.pcss b/res/css/views/rooms/_MemberListView.pcss index e13b17b226e..0aee9cb1596 100644 --- a/res/css/views/rooms/_MemberListView.pcss +++ b/res/css/views/rooms/_MemberListView.pcss @@ -14,4 +14,10 @@ Please see LICENSE files in the repository root for full details. .mx_MemberListView_container { height: 100%; } + + .mx_MemberListView_separator { + margin: 0; + border: none; + border-top: 2px solid var(--cpd-color-bg-subtle-primary); + } } diff --git a/res/css/views/rooms/_MemberTileView.pcss b/res/css/views/rooms/_MemberTileView.pcss index 702edd8f9d2..a8acb8975a6 100644 --- a/res/css/views/rooms/_MemberTileView.pcss +++ b/res/css/views/rooms/_MemberTileView.pcss @@ -31,9 +31,10 @@ Please see LICENSE files in the repository root for full details. min-width: 0; } - .mx_MemberTileView_user_label { + .mx_MemberTileView_userLabel { font: var(--cpd-font-body-sm-regular); font-size: 13px; + color: var(--cpd-color-text-secondary); } .mx_MemberTileView_avatar { @@ -41,18 +42,4 @@ Please see LICENSE files in the repository root for full details. height: 32px; width: 32px; } - - .mx_E2EIconView { - display: flex; - justify-content: center; - align-items: center; - } - - .mx_E2EIconView_warning { - color: var(--cpd-color-icon-critical-primary); - } - - .mx_E2EIconView_verified { - color: var(--cpd-color-icon-success-primary); - } } diff --git a/src/components/viewmodels/memberlist/MemberListViewModel.tsx b/src/components/viewmodels/memberlist/MemberListViewModel.tsx index 88eacb1b931..6955746c358 100644 --- a/src/components/viewmodels/memberlist/MemberListViewModel.tsx +++ b/src/components/viewmodels/memberlist/MemberListViewModel.tsx @@ -99,8 +99,12 @@ export function sdkRoomMemberToRoomMember(member: SdkRoomMember): Member { }; } +export const SEPARATOR = "SEPARATOR"; +export type MemberWithSeparator = Member | typeof SEPARATOR; + export interface MemberListViewState { - members: Member[]; + members: MemberWithSeparator[]; + memberCount: number; search: (searchQuery: string) => void; isPresenceEnabled: boolean; shouldShowInvite: boolean; @@ -118,10 +122,16 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { } const sdkContext = useContext(SDKContext); - const [memberMap, setMemberMap] = useState>(new Map()); + const [memberMap, setMemberMap] = useState>(new Map()); const [isLoading, setIsLoading] = useState(true); // This is the last known total number of members in this room. const [totalMemberCount, setTotalMemberCount] = useState(0); + /** + * This is the current number of members in the list. + * This number will be less than the total number of members + * in the room when the search functionality is used. + */ + const [memberCount, setMemberCount] = useState(0); const loadMembers = useMemo( () => @@ -131,24 +141,34 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { roomId, searchQuery, ); - const newMemberMap = new Map(); - // First add the invited room members + const threePidInvited = getPending3PidInvites(room, searchQuery); + + const newMemberMap = new Map(); + + // First add the joined room members + for (const member of joinedSdk) { + const roomMember = sdkRoomMemberToRoomMember(member); + newMemberMap.set(member.userId, roomMember); + } + + // Then a separator if needed + if (joinedSdk.length > 0 && (invitedSdk.length > 0 || threePidInvited.length > 0)) + newMemberMap.set(SEPARATOR, SEPARATOR); + + // Then add the invited room members for (const member of invitedSdk) { const roomMember = sdkRoomMemberToRoomMember(member); newMemberMap.set(member.userId, roomMember); } - // Then add the third party invites - const threePidInvited = getPending3PidInvites(room, searchQuery); + + // Finally add the third party invites for (const invited of threePidInvited) { const key = invited.threePidInvite!.event.getContent().display_name; newMemberMap.set(key, invited); } - // Finally add the joined room members - for (const member of joinedSdk) { - const roomMember = sdkRoomMemberToRoomMember(member); - newMemberMap.set(member.userId, roomMember); - } + setMemberMap(newMemberMap); + setMemberCount(joinedSdk.length + invitedSdk.length + threePidInvited.length); if (!searchQuery) { /** * Since searching for members only gives you the relevant @@ -241,6 +261,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { return { members: Array.from(memberMap.values()), + memberCount, search: loadMembers, shouldShowInvite, isPresenceEnabled, diff --git a/src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx b/src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx index daeb8d899f4..26c493be2bc 100644 --- a/src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx +++ b/src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import dis from "../../../../dispatcher/dispatcher"; import { Action } from "../../../../dispatcher/actions"; import { ThreePIDInvite } from "../../../../models/rooms/ThreePIDInvite"; +import { _t } from "../../../../languageHandler"; interface ThreePidTileViewModelProps { threePidInvite: ThreePIDInvite; @@ -16,6 +17,7 @@ interface ThreePidTileViewModelProps { export interface ThreePidTileViewState { name: string; onClick: () => void; + userLabel?: string; } export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): ThreePidTileViewState { @@ -28,8 +30,11 @@ export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): Thr }); }; + const userLabel = `(${_t("member_list|invited_label")})`; + return { name, onClick, + userLabel, }; } diff --git a/src/components/views/rooms/MemberList/MemberListHeaderView.tsx b/src/components/views/rooms/MemberList/MemberListHeaderView.tsx index 7b7531b1e9b..4dd5fd73c47 100644 --- a/src/components/views/rooms/MemberList/MemberListHeaderView.tsx +++ b/src/components/views/rooms/MemberList/MemberListHeaderView.tsx @@ -88,12 +88,10 @@ function getHeaderLabelJSX(vm: MemberListViewState): React.ReactNode { ); } - - const filteredMemberCount = vm.members.length; - if (filteredMemberCount === 0) { + if (vm.memberCount === 0) { return _t("member_list|no_matches"); } - return _t("member_list|count", { count: filteredMemberCount }); + return _t("member_list|count", { count: vm.memberCount }); } export const MemberListHeaderView: React.FC = (props: Props) => { diff --git a/src/components/views/rooms/MemberList/MemberListView.tsx b/src/components/views/rooms/MemberList/MemberListView.tsx index 8dd0a0995b5..f45b15eff1b 100644 --- a/src/components/views/rooms/MemberList/MemberListView.tsx +++ b/src/components/views/rooms/MemberList/MemberListView.tsx @@ -11,7 +11,11 @@ import { List, ListRowProps } from "react-virtualized/dist/commonjs/List"; import { AutoSizer } from "react-virtualized"; import { Flex } from "../../../utils/Flex"; -import { useMemberListViewModel } from "../../../viewmodels/memberlist/MemberListViewModel"; +import { + MemberWithSeparator, + SEPARATOR, + useMemberListViewModel, +} from "../../../viewmodels/memberlist/MemberListViewModel"; import { RoomMemberTileView } from "./tiles/RoomMemberTileView"; import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView"; import { MemberListHeaderView } from "./MemberListHeaderView"; @@ -26,10 +30,41 @@ interface IProps { const MemberListView: React.FC = (props: IProps) => { const vm = useMemberListViewModel(props.roomId); - const memberCount = vm.members.length; + const totalRows = vm.members.length; + + const getRowComponent = (item: MemberWithSeparator): React.JSX.Element => { + if (item === SEPARATOR) { + return
; + } else if (item.member) { + return ; + } else { + return ; + } + }; + + const getRowHeight = ({ index }: { index: number }): number => { + if (vm.members[index] === SEPARATOR) { + /** + * This is a separator of 2px height rendered between + * joined and invited members. + */ + return 2; + } else if (totalRows && index === totalRows) { + /** + * The empty spacer div rendered at the bottom should + * have a height of 32px. + */ + return 32; + } else { + /** + * The actual member tiles have a height of 56px. + */ + return 56; + } + }; const rowRenderer = ({ key, index, style }: ListRowProps): React.JSX.Element => { - if (index === memberCount) { + if (index === totalRows) { // We've rendered all the members, // now we render an empty div to add some space to the end of the list. return
; @@ -37,11 +72,7 @@ const MemberListView: React.FC = (props: IProps) => { const item = vm.members[index]; return (
- {item.member ? ( - - ) : ( - - )} + {getRowComponent(item)}
); }; @@ -63,11 +94,9 @@ const MemberListView: React.FC = (props: IProps) => { {({ height, width }) => ( (index === memberCount ? 32 : 56)} + rowHeight={getRowHeight} // The +1 refers to the additional empty div that we render at the end of the list. - rowCount={memberCount + 1} + rowCount={totalRows + 1} // Subtract the height of MemberlistHeaderView so that the parent div does not overflow. height={height - 113} width={width} diff --git a/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx b/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx index c0109e619bc..7aeb32a343b 100644 --- a/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx +++ b/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx @@ -14,7 +14,8 @@ import { E2EIconView } from "./common/E2EIconView"; import AvatarPresenceIconView from "./common/PresenceIconView"; import BaseAvatar from "../../../avatars/BaseAvatar"; import { _t } from "../../../../../languageHandler"; -import { MemberTileLayout } from "./common/MemberTileLayout"; +import { MemberTileView } from "./common/MemberTileView"; +import { InvitedIconView } from "./common/InvitedIconView"; interface IProps { member: RoomMember; @@ -43,25 +44,23 @@ export function RoomMemberTileView(props: IProps): JSX.Element { presenceJSX = ; } - let userLabelJSX; - if (vm.userLabel) { - userLabelJSX =
{vm.userLabel}
; - } - - let e2eIcon; + let iconJsx; if (vm.e2eStatus) { - e2eIcon = ; + iconJsx = ; + } + if (member.isInvite) { + iconJsx = ; } return ( - ); } diff --git a/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx b/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx index f6890cc034f..5a177eb5bf0 100644 --- a/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx +++ b/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx @@ -10,7 +10,8 @@ import React from "react"; import { useThreePidTileViewModel } from "../../../../viewmodels/memberlist/tiles/ThreePidTileViewModel"; import { ThreePIDInvite } from "../../../../../models/rooms/ThreePIDInvite"; import BaseAvatar from "../../../avatars/BaseAvatar"; -import { MemberTileLayout } from "./common/MemberTileLayout"; +import { MemberTileView } from "./common/MemberTileView"; +import { InvitedIconView } from "./common/InvitedIconView"; interface Props { threePidInvite: ThreePIDInvite; @@ -19,5 +20,15 @@ interface Props { export function ThreePidInviteTileView(props: Props): JSX.Element { const vm = useThreePidTileViewModel(props); const av =
diff --git a/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap b/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap index 6feb72ea62d..a4969824e09 100644 --- a/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap @@ -224,7 +224,29 @@ exports[`MemberTileView ThreePidInviteTileView renders ThreePidInvite correctly
+ > +
+ (Invited) +
+
+ + + +
+