diff --git a/Cargo.lock b/Cargo.lock index 726ebeeed2..b3eefef689 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4806,7 +4806,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -8016,7 +8016,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.32", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.17", "tokio", "tracing", @@ -8053,7 +8053,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", "windows-sys 0.60.2", ] @@ -11020,6 +11020,7 @@ dependencies = [ "heck 0.5.0", "hickory-resolver 0.25.2", "httpdate", + "image", "indicatif", "itertools 0.14.0", "modrinth-content-management", diff --git a/apps/app-frontend/src/components/ui/instance-page-header/index.vue b/apps/app-frontend/src/components/ui/instance-page-header/index.vue index 1cb84df1ba..59eea07309 100644 --- a/apps/app-frontend/src/components/ui/instance-page-header/index.vue +++ b/apps/app-frontend/src/components/ui/instance-page-header/index.vue @@ -15,12 +15,12 @@ Shared + diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue b/apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue index 4d73ef6886..e16171bc5b 100644 --- a/apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue +++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue @@ -6,6 +6,11 @@ :instance="instance" @published="emit('published')" /> + () const emit = defineEmits<{ published: [] delete: [] + 'review-update': [event: MouseEvent] }>() const sharedInstanceWrongAccount = computed(() => props.sharedInstanceWrongAccount ?? false) @@ -68,6 +76,14 @@ const showSharedInstancePublishAdmonition = computed( props.instance.shared_instance?.role === 'owner' && props.instance.shared_instance.status === 'stale', ) +const showSharedInstanceUpdateAdmonition = computed( + () => + !sharedInstanceWrongAccount.value && + !displayedSharedInstanceUnavailableReason.value && + props.instance.install_stage === 'installed' && + props.sharedInstanceRole === 'member' && + props.sharedInstanceUpdateAvailable === true, +) const stackItems = computed(() => { const items: InstanceAdmonitionItem[] = [] @@ -104,6 +120,15 @@ const stackItems = computed(() => { }) } + if (showSharedInstanceUpdateAdmonition.value) { + items.push({ + id: 'shared-instance-update-available', + type: 'info', + dismissible: false, + kind: 'shared-instance-update-available', + }) + } + return items }) diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-messages.ts b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-messages.ts index 5191ae9b26..011a57acaf 100644 --- a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-messages.ts +++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-messages.ts @@ -21,6 +21,19 @@ export const instanceAdmonitionsMessages = defineMessages({ id: 'app.instance.admonitions.shared-instance.reviewing-button', defaultMessage: 'Reviewing...', }, + sharedInstanceUpdateAvailableHeader: { + id: 'app.instance.admonitions.shared-instance.update-available-header', + defaultMessage: 'An update is available', + }, + sharedInstanceUpdateAvailableBody: { + id: 'app.instance.admonitions.shared-instance.update-available-body', + defaultMessage: + 'An update is required to play {name}. Please update to latest version to launch the game.', + }, + sharedInstanceReviewUpdateButton: { + id: 'app.instance.admonitions.shared-instance.review-update-button', + defaultMessage: 'Review update', + }, sharedInstanceReviewHeader: { id: 'app.instance.admonitions.shared-instance.review-header', defaultMessage: 'Review changes', diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-update-available.vue b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-update-available.vue new file mode 100644 index 0000000000..ffb96f4329 --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-update-available.vue @@ -0,0 +1,34 @@ + + + diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/types.ts b/apps/app-frontend/src/components/ui/instance/instance-admonitions/types.ts index ea7eb81be1..02948b3e8d 100644 --- a/apps/app-frontend/src/components/ui/instance/instance-admonitions/types.ts +++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/types.ts @@ -2,6 +2,7 @@ import type { StackedAdmonitionItem } from '@modrinth/ui' export type InstanceAdmonitionKind = | 'shared-instance-stale' + | 'shared-instance-update-available' | 'shared-instance-unavailable' | 'shared-instance-wrong-account' diff --git a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue index 3c84d90668..8568ebefa6 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue @@ -109,9 +109,13 @@ watch(selectedReleaseChannel, async (channel, previousChannel) => { }) async function resetIcon() { - icon.value = undefined - await edit_icon(instance.value.id, null).catch(handleError) - trackEvent('InstanceRemoveIcon') + try { + await edit_icon(instance.value.id, null) + icon.value = undefined + trackEvent('InstanceRemoveIcon') + } catch (error) { + handleError(error) + } } async function setIcon() { @@ -127,10 +131,13 @@ async function setIcon() { if (!value) return - icon.value = value - await edit_icon(instance.value.id, icon.value).catch(handleError) - - trackEvent('InstanceSetIcon') + try { + await edit_icon(instance.value.id, value) + icon.value = value + trackEvent('InstanceSetIcon') + } catch (error) { + handleError(error) + } } const editInstanceObject = computed(() => ({ diff --git a/apps/app-frontend/src/components/ui/instance_settings/SharingSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/SharingSettings.vue index aef391eea7..0886c82e3c 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/SharingSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/SharingSettings.vue @@ -1,31 +1,156 @@ diff --git a/apps/app-frontend/src/components/ui/shared-instances/ConfirmRevokeSharedInstanceInviteModal.vue b/apps/app-frontend/src/components/ui/shared-instances/ConfirmRevokeSharedInstanceInviteModal.vue new file mode 100644 index 0000000000..e373ce7b93 --- /dev/null +++ b/apps/app-frontend/src/components/ui/shared-instances/ConfirmRevokeSharedInstanceInviteModal.vue @@ -0,0 +1,82 @@ + + + diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue index f9c531fad1..342d900d56 100644 --- a/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue +++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue @@ -43,8 +43,9 @@ import type { GameInstance } from '@/helpers/types' type UpdateCompleteCallback = () => void | Promise const emit = defineEmits<{ + accepted: [] cancel: [] - complete: [] + complete: [successful: boolean] report: [event?: MouseEvent] sharedInstanceUnavailable: [reason: SharedInstanceUnavailableReason | null] }>() @@ -72,11 +73,14 @@ const diffs = computed( ) async function update() { + let successful = false + emit('accepted') try { if (instance.value) { const job = await install_update_shared_instance(instance.value.id) await wait_for_install_job(job.job_id) await onComplete.value() + successful = true } } catch (error) { if (isSharedInstanceUnavailableError(error)) { @@ -85,7 +89,7 @@ async function update() { } notifySharedInstanceError(error) } finally { - emit('complete') + emit('complete', successful) } } diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue index e4ebfd949f..19a498942b 100644 --- a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue @@ -64,7 +64,29 @@

- {{ formatMessage(messages.inviteWarning) }} + + + +

>() const contentModal = ref>() const externalFileTable = ref(null) const preview = ref(null) +const creator = ref(null) const install = ref<() => void | Promise>(() => {}) const reportMode = ref(false) const reportOnly = ref(false) @@ -302,6 +332,12 @@ const reportReasonOptions = computed[]>(() => [ const canSubmitReport = computed( () => Boolean(preview.value && additionalContext.value.trim()) && !submitLoading.value, ) +const creatorProfileLink = computed(() => { + const username = creator.value?.username + return username + ? () => openUrl(`${config.siteUrl}/user/${encodeURIComponent(username)}`) + : undefined +}) async function accept() { hide() @@ -381,6 +417,7 @@ function handleCancel() { } function handleHide() { resetReportState() + creator.value = null show_ads_window() } function resetReportState() { @@ -395,14 +432,17 @@ function resetReportState() { function show( previewValue: SharedInstanceInstallPreview, installValue: () => void | Promise, + creatorValue?: SharedInstanceCreator, event?: MouseEvent, ) { resetReportState() + creator.value = creatorValue ?? null install.value = installValue showPreview(previewValue, event) } function showReport(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) { resetReportState() + creator.value = null reportMode.value = true reportOnly.value = true install.value = () => {} @@ -437,6 +477,11 @@ const messages = defineMessages({ defaultMessage: 'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.', }, + inviteWarningWithCreator: { + id: 'app.modal.install-to-play.invite-warning-with-creator', + defaultMessage: + 'This invite was created by {username}, not Modrinth. Only accept invites from people you trust.', + }, reportDescription: { id: 'app.modal.install-to-play.report-description', defaultMessage: diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts index 671b4b1899..3646f87592 100644 --- a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts @@ -1,7 +1,7 @@ import type { Labrinth } from '@modrinth/api-client' import type { ContentItem } from '@modrinth/ui' -import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js' +import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js' import type { SharedInstanceInstallPreview } from '@/helpers/install' type VersionDependency = Labrinth.Versions.v2.Dependency & { version_id?: string } @@ -20,7 +20,18 @@ export function useSharedInstancePreviewContent() { async function modpackContentItems(preview: SharedInstanceInstallPreview) { if (!preview.modpackVersionId) return [] const version = await get_version(preview.modpackVersionId, 'must_revalidate') - return await contentItemsFromDependencies(version?.dependencies ?? []) + if (!version) return [] + + const [project, contentItems] = await Promise.all([ + get_project(version.project_id, 'must_revalidate'), + contentItemsFromDependencies(version.dependencies ?? []), + ]) + if (!project) return contentItems + + return contentItems.map((item) => ({ + ...item, + source: { project }, + })) } async function contentItemsFromDependencies(dependencies: Labrinth.Versions.v2.Dependency[]) { diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts index c71fe78f27..97d197dbcc 100644 --- a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts @@ -17,6 +17,7 @@ import { install_shared_instance, } from '@/helpers/install' import { list } from '@/helpers/instance' +import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors' import { useTheming } from '@/store/state' import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser' @@ -26,9 +27,15 @@ type InstallModal = { show( preview: Awaited>, install: () => Promise, + creator?: SharedInstanceCreator, ): void } +type SharedInstanceCreator = { + username: string + avatarUrl: string | null +} + type AccountRequiredModal = { show(event?: MouseEvent): Promise } @@ -45,6 +52,8 @@ export function useSharedInstanceInviteHandler( const auth = injectAuth() const client = injectModrinthClient() const { handleError } = injectNotificationManager() + const { notifySharedInstanceConnectionError, notifySharedInstanceError } = + useSharedInstanceErrors() const popupNotificationManager = injectPopupNotificationManager() const queryClient = useQueryClient() const router = useRouter() @@ -58,6 +67,7 @@ export function useSharedInstanceInviteHandler( instanceId: string preview: Awaited> install: () => Promise + creator?: SharedInstanceCreator onGoToInstance?: () => void | Promise } | undefined @@ -73,10 +83,13 @@ export function useSharedInstanceInviteHandler( async function resolveInvite(invite: SharedInstanceInvite) { const [invitedBy, sharedInstance] = await Promise.all([ - !invite.invitedByUsername && invite.invitedById + (!invite.invitedByUsername || !invite.invitedByAvatarUrl) && invite.invitedById ? get_user(invite.invitedById, 'bypass').catch(() => null) : null, - client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => null), + client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => { + notifySharedInstanceConnectionError() + return null + }), ]) return { @@ -90,15 +103,17 @@ export function useSharedInstanceInviteHandler( function showInstall( preview: Awaited>, install: () => Promise, + creator?: SharedInstanceCreator, ) { if (!installModal.value) throw new Error('Shared instance install modal is not available.') - installModal.value.show(preview, install) + installModal.value.show(preview, install, creator) } async function showInstallOrAlreadyInstalled( sharedInstanceId: string, preview: Awaited>, install: () => Promise, + creator?: SharedInstanceCreator, onGoToInstance?: () => void | Promise, ) { const existingInstance = (await list()).find( @@ -106,7 +121,7 @@ export function useSharedInstanceInviteHandler( ) if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) { - showInstall(preview, install) + showInstall(preview, install, creator) return } @@ -118,6 +133,7 @@ export function useSharedInstanceInviteHandler( instanceId: existingInstance.id, preview, install, + creator, onGoToInstance, } alreadyInstalledModal.value.show(existingInstance.name) @@ -146,7 +162,7 @@ export function useSharedInstanceInviteHandler( const pending = pendingAlreadyInstalled pendingAlreadyInstalled = undefined if (!pending) return - showInstall(pending.preview, pending.install) + showInstall(pending.preview, pending.install, pending.creator) } async function acceptNotification(notification: AppNotification, invite: SharedInstanceInvite) { @@ -172,10 +188,16 @@ export function useSharedInstanceInviteHandler( await markNotificationRead(notification) await queryClient.invalidateQueries({ queryKey: ['instances'] }) }, + invite.invitedByUsername + ? { + username: invite.invitedByUsername, + avatarUrl: invite.invitedByAvatarUrl, + } + : undefined, () => markNotificationRead(notification), ) } catch (error) { - handleError(toError(error)) + notifySharedInstanceError(error) } } @@ -253,19 +275,32 @@ export function useSharedInstanceInviteHandler( try { if (!(await requireAccount())) return const invite = await install_accept_shared_instance_invite(inviteId) - await showInstallOrAlreadyInstalled(invite.sharedInstanceId, invite.preview, async () => { - await install_shared_instance( - invite.sharedInstanceId, - invite.preview.name, - invite.managerId, - invite.serverManagerName, - invite.serverManagerIconUrl, - invite.instanceIconUrl, - ) - await queryClient.invalidateQueries({ queryKey: ['instances'] }) - }) + const manager = invite.managerId + ? await get_user(invite.managerId, 'bypass').catch(() => null) + : null + await showInstallOrAlreadyInstalled( + invite.sharedInstanceId, + invite.preview, + async () => { + await install_shared_instance( + invite.sharedInstanceId, + invite.preview.name, + invite.managerId, + invite.serverManagerName, + invite.serverManagerIconUrl, + invite.instanceIconUrl, + ) + await queryClient.invalidateQueries({ queryKey: ['instances'] }) + }, + manager + ? { + username: manager.username, + avatarUrl: manager.avatar_url ?? null, + } + : undefined, + ) } catch (error) { - handleError(toError(error)) + notifySharedInstanceError(error) } } diff --git a/apps/app-frontend/src/helpers/install.ts b/apps/app-frontend/src/helpers/install.ts index 69d608fd7e..6c92aaa897 100644 --- a/apps/app-frontend/src/helpers/install.ts +++ b/apps/app-frontend/src/helpers/install.ts @@ -102,6 +102,7 @@ export interface SharedInstanceUpdateDiff { } export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable' +export const SHARED_INSTANCES_API_ERROR_CODE = 'shared_instances_api_error' export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked' | 'quarantined' @@ -113,6 +114,10 @@ export function isSharedInstanceUnavailableError(error: unknown) { return getSharedInstanceUnavailableReason(error) !== null } +export function isSharedInstancesApiError(error: unknown) { + return isRecord(error) && error.code === SHARED_INSTANCES_API_ERROR_CODE +} + export function getSharedInstanceUnavailableReason( error: unknown, ): SharedInstanceUnavailableReason | null { diff --git a/apps/app-frontend/src/helpers/instance.ts b/apps/app-frontend/src/helpers/instance.ts index caf54aa46b..512af7cf40 100644 --- a/apps/app-frontend/src/helpers/instance.ts +++ b/apps/app-frontend/src/helpers/instance.ts @@ -365,6 +365,13 @@ export interface SharedInstanceInviteLink { maxUses: number } +export interface SharedInstanceInvite { + id: string + expiration: string + maxUses: number + uses: number +} + export async function can_current_user_use_shared_instances(): Promise { return await invoke('plugin:instance|instance_share_can_current_user_use') } @@ -394,6 +401,19 @@ export async function create_shared_instance_invite_link( }) } +export async function get_shared_instance_invites( + instanceId: string, +): Promise { + return await invoke('plugin:instance|instance_share_get_invites', { instanceId }) +} + +export async function revoke_shared_instance_invite( + instanceId: string, + inviteId: string, +): Promise { + return await invoke('plugin:instance|instance_share_revoke_invite', { instanceId, inviteId }) +} + export async function remove_shared_instance_users( instanceId: string, userIds: string[], diff --git a/apps/app-frontend/src/helpers/shared-instance-errors.ts b/apps/app-frontend/src/helpers/shared-instance-errors.ts index e1e92efcae..b0ce5d0f74 100644 --- a/apps/app-frontend/src/helpers/shared-instance-errors.ts +++ b/apps/app-frontend/src/helpers/shared-instance-errors.ts @@ -1,6 +1,10 @@ import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui' -import { getErrorMessage, type SharedInstanceUnavailableReason } from '@/helpers/install' +import { + getErrorMessage, + isSharedInstancesApiError, + type SharedInstanceUnavailableReason, +} from '@/helpers/install' export const sharedInstanceErrorMessages = defineMessages({ unavailableTitle: { @@ -39,6 +43,14 @@ export const sharedInstanceErrorMessages = defineMessages({ id: 'instance.shared-instance.error.title', defaultMessage: 'Something has gone wrong', }, + networkErrorTitle: { + id: 'instance.shared-instance.network-error.title', + defaultMessage: 'Network error', + }, + networkErrorText: { + id: 'instance.shared-instance.network-error.text', + defaultMessage: 'Unable to connect to shared instances API', + }, }) export function sharedInstanceUnavailableTextMessage( @@ -82,7 +94,20 @@ export function useSharedInstanceErrors() { }) } + function notifySharedInstanceConnectionError() { + addNotification({ + type: 'error', + title: formatMessage(sharedInstanceErrorMessages.networkErrorTitle), + text: formatMessage(sharedInstanceErrorMessages.networkErrorText), + }) + } + function notifySharedInstanceError(error: unknown) { + if (isSharedInstancesApiError(error)) { + notifySharedInstanceConnectionError() + return + } + addNotification({ type: 'error', title: formatMessage(sharedInstanceErrorMessages.errorTitle), @@ -92,6 +117,7 @@ export function useSharedInstanceErrors() { return { formatSharedInstanceUnavailable, + notifySharedInstanceConnectionError, notifySharedInstanceError, notifySharedInstanceUnavailable, } diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index cb4965cb85..ddd4105833 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -368,9 +368,18 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Review changes" }, + "app.instance.admonitions.shared-instance.review-update-button": { + "message": "Review update" + }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Reviewing..." }, + "app.instance.admonitions.shared-instance.update-available-body": { + "message": "An update is required to play {name}. Please update to latest version to launch the game." + }, + "app.instance.admonitions.shared-instance.update-available-header": { + "message": "An update is available" + }, "app.instance.confirm-delete.admonition-body": { "message": "All data for your instance will be permanently deleted, including your worlds, configs, and all installed content." }, @@ -437,6 +446,9 @@ "app.instance.share.invite-modal.heading": { "message": "Share {name}" }, + "app.instance.share.invite-modal.user-limit-reached": { + "message": "This instance has reached the {limit}-user limit." + }, "app.instance.share.locked.empty-description-prefix": { "message": "You need to sign in as" }, @@ -455,6 +467,12 @@ "app.instance.share.locked.wrong-account-heading": { "message": "Wrong account" }, + "app.instance.share.members.empty": { + "message": "No users have joined yet" + }, + "app.instance.share.members.no-filter-results": { + "message": "No users match your filters." + }, "app.instance.share.remove-user-modal.effect-access": { "message": "They will no longer receive updates for this shared instance" }, @@ -485,6 +503,12 @@ "app.instance.share.sign-in.button": { "message": "Sign in" }, + "app.instance.share.unable-to-connect.description": { + "message": "The shared instances service is not accessible at the moment, please try again later" + }, + "app.instance.share.unable-to-connect.heading": { + "message": "Unable to connect" + }, "app.instance.share.unlink.body": { "message": "You must unlink this modpack to share your instance" }, @@ -599,6 +623,9 @@ "app.modal.install-to-play.invite-warning": { "message": "This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust." }, + "app.modal.install-to-play.invite-warning-with-creator": { + "message": "This invite was created by {username}, not Modrinth. Only accept invites from people you trust." + }, "app.modal.install-to-play.mod-count": { "message": "{count, plural, one {# mod} other {# mods}}" }, @@ -1172,6 +1199,45 @@ "instance.server-modal.resource-pack": { "message": "Resource pack" }, + "instance.settings.sharing.active-invites.actions": { + "message": "Actions" + }, + "instance.settings.sharing.active-invites.code": { + "message": "Invite link" + }, + "instance.settings.sharing.active-invites.description": { + "message": "Anyone with one of these invite links can join while it remains active." + }, + "instance.settings.sharing.active-invites.empty": { + "message": "There are no active invites." + }, + "instance.settings.sharing.active-invites.expires": { + "message": "Expires" + }, + "instance.settings.sharing.active-invites.revoke": { + "message": "Revoke invite" + }, + "instance.settings.sharing.active-invites.revoke-with-code": { + "message": "Revoke invite {code}" + }, + "instance.settings.sharing.active-invites.title": { + "message": "Active invites" + }, + "instance.settings.sharing.active-invites.uses": { + "message": "Uses" + }, + "instance.settings.sharing.revoke-invite.admonition-body": { + "message": "The invite link {code} will stop working immediately. People who already joined will keep access." + }, + "instance.settings.sharing.revoke-invite.admonition-header": { + "message": "This action cannot be undone" + }, + "instance.settings.sharing.revoke-invite.confirm": { + "message": "Revoke invite" + }, + "instance.settings.sharing.revoke-invite.header": { + "message": "Revoke invite" + }, "instance.settings.tabs.general": { "message": "General" }, @@ -1373,6 +1439,12 @@ "instance.shared-instance.error.title": { "message": "Something has gone wrong" }, + "instance.shared-instance.network-error.text": { + "message": "Unable to connect to shared instances API" + }, + "instance.shared-instance.network-error.title": { + "message": "Network error" + }, "instance.shared-instance.owner-tooltip": { "message": "This instance's content is being shared to other users." }, diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue index 8d937f2f90..9d3f10200d 100644 --- a/apps/app-frontend/src/pages/instance/Index.vue +++ b/apps/app-frontend/src/pages/instance/Index.vue @@ -16,6 +16,8 @@ @@ -62,8 +64,10 @@ :shared-instance-expected-user-id="sharedInstanceExpectedUserId" :shared-instance-role="instance.shared_instance?.role" :shared-instance-signed-out="sharedInstanceSignedOut" + :shared-instance-update-available="showSharedInstanceUpdateAdmonition" @published="fetchInstance" @delete="requestInstanceDeletion" + @review-update="reviewSharedInstanceUpdate" />
@@ -223,6 +227,7 @@ const sharedInstanceUpdateModal = ref>() const deleteConfirmModal = ref>() const selectedInstanceToDelete = ref(null) +const hiddenSharedInstanceUpdateKey = ref(null) const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors() @@ -253,8 +258,19 @@ const { signedOut: sharedInstanceSignedOut, unavailableManager: sharedInstanceUnavailableManager, unavailableReason: sharedInstanceUnavailableReason, + updatePreview: sharedInstanceUpdatePreview, wrongAccount: sharedInstanceWrongAccount, } = sharedInstanceState +const sharedInstanceUpdateKey = computed(() => { + const instanceId = instance.value?.id + const latestVersion = sharedInstanceUpdatePreview.value?.latestVersion + return instanceId && latestVersion !== undefined ? `${instanceId}:${latestVersion}` : null +}) +const showSharedInstanceUpdateAdmonition = computed( + () => + sharedInstanceUpdatePreview.value?.updateAvailable === true && + sharedInstanceUpdateKey.value !== hiddenSharedInstanceUpdateKey.value, +) watch( () => router.currentRoute.value, @@ -530,6 +546,37 @@ async function handleSharedInstanceUnavailable( setSharedInstanceUnavailable(reason) } +function reviewSharedInstanceUpdate(event: MouseEvent) { + const currentInstance = instance.value + const preview = sharedInstanceUpdatePreview.value + if ( + !currentInstance || + currentInstance.shared_instance?.role !== 'member' || + !preview?.updateAvailable + ) { + return + } + + sharedInstanceUpdateModal.value?.show( + currentInstance, + preview, + async () => { + await fetchInstance() + }, + event, + ) +} + +function hideAcceptedSharedInstanceUpdate() { + hiddenSharedInstanceUpdateKey.value = sharedInstanceUpdateKey.value +} + +function handleSharedInstanceUpdateComplete(successful: boolean) { + if (!successful && hiddenSharedInstanceUpdateKey.value === sharedInstanceUpdateKey.value) { + hiddenSharedInstanceUpdateKey.value = null + } +} + const startInstance = async (context: string) => { if (!instance.value || instance.value.quarantined) return if (checkingSharedInstanceLaunch.value || loading.value || playing.value) return @@ -540,17 +587,16 @@ const startInstance = async (context: string) => { !!instance.value.shared_instance && !sharedInstanceActionsLocked.value && !offline.value if (canCheckSharedInstanceUpdate) { - let preview: Awaited> + let preview: Awaited> = null checkingSharedInstanceLaunch.value = true try { preview = await refreshSharedInstanceUpdatePreview() } catch (error) { if (isSharedInstanceUnavailableError(error)) { await handleSharedInstanceUnavailable(getSharedInstanceUnavailableReason(error)) - } else { - notifySharedInstanceError(error) + return } - return + notifySharedInstanceError(error) } finally { checkingSharedInstanceLaunch.value = false } @@ -657,7 +703,7 @@ async function reportSharedInstance(event?: MouseEvent, closeUpdateModal = false if (closeUpdateModal) sharedInstanceUpdateModal.value?.hide() sharedInstanceReportModal.value?.showReport(preview, event) } catch (error) { - handleError(error as Error) + notifySharedInstanceError(error) } } diff --git a/apps/app-frontend/src/pages/instance/share/index.vue b/apps/app-frontend/src/pages/instance/share/index.vue index 10ba2b04e0..b6addbe3bf 100644 --- a/apps/app-frontend/src/pages/instance/share/index.vue +++ b/apps/app-frontend/src/pages/instance/share/index.vue @@ -9,9 +9,19 @@ :link="inviteLink.link.value" :link-expires-at="inviteLink.details.value?.expiresAt" :link-max-uses="inviteLink.details.value?.maxUses" + :link-max-uses-limit="remainingUserSlots" :update-invite-link="inviteLink.update" :user-profile-link="userProfileLink" - :can-invite="!members.exclusiveMutationPending.value && !inviteLink.pending.value" + :can-invite=" + hasRemainingUserSlots && + !members.exclusiveMutationPending.value && + !inviteLink.pending.value + " + :invite-disabled-message=" + hasRemainingUserSlots + ? undefined + : formatMessage(messages.userLimitReached, { limit: SHARED_INSTANCE_USER_LIMIT }) + " @invite="invitePlayer" @cancel="cancelInvite" /> @@ -37,10 +47,19 @@ @state-change="publishState = $event" /> + + + -
+
-
+ +
- Shared instance + {{ report.shared_instance?.name ?? 'Shared instance' }} Version {{ report.shared_instance_version_id ?? 'unknown' }} · @@ -89,8 +96,7 @@ diff --git a/apps/frontend/src/components/ui/report/ReportView.vue b/apps/frontend/src/components/ui/report/ReportView.vue index 7989da545c..2ed7ef236d 100644 --- a/apps/frontend/src/components/ui/report/ReportView.vue +++ b/apps/frontend/src/components/ui/report/ReportView.vue @@ -1,5 +1,5 @@ @@ -51,7 +51,7 @@ const MAX_REPORTS = 1500 const { data: rawReportsData } = useQuery({ queryKey: ['reports', MAX_REPORTS], - queryFn: () => client.labrinth.reports_v3.list({ count: MAX_REPORTS }), + queryFn: () => client.labrinth.reports_v3.list({ count: MAX_REPORTS, all: false }), placeholderData: [], }) @@ -67,6 +67,13 @@ const versionReports = computed(() => const versionIds = computed(() => [ ...new Set(versionReports.value.map((report) => report.item_id)), ]) +const sharedInstanceIds = computed(() => [ + ...new Set( + rawReports.value + .filter((report) => report.item_type === 'shared-instance') + .map((report) => report.item_id), + ), +]) const userIds = computed(() => [...new Set(reporterUsers.value.concat(reportedUsers.value))]) const threadIds = computed(() => [ ...new Set( @@ -94,6 +101,21 @@ const { data: versions } = useQuery({ placeholderData: [], }) +const { data: sharedInstances } = useQuery({ + queryKey: computed(() => ['shared-instances', sharedInstanceIds.value]), + queryFn: async () => { + const results = await Promise.allSettled( + sharedInstanceIds.value.map(async (id) => ({ + id, + instance: await client.sharedinstances.instances_v1.get(id), + })), + ) + return results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])) + }, + enabled: computed(() => sharedInstanceIds.value.length > 0), + placeholderData: [], +}) + const { data: threads } = useQuery({ queryKey: computed(() => ['threads', threadIds.value]), queryFn: () => @@ -123,6 +145,9 @@ const { data: projects } = useQuery({ const userMap = computed(() => new Map(users.value.map((u) => [u.id, u]))) const versionMap = computed(() => new Map(versions.value.map((v) => [v.id, v]))) const projectMap = computed(() => new Map(projects.value.map((p) => [p.id, p]))) +const sharedInstanceMap = computed( + () => new Map(sharedInstances.value.map(({ id, instance }) => [id, instance])), +) const threadMap = computed(() => new Map(threads.value.map((t) => [t.id, t]))) const reports = computed(() => @@ -136,6 +161,8 @@ const reports = computed(() => } else if (report.item_type === 'version') { enrichedReport.version = versionMap.value.get(report.item_id) enrichedReport.project = projectMap.value.get(enrichedReport.version?.project_id) + } else if (report.item_type === 'shared-instance') { + enrichedReport.shared_instance = sharedInstanceMap.value.get(report.item_id) } if (report.thread_id) { const thread = threadMap.value.get(report.thread_id) @@ -149,7 +176,7 @@ const reports = computed(() => const filteredReports = computed(() => reports.value?.filter( (x) => - (props.moderation || x.reporterUser?.id === props.auth.user.id) && + (props.moderation || x.reporter === props.auth.user.id) && (viewMode.value === 'open' ? x.open : !x.open) && (reasonFilter.value === 'All' || reasonFilter.value === x.report_type), ), diff --git a/apps/frontend/src/pages/dashboard/reports.vue b/apps/frontend/src/pages/dashboard/reports.vue index e538b5e480..960f7c7cb1 100644 --- a/apps/frontend/src/pages/dashboard/reports.vue +++ b/apps/frontend/src/pages/dashboard/reports.vue @@ -1,9 +1,9 @@ diff --git a/packages/ui/src/components/base/StyledInput.vue b/packages/ui/src/components/base/StyledInput.vue index ad0573b57a..31a9ca57d5 100644 --- a/packages/ui/src/components/base/StyledInput.vue +++ b/packages/ui/src/components/base/StyledInput.vue @@ -142,6 +142,7 @@ const props = withDefaults( min?: number max?: number step?: number + clamp?: boolean disabled?: boolean readonly?: boolean error?: boolean @@ -159,6 +160,7 @@ const props = withDefaults( type: 'text', size: 'standard', variant: 'filled', + clamp: false, disabled: false, readonly: false, error: false, @@ -189,12 +191,22 @@ defineExpose({ function onInput(event: Event) { const target = event.target as HTMLInputElement | HTMLTextAreaElement - model.value = - props.type === 'number' && !props.multiline - ? target.value === '' - ? undefined - : Number(target.value) - : target.value + if (props.type !== 'number' || props.multiline) { + model.value = target.value + return + } + if (target.value === '') { + model.value = undefined + return + } + + let value = Number(target.value) + if (props.clamp) { + if (props.min !== undefined) value = Math.max(props.min, value) + if (props.max !== undefined) value = Math.min(props.max, value) + target.value = String(value) + } + model.value = value } function clear() { diff --git a/packages/ui/src/components/sharing/invite-players-modal/index.vue b/packages/ui/src/components/sharing/invite-players-modal/index.vue index 038c7e8e8f..0499ee1af7 100644 --- a/packages/ui/src/components/sharing/invite-players-modal/index.vue +++ b/packages/ui/src/components/sharing/invite-players-modal/index.vue @@ -135,6 +135,7 @@ ref="inviteLinkEditor" :link-expires-at="linkExpiresAt" :link-max-uses="linkMaxUses" + :link-max-uses-limit="linkMaxUsesLimit" :update-invite-link="updateInviteLink" /> @@ -169,6 +170,7 @@ const props = withDefaults( link?: string linkExpiresAt?: string | Date | null linkMaxUses?: number + linkMaxUsesLimit?: number updateInviteLink?: (settings: InviteLinkSettings) => Promise friendsLabel?: string searchPlaceholder?: string @@ -188,6 +190,7 @@ const props = withDefaults( suggestions: () => [], canInvite: true, linkMaxUses: 10, + linkMaxUsesLimit: 2147483647, }, ) diff --git a/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-invite-link-editor.vue b/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-invite-link-editor.vue index 60264bc2d1..433b42085e 100644 --- a/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-invite-link-editor.vue +++ b/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-invite-link-editor.vue @@ -1,19 +1,62 @@