From 7ba63538f8095fe5a8bf8b3afdd1f5165f9923a3 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sun, 1 Dec 2024 23:15:41 +0530 Subject: [PATCH] WIP --- package.json | 13 +- res/css/_components.pcss | 5 +- .../views/messages/_DisambiguatedProfile.pcss | 22 +- res/css/views/rooms/_MemberList.pcss | 69 ------ .../views/rooms/_MemberListHeaderView.pcss | 37 +++ res/css/views/rooms/_MemberListView.pcss | 19 ++ res/css/views/rooms/_MemberTileView.pcss | 52 ++++ res/css/views/rooms/_OverflowTile.pcss | 43 ++++ res/css/views/rooms/_PresenceLabel.pcss | 25 ++ src/components/structures/MainSplit.tsx | 2 +- src/components/structures/RightPanel.tsx | 33 +-- .../viewmodels/MemberListViewModel.tsx | 225 ++++++++++++++++++ .../viewmodels/MemberTileViewModel.tsx | 179 ++++++++++++++ .../views/avatars/MemberAvatarView.tsx | 57 +++++ .../views/dialogs/ForwardDialog.tsx | 17 +- .../views/messages/DisambiguatedProfile.tsx | 10 +- src/components/views/rooms/E2EIcon.tsx | 11 +- src/components/views/rooms/E2EIconView.tsx | 109 +++++++++ .../views/rooms/MemberListHeaderView.tsx | 120 ++++++++++ src/components/views/rooms/MemberListView.tsx | 78 ++++++ src/components/views/rooms/MemberTileView.tsx | 71 ++++++ .../views/rooms/OverflowTileView.tsx | 32 +++ .../views/rooms/PresenceIconView.tsx | 46 ++++ src/models/rooms/AvatarThumbnailData.ts | 30 +++ src/models/rooms/PresenceState.ts | 17 ++ src/models/rooms/RoomMember.ts | 31 +++ src/models/rooms/ThreePIDInvite.ts | 21 ++ yarn.lock | 88 ++++--- 28 files changed, 1310 insertions(+), 152 deletions(-) delete mode 100644 res/css/views/rooms/_MemberList.pcss create mode 100644 res/css/views/rooms/_MemberListHeaderView.pcss create mode 100644 res/css/views/rooms/_MemberListView.pcss create mode 100644 res/css/views/rooms/_MemberTileView.pcss create mode 100644 res/css/views/rooms/_OverflowTile.pcss create mode 100644 src/components/viewmodels/MemberListViewModel.tsx create mode 100644 src/components/viewmodels/MemberTileViewModel.tsx create mode 100644 src/components/views/avatars/MemberAvatarView.tsx create mode 100644 src/components/views/rooms/E2EIconView.tsx create mode 100644 src/components/views/rooms/MemberListHeaderView.tsx create mode 100644 src/components/views/rooms/MemberListView.tsx create mode 100644 src/components/views/rooms/MemberTileView.tsx create mode 100644 src/components/views/rooms/OverflowTileView.tsx create mode 100644 src/components/views/rooms/PresenceIconView.tsx create mode 100644 src/models/rooms/AvatarThumbnailData.ts 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/package.json b/package.json index 0a0d0a477be..276a2a19723 100644 --- a/package.json +++ b/package.json @@ -85,8 +85,8 @@ "@matrix-org/react-sdk-module-api": "^2.4.0", "@matrix-org/spec": "^1.7.0", "@sentry/browser": "^8.0.0", - "@vector-im/compound-design-tokens": "^2.0.1", - "@vector-im/compound-web": "^7.3.0", + "@vector-im/compound-design-tokens": "^2.1.0", + "@vector-im/compound-web": "^7.4.0", "@vector-im/matrix-wysiwyg": "2.37.13", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", @@ -146,7 +146,9 @@ "temporal-polyfill": "^0.2.5", "ua-parser-js": "^1.0.2", "uuid": "^11.0.0", - "what-input": "^5.2.10" + "what-input": "^5.2.10", + "@types/react-virtualized": "^9.21.30", + "react-virtualized": "^9.22.5" }, "devDependencies": { "@action-validator/cli": "^0.6.0", @@ -296,5 +298,6 @@ }, "engines": { "node": ">=20.0.0" - } -} + }, + "packageManager": "yarn@1.22.19+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447" +} \ No newline at end of file diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 12239fac2df..764f4c110e6 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -281,6 +281,8 @@ @import "./views/rooms/_EditMessageComposer.pcss"; @import "./views/rooms/_EmojiButton.pcss"; @import "./views/rooms/_EntityTile.pcss"; +@import "./views/rooms/_MemberTileView.pcss"; +@import "./views/rooms/_OverflowTile.pcss"; @import "./views/rooms/_EventBubbleTile.pcss"; @import "./views/rooms/_EventPreview.pcss"; @import "./views/rooms/_EventTile.pcss"; @@ -290,7 +292,8 @@ @import "./views/rooms/_LinkPreviewGroup.pcss"; @import "./views/rooms/_LinkPreviewWidget.pcss"; @import "./views/rooms/_LiveContentSummary.pcss"; -@import "./views/rooms/_MemberList.pcss"; +@import "./views/rooms/_MemberListView.pcss"; +@import "./views/rooms/_MemberListHeaderView.pcss"; @import "./views/rooms/_MessageComposer.pcss"; @import "./views/rooms/_MessageComposerFormatBar.pcss"; @import "./views/rooms/_NewRoomIntro.pcss"; diff --git a/res/css/views/messages/_DisambiguatedProfile.pcss b/res/css/views/messages/_DisambiguatedProfile.pcss index 25a28971d41..a5d0f1eff33 100644 --- a/res/css/views/messages/_DisambiguatedProfile.pcss +++ b/res/css/views/messages/_DisambiguatedProfile.pcss @@ -21,8 +21,28 @@ Please see LICENSE files in the repository root for full details. } .mx_DisambiguatedProfile_mxid { - margin-inline-start: 5px; color: $secondary-content; font-size: var(--cpd-font-size-body-sm); + margin-inline-start: 5px; + } +} + +.mx_MemberTileView .mx_DisambiguatedProfile { + display: flex; + flex-direction: column; + + .mx_DisambiguatedProfile_mxid { + margin-inline-start: 0; + font: var(--cpd-font-body-sm-regular); + } + + span:not(.mx_DisambiguatedProfile_mxid) { + /** + In a member tile, this span element is a flex child and so + we need the following for text overflow to work. + **/ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } } diff --git a/res/css/views/rooms/_MemberList.pcss b/res/css/views/rooms/_MemberList.pcss deleted file mode 100644 index e3fe819ab65..00000000000 --- a/res/css/views/rooms/_MemberList.pcss +++ /dev/null @@ -1,69 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2015, 2016 OpenMarket 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_MemberList { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; - - .mx_Spinner { - flex: 1 0 auto; - } - - .mx_SearchBox { - margin-bottom: 5px; - } - - h2 { - text-transform: uppercase; - color: $h3-color; - font-weight: var(--cpd-font-weight-semibold); - font-size: $font-13px; - padding-left: 3px; - padding-right: 12px; - margin-top: 8px; - margin-bottom: 4px; - } - - .mx_AutoHideScrollbar { - flex: 1 1 0; - margin-top: var(--cpd-space-3x); - } -} - -.mx_MemberList_chevron { - position: absolute; - right: 35px; - margin-top: -15px; -} - -.mx_MemberList_border { - overflow-y: auto; - - order: 1; - flex: 1 1 0px; -} - -.mx_MemberList_query { - height: 16px; - - /* stricter rule to override the one in _common.pcss */ - &[type="text"] { - font-size: $font-12px; - } -} - -.mx_MemberList_wrapper { - padding: 10px; -} - -.mx_MemberList_invite { - margin: 0 var(--cpd-space-2x); - width: calc(100% - var(--cpd-space-4x)); -} diff --git a/res/css/views/rooms/_MemberListHeaderView.pcss b/res/css/views/rooms/_MemberListHeaderView.pcss new file mode 100644 index 00000000000..6ebc27bacfe --- /dev/null +++ b/res/css/views/rooms/_MemberListHeaderView.pcss @@ -0,0 +1,37 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2015, 2016 OpenMarket 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_MemberListHeaderView { + border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-gray-400); + + .mx_MemberListHeaderView_container { + margin-top: var(--cpd-space-6x); + width: 100%; + } + + .mx_MemberListHeaderView_invite_small { + margin-left: var(--cpd-space-3x); + } + + .mx_MemberListHeaderView_invite_large { + width: 288px; + height: 36px; + } + + .mx_MemberListHeaderView_label { + padding: var(--cpd-space-6x) 0 var(--cpd-space-2x) var(--cpd-space-4x); + box-sizing: border-box; + width: 100%; + color: var(--cpd-color-text-secondary); + font: var(--cpd-font-body-sm-semibold); + } + + .mx_MemberListHeaderView_search { + width: 240px; + } +} diff --git a/res/css/views/rooms/_MemberListView.pcss b/res/css/views/rooms/_MemberListView.pcss new file mode 100644 index 00000000000..fc9f7ce8f93 --- /dev/null +++ b/res/css/views/rooms/_MemberListView.pcss @@ -0,0 +1,19 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2015, 2016 OpenMarket 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_MemberListView { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + + .mx_MemberListView_container { + height: 100%; + } + +} diff --git a/res/css/views/rooms/_MemberTileView.pcss b/res/css/views/rooms/_MemberTileView.pcss new file mode 100644 index 00000000000..a578f243fb0 --- /dev/null +++ b/res/css/views/rooms/_MemberTileView.pcss @@ -0,0 +1,52 @@ +.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: 8px + } + + .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/res/css/views/rooms/_OverflowTile.pcss b/res/css/views/rooms/_OverflowTile.pcss new file mode 100644 index 00000000000..501cbc91c9f --- /dev/null +++ b/res/css/views/rooms/_OverflowTile.pcss @@ -0,0 +1,43 @@ + +.mx_OverflowTileView { + display: flex; + align-items: center; + color: $primary-content; + cursor: pointer; +} + +.mx_OverflowTileView_text { + flex: 1 1 0; + overflow: hidden; + font: var(--cpd-font-body-md-regular); + text-overflow: ellipsis; + white-space: nowrap; + font-style: italic; +} + +.mx_OverflowTileView:hover { + padding-right: 30px; + position: relative; /* to keep the chevron aligned */ +} + +.mx_OverflowTileView:hover::before { + content: ""; + position: absolute; + top: calc(50% - 8px); /* center */ + right: -8px; + mask: url("@vector-im/compound-design-tokens/icons/chevron-right.svg"); + mask-repeat: no-repeat; + mask-position: center; + width: 16px; + height: 16px; + background-color: $header-panel-text-primary-color; +} + +.mx_OverflowTileView_icon { + padding-left: 3px; + padding-right: 12px; + padding-top: 4px; + padding-bottom: 4px; + position: relative; + line-height: 0; +} diff --git a/res/css/views/rooms/_PresenceLabel.pcss b/res/css/views/rooms/_PresenceLabel.pcss index c9c610c221d..dba814c5715 100644 --- a/res/css/views/rooms/_PresenceLabel.pcss +++ b/res/css/views/rooms/_PresenceLabel.pcss @@ -14,3 +14,28 @@ Please see LICENSE files in the repository root for full details. .mx_PresenceLabel_online { color: var(--cpd-color-text-success-primary); } + +.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/structures/MainSplit.tsx b/src/components/structures/MainSplit.tsx index 54aa2203549..dc8f911ef47 100644 --- a/src/components/structures/MainSplit.tsx +++ b/src/components/structures/MainSplit.tsx @@ -99,7 +99,7 @@ export default class MainSplit extends React.Component { { case RightPanelPhases.RoomMemberList: if (!!roomId) { card = ( - + + // ); } break; case RightPanelPhases.SpaceMemberList: if (!!cardState?.spaceId || !!roomId) { card = ( - + + // ); } break; diff --git a/src/components/viewmodels/MemberListViewModel.tsx b/src/components/viewmodels/MemberListViewModel.tsx new file mode 100644 index 00000000000..6af987e0805 --- /dev/null +++ b/src/components/viewmodels/MemberListViewModel.tsx @@ -0,0 +1,225 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +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"; + +function sdkRoomMemberToRoomMember(member: SDKRoomMember): RoomMember { + const displayUserId = + UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, { + roomId: member.roomId, + }) ?? member.userId; + + const mxcAvatarURL = member.getMxcAvatarUrl(); + const avatarThumbnailUrl = + (mxcAvatarURL && mediaFromMxc(mxcAvatarURL).getThumbnailOfSourceHttp(10, 10)) ?? undefined; + + const user = member.user; + let presenceState: PresenceState | undefined = undefined; + if (user) { + presenceState = (user.presence as PresenceState) || undefined; + } + + return { + 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: RoomMember[]; + memberCount: number; + search: (searchQuery: string) => void; + isPresenceEnabled: boolean; + shouldShowInvite: 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 [members, setMembers] = useState([]); + const [memberCount, setMemberCount] = useState(0); + const searchQuery = useRef(""); + const [isLoading, setIsLoading] = useState(true); + + const loadMembers = useMemo( + () => + throttle( + async (): Promise => { + const { joined: joinedSdk, invited: invitedSdk } = await sdkContext.memberListStore.loadMemberList( + roomId, + searchQuery.current, + ); + const joined = joinedSdk.map(sdkRoomMemberToRoomMember); + const invited = invitedSdk.map(sdkRoomMemberToRoomMember); + setMembers([...invited, ...joined]); + if (!searchQuery.current) setMemberCount(joined.length); + }, + 500, + { leading: true, trailing: true }, + ), + [roomId, sdkContext.memberListStore], + ); + + const search = useCallback( + (query: string) => { + searchQuery.current = query; + loadMembers(); + }, + [loadMembers], + ); + + const isPresenceEnabled = useMemo( + () => sdkContext.memberListStore.isPresenceEnabled(), + [sdkContext.memberListStore], + ); + + const getCanUserInviteToThisRoom = useCallback((): boolean => !!room && canInviteTo(room), [room]); + + const [canInvite, setCanInvite] = useState(getCanUserInviteToThisRoom()); + + const shouldShowInvite = useMemo(() => { + return room?.getMyMembership() == KnownMembership.Join && shouldShowComponent(UIComponent.InviteUsers); + }, [room]); + + const onInviteButtonClick = (ev: ButtonEvent): void => { + PosthogTrackers.trackInteraction("WebRightPanelMemberListInviteButton", ev); + ev.preventDefault(); + inviteToRoom(room); + }; + + useEffect(() => { + const onRoomStateUpdate = (state: RoomState): void => { + if (state.roomId === roomId) loadMembers(); + }; + + const onRoomMemberName = (ev: MatrixEvent, member: SDKRoomMember): void => { + if (member.roomId === roomId) loadMembers(); + }; + + const onRoomStateEvent = (event: MatrixEvent): void => { + if (event.getRoomId() === roomId && event.getType() === EventType.RoomThirdPartyInvite) loadMembers(); + const newCanInvite = getCanUserInviteToThisRoom(); + setCanInvite(newCanInvite); + }; + + const onRoom = (room: Room): void => { + if (room.roomId === roomId) loadMembers(); + // 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.onMemberListUpdated?.(true); + }; + + const onMyMembership = (room: Room, membership: string, oldMembership?: string): void => { + if (room.roomId !== roomId) return; + + if (membership === KnownMembership.Join && oldMembership !== KnownMembership.Join) { + // we just joined the room, load the member list + loadMembers(); + } + }; + + const onUserPresenceChange = (event: MatrixEvent | undefined, user: User): void => { + loadMembers(); + }; + + cli.on(RoomStateEvent.Update, onRoomStateUpdate); + cli.on(RoomMemberEvent.Name, onRoomMemberName); + cli.on(RoomStateEvent.Events, onRoomStateEvent); + cli.on(ClientEvent.Room, onRoom); // invites & joining after peek + cli.on(RoomEvent.MyMembership, onMyMembership); + cli.on(UserEvent.LastPresenceTs, onUserPresenceChange); + cli.on(UserEvent.Presence, onUserPresenceChange); + cli.on(UserEvent.CurrentlyActive, onUserPresenceChange); + + // Initial load of the memberlist + (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); + })(); + + return () => { + cli.off(RoomStateEvent.Update, onRoomStateUpdate); + cli.off(RoomMemberEvent.Name, onRoomMemberName); + cli.off(RoomStateEvent.Events, onRoomStateEvent); + cli.off(ClientEvent.Room, onRoom); // invites & joining after peek + cli.off(RoomEvent.MyMembership, onMyMembership); + cli.off(UserEvent.LastPresenceTs, onUserPresenceChange); + cli.off(UserEvent.Presence, onUserPresenceChange); + cli.off(UserEvent.CurrentlyActive, onUserPresenceChange); + }; + }, [cli, loadMembers, roomId, getCanUserInviteToThisRoom]); + + return { + members, + memberCount, + search, + shouldShowInvite, + isPresenceEnabled, + isLoading, + onInviteButtonClick, + canInvite, + }; +} diff --git a/src/components/viewmodels/MemberTileViewModel.tsx b/src/components/viewmodels/MemberTileViewModel.tsx new file mode 100644 index 00000000000..edf18eee2eb --- /dev/null +++ b/src/components/viewmodels/MemberTileViewModel.tsx @@ -0,0 +1,179 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useEffect, useMemo, useState } from "react"; +import { RoomStateEvent, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; +import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; +import { CryptoEvent } from "matrix-js-sdk/src/crypto"; +import { UserVerificationStatus } 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 IProps { + member: RoomMember; + showPresence?: boolean; +} + +export interface MemberTileViewState extends IProps { + 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|mod"), +}; + +export default function useMemberTileViewModel(props: IProps): 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 onDeviceVerificationChanged = (userId: string, deviceId: string, deviceInfo: DeviceInfo): 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); + cli.on(CryptoEvent.DeviceVerificationChanged, onDeviceVerificationChanged); + 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); + cli.removeListener(CryptoEvent.DeviceVerificationChanged, onDeviceVerificationChanged); + } + }; + }, [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 = "(Invited)"; + } + + return { + title, + member, + name, + onClick, + e2eStatus, + showPresence: props.showPresence, + userLabel, + }; +} diff --git a/src/components/views/avatars/MemberAvatarView.tsx b/src/components/views/avatars/MemberAvatarView.tsx new file mode 100644 index 00000000000..6fcb4881b64 --- /dev/null +++ b/src/components/views/avatars/MemberAvatarView.tsx @@ -0,0 +1,57 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { forwardRef, Ref } from "react"; + +import BaseAvatar from "./BaseAvatar"; +import { _t } from "../../../languageHandler"; +import { RoomMember } from "../../../models/rooms/RoomMember"; +import { AvatarThumbnailData, avatarUrl } from "../../../models/rooms/AvatarThumbnailData"; + +interface Props { + member: RoomMember; + size: string; + resizeMethod?: "crop" | "scale"; +} + +function MemberAvatarView({ size, resizeMethod = "crop", member }: Props, ref: Ref): JSX.Element { + let imageUrl = undefined; + const avatarThumbnailUrl = member.avatarThumbnailUrl; + + if (!!avatarThumbnailUrl) { + const data: AvatarThumbnailData = { + src: avatarThumbnailUrl, + width: parseInt(size, 10), + height: parseInt(size, 10), + resizeMethod: resizeMethod, + }; + imageUrl = avatarUrl(data); + } + + return ( + + ); +} + +export default forwardRef(MemberAvatarView); diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx index 0a0a70d1b5b..3e0de18efec 100644 --- a/src/components/views/dialogs/ForwardDialog.tsx +++ b/src/components/views/dialogs/ForwardDialog.tsx @@ -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 = ({ 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 ( - } - name={text} - showPresence={false} - onClick={() => setTruncateAt(totalCount)} - /> - ); + return setTruncateAt(totalCount)} />; } const onKeyDown = (ev: React.KeyboardEvent, state: IState): void => { diff --git a/src/components/views/messages/DisambiguatedProfile.tsx b/src/components/views/messages/DisambiguatedProfile.tsx index 0e7d2e046eb..25e402ce318 100644 --- a/src/components/views/messages/DisambiguatedProfile.tsx +++ b/src/components/views/messages/DisambiguatedProfile.tsx @@ -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 DisambiguatedMemberInfo { + userId: string; + roomId: string; + rawDisplayName?: string; + disambiguate: boolean; +} + interface IProps { - member?: RoomMember | null; + member?: DisambiguatedMemberInfo | null; fallbackName: string; onClick?(): void; colored?: boolean; diff --git a/src/components/views/rooms/E2EIcon.tsx b/src/components/views/rooms/E2EIcon.tsx index 29899e85ba9..8dd4bf63b4b 100644 --- a/src/components/views/rooms/E2EIcon.tsx +++ b/src/components/views/rooms/E2EIcon.tsx @@ -73,18 +73,17 @@ const E2EIcon: React.FC> = ({ className, ); + let style: CSSProperties | undefined; + if (size) { + style = { width: `${size}px`, height: `${size}px` }; + } + let e2eTitle: TranslationKey | undefined; if (isUser) { e2eTitle = crossSigningUserTitles[status]; } else { e2eTitle = crossSigningRoomTitles[status]; } - - let style: CSSProperties | undefined; - if (size) { - style = { width: `${size}px`, height: `${size}px` }; - } - const label = e2eTitle ? _t(e2eTitle) : ""; let content: JSX.Element; diff --git a/src/components/views/rooms/E2EIconView.tsx b/src/components/views/rooms/E2EIconView.tsx new file mode 100644 index 00000000000..e6a5bca3275 --- /dev/null +++ b/src/components/views/rooms/E2EIconView.tsx @@ -0,0 +1,109 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2019 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, { ComponentProps, CSSProperties } from "react"; +import classNames from "classnames"; +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, _td, TranslationKey } from "../../../languageHandler"; +import { E2EStatus } from "../../../utils/ShieldUtils"; +import { XOR } from "../../../@types/common"; +import { E2EState } from "./E2EIcon"; + +// export enum E2EState { +// Verified = "verified", +// Warning = "warning", +// Normal = "normal", +// } + +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"), +}; +const crossSigningRoomTitles: { [key in E2EState]?: TranslationKey } = { + [E2EState.Warning]: _td("encryption|cross_signing_room_warning"), + [E2EState.Normal]: _td("encryption|cross_signing_room_normal"), + [E2EState.Verified]: _td("encryption|cross_signing_room_verified"), +}; + +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 { + className?: string; + size?: number; + onClick?: () => void; + tooltipPlacement?: ComponentProps["placement"]; +} + +interface UserPropsF extends Props { + isUser: true; + status: E2EState | E2EStatus; +} + +interface RoomPropsF extends Props { + isUser?: false; + status: E2EStatus; +} + +const E2EIcon: React.FC> = ({ + isUser, + status, + className, + size, + onClick, + tooltipPlacement, +}) => { + const classes = classNames( + { + mx_E2EIconView: true, + }, + className, + ); + + let style: CSSProperties | undefined; + if (size) { + style = { width: `${size}px`, height: `${size}px` }; + } + + let e2eTitle: TranslationKey | undefined; + if (isUser) { + e2eTitle = crossSigningUserTitles[status]; + } else { + e2eTitle = crossSigningRoomTitles[status]; + } + const label = e2eTitle ? _t(e2eTitle) : ""; + + const icon = getIconFromStatus(status); + if (!icon) return null; + + return ( + +
+ {icon} +
+
+ ); +}; + +export default E2EIcon; diff --git a/src/components/views/rooms/MemberListHeaderView.tsx b/src/components/views/rooms/MemberListHeaderView.tsx new file mode 100644 index 00000000000..b05ba27d5e2 --- /dev/null +++ b/src/components/views/rooms/MemberListHeaderView.tsx @@ -0,0 +1,120 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +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/MemberListViewModel"; +import { _t } from "../../../languageHandler"; + +interface Props { + vm: MemberListViewState; +} + +interface TooltipProps { + canInvite: boolean; + children: React.ReactNode; +} + +const OptionalTooltip: React.FC = ({ canInvite, children }) => { + if (canInvite) return children; + // If the user isn't allowed to invite others to this room, wrap with a relevant tooltip. + return {children}; +}; + +/** + * 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 ( + + Loading... + + ); + } + + const filteredMemberCount = vm.members.length; + if (filteredMemberCount === 0) { + return "No matches"; + } + return `${filteredMemberCount} Members`; +} + +/** + * The top section of the memberlist contains: + * - Just an invite button if the number of members < 20 + * - Search bar + invite button if number of members > 20 + * - A header label, see function above. + */ +const MemberListHeaderView: React.FC = (props: Props) => { + const vm = props.vm; + const memberCount = vm.memberCount; + const contentJSX = + memberCount < 20 ? ( + + + + ) : ( + <> + vm.search((e as React.ChangeEvent).target.value)} + /> + +