From 656d3e8083a2ca02033cfa5a4e4a5d56f0427f5b Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sun, 5 Jan 2025 20:15:00 +0530 Subject: [PATCH 01/24] Add new e2e icon for the member tile --- res/css/views/rooms/_E2EIconView.pcss | 20 ++++++++ src/components/views/rooms/E2EIcon.tsx | 2 +- .../MemberList/tiles/common/E2EIconView.tsx | 47 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 res/css/views/rooms/_E2EIconView.pcss create mode 100644 src/components/views/rooms/MemberList/tiles/common/E2EIconView.tsx diff --git a/res/css/views/rooms/_E2EIconView.pcss b/res/css/views/rooms/_E2EIconView.pcss new file mode 100644 index 00000000000..19952db2007 --- /dev/null +++ b/res/css/views/rooms/_E2EIconView.pcss @@ -0,0 +1,20 @@ +/* +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_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); +} diff --git a/src/components/views/rooms/E2EIcon.tsx b/src/components/views/rooms/E2EIcon.tsx index 29899e85ba9..99ac07e0ecb 100644 --- a/src/components/views/rooms/E2EIcon.tsx +++ b/src/components/views/rooms/E2EIcon.tsx @@ -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"), diff --git a/src/components/views/rooms/MemberList/tiles/common/E2EIconView.tsx b/src/components/views/rooms/MemberList/tiles/common/E2EIconView.tsx new file mode 100644 index 00000000000..9d3212f5e76 --- /dev/null +++ b/src/components/views/rooms/MemberList/tiles/common/E2EIconView.tsx @@ -0,0 +1,47 @@ +/* +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 { 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 ; + case E2EState.Warning: + case E2EStatus.Warning: + return ; + } +} + +interface Props { + status: E2EState | E2EStatus; +} + +export const E2EIconView: React.FC = ({ status }) => { + const e2eTitle = crossSigningUserTitles[status]; + const label = e2eTitle ? _t(e2eTitle) : ""; + + const icon = getIconFromStatus(status); + if (!icon) return null; + + return ( + +
{icon}
+
+ ); +}; From 45c1bd13a4f2710fe0bb4a26ba6bd93a08c3bb9c Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sun, 5 Jan 2025 20:27:20 +0530 Subject: [PATCH 02/24] Add new presence icon for member tile --- res/css/views/rooms/_PresenceIconView.pcss | 32 ++++++++++++++ .../tiles/common/PresenceIconView.tsx | 44 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 res/css/views/rooms/_PresenceIconView.pcss create mode 100644 src/components/views/rooms/MemberList/tiles/common/PresenceIconView.tsx diff --git a/res/css/views/rooms/_PresenceIconView.pcss b/res/css/views/rooms/_PresenceIconView.pcss new file mode 100644 index 00000000000..6be4ac47827 --- /dev/null +++ b/res/css/views/rooms/_PresenceIconView.pcss @@ -0,0 +1,32 @@ +/* +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_PresenceIconView { + position: absolute; + top: 24px; + left: 24px; + width: 12px; + height: 12px; + display: flex; + justify-content: center; + align-items: center; + background: var(--cpd-color-bg-canvas-default); + border-radius: 100%; + + .mx_PresenceIconView_online { + color: var(--cpd-color-icon-accent-primary); + } + + .mx_PresenceIconView_offline, + .mx_PresenceIconView_dnd { + color: var(--cpd-color-icon-tertiary); + } + + .mx_PresenceIconView_unavailable { + color: var(--cpd-color-icon-quaternary); + } +} diff --git a/src/components/views/rooms/MemberList/tiles/common/PresenceIconView.tsx b/src/components/views/rooms/MemberList/tiles/common/PresenceIconView.tsx new file mode 100644 index 00000000000..18aaa13a239 --- /dev/null +++ b/src/components/views/rooms/MemberList/tiles/common/PresenceIconView.tsx @@ -0,0 +1,44 @@ +/* +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 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 ; + case "offline": + return ; + case "unavailable": + case "io.element.unreachable": + return ; + case BUSY_PRESENCE_NAME.name: + case BUSY_PRESENCE_NAME.altName: + return ; + default: + throw new Error(`Presence state "${state}" is unknown.`); + } +} + +const AvatarPresenceIconView: React.FC = ({ className, presenceState }) => { + const names = classNames("mx_PresenceIconView", className); + return
{getIconForPresenceState(presenceState)}
; +}; + +export default AvatarPresenceIconView; From 7768795dd7d5de3ed1abdf5d66f77eb97b112463 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sun, 5 Jan 2025 20:32:33 +0530 Subject: [PATCH 03/24] Implement new member tile --- res/css/views/rooms/_MemberTileView.pcss | 58 +++++++ .../memberlist/tiles/MemberTileViewModel.tsx | 160 ++++++++++++++++++ .../tiles/ThreePidTileViewModel.tsx | 35 ++++ .../MemberList/tiles/RoomMemberTileView.tsx | 67 ++++++++ .../tiles/ThreePidInviteTileView.tsx | 23 +++ .../tiles/common/MemberTileLayout.tsx | 40 +++++ src/models/rooms/PresenceState.ts | 8 + src/models/rooms/RoomMember.ts | 22 +++ src/models/rooms/ThreePIDInvite.ts | 12 ++ 9 files changed, 425 insertions(+) create mode 100644 res/css/views/rooms/_MemberTileView.pcss create mode 100644 src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx create mode 100644 src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx create mode 100644 src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx create mode 100644 src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx create mode 100644 src/components/views/rooms/MemberList/tiles/common/MemberTileLayout.tsx create mode 100644 src/models/rooms/PresenceState.ts create mode 100644 src/models/rooms/RoomMember.ts create mode 100644 src/models/rooms/ThreePIDInvite.ts diff --git a/res/css/views/rooms/_MemberTileView.pcss b/res/css/views/rooms/_MemberTileView.pcss new file mode 100644 index 00000000000..94d9987e598 --- /dev/null +++ b/res/css/views/rooms/_MemberTileView.pcss @@ -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); + } +} diff --git a/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx new file mode 100644 index 00000000000..e2a32c76dbf --- /dev/null +++ b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx @@ -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.Admin]: _td("power_level|admin"), + [PowerStatus.Moderator]: _td("power_level|moderator"), +}; + +export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberTileViewState { + const [e2eStatus, setE2eStatus] = useState(); + + useEffect(() => { + const cli = MatrixClientPeg.safeGet(); + + const updateE2EStatus = async (): Promise => { + 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, + }; +} diff --git a/src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx b/src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx new file mode 100644 index 00000000000..9725c305273 --- /dev/null +++ b/src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx @@ -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, + }; +} diff --git a/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx b/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx new file mode 100644 index 00000000000..6d3b511e123 --- /dev/null +++ b/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx @@ -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 = ( + + ); + const name = vm.name; + const nameJSX = ; + + const presenceState = member.presenceState; + let presenceJSX: JSX.Element | undefined; + if (vm.showPresence && presenceState) { + presenceJSX = ; + } + + let userLabelJSX; + if (vm.userLabel) { + userLabelJSX =
{vm.userLabel}
; + } + + let e2eIcon; + if (vm.e2eStatus) { + e2eIcon = ; + } + + return ( + + ); +} diff --git a/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx b/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx new file mode 100644 index 00000000000..2058f090c83 --- /dev/null +++ b/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx @@ -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 =