Files
modrinth/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue
T
aecsocket 6ec979a076 fix: hide ad webview when showing fullscreen content (#7110)
* fix: hide ad webview when showing fullscreen content

* fixes
2026-08-12 16:02:02 +00:00

621 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<NewModal
ref="modal"
:header="
reportMode
? formatMessage(messages.reportSharedInstance)
: formatMessage(messages.installToPlay)
"
:closable="!submitLoading"
:on-hide="handleHide"
:max-width="reportMode ? '816px' : '544px'"
:width="reportMode ? '816px' : '544px'"
:no-padding="reportMode"
:scrollable="reportMode"
>
<div v-if="preview" class="flex w-full flex-col gap-6" :class="{ 'p-6 pb-2': reportMode }">
<Admonition v-if="reportMode" type="info">
<div class="flex flex-col gap-2">
<p class="m-0">
<IntlFormatted :message-id="messages.reportDescription">
<template #rules-link="{ children }">
<AutoLink class="text-link hover:underline" to="https://modrinth.com/legal/rules">
<component :is="() => children" />
</AutoLink>
</template>
<template #terms-link="{ children }">
<AutoLink class="text-link hover:underline" to="https://modrinth.com/legal/terms">
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</p>
<ul class="m-0 list-disc pl-5">
<li>
<IntlFormatted :message-id="messages.supportAndBugReports">
<template #support-link="{ children }">
<AutoLink class="text-link hover:underline" to="https://support.modrinth.com">
<component :is="() => children" />
</AutoLink>
</template>
<template #github-link="{ children }">
<AutoLink
class="text-link hover:underline"
to="https://github.com/modrinth/code/issues"
>
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</li>
<li>
<IntlFormatted :message-id="messages.legalClaims">
<template #copyright-link="{ children }">
<AutoLink
class="text-link hover:underline"
to="https://modrinth.com/legal/copyright"
>
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</li>
</ul>
</div>
</Admonition>
<p v-else class="m-0 text-primary">
<IntlFormatted
v-if="creator"
:message-id="messages.inviteWarningWithCreator"
:values="{ username: creator.username }"
>
<template #creator="{ children }">
<AutoLink :to="creatorProfileLink" class="font-medium text-contrast hover:underline">
<Avatar
:src="creator.avatarUrl"
:alt="creator.username"
:tint-by="creator.username"
size="24px"
circle
no-shadow
class="mr-1 inline-block align-middle"
/>
<span><component :is="() => children" /></span>
</AutoLink>
</template>
</IntlFormatted>
<template v-else>
{{ formatMessage(messages.inviteWarning) }}
</template>
</p>
<SharedInstanceInstallSummary
:preview="preview"
:heading="reportMode ? formatMessage(messages.contentYouAreReporting) : undefined"
@view-contents="openViewContents"
/>
<Transition
enter-active-class="overflow-hidden transition-all duration-200 ease-out"
enter-from-class="max-h-0 opacity-0"
enter-to-class="max-h-[400px] opacity-100"
leave-active-class="overflow-hidden transition-all duration-200 ease-in"
leave-from-class="max-h-[400px] opacity-100"
leave-to-class="max-h-0 opacity-0"
>
<div v-if="reportMode" class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.reportReason) }}
</span>
<Combobox v-model="reportReason" :options="reportReasonOptions" />
</div>
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.additionalContext) }}
</span>
<MarkdownEditor
v-model="additionalContext"
:placeholder="formatMessage(messages.additionalContextPlaceholder)"
:on-image-upload="onImageUpload"
:min-height="120"
:max-height="240"
/>
</div>
<div v-if="reportOnly || blockTargetUserId" class="flex flex-col gap-2">
<Checkbox
v-if="reportOnly"
v-model="deleteInstance"
:label="formatMessage(messages.deleteInstance)"
/>
<Checkbox
v-if="blockTargetUserId"
v-model="blockUser"
:label="formatMessage(messages.blockUser)"
/>
</div>
</div>
</Transition>
<Admonition
v-if="!reportMode && hasExternalFiles"
type="warning"
:header="formatMessage(messages.unknownFilesWarning)"
>
{{ formatMessage(messages.unknownFilesDescription) }}
</Admonition>
<div v-if="!reportMode && hasExternalFiles" class="relative w-full">
<div
ref="externalFileTable"
class="max-h-[242px] overflow-y-auto rounded-2xl"
@scroll="checkTableScrollState"
>
<Table
:columns="externalFileColumns"
:data="externalFileRows"
row-key="id"
virtualized
:virtual-row-height="48"
class="shadow-sm"
>
<template #cell-name="{ value }">
<span class="block truncate" :title="String(value)">{{ value }}</span>
</template>
</Table>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTableTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTableBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
<p v-if="!reportMode && hasExternalFiles" class="m-0 text-primary">
{{ formatMessage(messages.reviewedFiles) }}
</p>
<div v-if="!reportMode" class="flex w-full items-center justify-between gap-2">
<Button type="quiet" color="red" @click="reportMode = true">
<ReportIcon />{{ formatMessage(commonMessages.reportButton) }}
</Button>
<div class="flex items-center gap-2">
<template v-if="hasExternalFiles">
<Button type="quiet" color="orange" @click="accept">
{{ formatMessage(messages.installAnyway) }}
</Button>
<Button type="colored" color="brand" @click="handleCancel">
<BanIcon />{{ formatMessage(messages.dontInstall) }}
</Button>
</template>
<template v-else>
<Button type="outlined" class="!border" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" @click="accept">
<DownloadIcon />{{ formatMessage(messages.installButton) }}
</Button>
</template>
</div>
</div>
</div>
<template v-if="reportMode" #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" class="!border" :disabled="submitLoading" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" :disabled="!canSubmitReport" @click="submitReport">
<SpinnerIcon v-if="submitLoading" class="animate-spin" />
<SendIcon v-else />
{{ formatMessage(commonMessages.reportButton) }}
</Button>
</div>
</template>
</NewModal>
<ModpackContentModal
ref="contentModal"
:header="formatMessage(messages.sharedInstanceContent)"
:modpack-name="preview?.name ?? ''"
:modpack-icon-url="preview?.iconUrl ?? undefined"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { BanIcon, DownloadIcon, ReportIcon, SendIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import {
Admonition,
AutoLink,
Avatar,
blockedUsersQueryKey,
Checkbox,
Combobox,
type ComboboxOption,
commonMessages,
defineMessages,
injectAuth,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
MarkdownEditor,
ModpackContentModal,
NewModal,
Table,
type TableColumn,
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, ref } from 'vue'
import { config } from '@/config'
import { toError } from '@/helpers/errors'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
import { create_report } from '@/helpers/reports'
import { block_user } from '@/helpers/users'
import SharedInstanceInstallSummary from './shared-instance-install-summary.vue'
import { useSharedInstancePreviewContent } from './use-shared-instance-preview-content'
type ExternalFileColumn = 'name'
type ExternalFileRow = {
id: string
name: string
}
type SharedInstanceCreator = {
id: string | null
username: string
avatarUrl: string | null
}
const modal = ref<InstanceType<typeof NewModal>>()
const contentModal = ref<InstanceType<typeof ModpackContentModal>>()
const externalFileTable = ref<HTMLElement | null>(null)
const preview = ref<SharedInstanceInstallPreview | null>(null)
const creator = ref<SharedInstanceCreator | null>(null)
const install = ref<() => void | Promise<void>>(() => {})
const reportMode = ref(false)
const reportOnly = ref(false)
type ReportReason = 'malicious' | 'inappropriate' | 'spam'
const reportReason = ref<ReportReason>('malicious')
const additionalContext = ref('')
const deleteInstance = ref(true)
const blockUser = ref(true)
const blockTargetUserId = ref<string | null>(null)
const submitLoading = ref(false)
const uploadedImageIDs = ref<string[]>([])
const emit = defineEmits<{
reported: [deleteInstance: boolean]
}>()
const { formatMessage } = useVIntl()
const auth = injectAuth()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification, handleError } = injectNotificationManager()
const { load } = useSharedInstancePreviewContent()
const {
showTopFade: showTableTopFade,
showBottomFade: showTableBottomFade,
checkScrollState: checkTableScrollState,
forceCheck: forceCheckTableScroll,
} = useScrollIndicator(externalFileTable)
const hasExternalFiles = computed(() => Boolean(preview.value?.externalFiles.length))
const externalFileRows = computed<ExternalFileRow[]>(() =>
(preview.value?.externalFiles ?? [])
.map((file, index) => ({
id: `${index}-${file.fileType}-${file.fileName}`,
name: file.fileName,
}))
.sort((left, right) => left.name.localeCompare(right.name)),
)
const reportReasonOptions = computed<ComboboxOption<ReportReason>[]>(() => [
{ value: 'malicious', label: formatMessage(messages.maliciousReason) },
{ value: 'inappropriate', label: formatMessage(messages.inappropriateReason) },
{ value: 'spam', label: formatMessage(messages.spamReason) },
])
const canSubmitReport = computed(
() => Boolean(preview.value && additionalContext.value.trim()) && !submitLoading.value,
)
const creatorProfileLink = computed(() => {
const username = creator.value?.username
return username
? () => openUrl(`${config.siteUrl}/user/${encodeURIComponent(username)}`)
: undefined
})
async function accept() {
hide()
try {
await install.value()
} catch (error) {
console.error('Failed to install shared instance:', error)
}
}
async function openViewContents() {
if (!preview.value) return
contentModal.value?.showLoading()
try {
contentModal.value?.show(await load(preview.value))
} catch (error) {
console.error('Failed to load shared instance contents:', error)
contentModal.value?.show([])
}
}
async function submitReport() {
const reportPreview = preview.value
const body = additionalContext.value.trim()
if (!reportPreview || !body || submitLoading.value) return
submitLoading.value = true
try {
const uploadedImages = uploadedImageIDs.value.slice(-10)
const blockTarget = blockUser.value ? blockTargetUserId.value : null
const [reportResult, blockResult] = await Promise.allSettled([
create_report({
report_type: reportReason.value,
item_type: 'shared-instance',
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
body,
uploaded_images: uploadedImages,
}),
blockTarget ? block_user(blockTarget) : Promise.resolve(),
])
if (blockTarget) {
if (blockResult.status === 'fulfilled') {
blockUser.value = false
const authUserId = auth.user.value?.id
if (authUserId) {
queryClient.setQueryData<Labrinth.BlockedUsers.v3.BlockedUserId[]>(
blockedUsersQueryKey(authUserId),
(blockedUsers = []) =>
blockedUsers.includes(blockTarget) ? blockedUsers : [...blockedUsers, blockTarget],
)
}
addNotification({
type: 'success',
title: formatMessage(messages.userBlocked),
})
} else {
handleError(toError(blockResult.reason))
}
}
if (reportResult.status === 'rejected') throw reportResult.reason
const shouldDeleteInstance = reportOnly.value && deleteInstance.value
hide()
addNotification({
type: 'success',
title: formatMessage(messages.reportSubmitted),
})
emit('reported', shouldDeleteInstance)
} catch (error) {
handleError(toError(error))
} finally {
submitLoading.value = false
}
}
async function onImageUpload(file: File) {
const imageExtensionByType: Partial<Record<string, Labrinth.Images.v3.ImageExtension>> = {
'image/gif': 'gif',
'image/jpeg': 'jpeg',
'image/png': 'png',
'image/webp': 'webp',
}
const extension = imageExtensionByType[file.type]
if (!extension) {
throw new Error(formatMessage(messages.invalidImageType))
}
if (file.size > 1024 * 1024) {
throw new Error(formatMessage(messages.imageTooLarge))
}
const image = await client.labrinth.images_v3.uploadImage(file, extension, {
context: 'report',
}).promise
uploadedImageIDs.value.push(image.id)
return image.url
}
function handleCancel() {
if (reportMode.value && !reportOnly.value) {
reportMode.value = false
void nextTick(() => forceCheckTableScroll())
return
}
hide()
}
function handleHide() {
resetReportState()
creator.value = null
}
function resetReportState() {
reportMode.value = false
reportOnly.value = false
reportReason.value = 'malicious'
additionalContext.value = ''
deleteInstance.value = true
blockUser.value = true
blockTargetUserId.value = null
submitLoading.value = false
uploadedImageIDs.value = []
}
function show(
previewValue: SharedInstanceInstallPreview,
installValue: () => void | Promise<void>,
creatorValue?: SharedInstanceCreator,
event?: MouseEvent,
) {
resetReportState()
creator.value = creatorValue ?? null
blockTargetUserId.value = creatorValue?.id ?? null
install.value = installValue
showPreview(previewValue, event)
}
function showReport(
previewValue: SharedInstanceInstallPreview,
blockTargetUserIdValue?: string | null,
event?: MouseEvent,
) {
resetReportState()
creator.value = null
blockTargetUserId.value = blockTargetUserIdValue ?? null
reportMode.value = true
reportOnly.value = true
install.value = () => {}
showPreview(previewValue, event)
}
function showPreview(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
preview.value = previewValue
modal.value?.show(event)
void nextTick(() => forceCheckTableScroll())
}
function hide() {
modal.value?.hide()
}
const messages = defineMessages({
installToPlay: { id: 'app.modal.install-to-play.header', defaultMessage: 'Install to play' },
reportSharedInstance: {
id: 'app.modal.install-to-play.report-shared-instance-header',
defaultMessage: 'Report shared instance',
},
reportSubmitted: {
id: 'app.modal.install-to-play.report-submitted',
defaultMessage: 'Report submitted',
},
sharedInstanceContent: {
id: 'app.modal.install-to-play.shared-instance-content',
defaultMessage: 'Shared instance content',
},
inviteWarning: {
id: 'app.modal.install-to-play.invite-warning',
defaultMessage:
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
},
inviteWarningWithCreator: {
id: 'app.modal.install-to-play.invite-warning-with-creator',
defaultMessage:
'This invite was created by <creator>{username}</creator>, not Modrinth. Only accept invites from people you trust.',
},
reportDescription: {
id: 'app.modal.install-to-play.report-description',
defaultMessage:
'Use this form to report instances that may violate our <rules-link>Rules</rules-link> or <terms-link>Terms of Use</terms-link>.',
},
supportAndBugReports: {
id: 'app.modal.install-to-play.report-support-and-bugs',
defaultMessage:
'For support requests, contact our <support-link>support team</support-link>. For bug reports, open a <github-link>GitHub issue</github-link>.',
},
legalClaims: {
id: 'app.modal.install-to-play.report-legal-claims',
defaultMessage:
'For DMCA notices or other legal claims, see our <copyright-link>Copyright Policy</copyright-link>.',
},
contentYouAreReporting: {
id: 'app.modal.install-to-play.content-you-are-reporting',
defaultMessage: 'Instance youre reporting',
},
reportReason: {
id: 'app.modal.install-to-play.report-reason',
defaultMessage: 'Which rule does this instance violate?',
},
maliciousReason: {
id: 'app.modal.install-to-play.report-reason.malicious',
defaultMessage: 'Malicious',
},
inappropriateReason: {
id: 'app.modal.install-to-play.report-reason.inappropriate',
defaultMessage: 'Inappropriate',
},
spamReason: {
id: 'app.modal.install-to-play.report-reason.spam',
defaultMessage: 'Spam',
},
additionalContext: {
id: 'app.modal.install-to-play.additional-context',
defaultMessage: 'Additional context',
},
additionalContextPlaceholder: {
id: 'app.modal.install-to-play.additional-context-placeholder',
defaultMessage: 'Include links and images if possible and relevant',
},
invalidImageType: {
id: 'app.modal.install-to-play.report-image-invalid-type',
defaultMessage: 'File is not an accepted image type',
},
imageTooLarge: {
id: 'app.modal.install-to-play.report-image-too-large',
defaultMessage: 'File exceeds the 1 MiB size limit',
},
deleteInstance: {
id: 'app.modal.install-to-play.delete-instance',
defaultMessage: 'Delete instance',
},
blockUser: {
id: 'app.modal.install-to-play.block-user',
defaultMessage: 'Block user',
},
userBlocked: {
id: 'app.modal.install-to-play.user-blocked',
defaultMessage: 'User blocked',
},
unknownFilesWarning: {
id: 'app.modal.install-to-play.unknown-files-warning',
defaultMessage: 'Unknown files warning',
},
unknownFilesDescription: {
id: 'app.modal.install-to-play.shared-instance-unknown-files-description',
defaultMessage:
'This shared instance contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust.',
},
unrecognizedFiles: {
id: 'app.modal.install-to-play.unrecognized-files',
defaultMessage: 'Unrecognized files',
},
reviewedFiles: {
id: 'app.modal.install-to-play.reviewed-files',
defaultMessage: "Files that aren't published to Modrinth aren't reviewed.",
},
installAnyway: {
id: 'app.modal.install-to-play.install-anyway',
defaultMessage: 'Install anyway',
},
dontInstall: {
id: 'app.modal.install-to-play.external-files-dont-install',
defaultMessage: "Don't install",
},
installButton: { id: 'app.modal.install-to-play.install-button', defaultMessage: 'Install' },
})
const externalFileColumns = computed<TableColumn<ExternalFileColumn>[]>(() => [
{
key: 'name',
label: formatMessage(messages.unrecognizedFiles),
cellClass: '!h-12',
},
])
defineExpose({ show, showReport, hide })
</script>