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
5 changes: 5 additions & 0 deletions apps/frontend/src/components/project-review/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
<template #permissions><Permissions :key="projectId" /></template>
<template #versions><Versions :key="projectId" /></template>
<template #tech-review><TechReview :key="projectId" /></template>
<template #slicer><Slicer :key="projectId" /></template>
</ProjectReviewLayout>
<template #fallback>
<p class="m-0 p-4 text-secondary" role="status">
Expand Down Expand Up @@ -122,6 +123,7 @@ import {
createReviewSession,
provideReviewSession,
} from '~/providers/project-review/review-session'
import { createReviewSlicer, provideReviewSlicer } from '~/providers/project-review/review-slicer'
import {
createReviewSubmission,
provideReviewSubmission,
Expand All @@ -144,6 +146,7 @@ import QueueBar from './queue-bar.vue'
import ReviewOutcomeButtons from './review-outcome-buttons/index.vue'
import { createReviewContext, provideReviewContext } from './review-panel/context'
import { createReviewShortcuts, provideReviewShortcuts, reviewShortcutBlocked } from './shortcuts'
import Slicer from './slicer/index.vue'
import TechReview from './tech-review/index.vue'
import Versions from './versions/index.vue'

Expand All @@ -166,7 +169,9 @@ const {
selection,
isLoading,
navigation,
versions,
} = injectProjectReviewPageContext()
provideReviewSlicer(createReviewSlicer(projectId, versions))
const visibleTabs = computed<readonly ProjectReviewTab[]>((previousTabs) => {
if (!project.value) {
return previousTabs ?? projectReviewTabs.filter((tab) => tab !== 'permissions')
Expand Down
5 changes: 5 additions & 0 deletions apps/frontend/src/components/project-review/layout/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ export const [injectProjectReviewContext, provideProjectReviewContext] =

export const [injectReviewSlot, provideReviewSlot] =
createContext<Ref<ProjectReviewSlot>>('ProjectReviewSlot')

/** Whether the surrounding panel is currently shown, e.g. it is the active tab in its group. */
export const [injectReviewPanelVisible, provideReviewPanelVisible] = createContext<
Readonly<Ref<boolean>>
>('ProjectReviewPanelVisible')
18 changes: 15 additions & 3 deletions apps/frontend/src/components/project-review/layout/panel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
slotName === 'right' ||
slotName === 'disclosures' ||
slotName === 'permissions' ||
slotName === 'gallery'
slotName === 'gallery' ||
slotName === 'slicer'
? 'overflow-hidden'
: 'overflow-auto'
"
Expand All @@ -25,10 +26,10 @@
<script setup lang="ts">
import { useVIntl } from '@modrinth/ui'
import type { IDockviewPanelProps } from 'dockview-vue'
import { computed } from 'vue'
import { computed, ref, watchEffect } from 'vue'

import { projectReviewMessages as messages } from '../messages'
import { injectProjectReviewContext, provideReviewSlot } from './context'
import { injectProjectReviewContext, provideReviewPanelVisible, provideReviewSlot } from './context'
import type { ProjectReviewSlot } from './types'

defineOptions({ inheritAttrs: false })
Expand All @@ -43,6 +44,17 @@ const slotName = computed(() =>
'slot' in props.params ? props.params.slot : props.params.params.slot,
)
provideReviewSlot(slotName)
const panelVisible = ref(true)
watchEffect((onCleanup) => {
if (!('api' in props.params)) return
const api = props.params.api
panelVisible.value = api.isVisible
const subscription = api.onDidVisibilityChange((event) => {
panelVisible.value = event.isVisible
})
onCleanup(() => subscription.dispose())
})
provideReviewPanelVisible(panelVisible)
const content = computed(() => slots[slotName.value])
const panelHidden = computed(
() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const projectReviewTabs = [
'versions',
'permissions',
'tech-review',
'slicer',
] as const

export type ProjectReviewTab = (typeof projectReviewTabs)[number]
Expand Down
12 changes: 12 additions & 0 deletions apps/frontend/src/components/project-review/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ export const projectReviewMessages = defineMessages({
id: 'moderation.project-review.tech-review',
defaultMessage: 'Tech Review',
},
slicer: {
id: 'moderation.project-review.slicer',
defaultMessage: 'Slicer',
},
slicerOpenExternal: {
id: 'moderation.project-review.slicerOpenExternal',
defaultMessage: 'Open in Slicer in a new tab',
},
showLeft: {
id: 'moderation.project-review.showLeft',
defaultMessage: 'Show project details',
Expand Down Expand Up @@ -637,6 +645,10 @@ export const projectReviewMessages = defineMessages({
id: 'moderation.project-review.openSlicer',
defaultMessage: 'Open in Slicer',
},
openVersionSlicer: {
id: 'moderation.project-review.openVersionSlicer',
defaultMessage: 'Open all files in Slicer',
},
scanAi: {
id: 'moderation.project-review.scanAi',
defaultMessage: 'Scan for AI-generated images',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const STORAGE_KEY = 'moderation.project-review.slicer-bridge.v1'

/**
* Version of `bridge.js`. Bump whenever the script changes so reviewers with an older copy
* installed in slicer are prompted to import the new one.
*/
export const SLICER_BRIDGE_VERSION = 4

interface SavedSlicerBridge {
version: number
}

/** Returns the bridge version slicer last confirmed it has installed, if any. */
export function readInstalledBridgeVersion(): number | undefined {
try {
const saved: SavedSlicerBridge | null = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null')
return typeof saved?.version === 'number' && Number.isFinite(saved.version)
? saved.version
: undefined
} catch {
return undefined
}
}

export function saveInstalledBridgeVersion(version: number) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ version } satisfies SavedSlicerBridge))
} catch {
// no-op
}
}

export function forgetInstalledBridge() {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
// no-op
}
}
127 changes: 127 additions & 0 deletions apps/frontend/src/components/project-review/slicer/bridge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Slicer script injected into the embedded slicer.run iframe via its `script` URL parameter.
* It listens for `load` requests from the Modrinth moderation page, replaces the slicer workspace
* with the requested files when they differ from what is loaded, and opens the requested entry.
*
* Slicer keeps imported scripts and loads them on startup, so several versions of this script may
* be installed at once. Only the newest one handles messages, and it announces its version in the
* `ready` handshake so the page knows whether it needs to import an update.
*
* The parent origin and version placeholders below are filled in by the page before injection.
*/
const PARENT_ORIGIN = '__MODRINTH_PARENT_ORIGIN__'
const VERSION = Number('__MODRINTH_BRIDGE_VERSION__')
const SOURCE = 'modrinth-slicer-bridge'
const INSTANCE_KEY = '__modrinthSlicerBridge'

const TAB_TYPES_BY_EXTENSION = {
image: ['jpg', 'jpeg', 'gif', 'png', 'webp'],
media: ['mp4', 'webm', 'ogg', 'mp3', 'wav', 'flac', 'mov', 'avi', 'm4a', 'aac', 'mkv'],
dump: ['hprof'],
}

let instance

function post(message) {
window.parent.postMessage({ source: SOURCE, ...message }, PARENT_ORIGIN)
}

/** Picks the tab type slicer itself would use when opening an entry with this name. */
function tabType(name) {
const extension = name.split('.').pop()?.toLowerCase() ?? ''
const match = Object.entries(TAB_TYPES_BY_EXTENSION).find(([, extensions]) =>
extensions.includes(extension),
)
return match?.[0] ?? 'code'
}

/**
* Finds a workspace entry by name. Falls back to the only entry sharing the same path below its
* top-level file, in case the top-level name differs from what the page expected.
*/
function findEntry(context, name) {
const exact = context.workspace.find(name)
if (exact) return exact
const separator = name.indexOf('/')
if (separator === -1) return null
const suffix = name.slice(separator)
const matches = context.workspace.entries().filter((entry) => entry.name.endsWith(suffix))
return matches.length === 1 ? matches[0] : null
}

/** Whether the workspace's top-level entries are exactly the given file names. */
function isLoaded(context, names) {
const loaded = context.workspace
.entries()
.map((entry) => entry.name)
.filter((name) => !name.includes('/'))
return loaded.length === names.length && names.every((name) => loaded.includes(name))
}

export default {
name: 'Modrinth moderation bridge',
description: 'Lets the Modrinth project review page load version files into slicer.',
version: '__MODRINTH_BRIDGE_VERSION__',
load(context) {
if (window.parent === window) return
const existing = window[INSTANCE_KEY]
if (existing && existing.version > VERSION) return
existing?.dispose()

let latestId
async function onMessage(event) {
if (event.source !== window.parent || event.origin !== PARENT_ORIGIN) return
const data = event.data
if (data?.source !== SOURCE || data.type !== 'load') return
latestId = data.id
try {
if (
!isLoaded(
context,
data.files.map(({ name }) => name),
)
) {
const files = await Promise.all(
data.files.map(async ({ url, name }) => {
const response = await fetch(url)
if (!response.ok) throw new Error(`${name}: HTTP ${response.status}`)
return { name, blob: await response.blob() }
}),
)
if (latestId !== data.id) return
await context.workspace.clear()
for (const { name, blob } of files) {
await context.workspace.add(name, blob)
}
}
if (!data.entry) return
const entry = findEntry(context, data.entry)
if (entry) {
await context.editor.add(tabType(entry.name), entry)
} else {
context.notification.warning('Could not find file in workspace', {
description: data.entry,
})
}
} catch (error) {
context.notification.error('Could not load files from Modrinth', {
description: String(error?.message ?? error),
})
}
}

window.addEventListener('message', onMessage)
instance = {
version: VERSION,
dispose: () => window.removeEventListener('message', onMessage),
}
window[INSTANCE_KEY] = instance
post({ type: 'ready', version: VERSION })
},
unload() {
if (!instance || window[INSTANCE_KEY] !== instance) return
instance.dispose()
delete window[INSTANCE_KEY]
instance = undefined
},
}
Loading
Loading