mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 09:34:50 +00:00
feat: instance sharing thru shared-instances service (#6569)
* feat: implement instance share page + search_users backend call * feat: invite players modal * feat: use tanstack queries for friends sync across app pages * feat: base shared instances implementation * fix: admon style * feat: impl instance admonitions like server panel * fix: impl get + del usage * feat: support modpack links * feat: invite notif accepting * fix: lint + fmt * feat: impl install to play * feat: impl usage of UpdateToPlayModal * feat: warnings on deleting/disabling shared-instance version content * fix: send instance name * feat: align with backend * feat: shared instances qa * feat: wrong account protection * feat: qa * fix: smartly apply updates * fix: install bug * fix: 401/404 differentiation * fix: fmt+prepr * feat: qa * feat: qa * fix: signing out messes up revoke/deleted checks * feat: qa * fix: fmt + lint * feat: lock content if part of shared instance * fix: lint * [do not merge] feat: rough invite links impl temp (#6666) * fix: wrong cmd * feat: invite page * fix: server-manager DTO mismatch * fix: drop anonymous invite link acceptance * refactor: structured shared-instance unavailable errors * refactor: centralise error presentations * refactor: dedupe shared instance diff detection * fix: logging in reqwests * refactor: move app.vue shared instances into handler * refactor: break up Share.vue * refactor: split up shared instances state outside of instance index * refactor: dedicated shared instances install/update modals + split up page * refactor: centralized managed content * refactor: split up install shared to own runner + shared.rs split up * refactor: dedupe sql for instance metadata enrichmnt * refactor: friends composable + dedupe friends logic across usages * chore: reduced unused code * fix: align with backend * fix: lint * fix: file sha changes * fix: invite links not working due to icon signed * feat: qa * feat: reporting frontend dummy * fix: try use header * remove: file hash field * fix: pin box * feat: malware warning for shared instances * fix: cache rule * feat: config files syncing * feat: disable config sharing * fix: header * fix: use mark ready * fix: dont cause push update for configs * fix: lint * feat: sharing page in settings * feat: move config + change flow * fix: qa * fix: lint prepr * feat: proxy file upload thru shared instances backend * fix: use collapisible * fix: push config * fix: config * feat: swap out sign in modal for new one * fix: report flow * fix: exclude configs.zip from external warnings * fix: nuxi init * fix: config bundle downloading * fix: error notif * fix: polling * fix: qa * fix: lint + prepr * feat: shared instances moderation frontend + hook up report flow * fix: report copy * fix: lint * fix: lint * fix: modrinth ids being undefined * feat: instance quarantining * fix: prepr + fmt * fix: quarantined -> locked terminology * fix: missing endpoint impls + fmt * fix: missing api in build.rs * fix: share tab jittery * fix: fmt *PT bug * fix: invites count as users even if pending * fix: prepr * fix: invite page owner in users list * fix: lint * fix: qa * fix: lint * fix: members stale not clearing * fix: invite use joined_at field * fix: lint * fix: qa --------- Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user