Preact: Redesign user moderation buttons

Supersedes #2662
This commit is contained in:
Guangcong Luo
2026-07-22 07:09:57 +00:00
parent 38c0608e30
commit 2d2a39387a
4 changed files with 146 additions and 200 deletions

View File

@@ -2655,7 +2655,7 @@ export const PS = new class extends PSModel {
otherButtons?: preact.ComponentChildren, parentElem?: HTMLElement,
} = {}) {
opts.cancelButton ??= 'Cancel';
return new Promise(resolve => {
return new Promise<boolean>(resolve => {
this.join(`popup-${this.popups.length}` as RoomID, {
args: { message, okValue: true, cancelValue: false, callback: resolve, ...opts, parentElem: null },
parentElem: opts.parentElem,
@@ -2664,14 +2664,14 @@ export const PS = new class extends PSModel {
}
prompt(message: string, opts: {
defaultValue?: string, okButton?: string, cancelButton?: string, type?: 'text' | 'password' | 'number' | 'numeric',
otherButtons?: preact.ComponentChildren, parentElem?: HTMLElement | null,
label?: string, otherButtons?: preact.ComponentChildren, parentElem?: HTMLElement | null,
} = {}): Promise<string | null> {
opts.cancelButton ??= 'Cancel';
return new Promise(resolve => {
this.join(`popup-${this.popups.length}` as RoomID, {
args: {
message, value: opts.defaultValue || '',
okValue: true, cancelValue: false, callback: resolve, ...opts, parentElem: null,
okValue: true, cancelValue: null, callback: resolve, ...opts, parentElem: null,
},
parentElem: opts.parentElem,
});

View File

@@ -1063,7 +1063,7 @@ export class TeamForm extends preact.Component<{
<label class="checkbox"><input
type="checkbox" name="customrules" checked={this.customRules} onChange={this.toggleCustomRule}
/> Custom rules</label>
</p> : <fieldset>
</p> : <fieldset class="fieldset">
<legend><label class="checkbox"><input
type="checkbox" name="customrules" checked={this.customRules} onChange={this.toggleCustomRule}
/> Custom rules</label></legend>

View File

@@ -332,133 +332,68 @@ class UserPanel extends PSRoomPanel<UserRoom> {
}
}
const USER_PUNISHMENTS = {
mute: { label: 'Mute', duration: '7 minutes', scope: 'room' },
hourmute: { label: 'Hourmute', duration: '1 hour', scope: 'room' },
ban: { label: 'Ban', duration: '2 days', scope: 'room' },
weekban: { label: 'Weekban', duration: '1 week', scope: 'room' },
lock: { label: 'Lock', duration: '2 days', scope: 'global' },
weeklock: { label: 'Weeklock', duration: '1 week', scope: 'global' },
namelock: { label: 'Namelock', duration: '2 days', scope: 'global' },
} as const;
type UserPunishment = keyof typeof USER_PUNISHMENTS;
class UserOptionsPanel extends PSRoomPanel {
static readonly id = 'useroptions';
static readonly routes = ['useroptions-*'];
static readonly location = 'popup';
static readonly noURL = true;
declare state: {
showMuteInput?: boolean,
showBanInput?: boolean,
showLockInput?: boolean,
showConfirm?: boolean,
requestSent?: boolean,
data?: Record<string, string>,
};
getTargets() {
const [, targetUser, targetRoomid] = PSUtils.splitFirst(this.props.room.id, '-', 2);
let targetRoom = (PS.rooms[targetRoomid] || null) as ChatRoom | null;
let targetRoom = (targetRoomid ? PS.rooms[targetRoomid] : null) as ChatRoom | null || null;
if (targetRoom?.type !== 'chat') targetRoom = targetRoom?.getParent() as ChatRoom;
if (targetRoom?.type !== 'chat') targetRoom = targetRoom?.getParent() as ChatRoom;
if (targetRoom?.type !== 'chat') targetRoom = null;
if (targetRoom?.pmTarget) targetRoom = null;
return { targetUser: targetUser as ID, targetRoomid: targetRoomid as RoomID, targetRoom };
}
handleMute = (ev: Event) => {
this.setState({ showMuteInput: true, showBanInput: false, showLockInput: false });
handleCommand = (ev: Event) => {
ev.preventDefault();
ev.stopImmediatePropagation();
const command = (ev.currentTarget as HTMLButtonElement).getAttribute('data-cmdpreview');
if (!command) return PS.alert("Room not found", { parentElem: ev.currentTarget as HTMLElement });
this.send(command);
};
handleBan = (ev: Event) => {
this.setState({ showBanInput: true, showMuteInput: false, showLockInput: false });
ev.preventDefault();
ev.stopImmediatePropagation();
};
handleLock = (ev: Event) => {
this.setState({ showLockInput: true, showMuteInput: false, showBanInput: false });
ev.preventDefault();
ev.stopImmediatePropagation();
};
handleCancel = (ev: Event) => {
this.setState({ showBanInput: false, showMuteInput: false, showLockInput: false, showConfirm: false });
ev.preventDefault();
ev.stopImmediatePropagation();
};
handleConfirm = (ev: Event) => {
const data = this.state.data;
if (!data) return;
send(command: string) {
const { targetUser, targetRoom } = this.getTargets();
let cmd = '';
if (data.action === "Mute") {
cmd += data.duration === "1 hour" ? "/hourmute " : "/mute ";
} else if (data.action === "Ban") {
cmd += data.duration === "1 week" ? "/weekban " : "/ban ";
} else if (data.action === "Lock") {
cmd += data.duration === "1 week" ? "/weeklock " : "/lock ";
} else if (data.action === "Namelock") {
cmd += "/namelock ";
if (targetRoom) {
targetRoom.send(command);
} else {
return;
PS.join(`dm-${targetUser}` as RoomID);
PS.rooms[`dm-${targetUser}`]!.send(command);
}
cmd += `${targetUser} ${data.reason ? ',' + data.reason : ''}`;
targetRoom?.send(cmd);
this.close();
};
}
openPunishment = (ev: Event) => {
const button = ev.currentTarget as HTMLButtonElement;
const punishment = button.value as UserPunishment;
ev.preventDefault();
ev.stopImmediatePropagation();
if (!(punishment in USER_PUNISHMENTS)) return;
handleAddFriend = (ev: Event) => {
const { targetUser, targetRoom } = this.getTargets();
targetRoom?.send(`/friend add ${targetUser}`);
this.setState({ requestSent: true });
ev.preventDefault();
ev.stopImmediatePropagation();
};
handleIgnore = () => {
const { targetUser, targetRoom } = this.getTargets();
targetRoom?.send(`/ignore ${targetUser}`);
this.close();
};
handleUnignore = () => {
const { targetUser, targetRoom } = this.getTargets();
targetRoom?.send(`/unignore ${targetUser}`);
this.close();
};
muteUser = (ev: Event) => {
this.setState({ showMuteInput: false });
const hrMute = (ev.currentTarget as HTMLButtonElement).value === "1hr";
const reason = this.base?.querySelector<HTMLInputElement>("input[name=mutereason]")?.value;
const data = {
action: 'Mute',
reason,
duration: hrMute ? "1 hour" : "7 minutes",
};
this.setState({ data, showConfirm: true });
ev.preventDefault();
ev.stopImmediatePropagation();
};
banUser = (ev: Event) => {
this.setState({ showBanInput: false });
const weekBan = (ev.currentTarget as HTMLButtonElement).value === "1wk";
const reason = this.base?.querySelector<HTMLInputElement>("input[name=banreason]")?.value;
const data = {
action: 'Ban',
reason,
duration: weekBan ? "1 week" : "2 days",
};
this.setState({ data, showConfirm: true });
ev.preventDefault();
ev.stopImmediatePropagation();
};
lockUser = (ev: Event) => {
this.setState({ showLockInput: false });
const weekLock = (ev.currentTarget as HTMLButtonElement).value === "1wk";
const isNamelock = (ev.currentTarget as HTMLButtonElement).value === "nmlk";
const reason = this.base?.querySelector<HTMLInputElement>("input[name=lockreason]")?.value;
const data = {
action: isNamelock ? 'Namelock' : 'Lock',
reason,
duration: weekLock ? "1 week" : "2 days",
};
this.setState({ data, showConfirm: true });
ev.preventDefault();
ev.stopImmediatePropagation();
const action = USER_PUNISHMENTS[punishment];
const scope = action.scope === 'room' ? ` in ${targetRoom?.title || '???'}` : '';
PS.prompt(
`${action.label} ${targetUser}${scope} for ${action.duration}?`,
{ okButton: action.label, parentElem: button, label: "Reason (optional):" }
).then(reason => {
if (reason === null) return;
reason = reason.trim();
this.send(`/${punishment} ${targetUser}${reason ? `, ${reason}` : ''}`);
});
};
isIgnoringUser = (userid: string) => {
@@ -469,33 +404,27 @@ class UserOptionsPanel extends PSRoomPanel {
override render() {
const room = this.props.room;
const banPerms = ["@", "#", "~"];
const mutePerms = ["%", ...banPerms];
const { targetUser, targetRoom } = this.getTargets();
const userRoomGroup = targetRoom?.users[PS.user.userid].charAt(0) || '';
const canMute = mutePerms.includes(userRoomGroup);
const canBan = banPerms.includes(userRoomGroup);
const canLock = mutePerms.includes(PS.user.group);
const isVisible = (actionName: string) => {
if (actionName === 'mute') {
return canMute && !this.state.showLockInput && !this.state.showBanInput && !this.state.showConfirm;
}
if (actionName === 'ban') {
return canBan && !this.state.showLockInput && !this.state.showMuteInput && !this.state.showConfirm;
}
if (actionName === 'lock') {
return canLock && !this.state.showBanInput && !this.state.showMuteInput && !this.state.showConfirm;
}
};
const userRoomGroup = targetRoom?.users[PS.user.userid]?.charAt(0);
const roomGroupOrder = PS.server.getGroup(userRoomGroup).order;
const globalGroupOrder = PS.server.getGroup(PS.user.group).order;
const canMute = !!targetRoom && roomGroupOrder <= PS.server.getGroup('%').order;
const canBan = !!targetRoom && roomGroupOrder <= PS.server.getGroup('@').order;
const canLock = globalGroupOrder <= PS.server.getGroup('%').order;
return <PSPanelWrapper room={room} width={280}><div class="pad">
return <PSPanelWrapper room={room} width={280}><div class="pad buttonmenu">
<p>
<button data-cmd={`/dm ${targetUser}, /friend add ${targetUser}`} class="button">
Add friend
</button>
</p>
<p>
{this.isIgnoringUser(targetUser) ? (
<button onClick={this.handleUnignore} class="button">
<button data-cmdpreview={`/unignore ${targetUser}`} onClick={this.handleCommand} class="button">
Unignore
</button>
) : (
<button onClick={this.handleIgnore} class="button">
<button data-cmdpreview={`/ignore ${targetUser}`} onClick={this.handleCommand} class="button">
Ignore
</button>
)}
@@ -505,80 +434,76 @@ class UserOptionsPanel extends PSRoomPanel {
Report
</button>
</p>
<p>
{this.state.requestSent ? (
<button class="button disabled">
Sent request
{(canMute || canBan) && <fieldset class="fieldset">
<legend>Moderate in {targetRoom?.title}</legend>
{canMute && <p>
<button
class="button button-first" value="mute" onClick={this.openPunishment}
data-cmdpreview={`/mute ${targetUser}`}
>
Mute <small>7m</small>
</button>
) : (
<button onClick={this.handleAddFriend} class="button">
Add friend
<button
class="button button-last" value="hourmute" onClick={this.openPunishment}
data-cmdpreview={`/hourmute ${targetUser}`}
>
Hourmute <small>1h</small>
</button>
)}
</p>
{(canMute || canBan || canLock) && <hr />}
{this.state.showConfirm && <p>
<small>
{this.state.data?.action} <b>{targetUser}</b> {}
{!this.state.data?.action.endsWith('ock') ? <>from <b>{targetRoom?.title}</b></> : ''} for {this.state.data?.duration}?
</small>
<p class="buttonbar">
<button class="button" onClick={this.handleConfirm}>
<i class="fa fa-confirm" aria-hidden></i> Confirm
</button> {}
<button class="button" onClick={this.handleCancel}>
Cancel
</p>}
{canBan && <p>
<button
class="button button-first" value="ban" onClick={this.openPunishment}
data-cmdpreview={`/ban ${targetUser}`}
>
Ban <small>2d</small>
</button>
<button
class="button button-last" value="weekban" onClick={this.openPunishment}
data-cmdpreview={`/weekban ${targetUser}`}
>
Weekban <small>1w</small>
</button>
</p>}
<p>
<button
data-cmdpreview={`/modlog userid=${targetUser}, room=${targetRoom?.id}`} onClick={this.handleCommand} class="button"
>
Modlog
</button>
</p>
</p>}
<p class="buttonbar">
{isVisible('mute') && (this.state.showMuteInput ? (
<div>
<label class="label">
Reason: {}
<input name="mutereason" class="textbox autofocus" placeholder="Mute reason (optional)" />
</label> {} <br />
<button class="button" onClick={this.muteUser} value="7min">For 7 Mins</button> {}
<button class="button" onClick={this.muteUser} value="1hr">For 1 Hour</button> {}
<button class="button" onClick={this.handleCancel}> Cancel</button>
</div>
) : (
<button class="button" onClick={this.handleMute}>
<i class="fa fa-hourglass-half" aria-hidden></i> Mute
</fieldset>}
{canLock && <fieldset class="fieldset">
<legend>Global moderation</legend>
<p>
<button
class="button button-first" value="lock" onClick={this.openPunishment}
data-cmdpreview={`/lock ${targetUser}`}
>
Lock <small>2d</small>
</button>
))} {}
{isVisible('ban') && (this.state.showBanInput ? (
<div>
<label class="label">
Reason: {}
<input name="banreason" class="textbox autofocus" placeholder="Ban reason (optional)" />
</label><br />
<button class="button" onClick={this.banUser} value="2d">For 2 Days</button> {}
<button class="button" onClick={this.banUser} value="1wk">For 1 Week</button> {}
<button class="button" onClick={this.handleCancel}>Cancel</button>
</div>
) : (
<button class="button" onClick={this.handleBan}>
<i class="fa fa-gavel" aria-hidden></i> Ban
<button
class="button button-last" value="weeklock" onClick={this.openPunishment}
data-cmdpreview={`/weeklock ${targetUser}`}
>
Weeklock <small>1w</small>
</button>
))} {}
{isVisible('lock') && (this.state.showLockInput ? (
<div>
<label class="label">
Reason: {}
<input name="lockreason" class="textbox autofocus" placeholder="Lock reason (optional)" />
</label><br />
<button class="button" onClick={this.lockUser} value="2d">For 2 Days</button> {}
<button class="button" onClick={this.lockUser} value="1wk">For 1 Week</button> {}
<button class="button" onClick={this.lockUser} value="nmlk">Namelock</button> {}
<button class="button" onClick={this.handleCancel}>Cancel</button>
</div>
) : (
<button class="button" onClick={this.handleLock}>
<i class="fa fa-lock" aria-hidden></i> Lock/Namelock
</p>
<p>
<button
class="button" value="namelock" onClick={this.openPunishment}
data-cmdpreview={`/namelock ${targetUser}`}
>
Namelock <small>2d</small>
</button>
))}
</p>
</p>
<p>
<button
data-cmdpreview={`/modlog userid=${targetUser}, room=global`} onClick={this.handleCommand} class="button"
>
Global modlog
</button>
</p>
</fieldset>}
</div></PSPanelWrapper>;
}
}
@@ -1934,9 +1859,9 @@ class PopupPanel extends PSRoomPanel<PopupRoom> {
style="white-space:pre-wrap;word-wrap:break-word"
dangerouslySetInnerHTML={{ __html: this.parseMessage(message as string || '') }}
></p>}
{!!type && <p><input
{!!type && <p><label class="label">{room.args?.label as string || ''} <input
name="value" type={type} inputMode={inputMode} class="textbox autofocus" style="width:100%;box-sizing:border-box"
/></p>}
/></label></p>}
<p class="buttonbar">
<button class={`button${!type ? ' autofocus' : ''}`} type="submit" style="min-width:50px">
<strong>{okButton}</strong>

View File

@@ -977,10 +977,13 @@ p.or:after {
background: #052f68;
color: #b3d2fc;
}
.challenge.outgoing fieldset {
border: 1px solid #217bf5;
.fieldset {
border: 1px solid #888;
border-radius: 5px;
}
.challenge.outgoing .fieldset {
border-color: #217bf5;
}
.challenge p {
margin: 4px 0;
}
@@ -1127,6 +1130,10 @@ form.menugroup {
border-radius: 4px;
}
/*********************************************************
* User menu
*********************************************************/
/*********************************************************
* Room list
*********************************************************/
@@ -2390,6 +2397,20 @@ pre.textbox.textbox-empty[placeholder]:before {
.buttonbar {
clear: both;
}
.buttonmenu p {
margin: 3px 0;
}
.buttonmenu legend {
font-size: 9pt;
color: #555;
}
.dark .buttonmenu legend {
color: #AAA;
}
.buttonmenu .fieldset {
padding: 1px 8px 5px;
margin: 8px 0;
}
.bglist {
margin: 0 -7px;