diff --git a/src/components/views/rooms/EntityTileRefactored.tsx b/src/components/views/rooms/EntityTileRefactored.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3103c92dd47c874eff98d924ef4abd11969b0e87 --- /dev/null +++ b/src/components/views/rooms/EntityTileRefactored.tsx @@ -0,0 +1,169 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 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 React, { useCallback } from "react"; +import classNames from "classnames"; + +import AccessibleButton from "../elements/AccessibleButton"; +import { _t, _td, TranslationKey } from "../../../languageHandler"; +import E2EIcon from "./E2EIcon"; +import { E2EState } from "../../../models/rooms/E2EState"; +import BaseAvatar from "../avatars/BaseAvatar"; +import PresenceLabel from "./PresenceLabel"; +import { PresenceState } from "../../../models/rooms/PresenceState"; + +export enum PowerStatus { + Admin = "admin", + Moderator = "moderator", +} + +const PowerLabel: Record<PowerStatus, TranslationKey> = { + [PowerStatus.Admin]: _td("power_level|admin"), + [PowerStatus.Moderator]: _td("power_level|mod"), +}; + +const PRESENCE_CLASS: Record<PresenceState, string> = { + "offline": "mx_EntityTile_offline", + "online": "mx_EntityTile_online", + "unavailable": "mx_EntityTile_unavailable", + "io.element.unreachable": "mx_EntityTile_unreachable", +}; + +function presenceClassForMember(presenceState?: PresenceState, lastActiveAgo?: number, showPresence?: boolean): string { + if (showPresence === false) { + return "mx_EntityTile_online_beenactive"; + } + + // offline is split into two categories depending on whether we have + // a last_active_ago for them. + if (presenceState === "offline") { + if (lastActiveAgo) { + return PRESENCE_CLASS["offline"] + "_beenactive"; + } else { + return PRESENCE_CLASS["offline"] + "_neveractive"; + } + } else if (presenceState) { + return PRESENCE_CLASS[presenceState]; + } else { + return PRESENCE_CLASS["offline"] + "_neveractive"; + } +} + +interface IProps { + name?: string; + nameJSX?: JSX.Element; + title?: string; + avatarJsx?: JSX.Element; // <BaseAvatar /> + className?: string; + presenceState?: PresenceState; + presenceLastActiveAgo: number; + presenceLastTs: number; + presenceCurrentlyActive?: boolean; + showInviteButton?: boolean; + onClick(): void; + showPresence?: boolean; + subtextLabel?: string; + e2eStatus?: E2EState; + powerStatus?: PowerStatus; +} + +export default function EntityTileRefactored({ + onClick = () => {}, + presenceState = "offline", + presenceLastActiveAgo = 0, + presenceLastTs = 0, + showInviteButton = false, + showPresence = true, + ...props +}: IProps): JSX.Element { + /** + * Creates the PresenceLabel component if needed + * @returns The PresenceLabel component if we need to render it, undefined otherwise + */ + const getPresenceLabel = useCallback((): JSX.Element | undefined => { + if (!showPresence) return; + const activeAgo = presenceLastActiveAgo ? Date.now() - (presenceLastTs - presenceLastActiveAgo) : -1; + return ( + <PresenceLabel + activeAgo={activeAgo} + currentlyActive={props.presenceCurrentlyActive} + presenceState={presenceState} + /> + ); + }, [presenceLastTs, presenceLastActiveAgo, presenceState, props.presenceCurrentlyActive, showPresence]); + + const mainClassNames: Record<string, boolean> = { + mx_EntityTile: true, + }; + if (props.className) mainClassNames[props.className] = true; + + const presenceClass = presenceClassForMember(presenceState, presenceLastActiveAgo, showPresence); + mainClassNames[presenceClass] = true; + + const name = props.nameJSX || props.name; + const nameAndPresence = ( + <div className="mx_EntityTile_details"> + <div className="mx_EntityTile_name">{name}</div> + {getPresenceLabel()} + </div> + ); + + let inviteButton; + if (showInviteButton) { + inviteButton = ( + <div className="mx_EntityTile_invite"> + <img + alt={_t("action|invite")} + src={require("../../../../res/img/plus.svg").default} + width="16" + height="16" + /> + </div> + ); + } + + let powerLabel; + const powerStatus = props.powerStatus; + if (powerStatus) { + const powerText = _t(PowerLabel[powerStatus]); + powerLabel = <div className="mx_EntityTile_power">{powerText}</div>; + } + + let e2eIcon; + const { e2eStatus } = props; + if (e2eStatus) { + e2eIcon = <E2EIcon status={e2eStatus} isUser={true} bordered={true} />; + } + + const av = props.avatarJsx || <BaseAvatar name={props.name} size="36px" aria-hidden="true" />; + + // The wrapping div is required to make the magic mouse listener work, for some reason. + return ( + <div> + <AccessibleButton className={classNames(mainClassNames)} title={props.title} onClick={onClick}> + <div className="mx_EntityTile_avatar"> + {av} + {e2eIcon} + </div> + {nameAndPresence} + {powerLabel} + {inviteButton} + </AccessibleButton> + </div> + ); +} diff --git a/src/components/views/rooms/MemberTileNext.tsx b/src/components/views/rooms/MemberTileNext.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d498abb279e88b1b6a38c726674c68f91210ad3d --- /dev/null +++ b/src/components/views/rooms/MemberTileNext.tsx @@ -0,0 +1,175 @@ +/* +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 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 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"; + +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]); + + const onClick = (): void => { + dis.dispatch({ + action: Action.ViewUser, + member: props.member, + push: true, + }); + }; + + const getDisplayName = (): string => { + return props.member.name; + }; + + const getPowerLabel = (): string => { + return _t("member_list|power_label", { + userName: UserIdentifierCustomisations.getDisplayUserIdentifier(props.member.userId, { + roomId: props.member.roomId, + }), + powerLevelNumber: props.member.powerLevel, + }).trim(); + }; + + const member = props.member; + const name = getDisplayName(); + + 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 nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />; + + return ( + <EntityTileRefactored + {...props} + presenceState={member.presence?.state} + presenceLastActiveAgo={member.presence?.lastActiveAgo || 0} + presenceLastTs={member.presence?.lastPresenceTime || 0} + presenceCurrentlyActive={member.presence?.currentlyActive || false} + avatarJsx={av} + title={getPowerLabel()} + name={name} + nameJSX={nameJSX} + powerStatus={powerStatus} + showPresence={props.showPresence} + e2eStatus={e2eStatus} + onClick={onClick} + /> + ); +}