Merge branch 'main' into coolbot/moderation-improvements

This commit is contained in:
coolbot100s 2025-07-25 01:05:51 -07:00
commit 473fe52865
5 changed files with 224 additions and 72 deletions

View File

@ -8,7 +8,7 @@
<div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions obtained. You may skip this step!</p>
<p>All permissions already obtained.</p>
</div>
<div v-else-if="!modPackData[currentIndex]">
@ -157,7 +157,7 @@ import type {
} from "@modrinth/utils";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted } from "vue";
import { useLocalStorage } from "@vueuse/core";
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
const props = defineProps<{
projectId: string;
@ -182,7 +182,26 @@ const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
const modPackData = ref<ModerationModpackItem[] | null>(null);
const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
`modpack-permissions-data-${props.projectId}`,
null,
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : null),
write: (v: any) => JSON.stringify(v),
},
},
);
const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
`modpack-permissions-permanent-no-${props.projectId}`,
[],
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : []),
write: (v: any) => JSON.stringify(v),
},
},
);
const currentIndex = ref(0);
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
@ -251,7 +270,45 @@ async function fetchModPackData(): Promise<void> {
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
internal: true,
})) as ModerationModpackResponse;
const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
.filter(([_, file]) => file.status === "permanent-no")
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
status: file.status,
approved: null,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name));
permanentNoFiles.value = permanentNoItems;
const sortedData: ModerationModpackItem[] = [
...Object.entries(data.identified || {})
.filter(
([_, file]) =>
file.status !== "yes" &&
file.status !== "with-attribution-and-source" &&
file.status !== "permanent-no",
)
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
status: file.status,
approved: null,
...(file.status === "unidentified" && {
proof: "",
url: "",
title: "",
}),
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.unknown_files || {})
.map(
([sha1, fileName]): ModerationUnknownModpackItem => ({
@ -310,6 +367,7 @@ async function fetchModPackData(): Promise<void> {
} catch (error) {
console.error("Failed to fetch modpack data:", error);
modPackData.value = [];
permanentNoFiles.value = [];
persistAll();
}
}
@ -321,6 +379,14 @@ function goToPrevious(): void {
}
}
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
},
{ deep: true },
);
function goToNext(): void {
if (modPackData.value && currentIndex.value < modPackData.value.length) {
currentIndex.value++;
@ -396,6 +462,17 @@ onMounted(() => {
}
});
watch(
modPackData,
(newValue) => {
if (newValue && newValue.length === 0) {
emit("complete");
clearPersistedData();
}
},
{ immediate: true },
);
watch(
() => props.projectId,
() => {
@ -406,6 +483,20 @@ watch(
}
},
);
function getModpackFiles(): {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
} {
return {
interactive: modPackData.value || [],
permanentNo: permanentNoFiles.value,
};
}
defineExpose({
getModpackFiles,
});
</script>
<style scoped>

View File

@ -240,24 +240,6 @@
</div>
<div v-else-if="generatedMessage" class="flex items-center gap-2">
<OverflowMenu :options="stageOptions" class="bg-transparent p-0">
<ButtonStyled circular>
<button v-tooltip="`Stages`">
<ListBulletedIcon />
</button>
</ButtonStyled>
<template
v-for="opt in stageOptions.filter(
(opt) => 'id' in opt && 'text' in opt && 'icon' in opt,
)"
#[opt.id]
:key="opt.id"
>
<component :is="opt.icon" v-if="opt.icon" class="mr-2" />
{{ opt.text }}
</template>
</OverflowMenu>
<ButtonStyled>
<button @click="goBackToStages">
<LeftArrowIcon aria-hidden="true" />
@ -368,21 +350,26 @@ import {
DropdownSelect,
MarkdownEditor,
} from "@modrinth/ui";
import { type Project, renderHighlightedString, type ModerationJudgements } from "@modrinth/utils";
import {
type Project,
renderHighlightedString,
type ModerationJudgements,
type ModerationModpackItem,
} from "@modrinth/utils";
import { computedAsync, useLocalStorage } from "@vueuse/core";
import type {
Action,
MultiSelectChipsAction,
DropdownAction,
ButtonAction,
ToggleAction,
ConditionalButtonAction,
Stage,
import {
type Action,
type MultiSelectChipsAction,
type DropdownAction,
type ButtonAction,
type ToggleAction,
type ConditionalButtonAction,
type Stage,
finalPermissionMessages,
} from "@modrinth/moderation";
import * as prettier from "prettier";
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
import KeybindsModal from "./ChecklistKeybindsModal.vue";
import { finalPermissionMessages } from "@modrinth/moderation/data/modpack-permissions-stage";
import prettier from "prettier";
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>();
@ -419,7 +406,6 @@ const done = ref(false);
function handleModpackPermissionsComplete() {
modpackPermissionsComplete.value = true;
nextStage();
}
const emit = defineEmits<{
@ -823,6 +809,31 @@ const isAnyVisibleInputs = computed(() => {
});
});
function getModpackFilesFromStorage(): {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
} {
try {
const sessionData = sessionStorage.getItem(`modpack-permissions-data-${props.project.id}`);
const interactive = sessionData ? (JSON.parse(sessionData) as ModerationModpackItem[]) : [];
const permanentNoData = sessionStorage.getItem(
`modpack-permissions-permanent-no-${props.project.id}`,
);
const permanentNo = permanentNoData
? (JSON.parse(permanentNoData) as ModerationModpackItem[])
: [];
return {
interactive: interactive || [],
permanentNo: permanentNo || [],
};
} catch (error) {
console.warn("Failed to parse session storage modpack data:", error);
return { interactive: [], permanentNo: [] };
}
}
async function assembleFullMessage() {
const messageParts: MessagePart[] = [];
@ -1092,13 +1103,14 @@ async function generateMessage() {
const baseMessage = await assembleFullMessage();
let fullMessage = baseMessage;
if (
props.project.project_type === "modpack" &&
Object.keys(modpackJudgements.value).length > 0
) {
const modpackMessage = generateModpackMessage(modpackJudgements.value);
if (modpackMessage) {
fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage;
if (props.project.project_type === "modpack") {
const modpackFilesData = getModpackFilesFromStorage();
if (modpackFilesData.interactive.length > 0 || modpackFilesData.permanentNo.length > 0) {
const modpackMessage = generateModpackMessage(modpackFilesData);
if (modpackMessage) {
fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage;
}
}
}
@ -1129,25 +1141,32 @@ async function generateMessage() {
}
}
function generateModpackMessage(judgements: ModerationJudgements) {
function generateModpackMessage(allFiles: {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
}) {
const issues = [];
const attributeMods = [];
const noMods = [];
const permanentNoMods = [];
const unidentifiedMods = [];
const attributeMods: string[] = [];
const noMods: string[] = [];
const permanentNoMods: string[] = [];
const unidentifiedMods: string[] = [];
for (const [, judgement] of Object.entries(judgements)) {
if (judgement.status === "with-attribution") {
attributeMods.push(judgement.file_name);
} else if (judgement.status === "no") {
noMods.push(judgement.file_name);
} else if (judgement.status === "permanent-no") {
permanentNoMods.push(judgement.file_name);
} else if (judgement.status === "unidentified") {
unidentifiedMods.push(judgement.file_name);
allFiles.interactive.forEach((file) => {
if (file.status === "unidentified") {
if (file.approved === "no") {
unidentifiedMods.push(file.file_name);
}
} else if (file.status === "with-attribution" && file.approved === "no") {
attributeMods.push(file.file_name);
} else if (file.status === "no" && file.approved === "no") {
noMods.push(file.file_name);
}
}
});
allFiles.permanentNo.forEach((file) => {
permanentNoMods.push(file.file_name);
});
if (
attributeMods.length > 0 ||
@ -1157,6 +1176,12 @@ function generateModpackMessage(judgements: ModerationJudgements) {
) {
issues.push("## Copyrighted content");
if (unidentifiedMods.length > 0) {
issues.push(
`${finalPermissionMessages.unidentified}\n${unidentifiedMods.map((mod) => `- ${mod}`).join("\n")}`,
);
}
if (attributeMods.length > 0) {
issues.push(
`${finalPermissionMessages["with-attribution"]}\n${attributeMods.map((mod) => `- ${mod}`).join("\n")}`,
@ -1172,12 +1197,6 @@ function generateModpackMessage(judgements: ModerationJudgements) {
`${finalPermissionMessages["permanent-no"]}\n${permanentNoMods.map((mod) => `- ${mod}`).join("\n")}`,
);
}
if (unidentifiedMods.length > 0) {
issues.push(
`${finalPermissionMessages["unidentified"]}\n${unidentifiedMods.map((mod) => `- ${mod}`).join("\n")}`,
);
}
}
return issues.join("\n\n");

View File

@ -1,13 +1,21 @@
<template>
<template v-if="moderation">
<Chips v-model="reasonFilter" :items="reasons" />
<p v-if="reports.length === MAX_REPORTS" class="text-red">
There are at least {{ MAX_REPORTS }} open reports. This page is at its max reports and will
not show any more recent ones.
</p>
<p v-else-if="reasonFilter === 'All'">There are {{ filteredReports.length }} open reports.</p>
<p v-else>
There are {{ filteredReports.length }}/{{ reports.length }} open '{{ reasonFilter }}' reports.
</p>
</template>
<ReportInfo
v-for="report in reports.filter(
(x) =>
(moderation || x.reporterUser.id === auth.user.id) &&
(viewMode === 'open' ? x.open : !x.open),
)"
v-for="report in filteredReports"
:key="report.id"
:report="report"
:thread="report.thread"
:show-message="false"
:moderation="moderation"
raised
:auth="auth"
@ -16,11 +24,12 @@
<p v-if="reports.length === 0">You don't have any active reports.</p>
</template>
<script setup>
import { Chips } from "@modrinth/ui";
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
import { addReportMessage } from "~/helpers/threads.js";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
defineProps({
const props = defineProps({
moderation: {
type: Boolean,
default: false,
@ -32,9 +41,14 @@ defineProps({
});
const viewMode = ref("open");
const reasonFilter = ref("All");
const reports = ref([]);
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report?count=1000"));
const MAX_REPORTS = 1500;
let { data: rawReports } = await useAsyncData("report", () =>
useBaseFetch(`report?count=${MAX_REPORTS}`),
);
rawReports = rawReports.value.map((report) => {
report.item_id = report.item_id.replace(/"/g, "");
@ -51,6 +65,7 @@ const userIds = [...new Set(reporterUsers.concat(reportedUsers))];
const threadIds = [
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
];
const reasons = ["All", ...new Set(rawReports.map((report) => report.report_type))];
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
@ -93,4 +108,13 @@ reports.value = rawReports.map((report) => {
report.open = true;
return report;
});
const filteredReports = computed(() =>
reports.value?.filter(
(x) =>
(props.moderation || x.reporterUser.id === props.auth.user.id) &&
(viewMode.value === "open" ? x.open : !x.open) &&
(reasonFilter.value === "All" || reasonFilter.value === x.report_type),
),
);
</script>

View File

@ -3,6 +3,7 @@ export * from './types/messages'
export * from './types/stage'
export * from './types/keybinds'
export * from './utils'
export { finalPermissionMessages } from './data/modpack-permissions-stage'
export { default as checklist } from './data/checklist'
export { default as keybinds } from './data/keybinds'

View File

@ -315,7 +315,7 @@ export interface ModerationPermissionType {
export interface ModerationBaseModpackItem {
sha1: string
file_name: string
type: 'unknown' | 'flame'
type: 'unknown' | 'flame' | 'identified'
status: ModerationModpackPermissionApprovalType['id'] | null
approved: ModerationPermissionType['id'] | null
}
@ -334,9 +334,26 @@ export interface ModerationFlameModpackItem extends ModerationBaseModpackItem {
url: string
}
export type ModerationModpackItem = ModerationUnknownModpackItem | ModerationFlameModpackItem
export interface ModerationIdentifiedModpackItem extends ModerationBaseModpackItem {
type: 'identified'
proof?: string
url?: string
title?: string
}
export type ModerationModpackItem =
| ModerationUnknownModpackItem
| ModerationFlameModpackItem
| ModerationIdentifiedModpackItem
export interface ModerationModpackResponse {
identified?: Record<
string,
{
file_name: string
status: ModerationModpackPermissionApprovalType['id']
}
>
unknown_files?: Record<string, string>
flame_files?: Record<
string,
@ -350,8 +367,8 @@ export interface ModerationModpackResponse {
}
export interface ModerationJudgement {
type: 'flame' | 'unknown'
status: string
type: 'flame' | 'unknown' | 'identified'
status: string | null
id?: string
link?: string
title?: string