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
@@ -0,0 +1,2 @@
export { default as InvitePlayersModal } from './invite-players-modal/index.vue'
export * from './invite-players-modal/types'
@@ -0,0 +1,393 @@
<template>
<NewModal
ref="modal"
:header="header"
width="min(34rem, calc(100vw - 2rem))"
max-width="34rem"
no-padding
noblur
>
<div class="flex max-h-[calc(100vh-8rem)] min-h-0 flex-col">
<div class="border-0 border-b border-solid border-surface-5 p-6">
<div class="flex items-start gap-2">
<Combobox
:key="searchInputKey"
:model-value="undefined"
:options="searchOptions"
:search-value="searchTarget"
:search-placeholder="searchPlaceholderLabel"
:placeholder="searchPlaceholderLabel"
:no-options-message="searchLookupMessage"
:min-search-length-to-open="searchMinimumLength"
:disable-search-filter="usesRemoteSearch"
class="min-w-0 flex-1"
searchable
show-search-icon
:show-chevron="false"
search-type="search"
search-name="modrinth-player-invite-search"
search-inputmode="search"
search-autocomplete="new-password"
search-autocorrect="off"
search-autocapitalize="none"
:search-spellcheck="false"
:search-input-attrs="passwordManagerIgnoreAttrs"
@search-input="handleSearchInput"
@select="handleSearchSelect"
>
<template #option="{ item, isSelected }">
<div class="flex min-w-0 items-center gap-2">
<Avatar
:src="findSearchUser(item.value)?.avatarUrl"
:alt="formatMessage(messages.avatarAlt, { username: item.label })"
:tint-by="item.label"
size="1.5rem"
circle
no-shadow
/>
<span
class="min-w-0 truncate font-semibold"
:class="isSelected ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
</template>
</Combobox>
<ButtonStyled color="brand">
<button
v-tooltip="searchInviteTooltip"
class="shrink-0"
:disabled="!canInviteSearchTarget"
@click="inviteSearchTarget"
>
<PlusIcon aria-hidden="true" />
{{ addButtonLabel }}
</button>
</ButtonStyled>
</div>
</div>
<div class="min-h-[11rem] overflow-y-auto bg-surface-2 px-6 py-4">
<div class="mb-2 text-base font-semibold text-primary">
{{ friendsHeading }}
</div>
<div
v-if="friends.length === 0"
class="flex min-h-32 items-center justify-center text-secondary"
>
{{ emptyFriendsLabel }}
</div>
<div v-else class="-mx-6 flex flex-col">
<InvitePlayersModalUserRow
v-for="friend in sortedFriends"
:key="friend.id"
:user="friend"
:avatar-alt="formatMessage(messages.avatarAlt, { username: friend.username })"
:added-label="addedButtonLabel"
:cancel-label="cancelButtonLabel"
:invite-label="inviteButtonLabel"
:requested-label="requestedButtonLabel"
:requested-tooltip="requestedTooltip(friend.username)"
:user-profile-link="userProfileLink"
@invite="inviteFriend"
@cancel="cancelInvite"
/>
</div>
</div>
<div v-if="link" class="border-0 border-t border-solid border-surface-5 p-6">
<div class="flex flex-col gap-2">
<div class="text-base font-semibold text-contrast">
{{ inviteLinkHeading }}
</div>
<ButtonStyled>
<button
type="button"
class="!h-10 w-full !justify-between !px-4 text-left !shadow-none"
@click="copyInviteLink"
>
<span class="min-w-0 truncate text-base font-semibold text-primary">
{{ link }}
</span>
<ClipboardCopyIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
</button>
</ButtonStyled>
<p v-if="link && linkExpiryDescription" class="m-0 text-base text-primary">
{{ linkExpiryDescription }}
<button
v-if="updateInviteLink"
type="button"
class="cursor-pointer border-0 bg-transparent p-0 text-base font-medium text-blue hover:underline"
@click="inviteLinkEditor?.show()"
>
{{ formatMessage(messages.editInviteLink) }}
</button>
</p>
</div>
</div>
</div>
</NewModal>
<InvitePlayersModalInviteLinkEditor
v-if="updateInviteLink"
ref="inviteLinkEditor"
:link-expires-at="linkExpiresAt"
:link-max-uses="linkMaxUses"
:update-invite-link="updateInviteLink"
/>
</template>
<script setup lang="ts">
import { ClipboardCopyIcon, PlusIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectNotificationManager } from '../../../providers'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import Combobox from '../../base/Combobox.vue'
import NewModal from '../../modal/NewModal.vue'
import InvitePlayersModalInviteLinkEditor from './invite-players-modal-invite-link-editor.vue'
import InvitePlayersModalUserRow from './invite-players-modal-user-row.vue'
import type {
InviteLinkSettings,
InvitePlayersInvitePayload,
InvitePlayersSearchUser,
InvitePlayersUser,
InvitePlayersUserProfileLink,
} from './types'
import { useInvitePlayersSearch } from './use-invite-players-search'
const props = withDefaults(
defineProps<{
header?: string
friends?: InvitePlayersUser[]
suggestions?: InvitePlayersSearchUser[]
searchUsers?: (query: string) => Promise<InvitePlayersSearchUser[]>
link?: string
linkExpiresAt?: string | Date | null
linkMaxUses?: number
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
friendsLabel?: string
searchPlaceholder?: string
addLabel?: string
inviteLabel?: string
addedLabel?: string
cancelLabel?: string
requestedLabel?: string
emptyFriendsLabel?: string
canInvite?: boolean
inviteDisabledMessage?: string
userProfileLink?: (username: string) => InvitePlayersUserProfileLink
}>(),
{
header: 'Share instance',
friends: () => [],
suggestions: () => [],
canInvite: true,
linkMaxUses: 10,
},
)
const emit = defineEmits<{
invite: [payload: InvitePlayersInvitePayload]
cancel: [user: InvitePlayersUser]
'copy-link': [link: string]
}>()
const { formatMessage } = useVIntl()
const notificationManager = injectNotificationManager(null)
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const inviteLinkEditor = ref<InstanceType<typeof InvitePlayersModalInviteLinkEditor> | null>(null)
const messages = defineMessages({
friendsHeading: {
id: 'sharing.invite-players-modal.friends-heading',
defaultMessage: 'Your friends - {count}',
},
searchPlaceholder: {
id: 'sharing.invite-players-modal.search-placeholder',
defaultMessage: 'Enter Modrinth username',
},
addButton: {
id: 'sharing.invite-players-modal.add',
defaultMessage: 'Add',
},
inviteButton: {
id: 'sharing.invite-players-modal.invite',
defaultMessage: 'Invite',
},
addedButton: {
id: 'sharing.invite-players-modal.added',
defaultMessage: 'Added',
},
cancelButton: {
id: 'sharing.invite-players-modal.cancel',
defaultMessage: 'Cancel',
},
requestedButton: {
id: 'sharing.invite-players-modal.requested',
defaultMessage: 'Request sent',
},
requestedTooltip: {
id: 'sharing.invite-players-modal.requested-tooltip',
defaultMessage: '{username} needs to accept your friend request first',
},
noFriends: {
id: 'sharing.invite-players-modal.no-friends',
defaultMessage: 'No friends found.',
},
noSearchResults: {
id: 'sharing.invite-players-modal.no-search-results',
defaultMessage: 'No matching users found.',
},
searching: {
id: 'sharing.invite-players-modal.searching',
defaultMessage: 'Searching...',
},
alreadyInvited: {
id: 'sharing.invite-players-modal.already-invited',
defaultMessage: 'This user has already been invited.',
},
inviteLinkHeading: {
id: 'sharing.invite-players-modal.invite-link-heading',
defaultMessage: 'Or use an invite link',
},
inviteExpiryDescription: {
id: 'sharing.invite-players-modal.invite-expiry-description',
defaultMessage: 'Your invite link expires in {duration}.',
},
editInviteLink: {
id: 'sharing.invite-players-modal.edit-invite-link',
defaultMessage: 'Edit invite link.',
},
linkCopiedTitle: {
id: 'sharing.invite-players-modal.link-copied-title',
defaultMessage: 'Link copied',
},
linkCopiedText: {
id: 'sharing.invite-players-modal.link-copied-text',
defaultMessage: 'The invite link has been copied to your clipboard.',
},
linkCopyFailedTitle: {
id: 'sharing.invite-players-modal.link-copy-failed-title',
defaultMessage: 'Failed to copy link',
},
avatarAlt: {
id: 'sharing.invite-players-modal.avatar-alt',
defaultMessage: "{username}'s avatar",
},
})
const friendsHeading = computed(
() =>
props.friendsLabel ??
formatMessage(messages.friendsHeading, {
count: props.friends.length,
}),
)
const searchPlaceholderLabel = computed(
() => props.searchPlaceholder ?? formatMessage(messages.searchPlaceholder),
)
const addButtonLabel = computed(() => props.addLabel ?? formatMessage(messages.addButton))
const inviteButtonLabel = computed(() => props.inviteLabel ?? formatMessage(messages.inviteButton))
const addedButtonLabel = computed(() => props.addedLabel ?? formatMessage(messages.addedButton))
const cancelButtonLabel = computed(() => props.cancelLabel ?? formatMessage(messages.cancelButton))
const requestedButtonLabel = computed(
() => props.requestedLabel ?? formatMessage(messages.requestedButton),
)
const requestedTooltip = (username: string) =>
formatMessage(messages.requestedTooltip, {
username,
})
const emptyFriendsLabel = computed(
() => props.emptyFriendsLabel ?? formatMessage(messages.noFriends),
)
const inviteLinkHeading = computed(() => formatMessage(messages.inviteLinkHeading))
const linkExpiryDescription = computed(() => {
if (!props.linkExpiresAt) return ''
const expiresAt = new Date(props.linkExpiresAt)
if (Number.isNaN(expiresAt.getTime())) return ''
const hours = Math.max(1, Math.ceil((expiresAt.getTime() - Date.now()) / 3_600_000))
const duration =
hours < 48 ? `${hours} ${hours === 1 ? 'hour' : 'hours'}` : `${Math.ceil(hours / 24)} days`
return formatMessage(messages.inviteExpiryDescription, { duration })
})
const inviteDisabledMessage = computed(
() => props.inviteDisabledMessage ?? formatMessage(messages.alreadyInvited),
)
const {
searchTarget,
searchInputKey,
searchOptions,
searchLookupMessage,
searchMinimumLength,
usesRemoteSearch,
passwordManagerIgnoreAttrs,
sortedFriends,
canInviteSearchTarget,
searchInviteTooltip,
findSearchUser,
handleSearchInput,
handleSearchSelect,
inviteSearchTarget,
resetSearch,
} = useInvitePlayersSearch({
friends: () => props.friends,
suggestions: () => props.suggestions,
searchUsers: () => props.searchUsers,
canInvite: () => props.canInvite,
inviteDisabledMessage,
alreadyInvitedMessage: () => formatMessage(messages.alreadyInvited),
searchingMessage: () => formatMessage(messages.searching),
noResultsMessage: () => formatMessage(messages.noSearchResults),
onInvite: (payload) => emit('invite', payload),
})
function inviteFriend(friend: InvitePlayersUser) {
emit('invite', {
user: friend,
source: 'friend',
})
}
function cancelInvite(friend: InvitePlayersUser) {
emit('cancel', friend)
}
async function copyInviteLink() {
if (!props.link) return
emit('copy-link', props.link)
try {
await navigator.clipboard.writeText(props.link)
notificationManager?.addNotification({
type: 'success',
title: formatMessage(messages.linkCopiedTitle),
text: formatMessage(messages.linkCopiedText),
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
notificationManager?.addNotification({
type: 'error',
title: formatMessage(messages.linkCopyFailedTitle),
text: message,
})
}
}
function show(event?: MouseEvent) {
resetSearch()
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>
@@ -0,0 +1,167 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" max-width="30rem">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.expiryLabel) }}</span>
<DatePicker
v-model="expiry"
:disabled="saving"
:min-date="minimumExpiry"
:max-date="maximumExpiry"
date-format="Y-m-d H:i"
alt-format="F j, Y at h:i K"
enable-time
wrapper-class="w-full"
input-class="w-full"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.maxUsesLabel) }}</span>
<StyledInput
v-model="maxUses"
type="number"
:min="1"
:max="2147483647"
:step="1"
:disabled="saving"
/>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button :disabled="saving" @click="modal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancel) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canSave" @click="save">
<SpinnerIcon v-if="saving" class="animate-spin" aria-hidden="true" />
<SaveIcon v-else aria-hidden="true" />
{{ formatMessage(messages.save) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectNotificationManager } from '../../../providers'
import ButtonStyled from '../../base/ButtonStyled.vue'
import DatePicker from '../../base/DatePicker.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
import type { InviteLinkSettings } from './types'
const props = defineProps<{
linkExpiresAt?: string | Date | null
linkMaxUses: number
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
}>()
const { formatMessage } = useVIntl()
const notificationManager = injectNotificationManager(null)
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const expiry = ref('')
const maxUses = ref<number>()
const minimumExpiry = ref(new Date())
const maximumExpiry = ref(new Date())
const saving = ref(false)
const messages = defineMessages({
title: {
id: 'sharing.invite-players-modal.edit-invite-link-title',
defaultMessage: 'Edit invite link',
},
expiryLabel: {
id: 'sharing.invite-players-modal.expiry-label',
defaultMessage: 'Expiry date',
},
maxUsesLabel: {
id: 'sharing.invite-players-modal.max-uses-label',
defaultMessage: 'Maximum uses',
},
cancel: {
id: 'sharing.invite-players-modal.cancel-button',
defaultMessage: 'Cancel',
},
save: {
id: 'sharing.invite-players-modal.save-button',
defaultMessage: 'Save',
},
failed: {
id: 'sharing.invite-players-modal.update-invite-link-failed-title',
defaultMessage: 'Failed to update invite link',
},
})
const canSave = computed(() => {
const date = parseLocalDate(expiry.value)
return (
!saving.value &&
!!date &&
date >= minimumExpiry.value &&
date <= maximumExpiry.value &&
Number.isInteger(maxUses.value ?? 0) &&
(maxUses.value ?? 0) > 0 &&
(maxUses.value ?? 0) <= 2147483647
)
})
function formatLocalDate(date: Date) {
const pad = (value: number) => value.toString().padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function parseLocalDate(value: string) {
const match = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(value)
if (!match) return null
const [, year, month, day, hour, minute] = match
const date = new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute))
return Number.isNaN(date.getTime()) ? null : date
}
function show() {
const now = new Date()
minimumExpiry.value = new Date(now.getTime() + 3_600_000)
minimumExpiry.value.setSeconds(0, 0)
minimumExpiry.value.setMinutes(minimumExpiry.value.getMinutes() + 1)
maximumExpiry.value = new Date(now.getTime() + 7 * 86_400_000)
maximumExpiry.value.setSeconds(0, 0)
const currentExpiry = props.linkExpiresAt ? new Date(props.linkExpiresAt) : maximumExpiry.value
const date =
Number.isNaN(currentExpiry.getTime()) || currentExpiry < minimumExpiry.value
? minimumExpiry.value
: currentExpiry > maximumExpiry.value
? maximumExpiry.value
: currentExpiry
expiry.value = formatLocalDate(date)
maxUses.value = props.linkMaxUses
modal.value?.show()
}
async function save() {
const date = parseLocalDate(expiry.value)
if (!canSave.value || !date || !props.updateInviteLink) return
saving.value = true
try {
await props.updateInviteLink({ expiresAt: date, maxUses: maxUses.value ?? 1 })
modal.value?.hide()
} catch (error) {
notificationManager?.addNotification({
type: 'error',
title: formatMessage(messages.failed),
text: error instanceof Error ? error.message : String(error),
})
} finally {
saving.value = false
}
}
defineExpose({ show })
</script>
@@ -0,0 +1,101 @@
<template>
<div
class="flex h-10 items-center justify-between gap-3 px-6 transition-colors hover:bg-surface-3"
>
<div class="flex min-w-0 items-center gap-1.5">
<AutoLink
v-tooltip="user.username"
:to="profileLink"
:target="profileTarget"
class="inline-flex min-w-0 items-center gap-1.5"
:class="profileLink ? 'text-primary hover:underline' : ''"
>
<span class="relative flex shrink-0">
<Avatar
:src="user.avatarUrl"
:alt="avatarAlt"
:tint-by="user.username"
size="1.5rem"
circle
no-shadow
/>
<span
v-if="user.online"
class="absolute bottom-[1.5px] right-[-1.5px] size-[9px] rounded-full border-[1.5px] border-solid border-surface-2 bg-brand"
/>
</span>
<span class="min-w-0 truncate text-base font-medium">
{{ user.username }}
</span>
</AutoLink>
</div>
<ButtonStyled v-if="status === 'added'" type="standard" color-fill="none">
<button disabled>
<CheckIcon aria-hidden="true" />
{{ addedLabel }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="status === 'pending'" type="outlined">
<button @click="$emit('cancel', user)">
{{ cancelLabel }}
</button>
</ButtonStyled>
<span v-else-if="status === 'requested'" v-tooltip="requestedTooltip" class="inline-flex">
<ButtonStyled type="standard" color-fill="none">
<button disabled>
{{ requestedLabel }}
</button>
</ButtonStyled>
</span>
<ButtonStyled v-else color-fill="none">
<button @click="$emit('invite', user)">
{{ inviteLabel }}
</button>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { CheckIcon } from '@modrinth/assets'
import { computed } from 'vue'
import AutoLink from '../../base/AutoLink.vue'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import type { InvitePlayersUser, InvitePlayersUserProfileLink } from './types'
const props = withDefaults(
defineProps<{
user: InvitePlayersUser
avatarAlt: string
addedLabel: string
cancelLabel: string
inviteLabel: string
requestedLabel: string
requestedTooltip: string
userProfileLink?: (username: string) => InvitePlayersUserProfileLink
}>(),
{
userProfileLink: undefined,
},
)
defineEmits<{
invite: [user: InvitePlayersUser]
cancel: [user: InvitePlayersUser]
}>()
const status = computed(() => props.user.status ?? 'available')
const profileLink = computed(() => getUserProfileLink(props.user.username))
const profileTarget = computed(() =>
typeof profileLink.value === 'string' && profileLink.value.startsWith('http')
? '_blank'
: undefined,
)
function getUserProfileLink(username: string): InvitePlayersUserProfileLink {
if (!username || username.includes('@')) return undefined
return props.userProfileLink?.(username) ?? `/user/${encodeURIComponent(username)}`
}
</script>
@@ -0,0 +1,36 @@
import type { RouteLocationRaw } from 'vue-router'
export type InvitePlayersUserStatus = 'available' | 'requested' | 'pending' | 'added'
export type InvitePlayersUserProfileLink =
| RouteLocationRaw
| (() => void | Promise<void>)
| undefined
export interface InvitePlayersUser {
id: string
username: string
avatarUrl?: string | null
status?: InvitePlayersUserStatus
online?: boolean
}
export interface InvitePlayersSearchUser {
id: string
username: string
avatarUrl?: string | null
email?: string
}
export interface InvitePlayersInvitePayload {
user: InvitePlayersUser
source: 'friend' | 'search'
}
export interface InviteLinkSettings {
expiresAt: Date
maxUses: number
}
export function normalizeInviteKey(value: string) {
return value.trim().toLowerCase()
}
@@ -0,0 +1,226 @@
import { useDebounceFn } from '@vueuse/core'
import { computed, type MaybeRefOrGetter, ref, toValue, watch } from 'vue'
import type { ComboboxOption } from '../../base/Combobox.vue'
import {
type InvitePlayersInvitePayload,
type InvitePlayersSearchUser,
type InvitePlayersUser,
type InvitePlayersUserStatus,
normalizeInviteKey,
} from './types'
export function useInvitePlayersSearch(options: {
friends: MaybeRefOrGetter<InvitePlayersUser[]>
suggestions: MaybeRefOrGetter<InvitePlayersSearchUser[]>
searchUsers: MaybeRefOrGetter<((query: string) => Promise<InvitePlayersSearchUser[]>) | undefined>
canInvite: MaybeRefOrGetter<boolean>
inviteDisabledMessage: MaybeRefOrGetter<string>
alreadyInvitedMessage: MaybeRefOrGetter<string>
searchingMessage: MaybeRefOrGetter<string>
noResultsMessage: MaybeRefOrGetter<string>
onInvite: (payload: InvitePlayersInvitePayload) => void
}) {
const searchTarget = ref('')
const searchInputKey = ref(0)
const selectedSearchUser = ref<InvitePlayersSearchUser | null>(null)
const remoteSearchUsers = ref<InvitePlayersSearchUser[]>([])
const searchLookupStatus = ref<'idle' | 'loading' | 'loaded'>('idle')
const searchLookupRequestId = ref(0)
const friendOrder = ref(new Map<string, number>())
const searchMinimumLength = 1
const passwordManagerIgnoreAttrs = {
'data-1p-ignore': 'true',
'data-bwignore': 'true',
'data-form-type': 'other',
'data-lpignore': 'true',
'data-protonpass-ignore': 'true',
}
const normalizedSearchTarget = computed(() => searchTarget.value.trim())
const usesRemoteSearch = computed(() => !!toValue(options.searchUsers))
const searchLookupMessage = computed(() =>
usesRemoteSearch.value && searchLookupStatus.value !== 'loaded'
? toValue(options.searchingMessage)
: toValue(options.noResultsMessage),
)
const searchableUsers = computed(() => {
const users = new Map<string, InvitePlayersSearchUser>()
for (const user of [...remoteSearchUsers.value, ...toValue(options.suggestions)]) {
users.set(normalizeInviteKey(user.id), user)
users.set(normalizeInviteKey(user.username), user)
if (user.email) users.set(normalizeInviteKey(user.email), user)
}
return [...new Set(users.values())]
})
const searchOptions = computed<ComboboxOption<string>[]>(() =>
searchableUsers.value.map((user) => ({
value: user.username,
label: user.username,
searchTerms: [user.username, user.id, user.email].filter(Boolean) as string[],
})),
)
const sortedFriends = computed(() =>
toValue(options.friends)
.map((friend, index) => ({
friend,
order: friendOrder.value.get(friend.id) ?? friendOrder.value.size + index,
}))
.sort((a, b) => a.order - b.order)
.map(({ friend }) => friend),
)
function findSearchUser(value: string) {
const normalizedValue = normalizeInviteKey(value)
return searchableUsers.value.find(
(user) =>
normalizeInviteKey(user.username) === normalizedValue ||
normalizeInviteKey(user.id) === normalizedValue ||
(!!user.email && normalizeInviteKey(user.email) === normalizedValue),
)
}
const matchedSearchUser = computed(() => {
if (
selectedSearchUser.value &&
normalizeInviteKey(selectedSearchUser.value.username) ===
normalizeInviteKey(normalizedSearchTarget.value)
) {
return selectedSearchUser.value
}
return findSearchUser(normalizedSearchTarget.value)
})
const invitedUserKeys = computed(() => {
const keys = new Set<string>()
for (const friend of toValue(options.friends)) {
if (friendStatus(friend) === 'available') continue
keys.add(normalizeInviteKey(friend.id))
keys.add(normalizeInviteKey(friend.username))
}
return keys
})
const searchTargetAlreadyInvited = computed(() => {
const user = matchedSearchUser.value
if (!user) return false
return (
invitedUserKeys.value.has(normalizeInviteKey(user.id)) ||
invitedUserKeys.value.has(normalizeInviteKey(user.username))
)
})
const canInviteSearchTarget = computed(
() =>
toValue(options.canInvite) &&
normalizedSearchTarget.value.length >= searchMinimumLength &&
!!matchedSearchUser.value &&
!searchTargetAlreadyInvited.value &&
(!usesRemoteSearch.value ||
searchLookupStatus.value === 'loaded' ||
!!selectedSearchUser.value),
)
const searchInviteTooltip = computed(() => {
if (!toValue(options.canInvite)) return toValue(options.inviteDisabledMessage)
if (searchTargetAlreadyInvited.value) return toValue(options.alreadyInvitedMessage)
return undefined
})
const searchTargetUsers = useDebounceFn(async (query: string, requestId: number) => {
const searchUsers = toValue(options.searchUsers)
if (!searchUsers) return
try {
const users = await searchUsers(query)
if (requestId !== searchLookupRequestId.value || query !== normalizedSearchTarget.value)
return
remoteSearchUsers.value = users
} catch {
if (requestId !== searchLookupRequestId.value || query !== normalizedSearchTarget.value)
return
remoteSearchUsers.value = []
} finally {
if (requestId === searchLookupRequestId.value && query === normalizedSearchTarget.value) {
searchLookupStatus.value = 'loaded'
}
}
}, 250)
function friendStatus(friend: InvitePlayersUser): InvitePlayersUserStatus {
return friend.status ?? 'available'
}
function syncFriendOrder(friends: InvitePlayersUser[]) {
const nextOrder = new Map(friendOrder.value)
let nextIndex = nextOrder.size
const unorderedFriends = friends.filter((friend) => !nextOrder.has(friend.id))
if (unorderedFriends.length === 0) return
for (const friend of unorderedFriends) {
nextOrder.set(friend.id, nextIndex)
nextIndex += 1
}
friendOrder.value = nextOrder
}
function handleSearchInput(value: string) {
searchTarget.value = value
selectedSearchUser.value = null
remoteSearchUsers.value = []
searchLookupRequestId.value += 1
if (normalizedSearchTarget.value.length < searchMinimumLength) {
searchLookupStatus.value = 'idle'
return
}
if (!usesRemoteSearch.value) {
searchLookupStatus.value = 'loaded'
return
}
searchLookupStatus.value = 'loading'
void searchTargetUsers(normalizedSearchTarget.value, searchLookupRequestId.value)
}
function handleSearchSelect(option: ComboboxOption<string>) {
searchTarget.value = option.value
selectedSearchUser.value = findSearchUser(option.value) ?? null
searchLookupStatus.value = 'loaded'
}
function resetSearch() {
searchTarget.value = ''
searchInputKey.value += 1
selectedSearchUser.value = null
remoteSearchUsers.value = []
searchLookupStatus.value = 'idle'
searchLookupRequestId.value += 1
}
function inviteSearchTarget() {
if (!canInviteSearchTarget.value) return
const user = matchedSearchUser.value
if (!user) return
options.onInvite({
user: {
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
},
source: 'search',
})
resetSearch()
}
watch(() => toValue(options.friends), syncFriendOrder, { immediate: true })
return {
searchTarget,
searchInputKey,
searchOptions,
searchLookupMessage,
searchMinimumLength,
usesRemoteSearch,
passwordManagerIgnoreAttrs,
sortedFriends,
canInviteSearchTarget,
searchInviteTooltip,
findSearchUser,
handleSearchInput,
handleSearchSelect,
inviteSearchTarget,
resetSearch,
}
}