Implement new memberlist design with MVVM architecture (#28874)

* Add new e2e icon for the member tile

* Add new presence icon for member tile

* Implement new member tile

* Implement memberlist view model

* Implement new memberlist header view

* Support the new memberlist in Diasambiguated profile

1. Use MemberInfo instead of RoomMember
2. CSS changes to reflect the new design

* Implement new memberlist view

* Add and use a new overflow component

We used the EntityTile component as a pretend overflow tile in some
places. This new lighter component is added so  that we can remove the
complex EntityTile component.

* Remove old code

* Add/remove css files from _components.pcss

* Increase minimum width as per design

* Actually use the new memberlist view

* Fix broken jest tests

* Add jest tests

* Playwright: Make it possible to disable presence

* Add playwright tests

* Fix lint error

* Undo translation changes that must be done via localazy

* Update license header

* Use waitFor instead of setTimeout

* Remove comment

* Switch over from template to container hs

* Revert unintended change

* Move config to top level
This commit is contained in:
R Midhun Suresh
2025-01-08 22:45:06 +05:30
committed by GitHub
parent f1899b9eb1
commit ebef0d353e
57 changed files with 2456 additions and 1788 deletions

View File

@@ -99,7 +99,7 @@ export default class MainSplit extends React.Component<IProps> {
<Resizable
key={this.props.sizeKey}
defaultSize={this.loadSidePanelSize()}
minWidth={264}
minWidth={320}
maxWidth="50%"
enable={{
top: false,

View File

@@ -17,7 +17,6 @@ import RightPanelStore from "../../stores/right-panel/RightPanelStore";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
import WidgetCard from "../views/right_panel/WidgetCard";
import MemberList from "../views/rooms/MemberList";
import UserInfo from "../views/right_panel/UserInfo";
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
import FilePanel from "./FilePanel";
@@ -34,6 +33,7 @@ import { IRightPanelCard, IRightPanelCardState } from "../../stores/right-panel/
import { Action } from "../../dispatcher/actions";
import { XOR } from "../../@types/common";
import ExtensionsCard from "../views/right_panel/ExtensionsCard";
import MemberListView from "../views/rooms/MemberList/MemberListView";
interface BaseProps {
overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView)
@@ -57,7 +57,6 @@ type Props = XOR<RoomlessProps, RoomProps>;
interface IState {
phase?: RightPanelPhases;
searchQuery: string;
cardState?: IRightPanelCardState;
}
@@ -67,10 +66,6 @@ export default class RightPanel extends React.Component<Props, IState> {
public constructor(props: Props, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
searchQuery: "",
};
}
private readonly delayedUpdate = throttle(
@@ -147,10 +142,6 @@ export default class RightPanel extends React.Component<Props, IState> {
}
};
private onSearchQueryChanged = (searchQuery: string): void => {
this.setState({ searchQuery });
};
public render(): React.ReactNode {
let card = <div />;
const roomId = this.props.room?.roomId;
@@ -159,15 +150,7 @@ export default class RightPanel extends React.Component<Props, IState> {
switch (phase) {
case RightPanelPhases.MemberList:
if (!!roomId) {
card = (
<MemberList
roomId={roomId}
key={roomId}
onClose={this.onClose}
searchQuery={this.state.searchQuery}
onSearchQueryChanged={this.onSearchQueryChanged}
/>
);
card = <MemberListView roomId={roomId} onClose={this.onClose} />;
}
break;

View File

@@ -0,0 +1,263 @@
/*
Copyright 2024 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 {
ClientEvent,
EventType,
MatrixEvent,
Room,
RoomEvent,
RoomMemberEvent,
RoomState,
RoomStateEvent,
RoomMember as SdkRoomMember,
User,
UserEvent,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { throttle } from "lodash";
import { RoomMember } from "../../../models/rooms/RoomMember";
import { mediaFromMxc } from "../../../customisations/Media";
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { PresenceState } from "../../../models/rooms/PresenceState";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { SDKContext } from "../../../contexts/SDKContext";
import PosthogTrackers from "../../../PosthogTrackers";
import { ButtonEvent } from "../../views/elements/AccessibleButton";
import { inviteToRoom } from "../../../utils/room/inviteToRoom";
import { canInviteTo } from "../../../utils/room/canInviteTo";
import { isValid3pidInvite } from "../../../RoomInvite";
import { ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite";
import { XOR } from "../../../@types/common";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>;
export function getPending3PidInvites(room: Room, searchQuery?: string): Member[] {
// include 3pid invites (m.room.third_party_invite) state events.
// The HS may have already converted these into m.room.member invites so
// we shouldn't add them if the 3pid invite state key (token) is in the
// member invite (content.third_party_invite.signed.token)
const inviteEvents = room.currentState.getStateEvents("m.room.third_party_invite").filter(function (e) {
if (!isValid3pidInvite(e)) return false;
if (searchQuery && !(e.getContent().display_name as string)?.includes(searchQuery)) return false;
// discard all invites which have a m.room.member event since we've
// already added them.
const memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey()!);
if (memberEvent) return false;
return true;
});
const invites: Member[] = inviteEvents.map((e) => {
return {
threePidInvite: {
event: e,
},
};
});
return invites;
}
export function sdkRoomMemberToRoomMember(member: SdkRoomMember): Member {
const displayUserId =
UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, {
roomId: member.roomId,
}) ?? member.userId;
const mxcAvatarURL = member.getMxcAvatarUrl();
const avatarThumbnailUrl =
(mxcAvatarURL && mediaFromMxc(mxcAvatarURL).getThumbnailOfSourceHttp(96, 96, "crop")) ?? undefined;
const user = member.user;
let presenceState: PresenceState | undefined;
if (user) {
presenceState = (user.presence as PresenceState) || undefined;
}
return {
member: {
roomId: member.roomId,
userId: member.userId,
displayUserId: displayUserId,
name: member.name,
rawDisplayName: member.rawDisplayName,
disambiguate: member.disambiguate,
avatarThumbnailUrl: avatarThumbnailUrl,
powerLevel: member.powerLevel,
lastModifiedTime: member.getLastModifiedTime(),
presenceState,
isInvite: member.membership === KnownMembership.Invite,
},
};
}
export interface MemberListViewState {
members: Member[];
search: (searchQuery: string) => void;
isPresenceEnabled: boolean;
shouldShowInvite: boolean;
shouldShowSearch: boolean;
isLoading: boolean;
canInvite: boolean;
onInviteButtonClick: (ev: ButtonEvent) => void;
}
export function useMemberListViewModel(roomId: string): MemberListViewState {
const cli = useMatrixClientContext();
const room = useMemo(() => cli.getRoom(roomId), [roomId, cli]);
if (!room) {
throw new Error(`Room with id ${roomId} does not exist!`);
}
const sdkContext = useContext(SDKContext);
const [memberMap, setMemberMap] = useState<Map<string, Member>>(new Map());
const [isLoading, setIsLoading] = useState<boolean>(true);
// This is the last known total number of members in this room.
const totalMemberCount = useRef<number>(0);
const searchQuery = useRef("");
const loadMembers = useMemo(
() =>
throttle(
async (): Promise<void> => {
const { joined: joinedSdk, invited: invitedSdk } = await sdkContext.memberListStore.loadMemberList(
roomId,
searchQuery.current,
);
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.current);
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);
if (!searchQuery.current) {
/**
* Since searching for members only gives you the relevant
* members matching the query, do not update the totalMemberCount!
**/
totalMemberCount.current = newMemberMap.size;
}
},
500,
{ leading: true, trailing: true },
),
[roomId, sdkContext.memberListStore, room],
);
const search = useCallback(
(query: string) => {
searchQuery.current = query;
loadMembers();
},
[loadMembers],
);
const isPresenceEnabled = useMemo(
() => sdkContext.memberListStore.isPresenceEnabled(),
[sdkContext.memberListStore],
);
// Determines whether the rendered invite button is enabled or disabled
const getCanUserInviteToThisRoom = useCallback((): boolean => !!room && canInviteTo(room), [room]);
const [canInvite, setCanInvite] = useState<boolean>(getCanUserInviteToThisRoom());
// Determines whether the invite button should be shown or not.
const getShouldShowInvite = useCallback(
(): boolean => room?.getMyMembership() === KnownMembership.Join && shouldShowComponent(UIComponent.InviteUsers),
[room],
);
const [shouldShowInvite, setShouldShowInvite] = useState<boolean>(getShouldShowInvite());
const onInviteButtonClick = (ev: ButtonEvent): void => {
PosthogTrackers.trackInteraction("WebRightPanelMemberListInviteButton", ev);
ev.preventDefault();
inviteToRoom(room);
};
useTypedEventEmitter(cli, RoomStateEvent.Events, (event: MatrixEvent) => {
if (event.getRoomId() === roomId && event.getType() === EventType.RoomThirdPartyInvite) {
loadMembers();
const newCanInvite = getCanUserInviteToThisRoom();
setCanInvite(newCanInvite);
}
});
useTypedEventEmitter(cli, RoomStateEvent.Update, (state: RoomState) => {
if (state.roomId === roomId) loadMembers();
});
useTypedEventEmitter(cli, RoomMemberEvent.Name, (_: MatrixEvent, member: SdkRoomMember) => {
if (member.roomId === roomId) loadMembers();
});
useTypedEventEmitter(cli, ClientEvent.Room, (room: Room) => {
// We listen for room events because when we accept an invite
// we need to wait till the room is fully populated with state
// before refreshing the member list else we get a stale list.
if (room.roomId === roomId) loadMembers();
});
useTypedEventEmitter(cli, RoomEvent.MyMembership, (room: Room, membership: string, oldMembership?: string) => {
if (room.roomId !== roomId) return;
if (membership === KnownMembership.Join && oldMembership !== KnownMembership.Join) {
// we just joined the room, load the member list
loadMembers();
const newShouldShowInvite = getShouldShowInvite();
setShouldShowInvite(newShouldShowInvite);
}
});
useTypedEventEmitter(cli, UserEvent.Presence, (_: MatrixEvent | undefined, user: User) => {
if (memberMap.has(user.userId)) loadMembers();
});
useTypedEventEmitter(cli, UserEvent.CurrentlyActive, (_: MatrixEvent | undefined, user: User) => {
if (memberMap.has(user.userId)) loadMembers();
});
// Initial load of the memberlist
useEffect(() => {
(async () => {
await loadMembers();
/**
* isLoading is used to render a spinner on initial call.
* Further calls need not mutate this state since it's perfectly fine to
* show the existing memberlist until the new one loads.
*/
setIsLoading(false);
})();
}, [loadMembers]);
return {
members: Array.from(memberMap.values()),
search,
shouldShowInvite,
isPresenceEnabled,
isLoading,
onInviteButtonClick,
shouldShowSearch: totalMemberCount.current >= 20,
canInvite,
};
}

View File

@@ -0,0 +1,160 @@
/*
Copyright 2024 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 { useEffect, useMemo, useState } from "react";
import { RoomStateEvent, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import dis from "../../../../dispatcher/dispatcher";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import { Action } from "../../../../dispatcher/actions";
import { asyncSome } from "../../../../utils/arrays";
import { getUserDeviceIds } from "../../../../utils/crypto/deviceInfo";
import { RoomMember } from "../../../../models/rooms/RoomMember";
import { E2EState } from "../../../views/rooms/E2EIcon";
import { _t, _td, TranslationKey } from "../../../../languageHandler";
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
interface MemberTileViewModelProps {
member: RoomMember;
showPresence?: boolean;
}
export interface MemberTileViewState extends MemberTileViewModelProps {
e2eStatus?: E2EState;
name: string;
onClick: () => void;
title?: string;
userLabel?: string;
}
export enum PowerStatus {
Admin = "admin",
Moderator = "moderator",
}
const PowerLabel: Record<PowerStatus, TranslationKey> = {
[PowerStatus.Admin]: _td("power_level|admin"),
[PowerStatus.Moderator]: _td("power_level|moderator"),
};
export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberTileViewState {
const [e2eStatus, setE2eStatus] = useState<E2EState | undefined>();
useEffect(() => {
const cli = MatrixClientPeg.safeGet();
const updateE2EStatus = async (): Promise<void> => {
const { userId } = props.member;
const isMe = userId === cli.getUserId();
const userTrust = await cli.getCrypto()?.getUserVerificationStatus(userId);
if (!userTrust?.isCrossSigningVerified()) {
setE2eStatus(userTrust?.wasCrossSigningVerified() ? E2EState.Warning : E2EState.Normal);
return;
}
const deviceIDs = await getUserDeviceIds(cli, userId);
const anyDeviceUnverified = await asyncSome(deviceIDs, async (deviceId) => {
// For your own devices, we use the stricter check of cross-signing
// verification to encourage everyone to trust their own devices via
// cross-signing so that other users can then safely trust you.
// For other people's devices, the more general verified check that
// includes locally verified devices can be used.
const deviceTrust = await cli.getCrypto()?.getDeviceVerificationStatus(userId, deviceId);
return !deviceTrust || (isMe ? !deviceTrust.crossSigningVerified : !deviceTrust.isVerified());
});
setE2eStatus(anyDeviceUnverified ? E2EState.Warning : E2EState.Verified);
};
const onRoomStateEvents = (ev: MatrixEvent): void => {
if (ev.getType() !== EventType.RoomEncryption) return;
const { roomId } = props.member;
if (ev.getRoomId() !== roomId) return;
// The room is encrypted now.
cli.removeListener(RoomStateEvent.Events, onRoomStateEvents);
updateE2EStatus();
};
const onUserTrustStatusChanged = (userId: string, trustStatus: UserVerificationStatus): void => {
if (userId !== props.member.userId) return;
updateE2EStatus();
};
const { roomId } = props.member;
if (roomId) {
const isRoomEncrypted = cli.isRoomEncrypted(roomId);
if (isRoomEncrypted) {
cli.on(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged);
updateE2EStatus();
} else {
// Listen for room to become encrypted
cli.on(RoomStateEvent.Events, onRoomStateEvents);
}
}
return () => {
if (cli) {
cli.removeListener(RoomStateEvent.Events, onRoomStateEvents);
cli.removeListener(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged);
}
};
}, [props.member]);
const onClick = (): void => {
dis.dispatch({
action: Action.ViewUser,
member: props.member,
push: true,
});
};
const member = props.member;
const name = props.member.name;
const powerStatusMap = new Map([
[100, PowerStatus.Admin],
[50, PowerStatus.Moderator],
]);
// Find the nearest power level with a badge
let powerLevel = props.member.powerLevel;
for (const [pl] of powerStatusMap) {
if (props.member.powerLevel >= pl) {
powerLevel = pl;
break;
}
}
const title = useMemo(() => {
return _t("member_list|power_label", {
userName: UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, {
roomId: member.roomId,
}),
powerLevelNumber: member.powerLevel,
}).trim();
}, [member.powerLevel, member.roomId, member.userId]);
let userLabel;
const powerStatus = powerStatusMap.get(powerLevel);
if (powerStatus) {
userLabel = _t(PowerLabel[powerStatus]);
}
if (props.member.isInvite) {
userLabel = `(${_t("member_list|invited_label")})`;
}
return {
title,
member,
name,
onClick,
e2eStatus,
showPresence: props.showPresence,
userLabel,
};
}

View File

@@ -0,0 +1,35 @@
/*
Copyright 2024 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 dis from "../../../../dispatcher/dispatcher";
import { Action } from "../../../../dispatcher/actions";
import { ThreePIDInvite } from "../../../../models/rooms/ThreePIDInvite";
interface ThreePidTileViewModelProps {
threePidInvite: ThreePIDInvite;
}
export interface ThreePidTileViewState {
name: string;
onClick: () => void;
}
export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): ThreePidTileViewState {
const invite = props.threePidInvite;
const name = invite.event.getContent().display_name;
const onClick = (): void => {
dis.dispatch({
action: Action.View3pidInvite,
event: invite.event,
});
};
return {
name,
onClick,
};
}

View File

@@ -23,8 +23,6 @@ import {
TimelineEvents,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
// eslint-disable-next-line no-restricted-imports
import OverflowHorizontalSvg from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
@@ -42,8 +40,6 @@ import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
import { Action } from "../../../dispatcher/actions";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
@@ -60,6 +56,7 @@ import {
} from "../../../accessibility/RovingTabIndex";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { OverflowTileView } from "../rooms/OverflowTileView";
const AVATAR_SIZE = 30;
@@ -275,17 +272,9 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
}
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount: number, totalCount: number): JSX.Element {
const text = _t("common|and_n_others", { count: overflowCount });
return (
<EntityTile
className="mx_EntityTile_ellipsis"
avatarJsx={<BaseAvatar url={OverflowHorizontalSvg} name="..." size="36px" />}
name={text}
showPresence={false}
onClick={() => setTruncateAt(totalCount)}
/>
);
return <OverflowTileView remaining={overflowCount} onClick={() => setTruncateAt(totalCount)} />;
}
const onKeyDown = (ev: React.KeyboardEvent, state: IState): void => {

View File

@@ -8,15 +8,21 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import UserIdentifier from "../../../customisations/UserIdentifier";
interface MemberInfo {
userId: string;
roomId: string;
rawDisplayName?: string;
disambiguate: boolean;
}
interface IProps {
member?: RoomMember | null;
member?: MemberInfo | null;
fallbackName: string;
onClick?(): void;
colored?: boolean;

View File

@@ -22,7 +22,7 @@ export enum E2EState {
Normal = "normal",
}
const crossSigningUserTitles: { [key in E2EState]?: TranslationKey } = {
export const crossSigningUserTitles: { [key in E2EState]?: TranslationKey } = {
[E2EState.Warning]: _td("encryption|cross_signing_user_warning"),
[E2EState.Normal]: _td("encryption|cross_signing_user_normal"),
[E2EState.Verified]: _td("encryption|cross_signing_user_verified"),

View File

@@ -1,170 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket 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 classNames from "classnames";
import AccessibleButton from "../elements/AccessibleButton";
import { _t, _td, TranslationKey } from "../../../languageHandler";
import E2EIcon, { E2EState } from "./E2EIcon";
import BaseAvatar from "../avatars/BaseAvatar";
import PresenceLabel from "./PresenceLabel";
export enum PowerStatus {
Admin = "admin",
Moderator = "moderator",
}
const PowerLabel: Record<PowerStatus, TranslationKey> = {
[PowerStatus.Admin]: _td("power_level|admin"),
[PowerStatus.Moderator]: _td("power_level|mod"),
};
export type PresenceState = "offline" | "online" | "unavailable" | "io.element.unreachable";
const PRESENCE_CLASS: Record<PresenceState, string> = {
"offline": "mx_EntityTile_offline",
"online": "mx_EntityTile_online",
"unavailable": "mx_EntityTile_unavailable",
"io.element.unreachable": "mx_EntityTile_unreachable",
};
function presenceClassForMember(presenceState?: PresenceState, lastActiveAgo?: number, showPresence?: boolean): string {
if (showPresence === false) {
return "mx_EntityTile_online_beenactive";
}
// offline is split into two categories depending on whether we have
// a last_active_ago for them.
if (presenceState === "offline") {
if (lastActiveAgo) {
return PRESENCE_CLASS["offline"] + "_beenactive";
} else {
return PRESENCE_CLASS["offline"] + "_neveractive";
}
} else if (presenceState) {
return PRESENCE_CLASS[presenceState];
} else {
return PRESENCE_CLASS["offline"] + "_neveractive";
}
}
interface IProps {
name?: string;
nameJSX?: JSX.Element;
title?: string;
avatarJsx?: JSX.Element; // <BaseAvatar />
className?: string;
presenceState: PresenceState;
presenceLastActiveAgo: number;
presenceLastTs: number;
presenceCurrentlyActive?: boolean;
onClick(): void;
showPresence: boolean;
subtextLabel?: string;
e2eStatus?: E2EState;
powerStatus?: PowerStatus;
}
interface IState {
hover: boolean;
}
export default class EntityTile extends React.PureComponent<IProps, IState> {
public static defaultProps = {
onClick: () => {},
presenceState: "offline",
presenceLastActiveAgo: 0,
presenceLastTs: 0,
showInviteButton: false,
showPresence: true,
};
public constructor(props: IProps) {
super(props);
this.state = {
hover: false,
};
}
/**
* Creates the PresenceLabel component if needed
* @returns The PresenceLabel component if we need to render it, undefined otherwise
*/
private getPresenceLabel(): JSX.Element | undefined {
if (!this.props.showPresence) return;
const activeAgo = this.props.presenceLastActiveAgo
? Date.now() - (this.props.presenceLastTs - this.props.presenceLastActiveAgo)
: -1;
return (
<PresenceLabel
activeAgo={activeAgo}
currentlyActive={this.props.presenceCurrentlyActive}
presenceState={this.props.presenceState}
/>
);
}
public render(): React.ReactNode {
const mainClassNames: Record<string, boolean> = {
mx_EntityTile: true,
};
if (this.props.className) mainClassNames[this.props.className] = true;
const presenceClass = presenceClassForMember(
this.props.presenceState,
this.props.presenceLastActiveAgo,
this.props.showPresence,
);
mainClassNames[presenceClass] = true;
const name = this.props.nameJSX || this.props.name;
const nameAndPresence = (
<div className="mx_EntityTile_details">
<div className="mx_EntityTile_name">{name}</div>
{this.getPresenceLabel()}
</div>
);
let powerLabel;
const powerStatus = this.props.powerStatus;
if (powerStatus) {
const powerText = _t(PowerLabel[powerStatus]);
powerLabel = <div className="mx_EntityTile_power">{powerText}</div>;
}
let e2eIcon;
const { e2eStatus } = this.props;
if (e2eStatus) {
e2eIcon = <E2EIcon status={e2eStatus} isUser={true} bordered={true} />;
}
const av = this.props.avatarJsx || <BaseAvatar name={this.props.name} size="36px" aria-hidden="true" />;
// The wrapping div is required to make the magic mouse listener work, for some reason.
return (
<div>
<AccessibleButton
className={classNames(mainClassNames)}
title={this.props.title}
onClick={this.props.onClick}
>
<div className="mx_EntityTile_avatar">
{av}
{e2eIcon}
</div>
{nameAndPresence}
{powerLabel}
</AccessibleButton>
</div>
);
}
}

View File

@@ -1,450 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2017, 2018 New Vector Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket 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 {
MatrixEvent,
Room,
RoomEvent,
RoomMember,
RoomMemberEvent,
RoomState,
RoomStateEvent,
User,
UserEvent,
EventType,
ClientEvent,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { throttle } from "lodash";
import { Button, Tooltip } from "@vector-im/compound-web";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";
// eslint-disable-next-line no-restricted-imports
import OverflowHorizontalSvg from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { isValid3pidInvite } from "../../../RoomInvite";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import BaseCard from "../right_panel/BaseCard";
import TruncatedList from "../elements/TruncatedList";
import Spinner from "../elements/Spinner";
import SearchBox from "../../structures/SearchBox";
import { ButtonEvent } from "../elements/AccessibleButton";
import EntityTile from "./EntityTile";
import MemberTile from "./MemberTile";
import BaseAvatar from "../avatars/BaseAvatar";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import PosthogTrackers from "../../../PosthogTrackers";
import { SDKContext } from "../../../contexts/SDKContext";
import { canInviteTo } from "../../../utils/room/canInviteTo";
import { inviteToRoom } from "../../../utils/room/inviteToRoom";
import { Action } from "../../../dispatcher/actions";
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
const SHOW_MORE_INCREMENT = 100;
interface IProps {
roomId: string;
searchQuery: string;
onClose(): void;
onSearchQueryChanged: (query: string) => void;
}
interface IState {
loading: boolean;
filteredJoinedMembers: Array<RoomMember>;
filteredInvitedMembers: Array<RoomMember | MatrixEvent>;
canInvite: boolean;
truncateAtJoined: number;
truncateAtInvited: number;
}
export default class MemberList extends React.Component<IProps, IState> {
private readonly showPresence: boolean;
private unmounted = false;
public static contextType = SDKContext;
declare public context: React.ContextType<typeof SDKContext>;
private tiles: Map<string, MemberTile> = new Map();
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
super(props, context);
this.state = this.getMembersState([], []);
this.showPresence = context?.memberListStore.isPresenceEnabled() ?? true;
}
private listenForMembersChanges(): void {
const cli = MatrixClientPeg.safeGet();
cli.on(RoomStateEvent.Update, this.onRoomStateUpdate);
cli.on(RoomMemberEvent.Name, this.onRoomMemberName);
cli.on(RoomStateEvent.Events, this.onRoomStateEvent);
// We listen for changes to the lastPresenceTs which is essentially
// listening for all presence events (we display most of not all of
// the information contained in presence events).
cli.on(UserEvent.LastPresenceTs, this.onUserPresenceChange);
cli.on(UserEvent.Presence, this.onUserPresenceChange);
cli.on(UserEvent.CurrentlyActive, this.onUserPresenceChange);
cli.on(ClientEvent.Room, this.onRoom); // invites & joining after peek
cli.on(RoomEvent.MyMembership, this.onMyMembership);
}
public componentDidMount(): void {
this.unmounted = false;
this.listenForMembersChanges();
this.updateListNow(true);
}
public componentWillUnmount(): void {
this.unmounted = true;
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate);
cli.removeListener(RoomMemberEvent.Name, this.onRoomMemberName);
cli.removeListener(RoomEvent.MyMembership, this.onMyMembership);
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvent);
cli.removeListener(ClientEvent.Room, this.onRoom);
cli.removeListener(UserEvent.LastPresenceTs, this.onUserPresenceChange);
cli.removeListener(UserEvent.Presence, this.onUserPresenceChange);
cli.removeListener(UserEvent.CurrentlyActive, this.onUserPresenceChange);
}
// cancel any pending calls to the rate_limited_funcs
this.updateList.cancel();
}
private get canInvite(): boolean {
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(this.props.roomId);
return !!room && canInviteTo(room);
}
private getMembersState(invitedMembers: Array<RoomMember>, joinedMembers: Array<RoomMember>): IState {
return {
loading: false,
filteredJoinedMembers: joinedMembers,
filteredInvitedMembers: invitedMembers,
canInvite: this.canInvite,
// ideally we'd size this to the page height, but
// in practice I find that a little constraining
truncateAtJoined: INITIAL_LOAD_NUM_MEMBERS,
truncateAtInvited: INITIAL_LOAD_NUM_INVITED,
};
}
private onUserPresenceChange = (event: MatrixEvent | undefined, user: User): void => {
// Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile
// ever attaching their own listener.
const tile = this.tiles.get(user.userId);
if (tile) {
this.updateList(); // reorder the membership list
}
};
private onRoom = (room: Room): void => {
if (room.roomId !== this.props.roomId) {
return;
}
// We listen for room events because when we accept an invite
// we need to wait till the room is fully populated with state
// before refreshing the member list else we get a stale list.
this.updateListNow(true);
};
private onMyMembership = (room: Room, membership: string, oldMembership?: string): void => {
if (
room.roomId === this.props.roomId &&
membership === KnownMembership.Join &&
oldMembership !== KnownMembership.Join
) {
// we just joined the room, load the member list
this.updateListNow(true);
}
};
private onRoomStateUpdate = (state: RoomState): void => {
if (state.roomId !== this.props.roomId) return;
this.updateList();
};
private onRoomMemberName = (ev: MatrixEvent, member: RoomMember): void => {
if (member.roomId !== this.props.roomId) {
return;
}
this.updateList();
};
private onRoomStateEvent = (event: MatrixEvent): void => {
if (event.getRoomId() === this.props.roomId && event.getType() === EventType.RoomThirdPartyInvite) {
this.updateList();
}
if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite });
};
private updateList = throttle(
() => {
this.updateListNow(false);
},
500,
{ leading: true, trailing: true },
);
// XXX: exported for tests
public async updateListNow(showLoadingSpinner?: boolean): Promise<void> {
if (this.unmounted) {
return;
}
if (showLoadingSpinner) {
this.setState({ loading: true });
}
const { joined, invited } = await this.context.memberListStore.loadMemberList(
this.props.roomId,
this.props.searchQuery,
);
if (this.unmounted) {
return;
}
this.setState({
loading: false,
filteredJoinedMembers: joined,
filteredInvitedMembers: invited,
});
}
private createOverflowTileJoined = (overflowCount: number, totalCount: number): JSX.Element => {
return this.createOverflowTile(overflowCount, totalCount, this.showMoreJoinedMemberList);
};
private createOverflowTileInvited = (overflowCount: number, totalCount: number): JSX.Element => {
return this.createOverflowTile(overflowCount, totalCount, this.showMoreInvitedMemberList);
};
private createOverflowTile = (overflowCount: number, totalCount: number, onClick: () => void): JSX.Element => {
// For now we'll pretend this is any entity. It should probably be a separate tile.
const text = _t("common|and_n_others", { count: overflowCount });
return (
<EntityTile
className="mx_EntityTile_ellipsis"
avatarJsx={<BaseAvatar url={OverflowHorizontalSvg} name="..." size="36px" />}
name={text}
showPresence={false}
onClick={onClick}
/>
);
};
private showMoreJoinedMemberList = (): void => {
this.setState({
truncateAtJoined: this.state.truncateAtJoined + SHOW_MORE_INCREMENT,
});
};
private showMoreInvitedMemberList = (): void => {
this.setState({
truncateAtInvited: this.state.truncateAtInvited + SHOW_MORE_INCREMENT,
});
};
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>, snapshot?: any): void {
if (prevProps.searchQuery !== this.props.searchQuery) {
this.updateListNow(false);
}
}
private onSearchQueryChanged = (searchQuery: string): void => {
this.props.onSearchQueryChanged(searchQuery);
};
private onPending3pidInviteClick = (inviteEvent: MatrixEvent): void => {
dis.dispatch({
action: Action.View3pidInvite,
event: inviteEvent,
});
};
private getPending3PidInvites(): MatrixEvent[] {
// include 3pid invites (m.room.third_party_invite) state events.
// The HS may have already converted these into m.room.member invites so
// we shouldn't add them if the 3pid invite state key (token) is in the
// member invite (content.third_party_invite.signed.token)
const room = MatrixClientPeg.safeGet().getRoom(this.props.roomId);
if (room) {
return room.currentState.getStateEvents("m.room.third_party_invite").filter(function (e) {
if (!isValid3pidInvite(e)) return false;
// discard all invites which have a m.room.member event since we've
// already added them.
const memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey()!);
if (memberEvent) return false;
return true;
});
}
return [];
}
private makeMemberTiles(members: Array<RoomMember | MatrixEvent>): JSX.Element[] {
return members.map((m) => {
if (m instanceof RoomMember) {
// Is a Matrix invite
return (
<MemberTile
key={m.userId}
member={m}
ref={(tile) => {
if (tile) this.tiles.set(m.userId, tile);
else this.tiles.delete(m.userId);
}}
showPresence={this.showPresence}
/>
);
} else {
// Is a 3pid invite
return (
<EntityTile
key={m.getStateKey()}
name={m.getContent().display_name}
showPresence={false}
onClick={() => this.onPending3pidInviteClick(m)}
/>
);
}
});
}
private getChildrenJoined = (start: number, end: number): Array<JSX.Element> => {
return this.makeMemberTiles(this.state.filteredJoinedMembers.slice(start, end));
};
private getChildCountJoined = (): number => this.state.filteredJoinedMembers.length;
private getChildrenInvited = (start: number, end: number): Array<JSX.Element> => {
let targets = this.state.filteredInvitedMembers;
if (end > this.state.filteredInvitedMembers.length) {
targets = targets.concat(this.getPending3PidInvites());
}
return this.makeMemberTiles(targets.slice(start, end));
};
private getChildCountInvited = (): number => {
return this.state.filteredInvitedMembers.length + (this.getPending3PidInvites() || []).length;
};
public render(): React.ReactNode {
if (this.state.loading) {
return (
<BaseCard
id="memberlist-panel"
className="mx_MemberList"
ariaLabelledBy="memberlist-panel-tab"
role="tabpanel"
header={_t("common|people")}
onClose={this.props.onClose}
>
<Spinner />
</BaseCard>
);
}
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(this.props.roomId);
let inviteButton: JSX.Element | undefined;
if (room?.getMyMembership() === KnownMembership.Join && shouldShowComponent(UIComponent.InviteUsers)) {
const inviteButtonText = room.isSpaceRoom() ? _t("space|invite_this_space") : _t("room|invite_this_room");
const button = (
<Button
size="sm"
kind="secondary"
className="mx_MemberList_invite"
onClick={this.onInviteButtonClick}
disabled={!this.state.canInvite}
>
<UserAddIcon width="1em" height="1em" />
{inviteButtonText}
</Button>
);
if (this.state.canInvite) {
inviteButton = button;
} else {
inviteButton = <Tooltip label={_t("member_list|invite_button_no_perms_tooltip")}>{button}</Tooltip>;
}
}
let invitedHeader;
let invitedSection;
if (this.getChildCountInvited() > 0) {
invitedHeader = <h2>{_t("member_list|invited_list_heading")}</h2>;
invitedSection = (
<TruncatedList
className="mx_MemberList_section mx_MemberList_invited"
truncateAt={this.state.truncateAtInvited}
createOverflowElement={this.createOverflowTileInvited}
getChildren={this.getChildrenInvited}
getChildCount={this.getChildCountInvited}
/>
);
}
const footer = (
<SearchBox
className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
placeholder={_t("member_list|filter_placeholder")}
onSearch={this.onSearchQueryChanged}
initialValue={this.props.searchQuery}
/>
);
return (
<BaseCard
id="memberlist-panel"
className="mx_MemberList"
ariaLabelledBy="memberlist-panel-tab"
role="tabpanel"
header={_t("common|people")}
footer={footer}
onClose={this.props.onClose}
>
{inviteButton}
<div className="mx_MemberList_wrapper">
<TruncatedList
className="mx_MemberList_section mx_MemberList_joined"
truncateAt={this.state.truncateAtJoined}
createOverflowElement={this.createOverflowTileJoined}
getChildren={this.getChildrenJoined}
getChildCount={this.getChildCountJoined}
/>
{invitedHeader}
{invitedSection}
</div>
</BaseCard>
);
}
private onInviteButtonClick = (ev: ButtonEvent): void => {
PosthogTrackers.trackInteraction("WebRightPanelMemberListInviteButton", ev);
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(this.props.roomId)!;
inviteToRoom(room);
};
}

View File

@@ -0,0 +1,137 @@
/*
Copyright 2024 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 { Search, Text, Button, Tooltip, InlineSpinner } from "@vector-im/compound-web";
import React from "react";
import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";
import { UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Flex } from "../../../utils/Flex";
import { MemberListViewState } from "../../../viewmodels/memberlist/MemberListViewModel";
import { _t } from "../../../../languageHandler";
interface TooltipProps {
canInvite: boolean;
children: React.ReactNode;
}
const OptionalTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
if (canInvite) return children;
// If the user isn't allowed to invite others to this room, wrap with a relevant tooltip.
return <Tooltip description={_t("member_list|invite_button_no_perms_tooltip")}>{children}</Tooltip>;
};
interface Props {
vm: MemberListViewState;
}
const InviteButton: React.FC<Props> = ({ vm }) => {
const shouldShowInvite = vm.shouldShowInvite;
const shouldShowSearch = vm.shouldShowSearch;
const disabled = !vm.canInvite;
if (!shouldShowInvite) {
// In this case, invite button should not be rendered.
return null;
}
if (shouldShowSearch) {
/// When rendered alongside a search box, the invite button is just an icon.
return (
<OptionalTooltip canInvite={vm.canInvite}>
<Button
className="mx_MemberListHeaderView_invite_small"
kind="primary"
onClick={vm.onInviteButtonClick}
size="sm"
iconOnly={true}
Icon={InviteIcon}
disabled={disabled}
aria-label={_t("action|invite")}
/>
</OptionalTooltip>
);
}
// Without a search box, invite button is a full size button.
return (
<OptionalTooltip canInvite={vm.canInvite}>
<Button
kind="secondary"
size="sm"
Icon={UserAddIcon}
className="mx_MemberListHeaderView_invite_large"
disabled={!vm.canInvite}
onClick={vm.onInviteButtonClick}
>
{_t("action|invite")}
</Button>
</OptionalTooltip>
);
};
/**
* This should be:
* A loading text with spinner while the memberlist loads.
* Member count of the room when there's nothing in the search field.
* Number of matching members during search or 'No result' if search found nothing.
*/
function getHeaderLabelJSX(vm: MemberListViewState): React.ReactNode {
if (vm.isLoading) {
return (
<Flex align="center" gap="8px">
<InlineSpinner /> {_t("common|loading")}
</Flex>
);
}
const filteredMemberCount = vm.members.length;
if (filteredMemberCount === 0) {
return _t("member_list|no_matches");
}
return _t("member_list|count", { count: filteredMemberCount });
}
export const MemberListHeaderView: React.FC<Props> = (props: Props) => {
const vm = props.vm;
let contentJSX: React.ReactNode;
if (vm.shouldShowSearch) {
// When we need to show the search box
contentJSX = (
<Flex justify="center" className="mx_MemberListHeaderView_container">
<Search
className="mx_MemberListHeaderView_search mx_no_textinput"
name="searchMembers"
placeholder={_t("member_list|filter_placeholder")}
onChange={(e) => vm.search((e as React.ChangeEvent<HTMLInputElement>).target.value)}
/>
<InviteButton vm={vm} />
</Flex>
);
} else if (!vm.shouldShowSearch && vm.shouldShowInvite) {
// When we don't need to show the search box but still need an invite button
contentJSX = (
<Flex justify="center" className="mx_MemberListHeaderView_container">
<InviteButton vm={vm} />
</Flex>
);
} else {
// No search box and no invite icon, so nothing to render!
contentJSX = null;
}
return (
<Flex className="mx_MemberListHeaderView" as="header" align="center" justify="space-between" direction="column">
{!vm.isLoading && contentJSX}
<Text as="div" size="sm" weight="semibold" className="mx_MemberListHeaderView_label">
{getHeaderLabelJSX(vm)}
</Text>
</Flex>
);
};

View File

@@ -0,0 +1,82 @@
/*
Copyright 2024 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 { Form } from "@vector-im/compound-web";
import React from "react";
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 { RoomMemberTileView } from "./tiles/RoomMemberTileView";
import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
import { MemberListHeaderView } from "./MemberListHeaderView";
import BaseCard from "../../right_panel/BaseCard";
import { _t } from "../../../../languageHandler";
interface IProps {
roomId: string;
onClose: () => void;
}
const MemberListView: React.FC<IProps> = (props: IProps) => {
const vm = useMemberListViewModel(props.roomId);
const memberCount = vm.members.length;
const rowRenderer = ({ key, index, style }: ListRowProps): React.JSX.Element => {
if (index === memberCount) {
// 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} />
)}
</div>
);
};
return (
<BaseCard
id="memberlist-panel"
className="mx_MemberListView"
ariaLabelledBy="memberlist-panel-tab"
role="tabpanel"
header={_t("common|people")}
onClose={props.onClose}
>
<Flex align="stretch" direction="column" className="mx_MemberListView_container">
<Form.Root>
<MemberListHeaderView vm={vm} />
</Form.Root>
<AutoSizer>
{({ 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)}
// The +1 refers to the additional empty div that we render at the end of the list.
rowCount={memberCount + 1}
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
height={height - 113}
width={width}
/>
)}
</AutoSizer>
</Flex>
</BaseCard>
);
};
export default MemberListView;

View File

@@ -0,0 +1,67 @@
/*
Copyright 2024 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 DisambiguatedProfile from "../../../messages/DisambiguatedProfile";
import { RoomMember } from "../../../../../models/rooms/RoomMember";
import { useMemberTileViewModel } from "../../../../viewmodels/memberlist/tiles/MemberTileViewModel";
import { E2EIconView } from "./common/E2EIconView";
import AvatarPresenceIconView from "./common/PresenceIconView";
import BaseAvatar from "../../../avatars/BaseAvatar";
import { _t } from "../../../../../languageHandler";
import { MemberTileLayout } from "./common/MemberTileLayout";
interface IProps {
member: RoomMember;
showPresence?: boolean;
}
export function RoomMemberTileView(props: IProps): JSX.Element {
const vm = useMemberTileViewModel(props);
const member = vm.member;
const av = (
<BaseAvatar
size="32px"
name={member.name}
idName={member.userId}
title={member.displayUserId}
url={member.avatarThumbnailUrl}
altText={_t("common|user_avatar")}
/>
);
const name = vm.name;
const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />;
const presenceState = member.presenceState;
let presenceJSX: JSX.Element | undefined;
if (vm.showPresence && presenceState) {
presenceJSX = <AvatarPresenceIconView presenceState={presenceState} />;
}
let userLabelJSX;
if (vm.userLabel) {
userLabelJSX = <div className="mx_MemberTileView_user_label">{vm.userLabel}</div>;
}
let e2eIcon;
if (vm.e2eStatus) {
e2eIcon = <E2EIconView status={vm.e2eStatus} />;
}
return (
<MemberTileLayout
title={vm.title}
onClick={vm.onClick}
avatarJsx={av}
presenceJsx={presenceJSX}
nameJsx={nameJSX}
userLabelJsx={userLabelJSX}
e2eIconJsx={e2eIcon}
/>
);
}

View File

@@ -0,0 +1,23 @@
/*
Copyright 2024 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 { useThreePidTileViewModel } from "../../../../viewmodels/memberlist/tiles/ThreePidTileViewModel";
import { ThreePIDInvite } from "../../../../../models/rooms/ThreePIDInvite";
import BaseAvatar from "../../../avatars/BaseAvatar";
import { MemberTileLayout } from "./common/MemberTileLayout";
interface Props {
threePidInvite: ThreePIDInvite;
}
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} />;
}

View File

@@ -0,0 +1,47 @@
/*
Copyright 2024 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 { Tooltip } from "@vector-im/compound-web";
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
import { _t } from "../../../../../../languageHandler";
import { E2EStatus } from "../../../../../../utils/ShieldUtils";
import { E2EState, crossSigningUserTitles } from "../../../E2EIcon";
function getIconFromStatus(status: E2EState | E2EStatus): React.JSX.Element | undefined {
switch (status) {
case E2EState.Normal:
case E2EStatus.Normal:
return undefined;
case E2EState.Verified:
case E2EStatus.Verified:
return <VerifiedIcon height="16px" width="16px" className="mx_E2EIconView_verified" />;
case E2EState.Warning:
case E2EStatus.Warning:
return <ErrorIcon height="16px" width="16px" className="mx_E2EIconView_warning" />;
}
}
interface Props {
status: E2EState | E2EStatus;
}
export const E2EIconView: React.FC<Props> = ({ status }) => {
const e2eTitle = crossSigningUserTitles[status];
const label = e2eTitle ? _t(e2eTitle) : "";
const icon = getIconFromStatus(status);
if (!icon) return null;
return (
<Tooltip label={label}>
<div className="mx_E2EIconView">{icon}</div>
</Tooltip>
);
};

View File

@@ -0,0 +1,40 @@
/*
Copyright 2024 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 AccessibleButton from "../../../../elements/AccessibleButton";
interface Props {
avatarJsx: JSX.Element;
nameJsx: JSX.Element | string;
onClick: () => void;
title?: string;
presenceJsx?: JSX.Element;
userLabelJsx?: JSX.Element;
e2eIconJsx?: JSX.Element;
}
export function MemberTileLayout(props: Props): JSX.Element {
return (
// The wrapping div is required to make the magic mouse listener work, for some reason.
<div>
<AccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
<div className="mx_MemberTileView_left">
<div className="mx_MemberTileView_avatar">
{props.avatarJsx} {props.presenceJsx}
</div>
<div className="mx_MemberTileView_name">{props.nameJsx}</div>
</div>
<div className="mx_MemberTileView_right">
{props.userLabelJsx}
{props.e2eIconJsx}
</div>
</AccessibleButton>
</div>
);
}

View File

@@ -0,0 +1,44 @@
/*
Copyright 2024 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 OnlineOrUnavailableIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-solid-8x8";
import OfflineIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-outline-8x8";
import DNDIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8";
import classNames from "classnames";
import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue";
interface Props {
className?: string;
presenceState: string;
}
export const BUSY_PRESENCE_NAME = new UnstableValue("busy", "org.matrix.msc3026.busy");
function getIconForPresenceState(state: string): React.JSX.Element {
switch (state) {
case "online":
return <OnlineOrUnavailableIcon height="8px" width="8px" className="mx_PresenceIconView_online" />;
case "offline":
return <OfflineIcon height="8px" width="8px" className="mx_PresenceIconView_offline" />;
case "unavailable":
case "io.element.unreachable":
return <OnlineOrUnavailableIcon height="8px" width="8px" className="mx_PresenceIconView_unavailable" />;
case BUSY_PRESENCE_NAME.name:
case BUSY_PRESENCE_NAME.altName:
return <DNDIcon height="8px" width="8px" className="mx_PresenceIconView_dnd" />;
default:
throw new Error(`Presence state "${state}" is unknown.`);
}
}
const AvatarPresenceIconView: React.FC<Props> = ({ className, presenceState }) => {
const names = classNames("mx_PresenceIconView", className);
return <div className={names}>{getIconForPresenceState(presenceState)}</div>;
};
export default AvatarPresenceIconView;

View File

@@ -1,220 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016 OpenMarket 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 { RoomMember, RoomStateEvent, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import dis from "../../../dispatcher/dispatcher";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Action } from "../../../dispatcher/actions";
import EntityTile, { PowerStatus, PresenceState } from "./EntityTile";
import MemberAvatar from "./../avatars/MemberAvatar";
import DisambiguatedProfile from "../messages/DisambiguatedProfile";
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
import { E2EState } from "./E2EIcon";
import { asyncSome } from "../../../utils/arrays";
import { getUserDeviceIds } from "../../../utils/crypto/deviceInfo";
interface IProps {
member: RoomMember;
showPresence?: boolean;
}
interface IState {
isRoomEncrypted: boolean;
e2eStatus?: E2EState;
}
export default class MemberTile extends React.Component<IProps, IState> {
private userLastModifiedTime?: number;
private memberLastModifiedTime?: number;
public static defaultProps = {
showPresence: true,
};
public constructor(props: IProps) {
super(props);
this.state = {
isRoomEncrypted: false,
};
}
public componentDidMount(): void {
const cli = MatrixClientPeg.safeGet();
const { roomId } = this.props.member;
if (roomId) {
const isRoomEncrypted = cli.isRoomEncrypted(roomId);
this.setState({
isRoomEncrypted,
});
if (isRoomEncrypted) {
cli.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
this.updateE2EStatus();
} else {
// Listen for room to become encrypted
cli.on(RoomStateEvent.Events, this.onRoomStateEvents);
}
}
}
public componentWillUnmount(): void {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
cli.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
}
}
private onRoomStateEvents = (ev: MatrixEvent): void => {
if (ev.getType() !== EventType.RoomEncryption) return;
const { roomId } = this.props.member;
if (ev.getRoomId() !== roomId) return;
// The room is encrypted now.
const cli = MatrixClientPeg.safeGet();
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
this.setState({
isRoomEncrypted: true,
});
this.updateE2EStatus();
};
private onUserTrustStatusChanged = (userId: string, trustStatus: UserVerificationStatus): void => {
if (userId !== this.props.member.userId) return;
this.updateE2EStatus();
};
private async updateE2EStatus(): Promise<void> {
const cli = MatrixClientPeg.safeGet();
const { userId } = this.props.member;
const isMe = userId === cli.getUserId();
const userTrust = await cli.getCrypto()?.getUserVerificationStatus(userId);
if (!userTrust?.isCrossSigningVerified()) {
this.setState({
e2eStatus: userTrust?.wasCrossSigningVerified() ? E2EState.Warning : E2EState.Normal,
});
return;
}
const deviceIDs = await getUserDeviceIds(cli, userId);
const anyDeviceUnverified = await asyncSome(deviceIDs, async (deviceId) => {
// For your own devices, we use the stricter check of cross-signing
// verification to encourage everyone to trust their own devices via
// cross-signing so that other users can then safely trust you.
// For other people's devices, the more general verified check that
// includes locally verified devices can be used.
const deviceTrust = await cli.getCrypto()?.getDeviceVerificationStatus(userId, deviceId);
return !deviceTrust || (isMe ? !deviceTrust.crossSigningVerified : !deviceTrust.isVerified());
});
this.setState({
e2eStatus: anyDeviceUnverified ? E2EState.Warning : E2EState.Verified,
});
}
public shouldComponentUpdate(nextProps: IProps, nextState: IState): boolean {
if (
this.memberLastModifiedTime === undefined ||
this.memberLastModifiedTime < nextProps.member.getLastModifiedTime()
) {
return true;
}
if (
nextProps.member.user &&
(this.userLastModifiedTime === undefined ||
this.userLastModifiedTime < nextProps.member.user.getLastModifiedTime())
) {
return true;
}
if (nextState.isRoomEncrypted !== this.state.isRoomEncrypted || nextState.e2eStatus !== this.state.e2eStatus) {
return true;
}
return false;
}
private onClick = (): void => {
dis.dispatch({
action: Action.ViewUser,
member: this.props.member,
push: true,
});
};
private getDisplayName(): string {
return this.props.member.name;
}
private getPowerLabel(): string {
return _t("member_list|power_label", {
userName: UserIdentifierCustomisations.getDisplayUserIdentifier(this.props.member.userId, {
roomId: this.props.member.roomId,
}),
powerLevelNumber: this.props.member.powerLevel,
}).trim();
}
public render(): React.ReactNode {
const member = this.props.member;
const name = this.getDisplayName();
const presenceState = member.user?.presence as PresenceState | undefined;
const av = <MemberAvatar member={member} size="36px" aria-hidden="true" />;
if (member.user) {
this.userLastModifiedTime = member.user.getLastModifiedTime();
}
this.memberLastModifiedTime = member.getLastModifiedTime();
const powerStatusMap = new Map([
[100, PowerStatus.Admin],
[50, PowerStatus.Moderator],
]);
// Find the nearest power level with a badge
let powerLevel = this.props.member.powerLevel;
for (const [pl] of powerStatusMap) {
if (this.props.member.powerLevel >= pl) {
powerLevel = pl;
break;
}
}
const powerStatus = powerStatusMap.get(powerLevel);
let e2eStatus: E2EState | undefined;
if (this.state.isRoomEncrypted) {
e2eStatus = this.state.e2eStatus;
}
const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />;
return (
<EntityTile
{...this.props}
presenceState={presenceState}
presenceLastActiveAgo={member.user ? member.user.lastActiveAgo : 0}
presenceLastTs={member.user ? member.user.lastPresenceTs : 0}
presenceCurrentlyActive={member.user ? member.user.currentlyActive : false}
avatarJsx={av}
title={this.getPowerLabel()}
name={name}
nameJSX={nameJSX}
powerStatus={powerStatus}
showPresence={this.props.showPresence}
e2eStatus={e2eStatus}
onClick={this.onClick}
/>
);
}
}

View File

@@ -0,0 +1,32 @@
/*
Copyright 2024 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 Icon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
interface Props {
// The number of remaining items
remaining: number;
onClick(): void;
}
/**
* @deprecated Only used in ForwardDialog component; newer designs have moved away from this.
*/
export const OverflowTileView: React.FC<Props> = ({ remaining, onClick }) => {
return (
<AccessibleButton onClick={onClick} className="mx_OverflowTileView">
<div className="mx_OverflowTileView_icon">
<Icon height="36px" width="36px" />
</div>
<div className="mx_OverflowTileView_text">{_t("common|and_n_others", { count: remaining })}</div>
</AccessibleButton>
);
};

View File

@@ -1579,9 +1579,14 @@
"toggle_attribution": "Toggle attribution"
},
"member_list": {
"count": {
"one": "%(count)s Member",
"other": "%(count)s Members"
},
"filter_placeholder": "Filter room members",
"invite_button_no_perms_tooltip": "You do not have permission to invite users",
"invited_list_heading": "Invited",
"invited_label": "Invited",
"no_matches": "No matches",
"power_label": "%(userName)s (power %(powerLevelNumber)s)"
},
"member_list_back_action_label": "Room members",
@@ -1734,7 +1739,6 @@
"custom_level": "Custom level",
"default": "Default",
"label": "Power level",
"mod": "Mod",
"moderator": "Moderator",
"restricted": "Restricted"
},
@@ -3066,7 +3070,6 @@
"invite": "Invite people",
"invite_description": "Invite with email or username",
"invite_link": "Share invite link",
"invite_this_space": "Invite to this space",
"joining_space": "Joining",
"landing_welcome": "Welcome to <name/>",
"leave_dialog_action": "Leave space",

View File

@@ -0,0 +1,8 @@
/*
Copyright 2024 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.
*/
export type PresenceState = "offline" | "online" | "unavailable" | "io.element.unreachable";

View File

@@ -0,0 +1,22 @@
/*
Copyright 2024 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 { PresenceState } from "./PresenceState";
export type RoomMember = {
roomId: string;
userId: string;
displayUserId: string;
name: string;
rawDisplayName?: string;
disambiguate: boolean;
avatarThumbnailUrl?: string;
powerLevel: number;
lastModifiedTime: number;
presenceState?: PresenceState;
isInvite: boolean;
};

View File

@@ -0,0 +1,12 @@
/*
Copyright 2024 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 { MatrixEvent } from "matrix-js-sdk/src/matrix";
export type ThreePIDInvite = {
event: MatrixEvent;
};