Implement changes to memberlist from feedback (#29029)

* Add a separator between joined and invited members

* Fix user label in tile having wrong color

* Changes to member tiles

- ThreePidInviteTile now contains an user label showing "(Invited)" and
  an email icon.
- RoomMemberTile now includes an icon similar to above.
- Refactors a bunch of code to make this change sensible.

* Remove redundant css code

* Fix tests

* Update src/components/viewmodels/memberlist/MemberListViewModel.tsx

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

* Update year in license

* Fix lint error

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
R Midhun Suresh
2025-01-21 16:31:32 +05:30
committed by GitHub
parent cf895b4296
commit 1644169ff3
14 changed files with 183 additions and 64 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -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";

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<Map<string, Member>>(new Map());
const [memberMap, setMemberMap] = useState<Map<string, MemberWithSeparator>>(new Map());
const [isLoading, setIsLoading] = useState<boolean>(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<string, Member>();
// First 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);
for (const invited of threePidInvited) {
const key = invited.threePidInvite!.event.getContent().display_name;
newMemberMap.set(key, invited);
}
// Finally add the joined room members
const newMemberMap = new Map<string, MemberWithSeparator>();
// 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);
}
// Finally add the third party invites
for (const invited of threePidInvited) {
const key = invited.threePidInvite!.event.getContent().display_name;
newMemberMap.set(key, invited);
}
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,

View File

@@ -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,
};
}

View File

@@ -88,12 +88,10 @@ function getHeaderLabelJSX(vm: MemberListViewState): React.ReactNode {
</Flex>
);
}
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: Props) => {

View File

@@ -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<IProps> = (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 <hr className="mx_MemberListView_separator" />;
} else if (item.member) {
return <RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} />;
} else {
return <ThreePidInviteTileView threePidInvite={item.threePidInvite} />;
}
};
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 <div key={key} style={style} />;
@@ -37,11 +72,7 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
const item = vm.members[index];
return (
<div key={key} style={style}>
{item.member ? (
<RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} />
) : (
<ThreePidInviteTileView threePidInvite={item.threePidInvite} />
)}
{getRowComponent(item)}
</div>
);
};
@@ -63,11 +94,9 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
{({ height, width }) => (
<List
rowRenderer={rowRenderer}
// All the member tiles will have a height of 56px.
// The additional empty div at the end of the list should have a height of 32px.
rowHeight={({ index }) => (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}

View File

@@ -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 = <AvatarPresenceIconView presenceState={presenceState} />;
}
let userLabelJSX;
if (vm.userLabel) {
userLabelJSX = <div className="mx_MemberTileView_user_label">{vm.userLabel}</div>;
}
let e2eIcon;
let iconJsx;
if (vm.e2eStatus) {
e2eIcon = <E2EIconView status={vm.e2eStatus} />;
iconJsx = <E2EIconView status={vm.e2eStatus} />;
}
if (member.isInvite) {
iconJsx = <InvitedIconView isThreePid={false} />;
}
return (
<MemberTileLayout
<MemberTileView
title={vm.title}
onClick={vm.onClick}
avatarJsx={av}
presenceJsx={presenceJSX}
nameJsx={nameJSX}
userLabelJsx={userLabelJSX}
e2eIconJsx={e2eIcon}
userLabel={vm.userLabel}
iconJsx={iconJsx}
/>
);
}

View File

@@ -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 = <BaseAvatar name={vm.name} size="32px" aria-hidden="true" />;
return <MemberTileLayout nameJsx={vm.name} avatarJsx={av} onClick={vm.onClick} />;
const iconJsx = <InvitedIconView isThreePid={true} />;
return (
<MemberTileView
nameJsx={vm.name}
avatarJsx={av}
onClick={vm.onClick}
userLabel={vm.userLabel}
iconJsx={iconJsx}
/>
);
}

View File

@@ -0,0 +1,25 @@
/*
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 EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";
import { Flex } from "../../../../../utils/Flex";
interface Props {
isThreePid: boolean;
}
export function InvitedIconView({ isThreePid }: Props): JSX.Element {
const Icon = isThreePid ? EmailIcon : UserAddIcon;
return (
<Flex align="center" className="mx_InvitedIconView">
<Icon height="16px" width="16px" />
</Flex>
);
}

View File

@@ -15,11 +15,16 @@ interface Props {
onClick: () => void;
title?: string;
presenceJsx?: JSX.Element;
userLabelJsx?: JSX.Element;
e2eIconJsx?: JSX.Element;
userLabel?: React.ReactNode;
iconJsx?: JSX.Element;
}
export function MemberTileLayout(props: Props): JSX.Element {
export function MemberTileView(props: Props): JSX.Element {
let userLabelJsx: React.ReactNode;
if (props.userLabel) {
userLabelJsx = <div className="mx_MemberTileView_userLabel">{props.userLabel}</div>;
}
return (
// The wrapping div is required to make the magic mouse listener work, for some reason.
<div>
@@ -31,8 +36,8 @@ export function MemberTileLayout(props: Props): JSX.Element {
<div className="mx_MemberTileView_name">{props.nameJsx}</div>
</div>
<div className="mx_MemberTileView_right">
{props.userLabelJsx}
{props.e2eIconJsx}
{userLabelJsx}
{props.iconJsx}
</div>
</AccessibleButton>
</div>

View File

@@ -224,7 +224,29 @@ exports[`MemberTileView ThreePidInviteTileView renders ThreePidInvite correctly
</div>
<div
class="mx_MemberTileView_right"
/>
>
<div
class="mx_MemberTileView_userLabel"
>
(Invited)
</div>
<div
class="mx_Flex mx_InvitedIconView"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
>
<svg
fill="currentColor"
height="16px"
viewBox="0 0 24 24"
width="16px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm0 5.111a1 1 0 0 0 .514.874l7 3.89a1 1 0 0 0 .972 0l7-3.89a1 1 0 1 0-.972-1.748L12 11.856 5.486 8.237A1 1 0 0 0 4 9.111Z"
/>
</svg>
</div>
</div>
</div>
</div>
</div>