mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +00:00
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:
@@ -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(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user