mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
web: implement user moderation actions (#588)
Co-authored-by: Tulir Asokan <tulir@maunium.net>
This commit is contained in:
parent
4649689b72
commit
9cff332671
12 changed files with 211 additions and 5 deletions
|
@ -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"`
|
||||
|
|
|
@ -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<void> {
|
||||
return this.request("set_membership", { room_id, user_id, action, reason })
|
||||
}
|
||||
|
||||
setAccountData(type: EventType, content: unknown, room_id?: RoomID): Promise<boolean> {
|
||||
return this.request("set_account_data", { type, content, room_id })
|
||||
}
|
||||
|
|
|
@ -292,3 +292,5 @@ export interface DBPushRegistration {
|
|||
encryption: { key: string }
|
||||
expiration?: number
|
||||
}
|
||||
|
||||
export type MembershipAction = "invite" | "kick" | "ban" | "unban"
|
||||
|
|
|
@ -218,6 +218,10 @@ export interface ReactionEventContent {
|
|||
"com.beeper.reaction.shortcode"?: string
|
||||
}
|
||||
|
||||
export interface IgnoredUsersEventContent {
|
||||
ignored_users: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface EncryptedFile {
|
||||
url: ContentURI
|
||||
k: string
|
||||
|
|
1
web/src/icons/block.svg
Normal file
1
web/src/icons/block.svg
Normal 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
1
web/src/icons/gavel.svg
Normal 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 |
1
web/src/icons/person-add.svg
Normal file
1
web/src/icons/person-add.svg
Normal 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 |
1
web/src/icons/person-remove.svg
Normal file
1
web/src/icons/person-remove.svg
Normal 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 |
|
@ -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;
|
||||
|
|
|
@ -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) => {
|
|||
</>}
|
||||
<DeviceList client={client} room={roomCtx?.store} userID={userID}/>
|
||||
<hr/>
|
||||
<UserModeration client={client} room={roomCtx?.store} member={memberEvt} userID={userID}/>
|
||||
<hr/>
|
||||
{errors?.length ? <>
|
||||
<UserInfoError errors={errors}/>
|
||||
<hr/>
|
||||
|
|
147
web/src/ui/rightpanel/UserModeration.tsx
Normal file
147
web/src/ui/rightpanel/UserModeration.tsx
Normal 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
|
|
@ -13,16 +13,16 @@
|
|||
//
|
||||
// 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 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 <form onSubmit={onConfirmWrapped}>
|
||||
<h3>{title}</h3>
|
||||
<div className="timeline-event-container">
|
||||
{evt && <div className="timeline-event-container">
|
||||
<TimelineEvent evt={evt} prevEvt={null} disableMenu={true} />
|
||||
</div>
|
||||
</div>}
|
||||
<div className="confirm-description">
|
||||
{description}
|
||||
</div>
|
||||
|
|
Loading…
Add table
Reference in a new issue