diff --git a/src/components/dialogs/UserDialog/UserDialog.vue b/src/components/dialogs/UserDialog/UserDialog.vue index f100599b..49848c6a 100644 --- a/src/components/dialogs/UserDialog/UserDialog.vue +++ b/src/components/dialogs/UserDialog/UserDialog.vue @@ -554,756 +554,23 @@ - - - - - - - {{ - t('dialog.user.groups.total_count', { count: userDialog.mutualFriends.length }) - }} - - - {{ t('dialog.user.groups.sort_by') }} - - - - - - - {{ t(item.name) }} - - - - - - - - - - - - - - - + - - - - - - - {{ - t('dialog.user.groups.total_count', { count: userDialog.userGroups.groups.length }) - }} - - {{ t('dialog.user.groups.hold_shift') }} - - - - - {{ t('dialog.user.groups.sort_by') }} - - - - - - - {{ t(item.name) }} - - - - - - {{ t('dialog.user.groups.exit_edit_mode') }} - - - {{ t('dialog.user.groups.edit_mode') }} - - - - - - - - - - - - - - {{ t('dialog.group.actions.visibility_everyone') }} - - - {{ t('dialog.group.actions.visibility_friends') }} - - - {{ t('dialog.group.actions.visibility_hidden') }} - - - {{ t('dialog.user.groups.leave_group_tooltip') }} - - - - - - - {{ - userDialogGroupAllSelected - ? t('dialog.group.actions.deselect_all') - : t('dialog.group.actions.select_all') - }} - - - - - - toggleGroupSelection(group.id)" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ t('dialog.group.members.visibility') }} - {{ group.myMember.visibility }} - - - - ({{ group.memberCount }}) - - - setGroupVisibility(group.id, value)"> - - - - - - {{ t('dialog.group.actions.visibility_everyone') }} - - - {{ t('dialog.group.actions.visibility_friends') }} - - - {{ t('dialog.group.actions.visibility_hidden') }} - - - - - - - - - - - - - - - - - - - {{ - t('dialog.user.groups.own_groups') - }} - {{ userDialog.userGroups.ownGroups.length }}/{{ - // @ts-ignore - cachedConfig?.constants?.GROUPS?.MAX_OWNED - }} - - - - - - - - - - - - - - {{ t('dialog.group.members.visibility') }} - {{ group.memberVisibility }} - - - - ({{ group.memberCount }}) - - - - - - - {{ - t('dialog.user.groups.mutual_groups') - }} - {{ - userDialog.userGroups.mutualGroups.length - }} - - - - - - - - - - - - - - {{ t('dialog.group.members.visibility') }} - {{ group.memberVisibility }} - - - - ({{ group.memberCount }}) - - - - - - - {{ t('dialog.user.groups.groups') }} - - {{ userDialog.userGroups.remainingGroups.length }} - - / - - {{ cachedConfig?.constants?.GROUPS?.MAX_JOINED_PLUS }} - - - {{ cachedConfig?.constants?.GROUPS?.MAX_JOINED }} - - - - - - - - - - - - - - - - - {{ t('dialog.group.members.visibility') }} - {{ group.memberVisibility }} - - - - ({{ group.memberCount }}) - - - - - - - + - - - - - - - {{ - t('dialog.user.worlds.total_count', { count: userDialog.worlds.length }) - }} - - - {{ t('dialog.user.worlds.sort_by') }} - - - - - - - {{ t(item.name) }} - - - - {{ t('dialog.user.worlds.order_by') }} - - - - - - - {{ t(item.name) }} - - - - - - - - - - - - - - ({{ world.occupants }}) - - - - - - - + - - - - - - - - - - {{ list[2].length }}/{{ favoriteLimits.maxFavoritesPerGroup.world }} - - - - - - - - - - - ({{ world.occupants }}) - - - - - - - - - - - + - - - - - - - - - - - - {{ - t('dialog.user.avatars.total_count', { count: userDialogAvatars.length }) - }} - - - - - {{ t('dialog.user.avatars.sort_by') }} - - - - - - {{ t('dialog.user.avatars.sort_by_name') }} - {{ - t('dialog.user.avatars.sort_by_update') - }} - {{ - t('dialog.user.avatars.sort_by_uploaded') - }} - - - {{ t('dialog.user.avatars.group_by') }} - (userDialog.avatarReleaseStatus = value)"> - - - - - {{ t('dialog.user.avatars.all') }} - {{ t('dialog.user.avatars.public') }} - {{ t('dialog.user.avatars.private') }} - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -1375,27 +642,6 @@ import DeprecationAlert from '@/components/DeprecationAlert.vue'; import VueJsonPretty from 'vue-json-pretty'; - import { - compareByDisplayName, - compareByFriendOrder, - compareByLastActiveRef, - compareByMemberCount, - compareByName, - copyToClipboard, - downloadAndSaveJson, - formatDateFilter, - getFaviconUrl, - isFriendOnline, - isRealInstance, - openExternalLink, - parseLocation, - refreshInstancePlayerCount, - timeToText, - userImage, - userOnlineFor, - userOnlineForTimestamp, - userStatusClass - } from '../../../shared/utils'; import { useAdvancedSettingsStore, useAppearanceSettingsStore, @@ -1415,18 +661,31 @@ useUserStore, useWorldStore } from '../../../stores'; + import { + copyToClipboard, + downloadAndSaveJson, + formatDateFilter, + getFaviconUrl, + isFriendOnline, + isRealInstance, + openExternalLink, + parseLocation, + refreshInstancePlayerCount, + timeToText, + userImage, + userOnlineFor, + userOnlineForTimestamp, + userStatusClass + } from '../../../shared/utils'; import { favoriteRequest, friendRequest, - groupRequest, miscRequest, notificationRequest, playerModerationRequest, userRequest, worldRequest } from '../../../api'; - import { userDialogGroupSortingOptions, userDialogMutualFriendSortingOptions } from '../../../shared/constants'; - import { userDialogWorldOrderOptions, userDialogWorldSortingOptions } from '../../../shared/constants/'; import { database } from '../../../service/database'; import { formatJsonVars } from '../../../shared/utils/base/ui'; import { processBulk } from '../../../service/request'; @@ -1434,6 +693,11 @@ import DialogJsonTab from '../DialogJsonTab.vue'; import InstanceActionBar from '../../InstanceActionBar.vue'; import SendInviteDialog from '../InviteDialog/SendInviteDialog.vue'; + import UserDialogAvatarsTab from './UserDialogAvatarsTab.vue'; + import UserDialogFavoriteWorldsTab from './UserDialogFavoriteWorldsTab.vue'; + import UserDialogGroupsTab from './UserDialogGroupsTab.vue'; + import UserDialogMutualFriendsTab from './UserDialogMutualFriendsTab.vue'; + import UserDialogWorldsTab from './UserDialogWorldsTab.vue'; import UserSummaryHeader from './UserSummaryHeader.vue'; const BioDialog = defineAsyncComponent(() => import('./BioDialog.vue')); @@ -1459,56 +723,39 @@ } return tabs; }); - const favoriteWorldTabs = computed(() => - (userDialog.value.userFavoriteWorlds || []).map((list, index) => ({ - value: String(index), - label: list?.[0] ?? '' - })) - ); + const favoriteWorldsTabRef = ref(null); + const mutualFriendsTabRef = ref(null); + const worldsTabRef = ref(null); + const avatarsTabRef = ref(null); + const groupsTabRef = ref(null); const modalStore = useModalStore(); const instanceStore = useInstanceStore(); const { hideUserNotes, hideUserMemos, isDarkMode } = storeToRefs(useAppearanceSettingsStore()); - const { bioLanguage, avatarRemoteDatabase, translationApi, translationApiType } = - storeToRefs(useAdvancedSettingsStore()); + const { bioLanguage, translationApi, translationApiType } = storeToRefs(useAdvancedSettingsStore()); const { translateText } = useAdvancedSettingsStore(); const { userDialog, languageDialog, currentUser, isLocalUserVrcPlusSupporter } = storeToRefs(useUserStore()); const { cachedUsers, showUserDialog, - sortUserDialogAvatars, refreshUserDialogAvatars, showSendBoopDialog, toggleSharedConnectionsOptOut, toggleDiscordFriendsOptOut } = useUserStore(); - const { favoriteLimits } = storeToRefs(useFavoriteStore()); - const { showFavoriteDialog, handleFavoriteWorldList } = useFavoriteStore(); - const { showAvatarDialog, lookupAvatars, showAvatarAuthorDialog } = useAvatarStore(); - const { cachedAvatars } = useAvatarStore(); - const { cachedWorlds, showWorldDialog } = useWorldStore(); - const { - showGroupDialog, - applyGroup, - saveCurrentUserGroups, - updateInGameGroupOrder, - leaveGroup, - leaveGroupPrompt, - setGroupVisibility, - handleGroupList, - showModerateGroupDialog - } = useGroupStore(); - const { currentUserGroups, inviteGroupDialog, inGameGroupOrder } = storeToRefs(useGroupStore()); + const { showFavoriteDialog } = useFavoriteStore(); + const { showAvatarDialog, showAvatarAuthorDialog } = useAvatarStore(); + const { showWorldDialog } = useWorldStore(); + const { showGroupDialog, showModerateGroupDialog } = useGroupStore(); + const { inviteGroupDialog } = storeToRefs(useGroupStore()); const { lastLocation, lastLocationDestination } = storeToRefs(useLocationStore()); const { refreshInviteMessageTableData } = useInviteStore(); const { friendLogTable } = storeToRefs(useFriendStore()); const { getFriendRequest, handleFriendDelete } = useFriendStore(); const { clearInviteImageUpload, showFullscreenImageDialog, showGalleryPage } = useGalleryStore(); - const { cachedConfig } = storeToRefs(useAuthStore()); const { applyPlayerModeration, handlePlayerModerationDelete } = useModerationStore(); - const { shiftHeld } = storeToRefs(useUiStore()); watch( () => userDialog.value.loading, @@ -1525,21 +772,12 @@ } ); - const userDialogGroupEditMode = ref(false); // whether edit mode is active - const userDialogGroupEditGroups = ref([]); // editable group list - const userDialogGroupAllSelected = ref(false); - const userDialogGroupEditSelectedGroupIds = ref([]); // selected groups in edit mode - const userDialogLastMutualFriends = ref(''); const userDialogLastGroup = ref(''); const userDialogLastAvatar = ref(''); const userDialogLastWorld = ref(''); const userDialogLastFavoriteWorld = ref(''); - const favoriteWorldsTab = ref('0'); - const userDialogWorldsRequestId = ref(0); - const userDialogFavoriteWorldsRequestId = ref(0); - const sendInviteDialogVisible = ref(false); const sendInviteDialog = ref({ messageSlot: {}, @@ -1584,49 +822,6 @@ const vrchatCredit = ref(null); const treeData = ref({}); - const userDialogAvatars = computed(() => { - const { avatars, avatarReleaseStatus } = userDialog.value; - if (avatarReleaseStatus === 'public' || avatarReleaseStatus === 'private') { - return avatars.filter((avatar) => avatar.releaseStatus === avatarReleaseStatus); - } - return avatars; - }); - const avatarSearchQuery = ref(''); - const filteredUserDialogAvatars = computed(() => { - const avatars = userDialogAvatars.value; - if (userDialog.value.ref?.id !== currentUser.value.id) { - return avatars; - } - const query = avatarSearchQuery.value.trim().toLowerCase(); - if (!query) { - return avatars; - } - return avatars.filter((avatar) => (avatar.name || '').toLowerCase().includes(query)); - }); - - watch( - () => userDialog.value.id, - () => { - avatarSearchQuery.value = ''; - } - ); - - /** - * - * @param visibility - */ - function userFavoriteWorldsStatus(visibility) { - const style = {}; - if (visibility === 'public') { - style.green = true; - } else if (visibility === 'friends') { - style.blue = true; - } else { - style.red = true; - } - return style; - } - /** * * @param user @@ -1700,33 +895,33 @@ } if (userDialogLastMutualFriends.value !== userId) { userDialogLastMutualFriends.value = userId; - getUserMutualFriends(userId); + mutualFriendsTabRef.value?.getUserMutualFriends(userId); } } else if (tabName === 'Groups') { if (userDialogLastGroup.value !== userId) { userDialogLastGroup.value = userId; - getUserGroups(userId); + groupsTabRef.value?.getUserGroups(userId); } } else if (tabName === 'Avatars') { - setUserDialogAvatars(userId); + avatarsTabRef.value?.setUserDialogAvatars(userId); if (userDialogLastAvatar.value !== userId) { userDialogLastAvatar.value = userId; if (userId === currentUser.value.id) { refreshUserDialogAvatars(); } else { - setUserDialogAvatarsRemote(userId); + avatarsTabRef.value?.setUserDialogAvatarsRemote(userId); } } } else if (tabName === 'Worlds') { - setUserDialogWorlds(userId); + worldsTabRef.value?.setUserDialogWorlds(userId); if (userDialogLastWorld.value !== userId) { userDialogLastWorld.value = userId; - refreshUserDialogWorlds(); + worldsTabRef.value?.refreshUserDialogWorlds(); } } else if (tabName === 'favorite-worlds') { if (userDialogLastFavoriteWorld.value !== userId) { userDialogLastFavoriteWorld.value = userId; - getUserFavoriteWorlds(userId); + favoriteWorldsTabRef.value?.getUserFavoriteWorlds(userId); } } else if (tabName === 'JSON') { refreshUserDialogTreeData(); @@ -1791,36 +986,6 @@ D.visible = true; } - /** - * - * @param userId - */ - async function setUserDialogAvatarsRemote(userId) { - if (avatarRemoteDatabase.value && userId !== currentUser.value.id) { - userDialog.value.isAvatarsLoading = true; - const data = await lookupAvatars('authorId', userId); - const avatars = new Set(); - userDialogAvatars.value.forEach((avatar) => { - avatars.add(avatar.id); - }); - if (data && typeof data === 'object') { - data.forEach((avatar) => { - if (avatar.id && !avatars.has(avatar.id)) { - if (avatar.authorId === userId) { - userDialog.value.avatars.push(avatar); - } else { - console.error(`Avatar authorId mismatch for ${avatar.id} - ${avatar.name}`); - } - } - }); - } - userDialog.value.avatarSorting = 'name'; - userDialog.value.avatarReleaseStatus = 'all'; - userDialog.value.isAvatarsLoading = false; - } - sortUserDialogAvatars(userDialog.value.avatars); - } - /** * * @param badge @@ -2307,305 +1472,6 @@ }); } - /** - * - * @param userId - */ - async function getUserGroups(userId) { - exitEditModeCurrentUserGroups(); - userDialog.value.isGroupsLoading = true; - userDialog.value.userGroups = { - groups: [], - ownGroups: [], - mutualGroups: [], - remainingGroups: [] - }; - const args = await groupRequest.getGroups({ userId }); - handleGroupList(args); - if (userId !== userDialog.value.id) { - userDialog.value.isGroupsLoading = false; - return; - } - if (userId === currentUser.value.id) { - // update current user groups - currentUserGroups.value.clear(); - args.json.forEach((group) => { - const ref = applyGroup(group); - if (!currentUserGroups.value.has(group.id)) { - currentUserGroups.value.set(group.id, ref); - } - }); - - saveCurrentUserGroups(); - } - userDialog.value.userGroups.groups = args.json; - for (let i = 0; i < args.json.length; ++i) { - const group = args.json[i]; - if (!group?.id) { - console.error('getUserGroups, group ID is missing', group); - continue; - } - if (group.ownerId === userId) { - userDialog.value.userGroups.ownGroups.unshift(group); - } - if (userId === currentUser.value.id) { - // skip mutual groups for current user - if (group.ownerId !== userId) { - userDialog.value.userGroups.remainingGroups.unshift(group); - } - continue; - } - if (group.mutualGroup) { - userDialog.value.userGroups.mutualGroups.unshift(group); - } - if (!group.mutualGroup && group.ownerId !== userId) { - userDialog.value.userGroups.remainingGroups.unshift(group); - } - } - if (userId === currentUser.value.id) { - userDialog.value.groupSorting = userDialogGroupSortingOptions.inGame; - } else if (userDialog.value.groupSorting.value === userDialogGroupSortingOptions.inGame.value) { - userDialog.value.groupSorting = userDialogGroupSortingOptions.alphabetical; - } - await sortCurrentUserGroups(); - userDialog.value.isGroupsLoading = false; - } - - /** - * - * @param userId - */ - async function getUserMutualFriends(userId) { - userDialog.value.mutualFriends = []; - if (currentUser.value.hasSharedConnectionsOptOut) { - return; - } - userDialog.value.isMutualFriendsLoading = true; - const params = { - userId, - n: 100, - offset: 0 - }; - processBulk({ - fn: userRequest.getMutualFriends, - N: -1, - params, - handle: (args) => { - for (const json of args.json) { - if (userDialog.value.mutualFriends.some((u) => u.id === json.id)) { - continue; - } - const ref = cachedUsers.get(json.id); - if (typeof ref !== 'undefined') { - userDialog.value.mutualFriends.push(ref); - } else { - userDialog.value.mutualFriends.push(json); - } - } - setUserDialogMutualFriendSorting(userDialog.value.mutualFriendSorting); - }, - done: (success) => { - userDialog.value.isMutualFriendsLoading = false; - if (success) { - const mutualIds = userDialog.value.mutualFriends.map((u) => u.id); - database.updateMutualsForFriend(userId, mutualIds); - } - } - }); - } - - /** - * - * @param a - * @param b - */ - function sortGroupsByInGame(a, b) { - const aIndex = inGameGroupOrder.value.indexOf(a?.id); - const bIndex = inGameGroupOrder.value.indexOf(b?.id); - if (aIndex === -1 && bIndex === -1) { - return 0; - } - if (aIndex === -1) { - return 1; - } - if (bIndex === -1) { - return -1; - } - return aIndex - bIndex; - } - - /** - * - */ - async function sortCurrentUserGroups() { - const D = userDialog.value; - let sortMethod = () => 0; - - switch (D.groupSorting.value) { - case 'alphabetical': - sortMethod = compareByName; - break; - case 'members': - sortMethod = compareByMemberCount; - break; - case 'inGame': - sortMethod = sortGroupsByInGame; - await updateInGameGroupOrder(); - break; - } - - userDialog.value.userGroups.ownGroups.sort(sortMethod); - userDialog.value.userGroups.mutualGroups.sort(sortMethod); - userDialog.value.userGroups.remainingGroups.sort(sortMethod); - } - - /** - * - * @param userId - */ - function setUserDialogAvatars(userId) { - const avatars = new Set(); - userDialogAvatars.value.forEach((avatar) => { - avatars.add(avatar.id); - }); - for (const ref of cachedAvatars.values()) { - if (ref.authorId === userId && !avatars.has(ref.id)) { - userDialog.value.avatars.push(ref); - } - } - sortUserDialogAvatars(userDialog.value.avatars); - } - - /** - * - * @param userId - */ - function setUserDialogWorlds(userId) { - const worlds = []; - for (const ref of cachedWorlds.values()) { - if (ref.authorId === userId) { - worlds.push(ref); - } - } - userDialog.value.worlds = worlds; - } - - /** - * - */ - function refreshUserDialogWorlds() { - const D = userDialog.value; - if (D.isWorldsLoading) { - return; - } - const requestId = ++userDialogWorldsRequestId.value; - D.isWorldsLoading = true; - const params = { - n: 50, - offset: 0, - sort: userDialog.value.worldSorting.value, - order: userDialog.value.worldOrder.value, - // user: 'friends', - userId: D.id, - releaseStatus: 'public' - }; - if (params.userId === currentUser.value.id) { - params.user = 'me'; - params.releaseStatus = 'all'; - } - const worlds = []; - const worldIds = new Set(); - (async () => { - try { - let offset = 0; - while (true) { - const args = await worldRequest.getCachedWorlds({ - ...params, - offset - }); - if (requestId !== userDialogWorldsRequestId.value || D.id !== params.userId) { - return; - } - for (const world of args.json) { - if (!worldIds.has(world.id)) { - worldIds.add(world.id); - worlds.push(world); - } - } - if (args.json.length < params.n) { - break; - } - offset += params.n; - } - if (requestId === userDialogWorldsRequestId.value && D.id === params.userId) { - userDialog.value.worlds = worlds; - } - } finally { - if (requestId === userDialogWorldsRequestId.value) { - D.isWorldsLoading = false; - } - } - })().catch((err) => { - console.error('refreshUserDialogWorlds failed', err); - }); - } - - /** - * - * @param userId - */ - async function getUserFavoriteWorlds(userId) { - const requestId = ++userDialogFavoriteWorldsRequestId.value; - userDialog.value.isFavoriteWorldsLoading = true; - favoriteWorldsTab.value = '0'; - userDialog.value.userFavoriteWorlds = []; - const worldLists = []; - const groupArgs = await favoriteRequest.getCachedFavoriteGroups({ - ownerId: userId, - n: 100, - offset: 0 - }); - if (requestId !== userDialogFavoriteWorldsRequestId.value || userDialog.value.id !== userId) { - if (requestId === userDialogFavoriteWorldsRequestId.value) { - userDialog.value.isFavoriteWorldsLoading = false; - } - return; - } - const worldGroups = groupArgs.json.filter((list) => list.type === 'world'); - const tasks = worldGroups.map(async (list) => { - if (list.type !== 'world') { - return null; - } - const params = { - ownerId: userId, - n: 100, - offset: 0, - userId, - tag: list.name - }; - try { - const args = await favoriteRequest.getCachedFavoriteWorlds(params); - handleFavoriteWorldList(args); - return [list.displayName, list.visibility, args.json]; - } catch (err) { - console.error('getUserFavoriteWorlds', err); - return null; - } - }); - const results = await Promise.all(tasks); - for (const result of results) { - if (result) { - worldLists.push(result); - } - } - if (requestId === userDialogFavoriteWorldsRequestId.value) { - if (userDialog.value.id === userId) { - userDialog.value.userFavoriteWorlds = worldLists; - } - userDialog.value.isFavoriteWorldsLoading = false; - } - } - /** * */ @@ -2731,331 +1597,6 @@ copyToClipboard(displayName, 'User DisplayName copied to clipboard'); } - const userDialogGroupSortingKey = computed(() => { - const current = userDialog.value.groupSorting; - const found = Object.entries(userDialogGroupSortingOptions).find(([, option]) => { - if (option === current) { - return true; - } - return option?.value === current?.value || option?.name === current?.name; - }); - return found ? String(found[0]) : ''; - }); - - /** - * - * @param key - */ - function setUserDialogGroupSortingByKey(key) { - const option = userDialogGroupSortingOptions[key]; - if (!option) { - return; - } - setUserDialogGroupSorting(option); - } - - /** - * - * @param sortOrder - */ - async function setUserDialogGroupSorting(sortOrder) { - const D = userDialog.value; - if (D.groupSorting.value === sortOrder.value) { - return; - } - D.groupSorting = sortOrder; - await sortCurrentUserGroups(); - } - - const userDialogMutualFriendSortingKey = computed(() => { - const current = userDialog.value.mutualFriendSorting; - const found = Object.entries(userDialogMutualFriendSortingOptions).find(([, option]) => { - if (option === current) { - return true; - } - return option?.value === current?.value || option?.name === current?.name; - }); - return found ? String(found[0]) : ''; - }); - - /** - * - * @param key - */ - function setUserDialogMutualFriendSortingByKey(key) { - const option = userDialogMutualFriendSortingOptions[key]; - if (!option) { - return; - } - setUserDialogMutualFriendSorting(option); - } - - /** - * - * @param sortOrder - */ - async function setUserDialogMutualFriendSorting(sortOrder) { - const D = userDialog.value; - D.mutualFriendSorting = sortOrder; - switch (sortOrder.value) { - case 'alphabetical': - D.mutualFriends.sort(compareByDisplayName); - break; - case 'lastActive': - D.mutualFriends.sort(compareByLastActiveRef); - break; - case 'friendOrder': - D.mutualFriends.sort(compareByFriendOrder); - break; - } - } - - /** - * - */ - async function exitEditModeCurrentUserGroups() { - userDialogGroupEditMode.value = false; - userDialogGroupEditGroups.value = []; - userDialogGroupEditSelectedGroupIds.value = []; - userDialogGroupAllSelected.value = false; - await sortCurrentUserGroups(); - } - - /** - * - */ - async function editModeCurrentUserGroups() { - await updateInGameGroupOrder(); - userDialogGroupEditGroups.value = Array.from(currentUserGroups.value.values()); - userDialogGroupEditGroups.value.sort(sortGroupsByInGame); - userDialogGroupEditMode.value = true; - } - - /** - * - */ - async function saveInGameGroupOrder() { - userDialogGroupEditGroups.value.sort(sortGroupsByInGame); - try { - await AppApi.SetVRChatRegistryKey( - `VRC_GROUP_ORDER_${currentUser.value.id}`, - JSON.stringify(inGameGroupOrder.value), - 3 - ); - } catch (err) { - console.error(err); - toast.error('Failed to save in-game group order'); - } - } - - // Select all groups currently in the editable list by collecting their IDs - /** - * - */ - function selectAllGroups() { - const allSelected = userDialogGroupEditSelectedGroupIds.value.length === userDialogGroupEditGroups.value.length; - - // First update selection state - userDialogGroupEditSelectedGroupIds.value = allSelected ? [] : userDialogGroupEditGroups.value.map((g) => g.id); - userDialogGroupAllSelected.value = !allSelected; - - // Toggle editMode off and back on to force checkbox UI update - userDialogGroupEditMode.value = false; - nextTick(() => { - userDialogGroupEditMode.value = true; - }); - } - - const bulkGroupActionValue = ref(''); - - /** - * - * @param value - */ - function handleBulkGroupAction(value) { - bulkGroupActionValue.value = value; - - if (value === 'leave') { - bulkLeaveGroups(); - } else if (typeof value === 'string' && value.startsWith('visibility:')) { - const newVisibility = value.slice('visibility:'.length); - bulkSetVisibility(newVisibility); - } - - nextTick(() => { - bulkGroupActionValue.value = ''; - }); - } - - // Apply the given visibility to all selected groups - /** - * - * @param newVisibility - */ - async function bulkSetVisibility(newVisibility) { - for (const groupId of userDialogGroupEditSelectedGroupIds.value) { - setGroupVisibility(groupId, newVisibility); - } - } - - // Leave (remove user from) all selected groups - /** - * - */ - function bulkLeaveGroups() { - for (const groupId of userDialogGroupEditSelectedGroupIds.value) { - leaveGroup(groupId); - } - } - - // Toggle individual group selection for bulk actions - /** - * - * @param groupId - */ - function toggleGroupSelection(groupId) { - const index = userDialogGroupEditSelectedGroupIds.value.indexOf(groupId); - if (index === -1) { - userDialogGroupEditSelectedGroupIds.value.push(groupId); - } else { - userDialogGroupEditSelectedGroupIds.value.splice(index, 1); - } - } - - /** - * - * @param groupId - */ - function moveGroupUp(groupId) { - const index = inGameGroupOrder.value.indexOf(groupId); - if (index > 0) { - inGameGroupOrder.value.splice(index, 1); - inGameGroupOrder.value.splice(index - 1, 0, groupId); - saveInGameGroupOrder(); - } - } - - /** - * - * @param groupId - */ - function moveGroupDown(groupId) { - const index = inGameGroupOrder.value.indexOf(groupId); - if (index < inGameGroupOrder.value.length - 1) { - inGameGroupOrder.value.splice(index, 1); - inGameGroupOrder.value.splice(index + 1, 0, groupId); - saveInGameGroupOrder(); - } - } - - /** - * - * @param groupId - */ - function moveGroupTop(groupId) { - const index = inGameGroupOrder.value.indexOf(groupId); - if (index > 0) { - inGameGroupOrder.value.splice(index, 1); - inGameGroupOrder.value.unshift(groupId); - saveInGameGroupOrder(); - } - } - - /** - * - * @param groupId - */ - function moveGroupBottom(groupId) { - const index = inGameGroupOrder.value.indexOf(groupId); - if (index < inGameGroupOrder.value.length - 1) { - inGameGroupOrder.value.splice(index, 1); - inGameGroupOrder.value.push(groupId); - saveInGameGroupOrder(); - } - } - - /** - * - * @param sortOrder - */ - async function setUserDialogWorldSorting(sortOrder) { - const D = userDialog.value; - if (D.worldSorting.value === sortOrder.value) { - return; - } - D.worldSorting = sortOrder; - refreshUserDialogWorlds(); - } - - const userDialogWorldSortingKey = computed(() => { - const current = userDialog.value.worldSorting; - const found = Object.entries(userDialogWorldSortingOptions).find(([, option]) => { - if (option === current) { - return true; - } - return option?.value === current?.value || option?.name === current?.name; - }); - return found ? String(found[0]) : ''; - }); - - /** - * - * @param key - */ - function setUserDialogWorldSortingByKey(key) { - const option = userDialogWorldSortingOptions[key]; - if (!option) { - return; - } - setUserDialogWorldSorting(option); - } - - /** - * - * @param order - */ - async function setUserDialogWorldOrder(order) { - const D = userDialog.value; - if (D.worldOrder.value === order.value) { - return; - } - D.worldOrder = order; - refreshUserDialogWorlds(); - } - - const userDialogWorldOrderKey = computed(() => { - const current = userDialog.value.worldOrder; - const found = Object.entries(userDialogWorldOrderOptions).find(([, option]) => { - if (option === current) { - return true; - } - return option?.value === current?.value || option?.name === current?.name; - }); - return found ? String(found[0]) : ''; - }); - - /** - * - * @param key - */ - function setUserDialogWorldOrderByKey(key) { - const option = userDialogWorldOrderOptions[key]; - if (!option) { - return; - } - setUserDialogWorldOrder(option); - } - - /** - * - * @param sortOption - */ - function changeUserDialogAvatarSorting(sortOption) { - const D = userDialog.value; - D.avatarSorting = sortOption; - sortUserDialogAvatars(D.avatars); - } - /** * */ diff --git a/src/components/dialogs/UserDialog/UserDialogAvatarsTab.vue b/src/components/dialogs/UserDialog/UserDialogAvatarsTab.vue new file mode 100644 index 00000000..da798bd6 --- /dev/null +++ b/src/components/dialogs/UserDialog/UserDialogAvatarsTab.vue @@ -0,0 +1,212 @@ + + + + + + + + + + + + + {{ + t('dialog.user.avatars.total_count', { count: userDialogAvatars.length }) + }} + + + + + {{ t('dialog.user.avatars.sort_by') }} + + + + + + {{ t('dialog.user.avatars.sort_by_name') }} + {{ t('dialog.user.avatars.sort_by_update') }} + {{ t('dialog.user.avatars.sort_by_uploaded') }} + + + {{ t('dialog.user.avatars.group_by') }} + (userDialog.avatarReleaseStatus = value)"> + + + + + {{ t('dialog.user.avatars.all') }} + {{ t('dialog.user.avatars.public') }} + {{ t('dialog.user.avatars.private') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/dialogs/UserDialog/UserDialogFavoriteWorldsTab.vue b/src/components/dialogs/UserDialog/UserDialogFavoriteWorldsTab.vue new file mode 100644 index 00000000..952f5443 --- /dev/null +++ b/src/components/dialogs/UserDialog/UserDialogFavoriteWorldsTab.vue @@ -0,0 +1,171 @@ + + + + + + + + + + {{ list[2].length }}/{{ favoriteLimits.maxFavoritesPerGroup.world }} + + + + + + + + + + + ({{ world.occupants }}) + + + + + + + + + + + + + + diff --git a/src/components/dialogs/UserDialog/UserDialogGroupsTab.vue b/src/components/dialogs/UserDialog/UserDialogGroupsTab.vue new file mode 100644 index 00000000..cb5446f3 --- /dev/null +++ b/src/components/dialogs/UserDialog/UserDialogGroupsTab.vue @@ -0,0 +1,702 @@ + + + + + + + + {{ + t('dialog.user.groups.total_count', { count: userDialog.userGroups.groups.length }) + }} + + {{ t('dialog.user.groups.hold_shift') }} + + + + + {{ t('dialog.user.groups.sort_by') }} + + + + + + + {{ t(item.name) }} + + + + + + {{ t('dialog.user.groups.exit_edit_mode') }} + + + {{ t('dialog.user.groups.edit_mode') }} + + + + + + + + + + + + + + {{ t('dialog.group.actions.visibility_everyone') }} + + + {{ t('dialog.group.actions.visibility_friends') }} + + + {{ t('dialog.group.actions.visibility_hidden') }} + + + {{ t('dialog.user.groups.leave_group_tooltip') }} + + + + + + + {{ + userDialogGroupAllSelected + ? t('dialog.group.actions.deselect_all') + : t('dialog.group.actions.select_all') + }} + + + + + + toggleGroupSelection(group.id)" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ t('dialog.group.members.visibility') }} + {{ group.myMember.visibility }} + + + + ({{ group.memberCount }}) + + + setGroupVisibility(group.id, value)"> + + + + + + {{ t('dialog.group.actions.visibility_everyone') }} + + + {{ t('dialog.group.actions.visibility_friends') }} + + + {{ t('dialog.group.actions.visibility_hidden') }} + + + + + + + + + + + + + + + + + + + {{ t('dialog.user.groups.own_groups') }} + {{ userDialog.userGroups.ownGroups.length }}/{{ + // @ts-ignore + cachedConfig?.constants?.GROUPS?.MAX_OWNED + }} + + + + + + + + + + + + + + {{ t('dialog.group.members.visibility') }} + {{ group.memberVisibility }} + + + + ({{ group.memberCount }}) + + + + + + + {{ t('dialog.user.groups.mutual_groups') }} + {{ userDialog.userGroups.mutualGroups.length }} + + + + + + + + + + + + + + {{ t('dialog.group.members.visibility') }} + {{ group.memberVisibility }} + + + + ({{ group.memberCount }}) + + + + + + + {{ t('dialog.user.groups.groups') }} + + {{ userDialog.userGroups.remainingGroups.length }} + + / + + {{ cachedConfig?.constants?.GROUPS?.MAX_JOINED_PLUS }} + + + {{ cachedConfig?.constants?.GROUPS?.MAX_JOINED }} + + + + + + + + + + + + + + + + + {{ t('dialog.group.members.visibility') }} + {{ group.memberVisibility }} + + + + ({{ group.memberCount }}) + + + + + + + + + + diff --git a/src/components/dialogs/UserDialog/UserDialogMutualFriendsTab.vue b/src/components/dialogs/UserDialog/UserDialogMutualFriendsTab.vue new file mode 100644 index 00000000..f6dec69e --- /dev/null +++ b/src/components/dialogs/UserDialog/UserDialogMutualFriendsTab.vue @@ -0,0 +1,154 @@ + + + + + + + + {{ + t('dialog.user.groups.total_count', { count: userDialog.mutualFriends.length }) + }} + + + {{ t('dialog.user.groups.sort_by') }} + + + + + + + {{ t(item.name) }} + + + + + + + + + + + + + + + + + + diff --git a/src/components/dialogs/UserDialog/UserDialogWorldsTab.vue b/src/components/dialogs/UserDialog/UserDialogWorldsTab.vue new file mode 100644 index 00000000..18c7e56b --- /dev/null +++ b/src/components/dialogs/UserDialog/UserDialogWorldsTab.vue @@ -0,0 +1,214 @@ + + + + + + + + {{ + t('dialog.user.worlds.total_count', { count: userDialog.worlds.length }) + }} + + + {{ t('dialog.user.worlds.sort_by') }} + + + + + + + {{ t(item.name) }} + + + + {{ t('dialog.user.worlds.order_by') }} + + + + + + + {{ t(item.name) }} + + + + + + + + + + + + + + ({{ world.occupants }}) + + + + + + + + + + diff --git a/src/components/dialogs/UserDialog/__tests__/UserDialogAvatarsTab.test.js b/src/components/dialogs/UserDialog/__tests__/UserDialogAvatarsTab.test.js new file mode 100644 index 00000000..f722ad2a --- /dev/null +++ b/src/components/dialogs/UserDialog/__tests__/UserDialogAvatarsTab.test.js @@ -0,0 +1,262 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createTestingPinia } from '@pinia/testing'; +import { mount } from '@vue/test-utils'; + +// ─── Mocks (must be before any imports that use them) ──────────────── + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key) + }), + createI18n: () => ({ + global: { t: (key) => key }, + install: vi.fn() + }) +})); + +vi.mock('../../../../plugin/router', () => { + const { ref } = require('vue'); + return { + router: { + beforeEach: vi.fn(), + push: vi.fn(), + replace: vi.fn(), + currentRoute: ref({ path: '/', name: '', meta: {} }), + isReady: vi.fn().mockResolvedValue(true) + }, + initRouter: vi.fn() + }; +}); +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal(); + const { ref } = require('vue'); + return { + ...actual, + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: vi.fn(), + currentRoute: ref({ path: '/', name: '', meta: {} }) + })) + }; +}); +vi.mock('../../../../plugin/interopApi', () => ({ initInteropApi: vi.fn() })); +vi.mock('../../../../service/database', () => ({ + database: new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === '__esModule') return false; + return vi.fn().mockResolvedValue(null); + } + } + ) +})); +vi.mock('../../../../service/config', () => ({ + default: { + init: vi.fn(), + getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'), + setString: vi.fn(), + getBool: vi.fn().mockImplementation((_k, d) => d ?? false), + setBool: vi.fn(), + getInt: vi.fn().mockImplementation((_k, d) => d ?? 0), + setInt: vi.fn(), + getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0), + setFloat: vi.fn(), + getObject: vi.fn().mockReturnValue(null), + setObject: vi.fn(), + getArray: vi.fn().mockReturnValue([]), + setArray: vi.fn(), + remove: vi.fn() + } +})); +vi.mock('../../../../service/jsonStorage', () => ({ default: vi.fn() })); +vi.mock('../../../../service/watchState', () => ({ + watchState: { isLoggedIn: false } +})); + +import UserDialogAvatarsTab from '../UserDialogAvatarsTab.vue'; +import { useUserStore } from '../../../../stores'; + +// ─── Helpers ───────────────────────────────────────────────────────── + +const MOCK_AVATARS = [ + { + id: 'avtr_1', + name: 'Alpha', + thumbnailImageUrl: 'https://img/1.png', + releaseStatus: 'public', + authorId: 'usr_me' + }, + { + id: 'avtr_2', + name: 'Beta', + thumbnailImageUrl: 'https://img/2.png', + releaseStatus: 'private', + authorId: 'usr_me' + }, + { + id: 'avtr_3', + name: 'Gamma', + thumbnailImageUrl: 'https://img/3.png', + releaseStatus: 'public', + authorId: 'usr_me' + } +]; + +/** + * + * @param overrides + */ +function mountComponent(overrides = {}) { + const pinia = createTestingPinia({ + stubActions: false + }); + + const userStore = useUserStore(pinia); + userStore.userDialog = { + id: 'usr_me', + ref: { id: 'usr_me' }, + avatars: [...MOCK_AVATARS], + avatarSorting: 'name', + avatarReleaseStatus: 'all', + isAvatarsLoading: false, + isWorldsLoading: false, + ...overrides + }; + userStore.currentUser = { + id: 'usr_me', + ...overrides.currentUser + }; + + return mount(UserDialogAvatarsTab, { + global: { + plugins: [pinia], + stubs: { + RefreshCw: { template: '' }, + DeprecationAlert: { + template: '' + } + } + } + }); +} + +// ─── Tests ─────────────────────────────────────────────────────────── + +describe('UserDialogAvatarsTab.vue', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('rendering', () => { + test('renders avatar count', () => { + const wrapper = mountComponent(); + expect(wrapper.text()).toContain('3'); + }); + + test('renders all avatars when releaseStatus is "all"', () => { + const wrapper = mountComponent(); + const items = wrapper.findAll('.cursor-pointer'); + expect(items).toHaveLength(3); + }); + + test('renders avatar names', () => { + const wrapper = mountComponent(); + expect(wrapper.text()).toContain('Alpha'); + expect(wrapper.text()).toContain('Beta'); + expect(wrapper.text()).toContain('Gamma'); + }); + + test('renders avatar thumbnails', () => { + const wrapper = mountComponent(); + const images = wrapper.findAll('img'); + expect(images).toHaveLength(3); + expect(images[0].attributes('src')).toBe('https://img/1.png'); + }); + + test('shows deprecation alert for current user', () => { + const wrapper = mountComponent(); + expect(wrapper.find('.deprecation-stub').exists()).toBe(true); + }); + + test('hides deprecation alert for other users', () => { + const wrapper = mountComponent({ + id: 'usr_other', + ref: { id: 'usr_other' } + }); + expect(wrapper.find('.deprecation-stub').exists()).toBe(false); + }); + + test('shows empty state when no avatars and not loading', () => { + const wrapper = mountComponent({ avatars: [] }); + expect(wrapper.text()).toContain('0'); + }); + }); + + describe('filtering by releaseStatus', () => { + test('shows only public avatars when releaseStatus is "public"', () => { + const wrapper = mountComponent({ avatarReleaseStatus: 'public' }); + expect(wrapper.text()).toContain('Alpha'); + expect(wrapper.text()).toContain('Gamma'); + expect(wrapper.text()).not.toContain('Beta'); + }); + + test('shows only private avatars when releaseStatus is "private"', () => { + const wrapper = mountComponent({ avatarReleaseStatus: 'private' }); + expect(wrapper.text()).toContain('Beta'); + expect(wrapper.text()).not.toContain('Alpha'); + expect(wrapper.text()).not.toContain('Gamma'); + }); + }); + + describe('search', () => { + test('renders search input for current user', () => { + const wrapper = mountComponent(); + const input = wrapper.find('input'); + expect(input.exists()).toBe(true); + }); + + test('does not render search input for other users', () => { + const wrapper = mountComponent({ + id: 'usr_other', + ref: { id: 'usr_other' } + }); + const input = wrapper.find('input'); + expect(input.exists()).toBe(false); + }); + + test('filters avatars by search query', async () => { + const wrapper = mountComponent(); + const input = wrapper.find('input'); + await input.setValue('alpha'); + expect(wrapper.text()).toContain('Alpha'); + expect(wrapper.text()).not.toContain('Beta'); + expect(wrapper.text()).not.toContain('Gamma'); + }); + + test('search is case-insensitive', async () => { + const wrapper = mountComponent(); + const input = wrapper.find('input'); + await input.setValue('BETA'); + expect(wrapper.text()).toContain('Beta'); + }); + + test('shows all avatars when search query is cleared', async () => { + const wrapper = mountComponent(); + const input = wrapper.find('input'); + await input.setValue('alpha'); + await input.setValue(''); + expect(wrapper.text()).toContain('Alpha'); + expect(wrapper.text()).toContain('Beta'); + expect(wrapper.text()).toContain('Gamma'); + }); + }); + + describe('loading state', () => { + test('disables refresh button when loading', () => { + const wrapper = mountComponent({ isAvatarsLoading: true }); + const button = wrapper.find('button'); + expect(button.attributes('disabled')).toBeDefined(); + }); + }); +}); diff --git a/src/components/dialogs/UserDialog/__tests__/UserDialogMutualFriendsTab.test.js b/src/components/dialogs/UserDialog/__tests__/UserDialogMutualFriendsTab.test.js new file mode 100644 index 00000000..318fdeaa --- /dev/null +++ b/src/components/dialogs/UserDialog/__tests__/UserDialogMutualFriendsTab.test.js @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createTestingPinia } from '@pinia/testing'; +import { mount } from '@vue/test-utils'; + +// ─── Mocks ─────────────────────────────────────────────────────────── + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key) + }), + createI18n: () => ({ + global: { t: (key) => key }, + install: vi.fn() + }) +})); + +vi.mock('../../../../plugin/router', () => { + const { ref } = require('vue'); + return { + router: { + beforeEach: vi.fn(), + push: vi.fn(), + replace: vi.fn(), + currentRoute: ref({ path: '/', name: '', meta: {} }), + isReady: vi.fn().mockResolvedValue(true) + }, + initRouter: vi.fn() + }; +}); +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal(); + const { ref } = require('vue'); + return { + ...actual, + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: vi.fn(), + currentRoute: ref({ path: '/', name: '', meta: {} }) + })) + }; +}); +vi.mock('../../../../plugin/interopApi', () => ({ initInteropApi: vi.fn() })); +vi.mock('../../../../service/database', () => ({ + database: new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === '__esModule') return false; + return vi.fn().mockResolvedValue(null); + } + } + ) +})); +vi.mock('../../../../service/config', () => ({ + default: { + init: vi.fn(), + getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'), + setString: vi.fn(), + getBool: vi.fn().mockImplementation((_k, d) => d ?? false), + setBool: vi.fn(), + getInt: vi.fn().mockImplementation((_k, d) => d ?? 0), + setInt: vi.fn(), + getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0), + setFloat: vi.fn(), + getObject: vi.fn().mockReturnValue(null), + setObject: vi.fn(), + getArray: vi.fn().mockReturnValue([]), + setArray: vi.fn(), + remove: vi.fn() + } +})); +vi.mock('../../../../service/jsonStorage', () => ({ default: vi.fn() })); +vi.mock('../../../../service/watchState', () => ({ + watchState: { isLoggedIn: false } +})); +vi.mock('../../../../service/request', () => ({ + request: vi.fn().mockResolvedValue({ json: {} }), + processBulk: vi.fn(), + buildRequestInit: vi.fn(), + parseResponse: vi.fn(), + shouldIgnoreError: vi.fn(), + $throw: vi.fn(), + failedGetRequests: new Map() +})); + +import UserDialogMutualFriendsTab from '../UserDialogMutualFriendsTab.vue'; +import { useUserStore } from '../../../../stores'; +import { userDialogMutualFriendSortingOptions } from '../../../../shared/constants'; + +// ─── Helpers ───────────────────────────────────────────────────────── + +const MOCK_MUTUAL_FRIENDS = [ + { + id: 'usr_1', + displayName: 'Charlie', + $userColour: '#ff0000', + currentAvatarThumbnailImageUrl: 'https://img/charlie.png' + }, + { + id: 'usr_2', + displayName: 'Alice', + $userColour: '#00ff00', + currentAvatarThumbnailImageUrl: 'https://img/alice.png' + }, + { + id: 'usr_3', + displayName: 'Bob', + $userColour: '#0000ff', + currentAvatarThumbnailImageUrl: 'https://img/bob.png' + } +]; + +/** + * + * @param overrides + */ +function mountComponent(overrides = {}) { + const pinia = createTestingPinia({ + stubActions: false + }); + + const userStore = useUserStore(pinia); + userStore.userDialog = { + id: 'usr_target', + ref: { id: 'usr_target' }, + mutualFriends: [...MOCK_MUTUAL_FRIENDS], + mutualFriendSorting: userDialogMutualFriendSortingOptions.alphabetical, + isMutualFriendsLoading: false, + ...overrides + }; + userStore.currentUser = { + id: 'usr_me', + hasSharedConnectionsOptOut: false, + ...overrides.currentUser + }; + + return mount(UserDialogMutualFriendsTab, { + global: { + plugins: [pinia], + stubs: { + RefreshCw: { template: '' } + } + } + }); +} + +// ─── Tests ─────────────────────────────────────────────────────────── + +describe('UserDialogMutualFriendsTab.vue', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('rendering', () => { + test('renders mutual friend count', () => { + const wrapper = mountComponent(); + expect(wrapper.text()).toContain('3'); + }); + + test('renders all mutual friends', () => { + const wrapper = mountComponent(); + const items = wrapper.findAll('li'); + expect(items).toHaveLength(3); + }); + + test('renders friend display names', () => { + const wrapper = mountComponent(); + expect(wrapper.text()).toContain('Charlie'); + expect(wrapper.text()).toContain('Alice'); + expect(wrapper.text()).toContain('Bob'); + }); + + test('renders friend avatar images', () => { + const wrapper = mountComponent(); + const images = wrapper.findAll('img'); + expect(images).toHaveLength(3); + }); + + test('applies user colour to display name', () => { + const wrapper = mountComponent(); + const nameSpan = wrapper.find('span[style*="color"]'); + expect(nameSpan.exists()).toBe(true); + }); + + test('renders empty list when no mutual friends', () => { + const wrapper = mountComponent({ mutualFriends: [] }); + const items = wrapper.findAll('li'); + expect(items).toHaveLength(0); + expect(wrapper.text()).toContain('0'); + }); + }); + + describe('loading state', () => { + test('disables refresh button when loading', () => { + const wrapper = mountComponent({ isMutualFriendsLoading: true }); + const button = wrapper.find('button'); + expect(button.attributes('disabled')).toBeDefined(); + }); + + test('refresh button is enabled when not loading', () => { + const wrapper = mountComponent({ isMutualFriendsLoading: false }); + const button = wrapper.find('button'); + expect(button.attributes('disabled')).toBeUndefined(); + }); + }); + + describe('click interactions', () => { + test('calls showUserDialog when a friend is clicked', async () => { + const pinia = createTestingPinia({ stubActions: false }); + const userStore = useUserStore(pinia); + userStore.userDialog = { + id: 'usr_target', + ref: { id: 'usr_target' }, + mutualFriends: [...MOCK_MUTUAL_FRIENDS], + mutualFriendSorting: + userDialogMutualFriendSortingOptions.alphabetical, + isMutualFriendsLoading: false + }; + userStore.currentUser = { id: 'usr_me' }; + const showUserDialogSpy = vi + .spyOn(userStore, 'showUserDialog') + .mockImplementation(() => {}); + + const wrapper = mount(UserDialogMutualFriendsTab, { + global: { + plugins: [pinia], + stubs: { + RefreshCw: { template: '' } + } + } + }); + + const firstItem = wrapper.findAll('li')[0]; + await firstItem.trigger('click'); + expect(showUserDialogSpy).toHaveBeenCalledWith('usr_1'); + }); + }); +}); diff --git a/src/components/dialogs/UserDialog/__tests__/UserDialogWorldsTab.test.js b/src/components/dialogs/UserDialog/__tests__/UserDialogWorldsTab.test.js new file mode 100644 index 00000000..87db8584 --- /dev/null +++ b/src/components/dialogs/UserDialog/__tests__/UserDialogWorldsTab.test.js @@ -0,0 +1,263 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createTestingPinia } from '@pinia/testing'; +import { mount } from '@vue/test-utils'; + +// ─── Mocks ─────────────────────────────────────────────────────────── + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key) + }), + createI18n: () => ({ + global: { t: (key) => key }, + install: vi.fn() + }) +})); + +vi.mock('../../../../plugin/router', () => { + const { ref } = require('vue'); + return { + router: { + beforeEach: vi.fn(), + push: vi.fn(), + replace: vi.fn(), + currentRoute: ref({ path: '/', name: '', meta: {} }), + isReady: vi.fn().mockResolvedValue(true) + }, + initRouter: vi.fn() + }; +}); +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal(); + const { ref } = require('vue'); + return { + ...actual, + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: vi.fn(), + currentRoute: ref({ path: '/', name: '', meta: {} }) + })) + }; +}); +vi.mock('../../../../plugin/interopApi', () => ({ initInteropApi: vi.fn() })); +vi.mock('../../../../service/database', () => ({ + database: new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === '__esModule') return false; + return vi.fn().mockResolvedValue(null); + } + } + ) +})); +vi.mock('../../../../service/config', () => ({ + default: { + init: vi.fn(), + getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'), + setString: vi.fn(), + getBool: vi.fn().mockImplementation((_k, d) => d ?? false), + setBool: vi.fn(), + getInt: vi.fn().mockImplementation((_k, d) => d ?? 0), + setInt: vi.fn(), + getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0), + setFloat: vi.fn(), + getObject: vi.fn().mockReturnValue(null), + setObject: vi.fn(), + getArray: vi.fn().mockReturnValue([]), + setArray: vi.fn(), + remove: vi.fn() + } +})); +vi.mock('../../../../service/jsonStorage', () => ({ default: vi.fn() })); +vi.mock('../../../../service/watchState', () => ({ + watchState: { isLoggedIn: false } +})); +vi.mock('../../../../service/request', () => ({ + request: vi.fn().mockResolvedValue({ json: {} }), + processBulk: vi.fn(), + buildRequestInit: vi.fn(), + parseResponse: vi.fn(), + shouldIgnoreError: vi.fn(), + $throw: vi.fn(), + failedGetRequests: new Map() +})); + +import UserDialogWorldsTab from '../UserDialogWorldsTab.vue'; +import { useUserStore } from '../../../../stores'; +import { + userDialogWorldSortingOptions, + userDialogWorldOrderOptions +} from '../../../../shared/constants'; + +// ─── Helpers ───────────────────────────────────────────────────────── + +const MOCK_WORLDS = [ + { + id: 'wrld_1', + name: 'Sunset Valley', + thumbnailImageUrl: 'https://img/world1.png', + occupants: 12, + authorId: 'usr_me' + }, + { + id: 'wrld_2', + name: 'Midnight Club', + thumbnailImageUrl: 'https://img/world2.png', + occupants: 5, + authorId: 'usr_me' + }, + { + id: 'wrld_3', + name: 'Cozy Cottage', + thumbnailImageUrl: 'https://img/world3.png', + occupants: 0, + authorId: 'usr_me' + } +]; + +/** + * + * @param overrides + */ +function mountComponent(overrides = {}) { + const pinia = createTestingPinia({ + stubActions: false + }); + + const userStore = useUserStore(pinia); + userStore.userDialog = { + id: 'usr_me', + ref: { id: 'usr_me' }, + worlds: [...MOCK_WORLDS], + worldSorting: userDialogWorldSortingOptions.name, + worldOrder: userDialogWorldOrderOptions.descending, + isWorldsLoading: false, + ...overrides + }; + userStore.currentUser = { + id: 'usr_me', + ...overrides.currentUser + }; + + return mount(UserDialogWorldsTab, { + global: { + plugins: [pinia], + stubs: { + RefreshCw: { template: '' } + } + } + }); +} + +// ─── Tests ─────────────────────────────────────────────────────────── + +describe('UserDialogWorldsTab.vue', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('rendering', () => { + test('renders world count', () => { + const wrapper = mountComponent(); + expect(wrapper.text()).toContain('3'); + }); + + test('renders all worlds', () => { + const wrapper = mountComponent(); + const items = wrapper.findAll('.cursor-pointer'); + expect(items).toHaveLength(3); + }); + + test('renders world names', () => { + const wrapper = mountComponent(); + expect(wrapper.text()).toContain('Sunset Valley'); + expect(wrapper.text()).toContain('Midnight Club'); + expect(wrapper.text()).toContain('Cozy Cottage'); + }); + + test('renders world thumbnail images', () => { + const wrapper = mountComponent(); + const images = wrapper.findAll('img'); + expect(images).toHaveLength(3); + expect(images[0].attributes('src')).toBe('https://img/world1.png'); + }); + + test('renders occupant count for worlds with occupants', () => { + const wrapper = mountComponent(); + expect(wrapper.text()).toContain('12'); + expect(wrapper.text()).toContain('5'); + }); + + test('does not render occupant count for worlds with zero occupants', () => { + const wrapper = mountComponent({ + worlds: [ + { + id: 'wrld_3', + name: 'Empty', + thumbnailImageUrl: '', + occupants: 0 + } + ] + }); + // The (0) should NOT be rendered because v-if="world.occupants" is falsy for 0 + const items = wrapper.findAll('.cursor-pointer'); + expect(items).toHaveLength(1); + expect(wrapper.text()).not.toContain('(0)'); + }); + + test('renders empty state when no worlds and not loading', () => { + const wrapper = mountComponent({ worlds: [] }); + expect(wrapper.text()).toContain('0'); + }); + }); + + describe('loading state', () => { + test('disables refresh button when loading', () => { + const wrapper = mountComponent({ isWorldsLoading: true }); + const button = wrapper.find('button'); + expect(button.attributes('disabled')).toBeDefined(); + }); + + test('refresh button is enabled when not loading', () => { + const wrapper = mountComponent({ isWorldsLoading: false }); + const button = wrapper.find('button'); + expect(button.attributes('disabled')).toBeUndefined(); + }); + }); + + describe('click interactions', () => { + test('calls showWorldDialog when a world is clicked', async () => { + const pinia = createTestingPinia({ stubActions: false }); + const userStore = useUserStore(pinia); + const { useWorldStore } = await import('../../../../stores'); + const worldStore = useWorldStore(pinia); + const showWorldDialogSpy = vi + .spyOn(worldStore, 'showWorldDialog') + .mockImplementation(() => {}); + + userStore.userDialog = { + id: 'usr_me', + ref: { id: 'usr_me' }, + worlds: [...MOCK_WORLDS], + worldSorting: userDialogWorldSortingOptions.name, + worldOrder: userDialogWorldOrderOptions.descending, + isWorldsLoading: false + }; + userStore.currentUser = { id: 'usr_me' }; + + const wrapper = mount(UserDialogWorldsTab, { + global: { + plugins: [pinia], + stubs: { + RefreshCw: { template: '' } + } + } + }); + + const firstItem = wrapper.findAll('.cursor-pointer')[0]; + await firstItem.trigger('click'); + expect(showWorldDialogSpy).toHaveBeenCalledWith('wrld_1'); + }); + }); +}); diff --git a/src/composables/__tests__/useOptionKeySelect.test.js b/src/composables/__tests__/useOptionKeySelect.test.js new file mode 100644 index 00000000..0c94fd61 --- /dev/null +++ b/src/composables/__tests__/useOptionKeySelect.test.js @@ -0,0 +1,196 @@ +import { describe, expect, test, vi } from 'vitest'; +import { ref } from 'vue'; +import { useOptionKeySelect } from '../useOptionKeySelect'; + +const OPTIONS = { + alphabetical: { value: 'alphabetical', name: 'sort.alphabetical' }, + members: { value: 'members', name: 'sort.members' }, + recent: { value: 'recent', name: 'sort.recent' } +}; + +describe('useOptionKeySelect', () => { + describe('selectedKey', () => { + test('returns the key when current value is an exact reference match', () => { + const current = ref(OPTIONS.members); + const { selectedKey } = useOptionKeySelect( + OPTIONS, + () => current.value, + vi.fn() + ); + expect(selectedKey.value).toBe('members'); + }); + + test('returns the key when matching by value property', () => { + const current = ref({ + value: 'alphabetical', + name: 'sort.alphabetical' + }); + const { selectedKey } = useOptionKeySelect( + OPTIONS, + () => current.value, + vi.fn() + ); + expect(selectedKey.value).toBe('alphabetical'); + }); + + test('returns the key when matching by name property only', () => { + const current = ref({ value: 'different', name: 'sort.recent' }); + const { selectedKey } = useOptionKeySelect( + OPTIONS, + () => current.value, + vi.fn() + ); + expect(selectedKey.value).toBe('recent'); + }); + + test('returns empty string when no match is found', () => { + const current = ref({ value: 'unknown', name: 'sort.unknown' }); + const { selectedKey } = useOptionKeySelect( + OPTIONS, + () => current.value, + vi.fn() + ); + expect(selectedKey.value).toBe(''); + }); + + test('returns empty string when current value is null', () => { + const { selectedKey } = useOptionKeySelect( + OPTIONS, + () => null, + vi.fn() + ); + expect(selectedKey.value).toBe(''); + }); + + test('returns empty string when current value is undefined', () => { + const { selectedKey } = useOptionKeySelect( + OPTIONS, + () => undefined, + vi.fn() + ); + expect(selectedKey.value).toBe(''); + }); + + test('is reactive to changes in the getter', () => { + const current = ref(OPTIONS.alphabetical); + const { selectedKey } = useOptionKeySelect( + OPTIONS, + () => current.value, + vi.fn() + ); + expect(selectedKey.value).toBe('alphabetical'); + + current.value = OPTIONS.recent; + expect(selectedKey.value).toBe('recent'); + }); + + test('returns the first matching key when multiple options could match', () => { + const dupeOptions = { + first: { value: 'shared', name: 'sort.shared' }, + second: { value: 'shared', name: 'sort.shared' } + }; + const current = ref({ value: 'shared', name: 'sort.shared' }); + const { selectedKey } = useOptionKeySelect( + dupeOptions, + () => current.value, + vi.fn() + ); + expect(selectedKey.value).toBe('first'); + }); + }); + + describe('selectByKey', () => { + test('calls onSelect with the correct option when key exists', () => { + const onSelect = vi.fn(); + const { selectByKey } = useOptionKeySelect( + OPTIONS, + () => null, + onSelect + ); + + selectByKey('members'); + expect(onSelect).toHaveBeenCalledWith(OPTIONS.members); + }); + + test('does not call onSelect when key does not exist', () => { + const onSelect = vi.fn(); + const { selectByKey } = useOptionKeySelect( + OPTIONS, + () => null, + onSelect + ); + + selectByKey('nonexistent'); + expect(onSelect).not.toHaveBeenCalled(); + }); + + test('does not call onSelect when key is empty string', () => { + const onSelect = vi.fn(); + const { selectByKey } = useOptionKeySelect( + OPTIONS, + () => null, + onSelect + ); + + selectByKey(''); + expect(onSelect).not.toHaveBeenCalled(); + }); + + test('passes the full option object, not just the value', () => { + const onSelect = vi.fn(); + const { selectByKey } = useOptionKeySelect( + OPTIONS, + () => null, + onSelect + ); + + selectByKey('recent'); + expect(onSelect).toHaveBeenCalledWith({ + value: 'recent', + name: 'sort.recent' + }); + }); + }); + + describe('edge cases', () => { + test('works with empty options map', () => { + const onSelect = vi.fn(); + const { selectedKey, selectByKey } = useOptionKeySelect( + {}, + () => null, + onSelect + ); + expect(selectedKey.value).toBe(''); + + selectByKey('anything'); + expect(onSelect).not.toHaveBeenCalled(); + }); + + test('works with numeric keys in options map', () => { + const numericOptions = { + 0: { value: 'zero', name: 'sort.zero' }, + 1: { value: 'one', name: 'sort.one' } + }; + const current = ref(numericOptions[1]); + const { selectedKey } = useOptionKeySelect( + numericOptions, + () => current.value, + vi.fn() + ); + expect(selectedKey.value).toBe('1'); + }); + + test('handles option with missing value property gracefully', () => { + const partialOptions = { + noValue: { name: 'sort.noValue' } + }; + const current = ref({ name: 'sort.noValue' }); + const { selectedKey } = useOptionKeySelect( + partialOptions, + () => current.value, + vi.fn() + ); + expect(selectedKey.value).toBe('noValue'); + }); + }); +}); diff --git a/src/composables/useOptionKeySelect.js b/src/composables/useOptionKeySelect.js new file mode 100644 index 00000000..2b29a04d --- /dev/null +++ b/src/composables/useOptionKeySelect.js @@ -0,0 +1,43 @@ +import { computed } from 'vue'; + +/** + * A composable that provides key-based selection for an options map. + * Extracts the repeated pattern of finding the current option's key from an + * options object and selecting a new option by key. + * + * @param {Object} optionsMap - A static object mapping string keys to option objects + * (each option should have at least `value` and `name` properties). + * @param {() => any} getCurrentValue - A getter function that returns the currently + * selected option value (e.g., `() => userDialog.value.worldSorting`). + * @param {(option: any) => void} onSelect - Callback invoked when a new option is + * selected by key. Receives the full option object. + * @returns {{ selectedKey: import('vue').ComputedRef, selectByKey: (key: string) => void }} + */ +export function useOptionKeySelect(optionsMap, getCurrentValue, onSelect) { + const selectedKey = computed(() => { + const current = getCurrentValue(); + const found = Object.entries(optionsMap).find(([, option]) => { + if (option === current) { + return true; + } + return ( + option?.value === current?.value || + option?.name === current?.name + ); + }); + return found ? String(found[0]) : ''; + }); + + /** + * @param {string} key + */ + function selectByKey(key) { + const option = optionsMap[key]; + if (!option) { + return; + } + onSelect(option); + } + + return { selectedKey, selectByKey }; +}