mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 10:04:52 +00:00
feat: instance sharing thru shared-instances service (#6569)
* feat: implement instance share page + search_users backend call * feat: invite players modal * feat: use tanstack queries for friends sync across app pages * feat: base shared instances implementation * fix: admon style * feat: impl instance admonitions like server panel * fix: impl get + del usage * feat: support modpack links * feat: invite notif accepting * fix: lint + fmt * feat: impl install to play * feat: impl usage of UpdateToPlayModal * feat: warnings on deleting/disabling shared-instance version content * fix: send instance name * feat: align with backend * feat: shared instances qa * feat: wrong account protection * feat: qa * fix: smartly apply updates * fix: install bug * fix: 401/404 differentiation * fix: fmt+prepr * feat: qa * feat: qa * fix: signing out messes up revoke/deleted checks * feat: qa * fix: fmt + lint * feat: lock content if part of shared instance * fix: lint * [do not merge] feat: rough invite links impl temp (#6666) * fix: wrong cmd * feat: invite page * fix: server-manager DTO mismatch * fix: drop anonymous invite link acceptance * refactor: structured shared-instance unavailable errors * refactor: centralise error presentations * refactor: dedupe shared instance diff detection * fix: logging in reqwests * refactor: move app.vue shared instances into handler * refactor: break up Share.vue * refactor: split up shared instances state outside of instance index * refactor: dedicated shared instances install/update modals + split up page * refactor: centralized managed content * refactor: split up install shared to own runner + shared.rs split up * refactor: dedupe sql for instance metadata enrichmnt * refactor: friends composable + dedupe friends logic across usages * chore: reduced unused code * fix: align with backend * fix: lint * fix: file sha changes * fix: invite links not working due to icon signed * feat: qa * feat: reporting frontend dummy * fix: try use header * remove: file hash field * fix: pin box * feat: malware warning for shared instances * fix: cache rule * feat: config files syncing * feat: disable config sharing * fix: header * fix: use mark ready * fix: dont cause push update for configs * fix: lint * feat: sharing page in settings * feat: move config + change flow * fix: qa * fix: lint prepr * feat: proxy file upload thru shared instances backend * fix: use collapisible * fix: push config * fix: config * feat: swap out sign in modal for new one * fix: report flow * fix: exclude configs.zip from external warnings * fix: nuxi init * fix: config bundle downloading * fix: error notif * fix: polling * fix: qa * fix: lint + prepr * feat: shared instances moderation frontend + hook up report flow * fix: report copy * fix: lint * fix: lint * fix: modrinth ids being undefined * feat: instance quarantining * fix: prepr + fmt * fix: quarantined -> locked terminology * fix: missing endpoint impls + fmt * fix: missing api in build.rs * fix: share tab jittery * fix: fmt *PT bug * fix: invites count as users even if pending * fix: prepr * fix: invite page owner in users list * fix: lint * fix: qa * fix: lint * fix: members stale not clearing * fix: invite use joined_at field * fix: lint * fix: qa --------- Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
This commit is contained in:
@@ -154,12 +154,15 @@ const { data, isPending, error, refetch } = useQuery({
|
||||
const notifications = await client.labrinth.notifications_v2.getUserNotifications(
|
||||
auth.value?.user?.id,
|
||||
)
|
||||
const visibleNotifications = notifications.filter(
|
||||
(n) => !n.type?.startsWith('shared_instance_'),
|
||||
)
|
||||
|
||||
const typesInFeed = [
|
||||
...new Set(notifications.filter((n) => showRead || !n.read).map((n) => n.type)),
|
||||
...new Set(visibleNotifications.filter((n) => showRead || !n.read).map((n) => n.type)),
|
||||
]
|
||||
|
||||
const filtered = notifications.filter(
|
||||
const filtered = visibleNotifications.filter(
|
||||
(n) =>
|
||||
(selectedType.value === 'all' || n.type === selectedType.value) && (showRead || !n.read),
|
||||
)
|
||||
@@ -173,7 +176,7 @@ const { data, isPending, error, refetch } = useQuery({
|
||||
notifications: notifs,
|
||||
notifTypes: typesInFeed.length > 1 ? ['all', ...typesInFeed] : typesInFeed,
|
||||
pages,
|
||||
hasRead: notifications.some((n) => n.read),
|
||||
hasRead: visibleNotifications.some((n) => n.read),
|
||||
}))
|
||||
},
|
||||
enabled: computed(() => !!auth.value?.user?.id),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { injectModrinthClient } from '@modrinth/ui'
|
||||
import type { Report } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
|
||||
import ModerationReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
|
||||
@@ -13,8 +12,8 @@ const { data: report } = useQuery({
|
||||
queryKey: computed(() => ['report', reportId]),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const report = (await client.labrinth.reports_v3.get(reportId)) as Report
|
||||
const enrichedReport = (await enrichReportBatch([report]))[0]
|
||||
const report = await client.labrinth.reports_v3.get(reportId)
|
||||
const enrichedReport = (await enrichReportBatch([report], client))[0]
|
||||
return enrichedReport
|
||||
} catch (error) {
|
||||
console.error('Error fetching report:', error)
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
|
||||
<ReportCard v-for="report in paginatedReports" v-else :key="report.id" :report="report" />
|
||||
<ReportCard v-for="report in paginatedReports" :key="report.id" :report="report" />
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||
@@ -204,13 +204,13 @@ import {
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
FloatingPanel,
|
||||
injectModrinthClient,
|
||||
MultiSelect,
|
||||
type MultiSelectItem,
|
||||
Pagination,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { Report, User } from '@modrinth/utils'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
import ReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
|
||||
@@ -222,6 +222,7 @@ const { formatMessage } = useVIntl()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = await useAuth()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const { data: allReports } = await useLazyAsyncData('new-moderation-reports', async () => {
|
||||
const startTime = performance.now()
|
||||
@@ -231,19 +232,19 @@ const { data: allReports } = await useLazyAsyncData('new-moderation-reports', as
|
||||
|
||||
const enrichmentPromises: Promise<ExtendedReport[]>[] = []
|
||||
|
||||
let reports: Report[]
|
||||
let reports: Labrinth.Reports.v3.Report[]
|
||||
let hasMoreReports = true
|
||||
while (hasMoreReports) {
|
||||
reports = (await useBaseFetch(`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}`, {
|
||||
apiVersion: 3,
|
||||
})) as Report[]
|
||||
})) as Labrinth.Reports.v3.Report[]
|
||||
|
||||
hasMoreReports = reports.length > 0
|
||||
if (!hasMoreReports) {
|
||||
break
|
||||
}
|
||||
|
||||
const enrichmentPromise = enrichReportBatch(reports)
|
||||
const enrichmentPromise = enrichReportBatch(reports, client)
|
||||
enrichmentPromises.push(enrichmentPromise)
|
||||
|
||||
// this is explicitly not the length of the reports array, because the API may return fewer reports due to a report in the middle not being
|
||||
@@ -323,6 +324,7 @@ const projectTypeFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'plugin', label: 'Plugins' },
|
||||
{ value: 'shader', label: 'Shaders' },
|
||||
{ value: 'minecraft_java_server', label: 'Servers' },
|
||||
{ value: 'shared-instance', label: 'Shared instance' },
|
||||
]
|
||||
|
||||
const currentReportTargetFilter = ref('all')
|
||||
@@ -331,6 +333,7 @@ const reportTargetFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'project', label: 'Projects' },
|
||||
{ value: 'user', label: 'Users' },
|
||||
{ value: 'version', label: 'Versions' },
|
||||
{ value: 'shared-instance', label: 'Shared instances' },
|
||||
]
|
||||
|
||||
const currentReportIssueFilter = ref('all')
|
||||
@@ -505,7 +508,9 @@ const filteredReports = computed(() => {
|
||||
}
|
||||
|
||||
const projectTypeFilterPredicate = (report: ExtendedReport) => {
|
||||
return projectTypeFilter === 'all' || report.project?.project_type === projectTypeFilter
|
||||
if (projectTypeFilter === 'all') return true
|
||||
if (projectTypeFilter === 'shared-instance') return report.item_type === 'shared-instance'
|
||||
return report.project?.project_type === projectTypeFilter
|
||||
}
|
||||
|
||||
const reportTargetFilterPredicate = (report: ExtendedReport) => {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<main
|
||||
class="relative isolate min-h-screen overflow-hidden bg-surface-1 px-4 pb-16 pt-36 sm:pt-52"
|
||||
>
|
||||
<div
|
||||
class="absolute left-1/2 top-0 -z-10 h-[23.3125rem] w-full -translate-x-1/2 overflow-hidden rounded-none !rounded-b-none sm:top-10 sm:w-[min(70rem,100%)] sm:rounded-[2.375rem]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
:src="InviteBackgroundIllustration"
|
||||
class="absolute inset-0 size-full scale-[2] object-cover object-[center_25%]"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
v-if="instanceIcon"
|
||||
:src="instanceIcon"
|
||||
class="absolute -inset-1/2 size-[200%] object-cover opacity-90 blur-[160px] saturate-200 [mix-blend-mode:color]"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-[linear-gradient(to_bottom,transparent_20%,var(--surface-1)_100%)]"
|
||||
/>
|
||||
</div>
|
||||
<SharedInstanceInviteOpenInAppModal ref="openInAppModal" />
|
||||
|
||||
<div v-if="isPending" class="relative z-10 flex flex-col items-center gap-4 text-secondary">
|
||||
<LoaderCircleIcon class="size-10 animate-spin" />
|
||||
<span>{{ formatMessage(messages.loadingInvite) }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error || !invite"
|
||||
class="relative z-10 mx-auto flex max-w-md flex-col items-center gap-5 text-center"
|
||||
>
|
||||
<CircleAlertIcon class="size-12 text-secondary" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="m-0 text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.unavailableTitle) }}
|
||||
</h1>
|
||||
<p class="m-0 text-primary">
|
||||
{{ formatMessage(messages.unavailableDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="relative z-10 mx-auto flex w-full max-w-[26.25rem] flex-col items-center gap-7"
|
||||
>
|
||||
<div class="invite-avatar relative size-[120px] shrink-0 rounded-full">
|
||||
<Avatar
|
||||
:src="inviterAvatar"
|
||||
:alt="inviterName"
|
||||
size="120px"
|
||||
circle
|
||||
class="!border-0 !shadow-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<div class="flex flex-col items-center gap-2.5">
|
||||
<h1 class="m-0 text-2xl font-semibold leading-8 text-contrast">
|
||||
{{ formatMessage(messages.invitationTitle, { name: inviterName }) }}
|
||||
</h1>
|
||||
<div
|
||||
class="flex w-max max-w-[calc(100vw-2rem)] flex-nowrap items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap text-lg leading-6 text-primary"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap rounded-xl font-semibold text-contrast"
|
||||
>
|
||||
<Avatar :src="instanceIcon" :alt="invite.instance_name" size="32px" no-shadow />
|
||||
<span class="min-w-0 truncate whitespace-nowrap">{{ invite.instance_name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="instanceUsers.length"
|
||||
class="flex items-center gap-2 rounded-full border border-solid border-surface-3 bg-surface-2 px-4 py-2 text-primary [box-shadow:0_1px_1px_rgb(0_0_0/12%)]"
|
||||
>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Avatar
|
||||
v-for="(user, index) in visibleInstanceUsers"
|
||||
:key="user.id"
|
||||
:src="user.avatar"
|
||||
:alt="user.name"
|
||||
:tint-by="user.id"
|
||||
size="28px"
|
||||
circle
|
||||
no-shadow
|
||||
class="!border-2 !border-surface-2"
|
||||
:class="{ '-mr-1.5': index < visibleInstanceUsers.length - 1 || hiddenUserCount > 0 }"
|
||||
/>
|
||||
<div
|
||||
v-if="hiddenUserCount"
|
||||
class="flex size-7 items-center justify-center rounded-full border-2 border-solid border-surface-2 bg-surface-3 text-xs font-medium leading-4 text-primary"
|
||||
>
|
||||
+{{ hiddenUserCount }}
|
||||
</div>
|
||||
</div>
|
||||
{{ formatMessage(messages.joinedCount, { count: instanceUsers.length }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<button type="button" @click="acceptInvite">
|
||||
<UserPlusIcon />
|
||||
{{ formatMessage(messages.acceptInvite) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<div class="flex w-full flex-col gap-2.5">
|
||||
<span class="pl-3 font-medium text-primary">{{
|
||||
formatMessage(messages.knowThisUser)
|
||||
}}</span>
|
||||
<Admonition type="neutral" class="shadow-card !text-primary">
|
||||
<template #icon="{ iconClass }">
|
||||
<CircleAlertIcon :class="[...iconClass, '!text-orange']" />
|
||||
</template>
|
||||
{{ formatMessage(messages.trustWarning) }}
|
||||
</Admonition>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
InviteBackgroundIllustration,
|
||||
LoaderCircleIcon,
|
||||
UserPlusIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, onServerPrefetch, useTemplateRef } from 'vue'
|
||||
|
||||
import SharedInstanceInviteOpenInAppModal from '~/components/ui/SharedInstanceInviteOpenInAppModal.vue'
|
||||
|
||||
useSeoMeta({
|
||||
robots: 'noindex',
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
loadingInvite: {
|
||||
id: 'shared-instance.invite.loading',
|
||||
defaultMessage: 'Loading invite…',
|
||||
},
|
||||
unavailableTitle: {
|
||||
id: 'shared-instance.invite.unavailable.title',
|
||||
defaultMessage: "This invite isn't available",
|
||||
},
|
||||
unavailableDescription: {
|
||||
id: 'shared-instance.invite.unavailable.description',
|
||||
defaultMessage:
|
||||
'It may have expired, reached its use limit, or been removed by the person who shared it.',
|
||||
},
|
||||
invitationTitle: {
|
||||
id: 'shared-instance.invite.title',
|
||||
defaultMessage: '{name} has invited you to play',
|
||||
},
|
||||
unknownInviter: {
|
||||
id: 'shared-instance.invite.inviter.unknown',
|
||||
defaultMessage: 'A Modrinth user',
|
||||
},
|
||||
joinedCount: {
|
||||
id: 'shared-instance.invite.joined-count',
|
||||
defaultMessage: '{count, plural, one {# person has} other {# people have}} already joined',
|
||||
},
|
||||
acceptInvite: {
|
||||
id: 'shared-instance.invite.accept',
|
||||
defaultMessage: 'Accept invite',
|
||||
},
|
||||
knowThisUser: {
|
||||
id: 'shared-instance.invite.trust.heading',
|
||||
defaultMessage: 'Do you know this user?',
|
||||
},
|
||||
trustWarning: {
|
||||
id: 'shared-instance.invite.trust.description',
|
||||
defaultMessage:
|
||||
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
|
||||
},
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const client = injectModrinthClient()
|
||||
const inviteId = computed(() => String(route.params.inviteId))
|
||||
const openInAppModal = useTemplateRef('openInAppModal')
|
||||
|
||||
const {
|
||||
data: invite,
|
||||
error,
|
||||
isPending,
|
||||
suspense,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => ['shared-instance-invite', inviteId.value]),
|
||||
queryFn: () => client.sharedinstances.invites_v1.get(inviteId.value),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const instanceIcon = computed(() => invite.value?.instance_icon ?? null)
|
||||
const instanceUsers = computed(() => {
|
||||
if (!invite.value) return []
|
||||
const managerIds = new Set(
|
||||
invite.value.managers.flatMap((manager) => (manager.type === 'user' ? [manager.id] : [])),
|
||||
)
|
||||
return (
|
||||
invite.value.instance_users?.filter(
|
||||
(user) => user.joined_at !== null && !managerIds.has(user.id),
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
const visibleInstanceUsers = computed(() => instanceUsers.value.slice(0, 4))
|
||||
const hiddenUserCount = computed(() =>
|
||||
Math.max(0, instanceUsers.value.length - visibleInstanceUsers.value.length),
|
||||
)
|
||||
|
||||
onServerPrefetch(() => suspense())
|
||||
|
||||
const inviter = computed(
|
||||
() =>
|
||||
invite.value?.managers.find((manager) => manager.type === 'user') ?? invite.value?.managers[0],
|
||||
)
|
||||
const inviterName = computed(() => inviter.value?.name ?? formatMessage(messages.unknownInviter))
|
||||
const inviterAvatar = computed(() => {
|
||||
if (!inviter.value) return null
|
||||
return inviter.value.type === 'user' ? inviter.value.avatar : inviter.value.icon
|
||||
})
|
||||
|
||||
function acceptInvite() {
|
||||
if (!invite.value) return
|
||||
|
||||
openInAppModal.value?.show({
|
||||
instance: {
|
||||
inviteId: inviteId.value,
|
||||
name: invite.value.instance_name,
|
||||
icon: invite.value.instance_icon,
|
||||
inviterName: inviterName.value,
|
||||
inviterAvatar: inviterAvatar.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.invite-avatar {
|
||||
box-shadow:
|
||||
0 0 0 8px rgb(255 255 255 / 5%),
|
||||
0 24px 48px rgb(0 0 0 / 3%),
|
||||
0 10px 18px rgb(0 0 0 / 3%),
|
||||
0 5px 8px rgb(0 0 0 / 4%),
|
||||
0 2px 4px rgb(0 0 0 / 4%);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user