diff --git a/src/components/dialogs/AvatarDialog/AvatarDialog.vue b/src/components/dialogs/AvatarDialog/AvatarDialog.vue index 1c1c7e1d..2945531a 100644 --- a/src/components/dialogs/AvatarDialog/AvatarDialog.vue +++ b/src/components/dialogs/AvatarDialog/AvatarDialog.vue @@ -570,17 +570,6 @@ import VueJsonPretty from 'vue-json-pretty'; - import { - commaNumber, - compareUnityVersion, - copyToClipboard, - downloadAndSaveJson, - formatDateFilter, - openExternalLink, - openFolderGeneric, - replaceVrcPackageUrl, - timeToText - } from '../../../shared/utils'; import { useAppearanceSettingsStore, useAvatarStore, @@ -591,6 +580,15 @@ useUiStore, useUserStore } from '../../../stores'; + import { + commaNumber, + compareUnityVersion, + copyToClipboard, + downloadAndSaveJson, + formatDateFilter, + openFolderGeneric, + timeToText + } from '../../../shared/utils'; import { DropdownMenu, DropdownMenuContent, @@ -598,17 +596,12 @@ DropdownMenuSeparator, DropdownMenuTrigger } from '../../ui/dropdown-menu'; - import { - handleImageUploadInput, - readFileAsBase64, - resizeImageToFitLimits, - uploadImageLegacy, - withUploadTimeout - } from '../../../shared/utils/imageUpload'; - import { avatarModerationRequest, avatarRequest, favoriteRequest } from '../../../api'; import { Badge } from '../../ui/badge'; + import { avatarRequest } from '../../../api'; import { database } from '../../../service/database'; import { formatJsonVars } from '../../../shared/utils/base/ui'; + import { handleImageUploadInput } from '../../../shared/utils/imageUpload'; + import { useAvatarDialogCommands } from './useAvatarDialogCommands'; import DialogJsonTab from '../DialogJsonTab.vue'; import ImageCropDialog from '../ImageCropDialog.vue'; @@ -632,15 +625,36 @@ const uiStore = useUiStore(); const { t } = useI18n(); + + const { + cropDialogOpen, + cropDialogFile, + changeAvatarImageLoading, + avatarDialogCommand, + onFileChangeAvatarImage, + onCropConfirmAvatar, + registerCallbacks + } = useAvatarDialogCommands(avatarDialog, { + t, + toast, + modalStore, + userDialog, + currentUser, + cachedAvatars, + cachedAvatarModerations, + showAvatarDialog, + showFavoriteDialog, + applyAvatarModeration, + applyAvatar, + sortUserDialogAvatars, + uiStore + }); + const avatarDialogTabs = computed(() => [ { value: 'Info', label: t('dialog.avatar.info.header') }, { value: 'JSON', label: t('dialog.avatar.json.header') } ]); - const cropDialogOpen = ref(false); - const cropDialogFile = ref(null); - const changeAvatarImageLoading = ref(false); - const treeData = ref({}); const memo = ref(''); const setAvatarTagsDialog = ref({ @@ -813,333 +827,11 @@ * * @param command */ - function avatarDialogCommand(command) { - const D = avatarDialog.value; - switch (command) { - case 'Refresh': - const avatarId = D.id; - showAvatarDialog(avatarId, { forceRefresh: true }); - break; - case 'Share': - copyAvatarUrl(D.id); - break; - case 'Rename': - promptRenameAvatar(D); - break; - case 'Change Image': - showChangeAvatarImageDialog(); - break; - case 'Change Description': - promptChangeAvatarDescription(D); - break; - case 'Change Content Tags': - showSetAvatarTagsDialog(D.id); - break; - case 'Change Styles and Author Tags': - showSetAvatarStylesDialog(); - break; - case 'Download Unity Package': - openExternalLink(replaceVrcPackageUrl(avatarDialog.value.ref.unityPackageUrl)); - break; - case 'Add Favorite': - showFavoriteDialog('avatar', D.id); - break; - default: - const commandLabelMap = { - 'Delete Favorite': t('dialog.avatar.actions.favorite_tooltip'), - 'Select Fallback Avatar': t('dialog.avatar.actions.select_fallback'), - 'Block Avatar': t('dialog.avatar.actions.block'), - 'Unblock Avatar': t('dialog.avatar.actions.unblock'), - 'Make Public': t('dialog.avatar.actions.make_public'), - 'Make Private': t('dialog.avatar.actions.make_private'), - Delete: t('dialog.avatar.actions.delete'), - 'Delete Imposter': t('dialog.avatar.actions.delete_impostor'), - 'Create Imposter': t('dialog.avatar.actions.create_impostor'), - 'Regenerate Imposter': t('dialog.avatar.actions.regenerate_impostor') - }; - modalStore - .confirm({ - title: t('confirm.title'), - description: t('confirm.command_question', { - command: commandLabelMap[command] ?? command - }) - }) - .then(({ ok }) => { - if (!ok) return; - switch (command) { - case 'Delete Favorite': - favoriteRequest.deleteFavorite({ - objectId: D.id - }); - break; - case 'Select Fallback Avatar': - avatarRequest - .selectFallbackAvatar({ - avatarId: D.id - }) - .then((args) => { - toast.success(t('message.avatar.fallback_changed')); - return args; - }); - break; - case 'Block Avatar': - avatarModerationRequest - .sendAvatarModeration({ - avatarModerationType: 'block', - targetAvatarId: D.id - }) - .then((args) => { - // 'AVATAR-MODERATION'; - applyAvatarModeration(args.json); - toast.success(t('message.avatar.blocked')); - return args; - }); - break; - case 'Unblock Avatar': - avatarModerationRequest - .deleteAvatarModeration({ - avatarModerationType: 'block', - targetAvatarId: D.id - }) - .then((args) => { - cachedAvatarModerations.delete(args.params.targetAvatarId); - const D = avatarDialog.value; - if ( - args.params.avatarModerationType === 'block' && - D.id === args.params.targetAvatarId - ) { - D.isBlocked = false; - } - }); - break; - case 'Make Public': - avatarRequest - .saveAvatar({ - id: D.id, - releaseStatus: 'public' - }) - .then((args) => { - applyAvatar(args.json); - toast.success(t('message.avatar.updated_public')); - return args; - }); - break; - case 'Make Private': - avatarRequest - .saveAvatar({ - id: D.id, - releaseStatus: 'private' - }) - .then((args) => { - applyAvatar(args.json); - toast.success(t('message.avatar.updated_private')); - return args; - }); - break; - case 'Delete': - avatarRequest - .deleteAvatar({ - avatarId: D.id - }) - .then((args) => { - const { json } = args; - cachedAvatars.delete(json._id); - if (userDialog.value.id === json.authorId) { - const map = new Map(); - for (const ref of cachedAvatars.values()) { - if (ref.authorId === json.authorId) { - map.set(ref.id, ref); - } - } - const array = Array.from(map.values()); - sortUserDialogAvatars(array); - } - - toast.success(t('message.avatar.deleted')); - uiStore.jumpBackDialogCrumb(); - return args; - }); - break; - case 'Delete Imposter': - avatarRequest - .deleteImposter({ - avatarId: D.id - }) - .then((args) => { - toast.success(t('message.avatar.impostor_deleted')); - showAvatarDialog(D.id); - return args; - }); - break; - case 'Create Imposter': - avatarRequest - .createImposter({ - avatarId: D.id - }) - .then((args) => { - toast.success(t('message.avatar.impostor_queued')); - return args; - }); - break; - case 'Regenerate Imposter': - avatarRequest - .deleteImposter({ - avatarId: D.id - }) - .then((args) => { - showAvatarDialog(D.id); - return args; - }) - .finally(() => { - avatarRequest - .createImposter({ - avatarId: D.id - }) - .then((args) => { - toast.success(t('message.avatar.impostor_regenerated')); - return args; - }); - }); - break; - } - }); - break; - } - } - - /** - * - */ - function showChangeAvatarImageDialog() { - document.getElementById('AvatarImageUploadButton').click(); - } - - /** - * - * @param e - */ - function onFileChangeAvatarImage(e) { - const { file, clearInput } = handleImageUploadInput(e, { - inputSelector: '#AvatarImageUploadButton', - tooLargeMessage: () => t('message.file.too_large'), - invalidTypeMessage: () => t('message.file.not_image') - }); - if (!file) { - return; - } - if (!avatarDialog.value.visible || avatarDialog.value.loading) { - clearInput(); - return; - } - clearInput(); - cropDialogFile.value = file; - cropDialogOpen.value = true; - } - - /** - * - * @param blob - */ - async function onCropConfirmAvatar(blob) { - changeAvatarImageLoading.value = true; - try { - await withUploadTimeout( - (async () => { - const base64Body = await readFileAsBase64(blob); - const base64File = await resizeImageToFitLimits(base64Body); - if (LINUX) { - const args = await avatarRequest.uploadAvatarImage(base64File); - const fileUrl = args.json.versions[args.json.versions.length - 1].file.url; - await avatarRequest.saveAvatar({ - id: avatarDialog.value.id, - imageUrl: fileUrl - }); - } else { - await uploadImageLegacy('avatar', { - entityId: avatarDialog.value.id, - imageUrl: avatarDialog.value.ref.imageUrl, - base64File, - blob - }); - } - })() - ); - toast.success(t('message.upload.success')); - // force refresh cover image - const avatarId = avatarDialog.value.id; - showAvatarDialog(avatarId, { forceRefresh: true }); - } catch (error) { - console.error('avatar image upload process failed:', error); - toast.error(t('message.upload.error')); - } finally { - changeAvatarImageLoading.value = false; - cropDialogOpen.value = false; - } - } - - /** - * - * @param avatar - */ - function promptChangeAvatarDescription(avatar) { - modalStore - .prompt({ - title: t('prompt.change_avatar_description.header'), - description: t('prompt.change_avatar_description.description'), - confirmText: t('prompt.change_avatar_description.ok'), - cancelText: t('prompt.change_avatar_description.cancel'), - inputValue: avatar.ref.description, - errorMessage: t('prompt.change_avatar_description.input_error') - }) - .then(({ ok, value }) => { - if (!ok) return; - if (value && value !== avatar.ref.description) { - avatarRequest - .saveAvatar({ - id: avatar.id, - description: value - }) - .then((args) => { - applyAvatar(args.json); - toast.success(t('prompt.change_avatar_description.message.success')); - return args; - }); - } - }) - .catch(() => {}); - } - - /** - * - * @param avatar - */ - function promptRenameAvatar(avatar) { - modalStore - .prompt({ - title: t('prompt.rename_avatar.header'), - description: t('prompt.rename_avatar.description'), - confirmText: t('prompt.rename_avatar.ok'), - cancelText: t('prompt.rename_avatar.cancel'), - inputValue: avatar.ref.name, - errorMessage: t('prompt.rename_avatar.input_error') - }) - .then(({ ok, value }) => { - if (!ok) return; - if (value && value !== avatar.ref.name) { - avatarRequest - .saveAvatar({ - id: avatar.id, - name: value - }) - .then((args) => { - applyAvatar(args.json); - toast.success(t('prompt.rename_avatar.message.success')); - return args; - }); - } - }) - .catch(() => {}); - } + // Register component callbacks for the command composable + registerCallbacks({ + showSetAvatarTagsDialog: () => showSetAvatarTagsDialog(avatarDialog.value.id), + showSetAvatarStylesDialog + }); /** * @@ -1164,14 +856,6 @@ copyToClipboard(id); } - /** - * - * @param id - */ - function copyAvatarUrl(id) { - copyToClipboard(`https://vrchat.com/home/avatar/${id}`); - } - /** * */ diff --git a/src/components/dialogs/AvatarDialog/__tests__/useAvatarDialogCommands.test.js b/src/components/dialogs/AvatarDialog/__tests__/useAvatarDialogCommands.test.js new file mode 100644 index 00000000..7cdcc9f7 --- /dev/null +++ b/src/components/dialogs/AvatarDialog/__tests__/useAvatarDialogCommands.test.js @@ -0,0 +1,402 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ref } from 'vue'; +import { useAvatarDialogCommands } from '../useAvatarDialogCommands'; + +// Mock external modules +vi.mock('../../../../api', () => ({ + avatarModerationRequest: { + sendAvatarModeration: vi.fn(), + deleteAvatarModeration: vi.fn() + }, + avatarRequest: { + saveAvatar: vi.fn(), + deleteAvatar: vi.fn(), + selectFallbackAvatar: vi.fn(), + deleteImposter: vi.fn(), + createImposter: vi.fn(), + uploadAvatarImage: vi.fn() + }, + favoriteRequest: { + deleteFavorite: vi.fn() + } +})); + +vi.mock('../../../../shared/utils', () => ({ + copyToClipboard: vi.fn(), + openExternalLink: vi.fn(), + replaceVrcPackageUrl: vi.fn((url) => url) +})); + +vi.mock('../../../../shared/utils/imageUpload', () => ({ + handleImageUploadInput: vi.fn(), + readFileAsBase64: vi.fn(), + resizeImageToFitLimits: vi.fn(), + uploadImageLegacy: vi.fn(), + withUploadTimeout: vi.fn() +})); + +const { copyToClipboard, openExternalLink } = + await import('../../../../shared/utils'); +const { favoriteRequest, avatarRequest, avatarModerationRequest } = + await import('../../../../api'); + +/** + * + */ +function createMockAvatarDialog() { + return ref({ + visible: true, + loading: false, + id: 'avtr_test123', + ref: { + name: 'TestAvatar', + description: 'Test desc', + imageUrl: 'https://example.com/img.png', + thumbnailImageUrl: 'https://example.com/thumb.png', + authorId: 'usr_author', + authorName: 'Author', + releaseStatus: 'private', + tags: ['content_horror'], + unityPackageUrl: 'https://example.com/pkg.unitypackage', + styles: { primary: 'style1', secondary: 'style2' } + }, + isBlocked: false, + hasImposter: false, + timeSpent: 0, + galleryLoading: false, + galleryImages: [] + }); +} + +/** + * + * @param overrides + */ +function createMockDeps(overrides = {}) { + return { + t: vi.fn((key) => key), + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + promise: vi.fn() + }), + modalStore: { + confirm: vi.fn(() => Promise.resolve({ ok: true })), + prompt: vi.fn(() => + Promise.resolve({ ok: true, value: 'new_value' }) + ) + }, + userDialog: ref({ id: 'usr_author' }), + currentUser: ref({ id: 'usr_current', currentAvatar: 'avtr_other' }), + cachedAvatars: new Map([ + ['avtr_test123', { id: 'avtr_test123', authorId: 'usr_author' }] + ]), + cachedAvatarModerations: new Map(), + showAvatarDialog: vi.fn(), + showFavoriteDialog: vi.fn(), + applyAvatarModeration: vi.fn((json) => json), + applyAvatar: vi.fn((json) => json), + sortUserDialogAvatars: vi.fn(), + uiStore: { jumpBackDialogCrumb: vi.fn() }, + ...overrides + }; +} + +describe('useAvatarDialogCommands', () => { + let avatarDialog; + let deps; + + beforeEach(() => { + vi.clearAllMocks(); + avatarDialog = createMockAvatarDialog(); + deps = createMockDeps(); + }); + + describe('direct commands', () => { + it('Refresh: should call showAvatarDialog with forceRefresh', () => { + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Refresh'); + expect(deps.showAvatarDialog).toHaveBeenCalledWith('avtr_test123', { + forceRefresh: true + }); + }); + + it('Share: should copy avatar URL', () => { + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Share'); + expect(copyToClipboard).toHaveBeenCalledWith( + 'https://vrchat.com/home/avatar/avtr_test123' + ); + }); + + it('Add Favorite: should call showFavoriteDialog', () => { + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Add Favorite'); + expect(deps.showFavoriteDialog).toHaveBeenCalledWith( + 'avatar', + 'avtr_test123' + ); + }); + + it('Download Unity Package: should open external link', () => { + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Download Unity Package'); + expect(openExternalLink).toHaveBeenCalled(); + }); + + it('Rename: should show prompt dialog', () => { + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Rename'); + expect(deps.modalStore.prompt).toHaveBeenCalled(); + }); + + it('Change Description: should show prompt dialog', () => { + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Change Description'); + expect(deps.modalStore.prompt).toHaveBeenCalled(); + }); + + it('Change Image: triggers file input (DOM-dependent, skip in node)', () => { + // This command calls document.getElementById which is only available in browser + // Just verify no error when command is dispatched (DOM interaction tested in e2e) + if (typeof document === 'undefined') { + return; + } + const mockBtn = { click: vi.fn() }; + vi.spyOn(document, 'getElementById').mockReturnValue(mockBtn); + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Change Image'); + expect(mockBtn.click).toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + }); + + describe('string callback commands', () => { + it('should delegate to registered callbacks', () => { + const showSetAvatarTagsDialog = vi.fn(); + const { avatarDialogCommand, registerCallbacks } = + useAvatarDialogCommands(avatarDialog, deps); + registerCallbacks({ showSetAvatarTagsDialog }); + avatarDialogCommand('Change Content Tags'); + expect(showSetAvatarTagsDialog).toHaveBeenCalled(); + }); + + it('should not throw when callback is not registered', () => { + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + expect(() => + avatarDialogCommand('Change Content Tags') + ).not.toThrow(); + }); + }); + + describe('confirmed commands', () => { + it('Delete Favorite: should confirm then delete', async () => { + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Delete Favorite'); + await vi.waitFor(() => { + expect(deps.modalStore.confirm).toHaveBeenCalled(); + }); + await vi.waitFor(() => { + expect(favoriteRequest.deleteFavorite).toHaveBeenCalledWith({ + objectId: 'avtr_test123' + }); + }); + }); + + it('confirmed command should not execute when cancelled', async () => { + deps.modalStore.confirm = vi.fn(() => + Promise.resolve({ ok: false }) + ); + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Delete Favorite'); + await vi.waitFor(() => { + expect(deps.modalStore.confirm).toHaveBeenCalled(); + }); + expect(favoriteRequest.deleteFavorite).not.toHaveBeenCalled(); + }); + + it('Select Fallback Avatar: should confirm then select', async () => { + avatarRequest.selectFallbackAvatar.mockResolvedValue({ json: {} }); + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Select Fallback Avatar'); + await vi.waitFor(() => { + expect(avatarRequest.selectFallbackAvatar).toHaveBeenCalledWith( + { avatarId: 'avtr_test123' } + ); + }); + }); + + it('Block Avatar: should confirm then send moderation', async () => { + avatarModerationRequest.sendAvatarModeration.mockResolvedValue({ + json: { targetAvatarId: 'avtr_test123' } + }); + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Block Avatar'); + await vi.waitFor(() => { + expect( + avatarModerationRequest.sendAvatarModeration + ).toHaveBeenCalledWith({ + avatarModerationType: 'block', + targetAvatarId: 'avtr_test123' + }); + }); + }); + + it('Unblock Avatar: should confirm then delete moderation', async () => { + avatarModerationRequest.deleteAvatarModeration.mockResolvedValue({ + params: { + targetAvatarId: 'avtr_test123', + avatarModerationType: 'block' + } + }); + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Unblock Avatar'); + await vi.waitFor(() => { + expect( + avatarModerationRequest.deleteAvatarModeration + ).toHaveBeenCalledWith({ + avatarModerationType: 'block', + targetAvatarId: 'avtr_test123' + }); + }); + }); + + it('Make Public: should save avatar with public status', async () => { + avatarRequest.saveAvatar.mockResolvedValue({ + json: { releaseStatus: 'public' } + }); + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Make Public'); + await vi.waitFor(() => { + expect(avatarRequest.saveAvatar).toHaveBeenCalledWith({ + id: 'avtr_test123', + releaseStatus: 'public' + }); + }); + }); + + it('Make Private: should save avatar with private status', async () => { + avatarRequest.saveAvatar.mockResolvedValue({ + json: { releaseStatus: 'private' } + }); + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Make Private'); + await vi.waitFor(() => { + expect(avatarRequest.saveAvatar).toHaveBeenCalledWith({ + id: 'avtr_test123', + releaseStatus: 'private' + }); + }); + }); + + it('Delete: should delete avatar and update cache', async () => { + avatarRequest.deleteAvatar.mockResolvedValue({ + json: { _id: 'avtr_test123', authorId: 'usr_author' } + }); + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Delete'); + await vi.waitFor(() => { + expect(avatarRequest.deleteAvatar).toHaveBeenCalledWith({ + avatarId: 'avtr_test123' + }); + }); + }); + + it('Create Imposter: should create imposter', async () => { + avatarRequest.createImposter.mockResolvedValue({ json: {} }); + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Create Imposter'); + await vi.waitFor(() => { + expect(avatarRequest.createImposter).toHaveBeenCalledWith({ + avatarId: 'avtr_test123' + }); + }); + }); + + it('Delete Imposter: should delete imposter and refresh', async () => { + avatarRequest.deleteImposter.mockResolvedValue({ json: {} }); + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + avatarDialogCommand('Delete Imposter'); + await vi.waitFor(() => { + expect(avatarRequest.deleteImposter).toHaveBeenCalledWith({ + avatarId: 'avtr_test123' + }); + }); + }); + }); + + describe('unknown command', () => { + it('should do nothing for unknown commands', () => { + const { avatarDialogCommand } = useAvatarDialogCommands( + avatarDialog, + deps + ); + expect(() => avatarDialogCommand('NonExistent')).not.toThrow(); + expect(deps.modalStore.confirm).not.toHaveBeenCalled(); + }); + }); + + describe('image upload state', () => { + it('should expose crop dialog state refs', () => { + const { cropDialogOpen, cropDialogFile, changeAvatarImageLoading } = + useAvatarDialogCommands(avatarDialog, deps); + expect(cropDialogOpen.value).toBe(false); + expect(cropDialogFile.value).toBeNull(); + expect(changeAvatarImageLoading.value).toBe(false); + }); + }); +}); diff --git a/src/components/dialogs/AvatarDialog/useAvatarDialogCommands.js b/src/components/dialogs/AvatarDialog/useAvatarDialogCommands.js new file mode 100644 index 00000000..6a2d0201 --- /dev/null +++ b/src/components/dialogs/AvatarDialog/useAvatarDialogCommands.js @@ -0,0 +1,485 @@ +import { ref } from 'vue'; +import { + avatarModerationRequest, + avatarRequest, + favoriteRequest +} from '../../../api'; +import { + copyToClipboard, + openExternalLink, + replaceVrcPackageUrl +} from '../../../shared/utils'; +import { + handleImageUploadInput, + readFileAsBase64, + resizeImageToFitLimits, + uploadImageLegacy, + 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 + * @returns {object} command composable API + */ +export function useAvatarDialogCommands( + avatarDialog, + { + t, + toast, + modalStore, + userDialog, + currentUser, + cachedAvatars, + cachedAvatarModerations, + showAvatarDialog, + showFavoriteDialog, + applyAvatarModeration, + applyAvatar, + sortUserDialogAvatars, + uiStore + } +) { + // --- Image crop dialog state --- + const cropDialogOpen = ref(false); + const cropDialogFile = ref(null); + const changeAvatarImageLoading = ref(false); + + // --- Image upload --- + + /** + * + */ + function showChangeAvatarImageDialog() { + document.getElementById('AvatarImageUploadButton').click(); + } + + /** + * @param {Event} e + */ + function onFileChangeAvatarImage(e) { + const { file, clearInput } = handleImageUploadInput(e, { + inputSelector: '#AvatarImageUploadButton', + tooLargeMessage: () => t('message.file.too_large'), + invalidTypeMessage: () => t('message.file.not_image') + }); + if (!file) { + return; + } + if (!avatarDialog.value.visible || avatarDialog.value.loading) { + clearInput(); + return; + } + clearInput(); + cropDialogFile.value = file; + cropDialogOpen.value = true; + } + + /** + * @param {Blob} blob + */ + async function onCropConfirmAvatar(blob) { + changeAvatarImageLoading.value = true; + try { + await withUploadTimeout( + (async () => { + const base64Body = await readFileAsBase64(blob); + const base64File = await resizeImageToFitLimits(base64Body); + if (LINUX) { + const args = + await avatarRequest.uploadAvatarImage(base64File); + const fileUrl = + args.json.versions[args.json.versions.length - 1] + .file.url; + await avatarRequest.saveAvatar({ + id: avatarDialog.value.id, + imageUrl: fileUrl + }); + } else { + await uploadImageLegacy('avatar', { + entityId: avatarDialog.value.id, + imageUrl: avatarDialog.value.ref.imageUrl, + base64File, + blob + }); + } + })() + ); + toast.success(t('message.upload.success')); + // force refresh cover image + const avatarId = avatarDialog.value.id; + showAvatarDialog(avatarId, { forceRefresh: true }); + } catch (error) { + console.error('avatar image upload process failed:', error); + toast.error(t('message.upload.error')); + } finally { + changeAvatarImageLoading.value = false; + cropDialogOpen.value = false; + } + } + + // --- Prompt dialogs --- + + /** + * @param {object} avatar + */ + function promptRenameAvatar(avatar) { + modalStore + .prompt({ + title: t('prompt.rename_avatar.header'), + description: t('prompt.rename_avatar.description'), + confirmText: t('prompt.rename_avatar.ok'), + cancelText: t('prompt.rename_avatar.cancel'), + inputValue: avatar.ref.name, + errorMessage: t('prompt.rename_avatar.input_error') + }) + .then(({ ok, value }) => { + if (!ok) return; + if (value && value !== avatar.ref.name) { + avatarRequest + .saveAvatar({ + id: avatar.id, + name: value + }) + .then((args) => { + applyAvatar(args.json); + toast.success( + t('prompt.rename_avatar.message.success') + ); + return args; + }); + } + }) + .catch(() => {}); + } + + /** + * @param {object} avatar + */ + function promptChangeAvatarDescription(avatar) { + modalStore + .prompt({ + title: t('prompt.change_avatar_description.header'), + description: t('prompt.change_avatar_description.description'), + confirmText: t('prompt.change_avatar_description.ok'), + cancelText: t('prompt.change_avatar_description.cancel'), + inputValue: avatar.ref.description, + errorMessage: t('prompt.change_avatar_description.input_error') + }) + .then(({ ok, value }) => { + if (!ok) return; + if (value && value !== avatar.ref.description) { + avatarRequest + .saveAvatar({ + id: avatar.id, + description: value + }) + .then((args) => { + applyAvatar(args.json); + toast.success( + t( + 'prompt.change_avatar_description.message.success' + ) + ); + return args; + }); + } + }) + .catch(() => {}); + } + + // --- Internal helper --- + + /** + * @param {string} id + */ + function copyAvatarUrl(id) { + copyToClipboard(`https://vrchat.com/home/avatar/${id}`); + } + + // --- Command map --- + // Direct commands: function + // String commands: delegate to component callback + // Confirmed commands: { confirm: true, label: string, handler: fn } + + function buildCommandMap() { + const D = () => avatarDialog.value; + + const confirmLabelMap = { + 'Delete Favorite': () => + t('dialog.avatar.actions.favorite_tooltip'), + 'Select Fallback Avatar': () => + t('dialog.avatar.actions.select_fallback'), + 'Block Avatar': () => t('dialog.avatar.actions.block'), + 'Unblock Avatar': () => t('dialog.avatar.actions.unblock'), + 'Make Public': () => t('dialog.avatar.actions.make_public'), + 'Make Private': () => t('dialog.avatar.actions.make_private'), + Delete: () => t('dialog.avatar.actions.delete'), + 'Delete Imposter': () => t('dialog.avatar.actions.delete_impostor'), + 'Create Imposter': () => t('dialog.avatar.actions.create_impostor'), + 'Regenerate Imposter': () => + t('dialog.avatar.actions.regenerate_impostor') + }; + + return { + // --- Direct commands --- + Refresh: () => { + showAvatarDialog(D().id, { forceRefresh: true }); + }, + Share: () => { + copyAvatarUrl(D().id); + }, + Rename: () => { + promptRenameAvatar(D()); + }, + 'Change Image': () => { + showChangeAvatarImageDialog(); + }, + 'Change Description': () => { + promptChangeAvatarDescription(D()); + }, + 'Download Unity Package': () => { + openExternalLink(replaceVrcPackageUrl(D().ref.unityPackageUrl)); + }, + 'Add Favorite': () => { + showFavoriteDialog('avatar', D().id); + }, + + // --- Delegated to component --- + 'Change Content Tags': 'showSetAvatarTagsDialog', + 'Change Styles and Author Tags': 'showSetAvatarStylesDialog', + + // --- Confirmed commands --- + 'Delete Favorite': { + confirm: true, + label: confirmLabelMap['Delete Favorite'], + handler: (id) => { + favoriteRequest.deleteFavorite({ objectId: id }); + } + }, + 'Select Fallback Avatar': { + confirm: true, + label: confirmLabelMap['Select Fallback Avatar'], + handler: (id) => { + avatarRequest + .selectFallbackAvatar({ avatarId: id }) + .then((args) => { + toast.success(t('message.avatar.fallback_changed')); + return args; + }); + } + }, + 'Block Avatar': { + confirm: true, + label: confirmLabelMap['Block Avatar'], + handler: (id) => { + avatarModerationRequest + .sendAvatarModeration({ + avatarModerationType: 'block', + targetAvatarId: id + }) + .then((args) => { + // 'AVATAR-MODERATION'; + applyAvatarModeration(args.json); + toast.success(t('message.avatar.blocked')); + return args; + }); + } + }, + 'Unblock Avatar': { + confirm: true, + label: confirmLabelMap['Unblock Avatar'], + handler: (id) => { + avatarModerationRequest + .deleteAvatarModeration({ + avatarModerationType: 'block', + targetAvatarId: id + }) + .then((args) => { + cachedAvatarModerations.delete( + args.params.targetAvatarId + ); + const D = avatarDialog.value; + if ( + args.params.avatarModerationType === 'block' && + D.id === args.params.targetAvatarId + ) { + D.isBlocked = false; + } + }); + } + }, + 'Make Public': { + confirm: true, + label: confirmLabelMap['Make Public'], + handler: (id) => { + avatarRequest + .saveAvatar({ id, releaseStatus: 'public' }) + .then((args) => { + applyAvatar(args.json); + toast.success(t('message.avatar.updated_public')); + return args; + }); + } + }, + 'Make Private': { + confirm: true, + label: confirmLabelMap['Make Private'], + handler: (id) => { + avatarRequest + .saveAvatar({ id, releaseStatus: 'private' }) + .then((args) => { + applyAvatar(args.json); + toast.success(t('message.avatar.updated_private')); + return args; + }); + } + }, + Delete: { + confirm: true, + label: confirmLabelMap['Delete'], + handler: (id) => { + avatarRequest + .deleteAvatar({ avatarId: id }) + .then((args) => { + const { json } = args; + cachedAvatars.delete(json._id); + if (userDialog.value.id === json.authorId) { + const map = new Map(); + for (const ref of cachedAvatars.values()) { + if (ref.authorId === json.authorId) { + map.set(ref.id, ref); + } + } + const array = Array.from(map.values()); + sortUserDialogAvatars(array); + } + + toast.success(t('message.avatar.deleted')); + uiStore.jumpBackDialogCrumb(); + return args; + }); + } + }, + 'Delete Imposter': { + confirm: true, + label: confirmLabelMap['Delete Imposter'], + handler: (id) => { + avatarRequest + .deleteImposter({ avatarId: id }) + .then((args) => { + toast.success(t('message.avatar.impostor_deleted')); + showAvatarDialog(id); + return args; + }); + } + }, + 'Create Imposter': { + confirm: true, + label: confirmLabelMap['Create Imposter'], + handler: (id) => { + avatarRequest + .createImposter({ avatarId: id }) + .then((args) => { + toast.success(t('message.avatar.impostor_queued')); + return args; + }); + } + }, + 'Regenerate Imposter': { + confirm: true, + label: confirmLabelMap['Regenerate Imposter'], + handler: (id) => { + avatarRequest + .deleteImposter({ avatarId: id }) + .then((args) => { + showAvatarDialog(id); + return args; + }) + .finally(() => { + avatarRequest + .createImposter({ avatarId: id }) + .then((args) => { + toast.success( + t('message.avatar.impostor_regenerated') + ); + return args; + }); + }); + } + } + }; + } + + const commandMap = buildCommandMap(); + + // Callbacks for string-type commands (delegated to component) + let componentCallbacks = {}; + + /** + * Register component-level callbacks for string-type commands. + * @param {object} callbacks + */ + function registerCallbacks(callbacks) { + componentCallbacks = callbacks; + } + + /** + * Dispatch an avatar dialog command. + * @param {string} command + */ + function avatarDialogCommand(command) { + const D = avatarDialog.value; + const entry = commandMap[command]; + + if (!entry) { + return; + } + + // String entry => delegate to component callback + if (typeof entry === 'string') { + const cb = componentCallbacks[entry]; + if (cb) { + cb(); + } + return; + } + + // Direct function + if (typeof entry === 'function') { + entry(); + return; + } + + // Confirmed command + if (entry.confirm) { + const displayLabel = + typeof entry.label === 'function' ? entry.label() : command; + modalStore + .confirm({ + title: t('confirm.title'), + description: t('confirm.command_question', { + command: displayLabel + }) + }) + .then(({ ok }) => { + if (ok) { + entry.handler(D.id); + } + }); + } + } + + return { + cropDialogOpen, + cropDialogFile, + changeAvatarImageLoading, + avatarDialogCommand, + onFileChangeAvatarImage, + onCropConfirmAvatar, + registerCallbacks + }; +} diff --git a/src/components/dialogs/GroupDialog/GroupDialog.vue b/src/components/dialogs/GroupDialog/GroupDialog.vue index f6457f51..7426887c 100644 --- a/src/components/dialogs/GroupDialog/GroupDialog.vue +++ b/src/components/dialogs/GroupDialog/GroupDialog.vue @@ -342,805 +342,20 @@ :unmount-on-hide="false" @update:modelValue="groupDialogTabClick"> diff --git a/src/components/dialogs/WorldDialog/WorldDialogInstancesTab.vue b/src/components/dialogs/WorldDialog/WorldDialogInstancesTab.vue new file mode 100644 index 00000000..a304f94f --- /dev/null +++ b/src/components/dialogs/WorldDialog/WorldDialogInstancesTab.vue @@ -0,0 +1,131 @@ + + + diff --git a/src/components/dialogs/WorldDialog/__tests__/useWorldDialogCommands.test.js b/src/components/dialogs/WorldDialog/__tests__/useWorldDialogCommands.test.js new file mode 100644 index 00000000..ccc22bce --- /dev/null +++ b/src/components/dialogs/WorldDialog/__tests__/useWorldDialogCommands.test.js @@ -0,0 +1,540 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { ref } from 'vue'; +import { useWorldDialogCommands } from '../useWorldDialogCommands'; + +vi.mock('../../../../api', () => ({ + favoriteRequest: { + deleteFavorite: vi.fn() + }, + miscRequest: { + deleteWorldPersistData: vi.fn() + }, + userRequest: { + saveCurrentUser: vi.fn() + }, + worldRequest: { + saveWorld: vi.fn(), + publishWorld: vi.fn(), + unpublishWorld: vi.fn(), + deleteWorld: vi.fn() + } +})); + +vi.mock('../../../../shared/utils', () => ({ + openExternalLink: vi.fn(), + replaceVrcPackageUrl: vi.fn((url) => url) +})); + +vi.mock('../../../../shared/utils/imageUpload', () => ({ + handleImageUploadInput: vi.fn(), + readFileAsBase64: vi.fn(), + resizeImageToFitLimits: vi.fn(), + uploadImageLegacy: vi.fn(), + withUploadTimeout: vi.fn((p) => p) +})); + +const { favoriteRequest, miscRequest, userRequest, worldRequest } = + await import('../../../../api'); +const { openExternalLink } = await import('../../../../shared/utils'); + +function createWorldDialog(overrides = {}) { + return ref({ + id: 'wrld_123', + visible: true, + loading: false, + hasPersistData: true, + isFavorite: false, + $location: { + tag: 'wrld_123:12345~region(us)', + shortName: 'Test World' + }, + ref: { + name: 'Test World', + description: 'A test world', + authorId: 'usr_author', + capacity: 20, + recommendedCapacity: 10, + previewYoutubeId: 'abc123', + imageUrl: 'https://example.com/image.jpg', + unityPackageUrl: 'https://example.com/package.unitypackage', + urlList: ['https://example.com'], + tags: ['system_approved'] + }, + ...overrides + }); +} + +function createDeps(overrides = {}) { + return { + t: vi.fn((key) => key), + toast: { + success: vi.fn(), + error: vi.fn() + }, + modalStore: { + confirm: vi.fn().mockResolvedValue({ ok: true }), + prompt: vi.fn().mockResolvedValue({ ok: true, value: 'new value' }) + }, + userDialog: ref({ worlds: [] }), + cachedWorlds: new Map(), + showWorldDialog: vi.fn(), + showFavoriteDialog: vi.fn(), + newInstanceSelfInvite: vi.fn(), + showPreviousInstancesListDialog: vi.fn(), + showFullscreenImageDialog: vi.fn(), + ...overrides + }; +} + +describe('useWorldDialogCommands', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('initial state', () => { + test('provides reactive state refs', () => { + const worldDialog = createWorldDialog(); + const { + worldAllowedDomainsDialog, + isSetWorldTagsDialogVisible, + newInstanceDialogLocationTag, + cropDialogOpen, + cropDialogFile + } = useWorldDialogCommands(worldDialog, createDeps()); + + expect(worldAllowedDomainsDialog.value.visible).toBe(false); + expect(isSetWorldTagsDialogVisible.value).toBe(false); + expect(newInstanceDialogLocationTag.value).toBe(''); + expect(cropDialogOpen.value).toBe(false); + expect(cropDialogFile.value).toBeNull(); + }); + }); + + describe('worldDialogCommand', () => { + test('returns early when dialog is not visible', () => { + const worldDialog = createWorldDialog({ visible: false }); + const deps = createDeps(); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Refresh'); + expect(deps.showWorldDialog).not.toHaveBeenCalled(); + }); + + test('Refresh command calls showWorldDialog with forceRefresh', () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Refresh'); + expect(deps.showWorldDialog).toHaveBeenCalledWith( + worldDialog.value.$location.tag, + worldDialog.value.$location.shortName, + { forceRefresh: true } + ); + }); + + test('Add Favorite command calls showFavoriteDialog', () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Add Favorite'); + expect(deps.showFavoriteDialog).toHaveBeenCalledWith( + 'world', + 'wrld_123' + ); + }); + + test('New Instance and Self Invite calls newInstanceSelfInvite', () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('New Instance and Self Invite'); + expect(deps.newInstanceSelfInvite).toHaveBeenCalledWith('wrld_123'); + }); + + test('Previous Instances calls showPreviousInstancesListDialog', () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Previous Instances'); + expect(deps.showPreviousInstancesListDialog).toHaveBeenCalledWith( + 'world', + worldDialog.value.ref + ); + }); + + test('Change Tags sets isSetWorldTagsDialogVisible to true', () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { worldDialogCommand, isSetWorldTagsDialogVisible } = + useWorldDialogCommands(worldDialog, deps); + + worldDialogCommand('Change Tags'); + expect(isSetWorldTagsDialogVisible.value).toBe(true); + }); + + test('Download Unity Package opens external link', () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Download Unity Package'); + expect(openExternalLink).toHaveBeenCalled(); + }); + + test('Share copies world URL', () => { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + writable: true, + configurable: true + }); + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Share'); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'https://vrchat.com/home/world/wrld_123' + ); + }); + + test('Change Allowed Domains opens the allowed domains dialog', () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { worldDialogCommand, worldAllowedDomainsDialog } = + useWorldDialogCommands(worldDialog, deps); + + worldDialogCommand('Change Allowed Domains'); + expect(worldAllowedDomainsDialog.value.visible).toBe(true); + expect(worldAllowedDomainsDialog.value.worldId).toBe('wrld_123'); + }); + }); + + describe('confirmation commands', () => { + test('Delete Favorite shows confirm then calls API', async () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Delete Favorite'); + await vi.waitFor(() => { + expect(deps.modalStore.confirm).toHaveBeenCalled(); + expect(favoriteRequest.deleteFavorite).toHaveBeenCalledWith({ + objectId: 'wrld_123' + }); + }); + }); + + test('Make Home calls saveCurrentUser with homeLocation', async () => { + userRequest.saveCurrentUser.mockResolvedValue({ ok: true }); + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Make Home'); + await vi.waitFor(() => { + expect(userRequest.saveCurrentUser).toHaveBeenCalledWith({ + homeLocation: 'wrld_123' + }); + }); + }); + + test('Reset Home calls saveCurrentUser with empty homeLocation', async () => { + userRequest.saveCurrentUser.mockResolvedValue({ ok: true }); + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Reset Home'); + await vi.waitFor(() => { + expect(userRequest.saveCurrentUser).toHaveBeenCalledWith({ + homeLocation: '' + }); + }); + }); + + test('Publish calls publishWorld', async () => { + worldRequest.publishWorld.mockResolvedValue({ ok: true }); + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Publish'); + await vi.waitFor(() => { + expect(worldRequest.publishWorld).toHaveBeenCalledWith({ + worldId: 'wrld_123' + }); + }); + }); + + test('Unpublish calls unpublishWorld', async () => { + worldRequest.unpublishWorld.mockResolvedValue({ ok: true }); + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Unpublish'); + await vi.waitFor(() => { + expect(worldRequest.unpublishWorld).toHaveBeenCalledWith({ + worldId: 'wrld_123' + }); + }); + }); + + test('Delete Persistent Data calls deleteWorldPersistData', async () => { + miscRequest.deleteWorldPersistData.mockResolvedValue({ + params: { worldId: 'wrld_123' } + }); + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Delete Persistent Data'); + await vi.waitFor(() => { + expect(miscRequest.deleteWorldPersistData).toHaveBeenCalledWith( + { worldId: 'wrld_123' } + ); + }); + }); + + test('confirmation cancelled does not call API', async () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + deps.modalStore.confirm.mockResolvedValue({ ok: false }); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Make Home'); + await vi.waitFor(() => { + expect(deps.modalStore.confirm).toHaveBeenCalled(); + }); + expect(userRequest.saveCurrentUser).not.toHaveBeenCalled(); + }); + }); + + describe('prompt commands', () => { + test('Rename calls prompt then saveWorld', async () => { + worldRequest.saveWorld.mockResolvedValue({ ok: true }); + const worldDialog = createWorldDialog(); + const deps = createDeps(); + deps.modalStore.prompt.mockResolvedValue({ + ok: true, + value: 'New Name' + }); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Rename'); + await vi.waitFor(() => { + expect(worldRequest.saveWorld).toHaveBeenCalledWith({ + id: 'wrld_123', + name: 'New Name' + }); + }); + }); + + test('Change Description calls prompt then saveWorld', async () => { + worldRequest.saveWorld.mockResolvedValue({ ok: true }); + const worldDialog = createWorldDialog(); + const deps = createDeps(); + deps.modalStore.prompt.mockResolvedValue({ + ok: true, + value: 'New Desc' + }); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Change Description'); + await vi.waitFor(() => { + expect(worldRequest.saveWorld).toHaveBeenCalledWith({ + id: 'wrld_123', + description: 'New Desc' + }); + }); + }); + + test('Change Capacity calls prompt then saveWorld with number', async () => { + worldRequest.saveWorld.mockResolvedValue({ ok: true }); + const worldDialog = createWorldDialog(); + const deps = createDeps(); + deps.modalStore.prompt.mockResolvedValue({ ok: true, value: '30' }); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Change Capacity'); + await vi.waitFor(() => { + expect(worldRequest.saveWorld).toHaveBeenCalledWith({ + id: 'wrld_123', + capacity: 30 + }); + }); + }); + + test('Change Recommended Capacity calls prompt then saveWorld', async () => { + worldRequest.saveWorld.mockResolvedValue({ ok: true }); + const worldDialog = createWorldDialog(); + const deps = createDeps(); + deps.modalStore.prompt.mockResolvedValue({ ok: true, value: '15' }); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Change Recommended Capacity'); + await vi.waitFor(() => { + expect(worldRequest.saveWorld).toHaveBeenCalledWith({ + id: 'wrld_123', + recommendedCapacity: 15 + }); + }); + }); + + test('prompt cancelled does not call saveWorld', async () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + deps.modalStore.prompt.mockResolvedValue({ ok: false }); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Rename'); + await vi.waitFor(() => { + expect(deps.modalStore.prompt).toHaveBeenCalled(); + }); + expect(worldRequest.saveWorld).not.toHaveBeenCalled(); + }); + }); + + describe('promptChangeWorldYouTubePreview', () => { + test('parses YouTube URL with v parameter', async () => { + worldRequest.saveWorld.mockResolvedValue({ ok: true }); + const worldDialog = createWorldDialog(); + const deps = createDeps(); + deps.modalStore.prompt.mockResolvedValue({ + ok: true, + value: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + }); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Change YouTube Preview'); + await vi.waitFor(() => { + expect(worldRequest.saveWorld).toHaveBeenCalledWith({ + id: 'wrld_123', + previewYoutubeId: 'dQw4w9WgXcQ' + }); + }); + }); + + test('uses short id directly', async () => { + worldRequest.saveWorld.mockResolvedValue({ ok: true }); + const worldDialog = createWorldDialog(); + const deps = createDeps(); + deps.modalStore.prompt.mockResolvedValue({ + ok: true, + value: 'dQw4w9WgXcQ' + }); + const { worldDialogCommand } = useWorldDialogCommands( + worldDialog, + deps + ); + + worldDialogCommand('Change YouTube Preview'); + await vi.waitFor(() => { + expect(worldRequest.saveWorld).toHaveBeenCalledWith({ + id: 'wrld_123', + previewYoutubeId: 'dQw4w9WgXcQ' + }); + }); + }); + }); + + describe('clipboard operations', () => { + beforeEach(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + writable: true, + configurable: true + }); + }); + + test('copyWorldUrl writes correct URL', async () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { copyWorldUrl } = useWorldDialogCommands(worldDialog, deps); + + copyWorldUrl(); + await vi.waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'https://vrchat.com/home/world/wrld_123' + ); + }); + }); + + test('copyWorldName writes world name', async () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { copyWorldName } = useWorldDialogCommands(worldDialog, deps); + + copyWorldName(); + await vi.waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'Test World' + ); + }); + }); + }); +}); diff --git a/src/components/dialogs/WorldDialog/__tests__/useWorldDialogInfo.test.js b/src/components/dialogs/WorldDialog/__tests__/useWorldDialogInfo.test.js new file mode 100644 index 00000000..44c8ce7b --- /dev/null +++ b/src/components/dialogs/WorldDialog/__tests__/useWorldDialogInfo.test.js @@ -0,0 +1,388 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { ref } from 'vue'; +import { useWorldDialogInfo } from '../useWorldDialogInfo'; + +vi.mock('../../../../shared/utils', () => ({ + commaNumber: vi.fn((n) => n?.toLocaleString()), + compareUnityVersion: vi.fn(() => true), + formatDateFilter: vi.fn((d) => d), + timeToText: vi.fn((ms) => `${Math.floor(ms / 1000)}s`) +})); + +vi.mock('../../../../service/database', () => ({ + database: { + setWorldMemo: vi.fn(), + deleteWorldMemo: vi.fn() + } +})); + +const { database } = await import('../../../../service/database'); +const { compareUnityVersion } = await import('../../../../shared/utils'); + +/** + * + * @param overrides + */ +function createWorldDialog(overrides = {}) { + return ref({ + id: 'wrld_123', + memo: '', + timeSpent: 60000, + ref: { + name: 'Test World', + description: 'A test world', + publicationDate: '2024-06-01T00:00:00Z', + labsPublicationDate: '2024-05-01T00:00:00Z', + favorites: 100, + visits: 500, + tags: ['author_tag_fun', 'author_tag_social', 'content_horror'], + unityPackages: [ + { + platform: 'standalonewindows', + unityVersion: '2022.3.6f1', + unitySortNumber: 20220306, + variant: 'standard', + created_at: '2024-05-15T00:00:00Z' + }, + { + platform: 'android', + unityVersion: '2022.3.6f1', + unitySortNumber: 20220306, + variant: 'standard', + created_at: '2024-05-20T00:00:00Z' + } + ] + }, + ...overrides + }); +} + +/** + * + * @param overrides + */ +function createDeps(overrides = {}) { + return { + t: vi.fn((key) => key), + toast: { + success: vi.fn(), + error: vi.fn() + }, + ...overrides + }; +} + +describe('useWorldDialogInfo', () => { + beforeEach(() => { + vi.clearAllMocks(); + compareUnityVersion.mockReturnValue(true); + }); + + describe('memo computed', () => { + test('gets memo value from worldDialog', () => { + const worldDialog = createWorldDialog({ memo: 'my memo' }); + const { memo } = useWorldDialogInfo(worldDialog, createDeps()); + + expect(memo.value).toBe('my memo'); + }); + + test('sets memo value on worldDialog', () => { + const worldDialog = createWorldDialog(); + const { memo } = useWorldDialogInfo(worldDialog, createDeps()); + + memo.value = 'new memo'; + expect(worldDialog.value.memo).toBe('new memo'); + }); + }); + + describe('isTimeInLabVisible', () => { + test('returns true when both dates exist and are not "none"', () => { + const worldDialog = createWorldDialog(); + const { isTimeInLabVisible } = useWorldDialogInfo( + worldDialog, + createDeps() + ); + + expect(isTimeInLabVisible.value).toBe(true); + }); + + test('returns false when publicationDate is "none"', () => { + const worldDialog = createWorldDialog(); + worldDialog.value.ref.publicationDate = 'none'; + const { isTimeInLabVisible } = useWorldDialogInfo( + worldDialog, + createDeps() + ); + + expect(isTimeInLabVisible.value).toBe(false); + }); + + test('returns false when labsPublicationDate is falsy', () => { + const worldDialog = createWorldDialog(); + worldDialog.value.ref.labsPublicationDate = ''; + const { isTimeInLabVisible } = useWorldDialogInfo( + worldDialog, + createDeps() + ); + + expect(isTimeInLabVisible.value).toBeFalsy(); + }); + }); + + describe('timeInLab', () => { + test('computes time difference between publication and labs dates', () => { + const worldDialog = createWorldDialog(); + const { timeInLab } = useWorldDialogInfo(worldDialog, createDeps()); + + // Should call timeToText with the ms difference + expect(timeInLab.value).toBeDefined(); + }); + }); + + describe('favoriteRate', () => { + test('calculates favorite rate based on favorites and visits', () => { + const worldDialog = createWorldDialog(); + const { favoriteRate } = useWorldDialogInfo( + worldDialog, + createDeps() + ); + + // ((100 - 500) / 500 * 100 + 100) * 100 / 100 + // = (-80 + 100) = 20 + expect(favoriteRate.value).toBe(20); + }); + }); + + describe('worldTags', () => { + test('filters and formats author tags', () => { + const worldDialog = createWorldDialog(); + const { worldTags } = useWorldDialogInfo(worldDialog, createDeps()); + + expect(worldTags.value).toBe('fun, social'); + }); + + test('returns empty string when no author tags', () => { + const worldDialog = createWorldDialog(); + worldDialog.value.ref.tags = ['content_horror']; + const { worldTags } = useWorldDialogInfo(worldDialog, createDeps()); + + expect(worldTags.value).toBe(''); + }); + }); + + describe('timeSpent', () => { + test('converts milliseconds to text', () => { + const worldDialog = createWorldDialog({ timeSpent: 120000 }); + const { timeSpent } = useWorldDialogInfo(worldDialog, createDeps()); + + expect(timeSpent.value).toBe('120s'); + }); + }); + + describe('worldDialogPlatform', () => { + test('formats platform strings from unity packages', () => { + const worldDialog = createWorldDialog(); + const { worldDialogPlatform } = useWorldDialogInfo( + worldDialog, + createDeps() + ); + + expect(worldDialogPlatform.value).toContain('PC/2022.3.6f1'); + expect(worldDialogPlatform.value).toContain('Android/2022.3.6f1'); + }); + + test('skips packages filtered by compareUnityVersion', () => { + compareUnityVersion.mockReturnValue(false); + const worldDialog = createWorldDialog(); + const { worldDialogPlatform } = useWorldDialogInfo( + worldDialog, + createDeps() + ); + + expect(worldDialogPlatform.value).toBe(''); + }); + + test('uses platform name directly for unknown platforms', () => { + const worldDialog = createWorldDialog(); + worldDialog.value.ref.unityPackages = [ + { + platform: 'ios', + unityVersion: '2022.3.6f1', + unitySortNumber: 20220306 + } + ]; + const { worldDialogPlatform } = useWorldDialogInfo( + worldDialog, + createDeps() + ); + + expect(worldDialogPlatform.value).toBe('ios/2022.3.6f1'); + }); + }); + + describe('worldDialogPlatformCreatedAt', () => { + test('returns newest created_at per platform', () => { + const worldDialog = createWorldDialog(); + const { worldDialogPlatformCreatedAt } = useWorldDialogInfo( + worldDialog, + createDeps() + ); + + expect(worldDialogPlatformCreatedAt.value).toEqual({ + standalonewindows: '2024-05-15T00:00:00Z', + android: '2024-05-20T00:00:00Z' + }); + }); + + test('returns null when no unityPackages', () => { + const worldDialog = createWorldDialog(); + worldDialog.value.ref.unityPackages = undefined; + const { worldDialogPlatformCreatedAt } = useWorldDialogInfo( + worldDialog, + createDeps() + ); + + expect(worldDialogPlatformCreatedAt.value).toBeNull(); + }); + + test('skips non-standard variants', () => { + const worldDialog = createWorldDialog(); + worldDialog.value.ref.unityPackages = [ + { + platform: 'standalonewindows', + variant: 'custom', + created_at: '2024-05-15T00:00:00Z' + } + ]; + const { worldDialogPlatformCreatedAt } = useWorldDialogInfo( + worldDialog, + createDeps() + ); + + expect(worldDialogPlatformCreatedAt.value).toEqual({}); + }); + }); + + describe('onWorldMemoChange', () => { + test('saves memo when it has value', () => { + const worldDialog = createWorldDialog({ memo: 'test memo' }); + const { onWorldMemoChange } = useWorldDialogInfo( + worldDialog, + createDeps() + ); + + onWorldMemoChange(); + + expect(database.setWorldMemo).toHaveBeenCalledWith( + expect.objectContaining({ + worldId: 'wrld_123', + memo: 'test memo' + }) + ); + }); + + test('deletes memo when it is empty', () => { + const worldDialog = createWorldDialog({ memo: '' }); + const { onWorldMemoChange } = useWorldDialogInfo( + worldDialog, + createDeps() + ); + + onWorldMemoChange(); + + expect(database.deleteWorldMemo).toHaveBeenCalledWith('wrld_123'); + }); + }); + + describe('clipboard operations', () => { + let originalClipboard; + + beforeEach(() => { + originalClipboard = navigator.clipboard; + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: vi.fn().mockResolvedValue(undefined) + }, + writable: true, + configurable: true + }); + }); + + test('copyWorldId copies world id', async () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { copyWorldId } = useWorldDialogInfo(worldDialog, deps); + + copyWorldId(); + await vi.waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'wrld_123' + ); + }); + }); + + test('copyWorldUrl copies full url', async () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { copyWorldUrl } = useWorldDialogInfo(worldDialog, deps); + + copyWorldUrl(); + await vi.waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'https://vrchat.com/home/world/wrld_123' + ); + }); + }); + + test('copyWorldName copies world name', async () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { copyWorldName } = useWorldDialogInfo(worldDialog, deps); + + copyWorldName(); + await vi.waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'Test World' + ); + }); + }); + + test('shows toast on clipboard success', async () => { + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { copyWorldId } = useWorldDialogInfo(worldDialog, deps); + + copyWorldId(); + await vi.waitFor(() => { + expect(deps.toast.success).toHaveBeenCalled(); + }); + }); + + test('shows error toast on clipboard failure', async () => { + navigator.clipboard.writeText = vi + .fn() + .mockRejectedValue(new Error('denied')); + const worldDialog = createWorldDialog(); + const deps = createDeps(); + const { copyWorldId } = useWorldDialogInfo(worldDialog, deps); + + copyWorldId(); + await vi.waitFor(() => { + expect(deps.toast.error).toHaveBeenCalled(); + }); + }); + }); + + describe('utility re-exports', () => { + test('re-exports commaNumber and formatDateFilter', () => { + const worldDialog = createWorldDialog(); + const { commaNumber, formatDateFilter } = useWorldDialogInfo( + worldDialog, + createDeps() + ); + + expect(commaNumber).toBeDefined(); + expect(formatDateFilter).toBeDefined(); + }); + }); +}); diff --git a/src/components/dialogs/WorldDialog/useWorldDialogCommands.js b/src/components/dialogs/WorldDialog/useWorldDialogCommands.js new file mode 100644 index 00000000..790f6fcb --- /dev/null +++ b/src/components/dialogs/WorldDialog/useWorldDialogCommands.js @@ -0,0 +1,601 @@ +import { nextTick, ref } from 'vue'; + +import { + handleImageUploadInput, + readFileAsBase64, + resizeImageToFitLimits, + uploadImageLegacy, + withUploadTimeout +} from '../../../shared/utils/imageUpload'; +import { + favoriteRequest, + miscRequest, + userRequest, + worldRequest +} from '../../../api'; +import { openExternalLink, replaceVrcPackageUrl } from '../../../shared/utils'; + +/** + * Composable for WorldDialog commands, prompt functions, and image upload. + * @param {import('vue').Ref} worldDialog - reactive ref to the world dialog state + * @param {object} deps - external dependencies + * @param {Function} deps.t - i18n translation function + * @param {Function} deps.toast - toast notification function + * @param {object} deps.modalStore - modal store for confirm/prompt dialogs + * @param {import('vue').Ref} deps.userDialog - reactive ref to the user dialog state + * @param {Map} deps.cachedWorlds - cached worlds map + * @param {Function} deps.showWorldDialog - function to show world dialog + * @param {Function} deps.showFavoriteDialog - function to show favorite dialog + * @param {Function} deps.newInstanceSelfInvite - function for new instance self invite + * @param {Function} deps.showPreviousInstancesListDialog - function to show previous instances + * @param {Function} deps.showFullscreenImageDialog - function to show fullscreen image + * @returns {object} commands composable API + */ +export function useWorldDialogCommands( + worldDialog, + { + t, + toast, + modalStore, + userDialog, + cachedWorlds, + showWorldDialog, + showFavoriteDialog, + newInstanceSelfInvite, + showPreviousInstancesListDialog: openPreviousInstancesListDialog, + showFullscreenImageDialog + } +) { + const worldAllowedDomainsDialog = ref({ + visible: false, + worldId: '', + urlList: [] + }); + const isSetWorldTagsDialogVisible = ref(false); + const newInstanceDialogLocationTag = ref(''); + const cropDialogOpen = ref(false); + const cropDialogFile = ref(null); + const changeWorldImageLoading = ref(false); + + /** + * + */ + function showChangeWorldImageDialog() { + document.getElementById('WorldImageUploadButton').click(); + } + + /** + * + * @param e + */ + function onFileChangeWorldImage(e) { + const { file, clearInput } = handleImageUploadInput(e, { + inputSelector: '#WorldImageUploadButton', + tooLargeMessage: () => t('message.file.too_large'), + invalidTypeMessage: () => t('message.file.not_image') + }); + if (!file) { + return; + } + if (!worldDialog.value.visible || worldDialog.value.loading) { + clearInput(); + return; + } + clearInput(); + cropDialogFile.value = file; + cropDialogOpen.value = true; + } + + /** + * + * @param blob + */ + async function onCropConfirmWorld(blob) { + changeWorldImageLoading.value = true; + try { + await withUploadTimeout( + (async () => { + const base64Body = await readFileAsBase64(blob); + const base64File = await resizeImageToFitLimits(base64Body); + await uploadImageLegacy('world', { + entityId: worldDialog.value.id, + imageUrl: worldDialog.value.ref.imageUrl, + base64File, + blob + }); + })() + ); + toast.success(t('message.upload.success')); + } catch (error) { + console.error('World image upload process failed:', error); + toast.error(t('message.upload.error')); + } finally { + changeWorldImageLoading.value = false; + cropDialogOpen.value = false; + } + } + + /** + * + * @param tag + */ + function showNewInstanceDialog(tag) { + // trigger watcher + newInstanceDialogLocationTag.value = ''; + nextTick(() => (newInstanceDialogLocationTag.value = tag)); + } + + /** + * + */ + function copyWorldUrl() { + navigator.clipboard + .writeText(`https://vrchat.com/home/world/${worldDialog.value.id}`) + .then(() => { + toast.success(t('message.world.url_copied')); + }) + .catch((err) => { + console.error('copy failed:', err); + toast.error(t('message.copy_failed')); + }); + } + + /** + * + */ + function copyWorldName() { + navigator.clipboard + .writeText(worldDialog.value.ref.name) + .then(() => { + toast.success(t('message.world.name_copied')); + }) + .catch((err) => { + console.error('copy failed:', err); + toast.error(t('message.copy_failed')); + }); + } + + /** + * + */ + function showWorldAllowedDomainsDialog() { + const D = worldAllowedDomainsDialog.value; + D.worldId = worldDialog.value.id; + D.urlList = worldDialog.value.ref?.urlList ?? []; + D.visible = true; + } + + /** + * + * @param worldRef + */ + function showPreviousInstancesListDialog(worldRef) { + openPreviousInstancesListDialog('world', worldRef); + } + + /** + * + * @param world + */ + function promptRenameWorld(world) { + modalStore + .prompt({ + title: t('prompt.rename_world.header'), + description: t('prompt.rename_world.description'), + confirmText: t('prompt.rename_world.ok'), + cancelText: t('prompt.rename_world.cancel'), + inputValue: world.ref.name, + errorMessage: t('prompt.rename_world.input_error') + }) + .then(({ ok, value }) => { + if (!ok) return; + if (value && value !== world.ref.name) { + worldRequest + .saveWorld({ + id: world.id, + name: value + }) + .then((args) => { + toast.success( + t('prompt.rename_world.message.success') + ); + return args; + }); + } + }) + .catch(() => {}); + } + /** + * + * @param world + */ + function promptChangeWorldDescription(world) { + modalStore + .prompt({ + title: t('prompt.change_world_description.header'), + description: t('prompt.change_world_description.description'), + confirmText: t('prompt.change_world_description.ok'), + cancelText: t('prompt.change_world_description.cancel'), + inputValue: world.ref.description, + errorMessage: t('prompt.change_world_description.input_error') + }) + .then(({ ok, value }) => { + if (!ok) return; + if (value && value !== world.ref.description) { + worldRequest + .saveWorld({ + id: world.id, + description: value + }) + .then((args) => { + toast.success( + t( + 'prompt.change_world_description.message.success' + ) + ); + return args; + }); + } + }) + .catch(() => {}); + } + + /** + * + * @param world + */ + function promptChangeWorldCapacity(world) { + modalStore + .prompt({ + title: t('prompt.change_world_capacity.header'), + description: t('prompt.change_world_capacity.description'), + confirmText: t('prompt.change_world_capacity.ok'), + cancelText: t('prompt.change_world_capacity.cancel'), + inputValue: world.ref.capacity, + pattern: /\d+$/, + errorMessage: t('prompt.change_world_capacity.input_error') + }) + .then(({ ok, value }) => { + if (!ok) return; + if (value && value !== world.ref.capacity) { + worldRequest + .saveWorld({ + id: world.id, + capacity: Number(value) + }) + .then((args) => { + toast.success( + t( + 'prompt.change_world_capacity.message.success' + ) + ); + return args; + }); + } + }) + .catch(() => {}); + } + + /** + * + * @param world + */ + function promptChangeWorldRecommendedCapacity(world) { + modalStore + .prompt({ + title: t('prompt.change_world_recommended_capacity.header'), + description: t( + 'prompt.change_world_recommended_capacity.description' + ), + confirmText: t('prompt.change_world_capacity.ok'), + cancelText: t('prompt.change_world_capacity.cancel'), + inputValue: world.ref.recommendedCapacity, + pattern: /\d+$/, + errorMessage: t( + 'prompt.change_world_recommended_capacity.input_error' + ) + }) + .then(({ ok, value }) => { + if (!ok) return; + if (value && value !== world.ref.recommendedCapacity) { + worldRequest + .saveWorld({ + id: world.id, + recommendedCapacity: Number(value) + }) + .then((args) => { + toast.success( + t( + 'prompt.change_world_recommended_capacity.message.success' + ) + ); + return args; + }); + } + }) + .catch(() => {}); + } + + /** + * + * @param world + */ + function promptChangeWorldYouTubePreview(world) { + modalStore + .prompt({ + title: t('prompt.change_world_preview.header'), + description: t('prompt.change_world_preview.description'), + confirmText: t('prompt.change_world_preview.ok'), + cancelText: t('prompt.change_world_preview.cancel'), + inputValue: world.ref.previewYoutubeId, + errorMessage: t('prompt.change_world_preview.input_error') + }) + .then(({ ok, value }) => { + if (!ok) return; + if (value && value !== world.ref.previewYoutubeId) { + let processedValue = value; + if (value.length > 11) { + try { + const url = new URL(value); + const id1 = url.pathname; + const id2 = url.searchParams.get('v'); + if (id1 && id1.length === 12) { + processedValue = id1.substring(1, 12); + } + if (id2 && id2.length === 11) { + processedValue = id2; + } + } catch { + toast.error( + t('prompt.change_world_preview.message.error') + ); + return; + } + } + if (processedValue !== world.ref.previewYoutubeId) { + worldRequest + .saveWorld({ + id: world.id, + previewYoutubeId: processedValue + }) + .then((args) => { + toast.success( + t( + 'prompt.change_world_preview.message.success' + ) + ); + return args; + }); + } + } + }) + .catch(() => {}); + } + + /** + * + * @param command + */ + function worldDialogCommand(command) { + const D = worldDialog.value; + if (D.visible === false) { + return; + } + switch (command) { + case 'Delete Favorite': + case 'Make Home': + case 'Reset Home': + case 'Publish': + case 'Unpublish': + case 'Delete Persistent Data': + case 'Delete': + const commandLabelMap = { + 'Delete Favorite': t( + 'dialog.world.actions.favorites_tooltip' + ), + 'Make Home': t('dialog.world.actions.make_home'), + 'Reset Home': t('dialog.world.actions.reset_home'), + Publish: t('dialog.world.actions.publish_to_labs'), + Unpublish: t('dialog.world.actions.unpublish'), + 'Delete Persistent Data': t( + 'dialog.world.actions.delete_persistent_data' + ), + Delete: t('dialog.world.actions.delete') + }; + modalStore + .confirm({ + description: t('confirm.command_question', { + command: commandLabelMap[command] ?? command + }), + title: t('confirm.title') + }) + .then(({ ok }) => { + if (!ok) return; + switch (command) { + case 'Delete Favorite': + favoriteRequest.deleteFavorite({ + objectId: D.id + }); + break; + case 'Make Home': + userRequest + .saveCurrentUser({ + homeLocation: D.id + }) + .then((args) => { + toast.success( + t('message.world.home_updated') + ); + return args; + }); + break; + case 'Reset Home': + userRequest + .saveCurrentUser({ + homeLocation: '' + }) + .then((args) => { + toast.success( + t('message.world.home_reset') + ); + return args; + }); + break; + case 'Publish': + worldRequest + .publishWorld({ + worldId: D.id + }) + .then((args) => { + toast.success( + t('message.world.published') + ); + return args; + }); + break; + case 'Unpublish': + worldRequest + .unpublishWorld({ + worldId: D.id + }) + .then((args) => { + toast.success( + t('message.world.unpublished') + ); + return args; + }); + break; + case 'Delete Persistent Data': + miscRequest + .deleteWorldPersistData({ + worldId: D.id + }) + .then((args) => { + if ( + args.params.worldId === + worldDialog.value.id && + worldDialog.value.visible + ) { + worldDialog.value.hasPersistData = false; + } + toast.success( + t( + 'message.world.persistent_data_deleted' + ) + ); + return args; + }); + break; + case 'Delete': + worldRequest + .deleteWorld({ + worldId: D.id + }) + .then((args) => { + const { json } = args; + cachedWorlds.delete(json.id); + if ( + worldDialog.value.ref.authorId === + json.authorId + ) { + const map = new Map(); + for (const ref of cachedWorlds.values()) { + if ( + ref.authorId === + json.authorId + ) { + map.set(ref.id, ref); + } + } + const array = Array.from( + map.values() + ); + userDialog.value.worlds = array; + } + toast.success( + t('message.world.deleted') + ); + D.visible = false; + return args; + }); + break; + } + }) + .catch(() => {}); + break; + case 'Previous Instances': + showPreviousInstancesListDialog(D.ref); + break; + case 'Share': + copyWorldUrl(); + break; + case 'Change Allowed Domains': + showWorldAllowedDomainsDialog(); + break; + case 'Change Tags': + isSetWorldTagsDialogVisible.value = true; + break; + case 'Download Unity Package': + openExternalLink( + replaceVrcPackageUrl(worldDialog.value.ref.unityPackageUrl) + ); + break; + case 'Change Image': + showChangeWorldImageDialog(); + break; + case 'Refresh': + const { tag, shortName } = worldDialog.value.$location; + showWorldDialog(tag, shortName, { forceRefresh: true }); + break; + case 'New Instance': + showNewInstanceDialog(D.$location.tag); + break; + case 'Add Favorite': + showFavoriteDialog('world', D.id); + break; + case 'New Instance and Self Invite': + newInstanceSelfInvite(D.id); + break; + case 'Rename': + promptRenameWorld(D); + break; + case 'Change Description': + promptChangeWorldDescription(D); + break; + case 'Change Capacity': + promptChangeWorldCapacity(D); + break; + case 'Change Recommended Capacity': + promptChangeWorldRecommendedCapacity(D); + break; + case 'Change YouTube Preview': + promptChangeWorldYouTubePreview(D); + break; + default: + break; + } + } + + return { + worldAllowedDomainsDialog, + isSetWorldTagsDialogVisible, + newInstanceDialogLocationTag, + cropDialogOpen, + cropDialogFile, + changeWorldImageLoading, + worldDialogCommand, + onFileChangeWorldImage, + onCropConfirmWorld, + showNewInstanceDialog, + copyWorldUrl, + copyWorldName, + showWorldAllowedDomainsDialog, + showPreviousInstancesListDialog, + showFullscreenImageDialog, + promptRenameWorld, + promptChangeWorldDescription, + promptChangeWorldCapacity, + promptChangeWorldRecommendedCapacity, + promptChangeWorldYouTubePreview + }; +} diff --git a/src/components/dialogs/WorldDialog/useWorldDialogInfo.js b/src/components/dialogs/WorldDialog/useWorldDialogInfo.js new file mode 100644 index 00000000..bd27e9f8 --- /dev/null +++ b/src/components/dialogs/WorldDialog/useWorldDialogInfo.js @@ -0,0 +1,195 @@ +import { computed } from 'vue'; +import { + commaNumber, + compareUnityVersion, + formatDateFilter, + timeToText +} from '../../../shared/utils'; +import { database } from '../../../service/database'; + +/** + * Composable for WorldDialogInfoTab computed properties and actions. + * + * @param {import('vue').Ref} worldDialog - reactive ref to the world dialog state + * @param {Object} deps - external dependencies + * @param {Function} deps.t - i18n translation function + * @param {Function} deps.toast - toast notification function + * @returns {Object} info composable API + */ +export function useWorldDialogInfo(worldDialog, { t, toast }) { + const memo = computed({ + get() { + return worldDialog.value.memo; + }, + set(value) { + worldDialog.value.memo = value; + } + }); + + const isTimeInLabVisible = computed(() => { + return ( + worldDialog.value.ref.publicationDate && + worldDialog.value.ref.publicationDate !== 'none' && + worldDialog.value.ref.labsPublicationDate && + worldDialog.value.ref.labsPublicationDate !== 'none' + ); + }); + + const timeInLab = computed(() => { + return timeToText( + new Date(worldDialog.value.ref.publicationDate).getTime() - + new Date(worldDialog.value.ref.labsPublicationDate).getTime() + ); + }); + + const favoriteRate = computed(() => { + return ( + Math.round( + (((worldDialog.value.ref?.favorites - + worldDialog.value.ref?.visits) / + worldDialog.value.ref?.visits) * + 100 + + 100) * + 100 + ) / 100 + ); + }); + + const worldTags = computed(() => { + return worldDialog.value.ref?.tags + .filter((tag) => tag.startsWith('author_tag')) + .map((tag) => tag.replace('author_tag_', '')) + .join(', '); + }); + + const timeSpent = computed(() => { + return timeToText(worldDialog.value.timeSpent); + }); + + const worldDialogPlatform = computed(() => { + const { ref } = worldDialog.value; + const platforms = []; + if (ref.unityPackages) { + for (const unityPackage of ref.unityPackages) { + if (!compareUnityVersion(unityPackage.unitySortNumber)) { + continue; + } + let platform = 'PC'; + if (unityPackage.platform === 'standalonewindows') { + platform = 'PC'; + } else if (unityPackage.platform === 'android') { + platform = 'Android'; + } else if (unityPackage.platform) { + platform = unityPackage.platform; + } + platforms.unshift(`${platform}/${unityPackage.unityVersion}`); + } + } + return platforms.join(', '); + }); + + const worldDialogPlatformCreatedAt = computed(() => { + const { ref } = worldDialog.value; + if (!ref.unityPackages) { + return null; + } + let newest = {}; + for (const unityPackage of ref.unityPackages) { + if ( + unityPackage.variant && + unityPackage.variant !== 'standard' && + unityPackage.variant !== 'security' + ) { + continue; + } + const platform = unityPackage.platform; + const createdAt = unityPackage.created_at; + if ( + !newest[platform] || + new Date(createdAt) > new Date(newest[platform]) + ) { + newest[platform] = createdAt; + } + } + return newest; + }); + + /** + * + */ + function onWorldMemoChange() { + const worldId = worldDialog.value.id; + const memo = worldDialog.value.memo; + if (memo) { + database.setWorldMemo({ + worldId, + editedAt: new Date().toJSON(), + memo + }); + } else { + database.deleteWorldMemo(worldId); + } + } + + /** + * + */ + function copyWorldId() { + navigator.clipboard + .writeText(worldDialog.value.id) + .then(() => { + toast.success(t('message.world.id_copied')); + }) + .catch((err) => { + console.error('copy failed:', err); + toast.error(t('message.copy_failed')); + }); + } + + /** + * + */ + function copyWorldUrl() { + navigator.clipboard + .writeText(`https://vrchat.com/home/world/${worldDialog.value.id}`) + .then(() => { + toast.success(t('message.world.url_copied')); + }) + .catch((err) => { + console.error('copy failed:', err); + toast.error(t('message.copy_failed')); + }); + } + + /** + * + */ + function copyWorldName() { + navigator.clipboard + .writeText(worldDialog.value.ref.name) + .then(() => { + toast.success(t('message.world.name_copied')); + }) + .catch((err) => { + console.error('copy failed:', err); + toast.error(t('message.copy_failed')); + }); + } + + return { + memo, + isTimeInLabVisible, + timeInLab, + favoriteRate, + worldTags, + timeSpent, + worldDialogPlatform, + worldDialogPlatformCreatedAt, + onWorldMemoChange, + copyWorldId, + copyWorldUrl, + copyWorldName, + commaNumber, + formatDateFilter + }; +} diff --git a/src/stores/__tests__/vrcStatus.test.js b/src/stores/__tests__/vrcStatus.test.js new file mode 100644 index 00000000..726b3365 --- /dev/null +++ b/src/stores/__tests__/vrcStatus.test.js @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; + +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + formatDateFilter: vi.fn(() => 'formatted-time'), + openExternalLink: vi.fn(), + toast: { + warning: vi.fn(), + success: vi.fn(), + dismiss: vi.fn() + } +})); + +vi.mock('../../service/webapi', () => ({ + default: { + execute: (...args) => mocks.execute(...args) + } +})); + +vi.mock('../../shared/utils', () => ({ + formatDateFilter: (...args) => mocks.formatDateFilter(...args), + openExternalLink: (...args) => mocks.openExternalLink(...args) +})); + +vi.mock('vue-sonner', () => ({ + toast: mocks.toast +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key) => key + }) +})); + +vi.mock('worker-timers', () => ({ + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout: vi.fn(), + clearTimeout: vi.fn() +})); + +function flushPromises() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +import { useVrcStatusStore } from '../vrcStatus'; + +describe('useVrcStatusStore.getVrcStatus', () => { + beforeEach(async () => { + mocks.execute.mockResolvedValue({ + status: 200, + data: JSON.stringify({ + page: { updated_at: '2026-01-01T00:00:00.000Z' }, + status: { description: 'All Systems Operational' } + }) + }); + + setActivePinia(createPinia()); + useVrcStatusStore(); + await flushPromises(); + vi.clearAllMocks(); + }); + + test('sets failed status when API returns non-200', async () => { + const store = useVrcStatusStore(); + mocks.execute.mockResolvedValueOnce({ + status: 503, + data: 'service unavailable' + }); + + await store.getVrcStatus(); + + expect(mocks.execute).toHaveBeenCalledWith({ + url: 'https://status.vrchat.com/api/v2/status.json', + method: 'GET', + headers: { Referer: 'https://vrcx.app' } + }); + expect(store.lastStatus).toBe('Failed to fetch VRC status'); + expect(mocks.toast.warning).toHaveBeenCalledTimes(1); + }); + + test('fetches summary for incident status and appends component summary', async () => { + const store = useVrcStatusStore(); + mocks.execute + .mockResolvedValueOnce({ + status: 200, + data: JSON.stringify({ + page: { updated_at: '2026-01-02T00:00:00.000Z' }, + status: { description: 'Partial System Outage' } + }) + }) + .mockResolvedValueOnce({ + status: 200, + data: JSON.stringify({ + components: [ + { name: 'API', status: 'major_outage' }, + { name: 'Website', status: 'operational' }, + { name: 'CDN', status: 'partial_outage' } + ] + }) + }); + + await store.getVrcStatus(); + await flushPromises(); + + expect(mocks.execute).toHaveBeenCalledTimes(2); + expect(mocks.execute.mock.calls[1][0].url).toBe( + 'https://status.vrchat.com/api/v2/summary.json' + ); + expect(store.lastStatus).toBe('Partial System Outage'); + expect(store.statusText).toBe('Partial System Outage: API, CDN'); + expect(mocks.toast.warning).toHaveBeenCalled(); + }); + + test('clears status when all systems are operational', async () => { + const store = useVrcStatusStore(); + mocks.execute.mockResolvedValueOnce({ + status: 200, + data: JSON.stringify({ + page: { updated_at: '2026-01-03T00:00:00.000Z' }, + status: { description: 'All Systems Operational' } + }) + }); + + await store.getVrcStatus(); + + expect(mocks.execute).toHaveBeenCalledTimes(1); + expect(store.lastStatus).toBe(''); + expect(store.statusText).toBe(''); + }); +}); diff --git a/src/stores/__tests__/vrcxUpdater.test.js b/src/stores/__tests__/vrcxUpdater.test.js new file mode 100644 index 00000000..1e0e13a7 --- /dev/null +++ b/src/stores/__tests__/vrcxUpdater.test.js @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; + +const mocks = vi.hoisted(() => ({ + configRepository: { + getString: vi.fn(), + setString: vi.fn() + }, + changeLogRemoveLinks: vi.fn((value) => value), + toast: { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn() + } +})); + +vi.mock('../../service/config', () => ({ + default: mocks.configRepository +})); + +vi.mock('../../shared/utils', () => ({ + changeLogRemoveLinks: (...args) => mocks.changeLogRemoveLinks(...args) +})); + +vi.mock('vue-sonner', () => ({ + toast: mocks.toast +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key) => key + }) +})); + +function flushPromises() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +import { useVRCXUpdaterStore } from '../vrcxUpdater'; + +describe('useVRCXUpdaterStore.setAutoUpdateVRCX', () => { + beforeEach(async () => { + mocks.configRepository.getString.mockImplementation( + (key, defaultValue) => { + if (key === 'VRCX_autoUpdateVRCX') { + return Promise.resolve('Off'); + } + if (key === 'VRCX_id') { + return Promise.resolve('test-vrcx-id'); + } + if (key === 'VRCX_lastVRCXVersion') { + return Promise.resolve('2026.1.0'); + } + return Promise.resolve(defaultValue ?? ''); + } + ); + mocks.configRepository.setString.mockResolvedValue(undefined); + + globalThis.AppApi = { + GetVersion: vi.fn().mockResolvedValue('2026.1.0') + }; + + setActivePinia(createPinia()); + useVRCXUpdaterStore(); + await flushPromises(); + vi.clearAllMocks(); + }); + + test('sets autoUpdateVRCX to Off, clears pending flag, and persists config', async () => { + const store = useVRCXUpdaterStore(); + store.pendingVRCXUpdate = true; + + await store.setAutoUpdateVRCX('Off'); + + expect(store.autoUpdateVRCX).toBe('Off'); + expect(store.pendingVRCXUpdate).toBe(false); + expect(mocks.configRepository.setString).toHaveBeenCalledWith( + 'VRCX_autoUpdateVRCX', + 'Off' + ); + }); + + test('updates autoUpdateVRCX for non-Off values and keeps pending flag', async () => { + const store = useVRCXUpdaterStore(); + store.pendingVRCXUpdate = true; + + await store.setAutoUpdateVRCX('Notify'); + + expect(store.autoUpdateVRCX).toBe('Notify'); + expect(store.pendingVRCXUpdate).toBe(true); + expect(mocks.configRepository.setString).toHaveBeenCalledWith( + 'VRCX_autoUpdateVRCX', + 'Notify' + ); + }); +}); diff --git a/src/stores/group.js b/src/stores/group.js index 9540cad1..4555ccd7 100644 --- a/src/stores/group.js +++ b/src/stores/group.js @@ -168,14 +168,11 @@ export const useGroupStore = defineStore('Group', () => { D.members = []; D.memberFilter = groupDialogFilterOptions.everyone; D.calendar = []; - const loadGroupRequest = forceRefresh - ? groupRequest.getGroup({ - groupId, - includeRoles: false - }) - : groupRequest.getCachedGroup({ - groupId - }); + const loadGroupRequest = groupRequest.getGroup({ + groupId, + includeRoles: false + }); + loadGroupRequest .catch((err) => { D.loading = false; @@ -962,6 +959,9 @@ export const useGroupStore = defineStore('Group', () => { } } + /** + * + */ function clearGroupInstances() { groupInstances.value = []; }