feat: moderation improvements (#3881)

* feat: rough draft of tool

* fix: example doc

* feat: multiselect chips

* feat: conditional actions+messaages + utils for handling conditions

* feat: migrate checklist v1 to new format.

* fix: lint issues

* fix: severity util

* feat: README.md

* feat: start implementing new moderation checklist

* feat: message assembly + fix imports

* fix: lint issues

* feat: add input suggestions

* feat: utility cleanup

* fix: icon

* chore: remove debug logging

* chore: remove debug button

* feat: modpack permissions flow into it's own component

* feat: icons + use id in stage selection button

* Support md/plain text in stages.

* fix: checklist not persisting/showing on subpages

* feat: message gen + appr/with/deny buttons

* feat: better notification placement + queue navigation

* fix: default props for futureProjects

* fix: modpack perms message

* fix: issue with future projects props

* fix: tab index + z index fixes

* feat: keybinds

* fix: file approval types

* fix: generate message for non-modpack projects

* feat: add generate message to stages dropdown

* fix: variables not expanding

* feat: requests

* fix: empty message approval

* fix: issues from sync

* chore: add comment for old moderation checklist impl

* fix: git artifacts

* fix: update visibility logic for stages and actions

* fix: cleanup logic for should show

* fix: markdown editor accidental edit
This commit is contained in:
IMB11
2025-07-11 17:09:04 +01:00
committed by GitHub
parent f7700acce4
commit 359fbd4738
73 changed files with 4337 additions and 12 deletions

View File

@@ -0,0 +1,245 @@
import type { Project } from '@modrinth/utils'
import type { WeightedMessage } from './messages'
export type ActionType =
| 'button'
| 'dropdown'
| 'multi-select-chips'
| 'toggle'
| 'conditional-button'
export type Action =
| ButtonAction
| DropdownAction
| MultiSelectChipsAction
| ToggleAction
| ConditionalButtonAction
export type ModerationStatus = 'approved' | 'rejected' | 'flagged'
export type ModerationSeverity = 'low' | 'medium' | 'high' | 'critical'
export interface BaseAction {
/**
* The type of action, which determines how the action is presented to the moderator and what it does.
*/
type: ActionType
/**
* Any additional text data that is required to complete the action.
*/
relevantExtraInput?: AdditionalTextInput[]
/**
* Suggested moderation status when this action is selected.
*/
suggestedStatus?: ModerationStatus
/**
* Suggested severity level for this moderation action.
*/
severity?: ModerationSeverity
/**
* Actions that become available when this action is selected.
*/
enablesActions?: Action[]
/**
* Actions that become unavailable when this action is selected.
*/
disablesActions?: string[] // Array of action IDs
/**
* Unique identifier for this action, used for conditional logic.
*/
id?: string
/**
* A function that determines whether this action should be shown for a given project.
*
* By default, it returns `true`, meaning the action is always shown.
*/
shouldShow?: (project: Project) => boolean
}
/**
* Represents a conditional message that changes based on other selected actions.
*/
export interface ConditionalMessage extends WeightedMessage {
/**
* Conditions that must be met for this message to be used.
*/
conditions: {
/**
* Action IDs that must be selected for this message to apply.
*/
requiredActions?: string[]
/**
* Action IDs that must NOT be selected for this message to apply.
*/
excludedActions?: string[]
}
/**
* Fallback message if conditions are not met.
*/
fallbackMessage?: () => Promise<typeof import('*.md?raw') | string>
}
/**
* Represents a button action, which is a simple toggle button that can be used to append a message to the final moderation message.
*/
export interface ButtonAction extends BaseAction, WeightedMessage {
type: 'button'
/**
* The label of the button, which is displayed to the moderator. The text on the button.
*/
label: string
/**
* Alternative messages based on other selected actions.
*/
conditionalMessages?: ConditionalMessage[]
}
/**
* Represents a simple toggle/checkbox action with separate layout handling.
*/
export interface ToggleAction extends BaseAction, WeightedMessage {
type: 'toggle'
/**
* The label of the toggle, which is displayed to the moderator.
*/
label: string
/**
* Description text that appears below the toggle.
*/
description?: string
/**
* Whether the toggle is checked by default.
*/
defaultChecked?: boolean
/**
* Alternative messages based on other selected actions.
*/
conditionalMessages?: ConditionalMessage[]
}
/**
* Represents a button that has different behavior based on other selected actions.
*/
export interface ConditionalButtonAction extends BaseAction {
type: 'conditional-button'
/**
* The label of the button, which is displayed to the moderator.
*/
label: string
/**
* Different message configurations based on conditions.
*/
messageVariants: ConditionalMessage[]
}
export interface DropdownActionOption extends WeightedMessage {
/**
* The label of the option, which is displayed to the moderator.
*/
label: string
}
export interface DropdownAction extends BaseAction {
type: 'dropdown'
/**
* The label associated with the dropdown.
*/
label: string
/**
* The options available in the dropdown.
*/
options: DropdownActionOption[]
/**
* The default option selected in the dropdown, by index.
*/
defaultOption?: number
}
export interface MultiSelectChipsOption extends WeightedMessage {
/**
* The label of the chip, which is displayed to the moderator.
*/
label: string
}
export interface MultiSelectChipsAction extends BaseAction {
type: 'multi-select-chips'
/**
* The label associated with the multi-select chips.
*/
label: string
/**
* The options available in the multi-select chips.
*/
options: MultiSelectChipsOption[]
}
export interface AdditionalTextInput {
/**
* The label of the text input, which is displayed to the moderator.
*/
label: string
/**
* The placeholder text for the text input.
*/
placeholder?: string
/**
* Whether the text input is required to be filled out before the action can be completed.
*/
required?: boolean
/**
* Whether the text input should use the full markdown editor rather than a simple text input.
*/
large?: boolean
/**
* The variable name that will be replaced in the message with the input value.
* For example, if variable is "MESSAGE", then "%MESSAGE%" in the action message
* will be replaced with the input value.
*/
variable?: string
/**
* Conditions that determine when this input is shown.
*/
showWhen?: {
/**
* Action IDs that must be selected for this input to be shown.
*/
requiredActions?: string[]
/**
* Action IDs that must NOT be selected for this input to be shown.
*/
excludedActions?: string[]
}
/**
* Optional suggestions for the input. Useful for repeating phrases or common responses.
*/
suggestions?: string[]
}

View File

@@ -0,0 +1,130 @@
import type { Project } from '@modrinth/utils'
export interface ModerationActions {
tryGoNext: () => void
tryGoBack: () => void
tryGenerateMessage: () => void
trySkipProject: () => void
tryToggleCollapse: () => void
tryResetProgress: () => void
tryExitModeration: () => void
tryApprove: () => void
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
}
export interface ModerationState {
currentStage: number
totalStages: number
currentStageId: string | undefined
currentStageTitle: string
isCollapsed: boolean
isDone: boolean
hasGeneratedMessage: boolean
isLoadingMessage: boolean
isModpackPermissionsStage: boolean
futureProjectCount: number
visibleActionsCount: number
focusedActionIndex: number | null
focusedActionType: 'button' | 'toggle' | 'dropdown' | 'multi-select' | null
}
export interface ModerationContext {
project: Project
state: ModerationState
actions: ModerationActions
}
export interface KeybindDefinition {
key: string
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
preventDefault?: boolean
}
export interface KeybindListener {
id: string
keybind: KeybindDefinition | KeybindDefinition[] | string | string[]
description: string
enabled?: (ctx: ModerationContext) => boolean
action: (ctx: ModerationContext) => void
}
export function parseKeybind(keybindString: string): KeybindDefinition {
const parts = keybindString.split('+').map((p) => p.trim().toLowerCase())
return {
key: parts.find((p) => !['ctrl', 'shift', 'alt', 'meta', 'cmd'].includes(p)) || '',
ctrl: parts.includes('ctrl') || parts.includes('cmd'),
shift: parts.includes('shift'),
alt: parts.includes('alt'),
meta: parts.includes('meta') || parts.includes('cmd'),
preventDefault: true,
}
}
export function normalizeKeybind(keybind: KeybindDefinition | string): KeybindDefinition {
return typeof keybind === 'string' ? parseKeybind(keybind) : keybind
}
export function matchesKeybind(event: KeyboardEvent, keybind: KeybindDefinition | string): boolean {
const def = normalizeKeybind(keybind)
return (
event.key.toLowerCase() === def.key.toLowerCase() &&
event.ctrlKey === (def.ctrl ?? false) &&
event.shiftKey === (def.shift ?? false) &&
event.altKey === (def.alt ?? false) &&
event.metaKey === (def.meta ?? false)
)
}
export function handleKeybind(
event: KeyboardEvent,
ctx: ModerationContext,
keybinds: KeybindListener[],
): boolean {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
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
}

View File

@@ -0,0 +1,13 @@
export interface WeightedMessage {
/**
* The weight of the action's active message, used to determine the place where the message is placed in the final moderation message.
*/
weight: number
/**
* The message which is appended to the final moderation message if the button is active.
* @returns A function that lazily loads the message which is appended if the button is active.
* @example async () => (await import('../messages/example.md?raw')).default,
*/
message: () => Promise<string>
}

View File

@@ -0,0 +1,52 @@
import type { Project } from '@modrinth/utils'
import type { Action } from './actions'
import type { FunctionalComponent, SVGAttributes } from 'vue'
/**
* Represents a moderation stage with associated actions and optional navigation logic.
*/
export interface Stage {
/**
* The title of the stage, displayed to the moderator.
*/
title: string
/**
* An optional description or additional text for the stage.
*/
text?: () => Promise<string>
/**
* Optional id for the stage, used for identification in the checklist. Will be used in the stage list as well instead of the title.
*/
id?: string
/**
* Optional icon for the stage, displayed in the stage list and next to the title.
*/
icon?: FunctionalComponent<SVGAttributes>
/**
* URL to the guidance document for this stage.
*/
guidance_url: string
/**
* An array of actions that can be taken in this stage.
*/
actions: Action[]
/**
* Optional navigation path to redirect the moderator when this stage is shown.
*
* This is relative to the project page. For example, `/settings#side-types` would navigate to `https://modrinth.com/project/:id/settings#side-types`.
*/
navigate?: string
/**
* A function that determines whether this stage should be shown for a given project.
*
* By default, it returns `true`, meaning the stage is always shown.
*/
shouldShow?: (project: Project) => boolean
}