feat: instance sharing thru shared-instances service (#6569)

* feat: implement instance share page + search_users backend call

* feat: invite players modal

* feat: use tanstack queries for friends sync across app pages

* feat: base shared instances implementation

* fix: admon style

* feat: impl instance admonitions like server panel

* fix: impl get + del usage

* feat: support modpack links

* feat: invite notif accepting

* fix: lint + fmt

* feat: impl install to play

* feat: impl usage of UpdateToPlayModal

* feat: warnings on deleting/disabling shared-instance version content

* fix: send instance name

* feat: align with backend

* feat: shared instances qa

* feat: wrong account protection

* feat: qa

* fix: smartly apply updates

* fix: install bug

* fix: 401/404 differentiation

* fix: fmt+prepr

* feat: qa

* feat: qa

* fix: signing out messes up revoke/deleted checks

* feat: qa

* fix: fmt + lint

* feat: lock content if part of shared instance

* fix: lint

* [do not merge] feat: rough invite links impl temp (#6666)

* fix: wrong cmd

* feat: invite page

* fix: server-manager DTO mismatch

* fix: drop anonymous invite link acceptance

* refactor: structured shared-instance unavailable errors

* refactor: centralise error presentations

* refactor: dedupe shared instance diff detection

* fix: logging in reqwests

* refactor: move app.vue shared instances into handler

* refactor: break up Share.vue

* refactor: split up shared instances state outside of instance index

* refactor: dedicated shared instances install/update modals + split up page

* refactor: centralized managed content

* refactor: split up install shared to own runner + shared.rs split up

* refactor: dedupe sql for instance metadata enrichmnt

* refactor: friends composable + dedupe friends logic across usages

* chore: reduced unused code

* fix: align with backend

* fix: lint

* fix: file sha changes

* fix: invite links not working due to icon signed

* feat: qa

* feat: reporting frontend dummy

* fix: try use header

* remove: file hash field

* fix: pin box

* feat: malware warning for shared instances

* fix: cache rule

* feat: config files syncing

* feat: disable config sharing

* fix: header

* fix: use mark ready

* fix: dont cause push update for configs

* fix: lint

* feat: sharing page in settings

* feat: move config + change flow

* fix: qa

* fix: lint prepr

* feat: proxy file upload thru shared instances backend

* fix: use collapisible

* fix: push config

* fix: config

* feat: swap out sign in modal for new one

* fix: report flow

* fix: exclude configs.zip from external warnings

* fix: nuxi init

* fix: config bundle downloading

* fix: error notif

* fix: polling

* fix: qa

* fix: lint + prepr

* feat: shared instances moderation frontend + hook up report flow

* fix: report copy

* fix: lint

* fix: lint

* fix: modrinth ids being undefined

* feat: instance quarantining

* fix: prepr + fmt

* fix: quarantined -> locked terminology

* fix: missing endpoint impls + fmt

* fix: missing api in build.rs

* fix: share tab jittery

* fix: fmt

*PT bug

* fix: invites count as users even if pending

* fix: prepr

* fix: invite page owner in users list

* fix: lint

* fix: qa

* fix: lint

* fix: members stale not clearing

* fix: invite use joined_at field

* fix: lint

* fix: qa

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-24 13:06:38 +00:00
committed by GitHub
co-authored by sychic
parent 2e0d797bb0
commit e58af98f21
269 changed files with 18202 additions and 2579 deletions
+280 -51
View File
@@ -4,7 +4,8 @@
:class="['p-6 pr-2 pb-4', { 'shrink-0': isFixedRender }]"
@contextmenu.prevent.stop="(event) => handleRightClick(event)"
>
<ExportModal ref="exportModal" :instance="instance" />
<ExportModal v-if="!instance.quarantined" ref="exportModal" :instance="instance" />
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteSelectedInstance" />
<InstanceSettingsModal
:key="instance.id"
ref="settingsModal"
@@ -13,6 +14,15 @@
@unlinked="fetchInstance"
/>
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<SharedInstanceUpdateModal
ref="sharedInstanceUpdateModal"
@shared-instance-unavailable="handleSharedInstanceUnavailable"
@report="(event) => reportSharedInstance(event, true)"
/>
<SharedInstanceInstallModal
ref="sharedInstanceReportModal"
@reported="handleSharedInstanceReported"
/>
<InstancePageHeader
:instance="instance"
:icon-src="icon"
@@ -29,18 +39,32 @@
:ping="ping"
:minecraft-server="minecraftServer"
:linked-project-v3="linkedProjectV3"
:shared-instance-manager="sharedInstanceManager"
@repair="() => repairInstance()"
@stop="() => stopInstance('InstancePage')"
@play="() => startInstance('InstancePage')"
@play-server="() => handlePlayServer()"
@settings="() => settingsModal?.show()"
@open-folder="() => instance && showInstanceInFolder(instance.id)"
@export="() => exportModal?.show()"
@export="() => !instance.quarantined && exportModal?.show()"
@create-shortcut="() => createShortcut()"
@report="reportSharedInstance"
/>
</div>
<div :class="['px-6', { 'shrink-0': isFixedRender }]">
<NavTabs :links="tabs" />
<InstanceAdmonitions
class="mt-4"
:instance="instance"
:shared-instance-unavailable-reason="sharedInstanceUnavailableReason"
:shared-instance-unavailable-manager="sharedInstanceUnavailableManager"
:shared-instance-wrong-account="sharedInstanceWrongAccount"
:shared-instance-expected-user-id="sharedInstanceExpectedUserId"
:shared-instance-role="instance.shared_instance?.role"
:shared-instance-signed-out="sharedInstanceSignedOut"
@published="fetchInstance"
@delete="requestInstanceDeletion"
/>
</div>
<div :class="['p-6 pt-4', { 'min-h-0 flex-1 overflow-y-auto': isFixedRender }]">
<RouterView v-slot="{ Component }" :key="instance.id" :route="displayedInstanceRoute">
@@ -107,10 +131,11 @@ import {
StopCircleIcon,
TerminalSquareIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import { injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { injectAuth, injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
@@ -119,9 +144,13 @@ import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceAdmonitions from '@/components/ui/instance/instance-admonitions/index.vue'
import InstancePageHeader from '@/components/ui/instance-page-header/index.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
import SharedInstanceUpdateModal from '@/components/ui/shared-instances/SharedInstanceUpdateModal.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
@@ -130,10 +159,25 @@ import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { instance_listener, process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { get, get_full_path, kill, run } from '@/helpers/instance'
import {
getSharedInstanceUnavailableReason,
install_existing_instance,
install_get_shared_instance_preview,
install_pack_to_existing_instance,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import {
can_current_user_use_shared_instances,
get,
get_full_path,
kill,
remove,
run,
} from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_instance_id } from '@/helpers/process'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
@@ -141,10 +185,13 @@ import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state'
dayjs.extend(relativeTime)
const { addNotification, handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
const auth = injectAuth()
const queryClient = useQueryClient()
const route = useRoute()
@@ -167,17 +214,23 @@ const instance = ref<GameInstance>()
const preloadedContent = ref<InstanceContentData | null>(null)
const playing = ref(false)
const loading = ref(false)
const checkingSharedInstanceLaunch = ref(false)
const subpagePending = ref(false)
const stopping = ref(false)
const exportModal = ref<InstanceType<typeof ExportModal>>()
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
const sharedInstanceUpdateModal = ref<InstanceType<typeof SharedInstanceUpdateModal>>()
const sharedInstanceReportModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
const deleteConfirmModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
const selectedInstanceToDelete = ref<GameInstance | null>(null)
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
useLoadingBarToken(subpagePending)
const isServerInstance = ref(false)
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
const selected = ref<unknown[]>([])
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
const liveServerStatusOnline = ref(false)
@@ -189,11 +242,27 @@ const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
const activeInstanceId = ref<string>()
const sharedInstanceState = useSharedInstanceState(instance, offline, notifySharedInstanceError)
provideSharedInstanceState(sharedInstanceState)
const {
actionsLocked: sharedInstanceActionsLocked,
expectedUserId: sharedInstanceExpectedUserId,
manager: sharedInstanceManager,
refreshUpdatePreview: refreshSharedInstanceUpdatePreview,
setUnavailable: setSharedInstanceUnavailable,
signedOut: sharedInstanceSignedOut,
unavailableManager: sharedInstanceUnavailableManager,
unavailableReason: sharedInstanceUnavailableReason,
wrongAccount: sharedInstanceWrongAccount,
} = sharedInstanceState
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.path.startsWith('/instance')) {
if (
nextRoute.path.startsWith('/instance') &&
(!instance.value || nextRoute.params.id === instance.value.id)
) {
displayedInstanceRoute.value = nextRoute
}
},
@@ -219,17 +288,15 @@ function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
}
async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
preloadedContent.value = null
resetServerStatus()
const requestedInstanceId = route.params.id as string
const requestedRouteName = route.name
const nextInstance = await get(route.params.id as string).catch(handleError)
const nextInstance = await get(requestedInstanceId).catch(handleError)
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
let nextIsServerInstance = false
const contentPreloadPromise =
nextInstance && isContentSubpageRoute()
nextInstance && isContentSubpageRoute(requestedRouteName)
? loadInstanceContentData(nextInstance.id, undefined, handleError)
: Promise.resolve(null)
@@ -245,13 +312,25 @@ async function fetchInstance() {
}
}
const nextPreloadedContent = await contentPreloadPromise
let nextPreloadedContent = await contentPreloadPromise
let nextRoute = router.currentRoute.value
if (nextRoute.params.id !== requestedInstanceId) return
if (nextInstance && isContentSubpageRoute(nextRoute.name) && !nextPreloadedContent) {
nextPreloadedContent = await loadInstanceContentData(nextInstance.id, undefined, handleError)
nextRoute = router.currentRoute.value
if (nextRoute.params.id !== requestedInstanceId) return
}
instance.value = nextInstance ?? undefined
displayedInstanceRoute.value = nextRoute
sharedInstanceState.reset()
sharedInstanceState.refreshAvailability()
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
activeInstanceId.value = nextInstance?.id
resetServerStatus()
fetchDeferredData(nextInstance?.id)
@@ -335,29 +414,78 @@ const isFixedRender = computed(() => renderMode.value === 'fixed')
const contentSubpageProps = computed(() =>
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
)
const { data: canCurrentUserUseSharedInstances } = useQuery({
queryKey: computed(() => ['shared-instance-eligibility', auth.user.value?.id]),
queryFn: can_current_user_use_shared_instances,
enabled: () => !!auth.session_token.value && !!auth.user.value?.id,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const currentUserCanUseSharedInstances = computed(
() => !auth.session_token.value || canCurrentUserUseSharedInstances.value !== false,
)
const showShareTab = computed(() => {
const linkType = instance.value?.link?.type
const tabs = computed(() => [
{
label: 'Content',
href: `${basePath.value}`,
icon: BoxesIcon,
return (
currentUserCanUseSharedInstances.value &&
!instance.value?.quarantined &&
instance.value?.shared_instance?.role !== 'member' &&
linkType !== 'server_project' &&
linkType !== 'server_project_modpack'
)
})
const tabs = computed(() => {
const instanceTabs = [
{
label: 'Content',
href: `${basePath.value}`,
icon: BoxesIcon,
},
{
label: 'Files',
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
},
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
},
]
if (showShareTab.value) {
instanceTabs.push({
label: 'Share',
href: `${basePath.value}/share`,
icon: UserPlusIcon,
})
}
return instanceTabs
})
watch(
() => ({
quarantined: instance.value?.quarantined ?? false,
routeName: router.currentRoute.value.name,
}),
({ quarantined, routeName }) => {
if (quarantined && routeName === 'InstanceShare') {
void router.replace(basePath.value)
}
},
{
label: 'Files',
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
},
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
},
])
{ immediate: true },
)
if (instance.value) {
breadcrumbs.setName(
@@ -375,13 +503,8 @@ if (instance.value) {
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const startInstance = async (context: string) => {
if (!instance.value) return
if (updateToPlayModal.value?.hasUpdate) {
updateToPlayModal.value.show(instance.value)
return
}
const launchInstance = async (context: string) => {
if (!instance.value || instance.value.quarantined) return
loading.value = true
try {
await run(route.params.id as string)
@@ -391,6 +514,7 @@ const startInstance = async (context: string) => {
}
loading.value = false
if (!instance.value) return
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
@@ -398,6 +522,65 @@ const startInstance = async (context: string) => {
})
}
async function handleSharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
) {
notifySharedInstanceUnavailable(reason, sharedInstanceUnavailableManager.value)
await fetchInstance()
setSharedInstanceUnavailable(reason)
}
const startInstance = async (context: string) => {
if (!instance.value || instance.value.quarantined) return
if (checkingSharedInstanceLaunch.value || loading.value || playing.value) return
const instanceId = instance.value.id
const isSharedInstanceMember = instance.value.shared_instance?.role === 'member'
const canCheckSharedInstanceUpdate =
!!instance.value.shared_instance && !sharedInstanceActionsLocked.value && !offline.value
if (canCheckSharedInstanceUpdate) {
let preview: Awaited<ReturnType<typeof refreshSharedInstanceUpdatePreview>>
checkingSharedInstanceLaunch.value = true
try {
preview = await refreshSharedInstanceUpdatePreview()
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
await handleSharedInstanceUnavailable(getSharedInstanceUnavailableReason(error))
} else {
notifySharedInstanceError(error)
}
return
} finally {
checkingSharedInstanceLaunch.value = false
}
if (instance.value?.id !== instanceId) return
if (preview?.updateAvailable && sharedInstanceUpdateModal.value) {
sharedInstanceUpdateModal.value.show(instance.value, preview, async () => {
await fetchInstance()
await launchInstance(context)
})
return
}
}
if (updateToPlayModal.value?.hasUpdate) {
if (isSharedInstanceMember) {
updateToPlayModal.value.show(instance.value, null, async () => {
await fetchInstance()
await launchInstance(context)
})
} else {
updateToPlayModal.value.show(instance.value)
}
return
}
await launchInstance(context)
}
const stopInstance = async (context: string) => {
stopping.value = true
await kill(route.params.id as string).catch(handleError)
@@ -413,7 +596,7 @@ const stopInstance = async (context: string) => {
}
const handlePlayServer = async () => {
if (!instance.value?.link?.project_id) return
if (!instance.value?.link?.project_id || instance.value.quarantined) return
loading.value = true
try {
await playServerProject(instance.value.link.project_id)
@@ -424,6 +607,7 @@ const handlePlayServer = async () => {
}
const repairInstance = async () => {
if (instance.value.quarantined) return
if (
instance.value.install_stage !== 'pack_installed' &&
(instance.value.link?.type === 'modrinth_modpack' ||
@@ -441,7 +625,7 @@ const repairInstance = async () => {
}
const createShortcut = async () => {
if (!instance.value) return
if (!instance.value || instance.value.quarantined) return
try {
const shortcutPath = await createInstanceShortcut(instance.value.name, instance.value.id)
if (!shortcutPath) return
@@ -459,10 +643,51 @@ const createShortcut = async () => {
}
}
async function reportSharedInstance(event?: MouseEvent, closeUpdateModal = false) {
const reportInstance = instance.value
const sharedInstance = reportInstance?.shared_instance
if (!reportInstance || sharedInstance?.role !== 'member') return
try {
const preview = await install_get_shared_instance_preview(
sharedInstance.id,
reportInstance.name,
)
if (instance.value?.id !== reportInstance.id) return
if (closeUpdateModal) sharedInstanceUpdateModal.value?.hide()
sharedInstanceReportModal.value?.showReport(preview, event)
} catch (error) {
handleError(error as Error)
}
}
function handleSharedInstanceReported(deleteInstance: boolean) {
if (!deleteInstance || !instance.value) return
requestInstanceDeletion()
}
function requestInstanceDeletion() {
if (!instance.value) return
selectedInstanceToDelete.value = instance.value
deleteConfirmModal.value?.show()
}
async function deleteSelectedInstance() {
const selectedInstance = selectedInstanceToDelete.value
selectedInstanceToDelete.value = null
if (!selectedInstance) return
trackEvent('InstanceRemove', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
})
await router.push({ path: '/' })
await remove(selectedInstance.id).catch(handleError)
}
const handleRightClick = (event: MouseEvent) => {
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
...(instance.value?.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'open_folder' },
{ name: 'copy_path' },
@@ -480,10 +705,14 @@ const handleRightClick = (event: MouseEvent) => {
...baseOptions,
]
: [
{
name: 'play',
color: 'primary',
},
...(instance.value?.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
],
)
+111 -54
View File
@@ -19,14 +19,26 @@
ref="modpackContentModal"
:modpack-name="displayedModpackProject?.title"
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:enable-toggle="!props.isServerInstance && !isSharedMember && !isQuarantined"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
:switch-version="handleSwitchVersion"
:switch-version="
props.isServerInstance || isSharedMember || isQuarantined
? undefined
: handleSwitchVersion
"
@update:enabled="handleModpackContentToggle"
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
/>
<ConfirmDisableModal
ref="sharedDisableConfirmModal"
:count="pendingModpackDisableItems.length"
:item-type="formatMessage(messages.contentTypeProject)"
:warning="managedContentPolicy.disableWarning(pendingModpackDisableItems)"
:action-disabled="isInstanceBusy"
@disable="confirmPendingModpackContentDisable"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade"
@@ -38,7 +50,11 @@
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ExportModal
v-if="projects.length > 0 && !instance.quarantined"
ref="exportModal"
:instance="instance"
/>
<ContentUpdaterModal
v-if="updatingProject || updatingModpack"
ref="contentUpdaterModal"
@@ -78,6 +94,7 @@ import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets'
import {
type BulkOperationStatus,
commonMessages,
ConfirmDisableModal,
ConfirmModpackUpdateModal,
ContentCardLayout as ContentPageLayout,
type ContentItem,
@@ -91,7 +108,6 @@ import {
ModpackContentModal,
type ModpackContentModalState,
type OverflowMenuOption,
provideAppBackup,
provideContentManager,
ReadyTransition,
UnknownFileWarningModal,
@@ -109,6 +125,7 @@ import { useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
import {
@@ -116,13 +133,11 @@ import {
instance_listener,
type InstanceBulkUpdateProgress,
} from '@/helpers/events.js'
import { install_duplicate_instance, installJobInstanceId } from '@/helpers/install'
import {
add_project_from_path,
edit,
get_linked_modpack_content,
is_file_on_modrinth,
list,
remove_project,
switch_project_version_with_dependencies,
toggle_disable_project,
@@ -134,6 +149,7 @@ import { get as getSettings, set as setSettings } from '@/helpers/settings'
import type { CacheBehaviour, GameInstance } from '@/helpers/types'
import { highlightModInInstance } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme'
@@ -158,6 +174,10 @@ const messages = defineMessages({
id: 'app.instance.mods.projects-were-added',
defaultMessage: '{count} projects were added',
},
lockedContent: {
id: 'app.instance.mods.locked-content',
defaultMessage: 'Content in locked instances cannot be changed.',
},
contentTypeProject: {
id: 'app.instance.mods.content-type-project',
defaultMessage: 'project',
@@ -197,6 +217,13 @@ const props = defineProps<{
openSettings?: () => void
preloadedContent?: InstanceContentData | null
}>()
const managedContentPolicy = useManagedContentPolicy(computed(() => props.instance))
const {
isManagedModpack: isSharedMember,
isQuarantined,
canMutateContent,
canUpdateContent: canUpdateProject,
} = managedContentPolicy
function hasPreloadedContent(contentData: InstanceContentData | null | undefined) {
return contentData?.path === props.instance.id
@@ -287,6 +314,7 @@ const isBulkOperating = ref(false)
const isInstanceBusy = computed(() => props.instance?.install_stage !== 'installed')
const isPackLocked = computed(
() =>
props.instance.quarantined ||
props.instance?.link?.type === 'modrinth_modpack' ||
props.instance?.link?.type === 'server_project_modpack',
)
@@ -296,6 +324,8 @@ const exportModal = ref(null)
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>()
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal> | null>()
const modpackUpdateConfirmModal = ref<InstanceType<typeof ConfirmModpackUpdateModal> | null>()
const sharedDisableConfirmModal = ref<InstanceType<typeof ConfirmDisableModal> | null>()
const pendingModpackDisableItems = ref<ContentItem[]>([])
const unknownFileWarningModal = ref<InstanceType<typeof UnknownFileWarningModal> | null>()
const unknownFileName = ref('')
let resolveUnknownFileConfirmation: ((confirmed: boolean) => void) | null = null
@@ -377,8 +407,8 @@ function hasContentOperation(item: ContentItem) {
return keys.some((key) => activeContentOperationKeys.value.has(key))
}
function canUpdateProject(item: ContentItem) {
return !!item.file_path && !!item.has_update && !!item.update_version_id
function canDeleteContent(item: ContentItem) {
return canMutateContent(item)
}
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
@@ -486,7 +516,7 @@ async function getUpdaterProjectVersions(projectId: string, pinnedVersionId?: st
}
async function handleBrowseContent() {
if (!props.instance) return
if (!props.instance || props.instance.quarantined) return
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
query: { i: props.instance.id },
@@ -494,7 +524,7 @@ async function handleBrowseContent() {
}
async function handleUploadFiles() {
if (!props.instance) return
if (!props.instance || props.instance.quarantined) return
const files = await open({ multiple: true })
if (!files) return
const selectedFiles: Array<{ path: string; filename: string }> = []
@@ -517,26 +547,37 @@ async function handleUploadFiles() {
}),
)
const addedFiles: string[] = []
const confirmedFiles: Array<{ path: string; filename: string }> = []
for (const [index, { path, filename }] of selectedFiles.entries()) {
if (!fileRecognition[index] && !(await confirmUnknownFileInstallation(filename))) {
continue
}
try {
await add_project_from_path(props.instance.id, path)
addedFiles.push(filename)
} catch (e) {
handleError(e as Error)
}
confirmedFiles.push({ path, filename })
}
await initProjects()
if (addedFiles.length > 0) {
const names = addedFiles.map((f) => {
const item = projects.value.find(
(p) => p.file_name === f || p.file_name === f.replace('.zip', '.jar'),
)
return item?.project?.title ?? f
const addedFiles = (
await Promise.all(
confirmedFiles.map(async ({ path, filename }) => {
try {
const installedPath = await add_project_from_path(props.instance.id, path)
return { filename, installedPath }
} catch (error) {
handleError(error as Error)
return null
}
}),
)
).filter((result): result is { filename: string; installedPath: string } => result !== null)
const uniqueAddedFiles = [
...new Map(addedFiles.map((file) => [file.installedPath, file])).values(),
]
await initProjects('must_revalidate')
if (uniqueAddedFiles.length > 0) {
const names = uniqueAddedFiles.map(({ filename, installedPath }) => {
const item = projects.value.find((project) => project.file_path === installedPath)
return item?.project?.title ?? filename
})
addNotification({
type: 'success',
@@ -784,6 +825,7 @@ async function updateProject(mod: ContentItem) {
}
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
if (!canMutateContent(mod)) return
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
@@ -920,6 +962,7 @@ async function handleUpdate(id: string) {
}
async function handleSwitchVersion(item: ContentItem) {
if (!canMutateContent(item)) return
if (!item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
@@ -947,10 +990,32 @@ async function handleSwitchVersion(item: ContentItem) {
}
async function handleModpackContentToggle(item: ContentItem, enabled: boolean) {
if (!enabled && managedContentPolicy.disableWarning([item])) {
pendingModpackDisableItems.value = [item]
sharedDisableConfirmModal.value?.show()
return
}
await toggleDisableDebounced(item, enabled)
}
async function handleModpackContentBulkToggle(items: ContentItem[], enabled: boolean) {
if (!enabled && managedContentPolicy.disableWarning(items)) {
pendingModpackDisableItems.value = items
sharedDisableConfirmModal.value?.show()
return
}
await setModpackContentEnabled(items, enabled)
}
async function confirmPendingModpackContentDisable() {
const items = [...pendingModpackDisableItems.value]
pendingModpackDisableItems.value = []
await setModpackContentEnabled(items, false)
}
async function setModpackContentEnabled(items: ContentItem[], enabled: boolean) {
await Promise.all(items.map((item) => toggleDisableMod(item, enabled)))
}
@@ -1300,29 +1365,7 @@ function applyContentData(contentData: InstanceContentData) {
return true
}
provideAppBackup({
async createBackup() {
const allInstances = await list()
const prefix = `${props.instance.name} - Backup #`
const existingNums = allInstances
.filter((p) => p.name.startsWith(prefix))
.map((p) => parseInt(p.name.slice(prefix.length), 10))
.filter((n) => !isNaN(n))
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const job = await install_duplicate_instance(props.instance.id)
const newInstanceId = installJobInstanceId(job)
if (newInstanceId) {
await edit(newInstanceId, { name: `${prefix}${nextNum}` })
}
},
})
const CONTENT_HINT_KEY = 'content-tab-modpack-hint-dismissed'
const showContentHint = ref(localStorage.getItem(CONTENT_HINT_KEY) === null)
function dismissContentHint() {
showContentHint.value = false
localStorage.setItem(CONTENT_HINT_KEY, 'true')
}
provideInstanceBackup(() => props.instance)
provideContentManager({
items: mergedProjects,
@@ -1378,21 +1421,31 @@ provideContentManager({
}),
isPackLocked,
isBusy: isInstanceBusy,
disableAddContent: isQuarantined,
disableAddContentTooltip: formatMessage(messages.lockedContent),
isBulkOperating,
skipNonEssentialWarnings,
contentTypeLabel: ref(formatMessage(messages.contentTypeProject)),
toggleEnabled: toggleDisableDebounced,
bulkEnableItems: (items: ContentItem[]) =>
Promise.all(
items.filter((item) => !item.enabled).map((item) => toggleDisableMod(item, true)),
items
.filter((item) => canMutateContent(item) && !item.enabled)
.map((item) => toggleDisableMod(item, true)),
).then(() => {}),
bulkDisableItems: (items: ContentItem[]) =>
Promise.all(
items.filter((item) => item.enabled).map((item) => toggleDisableMod(item, false)),
items
.filter((item) => canMutateContent(item) && item.enabled)
.map((item) => toggleDisableMod(item, false)),
).then(() => {}),
deleteItem: removeMod,
bulkDeleteItems: (items: ContentItem[]) =>
Promise.all(items.map((item) => removeMod(item))).then(() => {}),
Promise.all(items.filter(canMutateContent).map((item) => removeMod(item))).then(() => {}),
canDeleteItem: canDeleteContent,
canToggleItem: canMutateContent,
getDeleteWarning: managedContentPolicy.deleteWarning,
getDisableWarning: managedContentPolicy.disableWarning,
getDeleteDependencyWarning,
refresh: () => initProjects('must_revalidate'),
browse: handleBrowseContent,
@@ -1401,14 +1454,15 @@ provideContentManager({
updateItem: handleUpdate,
bulkUpdateAll: bulkUpdateAllProjects,
bulkUpdateItem: updateProject,
updateModpack: props.isServerInstance ? undefined : handleModpackUpdate,
updateModpack:
props.isServerInstance || isSharedMember.value || isQuarantined.value
? undefined
: handleModpackUpdate,
viewModpackContent: handleModpackContent,
unlinkModpack: unpairInstance,
openSettings: props.openSettings,
switchVersion: handleSwitchVersion,
getOverflowOptions,
showContentHint,
dismissContentHint,
shareItems: handleShareItems,
getItemId: getContentItemId,
mapToTableItem: (item: ContentItem) => ({
@@ -1440,8 +1494,11 @@ provideContentManager({
link: () => openUrl(`https://modrinth.com/${item.owner!.type}/${item.owner!.id}`),
}
: undefined,
enabled: item.enabled,
enabled: canMutateContent(item) ? item.enabled : undefined,
installing: item.installing,
hideDelete: !canDeleteContent(item),
hideSwitchVersion: !canMutateContent(item),
hasUpdate: canUpdateProject(item),
}),
filterPersistKey: props.instance.id,
})
@@ -88,6 +88,7 @@
:highlighted="highlightedWorld === getWorldIdentifier(world)"
:supports-server-quick-play="supportsServerQuickPlay"
:supports-world-quick-play="supportsWorldQuickPlay"
:quarantined="instance.quarantined"
:current-protocol="protocolVersion"
:playing-instance="playing"
:playing-world="worldsMatch(world, worldPlaying)"
@@ -273,6 +274,7 @@ const instance = computed(() => props.instance)
const playing = computed(() => props.playing)
function play(world: World) {
if (props.instance.quarantined) return
emit('play', world)
}
@@ -523,6 +525,7 @@ function handleJoinError(err: Error) {
}
async function joinWorld(world: World) {
if (instance.value.quarantined) return
console.log(`Joining world ${getWorldIdentifier(world)}`)
startingInstance.value = true
worldPlaying.value = world
@@ -3,6 +3,7 @@ import Index from './Index.vue'
import Logs from './Logs.vue'
import Mods from './Mods.vue'
import Overview from './Overview.vue'
import Share from './share/index.vue'
import Worlds from './Worlds.vue'
export { Files, Index, Logs, Mods, Overview, Worlds }
export { Files, Index, Logs, Mods, Overview, Share, Worlds }
@@ -0,0 +1,373 @@
<template>
<div v-if="!instance.quarantined" class="flex flex-col gap-4">
<ModrinthAccountRequiredModal ref="accountRequiredModal" :request-auth="requestAuth" />
<InvitePlayersModal
ref="invitePlayersModal"
:header="formatMessage(messages.shareModalHeader, { name: instance.name })"
:friends="inviteFriends"
:search-users="searchInviteUsers"
:link="inviteLink.link.value"
:link-expires-at="inviteLink.details.value?.expiresAt"
:link-max-uses="inviteLink.details.value?.maxUses"
:update-invite-link="inviteLink.update"
:user-profile-link="userProfileLink"
@invite="invitePlayer"
@cancel="cancelInvite"
/>
<ConfirmUnlinkModal
ref="unlinkModal"
:warning="{
header: formatMessage(messages.unlinkForShareHeader),
body: formatMessage(messages.unlinkForShareBody),
}"
:backup-tip="importedModpackBackupTip"
@unlink="unlinkImportedModpack"
/>
<SharedInstanceRemoveMemberModal
ref="removeMemberModal"
:row="pendingRemovalRow"
:member-count="members.rows.value.length"
@confirm="removeMember"
@clear="pendingRemovalRow = null"
/>
<SharedInstancePublishModal
ref="publishModal"
:instance="instance"
@state-change="publishState = $event"
/>
<SharedInstanceMembersTable
v-if="members.rows.value.length > 0"
:rows="members.rows.value"
:actions-locked="sharedInstanceActionsLocked"
:invite-pending="inviteLink.pending.value"
:push-update-disabled="
instance.install_stage !== 'installed' || publishState !== 'idle' || !!offline
"
:push-update-pending="publishState !== 'idle'"
@invite="showInvitePlayers"
@remove="showRemoveMemberModal"
@push-update="reviewUpdate"
/>
<SharedInstanceShareEmptyState
v-else-if="sharedInstanceUnavailable"
:heading="formatMessage(sharedInstanceErrorMessages.unavailableTitle)"
:description="
formatSharedInstanceUnavailable(
sharedInstanceUnavailableReason,
sharedInstanceUnavailableManager,
)
"
/>
<SharedInstanceShareEmptyState
v-else-if="sharedInstanceActionsLocked"
:heading="formatMessage(lockedEmptyHeading)"
>
<template #description>
<span class="flex flex-wrap items-center justify-center gap-x-1.5 gap-y-1">
<span>{{ formatMessage(messages.lockedEmptyDescriptionPrefix) }}</span>
<span
v-if="linkedAccount"
class="inline-flex max-w-full min-w-0 items-center gap-1.5 align-middle font-semibold text-primary"
>
<Avatar
:src="linkedAccount.avatarUrl"
:alt="linkedAccount.username"
:tint-by="linkedAccount.tintBy"
size="20px"
circle
no-shadow
/>
<span class="min-w-0 truncate">{{ linkedAccount.username }}</span>
</span>
<span v-else class="font-semibold text-primary">{{
formatMessage(messages.linkedAccountFallback)
}}</span>
<span>{{ formatMessage(messages.lockedEmptyDescriptionSuffix) }}</span>
</span>
</template>
<template #actions>
<ButtonStyled color="brand"
><button class="!h-10" @click="signInToShare">
<LogInIcon aria-hidden="true" />{{ formatMessage(lockedActionButton) }}
</button></ButtonStyled
>
</template>
</SharedInstanceShareEmptyState>
<SharedInstanceShareEmptyState
v-else
:heading="formatMessage(messages.noFriendsInvitedHeading)"
:description="formatMessage(messages.noFriendsInvitedDescription)"
>
<template #actions>
<ButtonStyled color="brand"
><button
class="!h-10"
:disabled="inviteLink.pending.value"
@click="showInvitePlayers($event)"
>
<SpinnerIcon
v-if="inviteLink.pending.value"
class="animate-spin"
aria-hidden="true"
/><UserPlusIcon v-else aria-hidden="true" />{{
formatMessage(messages.inviteFriendsButton)
}}
</button></ButtonStyled
>
</template>
</SharedInstanceShareEmptyState>
</div>
</template>
<script setup lang="ts">
import { LogInIcon, SpinnerIcon, UserPlusIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
ConfirmUnlinkModal,
defineMessages,
injectAuth,
type InvitePlayersInvitePayload,
InvitePlayersModal,
type InvitePlayersUser,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref, toRef, watch } from 'vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
import {
getSharedInstanceUnavailableReason,
isSharedInstanceUnavailableError,
} from '@/helpers/install'
import { edit } from '@/helpers/instance'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth.ts'
import {
sharedInstanceErrorMessages,
useSharedInstanceErrors,
} from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectSharedInstanceState } from '../use-shared-instance-state'
import SharedInstanceMembersTable from './shared-instance-members-table.vue'
import SharedInstanceRemoveMemberModal from './shared-instance-remove-member-modal.vue'
import SharedInstanceShareEmptyState from './shared-instance-share-empty-state.vue'
import type { ShareRow } from './shared-instance-share-types'
import { useSharedInstanceInviteCandidates } from './use-shared-instance-invite-candidates'
import { useSharedInstanceInviteLink } from './use-shared-instance-invite-link'
import { useSharedInstanceMembers } from './use-shared-instance-members'
const props = defineProps<{
instance: GameInstance
offline?: boolean
}>()
const auth = injectAuth()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const {
formatSharedInstanceUnavailable,
notifySharedInstanceError,
notifySharedInstanceUnavailable,
} = useSharedInstanceErrors()
const sharedInstanceState = injectSharedInstanceState()
const instance = toRef(props, 'instance')
const actionsLocked = sharedInstanceState.shareActionsLocked
const sharedInstanceActionsLocked = actionsLocked
const currentUserId = computed(() => auth.user.value?.id ?? null)
const isSignedIn = computed(() => !!auth.session_token.value)
const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>()
const invitePlayersModal = ref<InstanceType<typeof InvitePlayersModal>>()
const unlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
const removeMemberModal = ref<InstanceType<typeof SharedInstanceRemoveMemberModal>>()
const publishModal = ref<InstanceType<typeof SharedInstancePublishModal>>()
const publishState = ref<'idle' | 'reviewing' | 'publishing'>('idle')
const pendingRemovalRow = ref<ShareRow | null>(null)
const importedModpackUnlinked = ref(false)
function notifyOperationError(error: unknown) {
if (isSharedInstanceUnavailableError(error)) {
notifySharedInstanceUnavailable(
getSharedInstanceUnavailableReason(error),
sharedInstanceState.unavailableManager.value,
)
} else {
notifySharedInstanceError(error)
}
}
const members = useSharedInstanceMembers({
instance,
currentUserId,
isSignedIn,
actionsLocked,
onError: notifyOperationError,
})
const {
inviteFriends,
search: searchInviteUsers,
requestFriend,
} = useSharedInstanceInviteCandidates({
rows: members.rows,
currentUserId,
isSignedIn,
actionsLocked,
})
const inviteLink = useSharedInstanceInviteLink(
computed(() => props.instance.id),
notifyOperationError,
)
const linkedAccount = computed(() => {
const manager = sharedInstanceState.manager.value
return manager?.type === 'user'
? { username: manager.name, avatarUrl: manager.avatarUrl, tintBy: manager.tintBy }
: null
})
const lockedEmptyHeading = computed(() =>
isSignedIn.value ? messages.lockedWrongAccountHeading : messages.lockedSignedOutHeading,
)
const lockedActionButton = computed(() =>
isSignedIn.value ? messages.switchAccountButton : messages.signInButton,
)
const sharedInstanceUnavailableReason = sharedInstanceState.unavailableReason
const sharedInstanceUnavailable = computed(() => !!sharedInstanceUnavailableReason.value)
const sharedInstanceUnavailableManager = sharedInstanceState.unavailableManager
const requiresUnlink = computed(
() =>
props.instance.link?.type === 'imported_modpack' &&
!props.instance.shared_instance &&
!importedModpackUnlinked.value,
)
const importedModpackBackupTip = computed(() =>
props.instance.link?.type === 'imported_modpack'
? (props.instance.link.name ?? props.instance.link.filename ?? undefined)
: undefined,
)
const messages = defineMessages({
signInButton: { id: 'app.instance.share.sign-in.button', defaultMessage: 'Sign in' },
noFriendsInvitedHeading: {
id: 'app.instance.share.empty.heading',
defaultMessage: 'No friends invited',
},
noFriendsInvitedDescription: {
id: 'app.instance.share.empty.description',
defaultMessage: 'You can share this instance with your friends!',
},
inviteFriendsButton: {
id: 'app.instance.share.empty.invite-friends-button',
defaultMessage: 'Invite friends',
},
shareModalHeader: {
id: 'app.instance.share.invite-modal.heading',
defaultMessage: 'Share {name}',
},
lockedWrongAccountHeading: {
id: 'app.instance.share.locked.wrong-account-heading',
defaultMessage: 'Wrong account',
},
lockedSignedOutHeading: {
id: 'app.instance.share.locked.signed-out-heading',
defaultMessage: 'Not signed in',
},
lockedEmptyDescriptionPrefix: {
id: 'app.instance.share.locked.empty-description-prefix',
defaultMessage: 'You need to sign in as',
},
lockedEmptyDescriptionSuffix: {
id: 'app.instance.share.locked.empty-description-suffix',
defaultMessage: 'to access this page.',
},
linkedAccountFallback: {
id: 'app.instance.share.locked.linked-account-fallback',
defaultMessage: 'the linked account',
},
switchAccountButton: {
id: 'app.instance.share.locked.switch-account-button',
defaultMessage: 'Switch account',
},
unlinkForShareHeader: {
id: 'app.instance.share.unlink.header',
defaultMessage: 'Sharing requires unlinking',
},
unlinkForShareBody: {
id: 'app.instance.share.unlink.body',
defaultMessage: 'You must unlink this modpack to share your instance',
},
})
function invitePlayer(payload: InvitePlayersInvitePayload) {
if (actionsLocked.value) return
if (payload.source === 'search') void requestFriend(payload.user)
members.invite(payload.user)
}
function cancelInvite(user: InvitePlayersUser) {
const row = members.find(user.id, user.username)
if (row) members.remove(row.id)
}
async function showInvitePlayers(event?: MouseEvent) {
if (actionsLocked.value) return
if (!isSignedIn.value) return signInToShare(event)
if (requiresUnlink.value) return unlinkModal.value?.show()
if (await inviteLink.ensure()) invitePlayersModal.value?.show(event)
}
async function unlinkImportedModpack() {
try {
await edit(props.instance.id, { link: null as unknown as undefined })
importedModpackUnlinked.value = true
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', props.instance.id] })
if (await inviteLink.ensure()) invitePlayersModal.value?.show()
} catch (error) {
notifyOperationError(error)
}
}
function showRemoveMemberModal(row: ShareRow) {
if (!actionsLocked.value) {
pendingRemovalRow.value = row
removeMemberModal.value?.show()
}
}
function reviewUpdate(event: MouseEvent) {
publishModal.value?.show(event)
}
function removeMember(row: ShareRow) {
members.remove(row.id)
}
function userProfileLink(username: string) {
return !username || username.includes('@')
? undefined
: () => openUrl(`https://modrinth.com/user/${encodeURIComponent(username)}`)
}
async function requestAuth(flow: ModrinthAuthFlow) {
await auth.requestSignIn(`/instance/${encodeURIComponent(props.instance.id)}/share`, flow, {
showModal: false,
})
return !!auth.session_token.value
}
function signInToShare(event?: MouseEvent) {
void accountRequiredModal.value?.show(event)
}
watch(
() => props.instance.id,
() => {
importedModpackUnlinked.value = false
},
)
watch(
[() => auth.isReady.value, isSignedIn, actionsLocked],
([ready, signedIn, locked]) => {
if (ready && !signedIn && !locked) signInToShare()
},
{ immediate: true, flush: 'post' },
)
provideInstanceBackup(() => props.instance)
</script>
@@ -0,0 +1,304 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<StyledInput
v-model="search"
:icon="SearchIcon"
:placeholder="`Search ${rows.length} users...`"
wrapper-class="min-w-0 flex-1"
input-class="!h-10"
clearable
/>
<template v-if="!actionsLocked">
<ButtonStyled>
<button
class="flex !h-10 shrink-0 items-center gap-2"
:disabled="pushUpdateDisabled"
@click="emit('push-update', $event)"
>
<SpinnerIcon v-if="pushUpdatePending" class="animate-spin" aria-hidden="true" />
<UploadIcon v-else aria-hidden="true" />
{{ formatMessage(messages.pushUpdate) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
class="flex !h-10 shrink-0 items-center gap-2"
:disabled="invitePending"
@click="emit('invite', $event)"
>
<SpinnerIcon v-if="invitePending" class="animate-spin" aria-hidden="true" />
<UserPlusIcon v-else aria-hidden="true" />
Invite friends
</button>
</ButtonStyled>
</template>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<FilterIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
<button
:class="filterClass(methodFilter === 'all')"
:aria-pressed="methodFilter === 'all'"
@click="methodFilter = 'all'"
>
All
</button>
<button
v-for="option in methodFilterOptions"
:key="option.id"
:class="filterClass(methodFilter === option.id)"
:aria-pressed="methodFilter === option.id"
@click="toggleMethodFilter(option.id)"
>
{{ option.label }}
</button>
</div>
</div>
<Table
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
:columns="columns"
:data="sortedRows"
row-key="id"
table-min-width="42rem"
@sort="handleSort"
>
<template #empty-state
><div class="flex h-64 items-center justify-center text-secondary">
No users match your filters.
</div></template
>
<template #cell-username="{ row }">
<div class="flex min-w-0 max-w-full items-center gap-2">
<AutoLink
v-tooltip="truncatedTooltip(usernameRefs[row.id], row.username)"
:to="userProfileLink(row.username)"
class="inline-flex max-w-full min-w-0 items-center gap-2 text-primary hover:underline"
>
<Avatar
:src="row.avatarUrl"
:alt="`${row.username}'s avatar`"
:tint-by="row.username"
size="24px"
circle
no-shadow
/>
<span
:ref="(element) => setUsernameRef(row.id, element)"
class="min-w-0 truncate font-medium"
>{{ row.username }}</span
>
</AutoLink>
</div>
</template>
<template #cell-lastPlayed="{ row }">
<span v-if="row.lastPlayedAt" v-tooltip="formatDateTime(row.lastPlayedAt)">{{
formatRelativeTime(row.lastPlayedAt)
}}</span>
<span v-else>Never</span>
</template>
<template #cell-joined="{ row }">
<span
v-if="row.pending"
class="inline-flex h-7 items-center rounded-full border border-surface-5 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
>Pending</span
>
<span v-else-if="row.joinedAt" v-tooltip="formatDateTime(row.joinedAt)">{{
formatRelativeTime(row.joinedAt)
}}</span>
</template>
<template #cell-method="{ row }">
<span class="inline-flex min-w-0 items-center gap-2">
<UserPlusIcon v-if="row.method === 'direct'" class="size-5 shrink-0" aria-hidden="true" />
<LinkIcon v-else class="size-5 shrink-0" aria-hidden="true" />
<span class="min-w-0 truncate">{{ methodLabels[row.method] }}</span>
</span>
</template>
<template #cell-actions="{ row }">
<div v-if="!actionsLocked" class="flex items-center justify-end">
<ButtonStyled circular type="transparent"
><button
v-tooltip="'Revoke access'"
:aria-label="`Revoke access for ${row.username}`"
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none"
@click="emit('remove', row)"
>
<XIcon aria-hidden="true" /></button
></ButtonStyled>
</div>
</template>
</Table>
</div>
</template>
<script setup lang="ts">
import {
FilterIcon,
LinkIcon,
SearchIcon,
SpinnerIcon,
UploadIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import {
AutoLink,
Avatar,
ButtonStyled,
defineMessages,
type SortDirection,
StyledInput,
Table,
type TableColumn,
truncatedTooltip,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref } from 'vue'
import {
type MethodFilter,
methodLabels,
type ShareMethod,
type ShareRow,
type ShareTableColumn,
} from './shared-instance-share-types'
const props = defineProps<{
rows: ShareRow[]
actionsLocked?: boolean
invitePending?: boolean
pushUpdateDisabled?: boolean
pushUpdatePending?: boolean
}>()
const emit = defineEmits<{
invite: [event: MouseEvent]
remove: [row: ShareRow]
'push-update': [event: MouseEvent]
}>()
const search = ref('')
const methodFilter = ref<MethodFilter>('all')
const sortColumn = ref<string | undefined>('joined')
const sortDirection = ref<SortDirection>('desc')
const usernameRefs = ref<Record<string, HTMLElement | null>>({})
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime({ style: 'narrow' })
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const methodFilterOptions: Array<{ id: ShareMethod; label: string }> = [
{ id: 'direct', label: methodLabels.direct },
{ id: 'link', label: methodLabels.link },
]
const columns = computed<TableColumn<ShareTableColumn>[]>(() => {
const result: TableColumn<ShareTableColumn>[] = [
{
key: 'username',
label: 'Username',
width: 'clamp(14rem, 30%, 26rem)',
enableSorting: true,
headerClass: '!pr-3',
cellClass: '!pr-3',
},
{
key: 'lastPlayed',
label: 'Last played',
width: 'clamp(7rem, 15%, 13rem)',
enableSorting: true,
headerClass: 'whitespace-nowrap !px-2',
cellClass: 'whitespace-nowrap !px-2',
},
{
key: 'joined',
label: 'Joined',
width: 'clamp(7rem, 14%, 12rem)',
enableSorting: true,
defaultSortDirection: 'desc',
headerClass: 'whitespace-nowrap !px-2',
cellClass: 'whitespace-nowrap !px-2',
},
{
key: 'method',
label: 'Method',
enableSorting: true,
headerClass: 'whitespace-nowrap !px-2',
cellClass: 'whitespace-nowrap !px-2',
},
]
if (!props.actionsLocked)
result.push({
key: 'actions',
label: 'Actions',
align: 'right',
width: 'clamp(5.5rem, 7%, 7rem)',
headerClass: 'whitespace-nowrap !pl-2 !pr-4',
cellClass: 'whitespace-nowrap !pl-2 !pr-4',
})
return result
})
const filteredRows = computed(() => {
const query = search.value.trim().toLowerCase()
return props.rows.filter((row) => {
if (methodFilter.value !== 'all' && row.method !== methodFilter.value) return false
if (!query) return true
return [
row.username,
row.lastPlayedAt ? formatRelativeTime(row.lastPlayedAt) : 'Never',
row.pending ? 'Pending' : row.joinedAt ? formatRelativeTime(row.joinedAt) : '',
methodLabels[row.method],
].some((value) => value.toLowerCase().includes(query))
})
})
const sortedRows = computed(() => [...filteredRows.value].sort(compareRows))
function compareRows(a: ShareRow, b: ShareRow) {
let compared: number
if (sortColumn.value === 'username') compared = a.username.localeCompare(b.username)
else if (sortColumn.value === 'lastPlayed')
compared =
(a.lastPlayedAt?.getTime() ?? Number.NEGATIVE_INFINITY) -
(b.lastPlayedAt?.getTime() ?? Number.NEGATIVE_INFINITY)
else if (sortColumn.value === 'method')
compared = methodLabels[a.method].localeCompare(methodLabels[b.method])
else
compared =
(a.pending ? Number.MAX_SAFE_INTEGER : (a.joinedAt?.getTime() ?? Number.NEGATIVE_INFINITY)) -
(b.pending
? Number.MAX_SAFE_INTEGER
: (b.joinedAt?.getTime() ?? Number.NEGATIVE_INFINITY)) ||
a.username.localeCompare(b.username)
return sortDirection.value === 'asc' ? compared : -compared
}
function handleSort(column: string, direction: SortDirection) {
sortColumn.value = column
sortDirection.value = direction
}
function toggleMethodFilter(filter: ShareMethod) {
methodFilter.value = methodFilter.value === filter ? 'all' : filter
}
function filterClass(active: boolean) {
return [
'cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]',
active
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5',
]
}
const messages = defineMessages({
pushUpdate: {
id: 'app.instance.admonitions.shared-instance.publish-button',
defaultMessage: 'Push update',
},
})
function userProfileLink(username: string) {
return !username || username.includes('@')
? undefined
: () => openUrl(`https://modrinth.com/user/${encodeURIComponent(username)}`)
}
function setUsernameRef(id: string, element: Element | null) {
usernameRefs.value[id] = element instanceof HTMLElement ? element : null
}
</script>
@@ -0,0 +1,122 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
max-width="470px"
@after-hide="emit('clear')"
>
<div class="flex flex-col gap-4">
<Admonition type="warning">{{ formatMessage(messages.warning, { username }) }}</Admonition>
<div class="flex min-w-0 items-center gap-2 rounded-[20px] bg-surface-2 p-3">
<Avatar
:src="row?.avatarUrl"
:alt="formatMessage(messages.avatarAlt, { username })"
:tint-by="username"
size="40px"
circle
no-shadow
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="min-w-0 truncate font-medium text-contrast">{{ username }}</span>
<span class="truncate text-sm text-secondary">{{
row ? methodLabels[row.method] : ''
}}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.effectsLabel) }}</span>
<ul class="m-0 list-disc pl-6 text-primary">
<li v-for="effect in effects" :key="effect.id" class="leading-6 marker:text-secondary">
{{ formatMessage(effect) }}
</li>
</ul>
</div>
<div class="flex justify-end gap-2 pt-1">
<ButtonStyled type="outlined"
><button class="!border !border-surface-5" @click="modal?.hide()">
<XIcon aria-hidden="true" />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
>
<ButtonStyled color="orange"
><button :disabled="!row" @click="confirm">
<UserXIcon aria-hidden="true" />{{ formatMessage(messages.removeButton) }}
</button></ButtonStyled
>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { UserXIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { methodLabels, type ShareRow } from './shared-instance-share-types'
const props = defineProps<{ row: ShareRow | null; memberCount: number }>()
const emit = defineEmits<{ confirm: [row: ShareRow]; clear: [] }>()
const modal = ref<InstanceType<typeof NewModal>>()
const { formatMessage } = useVIntl()
const username = computed(() => props.row?.username ?? '')
const messages = defineMessages({
header: { id: 'app.instance.share.remove-user-modal.header', defaultMessage: 'Revoke access' },
warning: {
id: 'app.instance.share.remove-user-modal.warning-body',
defaultMessage:
"If you revoke {username}'s access to this shared instance, you'll need to invite them again before they can receive updates.",
},
avatarAlt: {
id: 'app.instance.share.remove-user-modal.user-avatar-alt',
defaultMessage: "{username}'s avatar",
},
effectsLabel: {
id: 'app.instance.share.remove-user-modal.effects-label',
defaultMessage: 'What happens?',
},
effectAccess: {
id: 'app.instance.share.remove-user-modal.effect-access',
defaultMessage: 'They will no longer receive updates for this shared instance',
},
effectInstalledCopy: {
id: 'app.instance.share.remove-user-modal.effect-installed-copy',
defaultMessage: 'Any copy they already installed will stay on their device',
},
effectInviteAgain: {
id: 'app.instance.share.remove-user-modal.effect-invite-again',
defaultMessage: 'You can invite them again later',
},
effectLastUser: {
id: 'app.instance.share.remove-user-modal.effect-last-user',
defaultMessage: 'This is the last user, sharing will be turned off for this instance',
},
removeButton: {
id: 'app.instance.share.remove-user-modal.remove-button',
defaultMessage: 'Revoke access',
},
})
const effects = computed(() => {
const result = [messages.effectAccess, messages.effectInstalledCopy, messages.effectInviteAgain]
if (props.memberCount === 1) result.push(messages.effectLastUser)
return result
})
function show(event?: MouseEvent) {
modal.value?.show(event)
}
function confirm() {
if (props.row) {
modal.value?.hide()
emit('confirm', props.row)
}
}
defineExpose({ show })
</script>
@@ -0,0 +1,15 @@
<template>
<EmptyState type="empty-inbox">
<template #heading>{{ heading }}</template>
<template #description
><slot name="description">{{ description }}</slot></template
>
<template v-if="$slots.actions" #actions><slot name="actions" /></template>
</EmptyState>
</template>
<script setup lang="ts">
import { EmptyState } from '@modrinth/ui'
defineProps<{ heading: string; description?: string }>()
</script>
@@ -0,0 +1,20 @@
export type ShareMethod = 'direct' | 'link'
export type MethodFilter = ShareMethod | 'all'
export type ShareTableColumn = 'username' | 'lastPlayed' | 'joined' | 'method' | 'actions'
export type ShareRow = {
id: string
username: string
avatarUrl?: string
lastPlayedAt: Date | null
joinedAt: Date | null
method: ShareMethod
pending?: boolean
}
export const methodLabels: Record<ShareMethod, string> = {
direct: 'Direct invite',
link: 'Share link',
}
export { normalizeInviteKey } from '@modrinth/ui'
@@ -0,0 +1,105 @@
import {
injectNotificationManager,
type InvitePlayersSearchUser,
type InvitePlayersUser,
} from '@modrinth/ui'
import { computed, type Ref } from 'vue'
import { useFriends } from '@/composables/use-friends'
import { getFriendUserId } from '@/helpers/friends.ts'
import { get as getCredentials } from '@/helpers/mr_auth.ts'
import { search_user } from '@/helpers/users.ts'
import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types'
export function useSharedInstanceInviteCandidates(options: {
rows: Ref<ShareRow[]>
currentUserId: Ref<string | null>
isSignedIn: Ref<boolean>
actionsLocked: Ref<boolean>
}) {
const { handleError } = injectNotificationManager()
const friendsState = useFriends({
currentUserId: options.currentUserId,
getCredentials,
enabled: computed(
() =>
options.isSignedIn.value && !!options.currentUserId.value && !options.actionsLocked.value,
),
onError: handleError,
})
const friends = friendsState.friends
const invitedRows = computed(() => {
const invited = new Map<string, ShareRow>()
for (const row of options.rows.value) {
invited.set(normalizeInviteKey(row.id), row)
invited.set(normalizeInviteKey(row.username), row)
}
return invited
})
const inviteFriends = computed<InvitePlayersUser[]>(() =>
friends.value
.filter((friend) => friend.username && friend.accepted)
.sort((a, b) => Number(b.online) - Number(a.online))
.map((friend) => {
const id = getFriendUserId(friend, options.currentUserId.value)
const invited =
invitedRows.value.get(normalizeInviteKey(id)) ??
invitedRows.value.get(normalizeInviteKey(friend.username))
return {
id,
username: friend.username,
avatarUrl: friend.avatar,
online: friend.online,
status: invited ? (invited.pending ? 'pending' : 'added') : 'available',
}
}),
)
const candidateKeys = computed(() => {
const keys = new Set<string>()
for (const friend of inviteFriends.value) {
keys.add(normalizeInviteKey(friend.id))
keys.add(normalizeInviteKey(friend.username))
}
return keys
})
async function search(query: string): Promise<InvitePlayersSearchUser[]> {
if (options.actionsLocked.value) return []
const credentials = await getCredentials()
const ownUserId = options.currentUserId.value ?? credentials?.user_id ?? null
return (await search_user(query))
.filter((user) => user.id !== ownUserId)
.filter((user) => {
const id = normalizeInviteKey(user.id)
const username = normalizeInviteKey(user.username)
return (
!candidateKeys.value.has(id) &&
!candidateKeys.value.has(username) &&
!invitedRows.value.has(id) &&
!invitedRows.value.has(username)
)
})
.map((user) => ({
id: user.id,
username: user.username,
avatarUrl: user.avatar_url || undefined,
}))
}
async function requestFriend(user: InvitePlayersUser) {
if (options.actionsLocked.value) return
const credentials = await getCredentials()
const ownUserId = options.currentUserId.value ?? credentials?.user_id ?? null
if (ownUserId && normalizeInviteKey(user.id) === normalizeInviteKey(ownUserId)) return
if (!friendsState.findFriend(user.id, user.username)) {
friendsState.requestFriend({
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
})
}
}
return { inviteFriends, search, requestFriend }
}
@@ -0,0 +1,63 @@
import type { InviteLinkSettings } from '@modrinth/ui'
import { computed, type Ref, ref, watch } from 'vue'
import { config } from '@/config'
import { toError } from '@/helpers/errors'
import { create_shared_instance_invite_link } from '@/helpers/instance'
export function useSharedInstanceInviteLink(
instanceId: Ref<string>,
onError: (error: unknown) => void,
) {
const details = ref<Awaited<ReturnType<typeof create_shared_instance_invite_link>>>()
const pending = ref(false)
const link = computed(() =>
details.value
? `${config.siteUrl}/share/${encodeURIComponent(details.value.inviteId)}`
: undefined,
)
async function ensure() {
if (details.value) return true
if (pending.value) return false
pending.value = true
try {
details.value = await create_shared_instance_invite_link(instanceId.value)
return true
} catch (error) {
onError(error)
return false
} finally {
pending.value = false
}
}
async function update(settings: InviteLinkSettings) {
if (!details.value) return
pending.value = true
try {
const maxAgeSeconds = Math.max(
1,
Math.min(604800, Math.floor((settings.expiresAt.getTime() - Date.now()) / 1000)),
)
details.value = await create_shared_instance_invite_link(instanceId.value, {
maxAgeSeconds,
maxUses: settings.maxUses,
replaceInviteId: details.value.inviteId,
})
} catch (error) {
throw toError(error)
} finally {
pending.value = false
}
}
watch(instanceId, () => {
details.value = undefined
pending.value = false
})
return { details, pending, link, ensure, update }
}
@@ -0,0 +1,241 @@
import type { InvitePlayersUser } from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type Ref } from 'vue'
import { get_user_many } from '@/helpers/cache.js'
import {
get_shared_instance_users,
invite_shared_instance_users,
remove_shared_instance_users,
type SharedInstanceUser,
type SharedInstanceUsers,
} from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types'
type MembersQueryKey = readonly ['sharedInstanceUsers', string]
type OptimisticChange = {
queryKey: MembersQueryKey
userId: string
previousRow?: ShareRow
previousIndex: number
}
type InviteVariables = {
user: InvitePlayersUser
change: OptimisticChange
}
type RemoveVariables = {
id: string
hasPendingRecipients: boolean
change: OptimisticChange
}
export function useSharedInstanceMembers(options: {
instance: Ref<GameInstance>
currentUserId: Ref<string | null>
isSignedIn: Ref<boolean>
actionsLocked: Ref<boolean>
onError: (error: unknown) => void
}) {
const queryClient = useQueryClient()
const queryKey = computed(() => ['sharedInstanceUsers', options.instance.value.id] as const)
const invitingUserIds = new Set<string>()
const removingUserIds = new Set<string>()
const query = useQuery({
queryKey,
queryFn: ({ queryKey }) => fetchRows(queryKey),
enabled: () =>
options.isSignedIn.value && !!options.instance.value.id && !options.actionsLocked.value,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnReconnect: false,
refetchOnWindowFocus: false,
})
const rows = computed(() => query.data.value ?? [])
const inviteMutation = useMutation({
mutationFn: ({ user, change }: InviteVariables) =>
invite_shared_instance_users(change.queryKey[1], [user.id]),
onError: (error, { change }) => {
rollback(change)
options.onError(error)
},
onSettled: (_data, _error, { user }) => {
invitingUserIds.delete(normalizeInviteKey(user.id))
},
})
const removeMutation = useMutation({
mutationFn: ({ id, hasPendingRecipients, change }: RemoveVariables) =>
remove_shared_instance_users(change.queryKey[1], [id], hasPendingRecipients),
onError: (error, { change }) => {
rollback(change)
options.onError(error)
},
onSettled: (_data, _error, { id }) => {
removingUserIds.delete(normalizeInviteKey(id))
},
})
async function fetchRows(activeQueryKey: MembersQueryKey) {
const users = await get_shared_instance_users(activeQueryKey[1])
const loadedRows = await usersToRows(users)
const currentRows = queryClient.getQueryData<ShareRow[]>(activeQueryKey) ?? []
return preserveRowOrder(loadedRows, currentRows)
}
async function usersToRows(users: SharedInstanceUsers): Promise<ShareRow[]> {
const excludedIds = new Set(
[options.instance.value.shared_instance?.manager_id, options.currentUserId.value].filter(
(id): id is string => !!id,
),
)
const usersToDisplay = userEntries(users).filter((user) => !excludedIds.has(user.id))
if (usersToDisplay.length === 0) return []
const profiles = (await get_user_many(usersToDisplay.map((user) => user.id))) as Array<{
id: string
username?: string
avatar_url?: string | null
}>
return usersToDisplay.map((user) => {
const profile = profiles.find((candidate) => candidate.id === user.id)
const joinedAt = parseDate(user.joined_at)
return {
id: user.id,
username: profile?.username ?? user.id,
avatarUrl: profile?.avatar_url ?? undefined,
lastPlayedAt: parseDate(user.last_played),
joinedAt,
method: user.join_type === 'link' ? 'link' : 'direct',
pending: !joinedAt,
}
})
}
function find(id: string, username: string) {
const normalizedId = normalizeInviteKey(id)
const normalizedUsername = normalizeInviteKey(username)
return rows.value.find(
(row) =>
normalizeInviteKey(row.id) === normalizedId ||
normalizeInviteKey(row.username) === normalizedUsername,
)
}
function invite(user: InvitePlayersUser) {
const normalizedId = normalizeInviteKey(user.id)
if (
options.actionsLocked.value ||
invitingUserIds.has(normalizedId) ||
find(user.id, user.username)
) {
return
}
invitingUserIds.add(normalizedId)
const change = beginOptimisticChange(user.id)
updateRows(change.queryKey, (currentRows) => [...currentRows, inviteUserToRow(user)])
inviteMutation.mutate({ user, change })
}
function remove(id: string) {
const normalizedId = normalizeInviteKey(id)
if (options.actionsLocked.value || removingUserIds.has(normalizedId)) return
removingUserIds.add(normalizedId)
const hasPendingRecipients = rows.value.some(
(row) => row.pending && normalizeInviteKey(row.id) !== normalizedId,
)
const change = beginOptimisticChange(id)
updateRows(change.queryKey, (currentRows) =>
currentRows.filter((row) => normalizeInviteKey(row.id) !== normalizedId),
)
removeMutation.mutate({ id, hasPendingRecipients, change })
}
function beginOptimisticChange(userId: string): OptimisticChange {
const activeQueryKey = queryKey.value
void queryClient.cancelQueries({ queryKey: activeQueryKey, exact: true }, { revert: false })
const currentRows = queryClient.getQueryData<ShareRow[]>(activeQueryKey) ?? []
const previousIndex = currentRows.findIndex(
(row) => normalizeInviteKey(row.id) === normalizeInviteKey(userId),
)
return {
queryKey: activeQueryKey,
userId,
previousRow: previousIndex === -1 ? undefined : currentRows[previousIndex],
previousIndex,
}
}
function rollback(change: OptimisticChange) {
const normalizedId = normalizeInviteKey(change.userId)
updateRows(change.queryKey, (currentRows) => {
const rowsWithoutUser = currentRows.filter(
(row) => normalizeInviteKey(row.id) !== normalizedId,
)
if (!change.previousRow) return rowsWithoutUser
const previousIndex = Math.min(change.previousIndex, rowsWithoutUser.length)
return [
...rowsWithoutUser.slice(0, previousIndex),
change.previousRow,
...rowsWithoutUser.slice(previousIndex),
]
})
}
function updateRows(activeQueryKey: MembersQueryKey, update: (rows: ShareRow[]) => ShareRow[]) {
queryClient.setQueryData<ShareRow[]>(activeQueryKey, (currentRows = []) => update(currentRows))
}
return { rows, query, find, invite, remove }
}
function userEntries(users: SharedInstanceUsers): SharedInstanceUser[] {
if (users.users?.length > 0) return users.users
return users.user_ids.map((id) => ({
id,
joined_at: null,
join_type: 'invite',
last_played: null,
}))
}
function parseDate(value?: string | null) {
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function inviteUserToRow(user: InvitePlayersUser): ShareRow {
return {
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl ?? undefined,
lastPlayedAt: null,
joinedAt: null,
method: 'direct',
pending: true,
}
}
function preserveRowOrder(rows: ShareRow[], previousRows: ShareRow[]) {
const rowsById = new Map(rows.map((row) => [normalizeInviteKey(row.id), row]))
const orderedRows = previousRows.flatMap((previousRow) => {
const id = normalizeInviteKey(previousRow.id)
const row = rowsById.get(id)
if (!row) return []
rowsById.delete(id)
return [row]
})
return [...orderedRows, ...rowsById.values()]
}
@@ -0,0 +1,228 @@
import { injectAuth } from '@modrinth/ui'
import { computed, inject, type InjectionKey, provide, type Ref, ref, watch } from 'vue'
import { useUserQuery } from '@/composables/users/use-user-query'
import {
getSharedInstanceUnavailableReason,
install_get_shared_instance_update_preview,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import type { GameInstance } from '@/helpers/types'
export type SharedInstanceManager =
| {
type: 'user'
name: string
avatarUrl?: string
tintBy: string
}
| {
type: 'server'
name: string
avatarUrl?: string
tintBy: string
}
export function useSharedInstanceState(
instance: Ref<GameInstance | undefined>,
offline: Ref<boolean>,
notifyError: (error: unknown) => void,
) {
const auth = injectAuth()
const updatePreview =
ref<Awaited<ReturnType<typeof install_get_shared_instance_update_preview>>>(null)
const updatePreviewLoaded = ref(false)
const unavailableReason = ref<SharedInstanceUnavailableReason | null>(null)
const availabilityCheckKey = ref<string | null>(null)
const availabilityRefresh = ref(0)
let availabilityRequestId = 0
let availabilityRequest: {
key: string
promise: Promise<{
preview: Awaited<ReturnType<typeof install_get_shared_instance_update_preview>>
error: unknown | null
}>
} | null = null
const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null)
const wrongAccount = computed(() => {
if (auth.isReady && !auth.isReady.value) return false
if (!expectedUserId.value) return false
return auth.user.value?.id !== expectedUserId.value
})
const actionsLocked = computed(() => wrongAccount.value)
const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null)
const signedOut = computed(() => !auth.session_token.value)
const managerUserId = computed(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.role === 'owner') {
return actionsLocked.value ? (attachment.linked_user_id ?? null) : null
}
return attachment.manager_id ?? null
})
const managerUserQuery = useUserQuery(managerUserId)
const manager = computed<SharedInstanceManager | null>(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.server_manager_name) {
return {
type: 'server',
name: attachment.server_manager_name,
avatarUrl: attachment.server_manager_icon_url ?? undefined,
tintBy: attachment.server_manager_name,
}
}
const user = managerUserQuery.data.value
if (!user) return null
return {
type: 'user',
name: user.username,
avatarUrl: user.avatar_url ?? undefined,
tintBy: user.id,
}
})
const unavailableManager = computed(() => manager.value?.name ?? null)
function reset() {
availabilityRequestId++
availabilityRequest = null
availabilityCheckKey.value = null
updatePreview.value = null
updatePreviewLoaded.value = false
unavailableReason.value = null
}
function refreshAvailability() {
availabilityCheckKey.value = null
updatePreviewLoaded.value = false
availabilityRefresh.value++
}
function setUnavailable(reason: SharedInstanceUnavailableReason | null) {
availabilityRequestId++
availabilityRequest = null
availabilityCheckKey.value = null
updatePreview.value = null
updatePreviewLoaded.value = false
unavailableReason.value = reason
}
async function checkAvailability(instanceId: string, key: string, throwError = false) {
const requestId = ++availabilityRequestId
let request = availabilityRequest
if (!request || request.key !== key) {
const promise = install_get_shared_instance_update_preview(instanceId).then(
(preview) => ({ preview, error: null }),
(error: unknown) => ({ preview: null, error }),
)
request = { key, promise }
availabilityRequest = request
void promise.finally(() => {
if (availabilityRequest?.promise === promise) availabilityRequest = null
})
}
const result = await request.promise
if (!isCurrentRequest(requestId, instanceId, key)) return null
if (result.error !== null) {
updatePreviewLoaded.value = false
if (isSharedInstanceUnavailableError(result.error)) {
updatePreview.value = null
unavailableReason.value = getSharedInstanceUnavailableReason(result.error)
} else if (!throwError) {
notifyError(result.error)
}
if (throwError) throw result.error
return null
}
updatePreview.value = result.preview
updatePreviewLoaded.value = true
unavailableReason.value = null
return result.preview
}
async function refreshUpdatePreview() {
const instanceId = instance.value?.id
const userId = auth.user.value?.id
if (!instanceId || !userId) return null
const key = `${instanceId}:${userId}`
availabilityCheckKey.value = key
return await checkAvailability(instanceId, key, true)
}
function isCurrentRequest(requestId: number, instanceId: string, key: string) {
return (
requestId === availabilityRequestId &&
instance.value?.id === instanceId &&
availabilityCheckKey.value === key
)
}
watch(
() => ({
refresh: availabilityRefresh.value,
instanceId: instance.value?.id,
role: instance.value?.shared_instance?.role,
locked: actionsLocked.value,
offline: offline.value,
signedIn: !!auth.session_token.value,
userId: auth.user.value?.id ?? null,
authReady: auth.isReady?.value ?? true,
}),
async ({ instanceId, role, locked, offline, signedIn, userId, authReady }) => {
if (!instanceId || !role || locked || offline || !authReady || !signedIn || !userId) {
availabilityRequestId++
availabilityRequest = null
availabilityCheckKey.value = null
updatePreview.value = null
updatePreviewLoaded.value = false
if (instanceId && role) unavailableReason.value = null
return
}
const key = `${instanceId}:${userId}`
if (availabilityCheckKey.value === key) return
availabilityCheckKey.value = key
await checkAvailability(instanceId, key)
},
{ immediate: true },
)
return {
actionsLocked,
shareActionsLocked,
unavailableReason,
unavailableManager,
manager,
updatePreview,
expectedUserId,
wrongAccount,
signedOut,
reset,
refreshAvailability,
refreshUpdatePreview,
setUnavailable,
}
}
export type SharedInstanceState = ReturnType<typeof useSharedInstanceState>
const sharedInstanceStateKey: InjectionKey<SharedInstanceState> = Symbol('shared-instance-state')
export function provideSharedInstanceState(state: SharedInstanceState) {
provide(sharedInstanceStateKey, state)
}
export function injectSharedInstanceState() {
const state = inject(sharedInstanceStateKey)
if (!state) throw new Error('Shared instance state has not been provided.')
return state
}