project disclosures frontend (#6955)

* begin project disclosures

* new project settings header

* begin disclosure settings, edit content rules

* togglecards

* update phrasing of the rules, prepr

* more disclosure settings structuring

* add functionality toggle

* implement functionality with staging api

* improve project settings head titles

* feat(labrinth): project disclosures model

* feat(labrinth): project disclosures database model

* feat(labrinth): project disclosures get endpoint

* feat(labrinth): censor user ids if set by moderator

* feat(labrinth): wrap disclosures in struct

* feat(labrinth): edit project disclosures endpoint

* style(labrinth): cargo fmt

* style(labrinth): fix typo

* feat(labrinth): add fields for ai content disclosure

* fix(labrinth): field typo

* chore(labrinth): update query cache

* feat(labrinth): index disclosures in search

* refactor(labrinth): use enum instead of bools

* feat(labrinth): trigger incremental index on disclosure change

* feat(labrinth): change archived status to disclosure

* feat(labrinth): use disclosure for archival status

* fix(labrinth): error type for deserialization

* fix(labrinth): migration timestamp

* fix(labrinth): add disclosures to elasticsearch schema

* disclosures in search

* update photosensitivity warning copy

* update archiving, move general project settings to v3 (start redesign there), use project id lookups for stable cache keys, advanced in server search

* blue archive banner

* display AI use types

* improve labels

* add modrinth to link

* add placeholder missing disclosure report type

* add AI metadata checking to block image uploads with notice

* update copy

* update section 4 of rules

* Update rule 6 layout

* fix(labrinth): don't remove archival disclosure when changing status via v3 api

* feat(labrinth): derive str for ai usages and telemtry consent

* feat(labrinth): include ai usages and telemetry consent in disclosure types

* Update moderation checklist (#7056)

* fix: missing delphi severity (#7054)

* Utils nav, new disclosures stage, ai button in rules

* add prefix to collect

* Finish up new disclosures stage

* update r4 messages

* new r4 msg, update showcase clarity message

* fix: version upload failing to detect mrpack loader (#7063)

* fix: mrpack exporting with zip64 (#7064)

* fix: action bar max width (#7048)

* fix: action bar max width

* fix: width

* changelog

* Rule placeholders with subsections & anchors, update messages.

* update new messages to account for new rule and placeholder layout

* Add nag for content disclosures

---------

Co-authored-by: ThatGravyBoat <gravy@thatgravyboat.tech>
Co-authored-by: chyzman <chyzalt@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>

* prepr

* refactor(labrinth): move disclosure parsing to document creation

* fix(labrinth): prevent removing moderator added archival disclosures

* fix(labrinth): dedupe ai usages

* fix(labrinth): auth logic on archived disclosure removal

* feat(labrinth): add interactions field

* style(labrinth): cargo fmt

* feat(labrinth): granular disclosure locking

* add 5.8, move 5.9, update messages and placeholders accordingly.

* move disclosures stage earlier

* include consent model info in telemetry disclosure msgs

* prepr + update date

* typo

* qa pass

* search suboptions

* add empty state

* checkboxes in column

* persistent advanced filters

* blog draft

* prepr

* support soft deletion

* reload instead of optimistically updating

* update blog

* TOS update + minor verbiage adjustment

* 45 day grace

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
Co-authored-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
Co-authored-by: ThatGravyBoat <gravy@thatgravyboat.tech>
Co-authored-by: chyzman <chyzalt@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
This commit is contained in:
Prospector
2026-08-13 09:01:18 -07:00
committed by GitHub
co-authored by ThatGravyBoat chyzman Truman Gao sychic coolbot
parent 06b7c44dcc
commit 755825b09a
230 changed files with 5802 additions and 1268 deletions
@@ -0,0 +1,322 @@
import type { Labrinth } from '@modrinth/api-client'
import {
isActiveDisclosure,
isDisclosureCompatibleWithProjectTypes,
PROJECT_DISCLOSURE_TYPES,
} from '@modrinth/ui'
import type {
DisclosureFormState,
DisclosureLockStatus,
DisclosureOf,
DisclosureType,
NoteDisclosure,
ProjectDisclosure,
ProjectDisclosureData,
} from './types'
function findDisclosure<T extends DisclosureType>(
disclosures: ProjectDisclosureData[],
type: T,
): DisclosureOf<T> | undefined {
return disclosures.find((disclosure): disclosure is DisclosureOf<T> => disclosure.type === type)
}
type NoteDisclosureType = 'advertisements' | 'epilepsy_triggers' | 'archived'
function createNoteModel(
disclosures: ProjectDisclosureData[],
type: NoteDisclosureType,
): NoteDisclosure {
const disclosure = findDisclosure(disclosures, type)
return {
enabled: isActiveDisclosure(disclosure),
note: disclosure?.note ?? '',
}
}
function nonemptyOrPlaceholder(values: string[]): string[] {
return values.length > 0 ? [...values] : ['']
}
function createLockStatuses(
disclosures: ProjectDisclosureData[],
): Record<DisclosureType, DisclosureLockStatus> {
const lockStatuses = Object.fromEntries(
PROJECT_DISCLOSURE_TYPES.map((type) => [type, 'unlocked']),
) as Record<DisclosureType, DisclosureLockStatus>
for (const disclosure of disclosures) {
lockStatuses[disclosure.type] = disclosure.lock_status
}
return lockStatuses
}
export function disclosuresToForm(disclosures: ProjectDisclosureData[]): DisclosureFormState {
const ai = findDisclosure(disclosures, 'ai_content')
const paidFeatures = findDisclosure(disclosures, 'paid_features')
const telemetry = findDisclosure(disclosures, 'telemetry')
const derivative = findDisclosure(disclosures, 'derivative_work')
const systemInteractions = findDisclosure(disclosures, 'system_interactions')
return {
ai: {
enabled: isActiveDisclosure(ai),
uses: ai ? [...(ai.uses ?? [])] : [],
note: ai?.note ?? '',
},
advertising: createNoteModel(disclosures, 'advertisements'),
paidFeatures: {
enabled: isActiveDisclosure(paidFeatures),
features: paidFeatures ? nonemptyOrPlaceholder(paidFeatures.features) : [],
},
telemetry: {
enabled: isActiveDisclosure(telemetry),
consent: telemetry?.consent ?? 'opt_in',
entries: telemetry ? nonemptyOrPlaceholder(telemetry.data_collected) : [],
},
derivative: {
enabled: isActiveDisclosure(derivative),
sources: derivative ? derivative.sources.map((source) => ({ ...source })) : [],
},
photosensitivity: createNoteModel(disclosures, 'epilepsy_triggers'),
systemInteractions: {
enabled: isActiveDisclosure(systemInteractions),
note: systemInteractions?.note ?? '',
interactions: systemInteractions ? [...systemInteractions.interactions] : [],
},
archived: createNoteModel(disclosures, 'archived'),
lockStatuses: createLockStatuses(disclosures),
}
}
export function findDisclosureData<T extends DisclosureType>(
disclosures: ProjectDisclosureData[] | undefined,
type: T,
): DisclosureOf<T> | undefined {
if (!disclosures) return undefined
return findDisclosure(disclosures, type)
}
export function formToDisclosure(
form: DisclosureFormState,
type: DisclosureType,
): ProjectDisclosure {
switch (type) {
case 'ai_content':
return {
type: 'ai_content',
uses: [...form.ai.uses],
note: form.ai.note.trim() || null,
}
case 'advertisements':
return { type: 'advertisements', note: form.advertising.note.trim() || null }
case 'paid_features':
return {
type: 'paid_features',
features: form.paidFeatures.features.map((feature) => feature.trim()).filter(Boolean),
}
case 'telemetry':
return {
type: 'telemetry',
consent: form.telemetry.consent,
data_collected: form.telemetry.entries.map((entry) => entry.trim()).filter(Boolean),
}
case 'derivative_work':
return {
type: 'derivative_work',
sources: form.derivative.sources.map((source) => ({
label: source.label.trim(),
link: source.link?.trim() || null,
note: source.note?.trim() || null,
})),
}
case 'epilepsy_triggers':
return { type: 'epilepsy_triggers', note: form.photosensitivity.note.trim() || null }
case 'system_interactions':
return {
type: 'system_interactions',
interactions: [...form.systemInteractions.interactions],
note: form.systemInteractions.note.trim() || null,
}
case 'archived':
return { type: 'archived', note: form.archived.note.trim() || null }
}
}
export function formToDisclosures(form: DisclosureFormState): ProjectDisclosure[] {
const set: ProjectDisclosure[] = []
if (form.ai.enabled) {
set.push(formToDisclosure(form, 'ai_content'))
}
if (form.advertising.enabled) {
set.push(formToDisclosure(form, 'advertisements'))
}
if (form.paidFeatures.enabled) {
set.push(formToDisclosure(form, 'paid_features'))
}
if (form.telemetry.enabled) {
set.push(formToDisclosure(form, 'telemetry'))
}
if (form.derivative.enabled) {
set.push(formToDisclosure(form, 'derivative_work'))
}
if (form.photosensitivity.enabled) {
set.push(formToDisclosure(form, 'epilepsy_triggers'))
}
if (form.systemInteractions.enabled) {
set.push(formToDisclosure(form, 'system_interactions'))
}
if (form.archived.enabled) {
set.push(formToDisclosure(form, 'archived'))
}
return set
}
export function toModifyRequests(
form: DisclosureFormState,
previous: DisclosureFormState,
): Labrinth.Projects.v3.ModifyProjectDisclosures[] {
const next = formToDisclosures(form)
const previousDisclosures = formToDisclosures(previous)
const previousByType = new Map(
previousDisclosures.map((disclosure) => [disclosure.type, disclosure]),
)
const nextTypes = new Set(next.map((disclosure) => disclosure.type))
const remove: DisclosureType[] = []
const contentOnly: ProjectDisclosure[] = []
const lockChangeGroups = new Map<DisclosureLockStatus, ProjectDisclosure[]>()
const disabledLockGroups = new Map<DisclosureLockStatus, ProjectDisclosure[]>()
for (const disclosure of next) {
const existing = previousByType.get(disclosure.type)
const contentChanged = !existing || JSON.stringify(existing) !== JSON.stringify(disclosure)
const previousLock = previous.lockStatuses[disclosure.type] ?? 'unlocked'
const nextLock = form.lockStatuses[disclosure.type] ?? 'unlocked'
const lockChanged = previousLock !== nextLock
if (!contentChanged && !lockChanged) {
continue
}
if (lockChanged) {
const group = lockChangeGroups.get(nextLock) ?? []
group.push(disclosure)
lockChangeGroups.set(nextLock, group)
} else {
contentOnly.push(disclosure)
}
}
for (const type of PROJECT_DISCLOSURE_TYPES) {
if (nextTypes.has(type)) {
continue
}
const previousLock = previous.lockStatuses[type] ?? 'unlocked'
const nextLock = form.lockStatuses[type] ?? 'unlocked'
const lockChanged = previousLock !== nextLock
const wasEnabled = previousByType.has(type)
if (wasEnabled && !lockChanged) {
remove.push(type)
continue
}
if (!lockChanged) {
continue
}
const group = disabledLockGroups.get(nextLock) ?? []
group.push(formToDisclosure(form, type))
disabledLockGroups.set(nextLock, group)
}
const requests: Labrinth.Projects.v3.ModifyProjectDisclosures[] = []
if (remove.length > 0) {
requests.push({ set: [], remove })
}
if (contentOnly.length > 0) {
requests.push({ set: contentOnly, remove: [] })
}
for (const [lockStatus, set] of lockChangeGroups) {
requests.push({ set, remove: [], lock_status: lockStatus })
}
for (const [lockStatus, set] of disabledLockGroups) {
requests.push({ set, remove: [], lock_status: lockStatus })
requests.push({ set: [], remove: set.map((disclosure) => disclosure.type) })
}
return requests
}
export function getDisclosureFormSnapshot(form: DisclosureFormState) {
return {
disclosures: formToDisclosures(form),
lockStatuses: { ...form.lockStatuses },
}
}
export type DisclosureFormIssue =
| 'advertising-note'
| 'paid-features-empty'
| 'telemetry-empty'
| 'derivative-empty'
| 'derivative-source-label'
| 'photosensitivity-note'
| 'system-interactions-note'
export function getDisclosureFormIssues(
form: DisclosureFormState,
projectTypes?: readonly string[],
): DisclosureFormIssue[] {
const issues: DisclosureFormIssue[] = []
const missingNote = (model: NoteDisclosure) => model.enabled && !model.note.trim()
const compatible = (type: DisclosureType) =>
!projectTypes || isDisclosureCompatibleWithProjectTypes(type, projectTypes)
if (compatible('advertisements') && missingNote(form.advertising)) {
issues.push('advertising-note')
}
if (
compatible('paid_features') &&
form.paidFeatures.enabled &&
!form.paidFeatures.features.some((feature) => feature.trim())
) {
issues.push('paid-features-empty')
}
if (
compatible('telemetry') &&
form.telemetry.enabled &&
!form.telemetry.entries.some((entry) => entry.trim())
) {
issues.push('telemetry-empty')
}
if (compatible('derivative_work') && form.derivative.enabled) {
if (form.derivative.sources.length === 0) {
issues.push('derivative-empty')
} else if (form.derivative.sources.some((source) => !source.label?.trim())) {
issues.push('derivative-source-label')
}
}
if (compatible('epilepsy_triggers') && missingNote(form.photosensitivity)) {
issues.push('photosensitivity-note')
}
if (
compatible('system_interactions') &&
form.systemInteractions.enabled &&
!form.systemInteractions.note.trim()
) {
issues.push('system-interactions-note')
}
return issues
}