* Expose SDKContextClass via window for debugging * Remove stores from window if they are exposed via sdkContext * Avoid usages of global store instance where React context is accessible * Remove more usages of singleton store getter in favour of contexts * Remove more usages of singleton store getter in favour of contexts * Fix tests by adding SDKContext.Provider * Fix tests by adding SDKContext.Provider * Fix tests by adding SDKContext.Provider * Fix tests by adding SDKContext.Provider * Fix tests * Fix tests * Fix tests * Iterate * Fix bad merge * Iterate * Fix tests * Iterate * Iterate * Iterate * Iterate * Iterate * Improve coverage * Improve coverage
252 lines
8.5 KiB
TypeScript
252 lines
8.5 KiB
TypeScript
/*
|
|
Copyright 2024 New Vector Ltd.
|
|
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
|
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
|
Copyright 2017, 2018 Vector Creations Ltd
|
|
Copyright 2015, 2016 OpenMarket Ltd
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE files in the repository root for full details.
|
|
*/
|
|
|
|
import React, { type JSX, type ReactNode, useContext, useEffect, useMemo, useState } from "react";
|
|
import classNames from "classnames";
|
|
import { type MatrixClient, type RoomMember, type Room, type User, type Device } from "matrix-js-sdk/src/matrix";
|
|
import { type UserVerificationStatus, type VerificationRequest, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
|
|
|
import Modal from "../../../Modal";
|
|
import { _t } from "../../../languageHandler";
|
|
import { type ButtonEvent } from "../elements/AccessibleButton";
|
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
|
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
|
import EncryptionPanel from "./EncryptionPanel";
|
|
import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
|
|
import BaseCard from "./BaseCard";
|
|
import QuestionDialog from "../dialogs/QuestionDialog";
|
|
import PosthogTrackers from "../../../PosthogTrackers";
|
|
import { UserInfoHeaderView } from "./user_info/UserInfoHeaderView";
|
|
import { UserInfoBasicView } from "./user_info/UserInfoBasicView";
|
|
import { SDKContext } from "../../../contexts/SDKContext.ts";
|
|
|
|
export interface IDevice extends Device {
|
|
ambiguous?: boolean;
|
|
}
|
|
|
|
export const disambiguateDevices = (devices: IDevice[]): void => {
|
|
const names = Object.create(null);
|
|
for (let i = 0; i < devices.length; i++) {
|
|
const name = devices[i].displayName ?? "";
|
|
const indexList = names[name] || [];
|
|
indexList.push(i);
|
|
names[name] = indexList;
|
|
}
|
|
for (const name in names) {
|
|
if (names[name].length > 1) {
|
|
names[name].forEach((j: number) => {
|
|
devices[j].ambiguous = true;
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
export const warnSelfDemote = async (isSpace: boolean): Promise<boolean> => {
|
|
const { finished } = Modal.createDialog(QuestionDialog, {
|
|
title: _t("user_info|demote_self_confirm_title"),
|
|
description: (
|
|
<div>
|
|
{isSpace
|
|
? _t("user_info|demote_self_confirm_description_space")
|
|
: _t("user_info|demote_self_confirm_room")}
|
|
</div>
|
|
),
|
|
button: _t("user_info|demote_button"),
|
|
});
|
|
|
|
const [confirmed] = await finished;
|
|
return !!confirmed;
|
|
};
|
|
|
|
export const Container: React.FC<{
|
|
children: ReactNode;
|
|
className?: string;
|
|
}> = ({ children, className }) => {
|
|
const classes = classNames("mx_UserInfo_container", className);
|
|
return <div className={classes}>{children}</div>;
|
|
};
|
|
|
|
export interface IPowerLevelsContent {
|
|
events?: Record<string, number>;
|
|
// eslint-disable-next-line camelcase
|
|
users_default?: number;
|
|
// eslint-disable-next-line camelcase
|
|
events_default?: number;
|
|
// eslint-disable-next-line camelcase
|
|
state_default?: number;
|
|
ban?: number;
|
|
kick?: number;
|
|
redact?: number;
|
|
}
|
|
|
|
export interface IRoomPermissions {
|
|
modifyLevelMax: number;
|
|
canEdit: boolean;
|
|
canInvite: boolean;
|
|
}
|
|
|
|
async function getUserDeviceInfo(
|
|
userId: string,
|
|
cli: MatrixClient,
|
|
downloadUncached = false,
|
|
): Promise<Device[] | undefined> {
|
|
const userDeviceMap = await cli.getCrypto()?.getUserDeviceInfo([userId], downloadUncached);
|
|
const devicesMap = userDeviceMap?.get(userId);
|
|
|
|
if (!devicesMap) return;
|
|
|
|
return Array.from(devicesMap.values());
|
|
}
|
|
|
|
export const useDevices = (userId: string): IDevice[] | undefined | null => {
|
|
const cli = useContext(MatrixClientContext);
|
|
|
|
// undefined means yet to be loaded, null means failed to load, otherwise list of devices
|
|
const [devices, setDevices] = useState<undefined | null | IDevice[]>(undefined);
|
|
// Download device lists
|
|
useEffect(() => {
|
|
setDevices(undefined);
|
|
|
|
let cancelled = false;
|
|
|
|
async function downloadDeviceList(): Promise<void> {
|
|
try {
|
|
const devices = await getUserDeviceInfo(userId, cli, true);
|
|
|
|
if (cancelled || !devices) {
|
|
// we got cancelled - presumably a different user now
|
|
return;
|
|
}
|
|
|
|
disambiguateDevices(devices);
|
|
setDevices(devices);
|
|
} catch {
|
|
setDevices(null);
|
|
}
|
|
}
|
|
downloadDeviceList();
|
|
|
|
// Handle being unmounted
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [cli, userId]);
|
|
|
|
// Listen to changes
|
|
useEffect(() => {
|
|
let cancel = false;
|
|
const updateDevices = async (): Promise<void> => {
|
|
const newDevices = await getUserDeviceInfo(userId, cli);
|
|
if (cancel || !newDevices) return;
|
|
setDevices(newDevices);
|
|
};
|
|
const onDevicesUpdated = (users: string[]): void => {
|
|
if (!users.includes(userId)) return;
|
|
updateDevices();
|
|
};
|
|
const onUserTrustStatusChanged = (_userId: string, trustLevel: UserVerificationStatus): void => {
|
|
if (_userId !== userId) return;
|
|
updateDevices();
|
|
};
|
|
cli.on(CryptoEvent.DevicesUpdated, onDevicesUpdated);
|
|
cli.on(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged);
|
|
// Handle being unmounted
|
|
return () => {
|
|
cancel = true;
|
|
cli.removeListener(CryptoEvent.DevicesUpdated, onDevicesUpdated);
|
|
cli.removeListener(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged);
|
|
};
|
|
}, [cli, userId]);
|
|
|
|
return devices;
|
|
};
|
|
|
|
export type Member = User | RoomMember;
|
|
|
|
interface IProps {
|
|
user: Member;
|
|
room?: Room;
|
|
phase: RightPanelPhases.MemberInfo | RightPanelPhases.EncryptionPanel;
|
|
onClose(this: void): void;
|
|
verificationRequest?: VerificationRequest;
|
|
verificationRequestPromise?: Promise<VerificationRequest>;
|
|
}
|
|
|
|
const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPhases.MemberInfo, ...props }) => {
|
|
const sdkContext = useContext(SDKContext);
|
|
|
|
// fetch latest room member if we have a room, so we don't show historical information, falling back to user
|
|
const member = useMemo(() => (room ? room.getMember(user.userId) || user : user), [room, user]);
|
|
|
|
const isRoomEncrypted = useIsEncrypted(sdkContext.client!, room);
|
|
const devices = useDevices(user.userId) ?? [];
|
|
|
|
const classes = ["mx_UserInfo"];
|
|
|
|
const onEncryptionPanelClose = (): void => {
|
|
sdkContext.rightPanelStore.popCard();
|
|
};
|
|
|
|
let content: JSX.Element | undefined;
|
|
switch (phase) {
|
|
case RightPanelPhases.MemberInfo:
|
|
content = <UserInfoBasicView room={room as Room} member={member as User} />;
|
|
break;
|
|
case RightPanelPhases.EncryptionPanel:
|
|
classes.push("mx_UserInfo_smallAvatar");
|
|
content = (
|
|
<EncryptionPanel
|
|
{...(props as React.ComponentProps<typeof EncryptionPanel>)}
|
|
member={member as User | RoomMember}
|
|
onClose={onEncryptionPanelClose}
|
|
isRoomEncrypted={Boolean(isRoomEncrypted)}
|
|
/>
|
|
);
|
|
break;
|
|
}
|
|
|
|
let closeLabel: string | undefined;
|
|
if (phase === RightPanelPhases.EncryptionPanel) {
|
|
const verificationRequest = (props as React.ComponentProps<typeof EncryptionPanel>).verificationRequest;
|
|
if (verificationRequest && verificationRequest.pending) {
|
|
closeLabel = _t("action|cancel");
|
|
}
|
|
}
|
|
|
|
const header = (
|
|
<UserInfoHeaderView
|
|
hideVerificationSection={phase === RightPanelPhases.EncryptionPanel}
|
|
member={member}
|
|
devices={devices}
|
|
roomId={room?.roomId}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<BaseCard
|
|
className={classes.join(" ")}
|
|
header={_t("common|profile")}
|
|
onClose={onClose}
|
|
closeLabel={closeLabel}
|
|
onBack={(ev: ButtonEvent) => {
|
|
if (sdkContext.rightPanelStore.previousCard.phase === RightPanelPhases.MemberList) {
|
|
PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoBackButton", ev);
|
|
}
|
|
}}
|
|
>
|
|
{header}
|
|
{content}
|
|
</BaseCard>
|
|
);
|
|
};
|
|
|
|
export default UserInfo;
|