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:
Calum H.
2026-07-24 13:06:38 +00:00
committed by GitHub
co-authored by sychic
parent 2e0d797bb0
commit e58af98f21
269 changed files with 18202 additions and 2579 deletions
+95 -36
View File
@@ -78,14 +78,15 @@ import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWar
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import MinecraftRequiredModal from '@/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
@@ -103,8 +104,13 @@ import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import { list, run } from '@/helpers/instance'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import {
can_current_user_use_shared_instances,
get as getInstance,
list,
run,
} from '@/helpers/instance'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
@@ -155,6 +161,7 @@ const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
const credentials = ref()
let credentialsRefreshId = 0
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
@@ -200,6 +207,7 @@ const tauriApiClient = new TauriModrinthClient({
userAgent: async () => `modrinth/theseus/${await appVersion} (support@modrinth.com)`,
labrinthBaseUrl: config.labrinthBaseUrl,
archonBaseUrl: config.archonBaseUrl,
sharedInstancesBaseUrl: config.sharedInstancesBaseUrl,
features: [
new NodeAuthFeature({
getAuth: () => nodeAuthState.getAuth?.() ?? null,
@@ -223,6 +231,16 @@ const { data: authenticatedModrinthUser } = useQuery({
enabled: () => !!credentials.value?.session,
retry: false,
})
useQuery({
queryKey: computed(() => ['shared-instance-eligibility', credentials.value?.user?.id]),
queryFn: can_current_user_use_shared_instances,
enabled: () => !!credentials.value?.session && !!credentials.value?.user?.id,
retry: false,
staleTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const hasPlus = computed(
() =>
!!credentials.value?.user &&
@@ -735,9 +753,10 @@ const contentInstallModpackAlreadyInstalledModal = ref()
const addServerToInstanceModal = ref()
const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
const sharedInstanceInviteHandler = ref()
const updateToPlayModal = ref()
const modrinthLoginFlowWaitModal = ref()
const modrinthLoginModal = ref()
watch(incompatibilityWarningModal, (modal) => {
if (modal) {
@@ -745,8 +764,12 @@ watch(incompatibilityWarningModal, (modal) => {
}
})
setupAuthProvider(credentials, async (_redirectPath) => {
await signIn()
setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
if (options?.showModal === false) {
await signIn(flow)
} else {
await requestSignIn(flow)
}
})
async function validateSession(sessionToken) {
@@ -763,23 +786,31 @@ async function validateSession(sessionToken) {
}
async function fetchCredentials() {
const refreshId = ++credentialsRefreshId
credentials.value = undefined
const creds = await getCreds().catch(handleError)
if (refreshId !== credentialsRefreshId) return
if (creds && creds.user_id) {
if (creds.session && !(await validateSession(creds.session))) {
if (refreshId !== credentialsRefreshId) return
await logout().catch(handleError)
if (refreshId !== credentialsRefreshId) return
credentials.value = null
return
}
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
if (refreshId !== credentialsRefreshId) return
}
credentials.value = creds ?? null
}
async function signIn() {
modrinthLoginFlowWaitModal.value.show()
async function signIn(flow = 'sign-in') {
try {
await login()
await login(flow)
await fetchCredentials()
} catch (error) {
if (
@@ -791,12 +822,26 @@ async function signIn() {
} else {
handleError(error)
}
} finally {
modrinthLoginFlowWaitModal.value.hide()
}
}
async function requestSignIn(flow = 'sign-in') {
await modrinthLoginModal.value?.showSigningIn(flow)
}
async function requestModrinthAuth(flow = 'sign-in') {
await signIn(flow)
return !!credentials.value?.session
}
async function logOut() {
await performLogOut()
}
async function performLogOut() {
credentialsRefreshId++
credentials.value = undefined
await logout().catch(handleError)
await fetchCredentials()
}
@@ -917,30 +962,34 @@ function openServerInviteInviterProfile(inviterName) {
}
async function handleLiveNotification(notification) {
if (notification?.body?.type !== 'server_invite' || notification.read) return
if (displayedServerInviteNotifications.has(notification.id)) return
if (!notification?.body || notification.read) return
if (await sharedInstanceInviteHandler.value?.handleNotification(notification)) return
displayedServerInviteNotifications.add(notification.id)
if (notification.body.type === 'server_invite') {
if (displayedServerInviteNotifications.has(notification.id)) return
const serverName =
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
const inviterId = notification.body.invited_by
const invitedBy =
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
displayedServerInviteNotifications.add(notification.id)
addPopupNotification({
title: serverName,
autoCloseMs: null,
toast: {
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
},
})
const serverName =
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
const inviterId = notification.body.invited_by
const invitedBy =
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
addPopupNotification({
title: serverName,
autoCloseMs: null,
toast: {
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
},
})
}
}
async function handleCommand(e) {
@@ -967,6 +1016,9 @@ async function handleCommand(e) {
})
}
} else if (e.event === 'LaunchInstance') {
const instance = await getInstance(e.id).catch(handleError)
if (!instance || instance.quarantined) return
if (e.server) {
await start_join_server(e.id, e.server).catch(handleError)
} else if (e.singleplayer_world) {
@@ -974,6 +1026,8 @@ async function handleCommand(e) {
} else {
await run(e.id).catch(handleError)
}
} else if (e.event === 'InstallSharedInstanceInvite') {
await sharedInstanceInviteHandler.value?.installFromInviteId(e.invite_id)
} else if (e.event === 'InstallServer') {
await router.push(`/project/${e.id}`)
await playServerProject(e.id).catch(handleError)
@@ -1498,7 +1552,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<AppSettingsModal ref="settingsModal" />
</Suspense>
<Suspense>
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
<ModrinthAccountRequiredModal ref="modrinthLoginModal" :request-auth="requestModrinthAuth" />
</Suspense>
<CreationFlowModal
ref="installationModal"
@@ -1602,7 +1656,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</template>
<template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu>
<NavButton v-else v-tooltip.right="'Sign in to a Modrinth account'" :to="() => signIn()">
<NavButton
v-else
v-tooltip.right="'Sign in to a Modrinth account'"
:to="() => requestSignIn()"
>
<LogInIcon class="text-brand" />
</NavButton>
</div>
@@ -1749,7 +1807,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</div>
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<suspense>
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
<FriendsList :credentials="credentials" :sign-in="() => requestSignIn()" />
</suspense>
</div>
<PrideFundraiserBanner
@@ -1834,6 +1892,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
@go-to-instance="handleContentInstallModpackDuplicateGoToInstance"
/>
<SharedInstanceInviteHandler ref="sharedInstanceInviteHandler" />
<InstallToPlayModal ref="installToPlayModal" :show-external-warnings="false" />
<UpdateToPlayModal ref="updateToPlayModal" :show-external-warnings="false" />
</template>
@@ -65,8 +65,7 @@ async function duplicateInstance(p) {
const handleRightClick = (event, instanceId) => {
const item = instanceComponents.value.find((x) => x.instance.id === instanceId)
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
...(item.instance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open' },
@@ -90,10 +89,14 @@ const handleRightClick = (event, instanceId) => {
...baseOptions,
]
: [
{
name: 'play',
color: 'primary',
},
...(item.instance.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
],
)
@@ -73,8 +73,7 @@ async function duplicateInstance(p) {
const handleInstanceRightClick = async (event, passedInstance) => {
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
...(passedInstance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open_folder' },
@@ -98,10 +97,14 @@ const handleInstanceRightClick = async (event, passedInstance) => {
...baseOptions,
]
: [
{
name: 'play',
color: 'primary',
},
...(passedInstance.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
]
@@ -66,6 +66,7 @@ const checkProcess = async () => {
const play = async (e, context) => {
e?.stopPropagation()
if (props.instance.quarantined) return
loading.value = true
await run(props.instance.id)
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
@@ -94,6 +95,7 @@ const stop = async (e, context) => {
const repair = async (e) => {
e?.stopPropagation()
if (props.instance.quarantined) return
if (
props.instance.install_stage !== 'pack_installed' &&
@@ -116,6 +118,7 @@ const openFolder = async () => {
}
const addContent = async () => {
if (props.instance.quarantined) return
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: props.instance.id },
@@ -142,7 +145,9 @@ const unlisten = await process_listener((e) => {
}
})
onMounted(() => checkProcess())
onMounted(() => {
checkProcess()
})
onUnmounted(() => unlisten())
</script>
@@ -173,7 +178,11 @@ onUnmounted(() => unlisten())
<SpinnerIcon class="animate-spin" />
</button>
</ButtonStyled>
<ButtonStyled v-else :color="first ? 'brand' : 'standard'" circular>
<ButtonStyled
v-else-if="!instance.quarantined"
:color="first ? 'brand' : 'standard'"
circular
>
<button
v-tooltip="'Play'"
@click="(e) => play(e, 'InstanceCard')"
@@ -227,7 +236,12 @@ onUnmounted(() => unlisten())
class="animate-spin w-8 h-8"
tabindex="-1"
/>
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
<ButtonStyled
v-else-if="!installed && !instance.quarantined"
size="large"
color="brand"
circular
>
<button
v-tooltip="'Repair'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@@ -236,7 +250,7 @@ onUnmounted(() => unlisten())
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand" circular>
<ButtonStyled v-else-if="!instance.quarantined" size="large" color="brand" circular>
<button
v-tooltip="'Play'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@@ -10,18 +10,12 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { friend_listener } from '@/helpers/events'
import {
add_friend,
friends,
type FriendWithUserData,
remove_friend,
transformFriends,
} from '@/helpers/friends.ts'
import { useFriends } from '@/composables/use-friends'
import type { FriendWithUserData } from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
const { formatMessage } = useVIntl()
@@ -35,36 +29,23 @@ const props = defineProps<{
}>()
const userCredentials = computed(() => props.credentials)
const {
friends: userFriends,
loading,
requestFriend,
acceptFriend,
removeFriend: removeFriendRecord,
} = useFriends({
currentUserId: () => userCredentials.value?.user_id,
getCredentials: () => userCredentials.value,
onError: handleError,
})
const search = ref('')
const friendInvitesModal = ref()
const username = ref('')
const addFriendModal = ref()
async function addFriendFromModal() {
addFriendModal.value.hide()
await add_friend(username.value).catch(handleError)
username.value = ''
await loadFriends()
}
async function addFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await add_friend(id).catch(handleError)
await loadFriends()
}
}
async function removeFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await remove_friend(id).catch(handleError)
await loadFriends()
}
}
const userFriends = ref<FriendWithUserData[]>([])
const sortedFriends = computed<FriendWithUserData[]>(() =>
userFriends.value.slice().sort((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
@@ -108,42 +89,22 @@ const incomingRequests = computed(() =>
.sort((a, b) => b.created.diff(a.created)),
)
const loading = ref(true)
async function loadFriends(timeout = false) {
loading.value = timeout
function addFriendFromModal() {
const target = username.value.trim()
if (!target) return
try {
const friendsList = await friends()
userFriends.value = await transformFriends(friendsList, userCredentials.value)
loading.value = false
} catch (e) {
console.error('Error loading friends', e)
if (timeout) {
setTimeout(() => loadFriends(), 15 * 1000)
}
}
addFriendModal.value.hide()
requestFriend({ id: target, username: target })
username.value = ''
}
watch(
userCredentials,
() => {
if (userCredentials.value === undefined) {
userFriends.value = []
loading.value = false
} else if (userCredentials.value === null) {
userFriends.value = []
loading.value = false
} else {
loadFriends(true)
}
},
{ immediate: true },
)
function addFriend(friend: FriendWithUserData) {
acceptFriend(friend)
}
const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => {
unlisten()
})
function removeFriend(friend: FriendWithUserData) {
removeFriendRecord(friend)
}
const messages = defineMessages({
addFriend: {
@@ -4,23 +4,57 @@
<Avatar :src="iconSrc" :alt="instance.name" size="64px" :tint-by="instance.id" />
</template>
<template v-if="instance.shared_instance || instance.quarantined" #badges>
<PageHeaderBadgeItem
v-if="instance.quarantined"
:icon="LockIcon"
aria-label="Locked instance information"
class="!border-orange !bg-highlight-orange !text-orange"
>
Locked
</PageHeaderBadgeItem>
<PageHeaderBadgeItem
v-else
:icon="UnknownIcon"
:tooltip="sharedInstanceTooltip"
aria-label="Shared instance information"
class="!border-blue !bg-highlight-blue !text-blue"
>
Shared
</PageHeaderBadgeItem>
</template>
<template #metadata>
<InstanceHeaderServerMetadata
v-if="isServerInstance"
:loading-server-ping="loadingServerPing"
:players-online="playersOnline"
:status-online="statusOnline"
:recent-plays="recentPlays"
:ping="ping"
:minecraft-server="minecraftServer"
:linked-project-v3="linkedProjectV3"
:instance-id="instance.id"
/>
<div v-if="isServerInstance" class="flex flex-wrap items-center gap-2">
<InstanceHeaderServerMetadata
:loading-server-ping="loadingServerPing"
:players-online="playersOnline"
:status-online="statusOnline"
:recent-plays="recentPlays"
:ping="ping"
:minecraft-server="minecraftServer"
:linked-project-v3="linkedProjectV3"
:instance-id="instance.id"
/>
<PageHeaderMetadataItem v-if="sharedInstanceManager" :action="sharedInstanceManagerAction">
{{ sharedInstanceManagerLabel }}
<Avatar
:src="sharedInstanceManager.avatarUrl"
:alt="sharedInstanceManager.name"
:tint-by="sharedInstanceManager.tintBy"
size="24px"
:circle="sharedInstanceManager.type === 'user'"
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceManager.name }}</span>
</PageHeaderMetadataItem>
</div>
<PageHeaderMetadata v-else>
<PageHeaderMetadataItem :icon="Gamepad2Icon" tooltip="Minecraft version">
Minecraft {{ instance.game_version }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem
v-if="sharedInstanceManager?.type !== 'user'"
:icon="ServerLoaderIcon"
:icon-props="{ loader: loaderDisplayName }"
tooltip="Mod loader"
@@ -34,6 +68,18 @@
>
{{ playtimeLabel }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem v-if="sharedInstanceManager" :action="sharedInstanceManagerAction">
{{ sharedInstanceManagerLabel }}
<Avatar
:src="sharedInstanceManager.avatarUrl"
:alt="sharedInstanceManager.name"
:tint-by="sharedInstanceManager.tintBy"
size="24px"
:circle="sharedInstanceManager.type === 'user'"
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceManager.name }}</span>
</PageHeaderMetadataItem>
</PageHeaderMetadata>
</template>
@@ -44,12 +90,6 @@
{{ formatMessage(commonMessages.installingLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="instance.install_stage !== 'installed'" color="brand" size="large">
<button type="button" @click="emit('repair')">
<DownloadIcon />
{{ formatMessage(messages.repair) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing" color="red" size="large">
<button type="button" :disabled="stopping" @click="emit('stop')">
<StopCircleIcon />
@@ -58,6 +98,18 @@
}}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="instance.quarantined" color="brand" size="large">
<button v-tooltip="formatMessage(messages.lockedPlayTooltip)" type="button" disabled>
<PlayIcon />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="instance.install_stage !== 'installed'" color="brand" size="large">
<button type="button" @click="emit('repair')">
<DownloadIcon />
{{ formatMessage(messages.repair) }}
</button>
</ButtonStyled>
<JoinedButtons
v-else-if="!loading && isServerInstance"
:actions="serverPlayActions"
@@ -104,13 +156,16 @@ import {
DownloadIcon,
ExternalIcon,
FolderOpenIcon,
LockIcon,
MoreVerticalIcon,
PackageIcon,
PlayIcon,
ReportIcon,
SettingsIcon,
StopCircleIcon,
TagCategoryGamepad2Icon as Gamepad2Icon,
TimerIcon,
UnknownIcon,
} from '@modrinth/assets'
import {
Avatar,
@@ -123,6 +178,7 @@ import {
LoaderIcon as ServerLoaderIcon,
PageHeader,
PageHeaderActions,
PageHeaderBadgeItem,
PageHeaderMetadata,
PageHeaderMetadataItem,
type ServerLoader,
@@ -130,6 +186,7 @@ import {
type TeleportOverflowMenuItem,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed } from 'vue'
import type { GameInstance } from '@/helpers/types'
@@ -169,6 +226,10 @@ const messages = defineMessages({
id: 'instance.action.repair',
defaultMessage: 'Repair',
},
lockedPlayTooltip: {
id: 'instance.locked.play-tooltip',
defaultMessage: 'This instance has been locked',
},
starting: {
id: 'instance.action.starting',
defaultMessage: 'Starting...',
@@ -177,6 +238,14 @@ const messages = defineMessages({
id: 'instance.action.stopping',
defaultMessage: 'Stopping...',
},
sharedInstanceTooltip: {
id: 'instance.shared-instance.tooltip',
defaultMessage: "This instance's content is being managed by someone else.",
},
sharedInstanceOwnerTooltip: {
id: 'instance.shared-instance.owner-tooltip',
defaultMessage: "This instance's content is being shared to other users.",
},
})
const props = withDefaults(
@@ -196,6 +265,12 @@ const props = withDefaults(
ping?: number
minecraftServer?: Labrinth.Projects.v3.Project['minecraft_server']
linkedProjectV3?: Labrinth.Projects.v3.Project
sharedInstanceManager?: {
type: 'user' | 'server'
name: string
avatarUrl?: string
tintBy: string
} | null
}>(),
{
iconSrc: null,
@@ -212,6 +287,7 @@ const props = withDefaults(
ping: undefined,
minecraftServer: undefined,
linkedProjectV3: undefined,
sharedInstanceManager: null,
},
)
@@ -224,6 +300,7 @@ const emit = defineEmits<{
openFolder: []
export: []
createShortcut: []
report: [event?: MouseEvent]
}>()
const installingStages = [
@@ -241,6 +318,21 @@ const loaderDisplayName = computed(() => formatLoaderLabel(props.instance.loader
const loaderLabel = computed(() =>
[loaderDisplayName.value, props.instance.loader_version].filter(Boolean).join(' '),
)
const sharedInstanceTooltip = computed(() =>
formatMessage(
props.instance.shared_instance?.role === 'owner'
? messages.sharedInstanceOwnerTooltip
: messages.sharedInstanceTooltip,
),
)
const sharedInstanceManagerLabel = computed(() =>
props.sharedInstanceManager?.type === 'server' ? 'Linked to' : 'Managed by',
)
const sharedInstanceManagerAction = computed(() => {
const manager = props.sharedInstanceManager
if (manager?.type !== 'user') return undefined
return () => openUrl(`https://modrinth.com/user/${encodeURIComponent(manager.name)}`)
})
const playtimeLabel = computed(() => {
if (props.timePlayed <= 0) return formatMessage(messages.neverPlayed)
@@ -271,24 +363,46 @@ const serverPlayActions = computed<JoinedButtonAction[]>(() => [
action: () => emit('play'),
},
])
const moreActions = computed<TeleportOverflowMenuItem[]>(() => [
{
id: 'open-folder',
label: formatMessage(messages.openFolder),
icon: FolderOpenIcon,
action: () => emit('openFolder'),
},
{
id: 'export-mrpack',
label: formatMessage(messages.exportModpack),
icon: PackageIcon,
action: () => emit('export'),
},
{
id: 'create-shortcut',
label: formatMessage(messages.createShortcut),
icon: ExternalIcon,
action: () => emit('createShortcut'),
},
])
const moreActions = computed<TeleportOverflowMenuItem[]>(() => {
const actions: TeleportOverflowMenuItem[] = [
{
id: 'open-folder',
label: formatMessage(messages.openFolder),
icon: FolderOpenIcon,
action: () => emit('openFolder'),
},
]
if (!props.instance.quarantined) {
actions.push(
{
id: 'export-mrpack',
label: formatMessage(messages.exportModpack),
icon: PackageIcon,
action: () => emit('export'),
},
{
id: 'create-shortcut',
label: formatMessage(messages.createShortcut),
icon: ExternalIcon,
action: () => emit('createShortcut'),
},
)
}
if (props.instance.shared_instance?.role === 'member') {
actions.push(
{ divider: true },
{
id: 'report-shared-instance',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
color: 'red',
action: (event) => emit('report', event),
},
)
}
return actions
})
</script>
@@ -0,0 +1,116 @@
<template>
<StackedAdmonitions v-bind="$attrs" :items="stackItems" class="w-full">
<template #item="{ item, dismissible }">
<InstanceAdmonitionsSharedInstanceStale
v-if="item.kind === 'shared-instance-stale'"
:instance="instance"
@published="emit('published')"
/>
<InstanceAdmonitionsSharedInstanceWrongAccount
v-else-if="item.kind === 'shared-instance-wrong-account'"
:expected-user-id="sharedInstanceExpectedUserId"
:role="sharedInstanceRole"
:signed-out="sharedInstanceSignedOut"
/>
<InstanceAdmonitionsSharedInstanceUnavailable
v-else-if="item.kind === 'shared-instance-unavailable'"
:reason="displayedSharedInstanceUnavailableReason"
:manager="sharedInstanceUnavailableManager"
:dismissible="dismissible"
@dismiss="sharedInstanceUnavailableDismissed = true"
@delete="emit('delete')"
/>
</template>
</StackedAdmonitions>
</template>
<script setup lang="ts">
import { StackedAdmonitions } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
import type { GameInstance } from '@/helpers/types'
import InstanceAdmonitionsSharedInstanceStale from './instance-admonitions-shared-instance-stale.vue'
import InstanceAdmonitionsSharedInstanceUnavailable from './instance-admonitions-shared-instance-unavailable.vue'
import InstanceAdmonitionsSharedInstanceWrongAccount from './instance-admonitions-shared-instance-wrong-account.vue'
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<{
instance: GameInstance
sharedInstanceUnavailableReason?: SharedInstanceUnavailableReason | null
sharedInstanceUnavailableManager?: string | null
sharedInstanceWrongAccount?: boolean
sharedInstanceExpectedUserId?: string | null
sharedInstanceRole?: SharedInstanceRole | null
sharedInstanceSignedOut?: boolean
}>()
const emit = defineEmits<{
published: []
delete: []
}>()
const sharedInstanceWrongAccount = computed(() => props.sharedInstanceWrongAccount ?? false)
const displayedSharedInstanceUnavailableReason = computed<SharedInstanceUnavailableReason | null>(
() =>
props.instance.quarantined ? 'quarantined' : (props.sharedInstanceUnavailableReason ?? null),
)
const sharedInstanceUnavailableDismissed = ref(false)
const showSharedInstancePublishAdmonition = computed(
() =>
!sharedInstanceWrongAccount.value &&
props.instance.install_stage === 'installed' &&
props.instance.shared_instance?.role === 'owner' &&
props.instance.shared_instance.status === 'stale',
)
const stackItems = computed<InstanceAdmonitionItem[]>(() => {
const items: InstanceAdmonitionItem[] = []
if (sharedInstanceWrongAccount.value) {
items.push({
id: 'shared-instance-wrong-account',
type: 'warning',
dismissible: false,
kind: 'shared-instance-wrong-account',
})
}
const unavailableReason = displayedSharedInstanceUnavailableReason.value
const sharedInstanceQuarantined = unavailableReason === 'quarantined'
if (
unavailableReason &&
(sharedInstanceQuarantined || !sharedInstanceUnavailableDismissed.value)
) {
items.push({
id: 'shared-instance-unavailable',
type: 'warning',
dismissible: !sharedInstanceQuarantined,
kind: 'shared-instance-unavailable',
})
}
if (showSharedInstancePublishAdmonition.value) {
items.push({
id: 'shared-instance-stale',
type: 'warning',
dismissible: false,
kind: 'shared-instance-stale',
})
}
return items
})
watch(
() => [props.instance.id, displayedSharedInstanceUnavailableReason.value],
() => {
sharedInstanceUnavailableDismissed.value = false
},
)
</script>
@@ -0,0 +1,69 @@
import { defineMessages } from '@modrinth/ui'
export const instanceAdmonitionsMessages = defineMessages({
sharedInstanceChangesHeader: {
id: 'app.instance.admonitions.shared-instance.changes-header',
defaultMessage: "Your changes haven't been shared yet",
},
sharedInstanceChangesBody: {
id: 'app.instance.admonitions.shared-instance.changes-body',
defaultMessage: "Your local instance is ahead of the users you've shared it with.",
},
sharedInstancePublishButton: {
id: 'app.instance.admonitions.shared-instance.publish-button',
defaultMessage: 'Push update',
},
sharedInstancePublishingButton: {
id: 'app.instance.admonitions.shared-instance.publishing-button',
defaultMessage: 'Pushing...',
},
sharedInstanceReviewingButton: {
id: 'app.instance.admonitions.shared-instance.reviewing-button',
defaultMessage: 'Reviewing...',
},
sharedInstanceReviewHeader: {
id: 'app.instance.admonitions.shared-instance.review-header',
defaultMessage: 'Review changes',
},
sharedInstanceReviewAdmonitionHeader: {
id: 'app.instance.admonitions.shared-instance.review-admonition-header',
defaultMessage: 'Push update',
},
sharedInstanceReviewDescription: {
id: 'app.instance.admonitions.shared-instance.review-description',
defaultMessage:
'Review the content changes that will be shared with everyone using this instance.',
},
sharedInstanceAddedLabel: {
id: 'app.instance.admonitions.shared-instance.added-label',
defaultMessage: 'Added',
},
sharedInstanceRemovedLabel: {
id: 'app.instance.admonitions.shared-instance.removed-label',
defaultMessage: 'Removed',
},
sharedInstanceWrongAccountHeader: {
id: 'app.instance.shared-instance-wrong-account.warning-header',
defaultMessage: 'You are using the wrong Modrinth account',
},
sharedInstanceSignedOutHeader: {
id: 'app.instance.shared-instance-wrong-account.signed-out-header',
defaultMessage: 'You need to sign in to Modrinth',
},
sharedInstanceWrongAccountSignInAs: {
id: 'app.instance.shared-instance-wrong-account.sign-in-as-label',
defaultMessage: 'Sign in as',
},
sharedInstanceWrongAccountUserBody: {
id: 'app.instance.shared-instance-wrong-account.user-admonition-body-v2',
defaultMessage: 'to receive updates for this shared instance.',
},
sharedInstanceWrongAccountOwnerBody: {
id: 'app.instance.shared-instance-wrong-account.owner-admonition-body-v2',
defaultMessage: "to manage this shared instance. You won't be able to push updates to users.",
},
sharedInstanceWrongAccountFallbackUsername: {
id: 'app.instance.shared-instance-wrong-account.fallback-username',
defaultMessage: 'the linked account',
},
})
@@ -0,0 +1,66 @@
<template>
<Admonition
type="info"
inline-actions
:header="formatMessage(messages.sharedInstanceChangesHeader)"
>
{{ formatMessage(messages.sharedInstanceChangesBody) }}
<template #actions>
<ButtonStyled color="blue">
<button class="!h-10" :disabled="isPublishButtonDisabled" @click="reviewChanges">
<SpinnerIcon
v-if="isReviewingPublish || isPublishing"
class="animate-spin"
aria-hidden="true"
/>
<UploadIcon v-else aria-hidden="true" />
{{
isPublishing
? formatMessage(messages.sharedInstancePublishingButton)
: isReviewingPublish
? formatMessage(messages.sharedInstanceReviewingButton)
: formatMessage(messages.sharedInstancePublishButton)
}}
</button>
</ButtonStyled>
</template>
</Admonition>
<SharedInstancePublishModal
ref="publishModal"
:instance="instance"
@published="emit('published')"
@state-change="publishState = $event"
/>
</template>
<script setup lang="ts">
import { SpinnerIcon, UploadIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
import type { GameInstance } from '@/helpers/types'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
defineProps<{
instance: GameInstance
}>()
const emit = defineEmits<{
published: []
}>()
const { formatMessage } = useVIntl()
const publishModal = ref<InstanceType<typeof SharedInstancePublishModal>>()
const publishState = ref<'idle' | 'reviewing' | 'publishing'>('idle')
const isPublishing = computed(() => publishState.value === 'publishing')
const isReviewingPublish = computed(() => publishState.value === 'reviewing')
const isPublishButtonDisabled = computed(() => isPublishing.value || isReviewingPublish.value)
function reviewChanges(e?: MouseEvent) {
publishModal.value?.show(e)
}
</script>
@@ -0,0 +1,51 @@
<template>
<Admonition
type="warning"
:inline-actions="reason === 'quarantined'"
:header="formatMessage(sharedInstanceUnavailableTitleMessage(reason ?? null))"
:dismissible="dismissible"
@dismiss="emit('dismiss')"
>
{{ formatSharedInstanceUnavailable(reason ?? null, manager) }}
<template v-if="reason === 'quarantined'" #actions>
<ButtonStyled color="orange">
<button class="!h-10" @click="emit('delete')">
<TrashIcon aria-hidden="true" />
{{ formatMessage(messages.deleteInstance) }}
</button>
</ButtonStyled>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { TrashIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
import {
sharedInstanceUnavailableTitleMessage,
useSharedInstanceErrors,
} from '@/helpers/shared-instance-errors'
defineProps<{
reason?: SharedInstanceUnavailableReason | null
manager?: string | null
dismissible?: boolean
}>()
const emit = defineEmits<{
dismiss: []
delete: []
}>()
const { formatMessage } = useVIntl()
const { formatSharedInstanceUnavailable } = useSharedInstanceErrors()
const messages = defineMessages({
deleteInstance: {
id: 'instance.locked.delete-button',
defaultMessage: 'Delete instance',
},
})
</script>
@@ -0,0 +1,78 @@
<template>
<Admonition type="warning" :header="formatMessage(headerMessage)">
<span class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span>{{ formatMessage(messages.sharedInstanceWrongAccountSignInAs) }}</span>
<span
v-if="sharedInstanceExpectedUser"
class="inline-flex max-w-full min-w-0 items-center gap-1.5 align-middle font-semibold text-contrast"
>
<Avatar
:src="sharedInstanceExpectedUser.avatarUrl"
:alt="sharedInstanceExpectedUsername"
:tint-by="sharedInstanceExpectedUser.tintBy"
size="20px"
circle
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceExpectedUsername }}</span>
</span>
<span v-else class="font-semibold">{{ sharedInstanceExpectedUsername }}</span>
<span>{{ formatMessage(bodyMessage) }}</span>
</span>
</Admonition>
</template>
<script setup lang="ts">
import { Admonition, Avatar, useVIntl } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { get_user } from '@/helpers/cache'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
import type { SharedInstanceRole } from './types'
const props = defineProps<{
expectedUserId?: string | null
role?: SharedInstanceRole | null
signedOut?: boolean
}>()
const { formatMessage } = useVIntl()
const expectedUserId = computed(() => props.expectedUserId ?? null)
const expectedUserQuery = useQuery({
queryKey: computed(() => ['user', expectedUserId.value]),
queryFn: async () => {
if (!expectedUserId.value) return null
return await get_user(expectedUserId.value, 'bypass').catch(() => null)
},
enabled: () => !!expectedUserId.value,
staleTime: 30_000,
})
const sharedInstanceExpectedUser = computed(() => {
const user = expectedUserQuery.data.value
if (!user) return null
return {
username: user.username,
avatarUrl: user.avatar_url ?? undefined,
tintBy: user.id,
}
})
const sharedInstanceExpectedUsername = computed(
() =>
sharedInstanceExpectedUser.value?.username ||
formatMessage(messages.sharedInstanceWrongAccountFallbackUsername),
)
const headerMessage = computed(() =>
props.signedOut
? messages.sharedInstanceSignedOutHeader
: messages.sharedInstanceWrongAccountHeader,
)
const bodyMessage = computed(() =>
props.role === 'owner'
? messages.sharedInstanceWrongAccountOwnerBody
: messages.sharedInstanceWrongAccountUserBody,
)
</script>
@@ -0,0 +1,12 @@
import type { StackedAdmonitionItem } from '@modrinth/ui'
export type InstanceAdmonitionKind =
| 'shared-instance-stale'
| 'shared-instance-unavailable'
| 'shared-instance-wrong-account'
export type InstanceAdmonitionItem = StackedAdmonitionItem & {
kind: InstanceAdmonitionKind
}
export type SharedInstanceRole = 'owner' | 'member'
@@ -7,7 +7,6 @@ import {
injectFilePicker,
injectNotificationManager,
InstallationSettingsLayout,
provideAppBackup,
provideInstallationSettings,
useDebugLogger,
useVIntl,
@@ -16,24 +15,25 @@ import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache'
import {
install_duplicate_instance,
install_existing_instance,
install_pack_to_existing_instance,
installJobInstanceId,
wait_for_install_job,
} from '@/helpers/install'
import {
edit,
get_linked_modpack_info,
list,
unlink_shared_instance,
update_managed_modrinth_version,
update_repair_modrinth,
} from '@/helpers/instance'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectInstanceSettings } from '@/providers/instance-settings'
import { useTheming } from '@/store/state'
@@ -47,6 +47,7 @@ const debug = useDebugLogger('AppInstallationSettings')
const themeStore = useTheming()
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
const managedContentPolicy = useManagedContentPolicy(instance)
const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
)
@@ -111,9 +112,14 @@ debug('metadata queries configured', {
const isModrinthLinkedModpack = computed(
() =>
instance.value.link?.type === 'modrinth_modpack' ||
instance.value.link?.type === 'server_project_modpack',
instance.value.link?.type === 'server_project_modpack' ||
(instance.value.link?.type === 'shared_instance' &&
!!instance.value.link.modpack_project_id &&
!!instance.value.link.modpack_version_id),
)
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
const isSharedInstanceManagedModpack = managedContentPolicy.isManagedModpack
const canUnlinkSharedInstance = managedContentPolicy.canUnlink
const modpackInfoQuery = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
@@ -124,12 +130,43 @@ const modpackInfo = modpackInfoQuery.data
const repairing = ref(false)
const reinstalling = ref(false)
const unlinkingSharedInstance = ref(false)
const installationSettingsBusy = computed(
() =>
instance.value.quarantined ||
instance.value.install_stage !== 'installed' ||
repairing.value ||
reinstalling.value ||
unlinkingSharedInstance.value ||
!!offline,
)
const installationSettingsBusyMessage = computed(() =>
instance.value.quarantined ? formatMessage(messages.locked) : null,
)
async function unlinkSharedInstance() {
unlinkingSharedInstance.value = true
try {
await unlink_shared_instance(instance.value.id)
await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] })
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
} finally {
unlinkingSharedInstance.value = false
}
}
const messages = defineMessages({
loaderVersion: {
id: 'instance.settings.tabs.installation.loader-version',
defaultMessage: '{loader} version',
},
locked: {
id: 'instance.settings.tabs.installation.locked',
defaultMessage: 'Installation settings are unavailable while this instance is locked.',
},
})
function getManifest(loader: string) {
@@ -162,27 +199,7 @@ async function installLocalModpackFromPicker() {
return !!completed
}
provideAppBackup({
async createBackup() {
debug('createBackup: start', {
instanceId: instance.value.id,
instanceName: instance.value.name,
})
const allInstances = await list()
const prefix = `${instance.value.name} - Backup #`
const existingNums = allInstances
.filter((p) => p.name.startsWith(prefix))
.map((p) => parseInt(p.name.slice(prefix.length), 10))
.filter((n) => !isNaN(n))
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const job = await install_duplicate_instance(instance.value.id)
const newInstanceId = installJobInstanceId(job)
if (newInstanceId) {
await edit(newInstanceId, { name: `${prefix}${nextNum}` })
}
debug('createBackup: done', { newInstanceId, backupName: `${prefix}${nextNum}` })
},
})
provideInstanceBackup(instance)
provideInstallationSettings({
closeSettings: closeModal,
@@ -208,14 +225,14 @@ provideInstallationSettings({
}
return rows
}),
isLinked: computed(() => isModrinthLinkedModpack.value || isImportedModpack.value),
isBusy: computed(
isLinked: computed(
() =>
instance.value.install_stage !== 'installed' ||
repairing.value ||
reinstalling.value ||
!!offline,
isModrinthLinkedModpack.value ||
isImportedModpack.value ||
isSharedInstanceManagedModpack.value,
),
isBusy: installationSettingsBusy,
busyMessage: installationSettingsBusyMessage,
skipNonEssentialWarnings,
modpack: computed(() => {
if (isImportedModpack.value && instance.value.link?.type === 'imported_modpack') {
@@ -452,14 +469,28 @@ provideInstallationSettings({
isServer: false,
isApp: true,
showModpackVersionActions: computed(
() => isModrinthLinkedModpack.value && !isMinecraftServer.value,
() =>
isModrinthLinkedModpack.value &&
!isMinecraftServer.value &&
!isSharedInstanceManagedModpack.value,
),
isLocalFile: isImportedModpack,
isManagedModpack: isSharedInstanceManagedModpack,
managedModpackWarning: managedContentPolicy.managedModpackWarning,
repairing,
reinstalling,
})
</script>
<template>
<InstallationSettingsLayout />
<InstallationSettingsLayout>
<template #extra>
<SharedInstanceInstallationSettingsControls
:can-unlink="canUnlinkSharedInstance"
:busy="installationSettingsBusy"
:unlinking="unlinkingSharedInstance"
:unlink="unlinkSharedInstance"
/>
</template>
</InstallationSettingsLayout>
</template>
@@ -0,0 +1,42 @@
<template>
<div>
<SharedInstanceInstallationSettingsControls
can-unpublish
:busy="isBusy"
:unpublishing="unpublishing"
:unpublish="unpublishSharedInstance"
/>
</div>
</template>
<script setup lang="ts">
import { injectNotificationManager } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { unpublish_shared_instance } from '@/helpers/instance'
import { injectInstanceSettings } from '@/providers/instance-settings'
const { instance, offline, onUnlinked } = injectInstanceSettings()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const unpublishing = ref(false)
const isBusy = computed(
() => instance.value.install_stage !== 'installed' || unpublishing.value || !!offline,
)
async function unpublishSharedInstance() {
unpublishing.value = true
try {
await unpublish_shared_instance(instance.value.id)
queryClient.setQueryData(['sharedInstanceUsers', instance.value.id], [])
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
} finally {
unpublishing.value = false
}
}
</script>
@@ -1,43 +0,0 @@
<script setup lang="ts">
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
defineProps({
onFlowCancel: {
type: Function,
default() {
return async () => {}
},
},
})
const modal = ref()
function show() {
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show, hide })
</script>
<template>
<ModalWrapper ref="modal" @hide="onFlowCancel">
<template #title>
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
<LogInIcon /> Sign in
</span>
</template>
<div class="flex justify-center gap-2">
<SpinnerIcon class="w-12 h-12 animate-spin" />
</div>
<p class="text-sm text-secondary">
Please sign in at the browser window that just opened to continue.
</p>
</ModalWrapper>
</template>
@@ -4,8 +4,8 @@
:header="formatMessage(messages.installToPlay)"
:closable="true"
:on-hide="show_ads_window"
max-width="640px"
width="640px"
max-width="544px"
width="544px"
>
<div v-if="requiredContentProject" class="flex w-full flex-col gap-6">
<p class="m-0 text-primary">
@@ -423,8 +423,8 @@ const messages = defineMessages({
defaultMessage: 'Install anyway',
},
dontInstall: {
id: 'app.modal.install-to-play.dont-install',
defaultMessage: 'Dont install',
id: 'app.modal.install-to-play.external-files-dont-install',
defaultMessage: "Don't install",
},
})
@@ -6,6 +6,7 @@ import {
CoffeeIcon,
InfoIcon,
MonitorIcon,
UsersIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
@@ -25,6 +26,7 @@ import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.v
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import SharingSettings from '@/components/ui/instance_settings/SharingSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import { get_project_v3 } from '@/helpers/cache'
import { get_linked_modpack_info } from '@/helpers/instance'
@@ -97,6 +99,15 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: WrenchIcon,
content: InstallationSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.sharing',
defaultMessage: 'Sharing',
}),
icon: UsersIcon,
content: SharingSettings,
shown: props.instance.shared_instance?.role === 'owner' && !props.instance.quarantined,
},
{
name: defineMessage({
id: 'instance.settings.tabs.window',
@@ -0,0 +1,265 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(authenticating ? messages.signingInHeader : messages.header)"
:on-hide="handleHide"
no-padding
max-width="548px"
width="100%"
>
<div v-if="authenticating === null" class="flex w-full flex-col gap-6 p-6">
<div class="flex flex-col gap-2 px-3">
<h2 class="m-0 text-xl font-semibold leading-7 text-contrast">
{{ formatMessage(messages.signInHeading) }}
</h2>
<p class="m-0 text-base leading-6 text-primary">
{{ formatMessage(messages.description) }}
</p>
</div>
<div class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled>
<button class="w-full !shadow-none" type="button" @click="authenticate('sign-up')">
<UserPlusIcon aria-hidden="true" />
{{ formatMessage(messages.createAccountButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="w-full" type="button" @click="authenticate('sign-in')">
<LogInIcon aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
</div>
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
<IntlFormatted :message-id="messages.supportPrompt">
<template #support="{ children }">
<button
type="button"
class="inline cursor-pointer border-0 bg-transparent p-0 text-base font-medium leading-6 text-blue hover:underline"
@click="openSupport"
>
<component :is="() => children" />
</button>
</template>
</IntlFormatted>
</p>
</div>
</div>
<div v-else class="flex w-full flex-col gap-6 p-6">
<div class="flex flex-col gap-2.5 px-3">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-xl font-semibold leading-7 text-contrast">
{{ formatMessage(messages.continueInBrowserHeading) }}
</h2>
<p class="m-0 text-base leading-6 text-primary">
{{ formatMessage(messages.browserDescription) }}
</p>
</div>
<div class="flex items-center gap-1.5 text-primary">
<SpinnerIcon aria-hidden="true" class="h-5 w-5 shrink-0 animate-spin" />
<span class="text-base leading-6">
{{ formatMessage(messages.waitingForBrowser) }}
</span>
</div>
</div>
<div class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled type="outlined">
<button class="w-full" type="button" @click="modal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button
class="w-full !shadow-none"
type="button"
:disabled="reopeningBrowser"
@click="reopenBrowser"
>
<RefreshCwIcon aria-hidden="true" />
{{ formatMessage(messages.openBrowserAgainButton) }}
</button>
</ButtonStyled>
</div>
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
<IntlFormatted :message-id="messages.supportPrompt">
<template #support="{ children }">
<button
type="button"
class="inline cursor-pointer border-0 bg-transparent p-0 text-base font-medium leading-6 text-blue hover:underline"
@click="openSupport"
>
<component :is="() => children" />
</button>
</template>
</IntlFormatted>
</p>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { LogInIcon, RefreshCwIcon, SpinnerIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, IntlFormatted, NewModal, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
import { cancelLogin, type ModrinthAuthFlow } from '@/helpers/mr_auth'
const props = defineProps<{
requestAuth: (flow: ModrinthAuthFlow) => Promise<boolean>
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const authenticating = ref<ModrinthAuthFlow | null>(null)
const reopeningBrowser = ref(false)
let resolveShow: ((signedIn: boolean) => void) | undefined
let authenticationId = 0
let activeAuthentication: Promise<void> | undefined
function show(event?: MouseEvent) {
resetAuthentication(true)
resolveShow?.(false)
const modalInstance = modal.value
if (!modalInstance) return Promise.resolve(false)
return new Promise<boolean>((resolve) => {
resolveShow = resolve
modalInstance.show(event)
})
}
function showSigningIn(flow: ModrinthAuthFlow = 'sign-in', event?: MouseEvent) {
const result = show(event)
authenticate(flow)
return result
}
function finish(signedIn: boolean) {
resolveShow?.(signedIn)
resolveShow = undefined
}
function authenticate(flow: ModrinthAuthFlow) {
const id = ++authenticationId
authenticating.value = flow
const authentication = (async () => {
try {
if ((await props.requestAuth(flow)) && authenticationId === id) {
authenticating.value = null
activeAuthentication = undefined
finish(true)
modal.value?.hide()
}
} finally {
if (authenticationId === id) {
authenticating.value = null
activeAuthentication = undefined
}
}
})()
activeAuthentication = authentication
}
async function reopenBrowser() {
const flow = authenticating.value
if (!flow || reopeningBrowser.value) return
reopeningBrowser.value = true
const previousAuthentication = activeAuthentication
++authenticationId
try {
await cancelLogin()
await previousAuthentication?.catch(() => undefined)
if (authenticating.value === flow) authenticate(flow)
} finally {
reopeningBrowser.value = false
}
}
function resetAuthentication(cancelActive: boolean) {
const wasAuthenticating = authenticating.value !== null
++authenticationId
activeAuthentication = undefined
authenticating.value = null
reopeningBrowser.value = false
if (cancelActive && wasAuthenticating) void cancelLogin()
}
function handleHide() {
resetAuthentication(true)
finish(false)
}
function openSupport() {
openUrl('https://support.modrinth.com')
}
const messages = defineMessages({
header: {
id: 'modal.modrinth-account-required.header',
defaultMessage: 'Account required',
},
signingInHeader: {
id: 'modal.modrinth-account-required.signing-in-header',
defaultMessage: 'Signing in',
},
signInHeading: {
id: 'modal.modrinth-account-required.sign-in-heading',
defaultMessage: 'Sign in to a Modrinth account',
},
description: {
id: 'modal.modrinth-account-required.description',
defaultMessage:
"You'll need to sign into your Modrinth account before you can use this feature.",
},
createAccountButton: {
id: 'modal.modrinth-account-required.create-account-button',
defaultMessage: 'Create an account',
},
signInButton: {
id: 'modal.modrinth-account-required.sign-in-button',
defaultMessage: 'Sign in to Modrinth',
},
continueInBrowserHeading: {
id: 'modal.modrinth-account-required.continue-in-browser-heading',
defaultMessage: 'Continue in your browser',
},
browserDescription: {
id: 'modal.modrinth-account-required.browser-description',
defaultMessage:
'A new tab opened to sign in. Complete the sign in there, then return to the app.',
},
waitingForBrowser: {
id: 'modal.modrinth-account-required.waiting-for-browser',
defaultMessage: 'Waiting for browser confirmation...',
},
cancelButton: {
id: 'modal.modrinth-account-required.cancel-button',
defaultMessage: 'Cancel',
},
openBrowserAgainButton: {
id: 'modal.modrinth-account-required.open-browser-again-button',
defaultMessage: 'Open browser again',
},
supportPrompt: {
id: 'modal.modrinth-account-required.support-prompt',
defaultMessage: 'Having trouble signing in? <support>Get support</support>',
},
})
defineExpose({ show, showSigningIn })
</script>
@@ -8,6 +8,7 @@
:diffs="normalizedDiffs"
:version-date="versionDate"
:show-external-warnings="showExternalWarnings"
:external-warning-description="formatMessage(messages.externalWarningDescription)"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:removed-label="formatMessage(messages.removed)"
@@ -76,10 +77,15 @@ const { formatMessage } = useVIntl()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise<void>
defineProps<{
const { showExternalWarnings } = defineProps<{
showExternalWarnings?: boolean
}>()
const emit = defineEmits<{
cancel: []
complete: []
}>()
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
@@ -87,16 +93,16 @@ const diffs = ref<DependencyDiff[]>([])
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Version | null>(null)
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
diffs.value.map((diff) => ({
const normalizedDiffs = computed<ContentDiffItem[]>(() => {
return diffs.value.map((diff) => ({
type: diff.type,
external: Boolean(diff.fileName && !diff.project),
projectName: diff.project?.title,
fileName: diff.fileName,
currentVersionName: diff.currentVersion?.version_number,
newVersionName: diff.newVersion?.version_number,
})),
)
}))
})
const versionDate = computed(() =>
modpackVersion.value?.date_published
@@ -242,7 +248,6 @@ async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[
}
async function handleUpdate() {
hide()
const serverProjectId = instance.value?.link?.project_id
if (serverProjectId) startInstallingServer(serverProjectId)
try {
@@ -255,11 +260,12 @@ async function handleUpdate() {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) stopInstallingServer(serverProjectId)
emit('complete')
}
}
function handleDecline() {
hide()
emit('cancel')
}
function show(
@@ -293,12 +299,17 @@ const messages = defineMessages({
updateRequiredDescription: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
'An update is required to play {name}. Please update to the latest version to launch the game.',
'An update is required to play {name}. Please update to latest version to launch the game.',
},
removed: {
id: 'app.modal.update-to-play.removed',
defaultMessage: 'Removed',
},
externalWarningDescription: {
id: 'app.modal.update-to-play.server-modpack-unknown-files-description',
defaultMessage:
'This server modpack update contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust.',
},
})
const hasUpdate = computed(() => {
@@ -0,0 +1,100 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="warning" max-width="500px">
<p class="m-0 text-secondary">
<IntlFormatted :message-id="messages.body" :values="{ instanceName }">
<template #bold="{ children }">
<span class="font-medium text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</p>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button @click="handleInstallAnyway">
<DownloadIcon />
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { DownloadIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.instance.shared-instance-already-installed.header',
defaultMessage: 'Shared instance already installed',
},
body: {
id: 'app.instance.shared-instance-already-installed.body',
defaultMessage:
'This shared instance is already installed as <bold>{instanceName}</bold>. Are you sure you want to install another copy?',
},
instance: {
id: 'app.instance.shared-instance-already-installed.instance',
defaultMessage: 'Instance',
},
installAnyway: {
id: 'app.instance.shared-instance-already-installed.install-anyway',
defaultMessage: 'Install anyway',
},
})
const emit = defineEmits<{
(e: 'cancel' | 'go-to-instance' | 'install-anyway'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const instanceName = ref('')
function show(name: string) {
instanceName.value = name
modal.value?.show()
}
function handleCancel() {
modal.value?.hide()
emit('cancel')
}
function handleGoToInstance() {
modal.value?.hide()
emit('go-to-instance')
}
function handleInstallAnyway() {
modal.value?.hide()
emit('install-anyway')
}
defineExpose({
show,
})
</script>
@@ -0,0 +1,195 @@
<template>
<div v-if="canUnpublish" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{ formatMessage(messages.title) }}</span>
<div>
<ButtonStyled color="orange">
<button :disabled="busy" @click="unpublishModal?.show()">
<SpinnerIcon v-if="unpublishing" class="animate-spin" />
<UnlinkIcon v-else class="size-5" />
{{ formatMessage(unpublishing ? messages.unpublishingButton : messages.unpublishButton) }}
</button>
</ButtonStyled>
</div>
<span class="text-primary">{{ formatMessage(messages.unpublishDescription) }}</span>
</div>
<div v-if="canUnlink" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.linkedTitle)
}}</span>
<div>
<ButtonStyled color="orange">
<button :disabled="busy" @click="unlinkModal?.show()">
<SpinnerIcon v-if="unlinking" class="animate-spin" />
<UnlinkIcon v-else class="size-5" />
{{ formatMessage(unlinking ? messages.unlinkingButton : messages.unlinkButton) }}
</button>
</ButtonStyled>
</div>
<span class="text-primary">{{ formatMessage(messages.unlinkDescription) }}</span>
</div>
<NewModal
ref="unpublishModal"
:header="formatMessage(messages.unpublishModalHeader)"
fade="warning"
max-width="500px"
>
<Admonition type="warning" :header="formatMessage(messages.unpublishModalAdmonitionHeader)">{{
formatMessage(messages.unpublishModalBody)
}}</Admonition>
<template #actions
><div class="flex justify-end gap-2">
<ButtonStyled type="outlined"
><button class="!border" @click="unpublishModal?.hide()">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
><ButtonStyled color="orange"
><button :disabled="busy" @click="confirmUnpublish">
<UnlinkIcon />{{ formatMessage(messages.unpublishButton) }}
</button></ButtonStyled
>
</div></template
>
</NewModal>
<NewModal
ref="unlinkModal"
:header="formatMessage(messages.unlinkModalHeader)"
fade="warning"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition type="warning" :header="formatMessage(messages.unlinkModalAdmonitionHeader)">{{
formatMessage(messages.unlinkModalBody)
}}</Admonition>
<InlineBackupCreator
ref="backupCreator"
backup-name="Before unlinking shared instance"
@update:buttons-disabled="backupBusy = $event"
/>
</div>
<template #actions
><div class="flex justify-end gap-2">
<ButtonStyled type="outlined"
><button class="!border" @click="unlinkModal?.hide()">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
><ButtonStyled color="orange"
><button :disabled="busy || backupBusy" @click="confirmUnlink">
<UnlinkIcon />{{ formatMessage(messages.unlinkButton) }}
</button></ButtonStyled
>
</div></template
>
</NewModal>
</template>
<script setup lang="ts">
import { SpinnerIcon, UnlinkIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
InlineBackupCreator,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const props = withDefaults(
defineProps<{
canUnpublish?: boolean
canUnlink?: boolean
busy: boolean
unpublishing?: boolean
unlinking?: boolean
unpublish?: () => Promise<void>
unlink?: () => Promise<void>
}>(),
{
canUnpublish: false,
canUnlink: false,
unpublishing: false,
unlinking: false,
unpublish: undefined,
unlink: undefined,
},
)
const { formatMessage } = useVIntl()
const unpublishModal = ref<InstanceType<typeof NewModal>>()
const unlinkModal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const backupBusy = ref(false)
async function confirmUnpublish() {
unpublishModal.value?.hide()
await props.unpublish?.()
}
async function confirmUnlink() {
unlinkModal.value?.hide()
await props.unlink?.()
}
const messages = defineMessages({
title: {
id: 'installation-settings.shared-instance.title',
defaultMessage: 'Unpublish instance',
},
linkedTitle: {
id: 'installation-settings.shared-instance.linked-title',
defaultMessage: 'Linked shared instance',
},
unpublishButton: {
id: 'installation-settings.shared-instance.unpublish-button',
defaultMessage: 'Unpublish shared instance',
},
unpublishingButton: {
id: 'installation-settings.shared-instance.unpublishing-button',
defaultMessage: 'Unpublishing...',
},
unpublishDescription: {
id: 'installation-settings.shared-instance.unpublish-description',
defaultMessage:
'Remove this shared instance from Modrinth and stop sending updates to anyone using it. Your local instance will not be affected.',
},
unlinkButton: {
id: 'installation-settings.shared-instance.unlink-button',
defaultMessage: 'Unlink shared instance',
},
unlinkingButton: {
id: 'installation-settings.shared-instance.unlinking-button',
defaultMessage: 'Unlinking...',
},
unlinkDescription: {
id: 'installation-settings.shared-instance.unlink-description',
defaultMessage: 'Disconnect this local instance from future shared updates.',
},
unpublishModalHeader: {
id: 'installation-settings.unpublish-shared-instance.modal.header',
defaultMessage: 'Unpublish shared instance',
},
unpublishModalAdmonitionHeader: {
id: 'installation-settings.unpublish-shared-instance.modal.admonition-header',
defaultMessage: 'Unpublishing shared instance',
},
unpublishModalBody: {
id: 'installation-settings.unpublish-shared-instance.modal.admonition-body',
defaultMessage:
"This deletes the shared instance from Modrinth's servers. People using it in the Modrinth App will stop receiving updates, but your local instance and its content will stay on this device.",
},
unlinkModalHeader: {
id: 'installation-settings.unlink-shared-instance.modal.header',
defaultMessage: 'Unlink shared instance',
},
unlinkModalAdmonitionHeader: {
id: 'installation-settings.unlink-shared-instance.modal.admonition-header',
defaultMessage: 'Unlinking shared instance',
},
unlinkModalBody: {
id: 'installation-settings.unlink-shared-instance.modal.admonition-body',
defaultMessage:
'This only affects your local instance. Your installed content will stay on this device, and the shared instance and other people using it will not be affected.',
},
})
</script>
@@ -0,0 +1,189 @@
<template>
<ContentDiffModal
ref="publishReviewModal"
:header="formatMessage(messages.header)"
:admonition-header="formatMessage(messages.admonitionHeader)"
:description="formatMessage(messages.description)"
:diffs="publishDiffs"
:confirm-label="formatMessage(messages.publishButton)"
:confirm-icon="UploadIcon"
:confirm-disabled="publishDiffs.length === 0 && selectedConfigPaths.length === 0"
:added-label="formatMessage(messages.addedLabel)"
:removed-label="formatMessage(messages.removedLabel)"
@confirm="publishChanges"
@cancel="finishReview"
>
<template #additional-content>
<Accordion
class="min-w-0"
overflow-visible
button-class="w-full bg-transparent m-0 p-0 border-none"
>
<template #title>
<FileCogIcon class="size-4 shrink-0 text-primary" />
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.configTitle) }}
</span>
</template>
<div class="flex min-w-0 flex-col gap-3 pt-4">
<div class="max-h-[292px] overflow-y-auto rounded-[20px]">
<FileTreeSelect
v-model="selectedConfigPaths"
:items="configFileItems"
:show-size="false"
:show-modified="false"
/>
</div>
</div>
</Accordion>
</template>
</ContentDiffModal>
</template>
<script setup lang="ts">
import { FileCogIcon, UploadIcon } from '@modrinth/assets'
import {
Accordion,
type ContentDiffItem,
ContentDiffModal,
defineMessages,
FileTreeSelect,
type FileTreeSelectItem,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
getSharedInstanceUnavailableReason,
isSharedInstanceUnavailableError,
} from '@/helpers/install'
import { get_shared_instance_publish_preview, publish_shared_instance } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
type SharedInstancePublishState = 'idle' | 'reviewing' | 'publishing'
const props = defineProps<{
instance: GameInstance
}>()
const emit = defineEmits<{
published: []
'state-change': [state: SharedInstancePublishState]
}>()
const { formatMessage } = useVIntl()
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
const publishDiffs = ref<ContentDiffItem[]>([])
const configFilePaths = ref<string[]>([])
const selectedConfigPaths = ref<string[]>([])
const state = ref<SharedInstancePublishState>('idle')
const configFileItems = computed<FileTreeSelectItem[]>(() =>
configFilePaths.value.map((path) => ({ path, type: 'file' })),
)
async function show(e?: MouseEvent) {
if (state.value !== 'idle') return
setState('reviewing')
let reviewOpened = false
try {
const preview = await get_shared_instance_publish_preview(props.instance.id)
if (!preview) {
notifySharedInstanceUnavailable()
emit('published')
return
}
publishDiffs.value = preview.diffs.map((diff) => ({
type: diff.type,
projectName: diff.projectName ?? undefined,
fileName: diff.fileName ?? undefined,
currentVersionName: diff.currentVersionName ?? undefined,
newVersionName: diff.newVersionName ?? undefined,
fileCount: diff.configFileCount ?? undefined,
disabled: diff.disabled,
}))
configFilePaths.value = preview.configFiles
selectedConfigPaths.value = []
if (!publishReviewModal.value) return
publishReviewModal.value.show(e)
reviewOpened = true
} catch (error) {
handlePublishError(error)
} finally {
if (!reviewOpened) finishReview()
}
}
async function publishChanges() {
if (state.value !== 'reviewing') return
setState('publishing')
try {
await publish_shared_instance(props.instance.id, selectedConfigPaths.value)
emit('published')
} catch (error) {
handlePublishError(error)
} finally {
setState('idle')
}
}
function finishReview() {
if (state.value === 'reviewing') {
setState('idle')
}
}
function handlePublishError(error: unknown) {
if (isSharedInstanceUnavailableError(error)) {
notifySharedInstanceUnavailable(getSharedInstanceUnavailableReason(error))
emit('published')
return
}
notifySharedInstanceError(error)
}
function setState(nextState: SharedInstancePublishState) {
state.value = nextState
emit('state-change', nextState)
}
const messages = defineMessages({
header: {
id: 'instance.shared-instance.publish-review.header',
defaultMessage: 'Review changes',
},
admonitionHeader: {
id: 'instance.shared-instance.publish-review.admonition-header',
defaultMessage: 'Push update to players',
},
description: {
id: 'instance.shared-instance.publish-review.description',
defaultMessage:
'Review the content changes and choose any config files to include in this update.',
},
publishButton: {
id: 'instance.shared-instance.publish-review.publish-button',
defaultMessage: 'Push update',
},
addedLabel: {
id: 'instance.shared-instance.publish-review.added-label',
defaultMessage: 'Added',
},
removedLabel: {
id: 'instance.shared-instance.publish-review.removed-label',
defaultMessage: 'Removed',
},
configTitle: {
id: 'instance.shared-instance.publish-review.config-title-v2',
defaultMessage: 'Select config files',
},
})
defineExpose({ show })
</script>
@@ -0,0 +1,131 @@
<template>
<ContentDiffModal
ref="modal"
:header="formatMessage(messages.updateToPlay)"
:admonition-header="formatMessage(messages.updateRequired)"
:description="instance ? formatMessage(messages.description, { name: instance.name }) : ''"
:diffs="diffs"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:added-label="formatMessage(messages.addedLabel)"
:removed-label="formatMessage(messages.removedLabel)"
show-report-button
show-external-warnings
:external-warning-description="formatMessage(messages.externalWarningDescription)"
@confirm="update"
@cancel="emit('cancel')"
@report="emit('report', $event)"
/>
</template>
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets'
import {
commonMessages,
type ContentDiffItem,
ContentDiffModal,
defineMessages,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
getSharedInstanceUnavailableReason,
install_update_shared_instance,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
type SharedInstanceUpdatePreview,
wait_for_install_job,
} from '@/helpers/install'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
type UpdateCompleteCallback = () => void | Promise<void>
const emit = defineEmits<{
cancel: []
complete: []
report: [event?: MouseEvent]
sharedInstanceUnavailable: [reason: SharedInstanceUnavailableReason | null]
}>()
const modal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const preview = ref<SharedInstanceUpdatePreview | null>(null)
const onComplete = ref<UpdateCompleteCallback>(() => {})
const { formatMessage } = useVIntl()
const { notifySharedInstanceError } = useSharedInstanceErrors()
const diffs = computed<ContentDiffItem[]>(
() =>
preview.value?.diffs.map((diff) => ({
type: diff.type,
projectName: diff.projectName ?? undefined,
fileName: diff.fileName ?? undefined,
currentVersionName: diff.currentVersionName ?? undefined,
newVersionName: diff.newVersionName ?? undefined,
fileCount: diff.configFileCount ?? undefined,
disabled: diff.disabled,
external: diff.type === 'added' && !diff.projectId && !!diff.fileName,
})) ?? [],
)
async function update() {
try {
if (instance.value) {
const job = await install_update_shared_instance(instance.value.id)
await wait_for_install_job(job.job_id)
await onComplete.value()
}
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
emit('sharedInstanceUnavailable', getSharedInstanceUnavailableReason(error))
return
}
notifySharedInstanceError(error)
} finally {
emit('complete')
}
}
function show(
instanceValue: GameInstance,
previewValue: SharedInstanceUpdatePreview,
callback: UpdateCompleteCallback = () => {},
event?: MouseEvent,
) {
instance.value = instanceValue
preview.value = previewValue
onComplete.value = callback
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
const messages = defineMessages({
updateToPlay: { id: 'app.modal.update-to-play.header', defaultMessage: 'Update to play' },
updateRequired: {
id: 'app.modal.update-to-play.update-required',
defaultMessage: 'Update required',
},
description: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
'An update is required to play {name}. Please update to latest version to launch the game.',
},
addedLabel: {
id: 'app.modal.update-to-play.shared-instance-added-label',
defaultMessage: 'Added',
},
removedLabel: {
id: 'app.modal.update-to-play.shared-instance-removed-label',
defaultMessage: 'Removed',
},
externalWarningDescription: {
id: 'app.modal.update-to-play.shared-instance-unknown-files-description',
defaultMessage:
'This shared instance update contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust.',
},
})
defineExpose({ show, hide })
</script>
@@ -0,0 +1,533 @@
<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">
{{ formatMessage(messages.inviteWarning) }}
</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" class="flex flex-col gap-2">
<Checkbox v-model="deleteInstance" :label="formatMessage(messages.deleteInstance)" />
</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">
<ButtonStyled color="red" type="transparent">
<button @click="reportMode = true">
<ReportIcon />{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
<div class="flex items-center gap-2">
<template v-if="hasExternalFiles">
<ButtonStyled type="transparent" color="orange">
<button @click="accept">
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleCancel">
<BanIcon />{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled type="outlined">
<button class="!border" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="accept">
<DownloadIcon />{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
</template>
</div>
</div>
</div>
<template v-if="reportMode" #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border" :disabled="submitLoading" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canSubmitReport" @click="submitReport">
<SpinnerIcon v-if="submitLoading" class="animate-spin" />
<SendIcon v-else />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
</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 {
Admonition,
AutoLink,
ButtonStyled,
Checkbox,
Combobox,
type ComboboxOption,
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
MarkdownEditor,
ModpackContentModal,
NewModal,
Table,
type TableColumn,
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { toError } from '@/helpers/errors'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
import { create_report } from '@/helpers/reports'
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
}
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 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 submitLoading = ref(false)
const uploadedImageIDs = ref<string[]>([])
const emit = defineEmits<{
reported: [deleteInstance: boolean]
}>()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
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,
)
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)
await create_report({
report_type: reportReason.value,
item_type: 'shared-instance',
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
body,
uploaded_images: uploadedImages,
})
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()
show_ads_window()
}
function resetReportState() {
reportMode.value = false
reportOnly.value = false
reportReason.value = 'malicious'
additionalContext.value = ''
deleteInstance.value = true
submitLoading.value = false
uploadedImageIDs.value = []
}
function show(
previewValue: SharedInstanceInstallPreview,
installValue: () => void | Promise<void>,
event?: MouseEvent,
) {
resetReportState()
install.value = installValue
showPreview(previewValue, event)
}
function showReport(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
resetReportState()
reportMode.value = true
reportOnly.value = true
install.value = () => {}
showPreview(previewValue, event)
}
function showPreview(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
preview.value = previewValue
hide_ads_window()
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.',
},
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',
},
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:
'A file is only reviewed if its published to Modrinth, regardless of its file format (including .mrpack).',
},
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>
@@ -0,0 +1,60 @@
<template>
<div class="flex flex-col gap-2.5">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">{{
heading ?? formatMessage(messages.sharedInstance)
}}</span>
<ButtonStyled type="transparent">
<button @click="emit('viewContents')">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3">
<Avatar
:src="preview.iconUrl"
:alt="preview.name"
size="56px"
no-shadow
class="!rounded-2xl"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="truncate font-semibold text-contrast">{{ preview.name }}</span>
<span class="truncate text-sm font-medium text-secondary">
{{ loaderDisplay }} {{ preview.gameVersion }}
<template v-if="preview.modCount">
· {{ formatMessage(messages.modCount, { count: preview.modCount }) }}
</template>
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { EyeIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, defineMessages, formatLoader, useVIntl } from '@modrinth/ui'
import { computed, toRefs } from 'vue'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
const props = defineProps<{ preview: SharedInstanceInstallPreview; heading?: string }>()
const { preview, heading } = toRefs(props)
const emit = defineEmits<{ viewContents: [] }>()
const { formatMessage } = useVIntl()
const loaderDisplay = computed(() =>
preview.value.loader ? formatLoader(formatMessage, preview.value.loader) : '',
)
const messages = defineMessages({
sharedInstance: {
id: 'app.modal.install-to-play.shared-instance',
defaultMessage: 'Shared instance',
},
viewContents: { id: 'app.modal.install-to-play.view-contents', defaultMessage: 'View contents' },
modCount: {
id: 'app.modal.install-to-play.mod-count',
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
},
})
</script>
@@ -0,0 +1,135 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ContentItem } from '@modrinth/ui'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
type VersionDependency = Labrinth.Versions.v2.Dependency & { version_id?: string }
export function useSharedInstancePreviewContent() {
async function load(preview: SharedInstanceInstallPreview): Promise<ContentItem[]> {
return [
...preview.externalFiles.map(externalFileContentItem),
...(await modpackContentItems(preview)),
...(await contentItemsFromVersionIds(
preview.contentVersionIds.filter((id) => id !== preview.modpackVersionId),
)),
]
}
async function modpackContentItems(preview: SharedInstanceInstallPreview) {
if (!preview.modpackVersionId) return []
const version = await get_version(preview.modpackVersionId, 'must_revalidate')
return await contentItemsFromDependencies(version?.dependencies ?? [])
}
async function contentItemsFromDependencies(dependencies: Labrinth.Versions.v2.Dependency[]) {
const deps = dependencies as VersionDependency[]
const projectIds = unique(deps.map((dep) => dep.project_id).filter((id): id is string => !!id))
const versionIds = unique(deps.map((dep) => dep.version_id).filter((id): id is string => !!id))
const [projects, versions]: [Labrinth.Projects.v2.Project[], Labrinth.Versions.v2.Version[]] =
await Promise.all([
projectIds.length ? get_project_many(projectIds, 'must_revalidate') : [],
versionIds.length ? get_version_many(versionIds, 'must_revalidate') : [],
])
const projectMap = new Map(projects.map((project) => [project.id, project]))
const versionMap = new Map(versions.map((version) => [version.id, version]))
return deps.map((dependency): ContentItem => {
const project = dependency.project_id ? projectMap.get(dependency.project_id) : null
const version = dependency.version_id ? versionMap.get(dependency.version_id) : null
const fileName =
version?.files?.[0]?.filename ?? dependency.file_name ?? project?.title ?? 'Unknown'
return contentItem(
version?.id ?? project?.id ?? fileName,
fileName,
project,
version,
!project && !version,
dependency.project_id ?? fileName,
dependency.file_name ?? fileName,
)
})
}
async function contentItemsFromVersionIds(versionIds: string[]) {
const versions: Labrinth.Versions.v2.Version[] = versionIds.length
? await get_version_many(unique(versionIds), 'must_revalidate')
: []
const projectIds = unique(versions.map((version) => version.project_id).filter(Boolean))
const projects: Labrinth.Projects.v2.Project[] = projectIds.length
? await get_project_many(projectIds, 'must_revalidate')
: []
const projectMap = new Map(projects.map((project) => [project.id, project]))
return versions.map((version): ContentItem => {
const project = projectMap.get(version.project_id)
const fileName = version.files?.[0]?.filename ?? project?.title ?? version.name ?? 'Unknown'
return contentItem(
version.id,
fileName,
project,
version,
false,
version.project_id,
version.name,
)
})
}
return { load }
}
function contentItem(
id: string,
fileName: string,
project?: Labrinth.Projects.v2.Project | null,
version?: Labrinth.Versions.v2.Version | null,
external = false,
fallbackProjectId = id,
fallbackTitle = fileName,
projectType = project?.project_type ?? 'mod',
): ContentItem {
return {
id,
file_name: fileName,
project_type: projectType,
has_update: false,
update_version_id: null,
external,
project: {
id: project?.id ?? fallbackProjectId,
slug: project?.slug ?? fallbackProjectId,
title: project?.title ?? fallbackTitle,
icon_url: project?.icon_url ?? undefined,
},
...(version
? {
version: {
id: version.id,
file_name: fileName,
version_number: version.version_number ?? undefined,
date_published: version.date_published ?? undefined,
},
}
: {}),
}
}
function externalFileContentItem(
file: SharedInstanceInstallPreview['externalFiles'][number],
): ContentItem {
return contentItem(
`external:${file.fileType}:${file.fileName}`,
file.fileName,
null,
null,
true,
file.fileName,
file.fileName,
file.fileType,
)
}
function unique<T>(values: T[]) {
return Array.from(new Set(values))
}
@@ -0,0 +1,46 @@
<template>
<ModrinthAccountRequiredModal ref="accountRequiredModal" :request-auth="requestAuth" />
<SharedInstanceInstallModal ref="installModal" />
<SharedInstanceAlreadyInstalledModal
ref="alreadyInstalledModal"
@cancel="handleAlreadyInstalledCancel"
@go-to-instance="handleAlreadyInstalledGoToInstance"
@install-anyway="handleAlreadyInstalledInstallAnyway"
/>
</template>
<script setup lang="ts">
import { injectAuth } from '@modrinth/ui'
import { nextTick, ref } from 'vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
import SharedInstanceAlreadyInstalledModal from '@/components/ui/shared-instances/SharedInstanceAlreadyInstalledModal.vue'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth'
import type { SharedInstanceInviteHandler } from './shared-instance-invite-types'
import { useSharedInstanceInviteHandler } from './use-shared-instance-invite-handler'
const auth = injectAuth()
const installModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
const alreadyInstalledModal = ref<InstanceType<typeof SharedInstanceAlreadyInstalledModal>>()
const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>()
const {
handleNotification,
installFromInviteId,
handleAlreadyInstalledCancel,
handleAlreadyInstalledGoToInstance,
handleAlreadyInstalledInstallAnyway,
} = useSharedInstanceInviteHandler(installModal, alreadyInstalledModal, accountRequiredModal)
async function requestAuth(flow: ModrinthAuthFlow) {
await auth.requestSignIn('', flow, { showModal: false })
await nextTick()
return !!auth.session_token.value
}
defineExpose<SharedInstanceInviteHandler>({
handleNotification,
installFromInviteId,
})
</script>
@@ -0,0 +1,29 @@
import type {
AppNotification,
SharedInstanceInvite,
SharedInstanceInviteNotificationBody,
} from './shared-instance-invite-types'
function optionalString(value: unknown) {
return typeof value === 'string' ? value : null
}
export function parseSharedInstanceInviteNotification(
notification: AppNotification,
): SharedInstanceInvite | null {
if (notification.body?.type !== 'shared_instance_invite') return null
const body = notification.body as SharedInstanceInviteNotificationBody
const sharedInstanceId = optionalString(body.shared_instance_id)
const sharedInstanceName = optionalString(body.shared_instance_name)
if (!sharedInstanceId || !sharedInstanceName) return null
return {
sharedInstanceId,
sharedInstanceName,
invitedById: optionalString(body.invited_by),
invitedByUsername: optionalString(body.invited_by_username),
invitedByAvatarUrl: optionalString(body.invited_by_avatar_url),
instanceIconUrl: optionalString(body.shared_instance_icon),
}
}
@@ -0,0 +1,29 @@
export type SharedInstanceInviteNotificationBody = {
type: 'shared_instance_invite'
shared_instance_id?: unknown
shared_instance_name?: unknown
invited_by?: unknown
invited_by_username?: unknown
invited_by_avatar_url?: unknown
shared_instance_icon?: unknown
}
export type AppNotification = {
id: string | number
read?: boolean
body?: { type?: unknown } & Record<string, unknown>
}
export type SharedInstanceInvite = {
sharedInstanceId: string
sharedInstanceName: string
invitedById: string | null
invitedByUsername: string | null
invitedByAvatarUrl: string | null
instanceIconUrl: string | null
}
export type SharedInstanceInviteHandler = {
handleNotification(notification: AppNotification): Promise<boolean>
installFromInviteId(inviteId: string): Promise<void>
}
@@ -0,0 +1,252 @@
import { ModrinthApiError } from '@modrinth/api-client'
import {
injectAuth,
injectModrinthClient,
injectNotificationManager,
injectPopupNotificationManager,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type Ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { config } from '@/config'
import { get_user } from '@/helpers/cache'
import { toError } from '@/helpers/errors'
import {
install_accept_shared_instance_invite,
install_get_shared_instance_preview,
install_shared_instance,
} from '@/helpers/install'
import { list } from '@/helpers/instance'
import { useTheming } from '@/store/state'
import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser'
import type { AppNotification, SharedInstanceInvite } from './shared-instance-invite-types'
type InstallModal = {
show(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
): void
}
type AccountRequiredModal = {
show(event?: MouseEvent): Promise<boolean>
}
type AlreadyInstalledModal = {
show(instanceName: string): void
}
export function useSharedInstanceInviteHandler(
installModal: Ref<InstallModal | undefined>,
alreadyInstalledModal: Ref<AlreadyInstalledModal | undefined>,
accountRequiredModal: Ref<AccountRequiredModal | undefined>,
) {
const auth = injectAuth()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const { addPopupNotification } = injectPopupNotificationManager()
const queryClient = useQueryClient()
const router = useRouter()
const themeStore = useTheming()
const displayedNotifications = new Set<string | number>()
let pendingAlreadyInstalled:
| {
instanceId: string
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>
install: () => Promise<void>
onGoToInstance?: () => void | Promise<void>
}
| undefined
async function markNotificationRead(notification: AppNotification) {
try {
await client.labrinth.notifications_v2.markAsRead(String(notification.id))
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 404) return
throw error
}
}
async function resolveInvite(invite: SharedInstanceInvite) {
const [invitedBy, sharedInstance] = await Promise.all([
!invite.invitedByUsername && invite.invitedById
? get_user(invite.invitedById, 'bypass').catch(() => null)
: null,
client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => null),
])
return {
...invite,
invitedByUsername: invite.invitedByUsername ?? invitedBy?.username ?? null,
invitedByAvatarUrl: invite.invitedByAvatarUrl ?? invitedBy?.avatar_url ?? null,
instanceIconUrl: sharedInstance ? sharedInstance.icon : invite.instanceIconUrl,
}
}
function showInstall(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
) {
if (!installModal.value) throw new Error('Shared instance install modal is not available.')
installModal.value.show(preview, install)
}
async function showInstallOrAlreadyInstalled(
sharedInstanceId: string,
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
onGoToInstance?: () => void | Promise<void>,
) {
const existingInstance = (await list()).find(
(instance) => instance.shared_instance?.id === sharedInstanceId,
)
if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) {
showInstall(preview, install)
return
}
if (!alreadyInstalledModal.value) {
throw new Error('Shared instance already installed modal is not available.')
}
pendingAlreadyInstalled = {
instanceId: existingInstance.id,
preview,
install,
onGoToInstance,
}
alreadyInstalledModal.value.show(existingInstance.name)
}
function handleAlreadyInstalledCancel() {
pendingAlreadyInstalled = undefined
}
async function handleAlreadyInstalledGoToInstance() {
const pending = pendingAlreadyInstalled
pendingAlreadyInstalled = undefined
if (!pending) return
if (pending.onGoToInstance) {
try {
await pending.onGoToInstance()
} catch (error) {
handleError(toError(error))
}
}
await router.push(`/instance/${encodeURIComponent(pending.instanceId)}/`)
}
function handleAlreadyInstalledInstallAnyway() {
const pending = pendingAlreadyInstalled
pendingAlreadyInstalled = undefined
if (!pending) return
showInstall(pending.preview, pending.install)
}
async function acceptNotification(notification: AppNotification, invite: SharedInstanceInvite) {
try {
const preview = await install_get_shared_instance_preview(
invite.sharedInstanceId,
invite.sharedInstanceName,
)
if (invite.instanceIconUrl) preview.iconUrl = invite.instanceIconUrl
await showInstallOrAlreadyInstalled(
invite.sharedInstanceId,
preview,
async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.sharedInstanceName,
invite.invitedById,
null,
null,
invite.instanceIconUrl,
)
await markNotificationRead(notification)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
},
() => markNotificationRead(notification),
)
} catch (error) {
handleError(toError(error))
}
}
async function handleNotification(notification: AppNotification) {
const parsedInvite = parseSharedInstanceInviteNotification(notification)
if (!parsedInvite) return false
if (displayedNotifications.has(notification.id)) return true
displayedNotifications.add(notification.id)
const invite = await resolveInvite(parsedInvite)
addPopupNotification({
title: invite.sharedInstanceName,
autoCloseMs: null,
toast: {
type: 'instance-invite',
actorName: invite.invitedByUsername,
actorAvatarUrl: invite.invitedByAvatarUrl ?? undefined,
entityName: invite.sharedInstanceName,
entityIconUrl: invite.instanceIconUrl ?? undefined,
onAccept: () => acceptNotification(notification, invite),
onDecline: () =>
markNotificationRead(notification).catch((error) => handleError(toError(error))),
onOpenActor: () => {
if (invite.invitedByUsername) {
openUrl(`${config.siteUrl}/user/${encodeURIComponent(invite.invitedByUsername)}`)
}
},
},
})
return true
}
async function requireAccount() {
if (!auth.isReady?.value) {
await new Promise<void>((resolve) => {
const stop = watch(auth.isReady!, (ready) => {
if (ready) {
stop()
resolve()
}
})
})
}
if (auth.session_token.value) return true
return (await accountRequiredModal.value?.show()) ?? false
}
async function installFromInviteId(inviteId: string) {
try {
if (!(await requireAccount())) return
const invite = await install_accept_shared_instance_invite(inviteId)
await showInstallOrAlreadyInstalled(invite.sharedInstanceId, invite.preview, async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.preview.name,
invite.managerId,
invite.serverManagerName,
invite.serverManagerIconUrl,
invite.instanceIconUrl,
)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
})
} catch (error) {
handleError(toError(error))
}
}
return {
handleNotification,
installFromInviteId,
handleAlreadyInstalledCancel,
handleAlreadyInstalledGoToInstance,
handleAlreadyInstalledInstallAnyway,
}
}
@@ -80,6 +80,7 @@ const playing = ref(false)
const play = async (event: MouseEvent) => {
event?.stopPropagation()
if (props.instance.quarantined) return
loading.value = true
await run(props.instance.id)
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
@@ -192,8 +193,14 @@ onUnmounted(() => {
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="playing ? 'Instance is already open' : null"
:disabled="playing || loading"
v-tooltip="
instance.quarantined
? 'This instance has been locked'
: playing
? 'Instance is already open'
: null
"
:disabled="instance.quarantined || playing || loading"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
@@ -176,6 +176,7 @@ function refreshServer(address: string, instanceId: string) {
}
async function joinWorld(world: WorldWithInstance, instance?: GameInstance) {
if (instance?.quarantined) return
console.log(`Joining world ${getWorldIdentifier(world)}`)
if (world.type === 'server') {
await start_join_server(world.instance_id, world.address).catch(handleError)
@@ -192,6 +193,7 @@ async function joinWorld(world: WorldWithInstance, instance?: GameInstance) {
}
async function playInstance(instance: GameInstance) {
if (instance.quarantined) return
await run(instance.id)
.catch((err) => handleSevereError(err, { instanceId: instance.id }))
.finally(() => {
@@ -300,6 +302,7 @@ onUnmounted(() => {
item.world.type === 'singleplayer' &&
hasWorldQuickPlaySupport(gameVersions, item.instance.game_version || '')
"
:quarantined="item.instance.quarantined"
:server-status="
item.world.type === 'server' ? serverData[item.world.address].status : undefined
"
@@ -77,6 +77,7 @@ const props = withDefaults(
startingInstance?: boolean
supportsServerQuickPlay?: boolean
supportsWorldQuickPlay?: boolean
quarantined?: boolean
currentProtocol?: ProtocolVersion | null
highlighted?: boolean
@@ -105,6 +106,7 @@ const props = withDefaults(
startingInstance: false,
supportsServerQuickPlay: true,
supportsWorldQuickPlay: false,
quarantined: false,
currentProtocol: null,
refreshing: false,
@@ -139,7 +141,7 @@ const managed = computed(() => props.managed)
const shortcutInstanceId = computed(() => props.shortcutInstanceId ?? props.instanceId)
async function createShortcut() {
if (!shortcutInstanceId.value) return
if (!shortcutInstanceId.value || props.quarantined) return
try {
const shortcutPath = await createInstanceShortcut(
@@ -422,23 +424,26 @@ const messages = defineMessages({
<ButtonStyled v-else>
<button
v-tooltip="
world.type === 'server'
? !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: playingOtherWorld
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
: !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
quarantined
? 'This instance has been locked'
: world.type === 'server'
? !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: playingOtherWorld
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
: !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="
quarantined ||
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
@@ -457,7 +462,7 @@ const messages = defineMessages({
{
id: 'play-instance',
shown: !!instanceId,
disabled: playingInstance,
disabled: playingInstance || quarantined,
action: () => emit('play-instance'),
},
{
@@ -511,7 +516,7 @@ const messages = defineMessages({
},
{
id: 'create-shortcut',
shown: !!shortcutInstanceId,
shown: !!shortcutInstanceId && !quarantined,
action: () => createShortcut(),
},
{
@@ -53,6 +53,10 @@ const messages = defineMessages({
id: 'app.action-bar.install.unknown-instance',
defaultMessage: 'Unknown instance',
},
updatingSharedContent: {
id: 'app.action-bar.install.updating-shared-content',
defaultMessage: 'Updating shared content',
},
})
const phaseMessages = defineMessages({
@@ -268,6 +272,9 @@ export async function useInstallJobNotifications(opts: {
version: job.details.major_version,
})
}
if (job.kind === 'update_shared_instance' && job.phase === 'downloading_content') {
return formatMessage(messages.updatingSharedContent)
}
return formatMessage(phaseMessages[job.phase])
}
@@ -0,0 +1,124 @@
import { type ContentActionWarning, type ContentItem, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, type Ref } from 'vue'
import type { GameInstance } from '@/helpers/types'
const managedSourceKinds = new Set(['shared_instance', 'modrinth_modpack', 'imported_modpack'])
export function useManagedContentPolicy(instance: Ref<GameInstance>) {
const { formatMessage } = useVIntl()
const isManagedModpack = computed(() => instance.value.shared_instance?.role === 'member')
const isQuarantined = computed(() => instance.value.quarantined)
const canUnpublish = computed(() => instance.value.shared_instance?.role === 'owner')
const canUnlink = computed(() => instance.value.shared_instance?.role === 'member')
const managedModpackWarning = computed(() => ({
admonitionHeader: formatMessage(messages.warningHeader),
changeVersionBody: formatMessage(messages.changeVersionBody),
unlinkBody: formatMessage(messages.unlinkBody),
}))
function isManagedContent(item: ContentItem) {
return (
isQuarantined.value ||
(isManagedModpack.value && !!item.source_kind && managedSourceKinds.has(item.source_kind))
)
}
function canMutateContent(item: ContentItem) {
return !isManagedContent(item)
}
function canUpdateContent(item: ContentItem) {
return (
canMutateContent(item) && !!item.file_path && !!item.has_update && !!item.update_version_id
)
}
function deleteWarning(items: ContentItem[]): ContentActionWarning | null {
if (!items.some(isManagedContent)) return null
return {
admonitionHeader: formatMessage(messages.warningHeader),
admonitionBody: formatMessage(
items.length === 1 ? messages.deleteSingleBody : messages.deleteBulkBody,
),
actionLabel: formatMessage(
items.length === 1 ? messages.deleteButton : messages.deleteManyButton,
{ count: items.length },
),
}
}
function disableWarning(items: ContentItem[]): ContentActionWarning | null {
if (!items.some(isManagedContent)) return null
return {
admonitionHeader: formatMessage(messages.warningHeader),
admonitionBody: formatMessage(
items.length === 1 ? messages.disableSingleBody : messages.disableBulkBody,
),
actionLabel: formatMessage(
items.length === 1 ? messages.disableButton : messages.disableManyButton,
{ count: items.length },
),
}
}
return {
isManagedModpack,
isQuarantined,
canUnpublish,
canUnlink,
managedModpackWarning,
isManagedContent,
canMutateContent,
canUpdateContent,
deleteWarning,
disableWarning,
}
}
const messages = defineMessages({
warningHeader: {
id: 'content.shared-instance.warning-header',
defaultMessage: 'This is part of the shared instance',
},
changeVersionBody: {
id: 'content.shared-instance.change-version-body',
defaultMessage:
'Changing the version only changes your local copy. Future shared instance updates may restore or change it again.',
},
unlinkBody: {
id: 'content.shared-instance.unlink-body',
defaultMessage:
'Unlinking only changes your local copy. Future shared instance updates may restore or change it again.',
},
deleteSingleBody: {
id: 'content.shared-instance.delete-single-body',
defaultMessage:
'Deleting it only changes your local copy. Future shared instance updates may restore or change it again.',
},
deleteBulkBody: {
id: 'content.shared-instance.delete-bulk-body',
defaultMessage:
'Some selected projects are part of the shared instance. Deleting them only changes your local copy, and future shared instance updates may restore or change them again.',
},
deleteButton: { id: 'content.shared-instance.delete-button', defaultMessage: 'Delete anyway' },
deleteManyButton: {
id: 'content.shared-instance.delete-many-button',
defaultMessage: 'Delete {count, number} projects anyway',
},
disableSingleBody: {
id: 'content.shared-instance.disable-single-body',
defaultMessage:
'Disabling it only changes your local copy. Future shared instance updates may re-enable, restore, or change it again.',
},
disableBulkBody: {
id: 'content.shared-instance.disable-bulk-body',
defaultMessage:
'Some selected projects are part of the shared instance. Disabling them only changes your local copy, and future shared instance updates may re-enable, restore, or change them again.',
},
disableButton: { id: 'content.shared-instance.disable-button', defaultMessage: 'Disable anyway' },
disableManyButton: {
id: 'content.shared-instance.disable-many-button',
defaultMessage: 'Disable {count, number} projects anyway',
},
})
@@ -0,0 +1,148 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type MaybeRefOrGetter, onUnmounted, toValue } from 'vue'
import { toError } from '@/helpers/errors'
import { friend_listener } from '@/helpers/events.js'
import {
acceptCachedFriend,
add_friend,
createPendingFriend,
type FriendCacheUser,
friendsQueryKey,
type FriendWithUserData,
getFriendsWithUserData,
getFriendUserId,
matchesFriend,
remove_friend,
removeCachedFriend,
upsertCachedFriend,
} from '@/helpers/friends'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
type FriendsMutationContext = {
queryKey: ReturnType<typeof friendsQueryKey>
previousFriends?: FriendWithUserData[]
}
type AddFriendMutationVariables = {
userId: string
user: FriendCacheUser
acceptExisting: boolean
}
type RemoveFriendMutationVariables = {
userId: string
user: FriendWithUserData
}
export function useFriends(options: {
currentUserId: MaybeRefOrGetter<string | null | undefined>
getCredentials: () => ModrinthCredentials | null | Promise<ModrinthCredentials | null>
enabled?: MaybeRefOrGetter<boolean>
onError?: (error: Error) => void
}) {
const queryClient = useQueryClient()
const queryKey = computed(() => friendsQueryKey(toValue(options.currentUserId)))
const query = useQuery({
queryKey,
queryFn: async () => getFriendsWithUserData(await options.getCredentials()),
enabled: () => !!toValue(options.currentUserId) && toValue(options.enabled ?? true),
staleTime: 30_000,
})
const friends = computed(() => query.data.value ?? [])
function restore(context?: FriendsMutationContext) {
if (!context) return
if (context.previousFriends === undefined) {
queryClient.removeQueries({ queryKey: context.queryKey, exact: true })
return
}
queryClient.setQueryData(context.queryKey, context.previousFriends)
}
const addMutation = useMutation({
mutationFn: ({ userId }: AddFriendMutationVariables) => add_friend(userId),
onMutate: async ({ user, acceptExisting }): Promise<FriendsMutationContext> => {
const activeQueryKey = queryKey.value
await queryClient.cancelQueries({ queryKey: activeQueryKey })
const previousFriends = queryClient.getQueryData<FriendWithUserData[]>(activeQueryKey)
const currentUserId = toValue(options.currentUserId)
queryClient.setQueryData<FriendWithUserData[]>(activeQueryKey, (cachedFriends = []) =>
acceptExisting
? acceptCachedFriend(cachedFriends, user.id, user.username, currentUserId)
: upsertCachedFriend(
cachedFriends,
createPendingFriend(user, currentUserId),
currentUserId,
),
)
return { queryKey: activeQueryKey, previousFriends }
},
onError: (error, _variables, context) => {
restore(context)
options.onError?.(toError(error))
},
onSettled: (_data, _error, _variables, context) => {
void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? queryKey.value })
},
})
const removeMutation = useMutation({
mutationFn: ({ userId }: RemoveFriendMutationVariables) => remove_friend(userId),
onMutate: async ({ user, userId }): Promise<FriendsMutationContext> => {
const activeQueryKey = queryKey.value
await queryClient.cancelQueries({ queryKey: activeQueryKey })
const previousFriends = queryClient.getQueryData<FriendWithUserData[]>(activeQueryKey)
queryClient.setQueryData<FriendWithUserData[]>(activeQueryKey, (cachedFriends = []) =>
removeCachedFriend(cachedFriends, userId, user.username, toValue(options.currentUserId)),
)
return { queryKey: activeQueryKey, previousFriends }
},
onError: (error, _variables, context) => {
restore(context)
options.onError?.(toError(error))
},
onSettled: (_data, _error, _variables, context) => {
void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? queryKey.value })
},
})
function requestFriend(user: FriendCacheUser, acceptExisting = false) {
addMutation.mutate({ userId: user.id, user, acceptExisting })
}
function acceptFriend(friend: FriendWithUserData) {
const userId = getFriendUserId(friend, toValue(options.currentUserId))
requestFriend({ id: userId, username: friend.username, avatarUrl: friend.avatar }, true)
}
function removeFriend(friend: FriendWithUserData) {
const userId = getFriendUserId(friend, toValue(options.currentUserId))
removeMutation.mutate({ userId, user: friend })
}
function findFriend(id: string, username: string) {
return friends.value.find((friend) =>
matchesFriend(friend, id, username, toValue(options.currentUserId)),
)
}
let unlisten: (() => void) | undefined
void friend_listener(() => {
void queryClient.invalidateQueries({ queryKey: queryKey.value })
}).then((listener) => {
unlisten = listener
})
onUnmounted(() => unlisten?.())
return {
query,
queryKey,
friends,
loading: computed(() => !!toValue(options.currentUserId) && query.isLoading.value),
requestFriend,
acceptFriend,
removeFriend,
findFriend,
}
}
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/vue-query'
import { computed, type Ref } from 'vue'
import { get_user } from '@/helpers/cache.js'
export function useUserQuery(userId: Ref<string | null | undefined>) {
return useQuery({
queryKey: computed(() => ['user', userId.value]),
queryFn: async ({ queryKey }) => {
const id = queryKey[1]
if (typeof id !== 'string') return null
return await get_user(id, 'bypass').catch(() => null)
},
enabled: () => !!userId.value,
staleTime: 30_000,
})
}
+4
View File
@@ -7,6 +7,9 @@ const labrinthBaseUrl = trimTrailingSlash(
const archonBaseUrl = trimTrailingSlash(
import.meta.env.MODRINTH_ARCHON_BASE_URL || 'https://archon.modrinth.com',
)
const sharedInstancesBaseUrl = trimTrailingSlash(
import.meta.env.SHARED_INSTANCES_API_BASE_URL || 'https://shared-instances.modrinth.com',
)
export const config = {
siteUrl,
@@ -15,4 +18,5 @@ export const config = {
'pk_test_51JbFxJJygY5LJFfKV50mnXzz3YLvBVe2Gd1jn7ljWAkaBlRz3VQdxN9mXcPSrFbSqxwAb0svte9yhnsmm7qHfcWn00R611Ce7b',
labrinthBaseUrl,
archonBaseUrl,
sharedInstancesBaseUrl,
}
+11
View File
@@ -0,0 +1,11 @@
export function toError(error: unknown) {
if (error instanceof Error) return error
if (typeof error === 'string') return new Error(error)
if (error && typeof error === 'object') {
const record = error as Record<string, unknown>
const message = record.message ?? record.error
if (typeof message === 'string') return new Error(message)
return new Error(JSON.stringify(error))
}
return new Error(String(error))
}
+107
View File
@@ -6,6 +6,8 @@ import dayjs from 'dayjs'
import { get_user_many } from '@/helpers/cache'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
export const friendsQueryKey = (userId?: string | null) => ['friends', userId ?? null] as const
export type UserStatus = {
user_id: string
instance_name: string | null
@@ -46,6 +48,111 @@ export type FriendWithUserData = {
online: boolean
avatar: string
}
export type FriendCacheUser = {
id: string
username: string
avatarUrl?: string | null
}
export async function getFriendsWithUserData(
credentials: ModrinthCredentials | null,
): Promise<FriendWithUserData[]> {
if (!credentials) return []
const friendsList = await friends()
return await transformFriends(friendsList, credentials)
}
export function createPendingFriend(
user: FriendCacheUser,
currentUserId?: string | null,
): FriendWithUserData {
return {
id: user.id,
friend_id: currentUserId ?? null,
status: null,
last_updated: null,
created: dayjs(),
username: user.username,
accepted: false,
online: false,
avatar: user.avatarUrl ?? '',
}
}
export function getFriendUserId(
friend: Pick<FriendWithUserData, 'id' | 'friend_id'>,
currentUserId?: string | null,
) {
return friend.id === currentUserId && friend.friend_id ? friend.friend_id : friend.id
}
export function matchesFriend(
friend: FriendWithUserData,
id: string,
username: string,
currentUserId?: string | null,
) {
const friendId = getFriendUserId(friend, currentUserId)
return (
normalizeFriendKey(friendId) === normalizeFriendKey(id) ||
normalizeFriendKey(friend.username) === normalizeFriendKey(username)
)
}
export function upsertCachedFriend(
friends: FriendWithUserData[],
friend: FriendWithUserData,
currentUserId?: string | null,
) {
const existingFriend = friends.find((cachedFriend) =>
matchesFriend(cachedFriend, friend.id, friend.username, currentUserId),
)
if (!existingFriend) return [friend, ...friends]
return friends.map((cachedFriend) =>
matchesFriend(cachedFriend, friend.id, friend.username, currentUserId)
? {
...cachedFriend,
...friend,
id: cachedFriend.id,
friend_id: cachedFriend.friend_id,
}
: cachedFriend,
)
}
export function acceptCachedFriend(
friends: FriendWithUserData[],
id: string,
username: string,
currentUserId?: string | null,
) {
return friends.map((friend) =>
matchesFriend(friend, id, username, currentUserId)
? {
...friend,
accepted: true,
}
: friend,
)
}
export function removeCachedFriend(
friends: FriendWithUserData[],
id: string,
username: string,
currentUserId?: string | null,
) {
return friends.filter((friend) => !matchesFriend(friend, id, username, currentUserId))
}
export function normalizeFriendKey(value: string) {
return value.trim().toLowerCase()
}
export async function transformFriends(
friends: UserFriend[],
credentials: ModrinthCredentials | null,
+143 -1
View File
@@ -45,6 +45,92 @@ export interface InstallPostInstallEdit {
link?: InstanceLink | null
}
export interface SharedInstanceInstallPreview {
sharedInstanceId: string
version: number
name: string
iconUrl?: string | null
gameVersion: string
loader: InstanceLoader
modCount: number
externalFileCount: number
modpackVersionId?: string | null
contentVersionIds: string[]
externalFiles: SharedInstanceExternalFilePreview[]
}
export interface SharedInstanceInviteInstallPreview {
sharedInstanceId: string
managerId?: string | null
serverManagerName?: string | null
serverManagerIconUrl?: string | null
instanceIconUrl?: string | null
preview: SharedInstanceInstallPreview
}
export interface SharedInstanceExternalFilePreview {
fileName: string
fileType: string
}
export interface SharedInstanceUpdatePreview {
sharedInstanceId: string
currentVersion?: number | null
latestVersion: number
updateAvailable: boolean
diffs: SharedInstanceUpdateDiff[]
}
export interface SharedInstanceUpdateDiff {
type:
| 'added'
| 'removed'
| 'updated'
| 'modpack_linked'
| 'modpack_updated'
| 'modpack_unlinked'
| 'game_version_updated'
| 'loader_updated'
| 'config_files_updated'
projectId?: string | null
projectName?: string | null
fileName?: string | null
currentVersionName?: string | null
newVersionName?: string | null
configFileCount?: number | null
disabled?: boolean
}
export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable'
export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked' | 'quarantined'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function isSharedInstanceUnavailableError(error: unknown) {
return getSharedInstanceUnavailableReason(error) !== null
}
export function getSharedInstanceUnavailableReason(
error: unknown,
): SharedInstanceUnavailableReason | null {
if (!isRecord(error) || error.code !== SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE) return null
return error.reason === 'deleted' ||
error.reason === 'access_revoked' ||
error.reason === 'quarantined'
? error.reason
: null
}
export function getErrorMessage(error: unknown): string {
if (typeof error === 'string') return error
if (error instanceof Error) return error.message || 'Unknown error'
if (isRecord(error) && typeof error.message === 'string') return error.message
return 'Unknown error'
}
export type InstallJobStatus =
| 'queued'
| 'running'
@@ -90,6 +176,7 @@ export interface InstallErrorView {
code: string
phase?: InstallPhaseId | null
message: string
reason?: SharedInstanceUnavailableReason | null
api?: {
error: string
status?: number | null
@@ -122,6 +209,8 @@ export interface InstallJobSnapshot {
kind:
| 'create_instance'
| 'create_modpack_instance'
| 'create_shared_instance'
| 'update_shared_instance'
| 'import_instance'
| 'duplicate_instance'
| 'install_existing_instance'
@@ -172,6 +261,58 @@ export async function install_create_modpack_instance(
})
}
export async function install_get_shared_instance_preview(sharedInstanceId: string, name: string) {
return await invoke<SharedInstanceInstallPreview>(
'plugin:install|install_get_shared_instance_preview',
{
sharedInstanceId,
name,
},
)
}
export async function install_accept_shared_instance_invite(inviteId: string) {
return await invoke<SharedInstanceInviteInstallPreview>(
'plugin:install|install_accept_shared_instance_invite',
{
inviteId,
},
)
}
export async function install_get_shared_instance_update_preview(instanceId: string) {
return await invoke<SharedInstanceUpdatePreview | null>(
'plugin:install|install_get_shared_instance_update_preview',
{
instanceId,
},
)
}
export async function install_shared_instance(
sharedInstanceId: string,
name: string,
managerId?: string | null,
serverManagerName?: string | null,
serverManagerIconUrl?: string | null,
instanceIconUrl?: string | null,
) {
return await invoke<InstallJobSnapshot>('plugin:install|install_shared_instance', {
sharedInstanceId,
name,
managerId,
serverManagerName,
serverManagerIconUrl,
instanceIconUrl,
})
}
export async function install_update_shared_instance(instanceId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|install_update_shared_instance', {
instanceId,
})
}
export async function install_import_instance(
launcherType: string,
basePath: string,
@@ -249,7 +390,8 @@ export function isInstallJobFinished(status: InstallJobStatus) {
function settleInstallJob(job: InstallJobSnapshot) {
if (job.status === 'succeeded') return job
throw new Error(job.error?.message ?? `Install job ${job.job_id} ${job.status}`)
if (job.error) throw job.error
throw new Error(`Install job ${job.job_id} ${job.status}`)
}
export async function wait_for_install_job(jobId: string) {
+92 -1
View File
@@ -7,13 +7,14 @@ import type { Labrinth } from '@modrinth/api-client'
import type { ContentItem, ContentOwner } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import type { InstallJobSnapshot } from './install'
import type { InstallJobSnapshot, SharedInstanceUpdateDiff } from './install'
import type {
CacheBehaviour,
ContentFile,
ContentFileProjectType,
GameInstance,
InstanceLoader,
SharedInstanceAttachment,
} from './types'
export async function remove(instanceId: string): Promise<void> {
@@ -335,3 +336,93 @@ export async function edit(instanceId: string, editInstance: Partial<GameInstanc
export async function edit_icon(instanceId: string, iconPath: string | null): Promise<void> {
return await invoke('plugin:instance|instance_edit_icon', { instanceId, iconPath })
}
export type SharedInstanceUsers = {
user_ids: string[]
users: SharedInstanceUser[]
tokens: number
}
export type SharedInstanceJoinType = 'owner' | 'invite' | 'link'
export type SharedInstanceUser = {
id: string
joined_at?: string | null
join_type: SharedInstanceJoinType
last_played?: string | null
}
export interface SharedInstancePublishPreview {
sharedInstanceId: string
latestVersion: number
diffs: SharedInstanceUpdateDiff[]
configFiles: string[]
}
export interface SharedInstanceInviteLink {
inviteId: string
expiresAt: string
maxUses: number
}
export async function can_current_user_use_shared_instances(): Promise<boolean> {
return await invoke('plugin:instance|instance_share_can_current_user_use')
}
export async function get_shared_instance_users(instanceId: string): Promise<SharedInstanceUsers> {
return await invoke('plugin:instance|instance_share_get_users', { instanceId })
}
export async function invite_shared_instance_users(
instanceId: string,
userIds: string[],
): Promise<SharedInstanceUsers> {
return await invoke('plugin:instance|instance_share_invite_users', { instanceId, userIds })
}
export async function create_shared_instance_invite_link(
instanceId: string,
options: {
maxAgeSeconds?: number
maxUses?: number
replaceInviteId?: string
} = {},
): Promise<SharedInstanceInviteLink> {
return await invoke('plugin:instance|instance_share_create_invite_link', {
instanceId,
...options,
})
}
export async function remove_shared_instance_users(
instanceId: string,
userIds: string[],
hasPendingRecipients: boolean,
): Promise<SharedInstanceUsers> {
return await invoke('plugin:instance|instance_share_remove_users', {
instanceId,
userIds,
hasPendingRecipients,
})
}
export async function get_shared_instance_publish_preview(
instanceId: string,
): Promise<SharedInstancePublishPreview | null> {
return await invoke('plugin:instance|instance_share_get_publish_preview', { instanceId })
}
export async function publish_shared_instance(
instanceId: string,
configPaths: string[],
): Promise<SharedInstanceAttachment> {
return await invoke('plugin:instance|instance_share_publish', { instanceId, configPaths })
}
export async function unlink_shared_instance(instanceId: string): Promise<void> {
return await invoke('plugin:instance|instance_share_unlink', { instanceId })
}
export async function unpublish_shared_instance(instanceId: string): Promise<void> {
return await invoke('plugin:instance|instance_share_unpublish', { instanceId })
}
+4 -2
View File
@@ -12,8 +12,10 @@ export type ModrinthCredentials = {
active: boolean
}
export async function login(): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login')
export type ModrinthAuthFlow = 'sign-in' | 'sign-up'
export async function login(flow: ModrinthAuthFlow = 'sign-in'): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login', { flow })
}
export async function logout(): Promise<void> {
+19
View File
@@ -0,0 +1,19 @@
import { invoke } from '@tauri-apps/api/core'
export type ReportItemType = 'project' | 'version' | 'user' | 'shared-instance'
export interface CreateReportRequest {
report_type: string
item_id: string
item_type: ReportItemType
body: string
uploaded_images: string[]
}
export interface CreateReportResponse {
id: string
}
export async function create_report(request: CreateReportRequest) {
return await invoke<CreateReportResponse>('plugin:reports|reports_create', { request })
}
@@ -0,0 +1,98 @@
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { getErrorMessage, type SharedInstanceUnavailableReason } from '@/helpers/install'
export const sharedInstanceErrorMessages = defineMessages({
unavailableTitle: {
id: 'instance.shared-instance.unavailable.title',
defaultMessage: 'Shared instance no longer available',
},
lockedTitle: {
id: 'instance.shared-instance.unavailable.locked-title',
defaultMessage: 'Instance locked',
},
unavailableText: {
id: 'instance.shared-instance.unavailable.text',
defaultMessage:
"Your local instance is still available, but it is no longer linked and won't receive updates.",
},
deletedText: {
id: 'instance.shared-instance.unavailable.deleted-text',
defaultMessage:
'The primary instance was deleted. This instance is still available, but it is no longer linked and will no longer receive updates.',
},
accessRevokedText: {
id: 'instance.shared-instance.unavailable.access-revoked-text',
defaultMessage:
'Your access to the shared instance was revoked. This instance is still available, but it is no longer linked and will no longer receive updates.',
},
lockedText: {
id: 'instance.shared-instance.unavailable.locked-text',
defaultMessage:
'This shared instance was locked by the Content Moderation team. It will no longer receive updates from the primary instance and cannot be played.',
},
unavailableFallbackManager: {
id: 'instance.shared-instance.unavailable.manager-fallback',
defaultMessage: 'the instance manager',
},
errorTitle: {
id: 'instance.shared-instance.error.title',
defaultMessage: 'Something has gone wrong',
},
})
export function sharedInstanceUnavailableTextMessage(
reason: SharedInstanceUnavailableReason | null,
) {
if (reason === 'deleted') return sharedInstanceErrorMessages.deletedText
if (reason === 'access_revoked') return sharedInstanceErrorMessages.accessRevokedText
if (reason === 'quarantined') return sharedInstanceErrorMessages.lockedText
return sharedInstanceErrorMessages.unavailableText
}
export function sharedInstanceUnavailableTitleMessage(
reason: SharedInstanceUnavailableReason | null,
) {
return reason === 'quarantined'
? sharedInstanceErrorMessages.lockedTitle
: sharedInstanceErrorMessages.unavailableTitle
}
export function useSharedInstanceErrors() {
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
function formatSharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
manager?: string | null,
) {
return formatMessage(sharedInstanceUnavailableTextMessage(reason), {
manager: manager || formatMessage(sharedInstanceErrorMessages.unavailableFallbackManager),
})
}
function notifySharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
manager?: string | null,
) {
addNotification({
type: 'warning',
title: formatMessage(sharedInstanceUnavailableTitleMessage(reason)),
text: formatSharedInstanceUnavailable(reason, manager),
})
}
function notifySharedInstanceError(error: unknown) {
addNotification({
type: 'error',
title: formatMessage(sharedInstanceErrorMessages.errorTitle),
text: getErrorMessage(error),
})
}
return {
formatSharedInstanceUnavailable,
notifySharedInstanceError,
notifySharedInstanceUnavailable,
}
}
+32 -1
View File
@@ -17,6 +17,8 @@ export type GameInstance = {
groups: string[]
link?: InstanceLink | null
shared_instance?: SharedInstanceAttachment | null
quarantined: boolean
update_channel: ReleaseChannel
created: Date
@@ -86,18 +88,47 @@ export type InstanceLink = InstanceLinkIdentity &
}
| {
type: 'shared_instance'
shared_instance_id: string
modpack_project_id?: ModrinthId | null
modpack_version_id?: ModrinthId | null
}
)
export type SharedInstanceAttachment = {
id: string
role: 'owner' | 'member'
manager_id?: string | null
server_manager_name?: string | null
server_manager_icon_url?: string | null
linked_user_id?: string | null
status:
| 'unknown'
| 'up_to_date'
| 'update_available'
| 'applying'
| 'stale'
| 'not_ready'
| 'error'
applied_version?: number | null
latest_version?: number | null
}
export type Instance = GameInstance
type ReleaseChannel = 'release' | 'beta' | 'alpha'
export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
export type ContentSourceKind =
| 'local'
| 'modrinth_modpack'
| 'server_project'
| 'modrinth_hosting'
| 'imported_modpack'
| 'shared_instance'
type ContentFile = {
enabled: boolean
source_kind?: ContentSourceKind | null
metadata?: {
project_id: string
version_id: string
+11
View File
@@ -0,0 +1,11 @@
import { invoke } from '@tauri-apps/api/core'
export type SearchUser = {
id: string
username: string
avatar_url: string | null
}
export async function search_user(query: string): Promise<SearchUser[]> {
return await invoke<SearchUser[]>('plugin:users|search_user', { query })
}
@@ -425,9 +425,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Durchsuche {count} Welten..."
},
"app.modal.install-to-play.dont-install": {
"message": "Nicht Installieren"
},
"app.modal.install-to-play.header": {
"message": "Installieren zum Spielen"
},
@@ -425,9 +425,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Durchsuche {count} Welten..."
},
"app.modal.install-to-play.dont-install": {
"message": "Nicht Installieren"
},
"app.modal.install-to-play.header": {
"message": "Installieren zum Spielen"
},
+375 -3
View File
@@ -89,6 +89,9 @@
"app.action-bar.install.unknown-instance": {
"message": "Unknown instance"
},
"app.action-bar.install.updating-shared-content": {
"message": "Updating shared content"
},
"app.action-bar.installs": {
"message": "Installs"
},
@@ -338,6 +341,36 @@
"app.install.phase.running_loader_processors": {
"message": "Running loader processors"
},
"app.instance.admonitions.shared-instance.added-label": {
"message": "Added"
},
"app.instance.admonitions.shared-instance.changes-body": {
"message": "Your local instance is ahead of the users you've shared it with."
},
"app.instance.admonitions.shared-instance.changes-header": {
"message": "Your changes haven't been shared yet"
},
"app.instance.admonitions.shared-instance.publish-button": {
"message": "Push update"
},
"app.instance.admonitions.shared-instance.publishing-button": {
"message": "Pushing..."
},
"app.instance.admonitions.shared-instance.removed-label": {
"message": "Removed"
},
"app.instance.admonitions.shared-instance.review-admonition-header": {
"message": "Push update"
},
"app.instance.admonitions.shared-instance.review-description": {
"message": "Review the content changes that will be shared with everyone using this instance."
},
"app.instance.admonitions.shared-instance.review-header": {
"message": "Review changes"
},
"app.instance.admonitions.shared-instance.reviewing-button": {
"message": "Reviewing..."
},
"app.instance.confirm-delete.admonition-body": {
"message": "All data for your instance will be permanently deleted, including your worlds, configs, and all installed content."
},
@@ -374,6 +407,9 @@
"app.instance.mods.content-type-project": {
"message": "project"
},
"app.instance.mods.locked-content": {
"message": "Content in locked instances cannot be changed."
},
"app.instance.mods.project-was-added": {
"message": "\"{name}\" was added"
},
@@ -389,6 +425,102 @@
"app.instance.mods.successfully-uploaded": {
"message": "Successfully uploaded"
},
"app.instance.share.empty.description": {
"message": "You can share this instance with your friends!"
},
"app.instance.share.empty.heading": {
"message": "No friends invited"
},
"app.instance.share.empty.invite-friends-button": {
"message": "Invite friends"
},
"app.instance.share.invite-modal.heading": {
"message": "Share {name}"
},
"app.instance.share.locked.empty-description-prefix": {
"message": "You need to sign in as"
},
"app.instance.share.locked.empty-description-suffix": {
"message": "to access this page."
},
"app.instance.share.locked.linked-account-fallback": {
"message": "the linked account"
},
"app.instance.share.locked.signed-out-heading": {
"message": "Not signed in"
},
"app.instance.share.locked.switch-account-button": {
"message": "Switch account"
},
"app.instance.share.locked.wrong-account-heading": {
"message": "Wrong account"
},
"app.instance.share.remove-user-modal.effect-access": {
"message": "They will no longer receive updates for this shared instance"
},
"app.instance.share.remove-user-modal.effect-installed-copy": {
"message": "Any copy they already installed will stay on their device"
},
"app.instance.share.remove-user-modal.effect-invite-again": {
"message": "You can invite them again later"
},
"app.instance.share.remove-user-modal.effect-last-user": {
"message": "This is the last user, sharing will be turned off for this instance"
},
"app.instance.share.remove-user-modal.effects-label": {
"message": "What happens?"
},
"app.instance.share.remove-user-modal.header": {
"message": "Revoke access"
},
"app.instance.share.remove-user-modal.remove-button": {
"message": "Revoke access"
},
"app.instance.share.remove-user-modal.user-avatar-alt": {
"message": "{username}'s avatar"
},
"app.instance.share.remove-user-modal.warning-body": {
"message": "If you revoke {username}'s access to this shared instance, you'll need to invite them again before they can receive updates."
},
"app.instance.share.sign-in.button": {
"message": "Sign in"
},
"app.instance.share.unlink.body": {
"message": "You must unlink this modpack to share your instance"
},
"app.instance.share.unlink.header": {
"message": "Sharing requires unlinking"
},
"app.instance.shared-instance-already-installed.body": {
"message": "This shared instance is already installed as <bold>{instanceName}</bold>. Are you sure you want to install another copy?"
},
"app.instance.shared-instance-already-installed.header": {
"message": "Shared instance already installed"
},
"app.instance.shared-instance-already-installed.install-anyway": {
"message": "Install anyway"
},
"app.instance.shared-instance-already-installed.instance": {
"message": "Instance"
},
"app.instance.shared-instance-wrong-account.fallback-username": {
"message": "the linked account"
},
"app.instance.shared-instance-wrong-account.owner-admonition-body-v2": {
"message": "to manage this shared instance. You won't be able to push updates to users."
},
"app.instance.shared-instance-wrong-account.sign-in-as-label": {
"message": "Sign in as"
},
"app.instance.shared-instance-wrong-account.signed-out-header": {
"message": "You need to sign in to Modrinth"
},
"app.instance.shared-instance-wrong-account.user-admonition-body-v2": {
"message": "to receive updates for this shared instance."
},
"app.instance.shared-instance-wrong-account.warning-header": {
"message": "You are using the wrong Modrinth account"
},
"app.instance.worlds.add-server": {
"message": "Add server"
},
@@ -440,8 +572,20 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Search {count} worlds..."
},
"app.modal.install-to-play.dont-install": {
"message": "Dont install"
"app.modal.install-to-play.additional-context": {
"message": "Additional context"
},
"app.modal.install-to-play.additional-context-placeholder": {
"message": "Include links and images if possible and relevant"
},
"app.modal.install-to-play.content-you-are-reporting": {
"message": "Instance youre reporting"
},
"app.modal.install-to-play.delete-instance": {
"message": "Delete instance"
},
"app.modal.install-to-play.external-files-dont-install": {
"message": "Don't install"
},
"app.modal.install-to-play.header": {
"message": "Install to play"
@@ -458,12 +602,51 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
"app.modal.install-to-play.report-description": {
"message": "Use this form to report instances that may violate our <rules-link>Rules</rules-link> or <terms-link>Terms of Use</terms-link>."
},
"app.modal.install-to-play.report-image-invalid-type": {
"message": "File is not an accepted image type"
},
"app.modal.install-to-play.report-image-too-large": {
"message": "File exceeds the 1 MiB size limit"
},
"app.modal.install-to-play.report-legal-claims": {
"message": "For DMCA notices or other legal claims, see our <copyright-link>Copyright Policy</copyright-link>."
},
"app.modal.install-to-play.report-reason": {
"message": "Which rule does this instance violate?"
},
"app.modal.install-to-play.report-reason.inappropriate": {
"message": "Inappropriate"
},
"app.modal.install-to-play.report-reason.malicious": {
"message": "Malicious"
},
"app.modal.install-to-play.report-reason.spam": {
"message": "Spam"
},
"app.modal.install-to-play.report-shared-instance-header": {
"message": "Report shared instance"
},
"app.modal.install-to-play.report-submitted": {
"message": "Report submitted"
},
"app.modal.install-to-play.report-support-and-bugs": {
"message": "For support requests, contact our <support-link>support team</support-link>. For bug reports, open a <github-link>GitHub issue</github-link>."
},
"app.modal.install-to-play.reviewed-files": {
"message": "A file is only reviewed if its published to Modrinth, regardless of its file format (including .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Shared instance"
},
"app.modal.install-to-play.shared-instance-content": {
"message": "Shared instance content"
},
"app.modal.install-to-play.shared-instance-unknown-files-description": {
"message": "This shared instance contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust."
},
"app.modal.install-to-play.unknown-files-description": {
"message": "This server modpack contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust."
},
@@ -482,8 +665,23 @@
"app.modal.update-to-play.removed": {
"message": "Removed"
},
"app.modal.update-to-play.server-modpack-unknown-files-description": {
"message": "This server modpack update contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust."
},
"app.modal.update-to-play.shared-instance-added-label": {
"message": "Added"
},
"app.modal.update-to-play.shared-instance-removed-label": {
"message": "Removed"
},
"app.modal.update-to-play.shared-instance-unknown-files-description": {
"message": "This shared instance update contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust."
},
"app.modal.update-to-play.update-required": {
"message": "Update required"
},
"app.modal.update-to-play.update-required-description": {
"message": "An update is required to play {name}. Please update to the latest version to launch the game."
"message": "An update is required to play {name}. Please update to latest version to launch the game."
},
"app.project.install-button.already-installed": {
"message": "This project is already installed"
@@ -740,6 +938,39 @@
"app.world.world-item.players-online": {
"message": "{count} online"
},
"content.shared-instance.change-version-body": {
"message": "Changing the version only changes your local copy. Future shared instance updates may restore or change it again."
},
"content.shared-instance.delete-bulk-body": {
"message": "Some selected projects are part of the shared instance. Deleting them only changes your local copy, and future shared instance updates may restore or change them again."
},
"content.shared-instance.delete-button": {
"message": "Delete anyway"
},
"content.shared-instance.delete-many-button": {
"message": "Delete {count, number} projects anyway"
},
"content.shared-instance.delete-single-body": {
"message": "Deleting it only changes your local copy. Future shared instance updates may restore or change it again."
},
"content.shared-instance.disable-bulk-body": {
"message": "Some selected projects are part of the shared instance. Disabling them only changes your local copy, and future shared instance updates may re-enable, restore, or change them again."
},
"content.shared-instance.disable-button": {
"message": "Disable anyway"
},
"content.shared-instance.disable-many-button": {
"message": "Disable {count, number} projects anyway"
},
"content.shared-instance.disable-single-body": {
"message": "Disabling it only changes your local copy. Future shared instance updates may re-enable, restore, or change it again."
},
"content.shared-instance.unlink-body": {
"message": "Unlinking only changes your local copy. Future shared instance updates may restore or change it again."
},
"content.shared-instance.warning-header": {
"message": "This is part of the shared instance"
},
"friends.action.add-friend": {
"message": "Add a friend"
},
@@ -803,6 +1034,48 @@
"friends.sign-in-to-add-friends": {
"message": "<link>Sign in to a Modrinth account</link> to add friends and see what they're playing!"
},
"installation-settings.shared-instance.linked-title": {
"message": "Linked shared instance"
},
"installation-settings.shared-instance.title": {
"message": "Unpublish instance"
},
"installation-settings.shared-instance.unlink-button": {
"message": "Unlink shared instance"
},
"installation-settings.shared-instance.unlink-description": {
"message": "Disconnect this local instance from future shared updates."
},
"installation-settings.shared-instance.unlinking-button": {
"message": "Unlinking..."
},
"installation-settings.shared-instance.unpublish-button": {
"message": "Unpublish shared instance"
},
"installation-settings.shared-instance.unpublish-description": {
"message": "Remove this shared instance from Modrinth and stop sending updates to anyone using it. Your local instance will not be affected."
},
"installation-settings.shared-instance.unpublishing-button": {
"message": "Unpublishing..."
},
"installation-settings.unlink-shared-instance.modal.admonition-body": {
"message": "This only affects your local instance. Your installed content will stay on this device, and the shared instance and other people using it will not be affected."
},
"installation-settings.unlink-shared-instance.modal.admonition-header": {
"message": "Unlinking shared instance"
},
"installation-settings.unlink-shared-instance.modal.header": {
"message": "Unlink shared instance"
},
"installation-settings.unpublish-shared-instance.modal.admonition-body": {
"message": "This deletes the shared instance from Modrinth's servers. People using it in the Modrinth App will stop receiving updates, but your local instance and its content will stay on this device."
},
"installation-settings.unpublish-shared-instance.modal.admonition-header": {
"message": "Unpublishing shared instance"
},
"installation-settings.unpublish-shared-instance.modal.header": {
"message": "Unpublish shared instance"
},
"instance.action.create-shortcut": {
"message": "Create shortcut"
},
@@ -872,6 +1145,12 @@
"instance.files.save-as": {
"message": "Save as..."
},
"instance.locked.delete-button": {
"message": "Delete instance"
},
"instance.locked.play-tooltip": {
"message": "This instance has been locked"
},
"instance.playtime.never-played": {
"message": "Never played"
},
@@ -1010,6 +1289,9 @@
"instance.settings.tabs.installation.loader-version": {
"message": "{loader} version"
},
"instance.settings.tabs.installation.locked": {
"message": "Installation settings are unavailable while this instance is locked."
},
"instance.settings.tabs.java": {
"message": "Java and memory"
},
@@ -1049,6 +1331,9 @@
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/path/to/java"
},
"instance.settings.tabs.sharing": {
"message": "Sharing"
},
"instance.settings.tabs.window": {
"message": "Window"
},
@@ -1079,6 +1364,57 @@
"instance.settings.tabs.window.width.enter": {
"message": "Enter width..."
},
"instance.shared-instance.error.title": {
"message": "Something has gone wrong"
},
"instance.shared-instance.owner-tooltip": {
"message": "This instance's content is being shared to other users."
},
"instance.shared-instance.publish-review.added-label": {
"message": "Added"
},
"instance.shared-instance.publish-review.admonition-header": {
"message": "Push update to players"
},
"instance.shared-instance.publish-review.config-title-v2": {
"message": "Select config files"
},
"instance.shared-instance.publish-review.description": {
"message": "Review the content changes and choose any config files to include in this update."
},
"instance.shared-instance.publish-review.header": {
"message": "Review changes"
},
"instance.shared-instance.publish-review.publish-button": {
"message": "Push update"
},
"instance.shared-instance.publish-review.removed-label": {
"message": "Removed"
},
"instance.shared-instance.tooltip": {
"message": "This instance's content is being managed by someone else."
},
"instance.shared-instance.unavailable.access-revoked-text": {
"message": "Your access to the shared instance was revoked. This instance is still available, but it is no longer linked and will no longer receive updates."
},
"instance.shared-instance.unavailable.deleted-text": {
"message": "The primary instance was deleted. This instance is still available, but it is no longer linked and will no longer receive updates."
},
"instance.shared-instance.unavailable.locked-text": {
"message": "This shared instance was locked by the Content Moderation team. It will no longer receive updates from the primary instance and cannot be played."
},
"instance.shared-instance.unavailable.locked-title": {
"message": "Instance locked"
},
"instance.shared-instance.unavailable.manager-fallback": {
"message": "the instance manager"
},
"instance.shared-instance.unavailable.text": {
"message": "Your local instance is still available, but it is no longer linked and won't receive updates."
},
"instance.shared-instance.unavailable.title": {
"message": "Shared instance no longer available"
},
"instance.worlds.a_minecraft_server": {
"message": "A Minecraft Server"
},
@@ -1163,6 +1499,42 @@
"minecraft-required.sign-in": {
"message": "Sign in to Microsoft"
},
"modal.modrinth-account-required.browser-description": {
"message": "A new tab opened to sign in. Complete the sign in there, then return to the app."
},
"modal.modrinth-account-required.cancel-button": {
"message": "Cancel"
},
"modal.modrinth-account-required.continue-in-browser-heading": {
"message": "Continue in your browser"
},
"modal.modrinth-account-required.create-account-button": {
"message": "Create an account"
},
"modal.modrinth-account-required.description": {
"message": "You'll need to sign into your Modrinth account before you can use this feature."
},
"modal.modrinth-account-required.header": {
"message": "Account required"
},
"modal.modrinth-account-required.open-browser-again-button": {
"message": "Open browser again"
},
"modal.modrinth-account-required.sign-in-button": {
"message": "Sign in to Modrinth"
},
"modal.modrinth-account-required.sign-in-heading": {
"message": "Sign in to a Modrinth account"
},
"modal.modrinth-account-required.signing-in-header": {
"message": "Signing in"
},
"modal.modrinth-account-required.support-prompt": {
"message": "Having trouble signing in? <support>Get support</support>"
},
"modal.modrinth-account-required.waiting-for-browser": {
"message": "Waiting for browser confirmation..."
},
"search.filter.locked.instance": {
"message": "Provided by the instance"
},
@@ -425,9 +425,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Buscar en {count} mundos..."
},
"app.modal.install-to-play.dont-install": {
"message": "No instalar"
},
"app.modal.install-to-play.header": {
"message": "Instalar para jugar"
},
@@ -425,9 +425,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Rechercher {count} mondes..."
},
"app.modal.install-to-play.dont-install": {
"message": "Ne pas installer"
},
"app.modal.install-to-play.header": {
"message": "Installer pour jouer"
},
@@ -425,9 +425,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Keresés {count} világ között..."
},
"app.modal.install-to-play.dont-install": {
"message": "Ne töltse le"
},
"app.modal.install-to-play.header": {
"message": "Telepítés a játékhoz"
},
@@ -422,9 +422,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Cerca tra {count} mondi..."
},
"app.modal.install-to-play.dont-install": {
"message": "Non installare"
},
"app.modal.install-to-play.header": {
"message": "Installa per continuare"
},
@@ -425,9 +425,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "{count}개의 세계 검색..."
},
"app.modal.install-to-play.dont-install": {
"message": "설치하지 않음"
},
"app.modal.install-to-play.header": {
"message": "설치하고 플레이"
},
@@ -425,9 +425,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Zoek werelden"
},
"app.modal.install-to-play.dont-install": {
"message": "Niet installeren"
},
"app.modal.install-to-play.header": {
"message": "Installeer om te spelen"
},
@@ -422,9 +422,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Szukaj wśród {count} światów..."
},
"app.modal.install-to-play.dont-install": {
"message": "Nie instaluj"
},
"app.modal.install-to-play.header": {
"message": "Zainstaluj, aby grać"
},
@@ -425,9 +425,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Buscar {count} mundos..."
},
"app.modal.install-to-play.dont-install": {
"message": "Não instalar"
},
"app.modal.install-to-play.header": {
"message": "Instale para jogar"
},
@@ -419,9 +419,6 @@
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Удаление {name}"
},
"app.modal.install-to-play.dont-install": {
"message": "Не устанавливать"
},
"app.modal.install-to-play.header": {
"message": "Установка перед запуском"
},
@@ -425,9 +425,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Pretraži {count} svetova..."
},
"app.modal.install-to-play.dont-install": {
"message": "Ne instaliraj"
},
"app.modal.install-to-play.header": {
"message": "Instaliraj da bi igrao"
},
@@ -407,9 +407,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Sök {count} världar..."
},
"app.modal.install-to-play.dont-install": {
"message": "Installera inte"
},
"app.modal.install-to-play.header": {
"message": "Installera för att spela"
},
@@ -425,9 +425,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "{count} dünya ara..."
},
"app.modal.install-to-play.dont-install": {
"message": "İndirme"
},
"app.modal.install-to-play.header": {
"message": "Oynamak için yükleyin"
},
@@ -425,9 +425,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Пошук {count} світів…"
},
"app.modal.install-to-play.dont-install": {
"message": "Не встановлювати"
},
"app.modal.install-to-play.header": {
"message": "Установлення для гри"
},
@@ -290,9 +290,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Tìm kiếm {count} world..."
},
"app.modal.install-to-play.dont-install": {
"message": "Ngừng tải"
},
"app.modal.install-to-play.header": {
"message": "Tải xuống để chơi"
},
@@ -425,9 +425,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "搜索 {count} 个世界……"
},
"app.modal.install-to-play.dont-install": {
"message": "不要安装"
},
"app.modal.install-to-play.header": {
"message": "安装以游玩"
},
@@ -425,9 +425,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "搜尋 {count} 個世界..."
},
"app.modal.install-to-play.dont-install": {
"message": "不要安裝"
},
"app.modal.install-to-play.header": {
"message": "安裝以遊玩"
},
+280 -51
View File
@@ -4,7 +4,8 @@
:class="['p-6 pr-2 pb-4', { 'shrink-0': isFixedRender }]"
@contextmenu.prevent.stop="(event) => handleRightClick(event)"
>
<ExportModal ref="exportModal" :instance="instance" />
<ExportModal v-if="!instance.quarantined" ref="exportModal" :instance="instance" />
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteSelectedInstance" />
<InstanceSettingsModal
:key="instance.id"
ref="settingsModal"
@@ -13,6 +14,15 @@
@unlinked="fetchInstance"
/>
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<SharedInstanceUpdateModal
ref="sharedInstanceUpdateModal"
@shared-instance-unavailable="handleSharedInstanceUnavailable"
@report="(event) => reportSharedInstance(event, true)"
/>
<SharedInstanceInstallModal
ref="sharedInstanceReportModal"
@reported="handleSharedInstanceReported"
/>
<InstancePageHeader
:instance="instance"
:icon-src="icon"
@@ -29,18 +39,32 @@
:ping="ping"
:minecraft-server="minecraftServer"
:linked-project-v3="linkedProjectV3"
:shared-instance-manager="sharedInstanceManager"
@repair="() => repairInstance()"
@stop="() => stopInstance('InstancePage')"
@play="() => startInstance('InstancePage')"
@play-server="() => handlePlayServer()"
@settings="() => settingsModal?.show()"
@open-folder="() => instance && showInstanceInFolder(instance.id)"
@export="() => exportModal?.show()"
@export="() => !instance.quarantined && exportModal?.show()"
@create-shortcut="() => createShortcut()"
@report="reportSharedInstance"
/>
</div>
<div :class="['px-6', { 'shrink-0': isFixedRender }]">
<NavTabs :links="tabs" />
<InstanceAdmonitions
class="mt-4"
:instance="instance"
:shared-instance-unavailable-reason="sharedInstanceUnavailableReason"
:shared-instance-unavailable-manager="sharedInstanceUnavailableManager"
:shared-instance-wrong-account="sharedInstanceWrongAccount"
:shared-instance-expected-user-id="sharedInstanceExpectedUserId"
:shared-instance-role="instance.shared_instance?.role"
:shared-instance-signed-out="sharedInstanceSignedOut"
@published="fetchInstance"
@delete="requestInstanceDeletion"
/>
</div>
<div :class="['p-6 pt-4', { 'min-h-0 flex-1 overflow-y-auto': isFixedRender }]">
<RouterView v-slot="{ Component }" :key="instance.id" :route="displayedInstanceRoute">
@@ -107,10 +131,11 @@ import {
StopCircleIcon,
TerminalSquareIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import { injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { injectAuth, injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
@@ -119,9 +144,13 @@ import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceAdmonitions from '@/components/ui/instance/instance-admonitions/index.vue'
import InstancePageHeader from '@/components/ui/instance-page-header/index.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
import SharedInstanceUpdateModal from '@/components/ui/shared-instances/SharedInstanceUpdateModal.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
@@ -130,10 +159,25 @@ import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { instance_listener, process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { get, get_full_path, kill, run } from '@/helpers/instance'
import {
getSharedInstanceUnavailableReason,
install_existing_instance,
install_get_shared_instance_preview,
install_pack_to_existing_instance,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import {
can_current_user_use_shared_instances,
get,
get_full_path,
kill,
remove,
run,
} from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_instance_id } from '@/helpers/process'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
@@ -141,10 +185,13 @@ import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state'
dayjs.extend(relativeTime)
const { addNotification, handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
const auth = injectAuth()
const queryClient = useQueryClient()
const route = useRoute()
@@ -167,17 +214,23 @@ const instance = ref<GameInstance>()
const preloadedContent = ref<InstanceContentData | null>(null)
const playing = ref(false)
const loading = ref(false)
const checkingSharedInstanceLaunch = ref(false)
const subpagePending = ref(false)
const stopping = ref(false)
const exportModal = ref<InstanceType<typeof ExportModal>>()
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
const sharedInstanceUpdateModal = ref<InstanceType<typeof SharedInstanceUpdateModal>>()
const sharedInstanceReportModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
const deleteConfirmModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
const selectedInstanceToDelete = ref<GameInstance | null>(null)
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
useLoadingBarToken(subpagePending)
const isServerInstance = ref(false)
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
const selected = ref<unknown[]>([])
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
const liveServerStatusOnline = ref(false)
@@ -189,11 +242,27 @@ const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
const activeInstanceId = ref<string>()
const sharedInstanceState = useSharedInstanceState(instance, offline, notifySharedInstanceError)
provideSharedInstanceState(sharedInstanceState)
const {
actionsLocked: sharedInstanceActionsLocked,
expectedUserId: sharedInstanceExpectedUserId,
manager: sharedInstanceManager,
refreshUpdatePreview: refreshSharedInstanceUpdatePreview,
setUnavailable: setSharedInstanceUnavailable,
signedOut: sharedInstanceSignedOut,
unavailableManager: sharedInstanceUnavailableManager,
unavailableReason: sharedInstanceUnavailableReason,
wrongAccount: sharedInstanceWrongAccount,
} = sharedInstanceState
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.path.startsWith('/instance')) {
if (
nextRoute.path.startsWith('/instance') &&
(!instance.value || nextRoute.params.id === instance.value.id)
) {
displayedInstanceRoute.value = nextRoute
}
},
@@ -219,17 +288,15 @@ function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
}
async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
preloadedContent.value = null
resetServerStatus()
const requestedInstanceId = route.params.id as string
const requestedRouteName = route.name
const nextInstance = await get(route.params.id as string).catch(handleError)
const nextInstance = await get(requestedInstanceId).catch(handleError)
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
let nextIsServerInstance = false
const contentPreloadPromise =
nextInstance && isContentSubpageRoute()
nextInstance && isContentSubpageRoute(requestedRouteName)
? loadInstanceContentData(nextInstance.id, undefined, handleError)
: Promise.resolve(null)
@@ -245,13 +312,25 @@ async function fetchInstance() {
}
}
const nextPreloadedContent = await contentPreloadPromise
let nextPreloadedContent = await contentPreloadPromise
let nextRoute = router.currentRoute.value
if (nextRoute.params.id !== requestedInstanceId) return
if (nextInstance && isContentSubpageRoute(nextRoute.name) && !nextPreloadedContent) {
nextPreloadedContent = await loadInstanceContentData(nextInstance.id, undefined, handleError)
nextRoute = router.currentRoute.value
if (nextRoute.params.id !== requestedInstanceId) return
}
instance.value = nextInstance ?? undefined
displayedInstanceRoute.value = nextRoute
sharedInstanceState.reset()
sharedInstanceState.refreshAvailability()
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
activeInstanceId.value = nextInstance?.id
resetServerStatus()
fetchDeferredData(nextInstance?.id)
@@ -335,29 +414,78 @@ const isFixedRender = computed(() => renderMode.value === 'fixed')
const contentSubpageProps = computed(() =>
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
)
const { data: canCurrentUserUseSharedInstances } = useQuery({
queryKey: computed(() => ['shared-instance-eligibility', auth.user.value?.id]),
queryFn: can_current_user_use_shared_instances,
enabled: () => !!auth.session_token.value && !!auth.user.value?.id,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const currentUserCanUseSharedInstances = computed(
() => !auth.session_token.value || canCurrentUserUseSharedInstances.value !== false,
)
const showShareTab = computed(() => {
const linkType = instance.value?.link?.type
const tabs = computed(() => [
{
label: 'Content',
href: `${basePath.value}`,
icon: BoxesIcon,
return (
currentUserCanUseSharedInstances.value &&
!instance.value?.quarantined &&
instance.value?.shared_instance?.role !== 'member' &&
linkType !== 'server_project' &&
linkType !== 'server_project_modpack'
)
})
const tabs = computed(() => {
const instanceTabs = [
{
label: 'Content',
href: `${basePath.value}`,
icon: BoxesIcon,
},
{
label: 'Files',
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
},
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
},
]
if (showShareTab.value) {
instanceTabs.push({
label: 'Share',
href: `${basePath.value}/share`,
icon: UserPlusIcon,
})
}
return instanceTabs
})
watch(
() => ({
quarantined: instance.value?.quarantined ?? false,
routeName: router.currentRoute.value.name,
}),
({ quarantined, routeName }) => {
if (quarantined && routeName === 'InstanceShare') {
void router.replace(basePath.value)
}
},
{
label: 'Files',
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
},
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
},
])
{ immediate: true },
)
if (instance.value) {
breadcrumbs.setName(
@@ -375,13 +503,8 @@ if (instance.value) {
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const startInstance = async (context: string) => {
if (!instance.value) return
if (updateToPlayModal.value?.hasUpdate) {
updateToPlayModal.value.show(instance.value)
return
}
const launchInstance = async (context: string) => {
if (!instance.value || instance.value.quarantined) return
loading.value = true
try {
await run(route.params.id as string)
@@ -391,6 +514,7 @@ const startInstance = async (context: string) => {
}
loading.value = false
if (!instance.value) return
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
@@ -398,6 +522,65 @@ const startInstance = async (context: string) => {
})
}
async function handleSharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
) {
notifySharedInstanceUnavailable(reason, sharedInstanceUnavailableManager.value)
await fetchInstance()
setSharedInstanceUnavailable(reason)
}
const startInstance = async (context: string) => {
if (!instance.value || instance.value.quarantined) return
if (checkingSharedInstanceLaunch.value || loading.value || playing.value) return
const instanceId = instance.value.id
const isSharedInstanceMember = instance.value.shared_instance?.role === 'member'
const canCheckSharedInstanceUpdate =
!!instance.value.shared_instance && !sharedInstanceActionsLocked.value && !offline.value
if (canCheckSharedInstanceUpdate) {
let preview: Awaited<ReturnType<typeof refreshSharedInstanceUpdatePreview>>
checkingSharedInstanceLaunch.value = true
try {
preview = await refreshSharedInstanceUpdatePreview()
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
await handleSharedInstanceUnavailable(getSharedInstanceUnavailableReason(error))
} else {
notifySharedInstanceError(error)
}
return
} finally {
checkingSharedInstanceLaunch.value = false
}
if (instance.value?.id !== instanceId) return
if (preview?.updateAvailable && sharedInstanceUpdateModal.value) {
sharedInstanceUpdateModal.value.show(instance.value, preview, async () => {
await fetchInstance()
await launchInstance(context)
})
return
}
}
if (updateToPlayModal.value?.hasUpdate) {
if (isSharedInstanceMember) {
updateToPlayModal.value.show(instance.value, null, async () => {
await fetchInstance()
await launchInstance(context)
})
} else {
updateToPlayModal.value.show(instance.value)
}
return
}
await launchInstance(context)
}
const stopInstance = async (context: string) => {
stopping.value = true
await kill(route.params.id as string).catch(handleError)
@@ -413,7 +596,7 @@ const stopInstance = async (context: string) => {
}
const handlePlayServer = async () => {
if (!instance.value?.link?.project_id) return
if (!instance.value?.link?.project_id || instance.value.quarantined) return
loading.value = true
try {
await playServerProject(instance.value.link.project_id)
@@ -424,6 +607,7 @@ const handlePlayServer = async () => {
}
const repairInstance = async () => {
if (instance.value.quarantined) return
if (
instance.value.install_stage !== 'pack_installed' &&
(instance.value.link?.type === 'modrinth_modpack' ||
@@ -441,7 +625,7 @@ const repairInstance = async () => {
}
const createShortcut = async () => {
if (!instance.value) return
if (!instance.value || instance.value.quarantined) return
try {
const shortcutPath = await createInstanceShortcut(instance.value.name, instance.value.id)
if (!shortcutPath) return
@@ -459,10 +643,51 @@ const createShortcut = async () => {
}
}
async function reportSharedInstance(event?: MouseEvent, closeUpdateModal = false) {
const reportInstance = instance.value
const sharedInstance = reportInstance?.shared_instance
if (!reportInstance || sharedInstance?.role !== 'member') return
try {
const preview = await install_get_shared_instance_preview(
sharedInstance.id,
reportInstance.name,
)
if (instance.value?.id !== reportInstance.id) return
if (closeUpdateModal) sharedInstanceUpdateModal.value?.hide()
sharedInstanceReportModal.value?.showReport(preview, event)
} catch (error) {
handleError(error as Error)
}
}
function handleSharedInstanceReported(deleteInstance: boolean) {
if (!deleteInstance || !instance.value) return
requestInstanceDeletion()
}
function requestInstanceDeletion() {
if (!instance.value) return
selectedInstanceToDelete.value = instance.value
deleteConfirmModal.value?.show()
}
async function deleteSelectedInstance() {
const selectedInstance = selectedInstanceToDelete.value
selectedInstanceToDelete.value = null
if (!selectedInstance) return
trackEvent('InstanceRemove', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
})
await router.push({ path: '/' })
await remove(selectedInstance.id).catch(handleError)
}
const handleRightClick = (event: MouseEvent) => {
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
...(instance.value?.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'open_folder' },
{ name: 'copy_path' },
@@ -480,10 +705,14 @@ const handleRightClick = (event: MouseEvent) => {
...baseOptions,
]
: [
{
name: 'play',
color: 'primary',
},
...(instance.value?.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
],
)
+111 -54
View File
@@ -19,14 +19,26 @@
ref="modpackContentModal"
:modpack-name="displayedModpackProject?.title"
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:enable-toggle="!props.isServerInstance && !isSharedMember && !isQuarantined"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
:switch-version="handleSwitchVersion"
:switch-version="
props.isServerInstance || isSharedMember || isQuarantined
? undefined
: handleSwitchVersion
"
@update:enabled="handleModpackContentToggle"
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
/>
<ConfirmDisableModal
ref="sharedDisableConfirmModal"
:count="pendingModpackDisableItems.length"
:item-type="formatMessage(messages.contentTypeProject)"
:warning="managedContentPolicy.disableWarning(pendingModpackDisableItems)"
:action-disabled="isInstanceBusy"
@disable="confirmPendingModpackContentDisable"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade"
@@ -38,7 +50,11 @@
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ExportModal
v-if="projects.length > 0 && !instance.quarantined"
ref="exportModal"
:instance="instance"
/>
<ContentUpdaterModal
v-if="updatingProject || updatingModpack"
ref="contentUpdaterModal"
@@ -78,6 +94,7 @@ import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets'
import {
type BulkOperationStatus,
commonMessages,
ConfirmDisableModal,
ConfirmModpackUpdateModal,
ContentCardLayout as ContentPageLayout,
type ContentItem,
@@ -91,7 +108,6 @@ import {
ModpackContentModal,
type ModpackContentModalState,
type OverflowMenuOption,
provideAppBackup,
provideContentManager,
ReadyTransition,
UnknownFileWarningModal,
@@ -109,6 +125,7 @@ import { useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
import {
@@ -116,13 +133,11 @@ import {
instance_listener,
type InstanceBulkUpdateProgress,
} from '@/helpers/events.js'
import { install_duplicate_instance, installJobInstanceId } from '@/helpers/install'
import {
add_project_from_path,
edit,
get_linked_modpack_content,
is_file_on_modrinth,
list,
remove_project,
switch_project_version_with_dependencies,
toggle_disable_project,
@@ -134,6 +149,7 @@ import { get as getSettings, set as setSettings } from '@/helpers/settings'
import type { CacheBehaviour, GameInstance } from '@/helpers/types'
import { highlightModInInstance } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme'
@@ -158,6 +174,10 @@ const messages = defineMessages({
id: 'app.instance.mods.projects-were-added',
defaultMessage: '{count} projects were added',
},
lockedContent: {
id: 'app.instance.mods.locked-content',
defaultMessage: 'Content in locked instances cannot be changed.',
},
contentTypeProject: {
id: 'app.instance.mods.content-type-project',
defaultMessage: 'project',
@@ -197,6 +217,13 @@ const props = defineProps<{
openSettings?: () => void
preloadedContent?: InstanceContentData | null
}>()
const managedContentPolicy = useManagedContentPolicy(computed(() => props.instance))
const {
isManagedModpack: isSharedMember,
isQuarantined,
canMutateContent,
canUpdateContent: canUpdateProject,
} = managedContentPolicy
function hasPreloadedContent(contentData: InstanceContentData | null | undefined) {
return contentData?.path === props.instance.id
@@ -287,6 +314,7 @@ const isBulkOperating = ref(false)
const isInstanceBusy = computed(() => props.instance?.install_stage !== 'installed')
const isPackLocked = computed(
() =>
props.instance.quarantined ||
props.instance?.link?.type === 'modrinth_modpack' ||
props.instance?.link?.type === 'server_project_modpack',
)
@@ -296,6 +324,8 @@ const exportModal = ref(null)
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>()
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal> | null>()
const modpackUpdateConfirmModal = ref<InstanceType<typeof ConfirmModpackUpdateModal> | null>()
const sharedDisableConfirmModal = ref<InstanceType<typeof ConfirmDisableModal> | null>()
const pendingModpackDisableItems = ref<ContentItem[]>([])
const unknownFileWarningModal = ref<InstanceType<typeof UnknownFileWarningModal> | null>()
const unknownFileName = ref('')
let resolveUnknownFileConfirmation: ((confirmed: boolean) => void) | null = null
@@ -377,8 +407,8 @@ function hasContentOperation(item: ContentItem) {
return keys.some((key) => activeContentOperationKeys.value.has(key))
}
function canUpdateProject(item: ContentItem) {
return !!item.file_path && !!item.has_update && !!item.update_version_id
function canDeleteContent(item: ContentItem) {
return canMutateContent(item)
}
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
@@ -486,7 +516,7 @@ async function getUpdaterProjectVersions(projectId: string, pinnedVersionId?: st
}
async function handleBrowseContent() {
if (!props.instance) return
if (!props.instance || props.instance.quarantined) return
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
query: { i: props.instance.id },
@@ -494,7 +524,7 @@ async function handleBrowseContent() {
}
async function handleUploadFiles() {
if (!props.instance) return
if (!props.instance || props.instance.quarantined) return
const files = await open({ multiple: true })
if (!files) return
const selectedFiles: Array<{ path: string; filename: string }> = []
@@ -517,26 +547,37 @@ async function handleUploadFiles() {
}),
)
const addedFiles: string[] = []
const confirmedFiles: Array<{ path: string; filename: string }> = []
for (const [index, { path, filename }] of selectedFiles.entries()) {
if (!fileRecognition[index] && !(await confirmUnknownFileInstallation(filename))) {
continue
}
try {
await add_project_from_path(props.instance.id, path)
addedFiles.push(filename)
} catch (e) {
handleError(e as Error)
}
confirmedFiles.push({ path, filename })
}
await initProjects()
if (addedFiles.length > 0) {
const names = addedFiles.map((f) => {
const item = projects.value.find(
(p) => p.file_name === f || p.file_name === f.replace('.zip', '.jar'),
)
return item?.project?.title ?? f
const addedFiles = (
await Promise.all(
confirmedFiles.map(async ({ path, filename }) => {
try {
const installedPath = await add_project_from_path(props.instance.id, path)
return { filename, installedPath }
} catch (error) {
handleError(error as Error)
return null
}
}),
)
).filter((result): result is { filename: string; installedPath: string } => result !== null)
const uniqueAddedFiles = [
...new Map(addedFiles.map((file) => [file.installedPath, file])).values(),
]
await initProjects('must_revalidate')
if (uniqueAddedFiles.length > 0) {
const names = uniqueAddedFiles.map(({ filename, installedPath }) => {
const item = projects.value.find((project) => project.file_path === installedPath)
return item?.project?.title ?? filename
})
addNotification({
type: 'success',
@@ -784,6 +825,7 @@ async function updateProject(mod: ContentItem) {
}
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
if (!canMutateContent(mod)) return
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
@@ -920,6 +962,7 @@ async function handleUpdate(id: string) {
}
async function handleSwitchVersion(item: ContentItem) {
if (!canMutateContent(item)) return
if (!item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
@@ -947,10 +990,32 @@ async function handleSwitchVersion(item: ContentItem) {
}
async function handleModpackContentToggle(item: ContentItem, enabled: boolean) {
if (!enabled && managedContentPolicy.disableWarning([item])) {
pendingModpackDisableItems.value = [item]
sharedDisableConfirmModal.value?.show()
return
}
await toggleDisableDebounced(item, enabled)
}
async function handleModpackContentBulkToggle(items: ContentItem[], enabled: boolean) {
if (!enabled && managedContentPolicy.disableWarning(items)) {
pendingModpackDisableItems.value = items
sharedDisableConfirmModal.value?.show()
return
}
await setModpackContentEnabled(items, enabled)
}
async function confirmPendingModpackContentDisable() {
const items = [...pendingModpackDisableItems.value]
pendingModpackDisableItems.value = []
await setModpackContentEnabled(items, false)
}
async function setModpackContentEnabled(items: ContentItem[], enabled: boolean) {
await Promise.all(items.map((item) => toggleDisableMod(item, enabled)))
}
@@ -1300,29 +1365,7 @@ function applyContentData(contentData: InstanceContentData) {
return true
}
provideAppBackup({
async createBackup() {
const allInstances = await list()
const prefix = `${props.instance.name} - Backup #`
const existingNums = allInstances
.filter((p) => p.name.startsWith(prefix))
.map((p) => parseInt(p.name.slice(prefix.length), 10))
.filter((n) => !isNaN(n))
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const job = await install_duplicate_instance(props.instance.id)
const newInstanceId = installJobInstanceId(job)
if (newInstanceId) {
await edit(newInstanceId, { name: `${prefix}${nextNum}` })
}
},
})
const CONTENT_HINT_KEY = 'content-tab-modpack-hint-dismissed'
const showContentHint = ref(localStorage.getItem(CONTENT_HINT_KEY) === null)
function dismissContentHint() {
showContentHint.value = false
localStorage.setItem(CONTENT_HINT_KEY, 'true')
}
provideInstanceBackup(() => props.instance)
provideContentManager({
items: mergedProjects,
@@ -1378,21 +1421,31 @@ provideContentManager({
}),
isPackLocked,
isBusy: isInstanceBusy,
disableAddContent: isQuarantined,
disableAddContentTooltip: formatMessage(messages.lockedContent),
isBulkOperating,
skipNonEssentialWarnings,
contentTypeLabel: ref(formatMessage(messages.contentTypeProject)),
toggleEnabled: toggleDisableDebounced,
bulkEnableItems: (items: ContentItem[]) =>
Promise.all(
items.filter((item) => !item.enabled).map((item) => toggleDisableMod(item, true)),
items
.filter((item) => canMutateContent(item) && !item.enabled)
.map((item) => toggleDisableMod(item, true)),
).then(() => {}),
bulkDisableItems: (items: ContentItem[]) =>
Promise.all(
items.filter((item) => item.enabled).map((item) => toggleDisableMod(item, false)),
items
.filter((item) => canMutateContent(item) && item.enabled)
.map((item) => toggleDisableMod(item, false)),
).then(() => {}),
deleteItem: removeMod,
bulkDeleteItems: (items: ContentItem[]) =>
Promise.all(items.map((item) => removeMod(item))).then(() => {}),
Promise.all(items.filter(canMutateContent).map((item) => removeMod(item))).then(() => {}),
canDeleteItem: canDeleteContent,
canToggleItem: canMutateContent,
getDeleteWarning: managedContentPolicy.deleteWarning,
getDisableWarning: managedContentPolicy.disableWarning,
getDeleteDependencyWarning,
refresh: () => initProjects('must_revalidate'),
browse: handleBrowseContent,
@@ -1401,14 +1454,15 @@ provideContentManager({
updateItem: handleUpdate,
bulkUpdateAll: bulkUpdateAllProjects,
bulkUpdateItem: updateProject,
updateModpack: props.isServerInstance ? undefined : handleModpackUpdate,
updateModpack:
props.isServerInstance || isSharedMember.value || isQuarantined.value
? undefined
: handleModpackUpdate,
viewModpackContent: handleModpackContent,
unlinkModpack: unpairInstance,
openSettings: props.openSettings,
switchVersion: handleSwitchVersion,
getOverflowOptions,
showContentHint,
dismissContentHint,
shareItems: handleShareItems,
getItemId: getContentItemId,
mapToTableItem: (item: ContentItem) => ({
@@ -1440,8 +1494,11 @@ provideContentManager({
link: () => openUrl(`https://modrinth.com/${item.owner!.type}/${item.owner!.id}`),
}
: undefined,
enabled: item.enabled,
enabled: canMutateContent(item) ? item.enabled : undefined,
installing: item.installing,
hideDelete: !canDeleteContent(item),
hideSwitchVersion: !canMutateContent(item),
hasUpdate: canUpdateProject(item),
}),
filterPersistKey: props.instance.id,
})
@@ -88,6 +88,7 @@
:highlighted="highlightedWorld === getWorldIdentifier(world)"
:supports-server-quick-play="supportsServerQuickPlay"
:supports-world-quick-play="supportsWorldQuickPlay"
:quarantined="instance.quarantined"
:current-protocol="protocolVersion"
:playing-instance="playing"
:playing-world="worldsMatch(world, worldPlaying)"
@@ -273,6 +274,7 @@ const instance = computed(() => props.instance)
const playing = computed(() => props.playing)
function play(world: World) {
if (props.instance.quarantined) return
emit('play', world)
}
@@ -523,6 +525,7 @@ function handleJoinError(err: Error) {
}
async function joinWorld(world: World) {
if (instance.value.quarantined) return
console.log(`Joining world ${getWorldIdentifier(world)}`)
startingInstance.value = true
worldPlaying.value = world
@@ -3,6 +3,7 @@ import Index from './Index.vue'
import Logs from './Logs.vue'
import Mods from './Mods.vue'
import Overview from './Overview.vue'
import Share from './share/index.vue'
import Worlds from './Worlds.vue'
export { Files, Index, Logs, Mods, Overview, Worlds }
export { Files, Index, Logs, Mods, Overview, Share, Worlds }
@@ -0,0 +1,373 @@
<template>
<div v-if="!instance.quarantined" class="flex flex-col gap-4">
<ModrinthAccountRequiredModal ref="accountRequiredModal" :request-auth="requestAuth" />
<InvitePlayersModal
ref="invitePlayersModal"
:header="formatMessage(messages.shareModalHeader, { name: instance.name })"
:friends="inviteFriends"
:search-users="searchInviteUsers"
:link="inviteLink.link.value"
:link-expires-at="inviteLink.details.value?.expiresAt"
:link-max-uses="inviteLink.details.value?.maxUses"
:update-invite-link="inviteLink.update"
:user-profile-link="userProfileLink"
@invite="invitePlayer"
@cancel="cancelInvite"
/>
<ConfirmUnlinkModal
ref="unlinkModal"
:warning="{
header: formatMessage(messages.unlinkForShareHeader),
body: formatMessage(messages.unlinkForShareBody),
}"
:backup-tip="importedModpackBackupTip"
@unlink="unlinkImportedModpack"
/>
<SharedInstanceRemoveMemberModal
ref="removeMemberModal"
:row="pendingRemovalRow"
:member-count="members.rows.value.length"
@confirm="removeMember"
@clear="pendingRemovalRow = null"
/>
<SharedInstancePublishModal
ref="publishModal"
:instance="instance"
@state-change="publishState = $event"
/>
<SharedInstanceMembersTable
v-if="members.rows.value.length > 0"
:rows="members.rows.value"
:actions-locked="sharedInstanceActionsLocked"
:invite-pending="inviteLink.pending.value"
:push-update-disabled="
instance.install_stage !== 'installed' || publishState !== 'idle' || !!offline
"
:push-update-pending="publishState !== 'idle'"
@invite="showInvitePlayers"
@remove="showRemoveMemberModal"
@push-update="reviewUpdate"
/>
<SharedInstanceShareEmptyState
v-else-if="sharedInstanceUnavailable"
:heading="formatMessage(sharedInstanceErrorMessages.unavailableTitle)"
:description="
formatSharedInstanceUnavailable(
sharedInstanceUnavailableReason,
sharedInstanceUnavailableManager,
)
"
/>
<SharedInstanceShareEmptyState
v-else-if="sharedInstanceActionsLocked"
:heading="formatMessage(lockedEmptyHeading)"
>
<template #description>
<span class="flex flex-wrap items-center justify-center gap-x-1.5 gap-y-1">
<span>{{ formatMessage(messages.lockedEmptyDescriptionPrefix) }}</span>
<span
v-if="linkedAccount"
class="inline-flex max-w-full min-w-0 items-center gap-1.5 align-middle font-semibold text-primary"
>
<Avatar
:src="linkedAccount.avatarUrl"
:alt="linkedAccount.username"
:tint-by="linkedAccount.tintBy"
size="20px"
circle
no-shadow
/>
<span class="min-w-0 truncate">{{ linkedAccount.username }}</span>
</span>
<span v-else class="font-semibold text-primary">{{
formatMessage(messages.linkedAccountFallback)
}}</span>
<span>{{ formatMessage(messages.lockedEmptyDescriptionSuffix) }}</span>
</span>
</template>
<template #actions>
<ButtonStyled color="brand"
><button class="!h-10" @click="signInToShare">
<LogInIcon aria-hidden="true" />{{ formatMessage(lockedActionButton) }}
</button></ButtonStyled
>
</template>
</SharedInstanceShareEmptyState>
<SharedInstanceShareEmptyState
v-else
:heading="formatMessage(messages.noFriendsInvitedHeading)"
:description="formatMessage(messages.noFriendsInvitedDescription)"
>
<template #actions>
<ButtonStyled color="brand"
><button
class="!h-10"
:disabled="inviteLink.pending.value"
@click="showInvitePlayers($event)"
>
<SpinnerIcon
v-if="inviteLink.pending.value"
class="animate-spin"
aria-hidden="true"
/><UserPlusIcon v-else aria-hidden="true" />{{
formatMessage(messages.inviteFriendsButton)
}}
</button></ButtonStyled
>
</template>
</SharedInstanceShareEmptyState>
</div>
</template>
<script setup lang="ts">
import { LogInIcon, SpinnerIcon, UserPlusIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
ConfirmUnlinkModal,
defineMessages,
injectAuth,
type InvitePlayersInvitePayload,
InvitePlayersModal,
type InvitePlayersUser,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref, toRef, watch } from 'vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
import {
getSharedInstanceUnavailableReason,
isSharedInstanceUnavailableError,
} from '@/helpers/install'
import { edit } from '@/helpers/instance'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth.ts'
import {
sharedInstanceErrorMessages,
useSharedInstanceErrors,
} from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectSharedInstanceState } from '../use-shared-instance-state'
import SharedInstanceMembersTable from './shared-instance-members-table.vue'
import SharedInstanceRemoveMemberModal from './shared-instance-remove-member-modal.vue'
import SharedInstanceShareEmptyState from './shared-instance-share-empty-state.vue'
import type { ShareRow } from './shared-instance-share-types'
import { useSharedInstanceInviteCandidates } from './use-shared-instance-invite-candidates'
import { useSharedInstanceInviteLink } from './use-shared-instance-invite-link'
import { useSharedInstanceMembers } from './use-shared-instance-members'
const props = defineProps<{
instance: GameInstance
offline?: boolean
}>()
const auth = injectAuth()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const {
formatSharedInstanceUnavailable,
notifySharedInstanceError,
notifySharedInstanceUnavailable,
} = useSharedInstanceErrors()
const sharedInstanceState = injectSharedInstanceState()
const instance = toRef(props, 'instance')
const actionsLocked = sharedInstanceState.shareActionsLocked
const sharedInstanceActionsLocked = actionsLocked
const currentUserId = computed(() => auth.user.value?.id ?? null)
const isSignedIn = computed(() => !!auth.session_token.value)
const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>()
const invitePlayersModal = ref<InstanceType<typeof InvitePlayersModal>>()
const unlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
const removeMemberModal = ref<InstanceType<typeof SharedInstanceRemoveMemberModal>>()
const publishModal = ref<InstanceType<typeof SharedInstancePublishModal>>()
const publishState = ref<'idle' | 'reviewing' | 'publishing'>('idle')
const pendingRemovalRow = ref<ShareRow | null>(null)
const importedModpackUnlinked = ref(false)
function notifyOperationError(error: unknown) {
if (isSharedInstanceUnavailableError(error)) {
notifySharedInstanceUnavailable(
getSharedInstanceUnavailableReason(error),
sharedInstanceState.unavailableManager.value,
)
} else {
notifySharedInstanceError(error)
}
}
const members = useSharedInstanceMembers({
instance,
currentUserId,
isSignedIn,
actionsLocked,
onError: notifyOperationError,
})
const {
inviteFriends,
search: searchInviteUsers,
requestFriend,
} = useSharedInstanceInviteCandidates({
rows: members.rows,
currentUserId,
isSignedIn,
actionsLocked,
})
const inviteLink = useSharedInstanceInviteLink(
computed(() => props.instance.id),
notifyOperationError,
)
const linkedAccount = computed(() => {
const manager = sharedInstanceState.manager.value
return manager?.type === 'user'
? { username: manager.name, avatarUrl: manager.avatarUrl, tintBy: manager.tintBy }
: null
})
const lockedEmptyHeading = computed(() =>
isSignedIn.value ? messages.lockedWrongAccountHeading : messages.lockedSignedOutHeading,
)
const lockedActionButton = computed(() =>
isSignedIn.value ? messages.switchAccountButton : messages.signInButton,
)
const sharedInstanceUnavailableReason = sharedInstanceState.unavailableReason
const sharedInstanceUnavailable = computed(() => !!sharedInstanceUnavailableReason.value)
const sharedInstanceUnavailableManager = sharedInstanceState.unavailableManager
const requiresUnlink = computed(
() =>
props.instance.link?.type === 'imported_modpack' &&
!props.instance.shared_instance &&
!importedModpackUnlinked.value,
)
const importedModpackBackupTip = computed(() =>
props.instance.link?.type === 'imported_modpack'
? (props.instance.link.name ?? props.instance.link.filename ?? undefined)
: undefined,
)
const messages = defineMessages({
signInButton: { id: 'app.instance.share.sign-in.button', defaultMessage: 'Sign in' },
noFriendsInvitedHeading: {
id: 'app.instance.share.empty.heading',
defaultMessage: 'No friends invited',
},
noFriendsInvitedDescription: {
id: 'app.instance.share.empty.description',
defaultMessage: 'You can share this instance with your friends!',
},
inviteFriendsButton: {
id: 'app.instance.share.empty.invite-friends-button',
defaultMessage: 'Invite friends',
},
shareModalHeader: {
id: 'app.instance.share.invite-modal.heading',
defaultMessage: 'Share {name}',
},
lockedWrongAccountHeading: {
id: 'app.instance.share.locked.wrong-account-heading',
defaultMessage: 'Wrong account',
},
lockedSignedOutHeading: {
id: 'app.instance.share.locked.signed-out-heading',
defaultMessage: 'Not signed in',
},
lockedEmptyDescriptionPrefix: {
id: 'app.instance.share.locked.empty-description-prefix',
defaultMessage: 'You need to sign in as',
},
lockedEmptyDescriptionSuffix: {
id: 'app.instance.share.locked.empty-description-suffix',
defaultMessage: 'to access this page.',
},
linkedAccountFallback: {
id: 'app.instance.share.locked.linked-account-fallback',
defaultMessage: 'the linked account',
},
switchAccountButton: {
id: 'app.instance.share.locked.switch-account-button',
defaultMessage: 'Switch account',
},
unlinkForShareHeader: {
id: 'app.instance.share.unlink.header',
defaultMessage: 'Sharing requires unlinking',
},
unlinkForShareBody: {
id: 'app.instance.share.unlink.body',
defaultMessage: 'You must unlink this modpack to share your instance',
},
})
function invitePlayer(payload: InvitePlayersInvitePayload) {
if (actionsLocked.value) return
if (payload.source === 'search') void requestFriend(payload.user)
members.invite(payload.user)
}
function cancelInvite(user: InvitePlayersUser) {
const row = members.find(user.id, user.username)
if (row) members.remove(row.id)
}
async function showInvitePlayers(event?: MouseEvent) {
if (actionsLocked.value) return
if (!isSignedIn.value) return signInToShare(event)
if (requiresUnlink.value) return unlinkModal.value?.show()
if (await inviteLink.ensure()) invitePlayersModal.value?.show(event)
}
async function unlinkImportedModpack() {
try {
await edit(props.instance.id, { link: null as unknown as undefined })
importedModpackUnlinked.value = true
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', props.instance.id] })
if (await inviteLink.ensure()) invitePlayersModal.value?.show()
} catch (error) {
notifyOperationError(error)
}
}
function showRemoveMemberModal(row: ShareRow) {
if (!actionsLocked.value) {
pendingRemovalRow.value = row
removeMemberModal.value?.show()
}
}
function reviewUpdate(event: MouseEvent) {
publishModal.value?.show(event)
}
function removeMember(row: ShareRow) {
members.remove(row.id)
}
function userProfileLink(username: string) {
return !username || username.includes('@')
? undefined
: () => openUrl(`https://modrinth.com/user/${encodeURIComponent(username)}`)
}
async function requestAuth(flow: ModrinthAuthFlow) {
await auth.requestSignIn(`/instance/${encodeURIComponent(props.instance.id)}/share`, flow, {
showModal: false,
})
return !!auth.session_token.value
}
function signInToShare(event?: MouseEvent) {
void accountRequiredModal.value?.show(event)
}
watch(
() => props.instance.id,
() => {
importedModpackUnlinked.value = false
},
)
watch(
[() => auth.isReady.value, isSignedIn, actionsLocked],
([ready, signedIn, locked]) => {
if (ready && !signedIn && !locked) signInToShare()
},
{ immediate: true, flush: 'post' },
)
provideInstanceBackup(() => props.instance)
</script>
@@ -0,0 +1,304 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<StyledInput
v-model="search"
:icon="SearchIcon"
:placeholder="`Search ${rows.length} users...`"
wrapper-class="min-w-0 flex-1"
input-class="!h-10"
clearable
/>
<template v-if="!actionsLocked">
<ButtonStyled>
<button
class="flex !h-10 shrink-0 items-center gap-2"
:disabled="pushUpdateDisabled"
@click="emit('push-update', $event)"
>
<SpinnerIcon v-if="pushUpdatePending" class="animate-spin" aria-hidden="true" />
<UploadIcon v-else aria-hidden="true" />
{{ formatMessage(messages.pushUpdate) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
class="flex !h-10 shrink-0 items-center gap-2"
:disabled="invitePending"
@click="emit('invite', $event)"
>
<SpinnerIcon v-if="invitePending" class="animate-spin" aria-hidden="true" />
<UserPlusIcon v-else aria-hidden="true" />
Invite friends
</button>
</ButtonStyled>
</template>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<FilterIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
<button
:class="filterClass(methodFilter === 'all')"
:aria-pressed="methodFilter === 'all'"
@click="methodFilter = 'all'"
>
All
</button>
<button
v-for="option in methodFilterOptions"
:key="option.id"
:class="filterClass(methodFilter === option.id)"
:aria-pressed="methodFilter === option.id"
@click="toggleMethodFilter(option.id)"
>
{{ option.label }}
</button>
</div>
</div>
<Table
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
:columns="columns"
:data="sortedRows"
row-key="id"
table-min-width="42rem"
@sort="handleSort"
>
<template #empty-state
><div class="flex h-64 items-center justify-center text-secondary">
No users match your filters.
</div></template
>
<template #cell-username="{ row }">
<div class="flex min-w-0 max-w-full items-center gap-2">
<AutoLink
v-tooltip="truncatedTooltip(usernameRefs[row.id], row.username)"
:to="userProfileLink(row.username)"
class="inline-flex max-w-full min-w-0 items-center gap-2 text-primary hover:underline"
>
<Avatar
:src="row.avatarUrl"
:alt="`${row.username}'s avatar`"
:tint-by="row.username"
size="24px"
circle
no-shadow
/>
<span
:ref="(element) => setUsernameRef(row.id, element)"
class="min-w-0 truncate font-medium"
>{{ row.username }}</span
>
</AutoLink>
</div>
</template>
<template #cell-lastPlayed="{ row }">
<span v-if="row.lastPlayedAt" v-tooltip="formatDateTime(row.lastPlayedAt)">{{
formatRelativeTime(row.lastPlayedAt)
}}</span>
<span v-else>Never</span>
</template>
<template #cell-joined="{ row }">
<span
v-if="row.pending"
class="inline-flex h-7 items-center rounded-full border border-surface-5 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
>Pending</span
>
<span v-else-if="row.joinedAt" v-tooltip="formatDateTime(row.joinedAt)">{{
formatRelativeTime(row.joinedAt)
}}</span>
</template>
<template #cell-method="{ row }">
<span class="inline-flex min-w-0 items-center gap-2">
<UserPlusIcon v-if="row.method === 'direct'" class="size-5 shrink-0" aria-hidden="true" />
<LinkIcon v-else class="size-5 shrink-0" aria-hidden="true" />
<span class="min-w-0 truncate">{{ methodLabels[row.method] }}</span>
</span>
</template>
<template #cell-actions="{ row }">
<div v-if="!actionsLocked" class="flex items-center justify-end">
<ButtonStyled circular type="transparent"
><button
v-tooltip="'Revoke access'"
:aria-label="`Revoke access for ${row.username}`"
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none"
@click="emit('remove', row)"
>
<XIcon aria-hidden="true" /></button
></ButtonStyled>
</div>
</template>
</Table>
</div>
</template>
<script setup lang="ts">
import {
FilterIcon,
LinkIcon,
SearchIcon,
SpinnerIcon,
UploadIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import {
AutoLink,
Avatar,
ButtonStyled,
defineMessages,
type SortDirection,
StyledInput,
Table,
type TableColumn,
truncatedTooltip,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref } from 'vue'
import {
type MethodFilter,
methodLabels,
type ShareMethod,
type ShareRow,
type ShareTableColumn,
} from './shared-instance-share-types'
const props = defineProps<{
rows: ShareRow[]
actionsLocked?: boolean
invitePending?: boolean
pushUpdateDisabled?: boolean
pushUpdatePending?: boolean
}>()
const emit = defineEmits<{
invite: [event: MouseEvent]
remove: [row: ShareRow]
'push-update': [event: MouseEvent]
}>()
const search = ref('')
const methodFilter = ref<MethodFilter>('all')
const sortColumn = ref<string | undefined>('joined')
const sortDirection = ref<SortDirection>('desc')
const usernameRefs = ref<Record<string, HTMLElement | null>>({})
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime({ style: 'narrow' })
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const methodFilterOptions: Array<{ id: ShareMethod; label: string }> = [
{ id: 'direct', label: methodLabels.direct },
{ id: 'link', label: methodLabels.link },
]
const columns = computed<TableColumn<ShareTableColumn>[]>(() => {
const result: TableColumn<ShareTableColumn>[] = [
{
key: 'username',
label: 'Username',
width: 'clamp(14rem, 30%, 26rem)',
enableSorting: true,
headerClass: '!pr-3',
cellClass: '!pr-3',
},
{
key: 'lastPlayed',
label: 'Last played',
width: 'clamp(7rem, 15%, 13rem)',
enableSorting: true,
headerClass: 'whitespace-nowrap !px-2',
cellClass: 'whitespace-nowrap !px-2',
},
{
key: 'joined',
label: 'Joined',
width: 'clamp(7rem, 14%, 12rem)',
enableSorting: true,
defaultSortDirection: 'desc',
headerClass: 'whitespace-nowrap !px-2',
cellClass: 'whitespace-nowrap !px-2',
},
{
key: 'method',
label: 'Method',
enableSorting: true,
headerClass: 'whitespace-nowrap !px-2',
cellClass: 'whitespace-nowrap !px-2',
},
]
if (!props.actionsLocked)
result.push({
key: 'actions',
label: 'Actions',
align: 'right',
width: 'clamp(5.5rem, 7%, 7rem)',
headerClass: 'whitespace-nowrap !pl-2 !pr-4',
cellClass: 'whitespace-nowrap !pl-2 !pr-4',
})
return result
})
const filteredRows = computed(() => {
const query = search.value.trim().toLowerCase()
return props.rows.filter((row) => {
if (methodFilter.value !== 'all' && row.method !== methodFilter.value) return false
if (!query) return true
return [
row.username,
row.lastPlayedAt ? formatRelativeTime(row.lastPlayedAt) : 'Never',
row.pending ? 'Pending' : row.joinedAt ? formatRelativeTime(row.joinedAt) : '',
methodLabels[row.method],
].some((value) => value.toLowerCase().includes(query))
})
})
const sortedRows = computed(() => [...filteredRows.value].sort(compareRows))
function compareRows(a: ShareRow, b: ShareRow) {
let compared: number
if (sortColumn.value === 'username') compared = a.username.localeCompare(b.username)
else if (sortColumn.value === 'lastPlayed')
compared =
(a.lastPlayedAt?.getTime() ?? Number.NEGATIVE_INFINITY) -
(b.lastPlayedAt?.getTime() ?? Number.NEGATIVE_INFINITY)
else if (sortColumn.value === 'method')
compared = methodLabels[a.method].localeCompare(methodLabels[b.method])
else
compared =
(a.pending ? Number.MAX_SAFE_INTEGER : (a.joinedAt?.getTime() ?? Number.NEGATIVE_INFINITY)) -
(b.pending
? Number.MAX_SAFE_INTEGER
: (b.joinedAt?.getTime() ?? Number.NEGATIVE_INFINITY)) ||
a.username.localeCompare(b.username)
return sortDirection.value === 'asc' ? compared : -compared
}
function handleSort(column: string, direction: SortDirection) {
sortColumn.value = column
sortDirection.value = direction
}
function toggleMethodFilter(filter: ShareMethod) {
methodFilter.value = methodFilter.value === filter ? 'all' : filter
}
function filterClass(active: boolean) {
return [
'cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]',
active
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5',
]
}
const messages = defineMessages({
pushUpdate: {
id: 'app.instance.admonitions.shared-instance.publish-button',
defaultMessage: 'Push update',
},
})
function userProfileLink(username: string) {
return !username || username.includes('@')
? undefined
: () => openUrl(`https://modrinth.com/user/${encodeURIComponent(username)}`)
}
function setUsernameRef(id: string, element: Element | null) {
usernameRefs.value[id] = element instanceof HTMLElement ? element : null
}
</script>
@@ -0,0 +1,122 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
max-width="470px"
@after-hide="emit('clear')"
>
<div class="flex flex-col gap-4">
<Admonition type="warning">{{ formatMessage(messages.warning, { username }) }}</Admonition>
<div class="flex min-w-0 items-center gap-2 rounded-[20px] bg-surface-2 p-3">
<Avatar
:src="row?.avatarUrl"
:alt="formatMessage(messages.avatarAlt, { username })"
:tint-by="username"
size="40px"
circle
no-shadow
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="min-w-0 truncate font-medium text-contrast">{{ username }}</span>
<span class="truncate text-sm text-secondary">{{
row ? methodLabels[row.method] : ''
}}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.effectsLabel) }}</span>
<ul class="m-0 list-disc pl-6 text-primary">
<li v-for="effect in effects" :key="effect.id" class="leading-6 marker:text-secondary">
{{ formatMessage(effect) }}
</li>
</ul>
</div>
<div class="flex justify-end gap-2 pt-1">
<ButtonStyled type="outlined"
><button class="!border !border-surface-5" @click="modal?.hide()">
<XIcon aria-hidden="true" />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
>
<ButtonStyled color="orange"
><button :disabled="!row" @click="confirm">
<UserXIcon aria-hidden="true" />{{ formatMessage(messages.removeButton) }}
</button></ButtonStyled
>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { UserXIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { methodLabels, type ShareRow } from './shared-instance-share-types'
const props = defineProps<{ row: ShareRow | null; memberCount: number }>()
const emit = defineEmits<{ confirm: [row: ShareRow]; clear: [] }>()
const modal = ref<InstanceType<typeof NewModal>>()
const { formatMessage } = useVIntl()
const username = computed(() => props.row?.username ?? '')
const messages = defineMessages({
header: { id: 'app.instance.share.remove-user-modal.header', defaultMessage: 'Revoke access' },
warning: {
id: 'app.instance.share.remove-user-modal.warning-body',
defaultMessage:
"If you revoke {username}'s access to this shared instance, you'll need to invite them again before they can receive updates.",
},
avatarAlt: {
id: 'app.instance.share.remove-user-modal.user-avatar-alt',
defaultMessage: "{username}'s avatar",
},
effectsLabel: {
id: 'app.instance.share.remove-user-modal.effects-label',
defaultMessage: 'What happens?',
},
effectAccess: {
id: 'app.instance.share.remove-user-modal.effect-access',
defaultMessage: 'They will no longer receive updates for this shared instance',
},
effectInstalledCopy: {
id: 'app.instance.share.remove-user-modal.effect-installed-copy',
defaultMessage: 'Any copy they already installed will stay on their device',
},
effectInviteAgain: {
id: 'app.instance.share.remove-user-modal.effect-invite-again',
defaultMessage: 'You can invite them again later',
},
effectLastUser: {
id: 'app.instance.share.remove-user-modal.effect-last-user',
defaultMessage: 'This is the last user, sharing will be turned off for this instance',
},
removeButton: {
id: 'app.instance.share.remove-user-modal.remove-button',
defaultMessage: 'Revoke access',
},
})
const effects = computed(() => {
const result = [messages.effectAccess, messages.effectInstalledCopy, messages.effectInviteAgain]
if (props.memberCount === 1) result.push(messages.effectLastUser)
return result
})
function show(event?: MouseEvent) {
modal.value?.show(event)
}
function confirm() {
if (props.row) {
modal.value?.hide()
emit('confirm', props.row)
}
}
defineExpose({ show })
</script>
@@ -0,0 +1,15 @@
<template>
<EmptyState type="empty-inbox">
<template #heading>{{ heading }}</template>
<template #description
><slot name="description">{{ description }}</slot></template
>
<template v-if="$slots.actions" #actions><slot name="actions" /></template>
</EmptyState>
</template>
<script setup lang="ts">
import { EmptyState } from '@modrinth/ui'
defineProps<{ heading: string; description?: string }>()
</script>
@@ -0,0 +1,20 @@
export type ShareMethod = 'direct' | 'link'
export type MethodFilter = ShareMethod | 'all'
export type ShareTableColumn = 'username' | 'lastPlayed' | 'joined' | 'method' | 'actions'
export type ShareRow = {
id: string
username: string
avatarUrl?: string
lastPlayedAt: Date | null
joinedAt: Date | null
method: ShareMethod
pending?: boolean
}
export const methodLabels: Record<ShareMethod, string> = {
direct: 'Direct invite',
link: 'Share link',
}
export { normalizeInviteKey } from '@modrinth/ui'
@@ -0,0 +1,105 @@
import {
injectNotificationManager,
type InvitePlayersSearchUser,
type InvitePlayersUser,
} from '@modrinth/ui'
import { computed, type Ref } from 'vue'
import { useFriends } from '@/composables/use-friends'
import { getFriendUserId } from '@/helpers/friends.ts'
import { get as getCredentials } from '@/helpers/mr_auth.ts'
import { search_user } from '@/helpers/users.ts'
import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types'
export function useSharedInstanceInviteCandidates(options: {
rows: Ref<ShareRow[]>
currentUserId: Ref<string | null>
isSignedIn: Ref<boolean>
actionsLocked: Ref<boolean>
}) {
const { handleError } = injectNotificationManager()
const friendsState = useFriends({
currentUserId: options.currentUserId,
getCredentials,
enabled: computed(
() =>
options.isSignedIn.value && !!options.currentUserId.value && !options.actionsLocked.value,
),
onError: handleError,
})
const friends = friendsState.friends
const invitedRows = computed(() => {
const invited = new Map<string, ShareRow>()
for (const row of options.rows.value) {
invited.set(normalizeInviteKey(row.id), row)
invited.set(normalizeInviteKey(row.username), row)
}
return invited
})
const inviteFriends = computed<InvitePlayersUser[]>(() =>
friends.value
.filter((friend) => friend.username && friend.accepted)
.sort((a, b) => Number(b.online) - Number(a.online))
.map((friend) => {
const id = getFriendUserId(friend, options.currentUserId.value)
const invited =
invitedRows.value.get(normalizeInviteKey(id)) ??
invitedRows.value.get(normalizeInviteKey(friend.username))
return {
id,
username: friend.username,
avatarUrl: friend.avatar,
online: friend.online,
status: invited ? (invited.pending ? 'pending' : 'added') : 'available',
}
}),
)
const candidateKeys = computed(() => {
const keys = new Set<string>()
for (const friend of inviteFriends.value) {
keys.add(normalizeInviteKey(friend.id))
keys.add(normalizeInviteKey(friend.username))
}
return keys
})
async function search(query: string): Promise<InvitePlayersSearchUser[]> {
if (options.actionsLocked.value) return []
const credentials = await getCredentials()
const ownUserId = options.currentUserId.value ?? credentials?.user_id ?? null
return (await search_user(query))
.filter((user) => user.id !== ownUserId)
.filter((user) => {
const id = normalizeInviteKey(user.id)
const username = normalizeInviteKey(user.username)
return (
!candidateKeys.value.has(id) &&
!candidateKeys.value.has(username) &&
!invitedRows.value.has(id) &&
!invitedRows.value.has(username)
)
})
.map((user) => ({
id: user.id,
username: user.username,
avatarUrl: user.avatar_url || undefined,
}))
}
async function requestFriend(user: InvitePlayersUser) {
if (options.actionsLocked.value) return
const credentials = await getCredentials()
const ownUserId = options.currentUserId.value ?? credentials?.user_id ?? null
if (ownUserId && normalizeInviteKey(user.id) === normalizeInviteKey(ownUserId)) return
if (!friendsState.findFriend(user.id, user.username)) {
friendsState.requestFriend({
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
})
}
}
return { inviteFriends, search, requestFriend }
}
@@ -0,0 +1,63 @@
import type { InviteLinkSettings } from '@modrinth/ui'
import { computed, type Ref, ref, watch } from 'vue'
import { config } from '@/config'
import { toError } from '@/helpers/errors'
import { create_shared_instance_invite_link } from '@/helpers/instance'
export function useSharedInstanceInviteLink(
instanceId: Ref<string>,
onError: (error: unknown) => void,
) {
const details = ref<Awaited<ReturnType<typeof create_shared_instance_invite_link>>>()
const pending = ref(false)
const link = computed(() =>
details.value
? `${config.siteUrl}/share/${encodeURIComponent(details.value.inviteId)}`
: undefined,
)
async function ensure() {
if (details.value) return true
if (pending.value) return false
pending.value = true
try {
details.value = await create_shared_instance_invite_link(instanceId.value)
return true
} catch (error) {
onError(error)
return false
} finally {
pending.value = false
}
}
async function update(settings: InviteLinkSettings) {
if (!details.value) return
pending.value = true
try {
const maxAgeSeconds = Math.max(
1,
Math.min(604800, Math.floor((settings.expiresAt.getTime() - Date.now()) / 1000)),
)
details.value = await create_shared_instance_invite_link(instanceId.value, {
maxAgeSeconds,
maxUses: settings.maxUses,
replaceInviteId: details.value.inviteId,
})
} catch (error) {
throw toError(error)
} finally {
pending.value = false
}
}
watch(instanceId, () => {
details.value = undefined
pending.value = false
})
return { details, pending, link, ensure, update }
}
@@ -0,0 +1,241 @@
import type { InvitePlayersUser } from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type Ref } from 'vue'
import { get_user_many } from '@/helpers/cache.js'
import {
get_shared_instance_users,
invite_shared_instance_users,
remove_shared_instance_users,
type SharedInstanceUser,
type SharedInstanceUsers,
} from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types'
type MembersQueryKey = readonly ['sharedInstanceUsers', string]
type OptimisticChange = {
queryKey: MembersQueryKey
userId: string
previousRow?: ShareRow
previousIndex: number
}
type InviteVariables = {
user: InvitePlayersUser
change: OptimisticChange
}
type RemoveVariables = {
id: string
hasPendingRecipients: boolean
change: OptimisticChange
}
export function useSharedInstanceMembers(options: {
instance: Ref<GameInstance>
currentUserId: Ref<string | null>
isSignedIn: Ref<boolean>
actionsLocked: Ref<boolean>
onError: (error: unknown) => void
}) {
const queryClient = useQueryClient()
const queryKey = computed(() => ['sharedInstanceUsers', options.instance.value.id] as const)
const invitingUserIds = new Set<string>()
const removingUserIds = new Set<string>()
const query = useQuery({
queryKey,
queryFn: ({ queryKey }) => fetchRows(queryKey),
enabled: () =>
options.isSignedIn.value && !!options.instance.value.id && !options.actionsLocked.value,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnReconnect: false,
refetchOnWindowFocus: false,
})
const rows = computed(() => query.data.value ?? [])
const inviteMutation = useMutation({
mutationFn: ({ user, change }: InviteVariables) =>
invite_shared_instance_users(change.queryKey[1], [user.id]),
onError: (error, { change }) => {
rollback(change)
options.onError(error)
},
onSettled: (_data, _error, { user }) => {
invitingUserIds.delete(normalizeInviteKey(user.id))
},
})
const removeMutation = useMutation({
mutationFn: ({ id, hasPendingRecipients, change }: RemoveVariables) =>
remove_shared_instance_users(change.queryKey[1], [id], hasPendingRecipients),
onError: (error, { change }) => {
rollback(change)
options.onError(error)
},
onSettled: (_data, _error, { id }) => {
removingUserIds.delete(normalizeInviteKey(id))
},
})
async function fetchRows(activeQueryKey: MembersQueryKey) {
const users = await get_shared_instance_users(activeQueryKey[1])
const loadedRows = await usersToRows(users)
const currentRows = queryClient.getQueryData<ShareRow[]>(activeQueryKey) ?? []
return preserveRowOrder(loadedRows, currentRows)
}
async function usersToRows(users: SharedInstanceUsers): Promise<ShareRow[]> {
const excludedIds = new Set(
[options.instance.value.shared_instance?.manager_id, options.currentUserId.value].filter(
(id): id is string => !!id,
),
)
const usersToDisplay = userEntries(users).filter((user) => !excludedIds.has(user.id))
if (usersToDisplay.length === 0) return []
const profiles = (await get_user_many(usersToDisplay.map((user) => user.id))) as Array<{
id: string
username?: string
avatar_url?: string | null
}>
return usersToDisplay.map((user) => {
const profile = profiles.find((candidate) => candidate.id === user.id)
const joinedAt = parseDate(user.joined_at)
return {
id: user.id,
username: profile?.username ?? user.id,
avatarUrl: profile?.avatar_url ?? undefined,
lastPlayedAt: parseDate(user.last_played),
joinedAt,
method: user.join_type === 'link' ? 'link' : 'direct',
pending: !joinedAt,
}
})
}
function find(id: string, username: string) {
const normalizedId = normalizeInviteKey(id)
const normalizedUsername = normalizeInviteKey(username)
return rows.value.find(
(row) =>
normalizeInviteKey(row.id) === normalizedId ||
normalizeInviteKey(row.username) === normalizedUsername,
)
}
function invite(user: InvitePlayersUser) {
const normalizedId = normalizeInviteKey(user.id)
if (
options.actionsLocked.value ||
invitingUserIds.has(normalizedId) ||
find(user.id, user.username)
) {
return
}
invitingUserIds.add(normalizedId)
const change = beginOptimisticChange(user.id)
updateRows(change.queryKey, (currentRows) => [...currentRows, inviteUserToRow(user)])
inviteMutation.mutate({ user, change })
}
function remove(id: string) {
const normalizedId = normalizeInviteKey(id)
if (options.actionsLocked.value || removingUserIds.has(normalizedId)) return
removingUserIds.add(normalizedId)
const hasPendingRecipients = rows.value.some(
(row) => row.pending && normalizeInviteKey(row.id) !== normalizedId,
)
const change = beginOptimisticChange(id)
updateRows(change.queryKey, (currentRows) =>
currentRows.filter((row) => normalizeInviteKey(row.id) !== normalizedId),
)
removeMutation.mutate({ id, hasPendingRecipients, change })
}
function beginOptimisticChange(userId: string): OptimisticChange {
const activeQueryKey = queryKey.value
void queryClient.cancelQueries({ queryKey: activeQueryKey, exact: true }, { revert: false })
const currentRows = queryClient.getQueryData<ShareRow[]>(activeQueryKey) ?? []
const previousIndex = currentRows.findIndex(
(row) => normalizeInviteKey(row.id) === normalizeInviteKey(userId),
)
return {
queryKey: activeQueryKey,
userId,
previousRow: previousIndex === -1 ? undefined : currentRows[previousIndex],
previousIndex,
}
}
function rollback(change: OptimisticChange) {
const normalizedId = normalizeInviteKey(change.userId)
updateRows(change.queryKey, (currentRows) => {
const rowsWithoutUser = currentRows.filter(
(row) => normalizeInviteKey(row.id) !== normalizedId,
)
if (!change.previousRow) return rowsWithoutUser
const previousIndex = Math.min(change.previousIndex, rowsWithoutUser.length)
return [
...rowsWithoutUser.slice(0, previousIndex),
change.previousRow,
...rowsWithoutUser.slice(previousIndex),
]
})
}
function updateRows(activeQueryKey: MembersQueryKey, update: (rows: ShareRow[]) => ShareRow[]) {
queryClient.setQueryData<ShareRow[]>(activeQueryKey, (currentRows = []) => update(currentRows))
}
return { rows, query, find, invite, remove }
}
function userEntries(users: SharedInstanceUsers): SharedInstanceUser[] {
if (users.users?.length > 0) return users.users
return users.user_ids.map((id) => ({
id,
joined_at: null,
join_type: 'invite',
last_played: null,
}))
}
function parseDate(value?: string | null) {
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function inviteUserToRow(user: InvitePlayersUser): ShareRow {
return {
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl ?? undefined,
lastPlayedAt: null,
joinedAt: null,
method: 'direct',
pending: true,
}
}
function preserveRowOrder(rows: ShareRow[], previousRows: ShareRow[]) {
const rowsById = new Map(rows.map((row) => [normalizeInviteKey(row.id), row]))
const orderedRows = previousRows.flatMap((previousRow) => {
const id = normalizeInviteKey(previousRow.id)
const row = rowsById.get(id)
if (!row) return []
rowsById.delete(id)
return [row]
})
return [...orderedRows, ...rowsById.values()]
}
@@ -0,0 +1,228 @@
import { injectAuth } from '@modrinth/ui'
import { computed, inject, type InjectionKey, provide, type Ref, ref, watch } from 'vue'
import { useUserQuery } from '@/composables/users/use-user-query'
import {
getSharedInstanceUnavailableReason,
install_get_shared_instance_update_preview,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import type { GameInstance } from '@/helpers/types'
export type SharedInstanceManager =
| {
type: 'user'
name: string
avatarUrl?: string
tintBy: string
}
| {
type: 'server'
name: string
avatarUrl?: string
tintBy: string
}
export function useSharedInstanceState(
instance: Ref<GameInstance | undefined>,
offline: Ref<boolean>,
notifyError: (error: unknown) => void,
) {
const auth = injectAuth()
const updatePreview =
ref<Awaited<ReturnType<typeof install_get_shared_instance_update_preview>>>(null)
const updatePreviewLoaded = ref(false)
const unavailableReason = ref<SharedInstanceUnavailableReason | null>(null)
const availabilityCheckKey = ref<string | null>(null)
const availabilityRefresh = ref(0)
let availabilityRequestId = 0
let availabilityRequest: {
key: string
promise: Promise<{
preview: Awaited<ReturnType<typeof install_get_shared_instance_update_preview>>
error: unknown | null
}>
} | null = null
const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null)
const wrongAccount = computed(() => {
if (auth.isReady && !auth.isReady.value) return false
if (!expectedUserId.value) return false
return auth.user.value?.id !== expectedUserId.value
})
const actionsLocked = computed(() => wrongAccount.value)
const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null)
const signedOut = computed(() => !auth.session_token.value)
const managerUserId = computed(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.role === 'owner') {
return actionsLocked.value ? (attachment.linked_user_id ?? null) : null
}
return attachment.manager_id ?? null
})
const managerUserQuery = useUserQuery(managerUserId)
const manager = computed<SharedInstanceManager | null>(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.server_manager_name) {
return {
type: 'server',
name: attachment.server_manager_name,
avatarUrl: attachment.server_manager_icon_url ?? undefined,
tintBy: attachment.server_manager_name,
}
}
const user = managerUserQuery.data.value
if (!user) return null
return {
type: 'user',
name: user.username,
avatarUrl: user.avatar_url ?? undefined,
tintBy: user.id,
}
})
const unavailableManager = computed(() => manager.value?.name ?? null)
function reset() {
availabilityRequestId++
availabilityRequest = null
availabilityCheckKey.value = null
updatePreview.value = null
updatePreviewLoaded.value = false
unavailableReason.value = null
}
function refreshAvailability() {
availabilityCheckKey.value = null
updatePreviewLoaded.value = false
availabilityRefresh.value++
}
function setUnavailable(reason: SharedInstanceUnavailableReason | null) {
availabilityRequestId++
availabilityRequest = null
availabilityCheckKey.value = null
updatePreview.value = null
updatePreviewLoaded.value = false
unavailableReason.value = reason
}
async function checkAvailability(instanceId: string, key: string, throwError = false) {
const requestId = ++availabilityRequestId
let request = availabilityRequest
if (!request || request.key !== key) {
const promise = install_get_shared_instance_update_preview(instanceId).then(
(preview) => ({ preview, error: null }),
(error: unknown) => ({ preview: null, error }),
)
request = { key, promise }
availabilityRequest = request
void promise.finally(() => {
if (availabilityRequest?.promise === promise) availabilityRequest = null
})
}
const result = await request.promise
if (!isCurrentRequest(requestId, instanceId, key)) return null
if (result.error !== null) {
updatePreviewLoaded.value = false
if (isSharedInstanceUnavailableError(result.error)) {
updatePreview.value = null
unavailableReason.value = getSharedInstanceUnavailableReason(result.error)
} else if (!throwError) {
notifyError(result.error)
}
if (throwError) throw result.error
return null
}
updatePreview.value = result.preview
updatePreviewLoaded.value = true
unavailableReason.value = null
return result.preview
}
async function refreshUpdatePreview() {
const instanceId = instance.value?.id
const userId = auth.user.value?.id
if (!instanceId || !userId) return null
const key = `${instanceId}:${userId}`
availabilityCheckKey.value = key
return await checkAvailability(instanceId, key, true)
}
function isCurrentRequest(requestId: number, instanceId: string, key: string) {
return (
requestId === availabilityRequestId &&
instance.value?.id === instanceId &&
availabilityCheckKey.value === key
)
}
watch(
() => ({
refresh: availabilityRefresh.value,
instanceId: instance.value?.id,
role: instance.value?.shared_instance?.role,
locked: actionsLocked.value,
offline: offline.value,
signedIn: !!auth.session_token.value,
userId: auth.user.value?.id ?? null,
authReady: auth.isReady?.value ?? true,
}),
async ({ instanceId, role, locked, offline, signedIn, userId, authReady }) => {
if (!instanceId || !role || locked || offline || !authReady || !signedIn || !userId) {
availabilityRequestId++
availabilityRequest = null
availabilityCheckKey.value = null
updatePreview.value = null
updatePreviewLoaded.value = false
if (instanceId && role) unavailableReason.value = null
return
}
const key = `${instanceId}:${userId}`
if (availabilityCheckKey.value === key) return
availabilityCheckKey.value = key
await checkAvailability(instanceId, key)
},
{ immediate: true },
)
return {
actionsLocked,
shareActionsLocked,
unavailableReason,
unavailableManager,
manager,
updatePreview,
expectedUserId,
wrongAccount,
signedOut,
reset,
refreshAvailability,
refreshUpdatePreview,
setUnavailable,
}
}
export type SharedInstanceState = ReturnType<typeof useSharedInstanceState>
const sharedInstanceStateKey: InjectionKey<SharedInstanceState> = Symbol('shared-instance-state')
export function provideSharedInstanceState(state: SharedInstanceState) {
provide(sharedInstanceStateKey, state)
}
export function injectSharedInstanceState() {
const state = inject(sharedInstanceStateKey)
if (!state) throw new Error('Shared instance state has not been provided.')
return state
}
@@ -47,7 +47,7 @@
<div class="flex flex-col gap-4 p-6">
<div
v-if="projectInstallContext"
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 px-3 py-4 border-surface-5"
>
<BrowseInstallHeader :install-context="projectInstallContext" />
</div>
@@ -0,0 +1,25 @@
import { provideAppBackup } from '@modrinth/ui'
import { type MaybeRefOrGetter, toValue } from 'vue'
import { install_duplicate_instance, installJobInstanceId } from '@/helpers/install'
import { edit, list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
export function provideInstanceBackup(instance: MaybeRefOrGetter<GameInstance>) {
provideAppBackup({
async createBackup() {
const source = toValue(instance)
const prefix = `${source.name} - Backup #`
const existingNumbers = (await list())
.filter((candidate) => candidate.name.startsWith(prefix))
.map((candidate) => Number.parseInt(candidate.name.slice(prefix.length), 10))
.filter(Number.isFinite)
const nextNumber = existingNumbers.length ? Math.max(...existingNumbers) + 1 : 1
const job = await install_duplicate_instance(source.id)
const backupInstanceId = installJobInstanceId(job)
if (backupInstanceId) {
await edit(backupInstanceId, { name: `${prefix}${nextNumber}` })
}
},
})
}
+12 -2
View File
@@ -1,5 +1,11 @@
import type { Labrinth } from '@modrinth/api-client'
import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
import {
type AuthFlow,
type AuthProvider,
type AuthRequestOptions,
type AuthUser,
provideAuth,
} from '@modrinth/ui'
import { computed, type Ref, ref, watchEffect } from 'vue'
type AppCredentials = {
@@ -9,7 +15,11 @@ type AppCredentials = {
export function setupAuthProvider(
credentials: Ref<AppCredentials | null | undefined>,
requestSignIn: (redirectPath: string) => void | Promise<void>,
requestSignIn: (
redirectPath: string,
flow?: AuthFlow,
options?: AuthRequestOptions,
) => void | Promise<void>,
) {
const sessionToken = ref<string | null>(null)
const user = ref<AuthUser | null>(null)
+9
View File
@@ -215,6 +215,15 @@ export default new createRouter({
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }],
},
},
{
path: 'share',
name: 'InstanceShare',
component: Instance.Share,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Share' }],
},
},
{
path: '',
name: 'Mods',
+1 -1
View File
@@ -94,7 +94,7 @@ export default defineConfig({
},
// to make use of `TAURI_ENV_DEBUG` and other env variables
// https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands
envPrefix: ['VITE_', 'TAURI_', 'MODRINTH_'],
envPrefix: ['VITE_', 'TAURI_', 'MODRINTH_', 'SHARED_INSTANCES_'],
build: {
rolldownOptions: {
onwarn(warning, defaultHandler) {
+30 -2
View File
@@ -55,7 +55,6 @@ fn main() {
InlinedPlugin::new()
.commands(&[
"get_importable_instances",
"import_instance",
"is_valid_importable_instance",
"get_default_launcher_path",
])
@@ -148,6 +147,11 @@ fn main() {
"install_get_modpack_preview",
"install_create_instance",
"install_create_modpack_instance",
"install_get_shared_instance_preview",
"install_accept_shared_instance_invite",
"install_get_shared_instance_update_preview",
"install_shared_instance",
"install_update_shared_instance",
"install_import_instance",
"install_duplicate_instance",
"install_existing_instance",
@@ -176,6 +180,14 @@ fn main() {
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"reports",
InlinedPlugin::new()
.commands(&["reports_create"])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"instance",
InlinedPlugin::new()
@@ -211,6 +223,15 @@ fn main() {
"instance_kill",
"instance_edit",
"instance_edit_icon",
"instance_share_can_current_user_use",
"instance_share_get_users",
"instance_share_invite_users",
"instance_share_create_invite_link",
"instance_share_remove_users",
"instance_share_get_publish_preview",
"instance_share_publish",
"instance_share_unlink",
"instance_share_unpublish",
"instance_export_mrpack",
"instance_get_pack_export_candidates",
])
@@ -252,6 +273,14 @@ fn main() {
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"users",
InlinedPlugin::new()
.commands(&["search_user"])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"utils",
InlinedPlugin::new()
@@ -276,7 +305,6 @@ fn main() {
.commands(&[
"init_ads_window",
"hide_ads_window",
"scroll_ads_window",
"show_ads_window",
"show_ads_consent_overlay",
"show_ads_consent_preferences",
+2
View File
@@ -102,11 +102,13 @@
"instance:default",
"install:default",
"process:default",
"reports:default",
"cache:default",
"files:default",
"settings:default",
"shortcuts:default",
"tags:default",
"users:default",
"utils:default",
"ads:default",
"friends:default",
+70
View File
@@ -6,6 +6,10 @@ use theseus::data::ModLoader;
use theseus::install::{
InstallJobSnapshot, InstallModpackPreview, InstallPostInstallEdit,
};
use theseus::instance::{
SharedInstanceInstallPreview, SharedInstanceInviteInstallPreview,
SharedInstanceUpdatePreview,
};
use theseus::pack::import::ImportLauncherType;
use theseus::pack::install_from::CreatePackLocation;
use uuid::Uuid;
@@ -16,6 +20,11 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
install_get_modpack_preview,
install_create_instance,
install_create_modpack_instance,
install_get_shared_instance_preview,
install_accept_shared_instance_invite,
install_get_shared_instance_update_preview,
install_shared_instance,
install_update_shared_instance,
install_import_instance,
install_duplicate_instance,
install_existing_instance,
@@ -101,6 +110,67 @@ pub async fn install_create_modpack_instance(
.await?)
}
#[tauri::command]
pub async fn install_get_shared_instance_preview(
shared_instance_id: String,
name: String,
) -> Result<SharedInstanceInstallPreview> {
Ok(theseus::instance::get_shared_instance_install_preview(
&shared_instance_id,
name,
)
.await?)
}
#[tauri::command]
pub async fn install_accept_shared_instance_invite(
invite_id: String,
) -> Result<SharedInstanceInviteInstallPreview> {
Ok(
theseus::instance::accept_shared_instance_invite_for_install(
&invite_id,
)
.await?,
)
}
#[tauri::command]
pub async fn install_get_shared_instance_update_preview(
instance_id: String,
) -> Result<Option<SharedInstanceUpdatePreview>> {
Ok(
theseus::instance::get_shared_instance_update_preview(&instance_id)
.await?,
)
}
#[tauri::command]
pub async fn install_shared_instance(
shared_instance_id: String,
name: String,
manager_id: Option<String>,
server_manager_name: Option<String>,
server_manager_icon_url: Option<String>,
instance_icon_url: Option<String>,
) -> Result<InstallJobSnapshot> {
Ok(theseus::instance::install_shared_instance(
&shared_instance_id,
name,
manager_id,
server_manager_name,
server_manager_icon_url,
instance_icon_url,
)
.await?)
}
#[tauri::command]
pub async fn install_update_shared_instance(
instance_id: String,
) -> Result<InstallJobSnapshot> {
Ok(theseus::instance::update_shared_instance(&instance_id).await?)
}
#[tauri::command]
pub async fn install_import_instance(
launcher_type: ImportLauncherType,
+147 -19
View File
@@ -10,6 +10,8 @@ use theseus::data::{
EditInstance as CoreEditInstance, InstanceInstallCandidate,
InstanceInstallTarget, InstanceLaunchOverridesPatch,
InstanceLink as CoreInstanceLink, InstanceMetadata, LinkedModpackInfo,
SharedInstanceAttachment as CoreSharedInstanceAttachment,
SharedInstanceRole,
};
use theseus::instance::InstallProjectWithDependenciesRequest;
use theseus::instance::QuickPlayType;
@@ -50,6 +52,15 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
instance_kill,
instance_edit,
instance_edit_icon,
instance_share_can_current_user_use,
instance_share_get_users,
instance_share_invite_users,
instance_share_create_invite_link,
instance_share_remove_users,
instance_share_get_publish_preview,
instance_share_publish,
instance_share_unlink,
instance_share_unpublish,
instance_export_mrpack,
instance_get_pack_export_candidates,
])
@@ -70,6 +81,8 @@ pub struct Instance {
pub loader_version: Option<String>,
pub groups: Vec<String>,
pub link: Option<InstanceLink>,
pub shared_instance: Option<SharedInstanceAttachment>,
pub quarantined: bool,
pub update_channel: ReleaseChannel,
pub created: chrono::DateTime<chrono::Utc>,
pub modified: chrono::DateTime<chrono::Utc>,
@@ -115,10 +128,40 @@ pub enum InstanceLink {
active_instance_id: Option<String>,
},
SharedInstance {
shared_instance_id: String,
modpack_project_id: Option<String>,
modpack_version_id: Option<String>,
},
}
#[derive(Serialize, Debug, Clone)]
pub struct SharedInstanceAttachment {
pub id: String,
pub role: SharedInstanceRole,
pub manager_id: Option<String>,
pub server_manager_name: Option<String>,
pub server_manager_icon_url: Option<String>,
pub linked_user_id: Option<String>,
pub status: String,
pub applied_version: Option<i32>,
pub latest_version: Option<i32>,
}
impl From<CoreSharedInstanceAttachment> for SharedInstanceAttachment {
fn from(attachment: CoreSharedInstanceAttachment) -> Self {
Self {
id: attachment.id.to_string(),
role: attachment.role,
manager_id: attachment.manager_id,
server_manager_name: attachment.server_manager_name,
server_manager_icon_url: attachment.server_manager_icon_url,
linked_user_id: attachment.linked_user_id,
status: attachment.status.as_str().to_string(),
applied_version: attachment.applied_version,
latest_version: attachment.latest_version,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct EditInstance {
pub name: Option<String>,
@@ -201,6 +244,8 @@ impl From<InstanceMetadata> for Instance {
loader_version: metadata.applied_content_set.loader_version,
groups: metadata.groups,
link: InstanceLink::from_core(metadata.link),
shared_instance: metadata.shared_instance.map(Into::into),
quarantined: metadata.quarantined,
update_channel: metadata.instance.update_channel,
created: metadata.instance.created,
modified: metadata.instance.modified,
@@ -268,11 +313,13 @@ impl InstanceLink {
.collect(),
active_instance_id: active_instance_id.map(|id| id.to_string()),
}),
CoreInstanceLink::SharedInstance { shared_instance_id } => {
Some(Self::SharedInstance {
shared_instance_id: shared_instance_id.to_string(),
})
}
CoreInstanceLink::SharedInstance {
modpack_project_id,
modpack_version_id,
} => Some(Self::SharedInstance {
modpack_project_id,
modpack_version_id,
}),
}
}
@@ -345,19 +392,13 @@ impl InstanceLink {
})
.transpose()?,
}),
Self::SharedInstance { shared_instance_id } => {
Ok(CoreInstanceLink::SharedInstance {
shared_instance_id: shared_instance_id.parse().map_err(
|err| {
theseus::Error::from(
theseus::ErrorKind::InputError(format!(
"Invalid shared instance id: {err}"
)),
)
},
)?,
})
}
Self::SharedInstance {
modpack_project_id,
modpack_version_id,
} => Ok(CoreInstanceLink::SharedInstance {
modpack_project_id,
modpack_version_id,
}),
}
}
}
@@ -744,3 +785,90 @@ pub async fn instance_edit_icon(
theseus::instance::edit_icon(instance_id, icon_path).await?;
Ok(())
}
#[tauri::command]
pub async fn instance_share_can_current_user_use() -> Result<bool> {
Ok(theseus::instance::can_active_user_use_shared_instances().await?)
}
#[tauri::command]
pub async fn instance_share_get_users(
instance_id: &str,
) -> Result<theseus::instance::SharedInstanceUsers> {
Ok(theseus::instance::get_shared_instance_users(instance_id).await?)
}
#[tauri::command]
pub async fn instance_share_invite_users(
instance_id: &str,
user_ids: Vec<String>,
) -> Result<theseus::instance::SharedInstanceUsers> {
Ok(
theseus::instance::invite_shared_instance_users(instance_id, user_ids)
.await?,
)
}
#[tauri::command]
pub async fn instance_share_create_invite_link(
instance_id: &str,
max_age_seconds: Option<i32>,
max_uses: Option<i32>,
replace_invite_id: Option<String>,
) -> Result<theseus::instance::SharedInstanceInviteLink> {
Ok(theseus::instance::create_shared_instance_invite_link(
instance_id,
max_age_seconds,
max_uses,
replace_invite_id,
)
.await?)
}
#[tauri::command]
pub async fn instance_share_remove_users(
instance_id: &str,
user_ids: Vec<String>,
has_pending_recipients: bool,
) -> Result<theseus::instance::SharedInstanceUsers> {
Ok(theseus::instance::remove_shared_instance_users(
instance_id,
user_ids,
has_pending_recipients,
)
.await?)
}
#[tauri::command]
pub async fn instance_share_get_publish_preview(
instance_id: &str,
) -> Result<Option<theseus::instance::SharedInstancePublishPreview>> {
Ok(
theseus::instance::get_shared_instance_publish_preview(instance_id)
.await?,
)
}
#[tauri::command]
pub async fn instance_share_publish(
instance_id: &str,
config_paths: Vec<String>,
) -> Result<SharedInstanceAttachment> {
Ok(
theseus::instance::publish_shared_instance(instance_id, config_paths)
.await?
.into(),
)
}
#[tauri::command]
pub async fn instance_share_unlink(instance_id: &str) -> Result<()> {
theseus::instance::unlink_shared_instance(instance_id).await?;
Ok(())
}
#[tauri::command]
pub async fn instance_share_unpublish(instance_id: &str) -> Result<()> {
theseus::instance::unpublish_shared_instance(instance_id).await?;
Ok(())
}
+14 -1
View File
@@ -12,9 +12,11 @@ pub mod metadata;
pub mod minecraft_skins;
pub mod mr_auth;
pub mod process;
pub mod reports;
pub mod settings;
pub mod shortcuts;
pub mod tags;
pub mod users;
pub mod utils;
pub mod ads;
@@ -85,9 +87,20 @@ macro_rules! impl_serialize {
TheseusSerializableError::Theseus(theseus_error) => {
$crate::error::display_tracing_error(theseus_error);
let mut state = serializer.serialize_struct("Theseus", 2)?;
let unavailable_reason = match theseus_error.raw.as_ref() {
theseus::ErrorKind::SharedInstanceUnavailable(reason) => Some(reason),
_ => None,
};
let mut state = serializer.serialize_struct(
"Theseus",
if unavailable_reason.is_some() { 4 } else { 2 },
)?;
state.serialize_field("field_name", "Theseus")?;
state.serialize_field("message", &theseus_error.to_string())?;
if let Some(reason) = unavailable_reason {
state.serialize_field("code", "shared_instance_unavailable")?;
state.serialize_field("reason", reason)?;
}
state.end()
}
$(
+2 -1
View File
@@ -22,6 +22,7 @@ pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
#[tauri::command]
pub async fn modrinth_login<R: Runtime>(
app: tauri::AppHandle<R>,
flow: mr_auth::ModrinthAuthFlow,
) -> Result<ModrinthCredentials> {
let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
@@ -32,7 +33,7 @@ pub async fn modrinth_login<R: Runtime>(
let auth_request_uri = format!(
"{}?launcher=true&ipver={}&port={}",
mr_auth::authenticate_begin_flow(),
mr_auth::authenticate_begin_flow(flow),
if auth_code_recv_socket.is_ipv4() {
"4"
} else {
+15
View File
@@ -0,0 +1,15 @@
use crate::api::Result;
use theseus::reports::{CreateReportRequest, CreateReportResponse};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("reports")
.invoke_handler(tauri::generate_handler![reports_create])
.build()
}
#[tauri::command]
pub async fn reports_create(
request: CreateReportRequest,
) -> Result<CreateReportResponse> {
Ok(theseus::reports::create_report(request).await?)
}
+13
View File
@@ -0,0 +1,13 @@
use crate::api::Result;
use theseus::users::SearchUser;
#[tauri::command]
pub async fn search_user(query: &str) -> Result<Vec<SearchUser>> {
Ok(theseus::users::search_user(query).await?)
}
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("users")
.invoke_handler(tauri::generate_handler![search_user])
.build()
}
+5 -3
View File
@@ -152,7 +152,7 @@ fn main() {
builder = builder
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if let Some(payload) = args.get(1) {
tracing::info!("Handling deep link from arg {payload}");
tracing::info!("Handling command-line deep link");
let payload = payload.clone();
tauri::async_runtime::spawn(api::utils::handle_command(
payload,
@@ -197,7 +197,7 @@ fn main() {
.unwrap_or(request);
tauri::async_runtime::spawn(async move {
tracing::info!("Handling deep link {actual_request}");
tracing::info!("Handling macOS deep link");
let mut payload = mtx_copy_copy.lock().await;
if payload.is_none() {
@@ -213,7 +213,7 @@ fn main() {
#[cfg(not(target_os = "macos"))]
app.listen("deep-link://new-url", |url| {
let payload = url.payload().to_owned();
tracing::info!("Handling deep link {payload}");
tracing::info!("Handling deep link");
tauri::async_runtime::spawn(api::utils::handle_command(
payload,
));
@@ -240,9 +240,11 @@ fn main() {
.plugin(api::metadata::init())
.plugin(api::minecraft_skins::init())
.plugin(api::process::init())
.plugin(api::reports::init())
.plugin(api::settings::init())
.plugin(api::shortcuts::init())
.plugin(api::tags::init())
.plugin(api::users::init())
.plugin(api::utils::init())
.plugin(api::cache::init())
.plugin(api::files::init())
+1
View File
@@ -1,4 +1,5 @@
BASE_URL=http://127.0.0.1:8000/v2/
BROWSER_BASE_URL=http://127.0.0.1:8000/v2/
PYRO_BASE_URL=https://staging-archon.modrinth.com
SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com
PROD_OVERRIDE=true
+1
View File
@@ -1,4 +1,5 @@
BASE_URL=https://api.modrinth.com/v2/
BROWSER_BASE_URL=https://api.modrinth.com/v2/
PYRO_BASE_URL=https://archon.modrinth.com
SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com
PROD_OVERRIDE=true
+1
View File
@@ -1,4 +1,5 @@
BASE_URL=https://staging-api.modrinth.com/v2/
BROWSER_BASE_URL=https://staging-api.modrinth.com/v2/
PYRO_BASE_URL=https://staging-archon.modrinth.com
SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com
PROD_OVERRIDE=true
+1
View File
@@ -1,4 +1,5 @@
BASE_URL=https://api.modrinth.com/v2/
BROWSER_BASE_URL=https://api.modrinth.com/v2/
PYRO_BASE_URL=https://staging-archon.modrinth.com
SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com
PROD_OVERRIDE=true
+12
View File
@@ -7,6 +7,7 @@ import svgLoader from 'vite-svg-loader'
import { GenericModrinthClient, type Labrinth } from '../../packages/api-client/src/index.ts'
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
const STAGING_SHARED_INSTANCES_API_URL = 'https://staging-shared-instances.modrinth.com'
const API_CLIENT_SOURCE = fileURLToPath(
new URL('../../packages/api-client/src/index.ts', import.meta.url),
)
@@ -210,6 +211,7 @@ export default defineNuxtConfig({
// @ts-ignore
rateLimitKey: process.env.RATE_LIMIT_IGNORE_KEY ?? globalThis.RATE_LIMIT_IGNORE_KEY,
pyroBaseUrl: process.env.PYRO_BASE_URL,
sharedInstancesBaseUrl: getSharedInstancesApiUrl(),
intercomIdentitySecret:
process.env.INTERCOM_IDENTITY_SECRET ??
// @ts-ignore
@@ -217,6 +219,7 @@ export default defineNuxtConfig({
public: {
apiBaseUrl: getApiUrl(),
pyroBaseUrl: process.env.PYRO_BASE_URL,
sharedInstancesBaseUrl: getSharedInstancesApiUrl(),
siteUrl: getDomain(),
intercomAppId:
process.env.INTERCOM_APP_ID ||
@@ -354,6 +357,15 @@ function getApiUrl() {
return process.env.BROWSER_BASE_URL ?? globalThis.BROWSER_BASE_URL ?? STAGING_API_URL
}
function getSharedInstancesApiUrl() {
return (
process.env.SHARED_INSTANCES_API_BASE_URL ??
// @ts-ignore
globalThis.SHARED_INSTANCES_API_BASE_URL ??
STAGING_SHARED_INSTANCES_API_URL
)
}
function isProduction() {
return process.env.NODE_ENV === 'production'
}
@@ -0,0 +1,274 @@
<template>
<div v-if="open" class="fixed inset-0 z-[100] flex items-center justify-center">
<div
:class="visible ? 'opacity-100' : 'opacity-0'"
class="fullscreen-overlay fixed inset-0 cursor-pointer backdrop-blur-xl transition-opacity duration-300 ease-out"
@click="hide"
/>
<div
class="relative z-[1] max-h-screen w-full max-w-[28rem] overflow-y-auto px-4 py-10 transition-all duration-300 ease-out"
:class="visible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'"
>
<div class="flex w-full flex-col items-center gap-6">
<div class="flex w-full flex-col gap-6">
<div
v-if="countdown > 0"
class="relative mx-auto flex size-[120px] items-center justify-center"
>
<svg class="absolute size-full -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
<circle
class="stroke-surface-4"
cx="50"
cy="50"
r="45"
fill="none"
stroke-width="6"
/>
<circle
class="stroke-brand transition-[stroke-dashoffset] duration-[50ms] ease-linear"
cx="50"
cy="50"
r="45"
fill="none"
stroke-width="6"
:stroke-dasharray="circumference"
:stroke-dashoffset="strokeDashoffset"
stroke-linecap="round"
/>
</svg>
<span class="z-[1] text-5xl font-bold text-contrast">{{ countdown }}</span>
</div>
<h2 class="m-0 text-center text-3xl font-bold text-contrast">
{{ formatMessage(messages.openingApp) }}
</h2>
<div
class="flex flex-col items-center gap-4 rounded-3xl border border-solid border-surface-5 bg-surface-3 p-6"
>
<div class="flex w-full items-center gap-3 rounded-xl bg-surface-2 p-3">
<Avatar :src="instance.icon" :alt="instance.name" size="48px" />
<div class="flex min-w-0 flex-col gap-1">
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
<span class="flex items-center gap-1.5 text-sm font-medium leading-4 text-primary">
<span>{{ formatMessage(messages.managedBy) }}</span>
<Avatar
:src="instance.inviterAvatar"
:alt="instance.inviterName"
size="16px"
circle
no-shadow
/>
<span>{{ instance.inviterName }}</span>
</span>
</div>
</div>
<div class="flex flex-col gap-3 text-left">
<span class="font-semibold text-contrast">{{
formatMessage(messages.whyUseApp)
}}</span>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 text-base">
<div
class="flex size-5 shrink-0 items-center justify-center rounded-full border border-solid border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitJoin) }}</span>
</div>
<div class="flex items-center gap-2 text-base">
<div
class="flex size-5 shrink-0 items-center justify-center rounded-full border border-solid border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitInstall) }}</span>
</div>
<div class="flex items-center gap-2 text-base">
<div
class="flex size-5 shrink-0 items-center justify-center rounded-full border border-solid border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitUpdate) }}</span>
</div>
</div>
</div>
</div>
</div>
<span v-if="countdown > 0" class="text-center text-secondary">{{
formatMessage(messages.openingAutomatically)
}}</span>
<div v-else class="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled>
<button class="flex-1" type="button" @click="hide">
<XIcon />
{{ formatMessage(commonMessages.closeButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<a
class="flex-1"
href="https://modrinth.com/app"
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon />
{{ formatMessage(messages.getApp) }}
</a>
</ButtonStyled>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, DownloadIcon, XIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onUnmounted, ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
openingApp: {
id: 'modal.shared-instance.open-in-app.title',
defaultMessage: 'Opening Modrinth App',
},
managedBy: {
id: 'modal.shared-instance.open-in-app.managed-by',
defaultMessage: 'Managed by',
},
whyUseApp: {
id: 'modal.shared-instance.open-in-app.why-use',
defaultMessage: 'Why use the Modrinth App',
},
benefitJoin: {
id: 'modal.shared-instance.open-in-app.benefit.join',
defaultMessage: 'Join the shared instance in one click',
},
benefitInstall: {
id: 'modal.shared-instance.open-in-app.benefit.install',
defaultMessage: 'Automatically install the required game and loader',
},
benefitUpdate: {
id: 'modal.shared-instance.open-in-app.benefit.update',
defaultMessage: 'Keep shared content updated when the instance changes',
},
openingAutomatically: {
id: 'modal.shared-instance.open-in-app.opening-automatically',
defaultMessage: 'The Modrinth App will open automatically...',
},
getApp: {
id: 'modal.shared-instance.open-in-app.get-app',
defaultMessage: 'Get Modrinth App',
},
})
export interface SharedInstanceInviteModalData {
inviteId: string
name: string
icon?: string | null
inviterName: string
inviterAvatar?: string | null
}
const open = ref(false)
const visible = ref(false)
const countdown = ref(3)
const countdownProgress = ref(1)
const instance = ref<SharedInstanceInviteModalData>({
inviteId: '',
name: '',
icon: null,
inviterName: '',
inviterAvatar: null,
})
let countdownInterval: ReturnType<typeof setInterval> | null = null
let progressInterval: ReturnType<typeof setInterval> | null = null
let showTimeout: ReturnType<typeof setTimeout> | null = null
let hideTimeout: ReturnType<typeof setTimeout> | null = null
const circumference = 2 * Math.PI * 45
const strokeDashoffset = computed(() => circumference * (1 - countdownProgress.value))
const appLink = computed(() => `modrinth://share/${encodeURIComponent(instance.value.inviteId)}`)
function startCountdown() {
countdown.value = 3
countdownProgress.value = 1
const totalDuration = 3000
const progressUpdateInterval = 16
const progressDecrement = progressUpdateInterval / totalDuration
progressInterval = setInterval(() => {
countdownProgress.value = Math.max(0, countdownProgress.value - progressDecrement)
}, progressUpdateInterval)
countdownInterval = setInterval(() => {
countdown.value--
if (countdown.value <= 0) stopCountdown()
}, 1000)
}
function stopCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
if (progressInterval) {
clearInterval(progressInterval)
progressInterval = null
}
}
async function show(options: { instance: SharedInstanceInviteModalData }) {
instance.value = options.instance
open.value = true
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleKeyDown)
await nextTick()
window.open(appLink.value, '_self')
showTimeout = setTimeout(() => {
visible.value = true
startCountdown()
}, 50)
}
function hide() {
visible.value = false
document.body.style.overflow = ''
window.removeEventListener('keydown', handleKeyDown)
stopCountdown()
if (showTimeout) clearTimeout(showTimeout)
hideTimeout = setTimeout(() => {
open.value = false
}, 300)
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') hide()
}
onUnmounted(() => {
document.body.style.overflow = ''
window.removeEventListener('keydown', handleKeyDown)
stopCountdown()
if (showTimeout) clearTimeout(showTimeout)
if (hideTimeout) clearTimeout(hideTimeout)
})
defineExpose({ show, hide, open })
</script>
<style scoped>
.fullscreen-overlay {
background: linear-gradient(to bottom, rgb(66 131 92 / 23%) 0%, rgb(17 35 43 / 40%) 97%);
}
</style>
@@ -89,14 +89,18 @@
</div>
<div v-else class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<NuxtLink
v-if="report.item_type !== 'shared-instance'"
:to="reportItemUrl"
target="_blank"
class="text-base font-semibold text-contrast hover:underline"
>
{{ reportItemTitle }}
</NuxtLink>
<span v-else class="text-base font-semibold text-contrast">
{{ reportItemTitle }}
</span>
<div
v-if="report.project?.project_type"
@@ -118,6 +122,20 @@
>
{{ report.version.files.find((f) => f.primary)?.filename || 'Unknown Version' }}
</span>
<span
v-if="report.item_type === 'shared-instance' && report.shared_instance_version_id"
class="text-sm text-secondary"
>
Version {{ report.shared_instance_version_id }}
</span>
<CopyCode v-if="report.item_type === 'shared-instance'" :text="report.item_id" />
<span
v-if="report.item_type === 'shared-instance' && sharedInstanceQuarantined"
class="bg-orange-highlight inline-flex items-center gap-1 rounded-full border border-solid border-orange px-2.5 py-1 text-sm font-semibold text-orange"
>
<LockIcon class="size-4" />
Quarantined
</span>
</div>
<div v-if="report.target" class="flex items-center gap-1">
@@ -135,6 +153,24 @@
{{ report.target.name }}
</NuxtLink>
</div>
<div
v-else-if="report.item_type === 'shared-instance' && sharedInstanceDetails"
class="flex items-center gap-1"
>
<Avatar
:src="sharedInstanceDetails.owner.avatar_url"
size="1.5rem"
circle
class="border border-surface-5 bg-surface-4 !shadow-none"
/>
<NuxtLink
:to="`/user/${sharedInstanceDetails.owner.username}`"
target="_blank"
class="text-sm font-medium text-secondary hover:underline"
>
{{ sharedInstanceDetails.owner.username }}
</NuxtLink>
</div>
</div>
</div>
</div>
@@ -154,6 +190,39 @@
:closed="reportClosed"
@update-thread="updateThread"
>
<template #afterMessages>
<template v-if="report.item_type === 'shared-instance'">
<div
v-if="sharedInstanceLoading"
class="flex items-center gap-2 border-0 border-t border-solid border-surface-4 px-4 py-6 text-secondary"
>
<LoaderCircleIcon class="size-5 animate-spin" />
Loading shared instance details
</div>
<div
v-else-if="sharedInstanceError"
class="flex flex-col items-start gap-3 border-0 border-t border-solid border-surface-4 px-4 py-6"
>
<div class="flex flex-col gap-1">
<span class="font-semibold text-contrast">
Shared instance details could not be loaded
</span>
<span class="text-sm text-secondary">{{ sharedInstanceError }}</span>
</div>
<ButtonStyled type="outlined">
<button @click="loadSharedInstanceDetails">Try again</button>
</ButtonStyled>
</div>
<SharedInstanceReportContext
v-else-if="sharedInstanceDetails"
:details="sharedInstanceDetails"
:ban-pending="sharedInstanceBanPending"
:load-version-content="loadSharedInstanceVersionContent"
@ban-owner="banSharedInstanceOwner"
@content-error="showSharedInstanceContentError"
/>
</template>
</template>
<template #closedActions>
<ButtonStyled v-if="isStaff(auth.user)" color="green">
<button class="mt-2 w-full gap-2 sm:w-auto" @click="reopenReport()">
@@ -184,29 +253,55 @@
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon, ClipboardCopyIcon, ExternalIcon } from '@modrinth/assets'
import type { Labrinth, SharedInstances } from '@modrinth/api-client'
import {
CheckCircleIcon,
ClipboardCopyIcon,
ExternalIcon,
LoaderCircleIcon,
LockIcon,
} from '@modrinth/assets'
import { type ExtendedReport, reportQuickReplies } from '@modrinth/moderation'
import {
Avatar,
ButtonStyled,
CollapsibleRegion,
type ContentItem,
CopyCode,
getProjectTypeIcon,
injectModrinthClient,
injectNotificationManager,
useFormatDateTime,
useRelativeTime,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { isStaff } from '~/helpers/users.js'
import ThreadView from '../thread/ThreadView.vue'
import SharedInstanceReportContext, {
type SharedInstanceOwnerInstance,
type SharedInstanceReportDetails,
type SharedInstanceReportUser,
} from './SharedInstanceReportContext.vue'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const auth = await useAuth()
type SharedInstanceVersionDependency = Labrinth.Versions.v2.Dependency & {
project_id?: string
version_id?: string
}
const props = defineProps<{
report: ExtendedReport
sharedInstanceDetailsLoader?: () => Promise<SharedInstanceReportDetails>
sharedInstanceVersionContentLoader?: (
instanceId: string,
version: number,
) => Promise<ContentItem[]>
}>()
const reportThread = ref<{
@@ -214,11 +309,25 @@ const reportThread = ref<{
sendReply: (privateMessage?: boolean) => Promise<void>
} | null>(null)
const isThreadCollapsed = ref(true)
const sharedInstanceDetails = ref<SharedInstanceReportDetails | null>(null)
const sharedInstanceLoading = ref(false)
const sharedInstanceError = ref<string | null>(null)
const sharedInstanceBanPending = ref(false)
const sharedInstanceVersions = new Map<string, SharedInstances.Instances.v1.InstanceVersion>()
let sharedInstanceDetailsRequest: Promise<void> | null = null
watch(isThreadCollapsed, (collapsed) => {
if (!collapsed) void loadSharedInstanceDetails()
})
const didCloseReport = ref(false)
const reportClosed = computed(() => {
return didCloseReport.value || props.report.closed
})
const sharedInstanceQuarantined = computed(
() =>
sharedInstanceDetails.value?.quarantine ?? props.report.shared_instance?.quarantine ?? false,
)
const threadWithReportBody = computed(() => {
if (!props.report.thread) return null
@@ -322,6 +431,299 @@ function updateThread(newThread: any) {
}
}
async function getSharedInstanceVersion(
instanceId: string,
versionNumber: number,
): Promise<SharedInstances.Instances.v1.InstanceVersion> {
const cacheKey = `${instanceId}:${versionNumber}`
const cachedVersion = sharedInstanceVersions.get(cacheKey)
if (cachedVersion) return cachedVersion
const instanceVersion = await client.sharedinstances.instances_v1.getVersion(
instanceId,
versionNumber,
)
sharedInstanceVersions.set(cacheKey, instanceVersion)
return instanceVersion
}
async function getOtherSharedInstances(ownerId: string): Promise<SharedInstanceOwnerInstance[]> {
const instanceIds = await client.sharedinstances.instances_v1.getForUser(ownerId)
const otherInstanceIds = [...new Set(instanceIds)].filter(
(instanceId) => instanceId !== props.report.item_id,
)
const results = await Promise.allSettled(
otherInstanceIds.map(async (instanceId) => {
const [instance, instanceUsers, latestVersion] = await Promise.all([
client.sharedinstances.instances_v1.get(instanceId),
client.sharedinstances.instances_v1.getUsers(instanceId),
client.sharedinstances.instances_v1.getLatestVersion(instanceId),
])
sharedInstanceVersions.set(`${instanceId}:${latestVersion.version}`, latestVersion)
return {
id: instanceId,
name: instance.name,
icon_url: instance.icon,
latest_version: latestVersion.version,
member_count: instanceUsers.users.length,
quarantine: instance.quarantine,
}
}),
)
return results
.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []))
.sort((a, b) => a.name.localeCompare(b.name))
}
async function loadSharedInstanceDetails() {
if (props.report.item_type !== 'shared-instance' || sharedInstanceDetails.value) return
if (sharedInstanceDetailsRequest) return sharedInstanceDetailsRequest
sharedInstanceDetailsRequest = (async () => {
sharedInstanceLoading.value = true
sharedInstanceError.value = null
try {
if (props.sharedInstanceDetailsLoader) {
sharedInstanceDetails.value = await props.sharedInstanceDetailsLoader()
return
}
const [instance, instanceUsers] = await Promise.all([
props.report.shared_instance ??
client.sharedinstances.instances_v1.get(props.report.item_id),
client.sharedinstances.instances_v1.getUsers(props.report.item_id),
])
const userIds = [...new Set(instanceUsers.users.map((user) => user.id))]
const users = userIds.length ? await client.labrinth.users_v2.getMultiple(userIds) : []
const usersById = new Map(users.map((user) => [user.id, user]))
const ownerMembership = instanceUsers.users.find((user) => user.join_type === 'owner')
if (!ownerMembership) {
throw new Error('The shared instance has no owner.')
}
const toReportUser = (
membership: (typeof instanceUsers.users)[number],
): SharedInstanceReportUser => {
const user = usersById.get(membership.id)
return {
id: membership.id,
username: user?.username ?? membership.id,
avatar_url: user?.avatar_url,
joined_at: membership.joined_at,
last_played: membership.last_played,
join_type: membership.join_type,
}
}
const reportedVersion = props.report.shared_instance_version_id
const versionNumbers = reportedVersion
? Array.from({ length: reportedVersion }, (_, index) => reportedVersion - index)
: []
const [versionDetails, otherInstancesResult] = await Promise.all([
Promise.all(
versionNumbers.map(async (versionNumber) => {
try {
const version = await getSharedInstanceVersion(props.report.item_id, versionNumber)
return {
version: version.version,
game_version: version.game_version,
loader: version.loader,
loader_version: version.loader_version,
}
} catch {
return { version: versionNumber }
}
}),
),
getOtherSharedInstances(ownerMembership.id)
.then((instances) => ({ instances, loaded: true }))
.catch(() => ({ instances: [], loaded: false })),
])
sharedInstanceDetails.value = {
id: props.report.item_id,
name: instance.name,
icon_url: instance.icon,
quarantine: instance.quarantine,
owner: toReportUser(ownerMembership),
members: instanceUsers.users
.filter((user) => user.id !== ownerMembership.id)
.map(toReportUser),
reported_version: versionDetails[0],
previous_versions: versionDetails.slice(1),
other_instances: otherInstancesResult.instances,
other_instances_loaded: otherInstancesResult.loaded,
}
} catch (error) {
sharedInstanceError.value = getErrorMessage(error, 'Failed to load shared instance details.')
} finally {
sharedInstanceLoading.value = false
sharedInstanceDetailsRequest = null
}
})()
return sharedInstanceDetailsRequest
}
async function loadSharedInstanceVersionContent(
instanceId: string,
versionNumber: number,
): Promise<ContentItem[]> {
if (props.report.item_type !== 'shared-instance') return []
if (props.sharedInstanceVersionContentLoader) {
return props.sharedInstanceVersionContentLoader(instanceId, versionNumber)
}
const instanceVersion = await getSharedInstanceVersion(instanceId, versionNumber)
const modpackVersionId = instanceVersion.modpack_id
const directVersionIds = (instanceVersion.modrinth_ids ?? []).filter(
(versionId) => versionId !== modpackVersionId,
)
const modpackVersion = modpackVersionId
? await client.labrinth.versions_v2.getVersion(modpackVersionId)
: null
const modpackDependencies = (modpackVersion?.dependencies ??
[]) as SharedInstanceVersionDependency[]
const dependencyVersionIds = modpackDependencies.flatMap((dependency) =>
dependency.version_id ? [dependency.version_id] : [],
)
const uniqueVersionIds = [...new Set([...directVersionIds, ...dependencyVersionIds])]
const versions = uniqueVersionIds.length
? await client.labrinth.versions_v2.getVersions(uniqueVersionIds)
: []
const dependencyProjectIds = modpackDependencies.flatMap((dependency) =>
dependency.project_id ? [dependency.project_id] : [],
)
const projectIds = [
...new Set([...versions.map((version) => version.project_id), ...dependencyProjectIds]),
]
const projects = projectIds.length
? await client.labrinth.projects_v2.getMultiple(projectIds)
: []
const versionsById = new Map(versions.map((version) => [version.id, version]))
const projectsById = new Map(projects.map((project) => [project.id, project]))
const directContent: ContentItem[] = [...new Set(directVersionIds)].flatMap((versionId) => {
const version = versionsById.get(versionId)
if (!version) return []
const project = projectsById.get(version.project_id)
return [sharedInstanceContentItem(version, project)]
})
const modpackContent: ContentItem[] = modpackDependencies.map((dependency) => {
const version = dependency.version_id ? versionsById.get(dependency.version_id) : undefined
const project = dependency.project_id
? projectsById.get(dependency.project_id)
: version
? projectsById.get(version.project_id)
: undefined
const primaryFile = version
? (version.files.find((file) => file.primary) ?? version.files[0])
: undefined
const fileName =
primaryFile?.filename ?? dependency.file_name ?? project?.title ?? version?.name ?? 'Unknown'
return sharedInstanceContentItem(
version,
project,
fileName,
dependency.project_id ?? fileName,
!project && !version,
)
})
const externalContent: ContentItem[] = instanceVersion.external_files.map((file, index) => ({
id: `external:${file.file_type}:${file.file_name}:${index}`,
file_name: file.file_name,
size: file.file_size,
project_type: file.file_type,
has_update: false,
update_version_id: null,
source_kind: 'shared_instance',
external: true,
external_url: file.url,
project: {
id: file.file_name,
slug: file.file_name,
title: file.file_name,
icon_url: undefined,
},
}))
return [...externalContent, ...modpackContent, ...directContent]
}
function sharedInstanceContentItem(
version: Labrinth.Versions.v2.Version | undefined,
project: Labrinth.Projects.v2.Project | undefined,
fallbackFileName?: string,
fallbackProjectId = version?.project_id ?? fallbackFileName ?? 'unknown',
external = false,
): ContentItem {
const primaryFile = version
? (version.files.find((file) => file.primary) ?? version.files[0])
: undefined
const fileName =
primaryFile?.filename ?? fallbackFileName ?? project?.title ?? version?.name ?? 'Unknown'
return {
id: version?.id ?? project?.id ?? fileName,
file_name: fileName,
size: primaryFile?.size,
project_type: project?.project_type ?? 'mod',
has_update: false,
update_version_id: null,
source_kind: 'shared_instance',
external,
project: {
id: project?.id ?? fallbackProjectId,
slug: project?.slug ?? fallbackProjectId,
title: project?.title ?? version?.name ?? fileName,
icon_url: project?.icon_url ?? undefined,
},
...(version
? {
version: {
id: version.id,
version_number: version.version_number,
file_name: fileName,
date_published: version.date_published,
},
}
: {}),
}
}
function showSharedInstanceContentError(error: unknown) {
addNotification({
type: 'error',
title: 'Failed to load version content',
text: getErrorMessage(
error,
'The content for this shared instance version could not be loaded.',
),
})
}
function getErrorMessage(error: unknown, fallback: string) {
if (typeof error === 'string') return error
if (!error || typeof error !== 'object') return fallback
const requestError = error as {
message?: string
data?: {
description?: string
}
}
return requestError.data?.description ?? requestError.message ?? fallback
}
const reportItemAvatarUrl = computed(() => {
switch (props.report.item_type) {
case 'project':
@@ -329,6 +731,8 @@ const reportItemAvatarUrl = computed(() => {
return props.report.project?.icon_url || ''
case 'user':
return props.report.user?.avatar_url || ''
case 'shared-instance':
return sharedInstanceDetails.value?.icon_url || props.report.shared_instance?.icon || ''
default:
return undefined
}
@@ -336,6 +740,11 @@ const reportItemAvatarUrl = computed(() => {
const reportItemTitle = computed(() => {
if (props.report.item_type === 'user') return props.report.user?.username || 'Unknown User'
if (props.report.item_type === 'shared-instance') {
return (
sharedInstanceDetails.value?.name || props.report.shared_instance?.name || 'Shared instance'
)
}
return props.report.project?.title || 'Unknown Project'
})
@@ -348,6 +757,8 @@ const reportItemUrl = computed(() => {
return `/${props.report.project?.project_type}/${props.report.project?.slug}`
case 'version':
return `/${props.report.project?.project_type}/${props.report.project?.slug}/version/${props.report.version?.id}`
case 'shared-instance':
return ''
default:
return `/${props.report.item_type}/${props.report.id}`
}
@@ -370,4 +781,40 @@ function copyId() {
})
})
}
async function banSharedInstanceOwner(owner: SharedInstanceReportUser) {
if (sharedInstanceBanPending.value) return
sharedInstanceBanPending.value = true
try {
await client.sharedinstances.moderation_v1.blacklistUsers({
user_ids: [owner.id],
})
if (sharedInstanceDetails.value) {
sharedInstanceDetails.value = {
...sharedInstanceDetails.value,
quarantine: true,
other_instances: sharedInstanceDetails.value.other_instances.map((instance) => ({
...instance,
quarantine: true,
})),
}
}
addNotification({
type: 'success',
title: 'Owner banned from shared instances',
text: `${owner.username} has been banned and all of their shared instances have been quarantined.`,
})
} catch (error) {
addNotification({
type: 'error',
title: 'Failed to ban shared instance owner',
text: getErrorMessage(error, `Could not ban ${owner.username} from shared instances.`),
})
} finally {
sharedInstanceBanPending.value = false
}
}
</script>

Some files were not shown because too many files have changed in this diff Show More