web: implement user moderation actions (#588)

Co-authored-by: Tulir Asokan <tulir@maunium.net>
This commit is contained in:
nexy7574 2025-01-23 23:03:53 +00:00 committed by GitHub
parent 4649689b72
commit 9cff332671
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 211 additions and 5 deletions

View file

@ -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 unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) {
return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content) 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": case "set_account_data":
return unmarshalAndCall(req.Data, func(params *setAccountDataParams) (bool, error) { return unmarshalAndCall(req.Data, func(params *setAccountDataParams) (bool, error) {
if params.RoomID != "" { if params.RoomID != "" {
@ -262,6 +277,13 @@ type sendStateEventParams struct {
Content json.RawMessage `json:"content"` 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 { type setAccountDataParams struct {
RoomID id.RoomID `json:"room_id,omitempty"` RoomID id.RoomID `json:"room_id,omitempty"`
Type string `json:"type"` Type string `json:"type"`

View file

@ -24,6 +24,7 @@ import type {
JSONValue, JSONValue,
LoginFlowsResponse, LoginFlowsResponse,
LoginRequest, LoginRequest,
MembershipAction,
Mentions, Mentions,
MessageEventContent, MessageEventContent,
PaginationResponse, PaginationResponse,
@ -167,6 +168,10 @@ export default abstract class RPCClient {
return this.request("set_state", { room_id, type, state_key, content }) return this.request("set_state", { room_id, type, state_key, content })
} }
setMembership(room_id: RoomID, user_id: UserID, action: MembershipAction, reason?: string): Promise<void> {
return this.request("set_membership", { room_id, user_id, action, reason })
}
setAccountData(type: EventType, content: unknown, room_id?: RoomID): Promise<boolean> { setAccountData(type: EventType, content: unknown, room_id?: RoomID): Promise<boolean> {
return this.request("set_account_data", { type, content, room_id }) return this.request("set_account_data", { type, content, room_id })
} }

View file

@ -292,3 +292,5 @@ export interface DBPushRegistration {
encryption: { key: string } encryption: { key: string }
expiration?: number expiration?: number
} }
export type MembershipAction = "invite" | "kick" | "ban" | "unban"

View file

@ -218,6 +218,10 @@ export interface ReactionEventContent {
"com.beeper.reaction.shortcode"?: string "com.beeper.reaction.shortcode"?: string
} }
export interface IgnoredUsersEventContent {
ignored_users: Record<string, unknown>
}
export interface EncryptedFile { export interface EncryptedFile {
url: ContentURI url: ContentURI
k: string k: string

1
web/src/icons/block.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q54 0 104-17.5t92-50.5L228-676q-33 42-50.5 92T160-480q0 134 93 227t227 93Zm252-124q33-42 50.5-92T800-480q0-134-93-227t-227-93q-54 0-104 17.5T284-732l448 448Z"/></svg>

After

Width:  |  Height:  |  Size: 477 B

1
web/src/icons/gavel.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M160-120v-80h480v80H160Zm226-194L160-540l84-86 228 226-86 86Zm254-254L414-796l86-84 226 226-86 86Zm184 408L302-682l56-56 522 522-56 56Z"/></svg>

After

Width:  |  Height:  |  Size: 261 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M720-400v-120H600v-80h120v-120h80v120h120v80H800v120h-80Zm-360-80q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM40-160v-112q0-34 17.5-62.5T104-378q62-31 126-46.5T360-440q66 0 130 15.5T616-378q29 15 46.5 43.5T680-272v112H40Zm80-80h480v-32q0-11-5.5-20T580-306q-54-27-109-40.5T360-360q-56 0-111 13.5T140-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T440-640q0-33-23.5-56.5T360-720q-33 0-56.5 23.5T280-640q0 33 23.5 56.5T360-560Zm0-80Zm0 400Z"/></svg>

After

Width:  |  Height:  |  Size: 604 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M640-520v-80h240v80H640Zm-280 40q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM40-160v-112q0-34 17.5-62.5T104-378q62-31 126-46.5T360-440q66 0 130 15.5T616-378q29 15 46.5 43.5T680-272v112H40Zm80-80h480v-32q0-11-5.5-20T580-306q-54-27-109-40.5T360-360q-56 0-111 13.5T140-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T440-640q0-33-23.5-56.5T360-720q-33 0-56.5 23.5T280-640q0 33 23.5 56.5T360-560Zm0-80Zm0 400Z"/></svg>

After

Width:  |  Height:  |  Size: 571 B

View file

@ -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 { div.errors {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -26,6 +26,7 @@ import UserExtendedProfile from "./UserExtendedProfile.tsx"
import DeviceList from "./UserInfoDeviceList.tsx" import DeviceList from "./UserInfoDeviceList.tsx"
import UserInfoError from "./UserInfoError.tsx" import UserInfoError from "./UserInfoError.tsx"
import MutualRooms from "./UserInfoMutualRooms.tsx" import MutualRooms from "./UserInfoMutualRooms.tsx"
import UserModeration from "./UserModeration.tsx"
interface UserInfoProps { interface UserInfoProps {
userID: UserID userID: UserID
@ -77,6 +78,8 @@ const UserInfo = ({ userID }: UserInfoProps) => {
</>} </>}
<DeviceList client={client} room={roomCtx?.store} userID={userID}/> <DeviceList client={client} room={roomCtx?.store} userID={userID}/>
<hr/> <hr/>
<UserModeration client={client} room={roomCtx?.store} member={memberEvt} userID={userID}/>
<hr/>
{errors?.length ? <> {errors?.length ? <>
<UserInfoError errors={errors}/> <UserInfoError errors={errors}/>
<hr/> <hr/>

View file

@ -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 <https://www.gnu.org/licenses/>.
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 (
<button
className={"moderation-action " + (isIgnored ? "positive" : "dangerous")}
onClick={isIgnored ? unignoreUser : ignoreUser}>
<IgnoreIcon/>
<span>{isIgnored ? "Unignore" : "Ignore"}</span>
</button>
)
}
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: <ConfirmWithMessageModal
title={`${titleCasedAction} user`}
description={<>Are you sure you want to {action} <code>{userID}</code>?</>}
placeholder="Reason (optional)"
confirmButton={titleCasedAction}
onConfirm={callback}
/>,
})
}
}
const membership = member?.content.membership || "leave"
return <div className="user-moderation">
<h4>Moderation</h4>
{room && (["knock", "leave"].includes(membership) || !member) && hasPL("invite") && (
<button className="moderation-action positive" onClick={runAction("invite")}>
<InviteIcon />
<span>{membership === "knock" ? "Accept join request" : "Invite"}</span>
</button>
)}
{room && ["knock", "invite", "join"].includes(membership) && hasPL("kick") && (
<button className="moderation-action dangerous" onClick={runAction("kick")}>
<KickIcon />
<span>{
membership === "join"
? "Kick"
: membership === "invite"
? "Revoke invitation"
: "Reject join request"
}</span>
</button>
)}
{room && membership !== "ban" && hasPL("ban") && (
<button className="moderation-action dangerous" onClick={runAction("ban")}>
<BanIcon />
<span>Ban</span>
</button>
)}
{room && membership === "ban" && hasPL("ban") && (
<button className="moderation-action positive" onClick={runAction("unban")}>
<BanIcon />
<span>Unban</span>
</button>
)}
<UserIgnoreButton userID={userID} client={client} />
</div>
}
export default UserModeration

View file

@ -13,16 +13,16 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useState } from "react" import React, { JSX, use, useState } from "react"
import { MemDBEvent } from "@/api/types" import { MemDBEvent } from "@/api/types"
import { isMobileDevice } from "@/util/ismobile.ts" import { isMobileDevice } from "@/util/ismobile.ts"
import { ModalCloseContext } from "../../modal" import { ModalCloseContext } from "../../modal"
import TimelineEvent from "../TimelineEvent.tsx" import TimelineEvent from "../TimelineEvent.tsx"
interface ConfirmWithMessageProps { interface ConfirmWithMessageProps {
evt: MemDBEvent evt?: MemDBEvent
title: string title: string
description: string description: string | JSX.Element
placeholder: string placeholder: string
confirmButton: string confirmButton: string
onConfirm: (reason: string) => void onConfirm: (reason: string) => void
@ -40,9 +40,9 @@ const ConfirmWithMessageModal = ({
} }
return <form onSubmit={onConfirmWrapped}> return <form onSubmit={onConfirmWrapped}>
<h3>{title}</h3> <h3>{title}</h3>
<div className="timeline-event-container"> {evt && <div className="timeline-event-container">
<TimelineEvent evt={evt} prevEvt={null} disableMenu={true} /> <TimelineEvent evt={evt} prevEvt={null} disableMenu={true} />
</div> </div>}
<div className="confirm-description"> <div className="confirm-description">
{description} {description}
</div> </div>