From 9cff332671727cdc05c5e9aaf90a00942bc3f083 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Thu, 23 Jan 2025 23:03:53 +0000 Subject: [PATCH] web: implement user moderation actions (#588) Co-authored-by: Tulir Asokan --- pkg/hicli/json-commands.go | 22 +++ web/src/api/rpc.ts | 5 + web/src/api/types/hitypes.ts | 2 + web/src/api/types/mxtypes.ts | 4 + web/src/icons/block.svg | 1 + web/src/icons/gavel.svg | 1 + web/src/icons/person-add.svg | 1 + web/src/icons/person-remove.svg | 1 + web/src/ui/rightpanel/RightPanel.css | 19 +++ web/src/ui/rightpanel/UserInfo.tsx | 3 + web/src/ui/rightpanel/UserModeration.tsx | 147 ++++++++++++++++++ .../timeline/menu/ConfirmWithMessageModal.tsx | 10 +- 12 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 web/src/icons/block.svg create mode 100644 web/src/icons/gavel.svg create mode 100644 web/src/icons/person-add.svg create mode 100644 web/src/icons/person-remove.svg create mode 100644 web/src/ui/rightpanel/UserModeration.tsx diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index dca1ea7..ea7c70f 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -66,6 +66,21 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) { return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content) }) + case "set_membership": + return unmarshalAndCall(req.Data, func(params *setMembershipParams) (any, error) { + switch params.Action { + case "invite": + return h.Client.InviteUser(ctx, params.RoomID, &mautrix.ReqInviteUser{UserID: params.UserID, Reason: params.Reason}) + case "kick": + return h.Client.KickUser(ctx, params.RoomID, &mautrix.ReqKickUser{UserID: params.UserID, Reason: params.Reason}) + case "ban": + return h.Client.BanUser(ctx, params.RoomID, &mautrix.ReqBanUser{UserID: params.UserID, Reason: params.Reason}) + case "unban": + return h.Client.UnbanUser(ctx, params.RoomID, &mautrix.ReqUnbanUser{UserID: params.UserID, Reason: params.Reason}) + default: + return nil, fmt.Errorf("unknown action %q", params.Action) + } + }) case "set_account_data": return unmarshalAndCall(req.Data, func(params *setAccountDataParams) (bool, error) { if params.RoomID != "" { @@ -262,6 +277,13 @@ type sendStateEventParams struct { Content json.RawMessage `json:"content"` } +type setMembershipParams struct { + Action string `json:"action"` + RoomID id.RoomID `json:"room_id"` + UserID id.UserID `json:"user_id"` + Reason string `json:"reason"` +} + type setAccountDataParams struct { RoomID id.RoomID `json:"room_id,omitempty"` Type string `json:"type"` diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index 95fb019..525c373 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -24,6 +24,7 @@ import type { JSONValue, LoginFlowsResponse, LoginRequest, + MembershipAction, Mentions, MessageEventContent, PaginationResponse, @@ -167,6 +168,10 @@ export default abstract class RPCClient { return this.request("set_state", { room_id, type, state_key, content }) } + setMembership(room_id: RoomID, user_id: UserID, action: MembershipAction, reason?: string): Promise { + return this.request("set_membership", { room_id, user_id, action, reason }) + } + setAccountData(type: EventType, content: unknown, room_id?: RoomID): Promise { return this.request("set_account_data", { type, content, room_id }) } diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index c7489cd..7defc86 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -292,3 +292,5 @@ export interface DBPushRegistration { encryption: { key: string } expiration?: number } + +export type MembershipAction = "invite" | "kick" | "ban" | "unban" diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 51a22d4..94059e5 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -218,6 +218,10 @@ export interface ReactionEventContent { "com.beeper.reaction.shortcode"?: string } +export interface IgnoredUsersEventContent { + ignored_users: Record +} + export interface EncryptedFile { url: ContentURI k: string diff --git a/web/src/icons/block.svg b/web/src/icons/block.svg new file mode 100644 index 0000000..01b6a9e --- /dev/null +++ b/web/src/icons/block.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/gavel.svg b/web/src/icons/gavel.svg new file mode 100644 index 0000000..78195c7 --- /dev/null +++ b/web/src/icons/gavel.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/person-add.svg b/web/src/icons/person-add.svg new file mode 100644 index 0000000..a6dc833 --- /dev/null +++ b/web/src/icons/person-add.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/person-remove.svg b/web/src/icons/person-remove.svg new file mode 100644 index 0000000..1e6b59b --- /dev/null +++ b/web/src/icons/person-remove.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/rightpanel/RightPanel.css b/web/src/ui/rightpanel/RightPanel.css index 50a3c44..ef7138a 100644 --- a/web/src/ui/rightpanel/RightPanel.css +++ b/web/src/ui/rightpanel/RightPanel.css @@ -192,6 +192,25 @@ div.right-panel-content.user { } } + div.user-moderation { + display: flex; + flex-direction: column; + + button.moderation-action { + padding: .5rem; + width: 100%; + gap: .5rem; + justify-content: left; + + &.dangerous { + color: var(--error-color); + } + &.positive { + color: var(--primary-color); + } + } + } + div.errors { display: flex; flex-direction: column; diff --git a/web/src/ui/rightpanel/UserInfo.tsx b/web/src/ui/rightpanel/UserInfo.tsx index 0181904..17823d1 100644 --- a/web/src/ui/rightpanel/UserInfo.tsx +++ b/web/src/ui/rightpanel/UserInfo.tsx @@ -26,6 +26,7 @@ import UserExtendedProfile from "./UserExtendedProfile.tsx" import DeviceList from "./UserInfoDeviceList.tsx" import UserInfoError from "./UserInfoError.tsx" import MutualRooms from "./UserInfoMutualRooms.tsx" +import UserModeration from "./UserModeration.tsx" interface UserInfoProps { userID: UserID @@ -77,6 +78,8 @@ const UserInfo = ({ userID }: UserInfoProps) => { }
+ +
{errors?.length ? <>
diff --git a/web/src/ui/rightpanel/UserModeration.tsx b/web/src/ui/rightpanel/UserModeration.tsx new file mode 100644 index 0000000..d5133a6 --- /dev/null +++ b/web/src/ui/rightpanel/UserModeration.tsx @@ -0,0 +1,147 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2025 Nexus Nicholson +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { use } from "react" +import Client from "@/api/client.ts" +import { RoomStateStore, useAccountData } from "@/api/statestore" +import { IgnoredUsersEventContent, MemDBEvent, MembershipAction } from "@/api/types" +import { ModalContext } from "../modal" +import ConfirmWithMessageModal from "../timeline/menu/ConfirmWithMessageModal.tsx" +import { getPowerLevels } from "../timeline/menu/util.ts" +import IgnoreIcon from "@/icons/block.svg?react" +import BanIcon from "@/icons/gavel.svg?react" +import InviteIcon from "@/icons/person-add.svg?react" +import KickIcon from "@/icons/person-remove.svg?react" + +interface UserModerationProps { + userID: string; + client: Client; + room: RoomStateStore | undefined; + member: MemDBEvent | null; +} + +const UserIgnoreButton = ({ userID, client }: { userID: string; client: Client }) => { + const ignoredUsers = useAccountData(client.store, "m.ignored_user_list") as IgnoredUsersEventContent | null + + const isIgnored = Boolean(ignoredUsers?.ignored_users?.[userID]) + const ignoreUser = () => { + const newIgnoredUsers = { ...(ignoredUsers || { ignored_users: {}}) } + newIgnoredUsers.ignored_users[userID] = {} + client.rpc.setAccountData("m.ignored_user_list", newIgnoredUsers).catch(err => { + console.error("Failed to ignore user", err) + window.alert(`Failed to ignore ${userID}: ${err}`) + }) + } + const unignoreUser = () => { + const newIgnoredUsers = { ...(ignoredUsers || { ignored_users: {}}) } + delete newIgnoredUsers.ignored_users[userID] + client.rpc.setAccountData("m.ignored_user_list", newIgnoredUsers).catch(err => { + console.error("Failed to unignore user", err) + window.alert(`Failed to unignore ${userID}: ${err}`) + }) + } + + return ( + + ) +} + +const UserModeration = ({ userID, client, member, room }: UserModerationProps) => { + const openModal = use(ModalContext) + const hasPL = (action: "invite" | "kick" | "ban") => { + if (!room) { + throw new Error("hasPL called without room") + } + const [pls, ownPL] = getPowerLevels(room, client) + if(action === "invite") { + return ownPL >= (pls.invite ?? 0) + } + const otherUserPL = pls.users?.[userID] ?? pls.users_default ?? 0 + return ownPL >= (pls[action] ?? 50) && ownPL > otherUserPL + } + + const runAction = (action: MembershipAction) => { + if (!room) { + throw new Error("runAction called without room") + } + const callback = (reason: string) => { + client.rpc.setMembership(room.roomID, userID, action, reason).then( + () => console.debug("Actioned", userID), + err => { + console.error("Failed to action", err) + window.alert(`Failed to ${action} ${userID}: ${err}`) + }, + ) + } + const titleCasedAction = action.charAt(0).toUpperCase() + action.slice(1) + return () => { + openModal({ + dimmed: true, + boxed: true, + innerBoxClass: "confirm-message-modal", + content: Are you sure you want to {action} {userID}?} + placeholder="Reason (optional)" + confirmButton={titleCasedAction} + onConfirm={callback} + />, + }) + } + } + const membership = member?.content.membership || "leave" + + return
+

Moderation

+ {room && (["knock", "leave"].includes(membership) || !member) && hasPL("invite") && ( + + )} + {room && ["knock", "invite", "join"].includes(membership) && hasPL("kick") && ( + + )} + {room && membership !== "ban" && hasPL("ban") && ( + + )} + {room && membership === "ban" && hasPL("ban") && ( + + )} + +
+} + +export default UserModeration diff --git a/web/src/ui/timeline/menu/ConfirmWithMessageModal.tsx b/web/src/ui/timeline/menu/ConfirmWithMessageModal.tsx index 9028a32..05d11e1 100644 --- a/web/src/ui/timeline/menu/ConfirmWithMessageModal.tsx +++ b/web/src/ui/timeline/menu/ConfirmWithMessageModal.tsx @@ -13,16 +13,16 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { use, useState } from "react" +import React, { JSX, use, useState } from "react" import { MemDBEvent } from "@/api/types" import { isMobileDevice } from "@/util/ismobile.ts" import { ModalCloseContext } from "../../modal" import TimelineEvent from "../TimelineEvent.tsx" interface ConfirmWithMessageProps { - evt: MemDBEvent + evt?: MemDBEvent title: string - description: string + description: string | JSX.Element placeholder: string confirmButton: string onConfirm: (reason: string) => void @@ -40,9 +40,9 @@ const ConfirmWithMessageModal = ({ } return

{title}

-
+ {evt &&
-
+
}
{description}