Implement new member tile
parent
45c1bd13a4
commit
7768795dd7
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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} />;
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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";
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
Loading…
Reference in New Issue