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:
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -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";
|
||||
|
||||
10
res/css/views/rooms/_InvitedIconView.pcss
Normal file
10
res/css/views/rooms/_InvitedIconView.pcss
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user