From fe176f22ff991fd017d10a6e5c8a99604f13c83b Mon Sep 17 00:00:00 2001 From: pa Date: Tue, 10 Mar 2026 21:40:52 +0900 Subject: [PATCH] refactor untils --- .../dialogs/AvatarDialog/AvatarDialog.vue | 13 +- .../__tests__/useAvatarDialogCommands.test.js | 7 +- .../AvatarDialog/useAvatarDialogCommands.js | 24 +- .../GroupDialog/GroupDialogInfoTab.vue | 5 +- .../UserDialog/EditNoteAndMemoDialog.vue | 3 +- .../dialogs/UserDialog/UserDialogInfoTab.vue | 4 +- .../WorldDialog/WorldDialogInstancesTab.vue | 4 +- .../__tests__/useWorldDialogCommands.test.js | 7 +- .../WorldDialog/useWorldDialogCommands.js | 19 +- src/coordinators/cacheCoordinator.js | 194 +++++++++++ .../dateCoordinator.js} | 2 +- src/coordinators/friendSyncCoordinator.js | 2 +- src/coordinators/imageUploadCoordinator.js | 198 ++++++++++++ src/coordinators/instanceCoordinator.js | 18 ++ .../memoCoordinator.js} | 4 +- src/coordinators/userCoordinator.js | 77 ++--- src/coordinators/worldCoordinator.js | 16 +- src/shared/utils/__tests__/appActions.test.js | 153 +++++++++ src/shared/utils/__tests__/chart.test.js | 40 +++ .../utils/__tests__/imageUpload.test.js | 5 +- src/shared/utils/__tests__/memos.test.js | 162 ++++++++++ src/shared/utils/appActions.js | 102 ++++++ src/shared/utils/base/__tests__/date.test.js | 2 +- src/shared/utils/common.js | 302 ++---------------- src/shared/utils/imageUpload.js | 203 +----------- src/shared/utils/index.js | 3 +- src/shared/utils/instance.js | 24 +- src/stores/friend.js | 7 +- src/stores/gallery.js | 2 +- src/stores/notification/index.js | 14 +- src/stores/user.js | 8 +- .../__tests__/FriendsLocations.test.js | 12 + src/views/MyAvatars/MyAvatars.vue | 19 +- .../__tests__/FriendsSidebar.test.js | 5 + .../__tests__/NotificationItem.test.js | 27 +- src/views/Tools/Gallery.vue | 3 +- 36 files changed, 1062 insertions(+), 628 deletions(-) create mode 100644 src/coordinators/cacheCoordinator.js rename src/{shared/utils/base/date.js => coordinators/dateCoordinator.js} (97%) create mode 100644 src/coordinators/imageUploadCoordinator.js create mode 100644 src/coordinators/instanceCoordinator.js rename src/{shared/utils/memos.js => coordinators/memoCoordinator.js} (95%) create mode 100644 src/shared/utils/__tests__/appActions.test.js create mode 100644 src/shared/utils/__tests__/chart.test.js create mode 100644 src/shared/utils/__tests__/memos.test.js create mode 100644 src/shared/utils/appActions.js diff --git a/src/components/dialogs/AvatarDialog/AvatarDialog.vue b/src/components/dialogs/AvatarDialog/AvatarDialog.vue index 640fdc3d..6924a85d 100644 --- a/src/components/dialogs/AvatarDialog/AvatarDialog.vue +++ b/src/components/dialogs/AvatarDialog/AvatarDialog.vue @@ -600,14 +600,18 @@ import { avatarRequest } from '../../../api'; import { database } from '../../../services/database'; import { formatJsonVars } from '../../../shared/utils/base/ui'; - import { handleImageUploadInput } from '../../../shared/utils/imageUpload'; + import { handleImageUploadInput } from '../../../coordinators/imageUploadCoordinator'; import { runDeleteVRChatCacheFlow as deleteVRChatCache } from '../../../coordinators/gameCoordinator'; - import { showAvatarDialog, applyAvatar, selectAvatarWithoutConfirmation } from '../../../coordinators/avatarCoordinator'; + import { + showAvatarDialog, + applyAvatar, + selectAvatarWithoutConfirmation + } from '../../../coordinators/avatarCoordinator'; import { useAvatarDialogCommands } from './useAvatarDialogCommands'; import DialogJsonTab from '../DialogJsonTab.vue'; import ImageCropDialog from '../ImageCropDialog.vue'; -import { showUserDialog } from '../../../coordinators/userCoordinator'; + import { showUserDialog } from '../../../coordinators/userCoordinator'; const SetAvatarStylesDialog = defineAsyncComponent(() => import('./SetAvatarStylesDialog.vue')); const SetAvatarTagsDialog = defineAsyncComponent(() => import('./SetAvatarTagsDialog.vue')); @@ -617,8 +621,7 @@ import { showUserDialog } from '../../../coordinators/userCoordinator'; const avatarStore = useAvatarStore(); const { cachedAvatarModerations, cachedAvatars } = avatarStore; const { avatarDialog } = storeToRefs(avatarStore); - const { getAvatarGallery, applyAvatarModeration } = - avatarStore; + const { getAvatarGallery, applyAvatarModeration } = avatarStore; const { showFavoriteDialog } = useFavoriteStore(); const { isGameRunning } = storeToRefs(useGameStore()); const { showFullscreenImageDialog } = useGalleryStore(); diff --git a/src/components/dialogs/AvatarDialog/__tests__/useAvatarDialogCommands.test.js b/src/components/dialogs/AvatarDialog/__tests__/useAvatarDialogCommands.test.js index 7cdcc9f7..c39c516d 100644 --- a/src/components/dialogs/AvatarDialog/__tests__/useAvatarDialogCommands.test.js +++ b/src/components/dialogs/AvatarDialog/__tests__/useAvatarDialogCommands.test.js @@ -30,11 +30,14 @@ vi.mock('../../../../shared/utils', () => ({ vi.mock('../../../../shared/utils/imageUpload', () => ({ handleImageUploadInput: vi.fn(), readFileAsBase64: vi.fn(), - resizeImageToFitLimits: vi.fn(), - uploadImageLegacy: vi.fn(), withUploadTimeout: vi.fn() })); +vi.mock('../../../../coordinators/imageUploadCoordinator', () => ({ + resizeImageToFitLimits: vi.fn(), + uploadImageLegacy: vi.fn() +})); + const { copyToClipboard, openExternalLink } = await import('../../../../shared/utils'); const { favoriteRequest, avatarRequest, avatarModerationRequest } = diff --git a/src/components/dialogs/AvatarDialog/useAvatarDialogCommands.js b/src/components/dialogs/AvatarDialog/useAvatarDialogCommands.js index b04da142..87896e81 100644 --- a/src/components/dialogs/AvatarDialog/useAvatarDialogCommands.js +++ b/src/components/dialogs/AvatarDialog/useAvatarDialogCommands.js @@ -1,4 +1,5 @@ import { ref } from 'vue'; + import { avatarModerationRequest, avatarRequest, @@ -11,18 +12,32 @@ import { } from '../../../shared/utils'; import { handleImageUploadInput, - readFileAsBase64, resizeImageToFitLimits, - uploadImageLegacy, + uploadImageLegacy +} from '../../../coordinators/imageUploadCoordinator'; +import { + readFileAsBase64, withUploadTimeout } from '../../../shared/utils/imageUpload'; /** * Composable for AvatarDialog command dispatch. * Uses a command map pattern instead of nested switch-case chains. - * * @param {import('vue').Ref} avatarDialog - reactive ref to the avatar dialog state * @param {object} deps - external dependencies + * @param deps.t + * @param deps.toast + * @param deps.modalStore + * @param deps.userDialog + * @param deps.currentUser + * @param deps.cachedAvatars + * @param deps.cachedAvatarModerations + * @param deps.showAvatarDialog + * @param deps.showFavoriteDialog + * @param deps.applyAvatarModeration + * @param deps.applyAvatar + * @param deps.sortUserDialogAvatars + * @param deps.uiStore * @returns {object} command composable API */ export function useAvatarDialogCommands( @@ -205,6 +220,9 @@ export function useAvatarDialogCommands( // String commands: delegate to component callback // Confirmed commands: { confirm: () => ({title, description, ...}), handler: fn } + /** + * + */ function buildCommandMap() { const D = () => avatarDialog.value; diff --git a/src/components/dialogs/GroupDialog/GroupDialogInfoTab.vue b/src/components/dialogs/GroupDialog/GroupDialogInfoTab.vue index 14ebb74e..b29aa518 100644 --- a/src/components/dialogs/GroupDialog/GroupDialogInfoTab.vue +++ b/src/components/dialogs/GroupDialog/GroupDialogInfoTab.vue @@ -352,16 +352,16 @@ getFaviconUrl, hasGroupPermission, openExternalLink, - refreshInstancePlayerCount, userImage, userStatusClass } from '../../../shared/utils'; + import { refreshInstancePlayerCount } from '../../../coordinators/instanceCoordinator'; import { useGalleryStore, useGroupStore, useInstanceStore, useLocationStore, useUserStore } from '../../../stores'; import { useGroupCalendarEvents } from './useGroupCalendarEvents'; import GroupCalendarEventCard from '../../../views/Tools/components/GroupCalendarEventCard.vue'; import InstanceActionBar from '../../InstanceActionBar.vue'; -import { showUserDialog } from '../../../coordinators/userCoordinator'; + import { showUserDialog } from '../../../coordinators/userCoordinator'; const props = defineProps({ showGroupPostEditDialog: { @@ -376,7 +376,6 @@ import { showUserDialog } from '../../../coordinators/userCoordinator'; const { t } = useI18n(); - const { groupDialog } = storeToRefs(useGroupStore()); const { lastLocation } = storeToRefs(useLocationStore()); const { showFullscreenImageDialog } = useGalleryStore(); diff --git a/src/components/dialogs/UserDialog/EditNoteAndMemoDialog.vue b/src/components/dialogs/UserDialog/EditNoteAndMemoDialog.vue index 59e89cbe..11df1445 100644 --- a/src/components/dialogs/UserDialog/EditNoteAndMemoDialog.vue +++ b/src/components/dialogs/UserDialog/EditNoteAndMemoDialog.vue @@ -54,7 +54,8 @@ import { useI18n } from 'vue-i18n'; import { miscRequest, userRequest } from '../../../api'; - import { replaceBioSymbols, saveUserMemo } from '../../../shared/utils'; + import { replaceBioSymbols } from '../../../shared/utils'; + import { saveUserMemo } from '../../../coordinators/memoCoordinator'; import { useAppearanceSettingsStore, useUserStore } from '../../../stores'; const { userDialog } = storeToRefs(useUserStore()); diff --git a/src/components/dialogs/UserDialog/UserDialogInfoTab.vue b/src/components/dialogs/UserDialog/UserDialogInfoTab.vue index 2043307a..05fb5540 100644 --- a/src/components/dialogs/UserDialog/UserDialogInfoTab.vue +++ b/src/components/dialogs/UserDialog/UserDialogInfoTab.vue @@ -483,13 +483,13 @@ isFriendOnline, isRealInstance, openExternalLink, - refreshInstancePlayerCount, timeToText, userImage, userOnlineFor, userOnlineForTimestamp, userStatusClass } from '../../../shared/utils'; + import { refreshInstancePlayerCount } from '../../../coordinators/instanceCoordinator'; import { useAdvancedSettingsStore, useAppearanceSettingsStore, @@ -505,7 +505,7 @@ import { queryRequest, userRequest } from '../../../api'; import InstanceActionBar from '../../InstanceActionBar.vue'; -import { showUserDialog } from '../../../coordinators/userCoordinator'; + import { showUserDialog } from '../../../coordinators/userCoordinator'; const EditNoteAndMemoDialog = defineAsyncComponent(() => import('./EditNoteAndMemoDialog.vue')); diff --git a/src/components/dialogs/WorldDialog/WorldDialogInstancesTab.vue b/src/components/dialogs/WorldDialog/WorldDialogInstancesTab.vue index 1411a9ac..37c0cb79 100644 --- a/src/components/dialogs/WorldDialog/WorldDialogInstancesTab.vue +++ b/src/components/dialogs/WorldDialog/WorldDialogInstancesTab.vue @@ -108,7 +108,7 @@ import { storeToRefs } from 'pinia'; import { useI18n } from 'vue-i18n'; - import { refreshInstancePlayerCount } from '../../../shared/utils'; + import { refreshInstancePlayerCount } from '../../../coordinators/instanceCoordinator'; import { useUserDisplay } from '../../../composables/useUserDisplay'; import { useAppearanceSettingsStore, @@ -125,7 +125,7 @@ const { userImage, userStatusClass } = useUserDisplay(); const { isAgeGatedInstancesVisible } = storeToRefs(useAppearanceSettingsStore()); - + const { currentUser } = storeToRefs(useUserStore()); const { worldDialog } = storeToRefs(useWorldStore()); const { lastLocation } = storeToRefs(useLocationStore()); diff --git a/src/components/dialogs/WorldDialog/__tests__/useWorldDialogCommands.test.js b/src/components/dialogs/WorldDialog/__tests__/useWorldDialogCommands.test.js index 92e0e5f1..e4b3f9a7 100644 --- a/src/components/dialogs/WorldDialog/__tests__/useWorldDialogCommands.test.js +++ b/src/components/dialogs/WorldDialog/__tests__/useWorldDialogCommands.test.js @@ -28,11 +28,14 @@ vi.mock('../../../../shared/utils', () => ({ vi.mock('../../../../shared/utils/imageUpload', () => ({ handleImageUploadInput: vi.fn(), readFileAsBase64: vi.fn(), - resizeImageToFitLimits: vi.fn(), - uploadImageLegacy: vi.fn(), withUploadTimeout: vi.fn((p) => p) })); +vi.mock('../../../../coordinators/imageUploadCoordinator', () => ({ + resizeImageToFitLimits: vi.fn(), + uploadImageLegacy: vi.fn() +})); + const { favoriteRequest, miscRequest, userRequest, worldRequest } = await import('../../../../api'); const { openExternalLink } = await import('../../../../shared/utils'); diff --git a/src/components/dialogs/WorldDialog/useWorldDialogCommands.js b/src/components/dialogs/WorldDialog/useWorldDialogCommands.js index 5fda0352..aff81e3d 100644 --- a/src/components/dialogs/WorldDialog/useWorldDialogCommands.js +++ b/src/components/dialogs/WorldDialog/useWorldDialogCommands.js @@ -1,19 +1,21 @@ import { nextTick, ref } from 'vue'; -import { - handleImageUploadInput, - readFileAsBase64, - resizeImageToFitLimits, - uploadImageLegacy, - withUploadTimeout -} from '../../../shared/utils/imageUpload'; import { favoriteRequest, miscRequest, userRequest, worldRequest } from '../../../api'; +import { + handleImageUploadInput, + resizeImageToFitLimits, + uploadImageLegacy +} from '../../../coordinators/imageUploadCoordinator'; import { openExternalLink, replaceVrcPackageUrl } from '../../../shared/utils'; +import { + readFileAsBase64, + withUploadTimeout +} from '../../../shared/utils/imageUpload'; /** * Composable for WorldDialog commands, prompt functions, and image upload. @@ -370,6 +372,9 @@ export function useWorldDialogCommands( // String commands: delegate to component callback // Confirmed commands: { confirm: () => ({title, description, ...}), handler: fn } + /** + * + */ function buildCommandMap() { const D = () => worldDialog.value; diff --git a/src/coordinators/cacheCoordinator.js b/src/coordinators/cacheCoordinator.js new file mode 100644 index 00000000..ffe4928e --- /dev/null +++ b/src/coordinators/cacheCoordinator.js @@ -0,0 +1,194 @@ +import { storeToRefs } from 'pinia'; +import { toast } from 'vue-sonner'; + +import { + useAuthStore, + useAvatarStore, + useInstanceStore, + useWorldStore +} from '../stores'; +import { + extractFileId, + extractFileVersion, + extractVariantVersion +} from '../shared/utils/fileUtils'; +import { compareUnityVersion } from '../shared/utils/avatar'; +import { queryRequest } from '../api'; + +async function deleteVRChatCache(ref) { + const authStore = useAuthStore(); + const sdkUnityVersion = authStore.cachedConfig.sdkUnityVersion; + let assetUrl = ''; + let variant = ''; + for (let i = ref.unityPackages.length - 1; i > -1; i--) { + const unityPackage = ref.unityPackages[i]; + if ( + unityPackage.variant && + unityPackage.variant !== 'standard' && + unityPackage.variant !== 'security' + ) { + continue; + } + if ( + unityPackage.platform === 'standalonewindows' && + compareUnityVersion(unityPackage.unitySortNumber, sdkUnityVersion) + ) { + assetUrl = unityPackage.assetUrl; + if (!unityPackage.variant || unityPackage.variant === 'standard') { + variant = 'security'; + } else { + variant = unityPackage.variant; + } + break; + } + } + const id = extractFileId(assetUrl); + const version = parseInt(extractFileVersion(assetUrl), 10); + const variantVersion = parseInt(extractVariantVersion(assetUrl), 10); + await AssetBundleManager.DeleteCache(id, version, variant, variantVersion); +} + +/** + * + * @param {object} ref + * @returns + */ +async function checkVRChatCache(ref) { + if (!ref.unityPackages) { + return { Item1: -1, Item2: false, Item3: '' }; + } + const authStore = useAuthStore(); + const sdkUnityVersion = authStore.cachedConfig.sdkUnityVersion; + let assetUrl = ''; + let variant = ''; + for (let i = ref.unityPackages.length - 1; i > -1; i--) { + const unityPackage = ref.unityPackages[i]; + if (unityPackage.variant && unityPackage.variant !== 'security') { + continue; + } + if ( + unityPackage.platform === 'standalonewindows' && + compareUnityVersion(unityPackage.unitySortNumber, sdkUnityVersion) + ) { + assetUrl = unityPackage.assetUrl; + if (!unityPackage.variant || unityPackage.variant === 'standard') { + variant = 'security'; + } else { + variant = unityPackage.variant; + } + break; + } + } + if (!assetUrl) { + assetUrl = ref.assetUrl; + } + const id = extractFileId(assetUrl); + const version = parseInt(extractFileVersion(assetUrl), 10); + const variantVersion = parseInt(extractVariantVersion(assetUrl), 10); + if (!id || !version) { + return { Item1: -1, Item2: false, Item3: '' }; + } + + try { + return AssetBundleManager.CheckVRChatCache( + id, + version, + variant, + variantVersion + ); + } catch (err) { + console.error('Failed reading VRChat cache size:', err); + toast.error(`Failed reading VRChat cache size: ${err}`); + return { Item1: -1, Item2: false, Item3: '' }; + } +} + +/** + * + * @param {object} ref + * @returns {Promise} + */ +async function getBundleDateSize(ref) { + const authStore = useAuthStore(); + const sdkUnityVersion = authStore.cachedConfig.sdkUnityVersion; + const avatarStore = useAvatarStore(); + const { avatarDialog } = storeToRefs(avatarStore); + const worldStore = useWorldStore(); + const { worldDialog } = storeToRefs(worldStore); + const instanceStore = useInstanceStore(); + const { currentInstanceWorld, currentInstanceLocation } = + storeToRefs(instanceStore); + const bundleJson = {}; + for (let i = ref.unityPackages.length - 1; i > -1; i--) { + const unityPackage = ref.unityPackages[i]; + if (!unityPackage) { + continue; + } + if ( + unityPackage.variant && + unityPackage.variant !== 'standard' && + unityPackage.variant !== 'security' + ) { + continue; + } + if ( + !compareUnityVersion(unityPackage.unitySortNumber, sdkUnityVersion) + ) { + continue; + } + + const platform = unityPackage.platform; + if (bundleJson[platform]) { + continue; + } + const assetUrl = unityPackage.assetUrl; + const fileId = extractFileId(assetUrl); + const version = parseInt(extractFileVersion(assetUrl), 10); + let variant = ''; + if (!unityPackage.variant || unityPackage.variant === 'standard') { + variant = 'security'; + } else { + variant = unityPackage.variant; + } + if (!fileId || !version) { + continue; + } + const args = await queryRequest.fetch('fileAnalysis', { + fileId, + version, + variant + }); + if (!args?.json?.success) { + continue; + } + + const json = args.json; + if (typeof json.fileSize !== 'undefined') { + json._fileSize = `${(json.fileSize / 1048576).toFixed(2)} MB`; + } + if (typeof json.uncompressedSize !== 'undefined') { + json._uncompressedSize = `${(json.uncompressedSize / 1048576).toFixed(2)} MB`; + } + if (typeof json.avatarStats?.totalTextureUsage !== 'undefined') { + json._totalTextureUsage = `${(json.avatarStats.totalTextureUsage / 1048576).toFixed(2)} MB`; + } + bundleJson[platform] = json; + + if (avatarDialog.value.id === ref.id) { + // update avatar dialog + avatarDialog.value.fileAnalysis[platform] = json; + } + // update world dialog + if (worldDialog.value.id === ref.id) { + worldDialog.value.fileAnalysis[platform] = json; + } + // update player list + if (currentInstanceLocation.value.worldId === ref.id) { + currentInstanceWorld.value.fileAnalysis[platform] = json; + } + } + + return bundleJson; +} + +export { deleteVRChatCache, checkVRChatCache, getBundleDateSize }; diff --git a/src/shared/utils/base/date.js b/src/coordinators/dateCoordinator.js similarity index 97% rename from src/shared/utils/base/date.js rename to src/coordinators/dateCoordinator.js index c53ab58c..48b2ae49 100644 --- a/src/shared/utils/base/date.js +++ b/src/coordinators/dateCoordinator.js @@ -1,4 +1,4 @@ -import { useAppearanceSettingsStore } from '../../../stores'; +import { useAppearanceSettingsStore } from '../stores'; function padZero(num) { return String(num).padStart(2, '0'); diff --git a/src/coordinators/friendSyncCoordinator.js b/src/coordinators/friendSyncCoordinator.js index 764a8f7c..4a95e2b3 100644 --- a/src/coordinators/friendSyncCoordinator.js +++ b/src/coordinators/friendSyncCoordinator.js @@ -1,7 +1,7 @@ import { toast } from 'vue-sonner'; import { AppDebug } from '../services/appConfig'; -import { migrateMemos } from '../shared/utils'; +import { migrateMemos } from './memoCoordinator'; import { reconnectWebSocket } from '../services/websocket'; import { useAuthStore } from '../stores/auth'; import { useFriendStore } from '../stores/friend'; diff --git a/src/coordinators/imageUploadCoordinator.js b/src/coordinators/imageUploadCoordinator.js new file mode 100644 index 00000000..280d8206 --- /dev/null +++ b/src/coordinators/imageUploadCoordinator.js @@ -0,0 +1,198 @@ +import { toast } from 'vue-sonner'; + +import { $throw } from '../services/request'; +import { AppDebug } from '../services/appConfig.js'; +import { extractFileId } from '../shared/utils'; +import { imageRequest } from '../api'; + +function resolveMessage(message) { + if (typeof message === 'function') { + return message(); + } + return message; +} + +function getInputElement(selector) { + if (!selector) { + return null; + } + if (typeof selector === 'function') { + return selector(); + } + if (typeof selector === 'string') { + return document.querySelector(selector); + } + return selector; +} + +export function handleImageUploadInput(event, options = {}) { + const { + inputSelector, + // 20MB + maxSize = 20000000, + acceptPattern = /image.*/, + tooLargeMessage, + invalidTypeMessage, + onClear + } = options; + + const clearInput = () => { + onClear?.(); + const input = getInputElement(inputSelector); + if (input) { + input.value = ''; + } + }; + + const files = event?.target?.files || event?.dataTransfer?.files; + if (!files || files.length === 0) { + clearInput(); + return { file: null, clearInput }; + } + + const file = files[0]; + if (file.size >= maxSize) { + if (tooLargeMessage) { + toast.error(resolveMessage(tooLargeMessage)); + } + clearInput(); + return { file: null, clearInput }; + } + + let acceptRegex = null; + if (acceptPattern) { + acceptRegex = + acceptPattern instanceof RegExp + ? acceptPattern + : new RegExp(acceptPattern); + } + + if (acceptRegex && !acceptRegex.test(file.type)) { + if (invalidTypeMessage) { + toast.error(resolveMessage(invalidTypeMessage)); + } + clearInput(); + return { file: null, clearInput }; + } + + return { file, clearInput }; +} + +/** + * @param {string} base64Data - base64 encoded image + * @returns {Promise} resized base64 encoded image + */ +export async function resizeImageToFitLimits(base64Data) { + // frontend limit check = 20MB + return AppApi.ResizeImageToFitLimits(base64Data); +} + +/** + * Upload image through AWS + * @param {'avatar'|'world'} type + * @param {object} opts + * @param {string} opts.entityId - avatar or world id + * @param {string} opts.imageUrl - current imageUrl on the entity + * @param {string} opts.base64File - base64 encoded image data + * @param {Blob} opts.blob - the original blob (used for file size) + */ +export async function uploadImageLegacy( + type, + { entityId, imageUrl, base64File, blob } +) { + const apiMap = { + avatar: { + uploadImage: imageRequest.uploadAvatarImage, + fileStart: imageRequest.uploadAvatarImageFileStart, + fileFinish: imageRequest.uploadAvatarImageFileFinish, + sigStart: imageRequest.uploadAvatarImageSigStart, + sigFinish: imageRequest.uploadAvatarImageSigFinish, + setImage: imageRequest.setAvatarImage + }, + world: { + uploadImage: imageRequest.uploadWorldImage, + fileStart: imageRequest.uploadWorldImageFileStart, + fileFinish: imageRequest.uploadWorldImageFileFinish, + sigStart: imageRequest.uploadWorldImageSigStart, + sigFinish: imageRequest.uploadWorldImageSigFinish, + setImage: imageRequest.setWorldImage + } + }; + const api = apiMap[type]; + + const fileMd5 = await AppApi.MD5File(base64File); + const fileSizeInBytes = parseInt(blob.size, 10); + const base64SignatureFile = await AppApi.SignFile(base64File); + const signatureMd5 = await AppApi.MD5File(base64SignatureFile); + const signatureSizeInBytes = parseInt( + await AppApi.FileLength(base64SignatureFile), + 10 + ); + const fileId = extractFileId(imageUrl); + + // imageInit + const uploadRes = await api.uploadImage( + { fileMd5, fileSizeInBytes, signatureMd5, signatureSizeInBytes }, + fileId + ); + const uploadedFileId = uploadRes.json.id; + const fileVersion = + uploadRes.json.versions[uploadRes.json.versions.length - 1].version; + + // imageFileStart + const fileStartRes = await api.fileStart({ + fileId: uploadedFileId, + fileVersion + }); + + // uploadImageFileAWS + const fileAwsRes = await webApiService.execute({ + url: fileStartRes.json.url, + uploadFilePUT: true, + fileData: base64File, + fileMIME: 'image/png', + fileMD5: fileMd5 + }); + if (fileAwsRes.status !== 200) { + $throw( + fileAwsRes.status, + `${type} image upload failed`, + fileStartRes.json.url + ); + } + + // imageFileFinish + await api.fileFinish({ fileId: uploadedFileId, fileVersion }); + + // imageSigStart + const sigStartRes = await api.sigStart({ + fileId: uploadedFileId, + fileVersion + }); + + // uploadImageSigAWS + const sigAwsRes = await webApiService.execute({ + url: sigStartRes.json.url, + uploadFilePUT: true, + fileData: base64SignatureFile, + fileMIME: 'application/x-rsync-signature', + fileMD5: signatureMd5 + }); + if (sigAwsRes.status !== 200) { + $throw( + sigAwsRes.status, + `${type} image upload failed`, + sigStartRes.json.url + ); + } + + // imageSigFinish + await api.sigFinish({ fileId: uploadedFileId, fileVersion }); + + // imageSet + const newImageUrl = `${AppDebug.endpointDomain}/file/${uploadedFileId}/${fileVersion}/file`; + const setRes = await api.setImage({ id: entityId, imageUrl: newImageUrl }); + if (setRes.json.imageUrl !== newImageUrl) { + $throw(0, `${type} image change failed`, newImageUrl); + } +} diff --git a/src/coordinators/instanceCoordinator.js b/src/coordinators/instanceCoordinator.js new file mode 100644 index 00000000..bbd9e9e7 --- /dev/null +++ b/src/coordinators/instanceCoordinator.js @@ -0,0 +1,18 @@ +import { instanceRequest } from '../api'; +import { parseLocation } from '../shared/utils/locationParser'; + +/** + * + * @param {object} instance + */ +function refreshInstancePlayerCount(instance) { + const L = parseLocation(instance); + if (L.isRealInstance) { + instanceRequest.getInstance({ + worldId: L.worldId, + instanceId: L.instanceId + }); + } +} + +export { refreshInstancePlayerCount }; diff --git a/src/shared/utils/memos.js b/src/coordinators/memoCoordinator.js similarity index 95% rename from src/shared/utils/memos.js rename to src/coordinators/memoCoordinator.js index 873cca19..f043177f 100644 --- a/src/shared/utils/memos.js +++ b/src/coordinators/memoCoordinator.js @@ -1,5 +1,5 @@ -import { useFriendStore, useUserStore } from '../../stores'; -import { database } from '../../services/database'; +import { useFriendStore, useUserStore } from '../stores'; +import { database } from '../services/database'; /** * @returns {Promise} diff --git a/src/coordinators/userCoordinator.js b/src/coordinators/userCoordinator.js index 4c17192b..868d21df 100644 --- a/src/coordinators/userCoordinator.js +++ b/src/coordinators/userCoordinator.js @@ -10,12 +10,12 @@ import { evictMapCache, extractFileId, findUserByDisplayName, - getUserMemo, getWorldName, isRealInstance, parseLocation, sanitizeUserJson } from '../shared/utils'; +import { getUserMemo } from './memoCoordinator'; import { avatarRequest, instanceRequest, @@ -73,7 +73,14 @@ export function applyUser(json) { const moderationStore = useModerationStore(); const photonStore = usePhotonStore(); - const { currentUser, cachedUsers, currentTravelers, customUserTags, state, userDialog } = userStore; + const { + currentUser, + cachedUsers, + currentTravelers, + customUserTags, + state, + userDialog + } = userStore; let ref = cachedUsers.get(json.id); let hasPropChanged = false; @@ -114,10 +121,8 @@ export function applyUser(json) { if (json.state !== 'online') { runUpdateFriendFlow(ref.id, json.state); } - const { - hasPropChanged: _hasPropChanged, - changedProps: _changedProps - } = diffObjectProps(ref, json, arraysMatch); + const { hasPropChanged: _hasPropChanged, changedProps: _changedProps } = + diffObjectProps(ref, json, arraysMatch); for (const prop in json) { if (typeof json[prop] !== 'undefined') { ref[prop] = json[prop]; @@ -235,10 +240,7 @@ export function applyUser(json) { } } if (hasPropChanged) { - if ( - changedProps.location && - changedProps.location[0] !== 'traveling' - ) { + if (changedProps.location && changedProps.location[0] !== 'traveling') { const ts = Date.now(); changedProps.location.push(ts - ref.$location_at); ref.$location_at = ts; @@ -286,11 +288,7 @@ export function showUserDialog(userId) { const D = userDialog; D.visible = true; if (isMainDialogOpen && D.id === userId) { - uiStore.setDialogCrumbLabel( - 'user', - D.id, - D.ref?.displayName || D.id - ); + uiStore.setDialogCrumbLabel('user', D.id, D.ref?.displayName || D.id); userStore.applyUserDialogLocation(true); return; } @@ -429,8 +427,7 @@ export function showUserDialog(userId) { D.joinCount = ref1.joinCount; D.timeSpent = ref1.timeSpent; } - const displayNameMap = - ref1.previousDisplayNames; + const displayNameMap = ref1.previousDisplayNames; const userNotifications = await database.getFriendLogHistoryForUserId( D.id, @@ -457,12 +454,10 @@ export function showUserDialog(userId) { } D.dateFriendedInfo = dateFriendedInfo; if (dateFriendedInfo.length > 0) { - const latestFriendedInfo = - dateFriendedInfo[0]; + const latestFriendedInfo = dateFriendedInfo[0]; D.unFriended = latestFriendedInfo.type === 'Unfriend'; - D.dateFriended = - latestFriendedInfo.created_at; + D.dateFriended = latestFriendedInfo.created_at; } displayNameMap.forEach( (updated_at, displayName) => { @@ -473,27 +468,24 @@ export function showUserDialog(userId) { } ); }); - AppApi.GetVRChatUserModeration( - currentUser.id, - userId - ).then((result) => { - D.avatarModeration = result; - if (result === 4) { - D.isHideAvatar = true; - } else if (result === 5) { - D.isShowAvatar = true; + AppApi.GetVRChatUserModeration(currentUser.id, userId).then( + (result) => { + D.avatarModeration = result; + if (result === 4) { + D.isHideAvatar = true; + } else if (result === 5) { + D.isShowAvatar = true; + } } - }); + ); if (!currentUser.hasSharedConnectionsOptOut) { try { queryRequest .fetch('mutualCounts', { userId }) .then((args) => { if (args.params.userId === D.id) { - D.mutualFriendCount = - args.json.friends; - D.mutualGroupCount = - args.json.groups; + D.mutualFriendCount = args.json.friends; + D.mutualGroupCount = args.json.groups; } }); } catch (error) { @@ -501,8 +493,7 @@ export function showUserDialog(userId) { } } } else { - D.previousDisplayNames = - currentUser.pastDisplayNames; + D.previousDisplayNames = currentUser.pastDisplayNames; database .getUserStats(D.ref, inCurrentWorld) .then((ref1) => { @@ -673,11 +664,8 @@ export function handleConfig(args) { if (typeof args.ref?.whiteListedAssetUrls !== 'object') { console.error('Invalid config whiteListedAssetUrls'); } - AppApi.PopulateImageHosts( - JSON.stringify(args.ref.whiteListedAssetUrls) - ); - const languages = - args.ref?.constants?.LANGUAGE?.SPOKEN_LANGUAGE_OPTIONS; + AppApi.PopulateImageHosts(JSON.stringify(args.ref.whiteListedAssetUrls)); + const languages = args.ref?.constants?.LANGUAGE?.SPOKEN_LANGUAGE_OPTIONS; if (!languages) { return; } @@ -1047,10 +1035,7 @@ export function updateAutoStateChange() { } const params = { status: newStatus }; - if ( - withCompany && - generalSettingsStore.autoStateChangeCompanyDescEnabled - ) { + if (withCompany && generalSettingsStore.autoStateChangeCompanyDescEnabled) { params.statusDescription = generalSettingsStore.autoStateChangeCompanyDesc; } else if ( diff --git a/src/coordinators/worldCoordinator.js b/src/coordinators/worldCoordinator.js index 71c591d7..a291a265 100644 --- a/src/coordinators/worldCoordinator.js +++ b/src/coordinators/worldCoordinator.js @@ -8,11 +8,11 @@ import { evictMapCache, getAvailablePlatforms, getBundleDateSize, - getWorldMemo, isRealInstance, parseLocation, sanitizeEntityJson } from '../shared/utils'; +import { getWorldMemo } from './memoCoordinator'; import { instanceRequest, queryRequest, worldRequest } from '../api'; import { database } from '../services/database'; import { patchWorldFromEvent } from '../queries'; @@ -118,21 +118,13 @@ export function showWorldDialog(tag, shortName = null, options = {}) { .then((args) => { if (D.id === args.ref.id) { D.ref = args.ref; - uiStore.setDialogCrumbLabel( - 'world', - D.id, - D.ref?.name || D.id - ); + uiStore.setDialogCrumbLabel('world', D.id, D.ref?.name || D.id); D.visible = true; D.loading = false; - D.isFavorite = favoriteStore.getCachedFavoritesByObjectId( - D.id - ); + D.isFavorite = favoriteStore.getCachedFavoritesByObjectId(D.id); if (!D.isFavorite) { D.isFavorite = - favoriteStore.localWorldFavoritesList.includes( - D.id - ); + favoriteStore.localWorldFavoritesList.includes(D.id); } let { isPC, isQuest, isIos } = getAvailablePlatforms( args.ref.unityPackages diff --git a/src/shared/utils/__tests__/appActions.test.js b/src/shared/utils/__tests__/appActions.test.js new file mode 100644 index 00000000..50ac89c4 --- /dev/null +++ b/src/shared/utils/__tests__/appActions.test.js @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + toast: { + success: vi.fn(), + error: vi.fn() + }, + searchStore: { + directAccessParse: vi.fn() + }, + modalStore: { + confirm: vi.fn() + }, + i18n: { + global: { + t: vi.fn(() => 'copy failed') + } + } +})); + +vi.mock('vue-sonner', () => ({ + toast: mocks.toast +})); + +vi.mock('../../../stores', () => ({ + useSearchStore: () => mocks.searchStore, + useModalStore: () => mocks.modalStore +})); + +vi.mock('../../../plugins/i18n', () => ({ + i18n: mocks.i18n +})); + +import { + copyToClipboard, + downloadAndSaveJson, + openDiscordProfile, + openExternalLink, + openFolderGeneric +} from '../appActions'; + +function flushPromises() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('appActions utils', () => { + let consoleErrorSpy; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.clearAllMocks(); + mocks.searchStore.directAccessParse.mockReturnValue(false); + mocks.modalStore.confirm.mockResolvedValue({ ok: false }); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: vi.fn().mockResolvedValue(undefined) + } + }); + globalThis.AppApi = { + OpenLink: vi.fn(), + OpenDiscordProfile: vi.fn().mockResolvedValue(undefined), + OpenFolderAndSelectItem: vi.fn() + }; + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + test('downloadAndSaveJson returns early for invalid params', () => { + downloadAndSaveJson('', { a: 1 }); + downloadAndSaveJson('name', null); + expect(document.querySelectorAll('a[download]').length).toBe(0); + }); + + test('downloadAndSaveJson creates and clicks download link', () => { + const appendSpy = vi.spyOn(document.body, 'appendChild'); + const removeSpy = vi.spyOn(document.body, 'removeChild'); + + downloadAndSaveJson('profile', { id: 1 }); + + expect(appendSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledTimes(1); + appendSpy.mockRestore(); + removeSpy.mockRestore(); + }); + + test('copyToClipboard shows success toast', async () => { + copyToClipboard('hello', 'copied'); + await flushPromises(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello'); + expect(mocks.toast.success).toHaveBeenCalledWith('copied'); + }); + + test('copyToClipboard shows translated error toast on failure', async () => { + navigator.clipboard.writeText.mockRejectedValue(new Error('denied')); + copyToClipboard('hello'); + await flushPromises(); + await flushPromises(); + expect(mocks.toast.error).toHaveBeenCalledWith('copy failed'); + }); + + test('openExternalLink returns early when direct access parse succeeds', async () => { + mocks.searchStore.directAccessParse.mockReturnValue(true); + openExternalLink('vrcx://user/usr_1'); + await flushPromises(); + expect(mocks.modalStore.confirm).not.toHaveBeenCalled(); + expect(AppApi.OpenLink).not.toHaveBeenCalled(); + }); + + test('openExternalLink copies link when confirm is canceled', async () => { + mocks.modalStore.confirm.mockResolvedValue({ + ok: false, + reason: 'cancel' + }); + openExternalLink('https://example.com'); + await flushPromises(); + await flushPromises(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'https://example.com' + ); + }); + + test('openExternalLink opens link when confirmed', async () => { + mocks.modalStore.confirm.mockResolvedValue({ ok: true }); + openExternalLink('https://example.com'); + await flushPromises(); + expect(AppApi.OpenLink).toHaveBeenCalledWith('https://example.com'); + }); + + test('openDiscordProfile validates empty discord id', () => { + openDiscordProfile(''); + expect(mocks.toast.error).toHaveBeenCalledWith('No Discord ID provided!'); + }); + + test('openDiscordProfile shows error toast when api fails', async () => { + AppApi.OpenDiscordProfile.mockRejectedValue(new Error('fail')); + openDiscordProfile('123'); + await flushPromises(); + expect(mocks.toast.error).toHaveBeenCalledWith( + 'Failed to open Discord profile!' + ); + }); + + test('openFolderGeneric delegates to AppApi', () => { + openFolderGeneric('/tmp/a.txt'); + expect(AppApi.OpenFolderAndSelectItem).toHaveBeenCalledWith( + '/tmp/a.txt', + true + ); + }); +}); diff --git a/src/shared/utils/__tests__/chart.test.js b/src/shared/utils/__tests__/chart.test.js new file mode 100644 index 00000000..23e6c4c8 --- /dev/null +++ b/src/shared/utils/__tests__/chart.test.js @@ -0,0 +1,40 @@ +import { describe, expect, test, vi } from 'vitest'; + +describe('loadEcharts', () => { + test('loads echarts module', async () => { + vi.resetModules(); + vi.doMock('echarts', () => ({ + __esModule: true, + marker: 'mock-echarts' + })); + const { loadEcharts } = await import('../chart.js'); + + const module = await loadEcharts(); + + expect(module).toMatchObject({ marker: 'mock-echarts' }); + }); + + test('returns cached module reference on subsequent calls', async () => { + vi.resetModules(); + vi.doMock('echarts', () => ({ + __esModule: true, + marker: 'mock-echarts' + })); + const { loadEcharts } = await import('../chart.js'); + + const first = await loadEcharts(); + const second = await loadEcharts(); + + expect(second).toBe(first); + }); + + test('rejects when echarts import fails', async () => { + vi.resetModules(); + vi.doMock('echarts', () => { + throw new Error('import failed'); + }); + const { loadEcharts } = await import('../chart.js'); + + await expect(loadEcharts()).rejects.toThrow(); + }); +}); diff --git a/src/shared/utils/__tests__/imageUpload.test.js b/src/shared/utils/__tests__/imageUpload.test.js index 62f24c15..487b6e97 100644 --- a/src/shared/utils/__tests__/imageUpload.test.js +++ b/src/shared/utils/__tests__/imageUpload.test.js @@ -13,7 +13,7 @@ vi.mock('../../../services/appConfig', () => ({ AppDebug: { endpointDomain: 'https://api.vrchat.cloud/api/1' } })); -vi.mock('../../utils/index.js', () => ({ +vi.mock('../../../shared/utils', () => ({ extractFileId: vi.fn() })); @@ -22,7 +22,8 @@ vi.mock('../../../api', () => ({ })); import { toast } from 'vue-sonner'; -import { handleImageUploadInput, withUploadTimeout } from '../imageUpload'; +import { withUploadTimeout } from '../imageUpload'; +import { handleImageUploadInput } from '../../../coordinators/imageUploadCoordinator'; // ─── withUploadTimeout ─────────────────────────────────────────────── diff --git a/src/shared/utils/__tests__/memos.test.js b/src/shared/utils/__tests__/memos.test.js new file mode 100644 index 00000000..7c95b5aa --- /dev/null +++ b/src/shared/utils/__tests__/memos.test.js @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + friends: new Map(), + setUserDialogMemo: vi.fn(), + database: { + getUserMemo: vi.fn(), + setUserMemo: vi.fn(), + deleteUserMemo: vi.fn(), + getAllUserMemos: vi.fn(), + getWorldMemo: vi.fn() + }, + storage: { + GetAll: vi.fn(), + Remove: vi.fn() + } +})); + +vi.mock('../../../stores', () => ({ + useFriendStore: () => ({ + friends: mocks.friends + }), + useUserStore: () => ({ + setUserDialogMemo: (...args) => mocks.setUserDialogMemo(...args) + }) +})); + +vi.mock('../../../services/database', () => ({ + database: mocks.database +})); + +import { + getAllUserMemos, + getUserMemo, + getWorldMemo, + migrateMemos, + saveUserMemo +} from '../../../coordinators/memoCoordinator.js'; + +describe('memos utils', () => { + let consoleErrorSpy; + + beforeEach(() => { + consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + mocks.friends = new Map(); + mocks.setUserDialogMemo.mockReset(); + mocks.database.getUserMemo.mockReset(); + mocks.database.setUserMemo.mockReset(); + mocks.database.deleteUserMemo.mockReset(); + mocks.database.getAllUserMemos.mockReset(); + mocks.database.getWorldMemo.mockReset(); + mocks.storage.GetAll.mockReset(); + mocks.storage.Remove.mockReset(); + globalThis.VRCXStorage = mocks.storage; + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + test('getUserMemo returns fallback when database throws', async () => { + mocks.database.getUserMemo.mockRejectedValue(new Error('boom')); + + const result = await getUserMemo('usr_1'); + + expect(result).toEqual({ + userId: '', + editedAt: '', + memo: '' + }); + }); + + test('getWorldMemo returns fallback when database throws', async () => { + mocks.database.getWorldMemo.mockRejectedValue(new Error('boom')); + + const result = await getWorldMemo('wrld_1'); + + expect(result).toEqual({ + worldId: '', + editedAt: '', + memo: '' + }); + }); + + test('saveUserMemo persists memo and syncs friend fields', async () => { + const friend = { memo: '', $nickName: '' }; + mocks.friends.set('usr_1', friend); + + await saveUserMemo('usr_1', 'Nick\nmore'); + + expect(mocks.database.setUserMemo).toHaveBeenCalledTimes(1); + expect(mocks.database.deleteUserMemo).not.toHaveBeenCalled(); + expect(friend.memo).toBe('Nick\nmore'); + expect(friend.$nickName).toBe('Nick'); + expect(mocks.setUserDialogMemo).toHaveBeenCalledWith('Nick\nmore'); + }); + + test('saveUserMemo deletes memo and clears nickname on empty input', async () => { + const friend = { memo: 'old', $nickName: 'old' }; + mocks.friends.set('usr_1', friend); + + await saveUserMemo('usr_1', ''); + + expect(mocks.database.deleteUserMemo).toHaveBeenCalledWith('usr_1'); + expect(friend.memo).toBe(''); + expect(friend.$nickName).toBe(''); + expect(mocks.setUserDialogMemo).toHaveBeenCalledWith(''); + }); + + test('getAllUserMemos applies memo data to existing cached friends', async () => { + const friend1 = { memo: '', $nickName: '' }; + const friend2 = { memo: '', $nickName: '' }; + mocks.friends.set('usr_1', friend1); + mocks.friends.set('usr_2', friend2); + mocks.database.getAllUserMemos.mockResolvedValue([ + { userId: 'usr_1', memo: 'Alpha\nline2' }, + { userId: 'usr_2', memo: '' }, + { userId: 'usr_missing', memo: 'ignored' } + ]); + + await getAllUserMemos(); + + expect(friend1.memo).toBe('Alpha\nline2'); + expect(friend1.$nickName).toBe('Alpha'); + expect(friend2.memo).toBe(''); + expect(friend2.$nickName).toBe(''); + }); + + test('migrateMemos moves memo_usr entries to database and storage cleanup', async () => { + const friend = { memo: '', $nickName: '' }; + mocks.friends.set('usr_1', friend); + mocks.storage.GetAll.mockResolvedValue( + JSON.stringify({ + memo_usr_1: 'hello', + other_key: 'x', + memo_usr_2: '' + }) + ); + + await migrateMemos(); + + expect(mocks.database.setUserMemo).toHaveBeenCalledTimes(1); + expect(mocks.database.setUserMemo).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'usr_1', + memo: 'hello' + }) + ); + expect(mocks.storage.Remove).toHaveBeenCalledWith('memo_usr_1'); + expect(mocks.storage.Remove).not.toHaveBeenCalledWith('memo_usr_2'); + }); + + test('migrateMemos rejects for invalid JSON payload', async () => { + mocks.storage.GetAll.mockResolvedValue('{bad json'); + + await expect(migrateMemos()).rejects.toThrow(); + expect(mocks.database.setUserMemo).not.toHaveBeenCalled(); + expect(mocks.storage.Remove).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/utils/appActions.js b/src/shared/utils/appActions.js new file mode 100644 index 00000000..8dd06851 --- /dev/null +++ b/src/shared/utils/appActions.js @@ -0,0 +1,102 @@ +import { toast } from 'vue-sonner'; + +import { useModalStore, useSearchStore } from '../../stores'; +import { escapeTag } from './base/string'; +import { i18n } from '../../plugins/i18n'; + +/** + * @param {string} fileName + * @param {*} data + */ +function downloadAndSaveJson(fileName, data) { + if (!fileName || !data) { + return; + } + try { + const link = document.createElement('a'); + link.setAttribute( + 'href', + `data:application/json;charset=utf-8,${encodeURIComponent( + JSON.stringify(data, null, 2) + )}` + ); + link.setAttribute('download', `${fileName}.json`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch { + toast.error(escapeTag('Failed to download JSON.')); + } +} + +/** + * + * @param {string} text + * @param {string} message + */ +function copyToClipboard(text, message = 'Copied successfully!') { + navigator.clipboard + .writeText(text) + .then(() => { + toast.success(message); + }) + .catch((err) => { + console.error('Copy failed:', err); + toast.error(i18n.global.t('message.copy_failed')); + }); +} + +/** + * + * @param {string} link + */ +function openExternalLink(link) { + const searchStore = useSearchStore(); + if (searchStore.directAccessParse(link)) { + return; + } + + const modalStore = useModalStore(); + modalStore + .confirm({ + description: `${link}`, + title: 'Open External Link', + confirmText: 'Open', + cancelText: 'Copy' + }) + .then(({ ok, reason }) => { + if (reason === 'cancel') { + copyToClipboard(link, 'Link copied to clipboard!'); + return; + } + if (ok) { + AppApi.OpenLink(link); + return; + } + }); +} + +function openDiscordProfile(discordId) { + if (!discordId) { + toast.error('No Discord ID provided!'); + return; + } + AppApi.OpenDiscordProfile(discordId).catch((err) => { + console.error('Failed to open Discord profile:', err); + toast.error('Failed to open Discord profile!'); + }); +} + +// #region | App: Random unsorted app methods, data structs, API functions, and an API feedback/file analysis event + +function openFolderGeneric(path) { + AppApi.OpenFolderAndSelectItem(path, true); +} + +export { + downloadAndSaveJson, + copyToClipboard, + openExternalLink, + openDiscordProfile, + openFolderGeneric +}; diff --git a/src/shared/utils/base/__tests__/date.test.js b/src/shared/utils/base/__tests__/date.test.js index 1a735809..4757acc7 100644 --- a/src/shared/utils/base/__tests__/date.test.js +++ b/src/shared/utils/base/__tests__/date.test.js @@ -15,7 +15,7 @@ vi.mock('../../../../plugins/router', () => ({ })); import { useAppearanceSettingsStore } from '../../../../stores'; -import { formatDateFilter } from '../date'; +import { formatDateFilter } from '../../../../coordinators/dateCoordinator'; describe('formatDateFilter', () => { beforeEach(() => { diff --git a/src/shared/utils/common.js b/src/shared/utils/common.js index ee2de7d1..e1f59c07 100644 --- a/src/shared/utils/common.js +++ b/src/shared/utils/common.js @@ -1,14 +1,3 @@ -import { storeToRefs } from 'pinia'; -import { toast } from 'vue-sonner'; - -import { - useAuthStore, - useAvatarStore, - useInstanceStore, - useModalStore, - useSearchStore, - useWorldStore -} from '../../stores'; import { extractFileId, extractFileVersion, @@ -17,148 +6,20 @@ import { import { escapeTag, replaceBioSymbols } from './base/string'; import { getFaviconUrl, replaceVrcPackageUrl } from './urlUtils'; import { AppDebug } from '../../services/appConfig.js'; -import { compareUnityVersion } from './avatar'; import { getAvailablePlatforms } from './platformUtils'; -import { i18n } from '../../plugins/i18n'; -import { queryRequest } from '../../api'; - -/** - * @param {string} fileName - * @param {*} data - */ -function downloadAndSaveJson(fileName, data) { - if (!fileName || !data) { - return; - } - try { - const link = document.createElement('a'); - link.setAttribute( - 'href', - `data:application/json;charset=utf-8,${encodeURIComponent( - JSON.stringify(data, null, 2) - )}` - ); - link.setAttribute('download', `${fileName}.json`); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } catch { - toast.error(escapeTag('Failed to download JSON.')); - } -} - -async function deleteVRChatCache(ref) { - const authStore = useAuthStore(); - const sdkUnityVersion = authStore.cachedConfig.sdkUnityVersion; - let assetUrl = ''; - let variant = ''; - for (let i = ref.unityPackages.length - 1; i > -1; i--) { - const unityPackage = ref.unityPackages[i]; - if ( - unityPackage.variant && - unityPackage.variant !== 'standard' && - unityPackage.variant !== 'security' - ) { - continue; - } - if ( - unityPackage.platform === 'standalonewindows' && - compareUnityVersion(unityPackage.unitySortNumber, sdkUnityVersion) - ) { - assetUrl = unityPackage.assetUrl; - if (!unityPackage.variant || unityPackage.variant === 'standard') { - variant = 'security'; - } else { - variant = unityPackage.variant; - } - break; - } - } - const id = extractFileId(assetUrl); - const version = parseInt(extractFileVersion(assetUrl), 10); - const variantVersion = parseInt(extractVariantVersion(assetUrl), 10); - await AssetBundleManager.DeleteCache(id, version, variant, variantVersion); -} - -/** - * - * @param {object} ref - * @returns - */ -async function checkVRChatCache(ref) { - if (!ref.unityPackages) { - return { Item1: -1, Item2: false, Item3: '' }; - } - const authStore = useAuthStore(); - const sdkUnityVersion = authStore.cachedConfig.sdkUnityVersion; - let assetUrl = ''; - let variant = ''; - for (let i = ref.unityPackages.length - 1; i > -1; i--) { - const unityPackage = ref.unityPackages[i]; - if (unityPackage.variant && unityPackage.variant !== 'security') { - continue; - } - if ( - unityPackage.platform === 'standalonewindows' && - compareUnityVersion(unityPackage.unitySortNumber, sdkUnityVersion) - ) { - assetUrl = unityPackage.assetUrl; - if (!unityPackage.variant || unityPackage.variant === 'standard') { - variant = 'security'; - } else { - variant = unityPackage.variant; - } - break; - } - } - if (!assetUrl) { - assetUrl = ref.assetUrl; - } - const id = extractFileId(assetUrl); - const version = parseInt(extractFileVersion(assetUrl), 10); - const variantVersion = parseInt(extractVariantVersion(assetUrl), 10); - if (!id || !version) { - return { Item1: -1, Item2: false, Item3: '' }; - } - - try { - return AssetBundleManager.CheckVRChatCache( - id, - version, - variant, - variantVersion - ); - } catch (err) { - console.error('Failed reading VRChat cache size:', err); - toast.error(`Failed reading VRChat cache size: ${err}`); - return { Item1: -1, Item2: false, Item3: '' }; - } -} - -/** - * - * @param {string} text - * @param {string} message - */ -function copyToClipboard(text, message = 'Copied successfully!') { - navigator.clipboard - .writeText(text) - .then(() => { - toast.success(message); - }) - .catch((err) => { - console.error('Copy failed:', err); - toast.error(i18n.global.t('message.copy_failed')); - }); -} /** * * @param {string} url * @param {number} resolution + * @param endpointDomain * @returns {string} */ -function convertFileUrlToImageUrl(url, resolution = 128, endpointDomain = AppDebug.endpointDomain) { +function convertFileUrlToImageUrl( + url, + resolution = 128, + endpointDomain = AppDebug.endpointDomain +) { if (!url) { return ''; } @@ -183,137 +44,9 @@ function convertFileUrlToImageUrl(url, resolution = 128, endpointDomain = AppDeb /** * - * @param {string} link + * @param func + * @param delay */ -function openExternalLink(link) { - const searchStore = useSearchStore(); - if (searchStore.directAccessParse(link)) { - return; - } - - const modalStore = useModalStore(); - modalStore - .confirm({ - description: `${link}`, - title: 'Open External Link', - confirmText: 'Open', - cancelText: 'Copy' - }) - .then(({ ok, reason }) => { - if (reason === 'cancel') { - copyToClipboard(link, 'Link copied to clipboard!'); - return; - } - if (ok) { - AppApi.OpenLink(link); - return; - } - }); -} - -function openDiscordProfile(discordId) { - if (!discordId) { - toast.error('No Discord ID provided!'); - return; - } - AppApi.OpenDiscordProfile(discordId).catch((err) => { - console.error('Failed to open Discord profile:', err); - toast.error('Failed to open Discord profile!'); - }); -} - -/** - * - * @param {object} ref - * @returns {Promise} - */ -async function getBundleDateSize(ref) { - const authStore = useAuthStore(); - const sdkUnityVersion = authStore.cachedConfig.sdkUnityVersion; - const avatarStore = useAvatarStore(); - const { avatarDialog } = storeToRefs(avatarStore); - const worldStore = useWorldStore(); - const { worldDialog } = storeToRefs(worldStore); - const instanceStore = useInstanceStore(); - const { currentInstanceWorld, currentInstanceLocation } = - storeToRefs(instanceStore); - const bundleJson = {}; - for (let i = ref.unityPackages.length - 1; i > -1; i--) { - const unityPackage = ref.unityPackages[i]; - if (!unityPackage) { - continue; - } - if ( - unityPackage.variant && - unityPackage.variant !== 'standard' && - unityPackage.variant !== 'security' - ) { - continue; - } - if (!compareUnityVersion(unityPackage.unitySortNumber, sdkUnityVersion)) { - continue; - } - - const platform = unityPackage.platform; - if (bundleJson[platform]) { - continue; - } - const assetUrl = unityPackage.assetUrl; - const fileId = extractFileId(assetUrl); - const version = parseInt(extractFileVersion(assetUrl), 10); - let variant = ''; - if (!unityPackage.variant || unityPackage.variant === 'standard') { - variant = 'security'; - } else { - variant = unityPackage.variant; - } - if (!fileId || !version) { - continue; - } - const args = await queryRequest.fetch('fileAnalysis', { - fileId, - version, - variant - }); - if (!args?.json?.success) { - continue; - } - - const json = args.json; - if (typeof json.fileSize !== 'undefined') { - json._fileSize = `${(json.fileSize / 1048576).toFixed(2)} MB`; - } - if (typeof json.uncompressedSize !== 'undefined') { - json._uncompressedSize = `${(json.uncompressedSize / 1048576).toFixed(2)} MB`; - } - if (typeof json.avatarStats?.totalTextureUsage !== 'undefined') { - json._totalTextureUsage = `${(json.avatarStats.totalTextureUsage / 1048576).toFixed(2)} MB`; - } - bundleJson[platform] = json; - - if (avatarDialog.value.id === ref.id) { - // update avatar dialog - avatarDialog.value.fileAnalysis[platform] = json; - } - // update world dialog - if (worldDialog.value.id === ref.id) { - worldDialog.value.fileAnalysis[platform] = json; - } - // update player list - if (currentInstanceLocation.value.worldId === ref.id) { - currentInstanceWorld.value.fileAnalysis[platform] = json; - } - } - - return bundleJson; -} - -// #region | App: Random unsorted app methods, data structs, API functions, and an API feedback/file analysis event - -function openFolderGeneric(path) { - AppApi.OpenFolderAndSelectItem(path, true); -} - function debounce(func, delay) { let timer = null; return function (...args) { @@ -325,12 +58,23 @@ function debounce(func, delay) { }; } +// Re-export from appActions and cacheCoordinator for backward compatibility export { - getAvailablePlatforms, downloadAndSaveJson, + copyToClipboard, + openExternalLink, + openDiscordProfile, + openFolderGeneric +} from './appActions'; + +export { deleteVRChatCache, checkVRChatCache, - copyToClipboard, + getBundleDateSize +} from '../../coordinators/cacheCoordinator'; + +export { + getAvailablePlatforms, getFaviconUrl, convertFileUrlToImageUrl, replaceVrcPackageUrl, @@ -338,9 +82,5 @@ export { extractFileVersion, extractVariantVersion, replaceBioSymbols, - openExternalLink, - openDiscordProfile, - getBundleDateSize, - openFolderGeneric, debounce }; diff --git a/src/shared/utils/imageUpload.js b/src/shared/utils/imageUpload.js index cdc117f5..97070319 100644 --- a/src/shared/utils/imageUpload.js +++ b/src/shared/utils/imageUpload.js @@ -1,12 +1,9 @@ -import { toast } from 'vue-sonner'; - -import { $throw } from '../../services/request'; -import { AppDebug } from '../../services/appConfig.js'; -import { extractFileId } from './index.js'; -import { imageRequest } from '../../api'; - const UPLOAD_TIMEOUT_MS = 30_000; +/** + * + * @param promise + */ export function withUploadTimeout(promise) { return Promise.race([ promise, @@ -19,79 +16,6 @@ export function withUploadTimeout(promise) { ]); } -function resolveMessage(message) { - if (typeof message === 'function') { - return message(); - } - return message; -} - -function getInputElement(selector) { - if (!selector) { - return null; - } - if (typeof selector === 'function') { - return selector(); - } - if (typeof selector === 'string') { - return document.querySelector(selector); - } - return selector; -} - -export function handleImageUploadInput(event, options = {}) { - const { - inputSelector, - // 20MB - maxSize = 20000000, - acceptPattern = /image.*/, - tooLargeMessage, - invalidTypeMessage, - onClear - } = options; - - const clearInput = () => { - onClear?.(); - const input = getInputElement(inputSelector); - if (input) { - input.value = ''; - } - }; - - const files = event?.target?.files || event?.dataTransfer?.files; - if (!files || files.length === 0) { - clearInput(); - return { file: null, clearInput }; - } - - const file = files[0]; - if (file.size >= maxSize) { - if (tooLargeMessage) { - toast.error(resolveMessage(tooLargeMessage)); - } - clearInput(); - return { file: null, clearInput }; - } - - let acceptRegex = null; - if (acceptPattern) { - acceptRegex = - acceptPattern instanceof RegExp - ? acceptPattern - : new RegExp(acceptPattern); - } - - if (acceptRegex && !acceptRegex.test(file.type)) { - if (invalidTypeMessage) { - toast.error(resolveMessage(invalidTypeMessage)); - } - clearInput(); - return { file: null, clearInput }; - } - - return { file, clearInput }; -} - /** * File -> base64 * @param {Blob|File} blob @@ -113,122 +37,3 @@ export function readFileAsBase64(blob) { r.readAsArrayBuffer(blob); }); } - -/** - * @param {string} base64Data - base64 encoded image - * @returns {Promise} resized base64 encoded image - */ -export async function resizeImageToFitLimits(base64Data) { - // frontend limit check = 20MB - return AppApi.ResizeImageToFitLimits(base64Data); -} - -/** - * Upload image through AWS - * @param {'avatar'|'world'} type - * @param {object} opts - * @param {string} opts.entityId - avatar or world id - * @param {string} opts.imageUrl - current imageUrl on the entity - * @param {string} opts.base64File - base64 encoded image data - * @param {Blob} opts.blob - the original blob (used for file size) - */ -export async function uploadImageLegacy( - type, - { entityId, imageUrl, base64File, blob } -) { - const apiMap = { - avatar: { - uploadImage: imageRequest.uploadAvatarImage, - fileStart: imageRequest.uploadAvatarImageFileStart, - fileFinish: imageRequest.uploadAvatarImageFileFinish, - sigStart: imageRequest.uploadAvatarImageSigStart, - sigFinish: imageRequest.uploadAvatarImageSigFinish, - setImage: imageRequest.setAvatarImage - }, - world: { - uploadImage: imageRequest.uploadWorldImage, - fileStart: imageRequest.uploadWorldImageFileStart, - fileFinish: imageRequest.uploadWorldImageFileFinish, - sigStart: imageRequest.uploadWorldImageSigStart, - sigFinish: imageRequest.uploadWorldImageSigFinish, - setImage: imageRequest.setWorldImage - } - }; - const api = apiMap[type]; - - const fileMd5 = await AppApi.MD5File(base64File); - const fileSizeInBytes = parseInt(blob.size, 10); - const base64SignatureFile = await AppApi.SignFile(base64File); - const signatureMd5 = await AppApi.MD5File(base64SignatureFile); - const signatureSizeInBytes = parseInt( - await AppApi.FileLength(base64SignatureFile), - 10 - ); - const fileId = extractFileId(imageUrl); - - // imageInit - const uploadRes = await api.uploadImage( - { fileMd5, fileSizeInBytes, signatureMd5, signatureSizeInBytes }, - fileId - ); - const uploadedFileId = uploadRes.json.id; - const fileVersion = - uploadRes.json.versions[uploadRes.json.versions.length - 1].version; - - // imageFileStart - const fileStartRes = await api.fileStart({ - fileId: uploadedFileId, - fileVersion - }); - - // uploadImageFileAWS - const fileAwsRes = await webApiService.execute({ - url: fileStartRes.json.url, - uploadFilePUT: true, - fileData: base64File, - fileMIME: 'image/png', - fileMD5: fileMd5 - }); - if (fileAwsRes.status !== 200) { - $throw( - fileAwsRes.status, - `${type} image upload failed`, - fileStartRes.json.url - ); - } - - // imageFileFinish - await api.fileFinish({ fileId: uploadedFileId, fileVersion }); - - // imageSigStart - const sigStartRes = await api.sigStart({ - fileId: uploadedFileId, - fileVersion - }); - - // uploadImageSigAWS - const sigAwsRes = await webApiService.execute({ - url: sigStartRes.json.url, - uploadFilePUT: true, - fileData: base64SignatureFile, - fileMIME: 'application/x-rsync-signature', - fileMD5: signatureMd5 - }); - if (sigAwsRes.status !== 200) { - $throw( - sigAwsRes.status, - `${type} image upload failed`, - sigStartRes.json.url - ); - } - - // imageSigFinish - await api.sigFinish({ fileId: uploadedFileId, fileVersion }); - - // imageSet - const newImageUrl = `${AppDebug.endpointDomain}/file/${uploadedFileId}/${fileVersion}/file`; - const setRes = await api.setImage({ id: entityId, imageUrl: newImageUrl }); - if (setRes.json.imageUrl !== newImageUrl) { - $throw(0, `${type} image change failed`, newImageUrl); - } -} diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js index 0c4f3bc0..40b79234 100644 --- a/src/shared/utils/index.js +++ b/src/shared/utils/index.js @@ -1,7 +1,7 @@ export * from './base/array'; export * from './base/devtool'; export * from './base/format'; -export * from './base/date'; +export { formatDateFilter } from '../../coordinators/dateCoordinator'; export * from './base/string'; export * from './avatar'; export * from './chart'; @@ -20,7 +20,6 @@ export * from './gallery'; export * from './location'; export * from './invite'; export * from './world'; -export * from './memos'; export * from './throttle'; export * from './retry'; export * from './gameLog'; diff --git a/src/shared/utils/instance.js b/src/shared/utils/instance.js index be8d4962..b3784fca 100644 --- a/src/shared/utils/instance.js +++ b/src/shared/utils/instance.js @@ -1,20 +1,3 @@ -import { instanceRequest } from '../../api'; -import { parseLocation } from './locationParser'; - -/** - * - * @param {object} instance - */ -function refreshInstancePlayerCount(instance) { - const L = parseLocation(instance); - if (L.isRealInstance) { - instanceRequest.getInstance({ - worldId: L.worldId, - instanceId: L.instanceId - }); - } -} - /** * * @param {string} instanceId @@ -131,9 +114,4 @@ function buildLegacyInstanceTag({ return tags.join(''); } -export { - refreshInstancePlayerCount, - isRealInstance, - getLaunchURL, - buildLegacyInstanceTag -}; +export { isRealInstance, getLaunchURL, buildLegacyInstanceTag }; diff --git a/src/stores/friend.js b/src/stores/friend.js index edeaaae8..39f735d7 100644 --- a/src/stores/friend.js +++ b/src/stores/friend.js @@ -8,9 +8,9 @@ import { createRateLimiter, executeWithBackoff, getFriendsSortFunction, - getUserMemo, isRealInstance } from '../shared/utils'; +import { getUserMemo } from '../coordinators/memoCoordinator'; import { friendRequest, userRequest } from '../api'; import { runInitFriendsListFlow, @@ -261,8 +261,6 @@ export const useFriendStore = defineStore('Friend', () => { init(); - - /** * */ @@ -699,13 +697,11 @@ export const useFriendStore = defineStore('Friend', () => { * @param {string} id */ - /** * * @param {object} ref */ - /** * * @param {object} currentUser @@ -1129,7 +1125,6 @@ export const useFriendStore = defineStore('Friend', () => { * @param id */ - /** * Clears all entries in friendLog. * Uses .clear() instead of reassignment to keep the same Map reference, diff --git a/src/stores/gallery.js b/src/stores/gallery.js index 8e6bb3ca..06c25236 100644 --- a/src/stores/gallery.js +++ b/src/stores/gallery.js @@ -16,7 +16,7 @@ import { vrcPlusImageRequest } from '../api'; import { AppDebug } from '../services/appConfig'; -import { handleImageUploadInput } from '../shared/utils/imageUpload'; +import { handleImageUploadInput } from '../coordinators/imageUploadCoordinator'; import { router } from '../plugins/router'; import { useAdvancedSettingsStore } from './settings/advanced'; import { useModalStore } from './modal'; diff --git a/src/stores/notification/index.js b/src/stores/notification/index.js index 81095240..2c8b803c 100644 --- a/src/stores/notification/index.js +++ b/src/stores/notification/index.js @@ -13,12 +13,12 @@ import { escapeTag, executeWithBackoff, findUserByDisplayName, - getUserMemo, parseLocation, parseNotificationDetails, removeFromArray, sanitizeNotificationJson } from '../../shared/utils'; +import { getUserMemo } from '../../coordinators/memoCoordinator'; import { friendRequest, instanceRequest, @@ -347,11 +347,13 @@ export const useNotificationStore = defineStore('Notification', () => { } } } - if (!checkCanInvite(currentLocation, { - currentUserId: userStore.currentUser.id, - lastLocationStr: locationStore.lastLocation.location, - cachedInstances: instanceStore.cachedInstances - })) { + if ( + !checkCanInvite(currentLocation, { + currentUserId: userStore.currentUser.id, + lastLocationStr: locationStore.lastLocation.location, + cachedInstances: instanceStore.cachedInstances + }) + ) { return; } diff --git a/src/stores/user.js b/src/stores/user.js index a7c6382d..69f3019d 100644 --- a/src/stores/user.js +++ b/src/stores/user.js @@ -8,16 +8,12 @@ import { compareByLocationAt, compareByName, compareByUpdatedAt, - getAllUserMemos, - getUserMemo, isRealInstance, parseLocation, replaceBioSymbols } from '../shared/utils'; -import { - instanceRequest, - userRequest -} from '../api'; +import { getAllUserMemos, getUserMemo } from '../coordinators/memoCoordinator'; +import { instanceRequest, userRequest } from '../api'; import { AppDebug } from '../services/appConfig'; import { database } from '../services/database'; import { runUpdateCurrentUserLocationFlow } from '../coordinators/locationCoordinator'; diff --git a/src/views/FriendsLocations/__tests__/FriendsLocations.test.js b/src/views/FriendsLocations/__tests__/FriendsLocations.test.js index 2f3c55d6..8f529382 100644 --- a/src/views/FriendsLocations/__tests__/FriendsLocations.test.js +++ b/src/views/FriendsLocations/__tests__/FriendsLocations.test.js @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ favoriteFriendGroups: null, groupedByGroupKeyFavoriteFriends: null, localFriendFavorites: null, + lastLocation: null, configGetString: vi.fn(), configGetBool: vi.fn(), configSetString: vi.fn(), @@ -37,6 +38,10 @@ mocks.sidebarSortMethods = mocks.makeRef('status'); mocks.favoriteFriendGroups = mocks.makeRef([]); mocks.groupedByGroupKeyFavoriteFriends = mocks.makeRef({}); mocks.localFriendFavorites = mocks.makeRef({}); +mocks.lastLocation = mocks.makeRef({ + location: 'wrld_home:123', + friendList: new Map() +}); vi.mock('pinia', () => ({ storeToRefs: (store) => store @@ -68,6 +73,9 @@ vi.mock('../../../stores', () => ({ favoriteFriendGroups: mocks.favoriteFriendGroups, groupedByGroupKeyFavoriteFriends: mocks.groupedByGroupKeyFavoriteFriends, localFriendFavorites: mocks.localFriendFavorites + }), + useLocationStore: () => ({ + lastLocation: mocks.lastLocation }) })); @@ -225,6 +233,10 @@ describe('FriendsLocations.vue', () => { mocks.favoriteFriendGroups.value = []; mocks.groupedByGroupKeyFavoriteFriends.value = {}; mocks.localFriendFavorites.value = {}; + mocks.lastLocation.value = { + location: 'wrld_home:123', + friendList: new Map() + }; mocks.configGetString.mockReset(); mocks.configGetBool.mockReset(); diff --git a/src/views/MyAvatars/MyAvatars.vue b/src/views/MyAvatars/MyAvatars.vue index 92e51476..886a9cf9 100644 --- a/src/views/MyAvatars/MyAvatars.vue +++ b/src/views/MyAvatars/MyAvatars.vue @@ -314,20 +314,24 @@ import { useI18n } from 'vue-i18n'; import { useVirtualizer } from '@tanstack/vue-virtual'; - import { - handleImageUploadInput, - readFileAsBase64, - resizeImageToFitLimits, - uploadImageLegacy, - withUploadTimeout - } from '../../shared/utils/imageUpload'; import { useAppearanceSettingsStore, useAvatarStore, useModalStore, useUserStore } from '../../stores'; import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../../components/ui/context-menu'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../../components/ui/dropdown-menu'; import { Field, FieldContent, FieldLabel } from '../../components/ui/field'; import { Popover, PopoverContent, PopoverTrigger } from '../../components/ui/popover'; + import { + applyAvatar, + selectAvatarWithoutConfirmation, + showAvatarDialog + } from '../../coordinators/avatarCoordinator'; + import { + handleImageUploadInput, + resizeImageToFitLimits, + uploadImageLegacy + } from '../../coordinators/imageUploadCoordinator'; import { DataTableEmpty, DataTableLayout } from '../../components/ui/data-table'; import { ToggleGroup, ToggleGroupItem } from '../../components/ui/toggle-group'; + import { readFileAsBase64, withUploadTimeout } from '../../shared/utils/imageUpload'; import { Badge } from '../../components/ui/badge'; import { Button } from '../../components/ui/button'; import { Input } from '../../components/ui/input'; @@ -347,7 +351,6 @@ import ManageTagsDialog from './ManageTagsDialog.vue'; import MyAvatarCard from './components/MyAvatarCard.vue'; import configRepository from '../../services/config.js'; - import { showAvatarDialog, selectAvatarWithoutConfirmation, applyAvatar } from '../../coordinators/avatarCoordinator'; const { t } = useI18n(); const appearanceSettingsStore = useAppearanceSettingsStore(); diff --git a/src/views/Sidebar/components/__tests__/FriendsSidebar.test.js b/src/views/Sidebar/components/__tests__/FriendsSidebar.test.js index 4fb14a8d..f5ba80e2 100644 --- a/src/views/Sidebar/components/__tests__/FriendsSidebar.test.js +++ b/src/views/Sidebar/components/__tests__/FriendsSidebar.test.js @@ -55,6 +55,9 @@ const mocks = vi.hoisted(() => ({ gameStore: { isGameRunning: { value: true } }, + instanceStore: { + cachedInstances: new Map() + }, configRepository: { getBool: vi.fn(), setBool: vi.fn() @@ -111,6 +114,7 @@ vi.mock('../../../../stores', () => ({ useGameStore: () => mocks.gameStore, useLaunchStore: () => mocks.launchStore, useLocationStore: () => mocks.locationStore, + useInstanceStore: () => mocks.instanceStore, useUserStore: () => mocks.userStore })); @@ -233,6 +237,7 @@ describe('FriendsSidebar.vue', () => { mocks.friendStore.activeFriends.value = []; mocks.friendStore.offlineFriends.value = []; mocks.friendStore.friendsInSameInstance.value = []; + mocks.instanceStore.cachedInstances = new Map(); mocks.appearanceStore.isSidebarGroupByInstance.value = false; mocks.appearanceStore.isHideFriendsInSameInstance.value = false; diff --git a/src/views/Sidebar/components/__tests__/NotificationItem.test.js b/src/views/Sidebar/components/__tests__/NotificationItem.test.js index 4e42b795..69a38e00 100644 --- a/src/views/Sidebar/components/__tests__/NotificationItem.test.js +++ b/src/views/Sidebar/components/__tests__/NotificationItem.test.js @@ -18,15 +18,25 @@ const mocks = vi.hoisted(() => ({ }, userStore: { cachedUsers: new Map(), - showSendBoopDialog: vi.fn() + showSendBoopDialog: vi.fn(), + currentUser: { id: 'usr_me' } + }, + friendStore: { + friends: new Map() }, groupStore: {}, locationStore: { - lastLocation: { value: { location: 'wrld_home:123' } } + lastLocation: { + location: 'wrld_home:123', + value: { location: 'wrld_home:123' } + } }, gameStore: { isGameRunning: { value: true } }, + instanceStore: { + cachedInstances: new Map() + }, showUserDialog: vi.fn(), showGroupDialog: vi.fn() })); @@ -42,9 +52,11 @@ vi.mock('pinia', async (importOriginal) => { vi.mock('../../../../stores', () => ({ useNotificationStore: () => mocks.notificationStore, useUserStore: () => mocks.userStore, + useFriendStore: () => mocks.friendStore, useGroupStore: () => mocks.groupStore, useLocationStore: () => mocks.locationStore, - useGameStore: () => mocks.gameStore + useGameStore: () => mocks.gameStore, + useInstanceStore: () => mocks.instanceStore })); vi.mock('../../../../coordinators/userCoordinator', () => ({ @@ -60,6 +72,13 @@ vi.mock('../../../../shared/utils', () => ({ userImage: vi.fn(() => 'https://example.com/avatar.png') })); +vi.mock('../../../../composables/useUserDisplay', () => ({ + useUserDisplay: () => ({ + userImage: vi.fn(() => 'https://example.com/avatar.png'), + userStatusClass: vi.fn(() => '') + }) +})); + vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key) => key, @@ -170,6 +189,8 @@ describe('NotificationItem.vue', () => { mocks.userStore.showSendBoopDialog.mockReset(); mocks.showGroupDialog.mockReset(); mocks.userStore.cachedUsers = new Map(); + mocks.friendStore.friends = new Map(); + mocks.instanceStore.cachedInstances = new Map(); }); test('renders sender and opens user dialog on sender click', async () => { diff --git a/src/views/Tools/Gallery.vue b/src/views/Tools/Gallery.vue index ca3bec32..3e9c43a2 100644 --- a/src/views/Tools/Gallery.vue +++ b/src/views/Tools/Gallery.vue @@ -586,7 +586,8 @@ } from '../../shared/utils'; import { inventoryRequest, miscRequest, userRequest, vrcPlusIconRequest, vrcPlusImageRequest } from '../../api'; import { useAdvancedSettingsStore, useAuthStore, useGalleryStore, useModalStore, useUserStore } from '../../stores'; - import { handleImageUploadInput, readFileAsBase64, withUploadTimeout } from '../../shared/utils/imageUpload'; + import { readFileAsBase64, withUploadTimeout } from '../../shared/utils/imageUpload'; + import { handleImageUploadInput } from '../../coordinators/imageUploadCoordinator'; import { emojiAnimationStyleList, emojiAnimationStyleUrl } from '../../shared/constants'; import { AppDebug } from '../../services/appConfig';