Skip to content

Commit a681705

Browse files
committed
feat: mvvm userinfo basic component
1 parent a05ca97 commit a681705

File tree

5 files changed

+627
-484
lines changed

5 files changed

+627
-484
lines changed
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import React, { useCallback, useContext, useEffect, useState } from "react";
9+
import {
10+
EventType,
11+
RoomMember,
12+
type IPowerLevelsContent,
13+
type Room,
14+
RoomStateEvent,
15+
type MatrixClient,
16+
type User,
17+
type MatrixEvent,
18+
KnownMembership,
19+
} from "matrix-js-sdk/src/matrix";
20+
import { logger } from "matrix-js-sdk/src/logger";
21+
22+
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
23+
import { useTypedEventEmitter } from "../../../../hooks/useEventEmitter";
24+
import Modal from "../../../../Modal";
25+
import ErrorDialog from "../../../views/dialogs/ErrorDialog";
26+
import { _t, UserFriendlyError } from "../../../../languageHandler";
27+
import { type IRoomPermissions } from "../../../views/right_panel/UserInfo";
28+
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
29+
import QuestionDialog from "../../../views/dialogs/QuestionDialog";
30+
import DMRoomMap from "../../../../utils/DMRoomMap";
31+
import dis from "../../../../dispatcher/dispatcher";
32+
import PosthogTrackers from "../../../../PosthogTrackers";
33+
import { ShareDialog } from "../../../views/dialogs/ShareDialog";
34+
import { type ComposerInsertPayload } from "../../../../dispatcher/payloads/ComposerInsertPayload";
35+
import { Action } from "../../../../dispatcher/actions";
36+
import { SdkContextClass } from "../../../../contexts/SDKContext";
37+
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
38+
import MultiInviter from "../../../../utils/MultiInviter";
39+
import { type ViewRoomPayload } from "../../../../dispatcher/payloads/ViewRoomPayload";
40+
41+
export interface UserInfoBasicState {
42+
powerLevels: IPowerLevelsContent;
43+
roomPermissions: IRoomPermissions;
44+
pendingUpdateCount: number;
45+
isMe: boolean;
46+
isRoomDMForMember: boolean;
47+
showDeactivateButton: boolean;
48+
showInviteButton: boolean;
49+
readReceiptButtonDisabled: boolean;
50+
showInsertPillButton: boolean | "";
51+
onSynapseDeactivate: () => void;
52+
startUpdating: () => void;
53+
stopUpdating: () => void;
54+
onShareUserClick: () => void;
55+
onInviteUserButton: (evt: Event) => void;
56+
onInsertPillButton: () => void;
57+
onReadReceiptButton: () => void;
58+
}
59+
60+
export const getPowerLevels = (room: Room): IPowerLevelsContent =>
61+
room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {};
62+
63+
const useRoomPermissions = (cli: MatrixClient, room: Room, user: RoomMember): IRoomPermissions => {
64+
const [roomPermissions, setRoomPermissions] = useState<IRoomPermissions>({
65+
// modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL
66+
modifyLevelMax: -1,
67+
canEdit: false,
68+
canInvite: false,
69+
});
70+
71+
const updateRoomPermissions = useCallback(() => {
72+
const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
73+
if (!powerLevels) return;
74+
75+
const me = room.getMember(cli.getUserId() || "");
76+
if (!me) return;
77+
78+
const them = user;
79+
const isMe = me.userId === them.userId;
80+
const canAffectUser = them.powerLevel < me.powerLevel || isMe;
81+
82+
let modifyLevelMax = -1;
83+
if (canAffectUser) {
84+
const editPowerLevel = powerLevels.events?.[EventType.RoomPowerLevels] ?? powerLevels.state_default ?? 50;
85+
if (me.powerLevel >= editPowerLevel) {
86+
modifyLevelMax = me.powerLevel;
87+
}
88+
}
89+
90+
setRoomPermissions({
91+
canInvite: me.powerLevel >= (powerLevels.invite ?? 0),
92+
canEdit: modifyLevelMax >= 0,
93+
modifyLevelMax,
94+
});
95+
}, [cli, user, room]);
96+
97+
useTypedEventEmitter(cli, RoomStateEvent.Update, updateRoomPermissions);
98+
useEffect(() => {
99+
updateRoomPermissions();
100+
return () => {
101+
setRoomPermissions({
102+
modifyLevelMax: -1,
103+
canEdit: false,
104+
canInvite: false,
105+
});
106+
};
107+
}, [updateRoomPermissions]);
108+
109+
return roomPermissions;
110+
};
111+
112+
const useIsSynapseAdmin = (cli?: MatrixClient): boolean => {
113+
return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false);
114+
};
115+
116+
export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsContent => {
117+
const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>(getPowerLevels(room));
118+
119+
const update = useCallback(
120+
(ev?: MatrixEvent) => {
121+
if (!room) return;
122+
if (ev && ev.getType() !== EventType.RoomPowerLevels) return;
123+
setPowerLevels(getPowerLevels(room));
124+
},
125+
[room],
126+
);
127+
128+
useTypedEventEmitter(cli, RoomStateEvent.Events, update);
129+
useEffect(() => {
130+
update();
131+
return () => {
132+
setPowerLevels({});
133+
};
134+
}, [update]);
135+
return powerLevels;
136+
};
137+
138+
export const useUserInfoBasicViewModel = (room: Room, member: User | RoomMember): UserInfoBasicState => {
139+
const cli = useContext(MatrixClientContext);
140+
141+
const powerLevels = useRoomPowerLevels(cli, room);
142+
// Load whether or not we are a Synapse Admin
143+
const isSynapseAdmin = useIsSynapseAdmin(cli);
144+
145+
// Count of how many operations are currently in progress, if > 0 then show a Spinner
146+
const [pendingUpdateCount, setPendingUpdateCount] = useState(0);
147+
148+
const roomPermissions = useRoomPermissions(cli, room, member as RoomMember);
149+
150+
const isSpace = room?.isSpaceRoom();
151+
152+
// selected member is current user
153+
const isMe = member.userId === cli.getUserId();
154+
155+
// hide the Roles section for DMs as it doesn't make sense there
156+
const isRoomDMForMember = !DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId);
157+
158+
// used to check if user can deactivate another member
159+
const isMemberSameDomain = member.userId.endsWith(`:${cli.getDomain()}`);
160+
161+
// We don't need a perfect check here, just something to pass as "probably not our homeserver". If
162+
// someone does figure out how to bypass this check the worst that happens is an error.
163+
const showDeactivateButton = isSynapseAdmin && isMemberSameDomain;
164+
165+
const showInsertPillButton = member instanceof RoomMember && member.roomId && !isSpace;
166+
167+
const readReceiptButtonDisabled = isSpace || !room?.getEventReadUpTo(member.userId);
168+
169+
const roomId =
170+
member instanceof RoomMember && member.roomId
171+
? member.roomId
172+
: SdkContextClass.instance.roomViewStore.getRoomId();
173+
174+
const showInviteButton =
175+
member instanceof RoomMember &&
176+
roomPermissions.canInvite &&
177+
(member?.membership ?? KnownMembership.Leave) === KnownMembership.Leave;
178+
179+
const startUpdating = useCallback(() => {
180+
setPendingUpdateCount(pendingUpdateCount + 1);
181+
}, [pendingUpdateCount]);
182+
183+
const stopUpdating = useCallback(() => {
184+
setPendingUpdateCount(pendingUpdateCount - 1);
185+
}, [pendingUpdateCount]);
186+
187+
const onSynapseDeactivate = useCallback(async () => {
188+
const { finished } = Modal.createDialog(QuestionDialog, {
189+
title: _t("user_info|deactivate_confirm_title"),
190+
description: <div>{_t("user_info|deactivate_confirm_description")}</div>,
191+
button: _t("user_info|deactivate_confirm_action"),
192+
danger: true,
193+
});
194+
195+
const [accepted] = await finished;
196+
if (!accepted) return;
197+
try {
198+
await cli.deactivateSynapseUser(member.userId);
199+
} catch (err) {
200+
logger.error("Failed to deactivate user");
201+
logger.error(err);
202+
203+
const description = err instanceof Error ? err.message : _t("invite|failed_generic");
204+
205+
Modal.createDialog(ErrorDialog, {
206+
title: _t("user_info|error_deactivate"),
207+
description,
208+
});
209+
}
210+
}, [cli, member.userId]);
211+
212+
const onReadReceiptButton = function (): void {
213+
const room = member instanceof RoomMember ? cli.getRoom(member.roomId) : null;
214+
if (!room || readReceiptButtonDisabled) return;
215+
216+
dis.dispatch<ViewRoomPayload>({
217+
action: Action.ViewRoom,
218+
highlighted: true,
219+
// this could return null, the default prevents a type error
220+
event_id: room.getEventReadUpTo(member.userId) || undefined,
221+
room_id: room.roomId,
222+
metricsTrigger: undefined, // room doesn't change
223+
});
224+
};
225+
226+
const onInsertPillButton = function (): void {
227+
dis.dispatch<ComposerInsertPayload>({
228+
action: Action.ComposerInsert,
229+
userId: member.userId,
230+
timelineRenderingType: TimelineRenderingType.Room,
231+
});
232+
};
233+
234+
const onInviteUserButton = async (ev: Event): Promise<void> => {
235+
try {
236+
// We use a MultiInviter to re-use the invite logic, even though we're only inviting one user.
237+
const inviter = new MultiInviter(cli, roomId || "");
238+
await inviter.invite([member.userId]).then(() => {
239+
if (inviter.getCompletionState(member.userId) !== "invited") {
240+
const errorStringFromInviterUtility = inviter.getErrorText(member.userId);
241+
if (errorStringFromInviterUtility) {
242+
throw new Error(errorStringFromInviterUtility);
243+
} else {
244+
throw new UserFriendlyError("slash_command|invite_failed", {
245+
user: member.userId,
246+
roomId,
247+
cause: undefined,
248+
});
249+
}
250+
}
251+
});
252+
} catch (err) {
253+
const description = err instanceof Error ? err.message : _t("invite|failed_generic");
254+
255+
Modal.createDialog(ErrorDialog, {
256+
title: _t("invite|failed_title"),
257+
description,
258+
});
259+
}
260+
261+
PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoInviteButton", ev);
262+
};
263+
264+
const onShareUserClick = (): void => {
265+
Modal.createDialog(ShareDialog, {
266+
target: member,
267+
});
268+
};
269+
270+
return {
271+
showDeactivateButton,
272+
powerLevels,
273+
roomPermissions,
274+
pendingUpdateCount,
275+
isMe,
276+
isRoomDMForMember,
277+
showInviteButton,
278+
readReceiptButtonDisabled,
279+
showInsertPillButton,
280+
onSynapseDeactivate,
281+
startUpdating,
282+
stopUpdating,
283+
onShareUserClick,
284+
onInviteUserButton,
285+
onInsertPillButton,
286+
onReadReceiptButton,
287+
};
288+
};
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
4+
Please see LICENSE files in the repository root for full details.
5+
*/
6+
7+
import React, { useContext, useEffect, useState, useCallback } from "react";
8+
import { type RoomMember, User, ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
9+
10+
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
11+
import { _t } from "../../../../languageHandler";
12+
import Modal from "../../../../Modal";
13+
import QuestionDialog from "../../../views/dialogs/QuestionDialog";
14+
import { useTypedEventEmitter } from "../../../../hooks/useEventEmitter";
15+
16+
17+
export interface UserInfoPowerLevelState {
18+
ignoreButtonClick: (ev: Event) => void;
19+
isIgnored: boolean;
20+
}
21+
22+
export const useUserInfoIgnoreButtonViewModel = (member: User | RoomMember): UserInfoPowerLevelState => {
23+
24+
const cli = useContext(MatrixClientContext);
25+
26+
const unignore = useCallback(() => {
27+
const ignoredUsers = cli.getIgnoredUsers();
28+
const index = ignoredUsers.indexOf(member.userId);
29+
if (index !== -1) ignoredUsers.splice(index, 1);
30+
cli.setIgnoredUsers(ignoredUsers);
31+
}, [cli, member]);
32+
33+
const ignore = useCallback(async () => {
34+
const name = (member instanceof User ? member.displayName : member.name) || member.userId;
35+
const { finished } = Modal.createDialog(QuestionDialog, {
36+
title: _t("user_info|ignore_confirm_title", { user: name }),
37+
description: <div>{_t("user_info|ignore_confirm_description")}</div>,
38+
button: _t("action|ignore"),
39+
});
40+
const [confirmed] = await finished;
41+
42+
if (confirmed) {
43+
const ignoredUsers = cli.getIgnoredUsers();
44+
ignoredUsers.push(member.userId);
45+
cli.setIgnoredUsers(ignoredUsers);
46+
}
47+
}, [cli, member]);
48+
49+
// Check whether the user is ignored
50+
const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(member.userId));
51+
// Recheck if the user or client changes
52+
useEffect(() => {
53+
setIsIgnored(cli.isUserIgnored(member.userId));
54+
}, [cli, member.userId]);
55+
56+
// Recheck also if we receive new accountData m.ignored_user_list
57+
const accountDataHandler = useCallback(
58+
(ev: MatrixEvent) => {
59+
if (ev.getType() === "m.ignored_user_list") {
60+
setIsIgnored(cli.isUserIgnored(member.userId));
61+
}
62+
},
63+
[cli, member.userId],
64+
);
65+
useTypedEventEmitter(cli, ClientEvent.AccountData, accountDataHandler);
66+
67+
const ignoreButtonClick = (ev: Event): void => {
68+
ev.preventDefault();
69+
if (isIgnored) {
70+
unignore();
71+
} else {
72+
ignore();
73+
}
74+
}
75+
76+
return {
77+
ignoreButtonClick,
78+
isIgnored,
79+
};
80+
};

0 commit comments

Comments
 (0)