diff --git a/.eslintrc.js b/.eslintrc.js index 444388d492ba45e16c0f5f5af67d7a36afee8a00..94cf8bbc0cfe8fb59003078c21346fce44d57cea 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -225,6 +225,7 @@ module.exports = { "src/components/views/messages/MVoiceMessageBody.tsx", "src/components/views/right_panel/EncryptionPanel.tsx", "src/components/views/rooms/EntityTile.tsx", + "src/components/views/rooms/EntityTileRefactored.tsx", "src/components/views/rooms/LinkPreviewGroup.tsx", "src/components/views/rooms/MemberList.tsx", "src/components/views/rooms/MessageComposer.tsx", diff --git a/src/components/views/rooms/MemberList.tsx b/src/components/views/rooms/MemberList.tsx index d3501e7571df0cfb316d71e3e2cf9f9503cfc218..8f1ba7aecfb30b530c9201c1407d27fd1a06e635 100644 --- a/src/components/views/rooms/MemberList.tsx +++ b/src/components/views/rooms/MemberList.tsx @@ -327,4 +327,4 @@ export default class RightPanel extends React.Component<Props, IState> { </aside> ); } -} \ No newline at end of file +} diff --git a/src/components/views/rooms/MemberListNext.tsx b/src/components/views/rooms/MemberListNext.tsx index 6f0fbe916e09a81e707d6aea103ce08b2032286b..952eef6e62b4dab1f5929b838599eb246a763cc0 100644 --- a/src/components/views/rooms/MemberListNext.tsx +++ b/src/components/views/rooms/MemberListNext.tsx @@ -15,11 +15,13 @@ limitations under the License. */ import { Form, IconButton, Search, Separator, Text } from "@vector-im/compound-web"; -import { Icon as InviteIcon } from "@vector-im/compound-design-tokens/icons/user-add-solid.svg"; import React, { useEffect, useRef, useState } from "react"; -import { Flex } from "../../utils/Flex"; import { List, ListRowProps } from "react-virtualized/dist/commonjs/List"; +import { Icon as InviteIcon } from "@vector-im/compound-design-tokens/icons/user-add-solid.svg"; + +import { Flex } from "../../utils/Flex"; import { useMemberListViewModel } from "../../../view-models/rooms/memberlist/MemberListViewModelNext"; +import MemberTileNext from "./MemberTileNext"; interface IProps { roomId: string; @@ -44,14 +46,14 @@ const MemberListNext: React.FC<IProps> = (props: IProps) => { } }, [listParent]); - function rowRenderer({ key, index, style }: ListRowProps) { + const rowRenderer = ({ key, index, style }: ListRowProps): React.JSX.Element => { const member = viewModel.members[index]; return ( <div key={key} style={style}> - {member.name} + <MemberTileNext member={member} showPresence={false} /> </div> ); - } + }; return ( <Flex align="stretch" direction="column" className="mx_MemberList_container"> diff --git a/src/components/views/rooms/MemberTileNext.tsx b/src/components/views/rooms/MemberTileNext.tsx index d498abb279e88b1b6a38c726674c68f91210ad3d..94b64abf61f1063db4e0e54627f9ef205813bcc2 100644 --- a/src/components/views/rooms/MemberTileNext.tsx +++ b/src/components/views/rooms/MemberTileNext.tsx @@ -15,143 +15,44 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useEffect, 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 React from "react"; -import dis from "../../../dispatcher/dispatcher"; import { _t } from "../../../languageHandler"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { Action } from "../../../dispatcher/actions"; -import { PowerStatus } from "./EntityTile"; import DisambiguatedProfile from "../messages/DisambiguatedProfile"; import UserIdentifierCustomisations from "../../../customisations/UserIdentifier"; -import { E2EState } from "../../../models/rooms/E2EState"; -import { asyncSome } from "../../../utils/arrays"; -import { getUserDeviceIds } from "../../../utils/crypto/deviceInfo"; import { RoomMember } from "../../../models/rooms/RoomMember"; import MemberAvatarNext from "../avatars/MemberAvatarNext"; import EntityTileRefactored from "./EntityTileRefactored"; +import useMemberTileViewModel, { MemberTileViewModel } from "./MemberTileViewModel"; interface IProps { member: RoomMember; showPresence?: boolean; } -export default function MemberTile(props: IProps): JSX.Element { - // const [isRoomEncrypted, setIsRoomEncrypted] = useState(false); - 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 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]); +export default function MemberTileView(props: IProps): JSX.Element { + const vm = useMemberTileViewModel(props); + return <MemberTile vm={vm} />; +} - const onClick = (): void => { - dis.dispatch({ - action: Action.ViewUser, - member: props.member, - push: true, - }); - }; - - const getDisplayName = (): string => { - return props.member.name; - }; +export function MemberTile(props: { vm: MemberTileViewModel }): JSX.Element { + const vm = props.vm; + const member = vm.member; const getPowerLabel = (): string => { return _t("member_list|power_label", { - userName: UserIdentifierCustomisations.getDisplayUserIdentifier(props.member.userId, { - roomId: props.member.roomId, + userName: UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, { + roomId: member.roomId, }), - powerLevelNumber: props.member.powerLevel, + powerLevelNumber: member.powerLevel, }).trim(); }; - const member = props.member; - const name = getDisplayName(); + const name = vm.name; const av = <MemberAvatarNext member={member} size="36px" aria-hidden="true" />; - 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 powerStatus = powerStatusMap.get(powerLevel); + const powerStatus = vm.powerStatus; const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />; @@ -167,9 +68,9 @@ export default function MemberTile(props: IProps): JSX.Element { name={name} nameJSX={nameJSX} powerStatus={powerStatus} - showPresence={props.showPresence} - e2eStatus={e2eStatus} - onClick={onClick} + showPresence={vm.showPresence} + e2eStatus={vm.e2eStatus} + onClick={vm.onClick} /> ); } diff --git a/src/components/views/rooms/MemberTileViewModel.tsx b/src/components/views/rooms/MemberTileViewModel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..059d38a95cfa03190253ad905647d9c2ef91329e --- /dev/null +++ b/src/components/views/rooms/MemberTileViewModel.tsx @@ -0,0 +1,143 @@ +/* +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, 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 { PowerStatus } from "./EntityTile"; +import { E2EState } from "../../../models/rooms/E2EState"; +import { asyncSome } from "../../../utils/arrays"; +import { getUserDeviceIds } from "../../../utils/crypto/deviceInfo"; +import { RoomMember } from "../../../models/rooms/RoomMember"; + +interface IProps { + member: RoomMember; + showPresence?: boolean; +} + +export interface MemberTileViewModel extends IProps { + e2eStatus?: E2EState; + name: string; + powerStatus?: PowerStatus; + onClick: () => void; +} + +export default function useMemberTileViewModel(props: IProps): MemberTileViewModel { + 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 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 powerStatus = powerStatusMap.get(powerLevel); + + return { powerStatus, member, name, onClick, e2eStatus, showPresence: props.showPresence }; +} diff --git a/src/view-models/rooms/memberlist/MemberListViewModelNext.ts b/src/view-models/rooms/memberlist/MemberListViewModelNext.ts index 21ec9315d7dc45ba962c3d04669e242085d34180..055122278e9c6f99f3e31e17e8f33b74ed171bd5 100644 --- a/src/view-models/rooms/memberlist/MemberListViewModelNext.ts +++ b/src/view-models/rooms/memberlist/MemberListViewModelNext.ts @@ -168,11 +168,11 @@ export function useMemberListViewModel(roomId: string): MemberListViewModelHook } export class MockMemberListViewModelHook implements MemberListViewModelHook { - loading: boolean = false; - members: RoomMember[] = []; - showInvite: boolean = true; - enableInvite: boolean = true; - searchQuery?: string | undefined = undefined; + public loading: boolean = false; + public members: RoomMember[] = []; + public showInvite: boolean = true; + public enableInvite: boolean = true; + public searchQuery?: string | undefined = undefined; constructor() { this.members = [...Array(100).keys()].map((i): RoomMember => { @@ -190,14 +190,14 @@ export class MockMemberListViewModelHook implements MemberListViewModelHook { }; }); } - setUp(): Promise<void> { + public setUp(): Promise<void> { throw new Error("Method not implemented."); } - tearDown(): void { + public tearDown(): void { throw new Error("Method not implemented."); } - onSearchQueryChanged(query: string): Promise<void> { + public onSearchQueryChanged(query: string): Promise<void> { throw new Error("Method not implemented."); } }