Skip to content

Commit

Permalink
Implement changes to memberlist from feedback (#29029)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Update year in license

* Fix lint error

---------

Co-authored-by: Michael Telatynski <[email protected]>
  • Loading branch information
MidhunSureshR and t3chguy authored Jan 21, 2025
1 parent cf895b4 commit 1644169
Show file tree
Hide file tree
Showing 14 changed files with 180 additions and 61 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions res/css/_components.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
10 changes: 10 additions & 0 deletions res/css/views/rooms/_InvitedIconView.pcss
Original file line number Diff line number Diff line change
@@ -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);
}
6 changes: 6 additions & 0 deletions res/css/views/rooms/_MemberListView.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
17 changes: 2 additions & 15 deletions res/css/views/rooms/_MemberTileView.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -31,28 +31,15 @@ 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 {
position: relative;
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);
}
}
43 changes: 32 additions & 11 deletions src/components/viewmodels/memberlist/MemberListViewModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
() =>
Expand All @@ -131,24 +141,34 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
roomId,
searchQuery,
);
const newMemberMap = new Map<string, Member>();
// First add the invited room members
const threePidInvited = getPending3PidInvites(room, searchQuery);

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);
}
// 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
Expand Down Expand Up @@ -241,6 +261,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {

return {
members: Array.from(memberMap.values()),
memberCount,
search: loadMembers,
shouldShowInvite,
isPresenceEnabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +17,7 @@ interface ThreePidTileViewModelProps {
export interface ThreePidTileViewState {
name: string;
onClick: () => void;
userLabel?: string;
}

export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): ThreePidTileViewState {
Expand All @@ -28,8 +30,11 @@ export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): Thr
});
};

const userLabel = `(${_t("member_list|invited_label")})`;

return {
name,
onClick,
userLabel,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
53 changes: 41 additions & 12 deletions src/components/views/rooms/MemberList/MemberListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,22 +30,49 @@ 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} />;
}
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>
);
};
Expand All @@ -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}
Expand Down
21 changes: 10 additions & 11 deletions src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}
/>
);
}
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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>
Expand Down
Loading

0 comments on commit 1644169

Please sign in to comment.