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