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:
co-authored by
Michael Telatynski
parent
cf895b4296
commit
1644169ff3
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+10
-5
@@ -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>
|
||||
Reference in New Issue
Block a user