Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
] }
base64 = "0.22.1"
bitflags = "2.9.4"
blocklist = { version = "1.0.0", default-features = false }
bon = "3.9.3"
bytemuck = "1.24.0"
bytes = "1.10.1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type Nag, nagDefinitions, toProjectNag } from '@modrinth/moderation'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'

import { DEFAULT_FEATURE_FLAGS } from '../../../composables/featureFlags'
import ModerationProjectNags from './ModerationProjectNags.vue'

const categories = [
Expand Down Expand Up @@ -92,7 +93,6 @@ const tags = {

const previewValues = {
count: 3,
domain: 'example.com',
fullUrl: 'https://example.com/prohibited-link',
languageCount: 12,
length: 12,
Expand All @@ -103,7 +103,6 @@ const previewValues = {
tagCount: 9,
tags: '16x|32x',
totalAvailableTags: 20,
type: 'mod',
url: 'https://example.com/prohibited-link',
value: 'example',
}
Expand All @@ -120,47 +119,130 @@ const suggestionKinds = new Set<Labrinth.Projects.v3.NormalizedProjectNagKind>([

const warningKinds = new Set<Labrinth.Projects.v3.NormalizedProjectNagKind>([
'missing-alt-text',
'verify-external-links',
'too-many-languages',
'too-many-tags',
'multiple-resolution-tags',
'moderator-feedback',
])

const previewNags = Object.keys(nagDefinitions).map((kind) => {
function createValidationNag(
kind: Labrinth.Projects.v3.NormalizedProjectNagKind,
details: Labrinth.Projects.v3.ProjectNag['details'] = {},
): Labrinth.Projects.v3.ProjectNag {
return {
kind: kind.replaceAll('-', '_') as Labrinth.Projects.v3.ProjectNagKind,
severity: suggestionKinds.has(kind)
? 'suggestion'
: warningKinds.has(kind)
? 'warning'
: 'required',
details: { ...previewValues, ...details },
}
}

interface NagPreviewVariant {
details?: Labrinth.Projects.v3.ProjectNag['details']
projectType?: string
}

const linkFields = [
'issues',
'source',
'wiki',
'discord',
'site',
'store',
'license',
'description',
'patreon',
'bmac',
'paypal',
'github',
'ko-fi',
'other',
]

const fieldLinkReasons = [
'global_blocklist_match',
'external_blocklist_match',
'wrong_field',
'ip_address',
'malformed',
'not_in_allowlist',
'duplicate',
'unverifiable',
]

const nagVariants: Partial<
Record<Labrinth.Projects.v3.NormalizedProjectNagKind, NagPreviewVariant[]>
> = {
'link-validation': [
{},
...fieldLinkReasons.flatMap((reason) =>
linkFields.map((field, index) => ({
details: {
reason,
field,
other_field: linkFields[(index + 1) % linkFields.length],
},
})),
),
{ details: { reason: 'download', field: 'description' } },
{ details: { reason: 'discord_invite', field: 'discord' } },
{ details: { reason: 'source_repository', field: 'source' } },
...['issues', 'wiki', 'source'].map((field) => ({
details: { reason: 'repository_feature', field },
})),
],
'invalid-license-url': [
{},
{ details: { domain: 'example.com' } },
{ details: { reason: 'malformed' } },
],
'upload-gallery-image': [{}, { projectType: 'resourcepack' }, { projectType: 'shader' }],
'long-headers': [{}, { details: { count: 1 } }],
'all-tags-selected': [{}, { details: { totalAvailableTags: 1 } }],
'multiple-resolution-tags': [{}, { details: { count: 1, tags: ['16x'] } }],
'too-many-tags': [{}, { details: { tagCount: 1 } }],
'too-many-tags-server': [{}, { details: { tagCount: 1 } }],
'too-many-languages': [{}, { details: { languageCount: 1 } }],
}

const everyNag: Nag[] = Object.keys(nagDefinitions).flatMap((kind) => {
const normalizedKind = kind as Labrinth.Projects.v3.NormalizedProjectNagKind
const projectNagKind = kind.replaceAll('-', '_') as Labrinth.Projects.v3.ProjectNagKind
const severity: Labrinth.Projects.v3.ProjectNagSeverity = suggestionKinds.has(normalizedKind)
? 'suggestion'
: warningKinds.has(normalizedKind)
? 'warning'
: 'required'
return toProjectNag(
{ kind: projectNagKind, severity, details: previewValues },
previewValues.projectType,
)
return (nagVariants[normalizedKind] ?? [{}]).map((variant, index) => {
const nag = toProjectNag(
createValidationNag(normalizedKind, variant.details),
variant.projectType ?? previewValues.projectType,
)
return { ...nag, id: `${nag.id}:preview:${index}` }
})
})

const everyNag: Nag[] = [
...previewNags,
{
id: 'resubmit-for-review-preview',
title: 'Resubmit for review',
description: () =>
"Your project has been rejected by Modrinth's staff. Address the moderation team's feedback before resubmitting.",
status: 'special-submit-action',
shouldShow: () => true,
link: {
path: 'moderation',
title: 'Visit moderation page',
shouldShow: () => true,
},
},
]
const draftNags = [
'add-icon',
'add-description',
'upload-version',
'select-environment',
'add-links',
'too-many-tags',
'check-disclosures',
] satisfies Labrinth.Projects.v3.NormalizedProjectNagKind[]

const meta = {
title: 'Website/Moderation/PublishingChecklist',
component: ModerationProjectNags,
beforeEach: () => {
const previousFlags = Object.getOwnPropertyDescriptor(globalThis, 'useFeatureFlags')
Object.defineProperty(globalThis, 'useFeatureFlags', {
configurable: true,
value: () => ref({ ...DEFAULT_FEATURE_FLAGS }),
})
return () => {
if (previousFlags) Object.defineProperty(globalThis, 'useFeatureFlags', previousFlags)
else Reflect.deleteProperty(globalThis, 'useFeatureFlags')
}
},
decorators: [
(story) => ({
components: { story },
Expand Down Expand Up @@ -199,6 +281,9 @@ export default meta
type Story = StoryObj<typeof meta>

export const EntirePublishingChecklist: Story = {
args: {
validationNags: draftNags.map((kind) => createValidationNag(kind)),
},
parameters: {
docs: {
description: {
Expand All @@ -211,13 +296,30 @@ export const EntirePublishingChecklist: Story = {
export const EveryNag: Story = {
args: {
nags: everyNag,
validationNags: draftNags.map((kind) => createValidationNag(kind)),
},
parameters: {
docs: {
description: {
story:
'Every publishing-checklist validation nag plus the submit and resubmit actions, including combinations that cannot normally appear together.',
'Every registered nag and its message variants, including link reasons and fields, license errors, gallery project types, and singular/plural copy.',
},
},
},
}

export const RejectedProject: Story = {
args: {
project: createProject('rejected'),
projectV3: createProjectV3('rejected'),
validationNags: [createValidationNag('moderator-feedback')],
},
}

export const WithheldProject: Story = {
args: {
project: createProject('withheld'),
projectV3: createProjectV3('withheld'),
validationNags: [createValidationNag('moderator-feedback')],
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
]"
:aria-label="getStatusTooltip(nag.status)"
/>
{{ getFormattedMessage(nag.title) }}
{{ getFormattedMessage(nag.title, nag.values) }}
</span>
<span>
<span
Expand Down Expand Up @@ -402,16 +402,15 @@ async function submitForReview() {
const applicableNags = computed<Nag[]>(() => {
if (props.nags) return props.nags

const nagsByKind = new Map<
Labrinth.Projects.v3.NormalizedProjectNagKind,
Labrinth.Projects.v3.ProjectNag
>()
const nagsById = new Map<string, Nag>()
for (const nag of props.validationNags) {
const kind = normalizeProjectNagKind(nag.kind)
if (kind && !nagsByKind.has(kind)) nagsByKind.set(kind, nag)
if (!kind) continue
const mapped = toProjectNag(nag, props.project.project_type)
if (!nagsById.has(mapped.id)) nagsById.set(mapped.id, mapped)
}

return [...nagsByKind.values()].map((nag) => toProjectNag(nag, props.project.project_type))
return [...nagsById.values()]
})

function isNagComplete(nag: Nag): boolean {
Expand Down Expand Up @@ -491,7 +490,7 @@ watch(
const actionableNagKeys = new Set(
validationNags
.filter((nag) => nag.severity === 'required' || nag.severity === 'warning')
.map((nag) => `${nag.severity}:${nag.kind}`),
.map((nag) => `${nag.severity}:${nag.kind}:${JSON.stringify(nag.details)}`),
)
const previousNagKeys = previousActionableNagKeys
const hasNewActionableNag =
Expand Down Expand Up @@ -552,11 +551,14 @@ function getNagDescriptionSegments(nag: Nag): { text: string; isUrl: boolean }[]
.map((text) => ({ text, isUrl: /^https?:\/\//i.test(text) }))
}

function getFormattedMessage(message: string | MessageDescriptor): string {
function getFormattedMessage(
message: string | MessageDescriptor,
values?: Nag['values'],
): string {
if (typeof message === 'string') {
return message
}
return formatMessage(message)
return formatMessage(message, values)
}
</script>

Expand Down
13 changes: 6 additions & 7 deletions apps/frontend/src/composables/project-nag-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ export type ProjectSettingsField =
| 'custom-license'
| 'license-url'
| 'external-links'
| 'source-issues-discord-links'
| 'non-discord-link-fields'
| 'link-field'
| 'source-availability'
| 'permissions'
| 'server-region'
Expand Down Expand Up @@ -50,14 +49,15 @@ export const projectNagFields = {
],
icon: ['add-icon'],
description: [
'link-validation',
'project-description-slur',
'project-description-profanity',
'project-description-non-standard-text',
'project-description-non-english',
'project-description-matches-summary',
'add-description',
'description-too-short',
'project-description-spam',
'project-description-banned-link',
'long-headers',
'description-ends-with-header',
'adjacent-headers',
Expand All @@ -67,10 +67,9 @@ export const projectNagFields = {
'gallery-images': ['upload-gallery-image', 'feature-gallery-image'],
license: ['select-license'],
'custom-license': ['add-custom-license-details'],
'license-url': ['invalid-license-url'],
'external-links': ['add-links', 'add-links-server', 'identical-links', 'banned-link-usage'],
'source-issues-discord-links': ['verify-external-links'],
'non-discord-link-fields': ['misused-discord-link'],
'license-url': ['invalid-license-url', 'link-validation'],
'link-field': ['link-validation'],
'external-links': ['add-links', 'add-links-server'],
'source-availability': ['gpl-license-source-required'],
permissions: ['review-permissions'],
'server-region': ['select-country'],
Expand Down
Loading