feat: moderation settings (#6895)

* feat: moderation settings

* chore: move moderation specific feature flag to settings

* feat: force open collapsed regions if opened in new tab

* chore: remove unused field

* chore: run prepr
This commit is contained in:
ThatGravyBoat
2026-07-28 19:38:56 +00:00
committed by GitHub
parent 82348fe40a
commit fe69e04785
25 changed files with 698 additions and 416 deletions
+46 -5
View File
@@ -1,77 +1,118 @@
import type { KeybindListener } from '../types/keybinds'
import type { Labrinth } from '@modrinth/api-client'
const copyProjectLink = async (
project: Labrinth.Projects.v2.Project,
permalink: boolean,
relative: boolean,
page: boolean,
) => {
let url = ``
if (relative) {
url += `${globalThis.location.origin}`
} else {
url += `https://modrinth.com`
}
if (permalink) {
url += `/project/${project.id}`
} else {
url += `/${project.project_type}/${project.slug}`
}
if (page) {
url += `/${globalThis.location.pathname.split('/').slice(3).join('/')}`
}
await navigator.clipboard.writeText(url)
}
const keybinds: { [id: string]: KeybindListener } = {
'next-stage': {
keybind: 'ArrowRight',
description: 'Go to next stage',
scope: 'checklist',
enabled: (ctx) => !ctx.state.isDone,
action: (ctx) => ctx.actions.tryGoNext(),
},
'previous-stage': {
keybind: 'ArrowLeft',
description: 'Go to previous stage',
scope: 'checklist',
enabled: (ctx) => !ctx.state.isDone,
action: (ctx) => ctx.actions.tryGoBack(),
},
'generate-message': {
keybind: 'Ctrl+Shift+E',
description: 'Generate moderation message',
scope: 'checklist',
action: (ctx) => ctx.actions.tryGenerateMessage(),
},
'toggle-collapse': {
keybind: 'Shift+C',
description: 'Toggle collapse/expand',
scope: 'checklist',
action: (ctx) => ctx.actions.tryToggleCollapse(),
},
'reset-progress': {
keybind: 'Ctrl+Shift+R',
description: 'Reset moderation progress',
scope: 'checklist',
action: (ctx) => ctx.actions.tryResetProgress(),
},
'skip-project': {
keybind: 'Ctrl+Shift+S',
description: 'Skip to next project',
scope: 'checklist',
enabled: (ctx) => ctx.state.futureProjectCount > 0 && !ctx.state.isDone,
action: (ctx) => ctx.actions.trySkipProject(),
},
'copy-permalink': {
keybind: 'Ctrl+Alt+C',
description: 'Copy permalink',
action: (ctx) => ctx.actions.tryCopyLink(true, false, false),
scope: 'project',
action: async (ctx) => copyProjectLink(ctx.project, true, false, false),
},
'copy-relative-permalink': {
keybind: 'Ctrl+Alt+R',
description: 'Copy relative permalink',
action: (ctx) => ctx.actions.tryCopyLink(true, true, false),
scope: 'project',
action: async (ctx) => copyProjectLink(ctx.project, true, true, false),
},
'copy-page-permalink': {
keybind: 'Shift+Ctrl+Alt+C',
description: 'Copy permalink with page',
action: (ctx) => ctx.actions.tryCopyLink(true, false, true),
scope: 'project',
action: async (ctx) => copyProjectLink(ctx.project, true, false, true),
},
'copy-page-relative-permalink': {
keybind: 'Shift+Ctrl+Alt+R',
description: 'Copy relative permalink with page',
action: (ctx) => ctx.actions.tryCopyLink(true, true, true),
scope: 'project',
action: async (ctx) => copyProjectLink(ctx.project, true, true, true),
},
'copy-id': {
keybind: 'Ctrl+Alt+D',
description: 'Copy Project ID',
action: (ctx) => ctx.actions.tryCopyId(),
scope: 'project',
action: async (ctx) => await navigator.clipboard.writeText(ctx.project.id),
},
'approve-project': {
keybind: 'Shift+Alt+A',
description: 'Approve project',
scope: 'checklist',
action: (ctx) => ctx.actions.tryApprove(),
},
'withhold-project': {
keybind: 'Shift+Alt+W',
description: 'Withhold project',
scope: 'checklist',
action: (ctx) => ctx.actions.tryWithhold(),
},
'reject-project': {
keybind: 'Shift+Alt+R',
description: 'Reject project',
scope: 'checklist',
action: (ctx) => ctx.actions.tryReject(),
},
}
+33
View File
@@ -0,0 +1,33 @@
import type { EnumSettingDefinition, ToggleSettingDefinition } from '../types/settings.ts'
const settings = {
General: {
ChecklistPosition: {
type: 'enum',
id: 'checklist-position',
title: 'Checklist Position',
description: 'Where the checklist should be displayed on the page',
entries: [
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' },
],
default: 'right',
} as EnumSettingDefinition,
ProjectKeybinds: {
type: 'toggle',
id: 'project-keybinds',
title: 'Enable Project Keybinds',
description: 'Weather certain keybinds should work without the checklist visible.',
default: false,
} as ToggleSettingDefinition,
PrivateMessageHighlight: {
type: 'toggle',
id: 'private-message-highlight',
title: 'Highlight Private Messages',
description: 'Whether private messages should be highlighted in the chat.',
default: true,
} as ToggleSettingDefinition,
},
} as const
export default settings
@@ -0,0 +1,82 @@
import {
type KeybindDefinition,
type KeybindListener,
matchesKeybind,
type ModerationContext,
normalizeKeybind,
} from '../types/keybinds.ts'
import keybinds from '../data/keybinds.ts'
function normalizeKeybinds(
keybind: KeybindDefinition | KeybindDefinition[] | string | string[],
): KeybindDefinition[] {
return Array.isArray(keybind) ? keybind.map(normalizeKeybind) : [normalizeKeybind(keybind)]
}
export type KeybindListenerWithDefault = KeybindListener & {
keybind: KeybindDefinition[]
defaultKeybind: KeybindDefinition[]
}
export class Keybinds {
private readonly configured: { [id: string]: KeybindDefinition[] } = {}
constructor(keybinds: { [id: string]: KeybindDefinition[] }) {
this.configured = keybinds
}
*[Symbol.iterator](): IterableIterator<[string, KeybindListenerWithDefault]> {
for (const [id, keybind] of Object.entries(keybinds)) {
yield [
id,
{
...keybind,
keybind: this.configured[id] ?? normalizeKeybinds(keybind.keybind),
defaultKeybind: normalizeKeybinds(keybind.keybind),
},
]
}
}
set(id: string, keybind: KeybindDefinition | KeybindDefinition[] | string | string[]): void {
this.configured[id] = normalizeKeybinds(keybind)
}
handle(event: KeyboardEvent, ctx: ModerationContext): boolean {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
(event.target as HTMLElement)?.closest('.cm-editor') ||
(event.target as HTMLElement)?.classList?.contains('cm-content') ||
(event.target as HTMLElement)?.classList?.contains('cm-line')
) {
return false
}
for (const [id, keybind] of Object.entries(keybinds)) {
if (ctx.scope !== keybind.scope) {
continue
}
if (keybind.enabled && !keybind.enabled(ctx as any)) {
continue
}
const definitions = this.configured[id] ?? normalizeKeybinds(keybind.keybind)
const matches = definitions.some((def) => matchesKeybind(event, def))
if (matches) {
keybind.action(ctx as any)
const shouldPrevent = definitions.some((def) => def.preventDefault !== false)
if (shouldPrevent) {
event.preventDefault()
}
return true
}
}
return false
}
}
@@ -0,0 +1,25 @@
import type { SettingDefinitionBase } from '../types/settings.ts'
export class Settings {
private readonly settings: { [id: string]: any }
private readonly onChange: () => void
constructor(
settings: { [id: string]: any } | undefined = undefined,
onChange: () => void = () => {},
) {
this.settings = settings || {}
this.onChange = onChange
}
get<T>(definition: SettingDefinitionBase<T>): T {
return this.settings[definition.id] ?? definition.default
}
set<T>(definition: SettingDefinitionBase<T>, value?: T): void {
const previous = this.settings[definition.id] ?? definition.default
this.settings[definition.id] = value
definition.onChange?.(previous, value ?? definition.default)
this.onChange()
}
}
+4
View File
@@ -1,5 +1,6 @@
export { default as checklist, stages, useStages } from './data/checklist'
export { default as keybinds } from './data/keybinds'
export { default as moderationSettings } from './data/settings'
export { default as nags } from './data/nags'
export * from './data/nags/index'
export { default as attributionQuickReplies } from './data/quick-replies/permissions-quick-replies'
@@ -11,6 +12,7 @@ export {
export * from './locales'
export * from './types/actions'
export * from './types/keybinds'
export * from './types/settings'
export * from './types/messages'
export * from './types/nags'
export * from './types/node'
@@ -19,3 +21,5 @@ export * from './types/quick-reply'
export * from './types/reports'
export * from './types/stage'
export * from './utils'
export * from './handles/keybinds'
export * from './handles/settings'
+21 -60
View File
@@ -14,17 +14,6 @@ export interface ModerationActions {
tryReject: () => void
tryWithhold: () => void
tryEditMessage: () => void
tryToggleAction: (actionIndex: number) => void
trySelectDropdownOption: (actionIndex: number, optionIndex: number) => void
tryToggleChip: (actionIndex: number, chipIndex: number) => void
tryFocusNextAction: () => void
tryFocusPreviousAction: () => void
tryActivateFocusedAction: () => void
tryCopyLink: (permalink: boolean, relative: boolean, page: boolean) => void
tryCopyId: () => void
}
export interface ModerationState {
@@ -41,17 +30,22 @@ export interface ModerationState {
futureProjectCount: number
visibleActionsCount: number
focusedActionIndex: number | null
focusedActionType: 'button' | 'toggle' | 'dropdown' | 'multi-select' | null
}
export interface ModerationContext {
export type ModerationProjectContext = {
project: Labrinth.Projects.v2.Project
scope: 'project'
}
export type ModerationChecklistContext = {
project: Labrinth.Projects.v2.Project
scope: 'checklist'
state: ModerationState
actions: ModerationActions
}
export type ModerationContext = ModerationProjectContext | ModerationChecklistContext
export interface KeybindDefinition {
key: string
ctrl?: boolean
@@ -61,13 +55,22 @@ export interface KeybindDefinition {
preventDefault?: boolean
}
export interface KeybindListener {
export type BaseKeybindListener<T> = {
keybind: KeybindDefinition | KeybindDefinition[] | string | string[]
description: string
enabled?: (ctx: ModerationContext) => boolean
action: (ctx: ModerationContext) => void
scope: 'project' | 'checklist'
enabled?: (ctx: T) => boolean
action: (ctx: T) => void
}
export type KeybindProjectListener = BaseKeybindListener<ModerationProjectContext> & {
scope: 'project'
}
export type KeybindChecklistListener = BaseKeybindListener<ModerationChecklistContext> & {
scope: 'checklist'
}
export type KeybindListener = KeybindProjectListener | KeybindChecklistListener
export function parseKeybind(keybindString: string): KeybindDefinition {
const parts = keybindString.split('+').map((p) => p.trim().toLowerCase())
@@ -106,45 +109,3 @@ export function toKeybindDefinition(event: KeyboardEvent): KeybindDefinition {
preventDefault: true,
}
}
export function handleKeybind(
event: KeyboardEvent,
ctx: ModerationContext,
keybinds: KeybindListener[],
): boolean {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
(event.target as HTMLElement)?.closest('.cm-editor') ||
(event.target as HTMLElement)?.classList?.contains('cm-content') ||
(event.target as HTMLElement)?.classList?.contains('cm-line') ||
document.getElementById('moderation-checklist-keybinds-modal')
) {
return false
}
for (const keybind of keybinds) {
if (keybind.enabled && !keybind.enabled(ctx)) {
continue
}
const keybindDefs = Array.isArray(keybind.keybind)
? keybind.keybind.map(normalizeKeybind)
: [normalizeKeybind(keybind.keybind)]
const matches = keybindDefs.some((def) => matchesKeybind(event, def))
if (matches) {
keybind.action(ctx)
const shouldPrevent = keybindDefs.some((def) => def.preventDefault !== false)
if (shouldPrevent) {
event.preventDefault()
}
return true
}
}
return false
}
+29
View File
@@ -0,0 +1,29 @@
export interface SettingDefinitionBase<T> {
type: string
id: string
title: string
description: string
default: T
onChange?: (previous: T | undefined, current: T) => void
}
export interface ToggleSettingDefinition extends SettingDefinitionBase<boolean> {
type: 'toggle'
}
export interface EnumSettingDefinition extends SettingDefinitionBase<string> {
type: 'enum'
entries: {
label: string
value: string
}[]
}
export interface StringSettingDefinition extends SettingDefinitionBase<string> {
type: 'string'
}
export type SettingDefinition =
| ToggleSettingDefinition
| EnumSettingDefinition
| StringSettingDefinition