Implement new member tile

midhun/member-redesign-accessibility
R Midhun Suresh 2025-01-05 20:32:33 +05:30
parent 45c1bd13a4
commit 7768795dd7
No known key found for this signature in database
9 changed files with 425 additions and 0 deletions

View File

@ -0,0 +1,58 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
.mx_MemberTileView {
display: flex;
padding: var(--cpd-space-3x) var(--cpd-space-3x) var(--cpd-space-3x) var(--cpd-space-4x);
box-sizing: border-box;
height: 56px;
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-gray-300);
.mx_MemberTileView_left,
.mx_MemberTileView_right {
display: flex;
align-items: center;
gap: var(--cpd-space-2x);
}
.mx_MemberTileView_left {
flex-basis: 209px;
flex-grow: 1;
min-width: 0;
}
.mx_MemberTileView_name {
font: var(--cpd-font-body-md-medium);
font-size: 15px;
min-width: 0;
}
.mx_MemberTileView_user_label {
font: var(--cpd-font-body-sm-regular);
font-size: 13px;
}
.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);
}
}

View File

@ -0,0 +1,160 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
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
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

@ -0,0 +1,67 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
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
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,40 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
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,8 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
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
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
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
export type ThreePIDInvite = {
event: MatrixEvent;
};