diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 1f39661978..017332df67 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -94,7 +94,12 @@ import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics' import { check_reachable } from '@/helpers/auth.js' import { get_user, get_version } from '@/helpers/cache.js' import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js' -import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install' +import { + install_create_modpack_instance, + install_get_shared_instance_preview, + install_get_modpack_preview, + install_shared_instance, +} from '@/helpers/install' import { list, run } from '@/helpers/instance' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts' import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts' @@ -251,6 +256,7 @@ const { const news = ref([]) const availableSurvey = ref(false) const displayedServerInviteNotifications = new Set() +const displayedSharedInstanceInviteNotifications = new Set() const offline = ref(!navigator.onLine) window.addEventListener('offline', () => { @@ -820,31 +826,141 @@ function openServerInviteInviterProfile(inviterName) { openUrl(`${config.siteUrl}/user/${encodeURIComponent(inviterName)}`) } +function getSharedInstanceInvite(notification) { + const sharedInstanceId = notification.body?.shared_instance_id + if (typeof sharedInstanceId !== 'string') { + throw new Error('Missing shared instance ID for invite notification.') + } + + const invitedById = notification.body?.invited_by + const invitedByUsername = + typeof notification.body?.invited_by_username === 'string' + ? notification.body.invited_by_username + : typeof notification.body?.inviter_username === 'string' + ? notification.body.inviter_username + : null + const sharedInstanceName = + typeof notification.body.shared_instance_name === 'string' + ? notification.body.shared_instance_name + : 'Shared instance' + + return { + sharedInstanceId, + sharedInstanceName, + invitedById: typeof invitedById === 'string' ? invitedById : null, + invitedByUsername, + } +} + +async function acceptSharedInstanceInviteNotification(notification) { + try { + const sharedInstanceInvite = getSharedInstanceInvite(notification) + const invitedBy = sharedInstanceInvite.invitedById + ? await get_user(sharedInstanceInvite.invitedById, 'bypass').catch(() => null) + : null + const invitedByUsername = sharedInstanceInvite.invitedByUsername ?? invitedBy?.username ?? null + const preview = await install_get_shared_instance_preview( + sharedInstanceInvite.sharedInstanceId, + sharedInstanceInvite.sharedInstanceName, + ) + const showInstallModal = installToPlayModal.value?.showSharedInstance + + if (!showInstallModal) { + throw new Error('Install to play modal is not available.') + } + + showInstallModal( + { + preview, + invitedByUsername, + }, + async () => { + try { + await install_shared_instance( + sharedInstanceInvite.sharedInstanceId, + sharedInstanceInvite.sharedInstanceName, + ) + await markLiveNotificationRead(notification) + queryClient.invalidateQueries({ queryKey: ['instances'] }) + } catch (error) { + handleError(error) + throw error + } + }, + ) + } catch (error) { + handleError(error) + } +} + +async function declineSharedInstanceInviteNotification(notification) { + try { + getSharedInstanceInvite(notification) + await markLiveNotificationRead(notification) + } catch (error) { + handleError(error) + } +} + async function handleLiveNotification(notification) { - if (notification?.body?.type !== 'server_invite' || notification.read) return - if (displayedServerInviteNotifications.has(notification.id)) return - - displayedServerInviteNotifications.add(notification.id) - - const serverName = - typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server' - const inviterId = notification.body.invited_by - const invitedBy = - typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null - - addPopupNotification({ - title: serverName, - autoCloseMs: null, - toast: { - type: 'server-invite', - actorName: invitedBy?.username ?? null, - actorAvatarUrl: invitedBy?.avatar_url ?? null, - entityName: serverName, - onAccept: () => acceptServerInviteNotification(notification), - onDecline: () => declineServerInviteNotification(notification), - onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null), - }, - }) + if (!notification?.body || notification.read) return + + if (notification.body.type === 'server_invite') { + if (displayedServerInviteNotifications.has(notification.id)) return + + displayedServerInviteNotifications.add(notification.id) + + const serverName = + typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server' + const inviterId = notification.body.invited_by + const invitedBy = + typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null + + addPopupNotification({ + title: serverName, + autoCloseMs: null, + toast: { + type: 'server-invite', + actorName: invitedBy?.username ?? null, + actorAvatarUrl: invitedBy?.avatar_url ?? null, + entityName: serverName, + onAccept: () => acceptServerInviteNotification(notification), + onDecline: () => declineServerInviteNotification(notification), + onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null), + }, + }) + } + + if (notification.body.type === 'shared_instance_invite') { + if (displayedSharedInstanceInviteNotifications.has(notification.id)) return + + let sharedInstanceInvite + try { + sharedInstanceInvite = getSharedInstanceInvite(notification) + } catch (error) { + handleError(error) + return + } + displayedSharedInstanceInviteNotifications.add(notification.id) + const invitedBy = sharedInstanceInvite.invitedById + ? await get_user(sharedInstanceInvite.invitedById, 'bypass').catch(() => null) + : null + const invitedByUsername = sharedInstanceInvite.invitedByUsername ?? invitedBy?.username ?? null + + addPopupNotification({ + title: sharedInstanceInvite.sharedInstanceName, + autoCloseMs: null, + toast: { + type: 'instance-invite', + actorName: invitedByUsername, + actorAvatarUrl: invitedBy?.avatar_url ?? null, + entityName: sharedInstanceInvite.sharedInstanceName, + onAccept: () => acceptSharedInstanceInviteNotification(notification), + onDecline: () => declineSharedInstanceInviteNotification(notification), + onOpenActor: () => openServerInviteInviterProfile(invitedByUsername), + }, + }) + } } async function handleCommand(e) { diff --git a/apps/app-frontend/src/components/ui/friends/FriendsList.vue b/apps/app-frontend/src/components/ui/friends/FriendsList.vue index c622c078e4..a7a76c4f6e 100644 --- a/apps/app-frontend/src/components/ui/friends/FriendsList.vue +++ b/apps/app-frontend/src/components/ui/friends/FriendsList.vue @@ -10,17 +10,22 @@ import { useRelativeTime, useVIntl, } from '@modrinth/ui' -import { computed, onUnmounted, ref, watch } from 'vue' +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import { computed, onUnmounted, ref } from 'vue' import FriendsSection from '@/components/ui/friends/FriendsSection.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import { friend_listener } from '@/helpers/events' import { + acceptCachedFriend, add_friend, - friends, + createPendingFriend, + friendsQueryKey, type FriendWithUserData, + getFriendsWithUserData, remove_friend, - transformFriends, + removeCachedFriend, + upsertCachedFriend, } from '@/helpers/friends.ts' import type { ModrinthCredentials } from '@/helpers/mr_auth' @@ -28,6 +33,7 @@ const { formatMessage } = useVIntl() const { handleError } = injectNotificationManager() const formatRelativeTime = useRelativeTime() +const queryClient = useQueryClient() const props = defineProps<{ credentials: ModrinthCredentials | null @@ -35,36 +41,22 @@ const props = defineProps<{ }>() const userCredentials = computed(() => props.credentials) +const friendsKey = computed(() => friendsQueryKey(userCredentials.value?.user_id)) const search = ref('') const friendInvitesModal = ref() const username = ref('') const addFriendModal = ref() -async function addFriendFromModal() { - addFriendModal.value.hide() - await add_friend(username.value).catch(handleError) - username.value = '' - await loadFriends() -} - -async function addFriend(friend: FriendWithUserData) { - const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id - if (id) { - await add_friend(id).catch(handleError) - await loadFriends() - } -} -async function removeFriend(friend: FriendWithUserData) { - const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id - if (id) { - await remove_friend(id).catch(handleError) - await loadFriends() - } -} +const friendsQuery = useQuery({ + queryKey: friendsKey, + queryFn: () => getFriendsWithUserData(userCredentials.value), + enabled: () => !!userCredentials.value, + staleTime: 30_000, +}) -const userFriends = ref([]) +const userFriends = computed(() => friendsQuery.data.value ?? []) const sortedFriends = computed(() => userFriends.value.slice().sort((a, b) => { if (a.last_updated === null && b.last_updated === null) { @@ -108,39 +100,144 @@ const incomingRequests = computed(() => .sort((a, b) => b.created.diff(a.created)), ) -const loading = ref(true) -async function loadFriends(timeout = false) { - loading.value = timeout +type FriendsMutationContext = { + queryKey: ReturnType + previousFriends?: FriendWithUserData[] +} - try { - const friendsList = await friends() - userFriends.value = await transformFriends(friendsList, userCredentials.value) - loading.value = false - } catch (e) { - console.error('Error loading friends', e) - if (timeout) { - setTimeout(() => loadFriends(), 15 * 1000) - } +type AddFriendMutationVariables = { + userId: string + user: { + id: string + username: string + avatarUrl?: string | null } + acceptExisting?: boolean } -watch( - userCredentials, - () => { - if (userCredentials.value === undefined) { - userFriends.value = [] - loading.value = false - } else if (userCredentials.value === null) { - userFriends.value = [] - loading.value = false - } else { - loadFriends(true) - } +type RemoveFriendMutationVariables = { + userId: string + user: FriendWithUserData +} + +const loading = computed(() => !!userCredentials.value && friendsQuery.isLoading.value) + +const addFriendMutation = useMutation({ + mutationFn: ({ userId }: AddFriendMutationVariables) => add_friend(userId), + onMutate: async ({ user, acceptExisting }): Promise => { + const queryKey = friendsKey.value + await queryClient.cancelQueries({ queryKey }) + const previousFriends = queryClient.getQueryData(queryKey) + + queryClient.setQueryData(queryKey, (friends = []) => + acceptExisting + ? acceptCachedFriend(friends, user.id, user.username, userCredentials.value?.user_id) + : upsertCachedFriend( + friends, + createPendingFriend(user, userCredentials.value?.user_id), + userCredentials.value?.user_id, + ), + ) + + return { queryKey, previousFriends } }, - { immediate: true }, -) + onError: (error, _variables, context) => { + restoreFriendsQuery(context) + handleError(toError(error)) + }, + onSettled: (_data, _error, _variables, context) => { + void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? friendsKey.value }) + }, +}) + +const removeFriendMutation = useMutation({ + mutationFn: ({ userId }: RemoveFriendMutationVariables) => remove_friend(userId), + onMutate: async ({ user, userId }): Promise => { + const queryKey = friendsKey.value + await queryClient.cancelQueries({ queryKey }) + const previousFriends = queryClient.getQueryData(queryKey) + + queryClient.setQueryData(queryKey, (friends = []) => + removeCachedFriend(friends, userId, user.username, userCredentials.value?.user_id), + ) + + return { queryKey, previousFriends } + }, + onError: (error, _variables, context) => { + restoreFriendsQuery(context) + handleError(toError(error)) + }, + onSettled: (_data, _error, _variables, context) => { + void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? friendsKey.value }) + }, +}) + +function restoreFriendsQuery(context?: FriendsMutationContext) { + if (!context) return + + if (context.previousFriends === undefined) { + queryClient.removeQueries({ queryKey: context.queryKey, exact: true }) + return + } + + queryClient.setQueryData(context.queryKey, context.previousFriends) +} + +function toError(error: unknown) { + if (error instanceof Error) return error + if (typeof error === 'string') return new Error(error) + if (error && typeof error === 'object') { + const record = error as Record + const message = record.message ?? record.error + if (typeof message === 'string') return new Error(message) + return new Error(JSON.stringify(error)) + } + return new Error(String(error)) +} + +function addFriendFromModal() { + const target = username.value.trim() + if (!target) return + + addFriendModal.value.hide() + addFriendMutation.mutate({ + userId: target, + user: { + id: target, + username: target, + }, + }) + username.value = '' +} + +function addFriend(friend: FriendWithUserData) { + const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id + if (id) { + addFriendMutation.mutate({ + userId: id, + user: { + id, + username: friend.username, + avatarUrl: friend.avatar, + }, + acceptExisting: true, + }) + } +} -const unlisten = await friend_listener(() => loadFriends()) +function removeFriend(friend: FriendWithUserData) { + const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id + if (id) { + removeFriendMutation.mutate({ + userId: id, + user: friend, + }) + } +} + +const unlisten = await friend_listener(() => { + void queryClient.invalidateQueries({ queryKey: friendsKey.value }) +}) onUnmounted(() => { unlisten() }) diff --git a/apps/app-frontend/src/components/ui/instance/InstanceAdmonitions.vue b/apps/app-frontend/src/components/ui/instance/InstanceAdmonitions.vue new file mode 100644 index 0000000000..1df46f6281 --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance/InstanceAdmonitions.vue @@ -0,0 +1,111 @@ + + + diff --git a/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue b/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue index b002aa6ab8..734276faea 100644 --- a/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue +++ b/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue @@ -1,6 +1,9 @@