mirror of
https://github.com/modrinth/code.git
synced 2026-09-03 13:36:48 +00:00
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:
co-authored by
ThatGravyBoat
chyzman
Truman Gao
sychic
coolbot
parent
06b7c44dcc
commit
755825b09a
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user