Skip to content

feat: mvvm userinfo basic component #30305

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
/*
Copyright 2025 New Vector 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, { useCallback, useContext, useEffect, useState } from "react";
import {
EventType,
RoomMember,
type IPowerLevelsContent,
type Room,
RoomStateEvent,
type MatrixClient,
type User,
type MatrixEvent,
KnownMembership,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";

import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
import { useTypedEventEmitter } from "../../../../hooks/useEventEmitter";
import Modal from "../../../../Modal";
import ErrorDialog from "../../../views/dialogs/ErrorDialog";
import { _t, UserFriendlyError } from "../../../../languageHandler";
import { type IRoomPermissions } from "../../../views/right_panel/UserInfo";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import QuestionDialog from "../../../views/dialogs/QuestionDialog";
import DMRoomMap from "../../../../utils/DMRoomMap";
import dis from "../../../../dispatcher/dispatcher";
import PosthogTrackers from "../../../../PosthogTrackers";
import { ShareDialog } from "../../../views/dialogs/ShareDialog";
import { type ComposerInsertPayload } from "../../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../../dispatcher/actions";
import { SdkContextClass } from "../../../../contexts/SDKContext";
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
import MultiInviter from "../../../../utils/MultiInviter";
import { type ViewRoomPayload } from "../../../../dispatcher/payloads/ViewRoomPayload";

export interface UserInfoBasicState {
powerLevels: IPowerLevelsContent;
roomPermissions: IRoomPermissions;
pendingUpdateCount: number;
isMe: boolean;
isRoomDMForMember: boolean;
showDeactivateButton: boolean;
showInviteButton: boolean;
readReceiptButtonDisabled: boolean;
showInsertPillButton: boolean | "";
onSynapseDeactivate: () => void;
startUpdating: () => void;
stopUpdating: () => void;
onShareUserClick: () => void;
onInviteUserButton: (evt: Event) => void;
onInsertPillButton: () => void;
onReadReceiptButton: () => void;
}

export const getPowerLevels = (room: Room): IPowerLevelsContent =>
room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {};

const useRoomPermissions = (cli: MatrixClient, room: Room, user: RoomMember): IRoomPermissions => {
const [roomPermissions, setRoomPermissions] = useState<IRoomPermissions>({
// modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL
modifyLevelMax: -1,
canEdit: false,
canInvite: false,
});

const updateRoomPermissions = useCallback(() => {
const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
if (!powerLevels) return;

const me = room.getMember(cli.getUserId() || "");
if (!me) return;

const them = user;
const isMe = me.userId === them.userId;
const canAffectUser = them.powerLevel < me.powerLevel || isMe;

let modifyLevelMax = -1;
if (canAffectUser) {
const editPowerLevel = powerLevels.events?.[EventType.RoomPowerLevels] ?? powerLevels.state_default ?? 50;
if (me.powerLevel >= editPowerLevel) {
modifyLevelMax = me.powerLevel;
}
}

setRoomPermissions({
canInvite: me.powerLevel >= (powerLevels.invite ?? 0),
canEdit: modifyLevelMax >= 0,
modifyLevelMax,
});
}, [cli, user, room]);

useTypedEventEmitter(cli, RoomStateEvent.Update, updateRoomPermissions);
useEffect(() => {
updateRoomPermissions();
return () => {
setRoomPermissions({
modifyLevelMax: -1,
canEdit: false,
canInvite: false,
});
};
}, [updateRoomPermissions]);

return roomPermissions;
};

const useIsSynapseAdmin = (cli?: MatrixClient): boolean => {
return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false);
};

export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsContent => {
const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>(getPowerLevels(room));

const update = useCallback(
(ev?: MatrixEvent) => {
if (!room) return;
if (ev && ev.getType() !== EventType.RoomPowerLevels) return;
setPowerLevels(getPowerLevels(room));
},
[room],
);

useTypedEventEmitter(cli, RoomStateEvent.Events, update);
useEffect(() => {
update();
return () => {
setPowerLevels({});
};
}, [update]);
return powerLevels;
};

export const useUserInfoBasicViewModel = (room: Room, member: User | RoomMember): UserInfoBasicState => {
const cli = useContext(MatrixClientContext);

const powerLevels = useRoomPowerLevels(cli, room);
// Load whether or not we are a Synapse Admin
const isSynapseAdmin = useIsSynapseAdmin(cli);

// Count of how many operations are currently in progress, if > 0 then show a Spinner
const [pendingUpdateCount, setPendingUpdateCount] = useState(0);

const roomPermissions = useRoomPermissions(cli, room, member as RoomMember);

const isSpace = room?.isSpaceRoom();

// selected member is current user
const isMe = member.userId === cli.getUserId();

// hide the Roles section for DMs as it doesn't make sense there
const isRoomDMForMember = !DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId);

// used to check if user can deactivate another member
const isMemberSameDomain = member.userId.endsWith(`:${cli.getDomain()}`);

// We don't need a perfect check here, just something to pass as "probably not our homeserver". If
// someone does figure out how to bypass this check the worst that happens is an error.
const showDeactivateButton = isSynapseAdmin && isMemberSameDomain;

const showInsertPillButton = member instanceof RoomMember && member.roomId && !isSpace;

const readReceiptButtonDisabled = isSpace || !room?.getEventReadUpTo(member.userId);

const roomId =
member instanceof RoomMember && member.roomId
? member.roomId
: SdkContextClass.instance.roomViewStore.getRoomId();

const showInviteButton =
member instanceof RoomMember &&
roomPermissions.canInvite &&
(member?.membership ?? KnownMembership.Leave) === KnownMembership.Leave;

const startUpdating = useCallback(() => {
setPendingUpdateCount(pendingUpdateCount + 1);
}, [pendingUpdateCount]);

const stopUpdating = useCallback(() => {
setPendingUpdateCount(pendingUpdateCount - 1);
}, [pendingUpdateCount]);

const onSynapseDeactivate = useCallback(async () => {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("user_info|deactivate_confirm_title"),
description: <div>{_t("user_info|deactivate_confirm_description")}</div>,
button: _t("user_info|deactivate_confirm_action"),
danger: true,
});

const [accepted] = await finished;
if (!accepted) return;
try {
await cli.deactivateSynapseUser(member.userId);
} catch (err) {
logger.error("Failed to deactivate user");
logger.error(err);

const description = err instanceof Error ? err.message : _t("invite|failed_generic");

Modal.createDialog(ErrorDialog, {
title: _t("user_info|error_deactivate"),
description,
});
}
}, [cli, member.userId]);

const onReadReceiptButton = function (): void {
const room = member instanceof RoomMember ? cli.getRoom(member.roomId) : null;
if (!room || readReceiptButtonDisabled) return;

dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
highlighted: true,
// this could return null, the default prevents a type error
event_id: room.getEventReadUpTo(member.userId) || undefined,
room_id: room.roomId,
metricsTrigger: undefined, // room doesn't change
});
};

const onInsertPillButton = function (): void {
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
userId: member.userId,
timelineRenderingType: TimelineRenderingType.Room,
});
};

const onInviteUserButton = async (ev: Event): Promise<void> => {
try {
// We use a MultiInviter to re-use the invite logic, even though we're only inviting one user.
const inviter = new MultiInviter(cli, roomId || "");
await inviter.invite([member.userId]).then(() => {
if (inviter.getCompletionState(member.userId) !== "invited") {
const errorStringFromInviterUtility = inviter.getErrorText(member.userId);
if (errorStringFromInviterUtility) {
throw new Error(errorStringFromInviterUtility);
} else {
throw new UserFriendlyError("slash_command|invite_failed", {
user: member.userId,
roomId,
cause: undefined,
});
}
}
});
} catch (err) {
const description = err instanceof Error ? err.message : _t("invite|failed_generic");

Modal.createDialog(ErrorDialog, {
title: _t("invite|failed_title"),
description,
});
}

PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoInviteButton", ev);
};

const onShareUserClick = (): void => {
Modal.createDialog(ShareDialog, {
target: member,
});
};

return {
showDeactivateButton,
powerLevels,
roomPermissions,
pendingUpdateCount,
isMe,
isRoomDMForMember,
showInviteButton,
readReceiptButtonDisabled,
showInsertPillButton,
onSynapseDeactivate,
startUpdating,
stopUpdating,
onShareUserClick,
onInviteUserButton,
onInsertPillButton,
onReadReceiptButton,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
Copyright 2025 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, { useContext, useEffect, useState, useCallback } from "react";
import { type RoomMember, User, ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";

import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
import QuestionDialog from "../../../views/dialogs/QuestionDialog";
import { useTypedEventEmitter } from "../../../../hooks/useEventEmitter";


export interface UserInfoPowerLevelState {
ignoreButtonClick: (ev: Event) => void;
isIgnored: boolean;
}

export const useUserInfoIgnoreButtonViewModel = (member: User | RoomMember): UserInfoPowerLevelState => {

const cli = useContext(MatrixClientContext);

const unignore = useCallback(() => {
const ignoredUsers = cli.getIgnoredUsers();
const index = ignoredUsers.indexOf(member.userId);
if (index !== -1) ignoredUsers.splice(index, 1);
cli.setIgnoredUsers(ignoredUsers);
}, [cli, member]);

const ignore = useCallback(async () => {
const name = (member instanceof User ? member.displayName : member.name) || member.userId;
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("user_info|ignore_confirm_title", { user: name }),
description: <div>{_t("user_info|ignore_confirm_description")}</div>,
button: _t("action|ignore"),
});
const [confirmed] = await finished;

if (confirmed) {
const ignoredUsers = cli.getIgnoredUsers();
ignoredUsers.push(member.userId);
cli.setIgnoredUsers(ignoredUsers);
}
}, [cli, member]);

// Check whether the user is ignored
const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(member.userId));
// Recheck if the user or client changes
useEffect(() => {
setIsIgnored(cli.isUserIgnored(member.userId));
}, [cli, member.userId]);

// Recheck also if we receive new accountData m.ignored_user_list
const accountDataHandler = useCallback(
(ev: MatrixEvent) => {
if (ev.getType() === "m.ignored_user_list") {
setIsIgnored(cli.isUserIgnored(member.userId));
}
},
[cli, member.userId],
);
useTypedEventEmitter(cli, ClientEvent.AccountData, accountDataHandler);

const ignoreButtonClick = (ev: Event): void => {
ev.preventDefault();
if (isIgnored) {
unignore();
} else {
ignore();
}
}

return {
ignoreButtonClick,
isIgnored,
};
};
Loading
Loading