mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-06 06:46:04 +02:00
refactor
This commit is contained in:
@@ -570,17 +570,6 @@
|
|||||||
|
|
||||||
import VueJsonPretty from 'vue-json-pretty';
|
import VueJsonPretty from 'vue-json-pretty';
|
||||||
|
|
||||||
import {
|
|
||||||
commaNumber,
|
|
||||||
compareUnityVersion,
|
|
||||||
copyToClipboard,
|
|
||||||
downloadAndSaveJson,
|
|
||||||
formatDateFilter,
|
|
||||||
openExternalLink,
|
|
||||||
openFolderGeneric,
|
|
||||||
replaceVrcPackageUrl,
|
|
||||||
timeToText
|
|
||||||
} from '../../../shared/utils';
|
|
||||||
import {
|
import {
|
||||||
useAppearanceSettingsStore,
|
useAppearanceSettingsStore,
|
||||||
useAvatarStore,
|
useAvatarStore,
|
||||||
@@ -591,6 +580,15 @@
|
|||||||
useUiStore,
|
useUiStore,
|
||||||
useUserStore
|
useUserStore
|
||||||
} from '../../../stores';
|
} from '../../../stores';
|
||||||
|
import {
|
||||||
|
commaNumber,
|
||||||
|
compareUnityVersion,
|
||||||
|
copyToClipboard,
|
||||||
|
downloadAndSaveJson,
|
||||||
|
formatDateFilter,
|
||||||
|
openFolderGeneric,
|
||||||
|
timeToText
|
||||||
|
} from '../../../shared/utils';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -598,17 +596,12 @@
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '../../ui/dropdown-menu';
|
} 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 { Badge } from '../../ui/badge';
|
||||||
|
import { avatarRequest } from '../../../api';
|
||||||
import { database } from '../../../service/database';
|
import { database } from '../../../service/database';
|
||||||
import { formatJsonVars } from '../../../shared/utils/base/ui';
|
import { formatJsonVars } from '../../../shared/utils/base/ui';
|
||||||
|
import { handleImageUploadInput } from '../../../shared/utils/imageUpload';
|
||||||
|
import { useAvatarDialogCommands } from './useAvatarDialogCommands';
|
||||||
|
|
||||||
import DialogJsonTab from '../DialogJsonTab.vue';
|
import DialogJsonTab from '../DialogJsonTab.vue';
|
||||||
import ImageCropDialog from '../ImageCropDialog.vue';
|
import ImageCropDialog from '../ImageCropDialog.vue';
|
||||||
@@ -632,15 +625,36 @@
|
|||||||
const uiStore = useUiStore();
|
const uiStore = useUiStore();
|
||||||
|
|
||||||
const { t } = useI18n();
|
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(() => [
|
const avatarDialogTabs = computed(() => [
|
||||||
{ value: 'Info', label: t('dialog.avatar.info.header') },
|
{ value: 'Info', label: t('dialog.avatar.info.header') },
|
||||||
{ value: 'JSON', label: t('dialog.avatar.json.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 treeData = ref({});
|
||||||
const memo = ref('');
|
const memo = ref('');
|
||||||
const setAvatarTagsDialog = ref({
|
const setAvatarTagsDialog = ref({
|
||||||
@@ -813,333 +827,11 @@
|
|||||||
*
|
*
|
||||||
* @param command
|
* @param command
|
||||||
*/
|
*/
|
||||||
function avatarDialogCommand(command) {
|
// Register component callbacks for the command composable
|
||||||
const D = avatarDialog.value;
|
registerCallbacks({
|
||||||
switch (command) {
|
showSetAvatarTagsDialog: () => showSetAvatarTagsDialog(avatarDialog.value.id),
|
||||||
case 'Refresh':
|
showSetAvatarStylesDialog
|
||||||
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(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -1164,14 +856,6 @@
|
|||||||
copyToClipboard(id);
|
copyToClipboard(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param id
|
|
||||||
*/
|
|
||||||
function copyAvatarUrl(id) {
|
|
||||||
copyToClipboard(`https://vrchat.com/home/avatar/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
v-if="!groupDialog.loading"
|
||||||
|
:src="groupDialog.ref.bannerUrl"
|
||||||
|
class="cursor-pointer"
|
||||||
|
style="flex: none; width: 100%; aspect-ratio: 6/1; object-fit: cover; border-radius: var(--radius-md)"
|
||||||
|
@click="showFullscreenImageDialog(groupDialog.ref.bannerUrl)"
|
||||||
|
loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-start px-2.5" style="max-height: none">
|
||||||
|
<span v-if="groupDialog.instances.length" style="font-size: 12px; font-weight: bold; margin: 6px">
|
||||||
|
{{ t('dialog.group.info.instances') }}
|
||||||
|
</span>
|
||||||
|
<div v-for="room in groupDialog.instances" :key="room.tag" style="width: 100%">
|
||||||
|
<div style="margin: 6px 0" class="flex items-center">
|
||||||
|
<Location :location="room.tag" class="text-sm" />
|
||||||
|
<InstanceActionBar
|
||||||
|
class="ml-1"
|
||||||
|
:location="room.tag"
|
||||||
|
:currentlocation="lastLocation.location"
|
||||||
|
:instance="room.ref"
|
||||||
|
:friendcount="room.friendCount"
|
||||||
|
refresh-tooltip="Refresh player count"
|
||||||
|
:on-refresh="() => refreshInstancePlayerCount(room.tag)" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="room.users.length"
|
||||||
|
class="flex flex-wrap items-start"
|
||||||
|
style="margin: 8px 0; padding: 0; max-height: unset">
|
||||||
|
<div
|
||||||
|
v-for="user in room.users"
|
||||||
|
:key="user.id"
|
||||||
|
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||||
|
@click="showUserDialog(user.id)">
|
||||||
|
<div class="relative inline-block flex-none size-9 mr-2.5" :class="userStatusClass(user)">
|
||||||
|
<img class="size-full rounded-full object-cover" :src="userImage(user)" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span
|
||||||
|
class="block truncate font-medium leading-[18px]"
|
||||||
|
:style="{ color: user.$userColour }"
|
||||||
|
v-text="user.displayName" />
|
||||||
|
<span v-if="user.location === 'traveling'" class="block truncate text-xs">
|
||||||
|
<Spinner class="inline-block mr-1" />
|
||||||
|
<Timer :epoch="user.$travelingToTime" />
|
||||||
|
</span>
|
||||||
|
<span v-else class="block truncate text-xs">
|
||||||
|
<Timer :epoch="user.$location_at" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.announcement') }}</span>
|
||||||
|
<span style="display: block" v-text="groupDialog.announcement.title" />
|
||||||
|
<div v-if="groupDialog.announcement.imageUrl" style="display: inline-block; margin-right: 6px">
|
||||||
|
<img
|
||||||
|
:src="groupDialog.announcement.imageUrl"
|
||||||
|
class="cursor-pointer"
|
||||||
|
style="
|
||||||
|
flex: none;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
object-fit: cover;
|
||||||
|
"
|
||||||
|
@click="showFullscreenImageDialog(groupDialog.announcement.imageUrl)"
|
||||||
|
loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
class="text-xs"
|
||||||
|
style="
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 0;
|
||||||
|
"
|
||||||
|
>{{ groupDialog.announcement.text || '-' }}</pre
|
||||||
|
>
|
||||||
|
<br />
|
||||||
|
<div v-if="groupDialog.announcement.id" class="text-xs" style="float: right; margin-left: 6px">
|
||||||
|
<TooltipWrapper v-if="groupDialog.announcement.roleIds.length" side="top">
|
||||||
|
<template #content>
|
||||||
|
<span>{{ t('dialog.group.posts.visibility') }}</span>
|
||||||
|
<br />
|
||||||
|
<template v-for="roleId in groupDialog.announcement.roleIds" :key="roleId">
|
||||||
|
<template v-for="role in groupDialog.ref.roles" :key="roleId + role.id"
|
||||||
|
><span v-if="role.id === roleId" v-text="role.name"
|
||||||
|
/></template>
|
||||||
|
<span
|
||||||
|
v-if="
|
||||||
|
groupDialog.announcement.roleIds.indexOf(roleId) <
|
||||||
|
groupDialog.announcement.roleIds.length - 1
|
||||||
|
">
|
||||||
|
,
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<Eye style="margin-right: 6px" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
<DisplayName :userid="groupDialog.announcement.authorId" style="margin-right: 6px" />
|
||||||
|
<span v-if="groupDialog.announcement.editorId" style="margin-right: 6px">
|
||||||
|
({{ t('dialog.group.posts.edited_by') }}
|
||||||
|
<DisplayName :userid="groupDialog.announcement.editorId" />)
|
||||||
|
</span>
|
||||||
|
<TooltipWrapper side="bottom">
|
||||||
|
<template #content>
|
||||||
|
<span
|
||||||
|
>{{ t('dialog.group.posts.created_at') }}
|
||||||
|
{{ formatDateFilter(groupDialog.announcement.createdAt, 'long') }}</span
|
||||||
|
>
|
||||||
|
<template v-if="groupDialog.announcement.updatedAt !== groupDialog.announcement.createdAt">
|
||||||
|
<br />
|
||||||
|
<span
|
||||||
|
>{{ t('dialog.group.posts.edited_at') }}
|
||||||
|
{{ formatDateFilter(groupDialog.announcement.updatedAt, 'long') }}</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<Timer :epoch="Date.parse(groupDialog.announcement.updatedAt)" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
<template v-if="hasGroupPermission(groupDialog.ref, 'group-announcement-manage')">
|
||||||
|
<TooltipWrapper side="top" :content="t('dialog.group.posts.edit_tooltip')">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
style="margin-left: 6px; padding: 0"
|
||||||
|
@click="showGroupPostEditDialog(groupDialog.id, groupDialog.announcement)"></Button>
|
||||||
|
</TooltipWrapper>
|
||||||
|
<TooltipWrapper side="top" :content="t('dialog.group.posts.delete_tooltip')">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
style="margin-left: 6px; padding: 0"
|
||||||
|
@click="confirmDeleteGroupPost(groupDialog.announcement)"></Button>
|
||||||
|
</TooltipWrapper>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.rules') }}</span>
|
||||||
|
<pre
|
||||||
|
class="text-xs"
|
||||||
|
style="font-family: inherit; font-size: 12px; white-space: pre-wrap; margin: 0 0.5em 0 0"
|
||||||
|
>{{ groupDialog.ref.rules || '-' }}</pre
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
|
||||||
|
<div class="flex-1" style="overflow: visible">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">{{
|
||||||
|
t('dialog.group.info.upcoming_events')
|
||||||
|
}}</span>
|
||||||
|
<template v-if="upcomingCalenderEvents.length > 0">
|
||||||
|
<br />
|
||||||
|
<div class="grid-view flex flex-wrap gap-4 overflow-y-auto max-h-[360px] py-2.5">
|
||||||
|
<GroupCalendarEventCard
|
||||||
|
v-for="value in upcomingCalenderEvents"
|
||||||
|
:key="value.id"
|
||||||
|
:event="value"
|
||||||
|
:is-following="value.userInterest?.isFollowing"
|
||||||
|
@update-following-calendar-data="updateFollowingCalendarData"
|
||||||
|
mode="grid"
|
||||||
|
card-class="group-dialog-grid-card" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="block truncate text-xs">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
|
||||||
|
<div class="flex-1" style="overflow: visible">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.past_events') }}</span>
|
||||||
|
<template v-if="pastCalenderEvents.length > 0">
|
||||||
|
<br />
|
||||||
|
<div class="grid-view flex flex-wrap gap-4 overflow-y-auto max-h-[360px] py-2.5">
|
||||||
|
<GroupCalendarEventCard
|
||||||
|
v-for="value in pastCalenderEvents"
|
||||||
|
:key="value.id"
|
||||||
|
:event="value"
|
||||||
|
:is-following="value.userInterest?.isFollowing"
|
||||||
|
@update-following-calendar-data="updateFollowingCalendarData"
|
||||||
|
mode="grid"
|
||||||
|
card-class="group-dialog-grid-card" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="block truncate text-xs">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.members') }}</span>
|
||||||
|
<div class="block truncate text-xs">
|
||||||
|
{{ groupDialog.ref.memberCount }} ({{ groupDialog.ref.onlineMemberCount }})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.created_at') }}</span>
|
||||||
|
<span class="block truncate text-xs">{{ formatDateFilter(groupDialog.ref.createdAt, 'long') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
|
||||||
|
@click="showPreviousInstancesListDialog(groupDialog.ref)">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="block truncate font-medium leading-[18px]"
|
||||||
|
style="display: flex; justify-content: space-between; align-items: center">
|
||||||
|
<span>
|
||||||
|
{{ t('dialog.group.info.last_visited') }}
|
||||||
|
</span>
|
||||||
|
<TooltipWrapper side="top" :content="t('dialog.user.info.open_previous_instance')">
|
||||||
|
<MoreHorizontal style="margin-right: 16px" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
</div>
|
||||||
|
<span class="block truncate text-xs">{{ formatDateFilter(groupDialog.lastVisit, 'long') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.links') }}</span>
|
||||||
|
<div
|
||||||
|
v-if="groupDialog.ref.links && groupDialog.ref.links.length > 0"
|
||||||
|
style="margin-top: 6px"
|
||||||
|
class="flex">
|
||||||
|
<template v-for="(link, index) in groupDialog.ref.links" :key="index">
|
||||||
|
<TooltipWrapper v-if="link">
|
||||||
|
<template #content>
|
||||||
|
<span v-text="link" />
|
||||||
|
</template>
|
||||||
|
<img
|
||||||
|
:src="getFaviconUrl(link)"
|
||||||
|
style="
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
"
|
||||||
|
@click.stop="openExternalLink(link)"
|
||||||
|
loading="lazy" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-else class="block truncate text-xs">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex justify-between w-full">
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-1/2">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.url') }}</span>
|
||||||
|
<span class="block truncate text-xs"
|
||||||
|
>{{ groupDialog.ref.$url }}
|
||||||
|
<TooltipWrapper side="top" :content="t('dialog.group.info.url_tooltip')">
|
||||||
|
<Button
|
||||||
|
class="rounded-full ml-1 text-xs"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
@click="copyToClipboard(groupDialog.ref.$url)"
|
||||||
|
><Copy class="h-4 w-4" />
|
||||||
|
</Button> </TooltipWrapper
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-1/2">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.id') }}</span>
|
||||||
|
<span class="block truncate text-xs"
|
||||||
|
>{{ groupDialog.id }}
|
||||||
|
<TooltipWrapper side="top" :content="t('dialog.group.info.id_tooltip')">
|
||||||
|
<Button
|
||||||
|
class="rounded-full ml-1 text-xs"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
@click="copyToClipboard(groupDialog.id)"
|
||||||
|
><Copy class="h-4 w-4" />
|
||||||
|
</Button> </TooltipWrapper
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="groupDialog.ref.membershipStatus === 'member'"
|
||||||
|
class="border-t border-border"
|
||||||
|
style="width: 100%; margin-top: 8px">
|
||||||
|
<div style="width: 100%; display: flex; margin-top: 8px">
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">{{
|
||||||
|
t('dialog.group.info.joined_at')
|
||||||
|
}}</span>
|
||||||
|
<span class="block truncate text-xs">{{
|
||||||
|
formatDateFilter(groupDialog.ref.myMember.joinedAt, 'long')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">{{
|
||||||
|
t('dialog.group.info.roles')
|
||||||
|
}}</span>
|
||||||
|
<span v-if="groupDialog.memberRoles.length === 0" class="block truncate text-xs"> - </span>
|
||||||
|
<span v-else class="block truncate text-xs">
|
||||||
|
<template v-for="(role, rIndex) in groupDialog.memberRoles" :key="rIndex">
|
||||||
|
<TooltipWrapper side="top">
|
||||||
|
<template #content>
|
||||||
|
<span>{{ t('dialog.group.info.role') }} {{ role.name }}</span>
|
||||||
|
<br />
|
||||||
|
<span
|
||||||
|
>{{ t('dialog.group.info.role_description') }} {{ role.description }}</span
|
||||||
|
>
|
||||||
|
<br />
|
||||||
|
<span v-if="role.updatedAt"
|
||||||
|
>{{ t('dialog.group.info.role_updated_at') }}
|
||||||
|
{{ formatDateFilter(role.updatedAt, 'long') }}</span
|
||||||
|
>
|
||||||
|
<span v-else
|
||||||
|
>{{ t('dialog.group.info.role_created_at') }}
|
||||||
|
{{ formatDateFilter(role.createdAt, 'long') }}</span
|
||||||
|
>
|
||||||
|
<br />
|
||||||
|
<span>{{ t('dialog.group.info.role_permissions') }}</span>
|
||||||
|
<br />
|
||||||
|
<template v-for="(permission, pIndex) in role.permissions" :key="pIndex">
|
||||||
|
<span>{{ permission }}</span>
|
||||||
|
<br />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
>{{ role.name
|
||||||
|
}}{{ rIndex < groupDialog.memberRoles.length - 1 ? ', ' : '' }}</span
|
||||||
|
>
|
||||||
|
</TooltipWrapper>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Copy, Eye, MoreHorizontal } from 'lucide-vue-next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import {
|
||||||
|
copyToClipboard,
|
||||||
|
formatDateFilter,
|
||||||
|
getFaviconUrl,
|
||||||
|
hasGroupPermission,
|
||||||
|
openExternalLink,
|
||||||
|
refreshInstancePlayerCount,
|
||||||
|
userImage,
|
||||||
|
userStatusClass
|
||||||
|
} from '../../../shared/utils';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
showGroupPostEditDialog: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
confirmDeleteGroupPost: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const { showUserDialog } = useUserStore();
|
||||||
|
const { groupDialog } = storeToRefs(useGroupStore());
|
||||||
|
const { lastLocation } = storeToRefs(useLocationStore());
|
||||||
|
const { showFullscreenImageDialog } = useGalleryStore();
|
||||||
|
const instanceStore = useInstanceStore();
|
||||||
|
|
||||||
|
const { pastCalenderEvents, upcomingCalenderEvents, updateFollowingCalendarData } =
|
||||||
|
useGroupCalendarEvents(groupDialog);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param groupRef
|
||||||
|
*/
|
||||||
|
function showPreviousInstancesListDialog(groupRef) {
|
||||||
|
instanceStore.showPreviousInstancesListDialog('group', groupRef);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<template>
|
||||||
|
<template v-if="groupDialog.visible">
|
||||||
|
<span
|
||||||
|
v-if="hasGroupPermission(groupDialog.ref, 'group-members-viewall')"
|
||||||
|
style="font-weight: bold; font-size: 16px"
|
||||||
|
>{{ t('dialog.group.members.all_members') }}</span
|
||||||
|
>
|
||||||
|
<span v-else style="font-weight: bold; font-size: 16px">{{ t('dialog.group.members.friends_only') }}</span>
|
||||||
|
<div style="margin-top: 8px">
|
||||||
|
<Button
|
||||||
|
class="rounded-full h-6 w-6"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
:loading="isGroupMembersLoading"
|
||||||
|
circle
|
||||||
|
@click="loadAllGroupMembers">
|
||||||
|
<Spinner v-if="isGroupMembersLoading" /><RefreshCcw v-else
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
class="rounded-full h-6 w-6 ml-2"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
style="margin-left: 6px"
|
||||||
|
@click="downloadAndSaveJson(`${groupDialog.id}_members`, groupDialog.members)">
|
||||||
|
<Download class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span v-if="groupDialog.memberSearch.length" style="font-size: 14px; margin-left: 6px; margin-right: 6px"
|
||||||
|
>{{ groupDialog.memberSearchResults.length }}/{{ groupDialog.ref.memberCount }}</span
|
||||||
|
>
|
||||||
|
<span v-else style="font-size: 14px; margin-left: 6px; margin-right: 6px"
|
||||||
|
>{{ groupDialog.members.length }}/{{ groupDialog.ref.memberCount }}</span
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="hasGroupPermission(groupDialog.ref, 'group-members-manage')"
|
||||||
|
style="float: right"
|
||||||
|
class="flex items-center">
|
||||||
|
<span style="margin-right: 6px">{{ t('dialog.group.members.sort_by') }}</span>
|
||||||
|
<Select
|
||||||
|
v-model="groupDialogMemberSortValue"
|
||||||
|
:disabled="isGroupMembersLoading || groupDialog.memberSearch.length > 0">
|
||||||
|
<SelectTrigger class="h-8 w-45 mr-1">
|
||||||
|
<SelectValue :placeholder="t('dialog.group.members.sort_by')" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem v-for="item in groupDialogSortingOptions" :key="item.value" :value="item.value">
|
||||||
|
{{ t(item.name) }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span class="ml-2 mr-1">{{ t('dialog.group.members.filter') }}</span>
|
||||||
|
<div style="display: inline-block; width: 220px">
|
||||||
|
<VirtualCombobox
|
||||||
|
v-model="groupDialogMemberFilterKey"
|
||||||
|
:groups="groupDialogMemberFilterGroups"
|
||||||
|
:disabled="isGroupMembersLoading || groupDialog.memberSearch.length > 0"
|
||||||
|
:placeholder="t('dialog.group.members.filter')"
|
||||||
|
:search-placeholder="t('dialog.group.members.search')"
|
||||||
|
:clearable="false"
|
||||||
|
:close-on-select="true">
|
||||||
|
<template #trigger="{ text }">
|
||||||
|
<span class="truncate">
|
||||||
|
{{ text || t('dialog.group.members.filter') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</VirtualCombobox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<InputGroupField
|
||||||
|
v-model="groupDialog.memberSearch"
|
||||||
|
:disabled="!hasGroupPermission(groupDialog.ref, 'group-members-manage')"
|
||||||
|
clearable
|
||||||
|
size="sm"
|
||||||
|
:placeholder="t('dialog.group.members.search')"
|
||||||
|
style="margin-top: 8px; margin-bottom: 8px"
|
||||||
|
@input="groupMembersSearch" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="groupDialog.memberSearch.length"
|
||||||
|
class="flex flex-wrap items-start"
|
||||||
|
style="margin-top: 8px; overflow: auto; max-height: 250px; min-width: 130px">
|
||||||
|
<div
|
||||||
|
v-for="user in groupDialog.memberSearchResults"
|
||||||
|
:key="user.id"
|
||||||
|
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||||
|
@click="showUserDialog(user.userId)">
|
||||||
|
<div class="relative inline-block flex-none size-9 mr-2.5">
|
||||||
|
<img class="size-full rounded-full object-cover" :src="userImage(user.user)" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span
|
||||||
|
class="block truncate font-medium leading-[18px]"
|
||||||
|
:style="{ color: user.user?.$userColour }"
|
||||||
|
v-text="user.user?.displayName" />
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
<template v-if="hasGroupPermission(groupDialog.ref, 'group-members-manage')">
|
||||||
|
<TooltipWrapper
|
||||||
|
v-if="user.isRepresenting"
|
||||||
|
side="top"
|
||||||
|
:content="t('dialog.group.members.representing')">
|
||||||
|
<Tag style="margin-right: 6px" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
<TooltipWrapper v-if="user.visibility !== 'visible'" side="top">
|
||||||
|
<template #content>
|
||||||
|
<span>{{ t('dialog.group.members.visibility') }} {{ user.visibility }}</span>
|
||||||
|
</template>
|
||||||
|
<Eye style="margin-right: 6px" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
<TooltipWrapper
|
||||||
|
v-if="!user.isSubscribedToAnnouncements"
|
||||||
|
side="top"
|
||||||
|
:content="t('dialog.group.members.unsubscribed_announcements')">
|
||||||
|
<MessageSquare style="margin-right: 6px" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
<TooltipWrapper v-if="user.managerNotes" side="top">
|
||||||
|
<template #content>
|
||||||
|
<span>{{ t('dialog.group.members.manager_notes') }}</span>
|
||||||
|
<br />
|
||||||
|
<span>{{ user.managerNotes }}</span>
|
||||||
|
</template>
|
||||||
|
<Pencil style="margin-right: 6px" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
</template>
|
||||||
|
<template v-for="roleId in user.roleIds" :key="roleId">
|
||||||
|
<template v-for="role in groupDialog.ref.roles" :key="role.id + roleId"
|
||||||
|
><span v-if="role.id === roleId" v-text="role.name" /></template
|
||||||
|
><template v-if="user.roleIds.indexOf(roleId) < user.roleIds.length - 1"
|
||||||
|
><span>, </span></template
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
v-else-if="groupDialog.members.length > 0"
|
||||||
|
class="infinite-list flex flex-wrap items-start"
|
||||||
|
style="margin-top: 8px; overflow: auto; max-height: 250px; min-width: 130px">
|
||||||
|
<li
|
||||||
|
v-for="user in groupDialog.members"
|
||||||
|
:key="user.id"
|
||||||
|
class="infinite-list-item box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||||
|
@click="showUserDialog(user.userId)">
|
||||||
|
<div class="relative inline-block flex-none size-9 mr-2.5">
|
||||||
|
<img class="size-full rounded-full object-cover" :src="userImage(user.user)" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span
|
||||||
|
class="block truncate font-medium leading-[18px]"
|
||||||
|
:style="{ color: user.user?.$userColour }"
|
||||||
|
v-text="user.user?.displayName" />
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
<template v-if="hasGroupPermission(groupDialog.ref, 'group-members-manage')">
|
||||||
|
<TooltipWrapper
|
||||||
|
v-if="user.isRepresenting"
|
||||||
|
side="top"
|
||||||
|
:content="t('dialog.group.members.representing')">
|
||||||
|
<Tag style="margin-right: 6px" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
<TooltipWrapper v-if="user.visibility !== 'visible'" side="top">
|
||||||
|
<template #content>
|
||||||
|
<span>{{ t('dialog.group.members.visibility') }} {{ user.visibility }}</span>
|
||||||
|
</template>
|
||||||
|
<Eye style="margin-right: 6px" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
<TooltipWrapper
|
||||||
|
v-if="!user.isSubscribedToAnnouncements"
|
||||||
|
side="top"
|
||||||
|
:content="t('dialog.group.members.unsubscribed_announcements')">
|
||||||
|
<MessageSquare style="margin-right: 6px" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
<TooltipWrapper v-if="user.managerNotes" side="top">
|
||||||
|
<template #content>
|
||||||
|
<span>{{ t('dialog.group.members.manager_notes') }}</span>
|
||||||
|
<br />
|
||||||
|
<span>{{ user.managerNotes }}</span>
|
||||||
|
</template>
|
||||||
|
<Pencil style="margin-right: 6px" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
</template>
|
||||||
|
<template v-for="roleId in user.roleIds" :key="roleId">
|
||||||
|
<template v-for="role in groupDialog.ref.roles" :key="roleId + role.id"
|
||||||
|
><span v-if="role.id === roleId" v-text="role.name" /></template
|
||||||
|
><template v-if="user.roleIds.indexOf(roleId) < user.roleIds.length - 1"
|
||||||
|
><span> </span></template
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<div
|
||||||
|
v-if="!isGroupMembersDone"
|
||||||
|
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer"
|
||||||
|
style="width: 100%; height: 45px; text-align: center"
|
||||||
|
@click="loadMoreGroupMembers">
|
||||||
|
<div v-if="!isGroupMembersLoading" class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">{{
|
||||||
|
t('dialog.group.members.load_more')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Download, Eye, MessageSquare, Pencil, RefreshCcw, Tag } from 'lucide-vue-next';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { InputGroupField } from '@/components/ui/input-group';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { VirtualCombobox } from '@/components/ui/virtual-combobox';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { downloadAndSaveJson, hasGroupPermission, userImage } from '../../../shared/utils';
|
||||||
|
import { useGroupStore, useUserStore } from '../../../stores';
|
||||||
|
import { groupDialogSortingOptions } from '../../../shared/constants';
|
||||||
|
import { useGroupMembers } from './useGroupMembers';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const { showUserDialog } = useUserStore();
|
||||||
|
const { currentUser } = storeToRefs(useUserStore());
|
||||||
|
const { groupDialog } = storeToRefs(useGroupStore());
|
||||||
|
const { applyGroupMember, handleGroupMember } = useGroupStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isGroupMembersDone,
|
||||||
|
isGroupMembersLoading,
|
||||||
|
groupDialogMemberSortValue,
|
||||||
|
groupDialogMemberFilterKey,
|
||||||
|
groupDialogMemberFilterGroups,
|
||||||
|
groupMembersSearch,
|
||||||
|
getGroupDialogGroupMembers,
|
||||||
|
loadMoreGroupMembers,
|
||||||
|
loadAllGroupMembers
|
||||||
|
} = useGroupMembers(groupDialog, { currentUser, applyGroupMember, handleGroupMember, t });
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getGroupDialogGroupMembers
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
class="rounded-full"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
:disabled="isGroupGalleryLoading"
|
||||||
|
@click="getGroupGalleries">
|
||||||
|
<Spinner v-if="isGroupGalleryLoading" />
|
||||||
|
<RefreshCw v-else />
|
||||||
|
</Button>
|
||||||
|
<TabsUnderline
|
||||||
|
v-model="groupDialogGalleryCurrentName"
|
||||||
|
:items="groupGalleryTabs"
|
||||||
|
:unmount-on-hide="false"
|
||||||
|
class="mt-2.5">
|
||||||
|
<template
|
||||||
|
v-for="(gallery, index) in groupDialog.ref.galleries"
|
||||||
|
:key="`label-${index}`"
|
||||||
|
v-slot:[`label-${index}`]>
|
||||||
|
<span style="font-weight: bold; font-size: 16px" v-text="gallery.name" />
|
||||||
|
<i class="x-status-icon" style="margin-left: 6px" :class="groupGalleryStatus(gallery)" />
|
||||||
|
<span class="text-muted-foreground" style="font-size: 12px; margin-left: 6px">{{
|
||||||
|
groupDialog.galleries[gallery.id] ? groupDialog.galleries[gallery.id].length : 0
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-for="(gallery, index) in groupDialog.ref.galleries"
|
||||||
|
:key="`content-${index}`"
|
||||||
|
v-slot:[String(index)]>
|
||||||
|
<span class="text-muted-foreground" style="padding: 8px" v-text="gallery.description" />
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
">
|
||||||
|
<Card
|
||||||
|
v-for="image in groupDialog.galleries[gallery.id]"
|
||||||
|
:key="image.id"
|
||||||
|
class="p-0 overflow-hidden transition-shadow hover:shadow-md">
|
||||||
|
<img
|
||||||
|
:src="image.imageUrl"
|
||||||
|
:class="[' cursor-pointer', 'max-w-full', 'max-h-full']"
|
||||||
|
@click="showFullscreenImageDialog(image.imageUrl)"
|
||||||
|
loading="lazy" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</TabsUnderline>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { RefreshCw } from 'lucide-vue-next';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { TabsUnderline } from '@/components/ui/tabs';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
|
import { useGalleryStore, useGroupStore } from '../../../stores';
|
||||||
|
import { useGroupGalleries } from './useGroupGalleries';
|
||||||
|
|
||||||
|
const { groupDialog } = storeToRefs(useGroupStore());
|
||||||
|
const { showFullscreenImageDialog } = useGalleryStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isGroupGalleryLoading,
|
||||||
|
groupDialogGalleryCurrentName,
|
||||||
|
groupGalleryTabs,
|
||||||
|
groupGalleryStatus,
|
||||||
|
getGroupGalleries
|
||||||
|
} = useGroupGalleries(groupDialog);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getGroupGalleries
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<template>
|
||||||
|
<template v-if="groupDialog.visible">
|
||||||
|
<span style="margin-right: 8px; vertical-align: top"
|
||||||
|
>{{ t('dialog.group.posts.posts_count') }} {{ groupDialog.posts.length }}</span
|
||||||
|
>
|
||||||
|
<InputGroupField
|
||||||
|
v-model="groupDialog.postsSearch"
|
||||||
|
clearable
|
||||||
|
size="sm"
|
||||||
|
:placeholder="t('dialog.group.posts.search_placeholder')"
|
||||||
|
style="width: 89%; margin-bottom: 8px"
|
||||||
|
@input="updateGroupPostSearch" />
|
||||||
|
<div class="flex flex-wrap items-start">
|
||||||
|
<div
|
||||||
|
v-for="post in groupDialog.postsFiltered"
|
||||||
|
:key="post.id"
|
||||||
|
class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span style="display: block" v-text="post.title" />
|
||||||
|
<div v-if="post.imageUrl" style="display: inline-block; margin-right: 6px">
|
||||||
|
<img
|
||||||
|
:src="post.imageUrl"
|
||||||
|
class="cursor-pointer"
|
||||||
|
style="
|
||||||
|
flex: none;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
object-fit: cover;
|
||||||
|
"
|
||||||
|
@click="showFullscreenImageDialog(post.imageUrl)"
|
||||||
|
loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
class="text-xs"
|
||||||
|
style="
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 0;
|
||||||
|
"
|
||||||
|
>{{ post.text || '-' }}</pre
|
||||||
|
>
|
||||||
|
<br />
|
||||||
|
<div v-if="post.authorId" class="text-xs" style="float: right; margin-left: 6px">
|
||||||
|
<TooltipWrapper v-if="post.roleIds.length" side="top">
|
||||||
|
<template #content>
|
||||||
|
<span>{{ t('dialog.group.posts.visibility') }}</span>
|
||||||
|
<br />
|
||||||
|
<template v-for="roleId in post.roleIds" :key="roleId">
|
||||||
|
<template v-for="role in groupDialog.ref.roles" :key="role.id + roleId"
|
||||||
|
><span v-if="role.id === roleId" v-text="role.name" />
|
||||||
|
</template>
|
||||||
|
<template v-if="post.roleIds.indexOf(roleId) < post.roleIds.length - 1"
|
||||||
|
><span>, </span></template
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<Eye style="margin-right: 6px" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
<DisplayName :userid="post.authorId" style="margin-right: 6px" />
|
||||||
|
<span v-if="post.editorId" style="margin-right: 6px"
|
||||||
|
>({{ t('dialog.group.posts.edited_by') }} <DisplayName :userid="post.editorId" />)</span
|
||||||
|
>
|
||||||
|
<TooltipWrapper side="bottom">
|
||||||
|
<template #content>
|
||||||
|
<span
|
||||||
|
>{{ t('dialog.group.posts.created_at') }}
|
||||||
|
{{ formatDateFilter(post.createdAt, 'long') }}</span
|
||||||
|
>
|
||||||
|
<template v-if="post.updatedAt !== post.createdAt">
|
||||||
|
<br />
|
||||||
|
<span
|
||||||
|
>{{ t('dialog.group.posts.edited_at') }}
|
||||||
|
{{ formatDateFilter(post.updatedAt, 'long') }}</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<Timer :epoch="Date.parse(post.updatedAt)" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
<template v-if="hasGroupPermission(groupDialog.ref, 'group-announcement-manage')">
|
||||||
|
<TooltipWrapper side="top" :content="t('dialog.group.posts.edit_tooltip')">
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
class="h-6 w-6 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
variant="ghost"
|
||||||
|
@click="showGroupPostEditDialog(groupDialog.id, post)"
|
||||||
|
><Pencil class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipWrapper>
|
||||||
|
<TooltipWrapper side="top" :content="t('dialog.group.posts.delete_tooltip')">
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
class="h-6 w-6 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
variant="ghost"
|
||||||
|
@click="confirmDeleteGroupPost(post)"
|
||||||
|
><Trash2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipWrapper>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Eye, Pencil, Trash2 } from 'lucide-vue-next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { InputGroupField } from '@/components/ui/input-group';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { formatDateFilter, hasGroupPermission } from '../../../shared/utils';
|
||||||
|
import { useGalleryStore, useGroupStore } from '../../../stores';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
showGroupPostEditDialog: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
confirmDeleteGroupPost: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const { groupDialog } = storeToRefs(useGroupStore());
|
||||||
|
const { updateGroupPostSearch } = useGroupStore();
|
||||||
|
const { showFullscreenImageDialog } = useGalleryStore();
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<div style="margin-top: 8px">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<Button
|
||||||
|
class="rounded-full"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="$emit('refresh')">
|
||||||
|
<Spinner v-if="loading" />
|
||||||
|
<RefreshCw v-else />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="$emit('select-all', tableData.data)"
|
||||||
|
>{{ t('dialog.group_member_moderation.select_all') }}</Button
|
||||||
|
>
|
||||||
|
<span style="font-size: 14px; margin-left: 6px; margin-right: 6px">{{
|
||||||
|
tableData.data.length
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!tableData.data.length"
|
||||||
|
@click="$emit('export')"
|
||||||
|
>{{ t('dialog.group_member_moderation.export_bans') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!hasGroupPermission(groupRef, 'group-bans-manage')"
|
||||||
|
@click="$emit('import')"
|
||||||
|
>{{ t('dialog.group_member_moderation.import_bans') }}</Button
|
||||||
|
>
|
||||||
|
<InputGroupField
|
||||||
|
v-model="tableData.filters[0].value"
|
||||||
|
clearable
|
||||||
|
size="sm"
|
||||||
|
class="w-80"
|
||||||
|
:placeholder="t('dialog.group.members.search')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTableLayout
|
||||||
|
style="margin-top: 8px"
|
||||||
|
:table="tanstackTable"
|
||||||
|
:loading="loading"
|
||||||
|
:page-sizes="pageSizes"
|
||||||
|
:total-items="totalItems" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { RefreshCw } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { InputGroupField } from '@/components/ui/input-group';
|
||||||
|
import { DataTableLayout } from '@/components/ui/data-table';
|
||||||
|
import { hasGroupPermission } from '@/shared/utils';
|
||||||
|
import { createColumns } from './groupMemberModerationBansColumns.jsx';
|
||||||
|
import { useVrcxVueTable } from '@/lib/table/useVrcxVueTable';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
tableData: { type: Object, required: true },
|
||||||
|
groupRef: { type: Object, default: () => ({}) },
|
||||||
|
pageSizes: { type: Array, required: true },
|
||||||
|
columnContext: { type: Object, required: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['refresh', 'select-all', 'export', 'import']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const bansSearch = computed(() =>
|
||||||
|
String(props.tableData.filters?.[0]?.value ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const rows = Array.isArray(props.tableData.data) ? props.tableData.data : [];
|
||||||
|
const q = bansSearch.value;
|
||||||
|
if (!q) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return rows.filter((r) => {
|
||||||
|
const name = (r?.$displayName ?? r?.user?.displayName ?? '').toString().toLowerCase();
|
||||||
|
return name.includes(q);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = computed(() => createColumns(props.columnContext));
|
||||||
|
|
||||||
|
const { table: tanstackTable } = useVrcxVueTable({
|
||||||
|
persistKey: 'group-moderation:bans',
|
||||||
|
get data() {
|
||||||
|
return filteredRows.value;
|
||||||
|
},
|
||||||
|
columns,
|
||||||
|
getRowId: (row) => String(row?.userId ?? row?.id ?? ''),
|
||||||
|
initialPagination: { pageIndex: 0, pageSize: props.tableData.pageSize ?? 15 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalItems = computed(() => tanstackTable.getFilteredRowModel().rows.length);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<span class="name">{{ t('dialog.group_member_moderation.user_id') }}</span>
|
||||||
|
<br />
|
||||||
|
<InputGroupField
|
||||||
|
:model-value="selectUserId"
|
||||||
|
size="sm"
|
||||||
|
style="margin-top: 6px"
|
||||||
|
:placeholder="t('dialog.group_member_moderation.user_id_placeholder')"
|
||||||
|
clearable
|
||||||
|
@update:model-value="$emit('update:selectUserId', $event)" />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
:disabled="!selectUserId"
|
||||||
|
@click="$emit('select-user')"
|
||||||
|
>{{ t('dialog.group_member_moderation.select_user') }}</Button
|
||||||
|
>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<span class="name">{{ t('dialog.group_member_moderation.selected_users') }}</span>
|
||||||
|
<Button
|
||||||
|
class="rounded-full"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="outline"
|
||||||
|
style="margin-left: 6px"
|
||||||
|
@click="$emit('clear-all')">
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
<br />
|
||||||
|
<Badge
|
||||||
|
v-for="user in selectedUsersArray"
|
||||||
|
:key="user.id"
|
||||||
|
variant="outline"
|
||||||
|
style="margin-right: 6px; margin-top: 6px">
|
||||||
|
<TooltipWrapper v-if="user.membershipStatus !== 'member'" side="top">
|
||||||
|
<template #content>
|
||||||
|
<span>{{ t('dialog.group_member_moderation.user_isnt_in_group') }}</span>
|
||||||
|
</template>
|
||||||
|
<AlertTriangle style="margin-left: 3px; display: inline-block" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
<span
|
||||||
|
v-text="user.user?.displayName || user.userId"
|
||||||
|
style="font-weight: bold; margin-left: 6px"></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style="
|
||||||
|
margin-left: 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
"
|
||||||
|
@click="$emit('delete-user', user)">
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<span class="name">{{ t('dialog.group_member_moderation.notes') }}</span>
|
||||||
|
<InputGroupTextareaField
|
||||||
|
:model-value="note"
|
||||||
|
class="text-xs"
|
||||||
|
:rows="2"
|
||||||
|
:placeholder="t('dialog.group_member_moderation.note_placeholder')"
|
||||||
|
style="margin-top: 6px"
|
||||||
|
input-class="resize-none min-h-0"
|
||||||
|
@update:model-value="$emit('update:note', $event)" />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<span class="name">{{ t('dialog.group_member_moderation.selected_roles') }}</span>
|
||||||
|
<br />
|
||||||
|
<Select :model-value="selectedRoles" multiple @update:model-value="$emit('update:selectedRoles', $event)">
|
||||||
|
<SelectTrigger style="margin-top: 6px">
|
||||||
|
<SelectValue :placeholder="t('dialog.group_member_moderation.choose_roles_placeholder')" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="role in groupRef.roles"
|
||||||
|
:key="role.id"
|
||||||
|
:value="role.id">
|
||||||
|
{{ role.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<span class="name">{{ t('dialog.group_member_moderation.actions') }}</span>
|
||||||
|
<br />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
Boolean(
|
||||||
|
!selectedRoles.length ||
|
||||||
|
progressCurrent ||
|
||||||
|
!hasGroupPermission(groupRef, 'group-roles-assign')
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="$emit('add-roles')"
|
||||||
|
>{{ t('dialog.group_member_moderation.add_roles') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="
|
||||||
|
Boolean(
|
||||||
|
!selectedRoles.length ||
|
||||||
|
progressCurrent ||
|
||||||
|
!hasGroupPermission(groupRef, 'group-roles-assign')
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="$emit('remove-roles')"
|
||||||
|
>{{ t('dialog.group_member_moderation.remove_roles') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
Boolean(
|
||||||
|
progressCurrent ||
|
||||||
|
!hasGroupPermission(groupRef, 'group-members-manage')
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="$emit('save-note')"
|
||||||
|
>{{ t('dialog.group_member_moderation.save_note') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
Boolean(
|
||||||
|
progressCurrent ||
|
||||||
|
!hasGroupPermission(groupRef, 'group-members-remove')
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="$emit('kick')"
|
||||||
|
>{{ t('dialog.group_member_moderation.kick') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
Boolean(
|
||||||
|
progressCurrent ||
|
||||||
|
!hasGroupPermission(groupRef, 'group-bans-manage')
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="$emit('ban')"
|
||||||
|
>{{ t('dialog.group_member_moderation.ban') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
Boolean(
|
||||||
|
progressCurrent ||
|
||||||
|
!hasGroupPermission(groupRef, 'group-bans-manage')
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="$emit('unban')"
|
||||||
|
>{{ t('dialog.group_member_moderation.unban') }}</Button
|
||||||
|
>
|
||||||
|
<span v-if="progressCurrent" style="margin-top: 8px">
|
||||||
|
<Spinner class="inline-block ml-2 mr-2" />
|
||||||
|
{{ t('dialog.group_member_moderation.progress') }} {{ progressCurrent }}/{{ progressTotal }}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
v-if="progressCurrent"
|
||||||
|
variant="secondary"
|
||||||
|
style="margin-left: 6px"
|
||||||
|
@click="$emit('cancel-progress')"
|
||||||
|
>{{ t('dialog.group_member_moderation.cancel') }}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { AlertTriangle, Trash2, X } from 'lucide-vue-next';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { InputGroupField, InputGroupTextareaField } from '@/components/ui/input-group';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { hasGroupPermission } from '@/shared/utils';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
selectUserId: { type: String, default: '' },
|
||||||
|
selectedUsersArray: { type: Array, default: () => [] },
|
||||||
|
selectedRoles: { type: Array, default: () => [] },
|
||||||
|
note: { type: String, default: '' },
|
||||||
|
progressCurrent: { type: Number, default: 0 },
|
||||||
|
progressTotal: { type: Number, default: 0 },
|
||||||
|
groupRef: { type: Object, default: () => ({}) }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits([
|
||||||
|
'update:selectUserId',
|
||||||
|
'update:note',
|
||||||
|
'update:selectedRoles',
|
||||||
|
'select-user',
|
||||||
|
'clear-all',
|
||||||
|
'delete-user',
|
||||||
|
'add-roles',
|
||||||
|
'remove-roles',
|
||||||
|
'save-note',
|
||||||
|
'kick',
|
||||||
|
'ban',
|
||||||
|
'unban',
|
||||||
|
'cancel-progress'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<template>
|
||||||
|
<div style="margin-top: 8px">
|
||||||
|
<Button
|
||||||
|
class="rounded-full"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="$emit('refresh')">
|
||||||
|
<Spinner v-if="loading" />
|
||||||
|
<RefreshCw v-else />
|
||||||
|
</Button>
|
||||||
|
<br />
|
||||||
|
<TabsUnderline default-value="sent" :items="invitesTabs" :unmount-on-hide="false">
|
||||||
|
<template #label-sent>
|
||||||
|
<span style="font-weight: bold; font-size: 16px">{{
|
||||||
|
t('dialog.group_member_moderation.sent_invites')
|
||||||
|
}}</span>
|
||||||
|
<span class="text-muted-foreground" style="font-size: 12px; margin-left: 6px">{{
|
||||||
|
invitesTable.data.length
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
<template #label-join>
|
||||||
|
<span style="font-weight: bold; font-size: 16px">{{
|
||||||
|
t('dialog.group_member_moderation.join_requests')
|
||||||
|
}}</span>
|
||||||
|
<span class="text-muted-foreground" style="font-size: 12px; margin-left: 6px">{{
|
||||||
|
joinRequestsTable.data.length
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
<template #label-blocked>
|
||||||
|
<span style="font-weight: bold; font-size: 16px">{{
|
||||||
|
t('dialog.group_member_moderation.blocked_requests')
|
||||||
|
}}</span>
|
||||||
|
<span class="text-muted-foreground" style="font-size: 12px; margin-left: 6px">{{
|
||||||
|
blockedTable.data.length
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
<template #sent>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="$emit('select-all', invitesTable.data)"
|
||||||
|
>{{ t('dialog.group_member_moderation.select_all') }}</Button
|
||||||
|
>
|
||||||
|
<DataTableLayout
|
||||||
|
style="margin-top: 8px"
|
||||||
|
:table="invitesTanstackTable"
|
||||||
|
:loading="loading"
|
||||||
|
:page-sizes="pageSizes"
|
||||||
|
:total-items="invitesTotalItems" />
|
||||||
|
<br />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="inviteActionDisabled"
|
||||||
|
@click="$emit('delete-sent-invite')"
|
||||||
|
>{{ t('dialog.group_member_moderation.delete_sent_invite') }}</Button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #join>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="$emit('select-all', joinRequestsTable.data)"
|
||||||
|
>{{ t('dialog.group_member_moderation.select_all') }}</Button
|
||||||
|
>
|
||||||
|
<DataTableLayout
|
||||||
|
style="margin-top: 8px"
|
||||||
|
:table="joinRequestsTanstackTable"
|
||||||
|
:loading="loading"
|
||||||
|
:page-sizes="pageSizes"
|
||||||
|
:total-items="joinRequestsTotalItems" />
|
||||||
|
<br />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="inviteActionDisabled"
|
||||||
|
class="mr-2"
|
||||||
|
@click="$emit('accept-invite-request')"
|
||||||
|
>{{ t('dialog.group_member_moderation.accept_join_requests') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="inviteActionDisabled"
|
||||||
|
class="mr-2"
|
||||||
|
@click="$emit('reject-invite-request')"
|
||||||
|
>{{ t('dialog.group_member_moderation.reject_join_requests') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="inviteActionDisabled"
|
||||||
|
@click="$emit('block-join-request')"
|
||||||
|
>{{ t('dialog.group_member_moderation.block_join_requests') }}</Button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #blocked>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="$emit('select-all', blockedTable.data)"
|
||||||
|
>{{ t('dialog.group_member_moderation.select_all') }}</Button
|
||||||
|
>
|
||||||
|
<DataTableLayout
|
||||||
|
style="margin-top: 8px"
|
||||||
|
:table="blockedTanstackTable"
|
||||||
|
:loading="loading"
|
||||||
|
:page-sizes="pageSizes"
|
||||||
|
:total-items="blockedTotalItems" />
|
||||||
|
<br />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="inviteActionDisabled"
|
||||||
|
@click="$emit('delete-blocked-request')"
|
||||||
|
>{{ t('dialog.group_member_moderation.delete_blocked_requests') }}</Button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</TabsUnderline>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { RefreshCw } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { TabsUnderline } from '@/components/ui/tabs';
|
||||||
|
import { DataTableLayout } from '@/components/ui/data-table';
|
||||||
|
import { hasGroupPermission } from '@/shared/utils';
|
||||||
|
import { createColumns as createInvitesColumns } from './groupMemberModerationInvitesColumns.jsx';
|
||||||
|
import { createColumns as createJoinRequestsColumns } from './groupMemberModerationJoinRequestsColumns.jsx';
|
||||||
|
import { createColumns as createBlockedColumns } from './groupMemberModerationBlockedColumns.jsx';
|
||||||
|
import { useVrcxVueTable } from '@/lib/table/useVrcxVueTable';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
invitesTable: { type: Object, required: true },
|
||||||
|
joinRequestsTable: { type: Object, required: true },
|
||||||
|
blockedTable: { type: Object, required: true },
|
||||||
|
groupRef: { type: Object, default: () => ({}) },
|
||||||
|
progressCurrent: { type: Number, default: 0 },
|
||||||
|
pageSizes: { type: Array, required: true },
|
||||||
|
columnContext: { type: Object, required: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits([
|
||||||
|
'refresh',
|
||||||
|
'select-all',
|
||||||
|
'delete-sent-invite',
|
||||||
|
'accept-invite-request',
|
||||||
|
'reject-invite-request',
|
||||||
|
'block-join-request',
|
||||||
|
'delete-blocked-request'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const invitesTabs = computed(() => [
|
||||||
|
{ value: 'sent', label: t('dialog.group_member_moderation.sent_invites') },
|
||||||
|
{ value: 'join', label: t('dialog.group_member_moderation.join_requests') },
|
||||||
|
{ value: 'blocked', label: t('dialog.group_member_moderation.blocked_requests') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inviteActionDisabled = computed(() =>
|
||||||
|
Boolean(props.progressCurrent || !hasGroupPermission(props.groupRef, 'group-invites-manage'))
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Invites TanStack table ───────────────────────────────────
|
||||||
|
const invitesColumns = computed(() => createInvitesColumns(props.columnContext));
|
||||||
|
const { table: invitesTanstackTable } = useVrcxVueTable({
|
||||||
|
persistKey: 'group-moderation:invites',
|
||||||
|
get data() {
|
||||||
|
return computed(() => props.invitesTable.data).value;
|
||||||
|
},
|
||||||
|
columns: invitesColumns,
|
||||||
|
getRowId: (row) => String(row?.userId ?? row?.id ?? ''),
|
||||||
|
initialPagination: { pageIndex: 0, pageSize: props.invitesTable.pageSize ?? 15 }
|
||||||
|
});
|
||||||
|
const invitesTotalItems = computed(() => invitesTanstackTable.getFilteredRowModel().rows.length);
|
||||||
|
|
||||||
|
// ── Join Requests TanStack table ─────────────────────────────
|
||||||
|
const joinRequestsColumns = computed(() => createJoinRequestsColumns(props.columnContext));
|
||||||
|
const { table: joinRequestsTanstackTable } = useVrcxVueTable({
|
||||||
|
persistKey: 'group-moderation:join-requests',
|
||||||
|
get data() {
|
||||||
|
return computed(() => props.joinRequestsTable.data).value;
|
||||||
|
},
|
||||||
|
columns: joinRequestsColumns,
|
||||||
|
getRowId: (row) => String(row?.userId ?? row?.id ?? ''),
|
||||||
|
initialPagination: { pageIndex: 0, pageSize: props.joinRequestsTable.pageSize ?? 15 }
|
||||||
|
});
|
||||||
|
const joinRequestsTotalItems = computed(() => joinRequestsTanstackTable.getFilteredRowModel().rows.length);
|
||||||
|
|
||||||
|
// ── Blocked TanStack table ───────────────────────────────────
|
||||||
|
const blockedColumns = computed(() => createBlockedColumns(props.columnContext));
|
||||||
|
const { table: blockedTanstackTable } = useVrcxVueTable({
|
||||||
|
persistKey: 'group-moderation:blocked',
|
||||||
|
get data() {
|
||||||
|
return computed(() => props.blockedTable.data).value;
|
||||||
|
},
|
||||||
|
columns: blockedColumns,
|
||||||
|
getRowId: (row) => String(row?.userId ?? row?.id ?? ''),
|
||||||
|
initialPagination: { pageIndex: 0, pageSize: props.blockedTable.pageSize ?? 15 }
|
||||||
|
});
|
||||||
|
const blockedTotalItems = computed(() => blockedTanstackTable.getFilteredRowModel().rows.length);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div style="margin-top: 8px">
|
||||||
|
<Button
|
||||||
|
class="rounded-full"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="$emit('refresh')">
|
||||||
|
<Spinner v-if="loading" />
|
||||||
|
<RefreshCw v-else />
|
||||||
|
</Button>
|
||||||
|
<span style="font-size: 14px; margin-left: 6px; margin-right: 6px">{{
|
||||||
|
tableData.data.length
|
||||||
|
}}</span>
|
||||||
|
<br />
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||||
|
<div>
|
||||||
|
<Select v-model="selectedAuditLogTypes" multiple>
|
||||||
|
<SelectTrigger style="margin: 8px 0; width: 250px">
|
||||||
|
<SelectValue
|
||||||
|
:placeholder="t('dialog.group_member_moderation.filter_type')" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="type in auditLogTypes"
|
||||||
|
:key="type"
|
||||||
|
:value="type">
|
||||||
|
{{ getAuditLogTypeName(type) }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button variant="outline" @click="$emit('export')">{{
|
||||||
|
t('dialog.group_member_moderation.export_logs')
|
||||||
|
}}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<InputGroupField
|
||||||
|
v-model="tableData.filters[0].value"
|
||||||
|
clearable
|
||||||
|
size="sm"
|
||||||
|
:placeholder="t('dialog.group.members.search')"
|
||||||
|
style="margin-top: 8px; margin-bottom: 8px" />
|
||||||
|
<br />
|
||||||
|
<DataTableLayout
|
||||||
|
style="margin-top: 8px"
|
||||||
|
:table="tanstackTable"
|
||||||
|
:loading="loading"
|
||||||
|
:page-sizes="pageSizes"
|
||||||
|
:total-items="totalItems" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { RefreshCw } from 'lucide-vue-next';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { InputGroupField } from '@/components/ui/input-group';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { DataTableLayout } from '@/components/ui/data-table';
|
||||||
|
import { getAuditLogTypeName } from './groupModerationUtils';
|
||||||
|
import { createColumns } from './groupMemberModerationLogsColumns.jsx';
|
||||||
|
import { useVrcxVueTable } from '@/lib/table/useVrcxVueTable';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
tableData: { type: Object, required: true },
|
||||||
|
auditLogTypes: { type: Array, default: () => [] },
|
||||||
|
pageSizes: { type: Array, required: true },
|
||||||
|
columnContext: { type: Object, required: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['refresh', 'export']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const selectedAuditLogTypes = ref([]);
|
||||||
|
|
||||||
|
defineExpose({ selectedAuditLogTypes });
|
||||||
|
|
||||||
|
const logsSearch = computed(() =>
|
||||||
|
String(props.tableData.filters?.[0]?.value ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const rows = Array.isArray(props.tableData.data) ? props.tableData.data : [];
|
||||||
|
const q = logsSearch.value;
|
||||||
|
if (!q) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return rows.filter((r) => {
|
||||||
|
const desc = (r?.description ?? '').toString().toLowerCase();
|
||||||
|
return desc.includes(q);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = computed(() => createColumns(props.columnContext));
|
||||||
|
|
||||||
|
const { table: tanstackTable } = useVrcxVueTable({
|
||||||
|
persistKey: 'group-moderation:logs',
|
||||||
|
get data() {
|
||||||
|
return filteredRows.value;
|
||||||
|
},
|
||||||
|
columns,
|
||||||
|
getRowId: (row) => String(row?.id ?? `${row?.created_at ?? ''}:${row?.eventType ?? ''}`),
|
||||||
|
initialPagination: { pageIndex: 0, pageSize: props.tableData.pageSize ?? 15 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalItems = computed(() => tanstackTable.getFilteredRowModel().rows.length);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-2">
|
||||||
|
<Button
|
||||||
|
class="rounded-full"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="$emit('refresh')">
|
||||||
|
<Spinner v-if="loading" />
|
||||||
|
<RefreshCw v-else />
|
||||||
|
</Button>
|
||||||
|
<span class="ml-1.5 mr-1.5" style="font-size: 14px">
|
||||||
|
{{ tableData.data.length }}/{{ groupRef.memberCount }}
|
||||||
|
</span>
|
||||||
|
<div class="mt-1.5" style="float: right">
|
||||||
|
<span class="mr-1.5">{{ t('dialog.group.members.sort_by') }}</span>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
as-child
|
||||||
|
:disabled="sortFilterDisabled">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="sortFilterDisabled"
|
||||||
|
@click.stop>
|
||||||
|
{{ t(memberSortOrder.name) }}
|
||||||
|
<ArrowDown class="ml-1.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="item in sortingOptions"
|
||||||
|
:key="item.name"
|
||||||
|
@click="$emit('sort-change', item)">
|
||||||
|
{{ t(item.name) }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<span class="ml-2 mr-1.5">{{ t('dialog.group.members.filter') }}</span>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
as-child
|
||||||
|
:disabled="sortFilterDisabled">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="sortFilterDisabled"
|
||||||
|
@click.stop>
|
||||||
|
{{ t(memberFilter.name) }}
|
||||||
|
<ArrowDown class="ml-1.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="item in filterOptions"
|
||||||
|
:key="item.name"
|
||||||
|
@click="$emit('filter-change', item)">
|
||||||
|
{{ t(item.name) }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<template v-for="role in groupRef.roles" :key="role.name">
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-if="!role.defaultRole"
|
||||||
|
@click="$emit('filter-change', role)">
|
||||||
|
{{ t(role.name) }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</template>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<InputGroupField
|
||||||
|
:model-value="memberSearch"
|
||||||
|
:disabled="!hasGroupPermission(groupRef, 'group-bans-manage')"
|
||||||
|
clearable
|
||||||
|
size="sm"
|
||||||
|
:placeholder="t('dialog.group.members.search')"
|
||||||
|
style="margin-top: 8px; margin-bottom: 8px"
|
||||||
|
@update:model-value="$emit('update:memberSearch', $event)"
|
||||||
|
@input="$emit('search')" />
|
||||||
|
<Button size="sm" variant="outline" @click="$emit('select-all', tableData.data)">{{
|
||||||
|
t('dialog.group_member_moderation.select_all')
|
||||||
|
}}</Button>
|
||||||
|
<DataTableLayout
|
||||||
|
v-if="tableData.data.length"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
:table="tanstackTable"
|
||||||
|
:loading="loading"
|
||||||
|
:page-sizes="pageSizes"
|
||||||
|
:total-items="totalItems" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ArrowDown, RefreshCw } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { InputGroupField } from '@/components/ui/input-group';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { DataTableLayout } from '@/components/ui/data-table';
|
||||||
|
import { hasGroupPermission } from '@/shared/utils';
|
||||||
|
import { createColumns } from './groupMemberModerationMembersColumns.jsx';
|
||||||
|
import { useVrcxVueTable } from '@/lib/table/useVrcxVueTable';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
tableData: { type: Object, required: true },
|
||||||
|
groupRef: { type: Object, default: () => ({}) },
|
||||||
|
memberSortOrder: { type: Object, required: true },
|
||||||
|
memberFilter: { type: Object, required: true },
|
||||||
|
memberSearch: { type: String, default: '' },
|
||||||
|
sortingOptions: { type: Array, required: true },
|
||||||
|
filterOptions: { type: Array, required: true },
|
||||||
|
pageSizes: { type: Array, required: true },
|
||||||
|
columnContext: { type: Object, required: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['refresh', 'update:memberSearch', 'search', 'sort-change', 'filter-change', 'select-all']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const sortFilterDisabled = computed(() =>
|
||||||
|
Boolean(
|
||||||
|
props.loading ||
|
||||||
|
props.memberSearch.length ||
|
||||||
|
!hasGroupPermission(props.groupRef, 'group-bans-manage')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = computed(() => createColumns(props.columnContext));
|
||||||
|
|
||||||
|
const { table: tanstackTable } = useVrcxVueTable({
|
||||||
|
persistKey: 'group-moderation:members',
|
||||||
|
get data() {
|
||||||
|
return computed(() => props.tableData.data).value;
|
||||||
|
},
|
||||||
|
columns,
|
||||||
|
getRowId: (row) => String(row?.userId ?? ''),
|
||||||
|
initialPagination: { pageIndex: 0, pageSize: props.tableData.pageSize ?? 15 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalItems = computed(() => tanstackTable.getFilteredRowModel().rows.length);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
// ─── Mocks ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key)
|
||||||
|
}),
|
||||||
|
createI18n: () => ({
|
||||||
|
global: { t: (key) => key },
|
||||||
|
install: vi.fn()
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../plugin/router', () => {
|
||||||
|
const { ref } = require('vue');
|
||||||
|
return {
|
||||||
|
router: {
|
||||||
|
beforeEach: vi.fn(),
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
currentRoute: ref({ path: '/', name: '', meta: {} }),
|
||||||
|
isReady: vi.fn().mockResolvedValue(true)
|
||||||
|
},
|
||||||
|
initRouter: vi.fn()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
const { ref } = require('vue');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useRouter: vi.fn(() => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
currentRoute: ref({ path: '/', name: '', meta: {} })
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('../../../../plugin/interopApi', () => ({ initInteropApi: vi.fn() }));
|
||||||
|
vi.mock('../../../../service/database', () => ({
|
||||||
|
database: new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: (_target, prop) => {
|
||||||
|
if (prop === '__esModule') return false;
|
||||||
|
return vi.fn().mockResolvedValue(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../service/config', () => ({
|
||||||
|
default: {
|
||||||
|
init: vi.fn(),
|
||||||
|
getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'),
|
||||||
|
setString: vi.fn(),
|
||||||
|
getBool: vi.fn().mockImplementation((_k, d) => d ?? false),
|
||||||
|
setBool: vi.fn(),
|
||||||
|
getInt: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||||
|
setInt: vi.fn(),
|
||||||
|
getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||||
|
setFloat: vi.fn(),
|
||||||
|
getObject: vi.fn().mockReturnValue(null),
|
||||||
|
setObject: vi.fn(),
|
||||||
|
getArray: vi.fn().mockReturnValue([]),
|
||||||
|
setArray: vi.fn(),
|
||||||
|
remove: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../service/jsonStorage', () => ({ default: vi.fn() }));
|
||||||
|
vi.mock('../../../../service/watchState', () => ({
|
||||||
|
watchState: { isLoggedIn: false }
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../service/request', () => ({
|
||||||
|
request: vi.fn().mockResolvedValue({ json: {} }),
|
||||||
|
processBulk: vi.fn(),
|
||||||
|
buildRequestInit: vi.fn(),
|
||||||
|
parseResponse: vi.fn(),
|
||||||
|
shouldIgnoreError: vi.fn(),
|
||||||
|
$throw: vi.fn(),
|
||||||
|
failedGetRequests: new Map()
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../api', () => ({
|
||||||
|
groupRequest: {
|
||||||
|
getCachedGroupGallery: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ json: [], params: {} })
|
||||||
|
},
|
||||||
|
userRequest: {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import GroupDialogPhotosTab from '../GroupDialogPhotosTab.vue';
|
||||||
|
import { useGroupStore } from '../../../../stores';
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MOCK_GALLERIES = [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
name: 'Photos',
|
||||||
|
description: 'General photos',
|
||||||
|
membersOnly: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'g2',
|
||||||
|
name: 'Screenshots',
|
||||||
|
description: 'Game screenshots',
|
||||||
|
membersOnly: true,
|
||||||
|
roleIdsToView: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_GALLERY_IMAGES = {
|
||||||
|
g1: [
|
||||||
|
{
|
||||||
|
id: 'img1',
|
||||||
|
imageUrl: 'https://img/photo1.png',
|
||||||
|
groupId: 'grp_1',
|
||||||
|
galleryId: 'g1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'img2',
|
||||||
|
imageUrl: 'https://img/photo2.png',
|
||||||
|
groupId: 'grp_1',
|
||||||
|
galleryId: 'g1'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
g2: [
|
||||||
|
{
|
||||||
|
id: 'img3',
|
||||||
|
imageUrl: 'https://img/screen1.png',
|
||||||
|
groupId: 'grp_1',
|
||||||
|
galleryId: 'g2'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} overrides
|
||||||
|
*/
|
||||||
|
function mountComponent(overrides = {}) {
|
||||||
|
const pinia = createTestingPinia({
|
||||||
|
stubActions: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupStore = useGroupStore(pinia);
|
||||||
|
groupStore.groupDialog = {
|
||||||
|
id: 'grp_1',
|
||||||
|
visible: true,
|
||||||
|
ref: {
|
||||||
|
galleries: [...MOCK_GALLERIES]
|
||||||
|
},
|
||||||
|
galleries: { ...MOCK_GALLERY_IMAGES },
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
|
||||||
|
return mount(GroupDialogPhotosTab, {
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
stubs: {
|
||||||
|
RefreshCw: { template: '<svg class="refresh-icon" />' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('GroupDialogPhotosTab.vue', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
test('renders gallery names', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
expect(wrapper.text()).toContain('Photos');
|
||||||
|
expect(wrapper.text()).toContain('Screenshots');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders gallery image counts', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
expect(wrapper.text()).toContain('2');
|
||||||
|
expect(wrapper.text()).toContain('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders gallery descriptions', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
expect(wrapper.text()).toContain('General photos');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders gallery images', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
const images = wrapper.findAll('img');
|
||||||
|
expect(images.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders refresh button', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
const button = wrapper.find('button');
|
||||||
|
expect(button.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders zero count for empty gallery', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
galleries: { g1: [], g2: [] }
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).toContain('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loading state', () => {
|
||||||
|
test('refresh button is enabled initially', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
const button = wrapper.find('button');
|
||||||
|
expect(button.attributes('disabled')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with no galleries', () => {
|
||||||
|
test('renders without gallery tabs when ref.galleries is empty', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
ref: { galleries: [] },
|
||||||
|
galleries: {}
|
||||||
|
});
|
||||||
|
// No gallery tabs should be rendered
|
||||||
|
expect(wrapper.text()).not.toContain('Photos');
|
||||||
|
expect(wrapper.text()).not.toContain('Screenshots');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
// ─── Mocks ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key)
|
||||||
|
}),
|
||||||
|
createI18n: () => ({
|
||||||
|
global: { t: (key) => key },
|
||||||
|
install: vi.fn()
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../plugin/router', () => {
|
||||||
|
const { ref } = require('vue');
|
||||||
|
return {
|
||||||
|
router: {
|
||||||
|
beforeEach: vi.fn(),
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
currentRoute: ref({ path: '/', name: '', meta: {} }),
|
||||||
|
isReady: vi.fn().mockResolvedValue(true)
|
||||||
|
},
|
||||||
|
initRouter: vi.fn()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
const { ref } = require('vue');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useRouter: vi.fn(() => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
currentRoute: ref({ path: '/', name: '', meta: {} })
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('../../../../plugin/interopApi', () => ({ initInteropApi: vi.fn() }));
|
||||||
|
vi.mock('../../../../service/database', () => ({
|
||||||
|
database: new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: (_target, prop) => {
|
||||||
|
if (prop === '__esModule') return false;
|
||||||
|
return vi.fn().mockResolvedValue(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../service/config', () => ({
|
||||||
|
default: {
|
||||||
|
init: vi.fn(),
|
||||||
|
getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'),
|
||||||
|
setString: vi.fn(),
|
||||||
|
getBool: vi.fn().mockImplementation((_k, d) => d ?? false),
|
||||||
|
setBool: vi.fn(),
|
||||||
|
getInt: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||||
|
setInt: vi.fn(),
|
||||||
|
getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||||
|
setFloat: vi.fn(),
|
||||||
|
getObject: vi.fn().mockReturnValue(null),
|
||||||
|
setObject: vi.fn(),
|
||||||
|
getArray: vi.fn().mockReturnValue([]),
|
||||||
|
setArray: vi.fn(),
|
||||||
|
remove: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../service/jsonStorage', () => ({ default: vi.fn() }));
|
||||||
|
vi.mock('../../../../service/watchState', () => ({
|
||||||
|
watchState: { isLoggedIn: false }
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../service/request', () => ({
|
||||||
|
request: vi.fn().mockResolvedValue({ json: {} }),
|
||||||
|
processBulk: vi.fn(),
|
||||||
|
buildRequestInit: vi.fn(),
|
||||||
|
parseResponse: vi.fn(),
|
||||||
|
shouldIgnoreError: vi.fn(),
|
||||||
|
$throw: vi.fn(),
|
||||||
|
failedGetRequests: new Map()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import GroupDialogPostsTab from '../GroupDialogPostsTab.vue';
|
||||||
|
import { useGroupStore } from '../../../../stores';
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MOCK_POSTS = [
|
||||||
|
{
|
||||||
|
id: 'post_1',
|
||||||
|
title: 'Welcome Post',
|
||||||
|
text: 'Hello everyone!',
|
||||||
|
imageUrl: 'https://img/post1.png',
|
||||||
|
authorId: 'usr_author1',
|
||||||
|
editorId: null,
|
||||||
|
roleIds: [],
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'post_2',
|
||||||
|
title: 'Rules Update',
|
||||||
|
text: 'Updated rules here.',
|
||||||
|
imageUrl: null,
|
||||||
|
authorId: 'usr_author2',
|
||||||
|
editorId: 'usr_editor',
|
||||||
|
roleIds: ['role_1'],
|
||||||
|
createdAt: '2024-02-01T00:00:00Z',
|
||||||
|
updatedAt: '2024-02-15T00:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'post_3',
|
||||||
|
title: 'Event Announcement',
|
||||||
|
text: '',
|
||||||
|
imageUrl: null,
|
||||||
|
authorId: 'usr_author1',
|
||||||
|
editorId: null,
|
||||||
|
roleIds: [],
|
||||||
|
createdAt: '2024-03-01T00:00:00Z',
|
||||||
|
updatedAt: '2024-03-01T00:00:00Z'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} overrides
|
||||||
|
*/
|
||||||
|
function mountComponent(overrides = {}) {
|
||||||
|
const pinia = createTestingPinia({
|
||||||
|
stubActions: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupStore = useGroupStore(pinia);
|
||||||
|
groupStore.groupDialog = {
|
||||||
|
id: 'grp_1',
|
||||||
|
visible: true,
|
||||||
|
posts: [...MOCK_POSTS],
|
||||||
|
postsFiltered: [...MOCK_POSTS],
|
||||||
|
postsSearch: '',
|
||||||
|
ref: {
|
||||||
|
roles: [
|
||||||
|
{ id: 'role_1', name: 'Admin' },
|
||||||
|
{ id: 'role_2', name: 'Member' }
|
||||||
|
],
|
||||||
|
permissions: []
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
|
||||||
|
return mount(GroupDialogPostsTab, {
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
stubs: {
|
||||||
|
Eye: { template: '<svg class="eye-icon" />' },
|
||||||
|
Pencil: { template: '<svg class="pencil-icon" />' },
|
||||||
|
Trash2: { template: '<svg class="trash-icon" />' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
showGroupPostEditDialog: vi.fn(),
|
||||||
|
confirmDeleteGroupPost: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('GroupDialogPostsTab.vue', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
test('renders post count', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
expect(wrapper.text()).toContain('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders all post titles', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
expect(wrapper.text()).toContain('Welcome Post');
|
||||||
|
expect(wrapper.text()).toContain('Rules Update');
|
||||||
|
expect(wrapper.text()).toContain('Event Announcement');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders post text', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
expect(wrapper.text()).toContain('Hello everyone!');
|
||||||
|
expect(wrapper.text()).toContain('Updated rules here.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders dash for empty post text', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
posts: [MOCK_POSTS[2]],
|
||||||
|
postsFiltered: [MOCK_POSTS[2]]
|
||||||
|
});
|
||||||
|
const preElements = wrapper.findAll('pre');
|
||||||
|
expect(preElements.some((pre) => pre.text() === '-')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders post image when imageUrl exists', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
const images = wrapper.findAll('img');
|
||||||
|
expect(
|
||||||
|
images.some(
|
||||||
|
(img) => img.attributes('src') === 'https://img/post1.png'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not render image for posts without imageUrl', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
posts: [MOCK_POSTS[1]],
|
||||||
|
postsFiltered: [MOCK_POSTS[1]]
|
||||||
|
});
|
||||||
|
expect(wrapper.findAll('img')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders search input', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
expect(
|
||||||
|
wrapper.findComponent({ name: 'InputGroupField' }).exists()
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders empty state when no posts', () => {
|
||||||
|
const wrapper = mountComponent({ posts: [], postsFiltered: [] });
|
||||||
|
expect(wrapper.text()).toContain('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filtered posts', () => {
|
||||||
|
test('renders only filtered posts', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
postsFiltered: [MOCK_POSTS[0]]
|
||||||
|
});
|
||||||
|
const postItems = wrapper.findAll('.cursor-default');
|
||||||
|
// should only render 1 filtered post
|
||||||
|
expect(postItems).toHaveLength(1);
|
||||||
|
expect(wrapper.text()).toContain('Welcome Post');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key) => key
|
||||||
|
}),
|
||||||
|
createI18n: () => ({
|
||||||
|
global: { t: (key) => key },
|
||||||
|
install: vi.fn()
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../shared/utils', () => ({
|
||||||
|
hasGroupPermission: vi.fn((_group, permission) => {
|
||||||
|
if (_group?._mockPermissions) {
|
||||||
|
return _group._mockPermissions.includes(permission);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
import GroupModerationBulkActions from '../GroupModerationBulkActions.vue';
|
||||||
|
|
||||||
|
function mountComponent(props = {}) {
|
||||||
|
return mount(GroupModerationBulkActions, {
|
||||||
|
props: {
|
||||||
|
selectUserId: '',
|
||||||
|
selectedUsersArray: [],
|
||||||
|
selectedRoles: [],
|
||||||
|
note: '',
|
||||||
|
progressCurrent: 0,
|
||||||
|
progressTotal: 0,
|
||||||
|
groupRef: {
|
||||||
|
roles: [
|
||||||
|
{ id: 'role_1', name: 'Admin' },
|
||||||
|
{ id: 'role_2', name: 'Moderator' }
|
||||||
|
],
|
||||||
|
_mockPermissions: [
|
||||||
|
'group-roles-assign',
|
||||||
|
'group-members-manage',
|
||||||
|
'group-members-remove',
|
||||||
|
'group-bans-manage'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AlertTriangle: { template: '<svg class="alert-icon" />' },
|
||||||
|
Trash2: { template: '<svg class="trash-icon" />' },
|
||||||
|
X: { template: '<svg class="x-icon" />' },
|
||||||
|
TooltipWrapper: {
|
||||||
|
template: '<div class="tooltip-stub"><slot /><slot name="content" /></div>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GroupModerationBulkActions.vue', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
test('renders user ID input field', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
expect(wrapper.text()).toContain('dialog.group_member_moderation.user_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders selected users section', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
expect(wrapper.text()).toContain('dialog.group_member_moderation.selected_users');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders roles dropdown with available roles', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
expect(wrapper.text()).toContain('dialog.group_member_moderation.selected_roles');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders action buttons', () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
const text = wrapper.text();
|
||||||
|
expect(text).toContain('dialog.group_member_moderation.add_roles');
|
||||||
|
expect(text).toContain('dialog.group_member_moderation.remove_roles');
|
||||||
|
expect(text).toContain('dialog.group_member_moderation.save_note');
|
||||||
|
expect(text).toContain('dialog.group_member_moderation.kick');
|
||||||
|
expect(text).toContain('dialog.group_member_moderation.ban');
|
||||||
|
expect(text).toContain('dialog.group_member_moderation.unban');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders selected user badges', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
selectedUsersArray: [
|
||||||
|
{ id: 'usr_1', userId: 'usr_1', membershipStatus: 'member', user: { displayName: 'Alice' } },
|
||||||
|
{ id: 'usr_2', userId: 'usr_2', membershipStatus: 'member', user: { displayName: 'Bob' } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).toContain('Alice');
|
||||||
|
expect(wrapper.text()).toContain('Bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows warning tooltip for non-member users', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
selectedUsersArray: [
|
||||||
|
{ id: 'usr_1', userId: 'usr_1', membershipStatus: 'banned', user: { displayName: 'Charlie' } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).toContain('dialog.group_member_moderation.user_isnt_in_group');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not show warning for member users', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
selectedUsersArray: [
|
||||||
|
{ id: 'usr_1', userId: 'usr_1', membershipStatus: 'member', user: { displayName: 'Alice' } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).not.toContain('dialog.group_member_moderation.user_isnt_in_group');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('progress indicator', () => {
|
||||||
|
test('shows progress when progressCurrent > 0', () => {
|
||||||
|
const wrapper = mountComponent({ progressCurrent: 3, progressTotal: 10 });
|
||||||
|
expect(wrapper.text()).toContain('dialog.group_member_moderation.progress');
|
||||||
|
expect(wrapper.text()).toContain('3/10');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows cancel button during progress', () => {
|
||||||
|
const wrapper = mountComponent({ progressCurrent: 3, progressTotal: 10 });
|
||||||
|
expect(wrapper.text()).toContain('dialog.group_member_moderation.cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides progress when not in progress', () => {
|
||||||
|
const wrapper = mountComponent({ progressCurrent: 0 });
|
||||||
|
expect(wrapper.text()).not.toContain('dialog.group_member_moderation.progress');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('button disabled states', () => {
|
||||||
|
test('add/remove roles disabled when no roles selected', () => {
|
||||||
|
const wrapper = mountComponent({ selectedRoles: [] });
|
||||||
|
const addBtn = wrapper.findAll('button').find((b) =>
|
||||||
|
b.text().includes('dialog.group_member_moderation.add_roles')
|
||||||
|
);
|
||||||
|
expect(addBtn.attributes('disabled')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add/remove roles enabled when roles are selected', () => {
|
||||||
|
const wrapper = mountComponent({ selectedRoles: ['role_1'] });
|
||||||
|
const addBtn = wrapper.findAll('button').find((b) =>
|
||||||
|
b.text().includes('dialog.group_member_moderation.add_roles')
|
||||||
|
);
|
||||||
|
expect(addBtn.attributes('disabled')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('action buttons disabled during progress', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
selectedRoles: ['role_1'],
|
||||||
|
progressCurrent: 5,
|
||||||
|
progressTotal: 10
|
||||||
|
});
|
||||||
|
const kickBtn = wrapper.findAll('button').find((b) =>
|
||||||
|
b.text().includes('dialog.group_member_moderation.kick')
|
||||||
|
);
|
||||||
|
expect(kickBtn.attributes('disabled')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select user button disabled when no user ID entered', () => {
|
||||||
|
const wrapper = mountComponent({ selectUserId: '' });
|
||||||
|
const selectBtn = wrapper.findAll('button').find((b) =>
|
||||||
|
b.text().includes('dialog.group_member_moderation.select_user')
|
||||||
|
);
|
||||||
|
expect(selectBtn.attributes('disabled')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select user button enabled when user ID is entered', () => {
|
||||||
|
const wrapper = mountComponent({ selectUserId: 'usr_test' });
|
||||||
|
const selectBtn = wrapper.findAll('button').find((b) =>
|
||||||
|
b.text().includes('dialog.group_member_moderation.select_user')
|
||||||
|
);
|
||||||
|
expect(selectBtn.attributes('disabled')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('permissions', () => {
|
||||||
|
test('disables kick when missing group-members-remove permission', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
groupRef: {
|
||||||
|
roles: [],
|
||||||
|
_mockPermissions: ['group-bans-manage']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const kickBtn = wrapper.findAll('button').find((b) =>
|
||||||
|
b.text().includes('dialog.group_member_moderation.kick')
|
||||||
|
);
|
||||||
|
expect(kickBtn.attributes('disabled')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disables ban/unban when missing group-bans-manage permission', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
groupRef: {
|
||||||
|
roles: [],
|
||||||
|
_mockPermissions: ['group-members-remove']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const banBtn = wrapper.findAll('button').find((b) =>
|
||||||
|
b.text().includes('dialog.group_member_moderation.ban')
|
||||||
|
);
|
||||||
|
const unbanBtn = wrapper.findAll('button').find((b) =>
|
||||||
|
b.text().includes('dialog.group_member_moderation.unban')
|
||||||
|
);
|
||||||
|
expect(banBtn.attributes('disabled')).toBeDefined();
|
||||||
|
expect(unbanBtn.attributes('disabled')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('events', () => {
|
||||||
|
test('emits select-user on select button click', async () => {
|
||||||
|
const wrapper = mountComponent({ selectUserId: 'usr_test' });
|
||||||
|
const selectBtn = wrapper.findAll('button').find((b) =>
|
||||||
|
b.text().includes('dialog.group_member_moderation.select_user')
|
||||||
|
);
|
||||||
|
await selectBtn.trigger('click');
|
||||||
|
expect(wrapper.emitted('select-user')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits clear-all on trash button click', async () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
// The trash button is the rounded-full icon-sm button after "selected_users" label
|
||||||
|
const buttons = wrapper.findAll('button');
|
||||||
|
const trashBtn = buttons.find((b) => {
|
||||||
|
const classes = b.classes();
|
||||||
|
return classes.includes('rounded-full');
|
||||||
|
});
|
||||||
|
await trashBtn.trigger('click');
|
||||||
|
expect(wrapper.emitted('clear-all')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits delete-user when removing a selected user', async () => {
|
||||||
|
const user = { id: 'usr_1', userId: 'usr_1', membershipStatus: 'member', user: { displayName: 'Alice' } };
|
||||||
|
const wrapper = mountComponent({ selectedUsersArray: [user] });
|
||||||
|
// The X button is a native <button type="button"> inside each Badge
|
||||||
|
const deleteBtn = wrapper.find('button[type="button"]');
|
||||||
|
await deleteBtn.trigger('click');
|
||||||
|
expect(wrapper.emitted('delete-user')?.[0]?.[0]).toEqual(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits ban on ban button click', async () => {
|
||||||
|
const wrapper = mountComponent();
|
||||||
|
const banBtn = wrapper.findAll('button').find((b) =>
|
||||||
|
b.text().includes('dialog.group_member_moderation.ban')
|
||||||
|
);
|
||||||
|
await banBtn.trigger('click');
|
||||||
|
expect(wrapper.emitted('ban')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits cancel-progress on cancel click', async () => {
|
||||||
|
const wrapper = mountComponent({ progressCurrent: 3, progressTotal: 10 });
|
||||||
|
const cancelBtn = wrapper.findAll('button').find((b) =>
|
||||||
|
b.text().includes('dialog.group_member_moderation.cancel')
|
||||||
|
);
|
||||||
|
await cancelBtn.trigger('click');
|
||||||
|
expect(wrapper.emitted('cancel-progress')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useGroupCalendarEvents } from '../useGroupCalendarEvents';
|
||||||
|
|
||||||
|
function createGroupDialog(calendar = []) {
|
||||||
|
return ref({
|
||||||
|
calendar
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAST_DATE = '2020-01-01T00:00:00Z';
|
||||||
|
const FUTURE_DATE = '2099-12-31T23:59:59Z';
|
||||||
|
|
||||||
|
describe('useGroupCalendarEvents', () => {
|
||||||
|
describe('pastCalenderEvents', () => {
|
||||||
|
test('returns empty array when calendar is null', () => {
|
||||||
|
const groupDialog = ref({ calendar: null });
|
||||||
|
const { pastCalenderEvents } = useGroupCalendarEvents(groupDialog);
|
||||||
|
expect(pastCalenderEvents.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty array when calendar is undefined', () => {
|
||||||
|
const groupDialog = ref({});
|
||||||
|
const { pastCalenderEvents } = useGroupCalendarEvents(groupDialog);
|
||||||
|
expect(pastCalenderEvents.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty array when no past events exist', () => {
|
||||||
|
const groupDialog = createGroupDialog([
|
||||||
|
{ id: '1', endsAt: FUTURE_DATE }
|
||||||
|
]);
|
||||||
|
const { pastCalenderEvents } = useGroupCalendarEvents(groupDialog);
|
||||||
|
expect(pastCalenderEvents.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns only past events', () => {
|
||||||
|
const groupDialog = createGroupDialog([
|
||||||
|
{ id: '1', endsAt: PAST_DATE },
|
||||||
|
{ id: '2', endsAt: FUTURE_DATE },
|
||||||
|
{ id: '3', endsAt: PAST_DATE }
|
||||||
|
]);
|
||||||
|
const { pastCalenderEvents } = useGroupCalendarEvents(groupDialog);
|
||||||
|
expect(pastCalenderEvents.value).toHaveLength(2);
|
||||||
|
expect(pastCalenderEvents.value.map((e) => e.id)).toEqual([
|
||||||
|
'1',
|
||||||
|
'3'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is reactive to calendar changes', () => {
|
||||||
|
const groupDialog = createGroupDialog([]);
|
||||||
|
const { pastCalenderEvents } = useGroupCalendarEvents(groupDialog);
|
||||||
|
expect(pastCalenderEvents.value).toHaveLength(0);
|
||||||
|
|
||||||
|
groupDialog.value.calendar = [{ id: '1', endsAt: PAST_DATE }];
|
||||||
|
expect(pastCalenderEvents.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upcomingCalenderEvents', () => {
|
||||||
|
test('returns empty array when calendar is null', () => {
|
||||||
|
const groupDialog = ref({ calendar: null });
|
||||||
|
const { upcomingCalenderEvents } =
|
||||||
|
useGroupCalendarEvents(groupDialog);
|
||||||
|
expect(upcomingCalenderEvents.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty array when no upcoming events exist', () => {
|
||||||
|
const groupDialog = createGroupDialog([
|
||||||
|
{ id: '1', endsAt: PAST_DATE }
|
||||||
|
]);
|
||||||
|
const { upcomingCalenderEvents } =
|
||||||
|
useGroupCalendarEvents(groupDialog);
|
||||||
|
expect(upcomingCalenderEvents.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns only upcoming events', () => {
|
||||||
|
const groupDialog = createGroupDialog([
|
||||||
|
{ id: '1', endsAt: PAST_DATE },
|
||||||
|
{ id: '2', endsAt: FUTURE_DATE },
|
||||||
|
{ id: '3', endsAt: FUTURE_DATE }
|
||||||
|
]);
|
||||||
|
const { upcomingCalenderEvents } =
|
||||||
|
useGroupCalendarEvents(groupDialog);
|
||||||
|
expect(upcomingCalenderEvents.value).toHaveLength(2);
|
||||||
|
expect(upcomingCalenderEvents.value.map((e) => e.id)).toEqual([
|
||||||
|
'2',
|
||||||
|
'3'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('past and upcoming are mutually exclusive', () => {
|
||||||
|
const events = [
|
||||||
|
{ id: '1', endsAt: PAST_DATE },
|
||||||
|
{ id: '2', endsAt: FUTURE_DATE }
|
||||||
|
];
|
||||||
|
const groupDialog = createGroupDialog(events);
|
||||||
|
const { pastCalenderEvents, upcomingCalenderEvents } =
|
||||||
|
useGroupCalendarEvents(groupDialog);
|
||||||
|
|
||||||
|
const allIds = [
|
||||||
|
...pastCalenderEvents.value.map((e) => e.id),
|
||||||
|
...upcomingCalenderEvents.value.map((e) => e.id)
|
||||||
|
];
|
||||||
|
expect(allIds).toHaveLength(2);
|
||||||
|
expect(new Set(allIds).size).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateFollowingCalendarData', () => {
|
||||||
|
test('updates an existing event by id', () => {
|
||||||
|
const groupDialog = createGroupDialog([
|
||||||
|
{ id: '1', title: 'Old Title', endsAt: FUTURE_DATE },
|
||||||
|
{ id: '2', title: 'Other', endsAt: FUTURE_DATE }
|
||||||
|
]);
|
||||||
|
const { updateFollowingCalendarData } =
|
||||||
|
useGroupCalendarEvents(groupDialog);
|
||||||
|
|
||||||
|
updateFollowingCalendarData({ id: '1', title: 'New Title' });
|
||||||
|
|
||||||
|
expect(groupDialog.value.calendar[0].title).toBe('New Title');
|
||||||
|
expect(groupDialog.value.calendar[0].endsAt).toBe(FUTURE_DATE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not modify other events', () => {
|
||||||
|
const groupDialog = createGroupDialog([
|
||||||
|
{ id: '1', title: 'Event 1', endsAt: FUTURE_DATE },
|
||||||
|
{ id: '2', title: 'Event 2', endsAt: FUTURE_DATE }
|
||||||
|
]);
|
||||||
|
const { updateFollowingCalendarData } =
|
||||||
|
useGroupCalendarEvents(groupDialog);
|
||||||
|
|
||||||
|
updateFollowingCalendarData({ id: '1', title: 'Updated' });
|
||||||
|
|
||||||
|
expect(groupDialog.value.calendar[1].title).toBe('Event 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does nothing when event id is not found', () => {
|
||||||
|
const events = [{ id: '1', title: 'Event 1', endsAt: FUTURE_DATE }];
|
||||||
|
const groupDialog = createGroupDialog([...events]);
|
||||||
|
const { updateFollowingCalendarData } =
|
||||||
|
useGroupCalendarEvents(groupDialog);
|
||||||
|
|
||||||
|
updateFollowingCalendarData({
|
||||||
|
id: 'nonexistent',
|
||||||
|
title: 'Updated'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groupDialog.value.calendar[0].title).toBe('Event 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merges new properties into the event', () => {
|
||||||
|
const groupDialog = createGroupDialog([
|
||||||
|
{ id: '1', title: 'Event', endsAt: FUTURE_DATE }
|
||||||
|
]);
|
||||||
|
const { updateFollowingCalendarData } =
|
||||||
|
useGroupCalendarEvents(groupDialog);
|
||||||
|
|
||||||
|
updateFollowingCalendarData({
|
||||||
|
id: '1',
|
||||||
|
userInterest: { isFollowing: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groupDialog.value.calendar[0].title).toBe('Event');
|
||||||
|
expect(groupDialog.value.calendar[0].userInterest.isFollowing).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
vi.mock('../../../../api', () => ({
|
||||||
|
groupRequest: {
|
||||||
|
getCachedGroupGallery: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useGroupGalleries } from '../useGroupGalleries';
|
||||||
|
import { groupRequest } from '../../../../api';
|
||||||
|
|
||||||
|
function createGroupDialog(overrides = {}) {
|
||||||
|
return ref({
|
||||||
|
id: 'grp_1',
|
||||||
|
ref: {
|
||||||
|
galleries: []
|
||||||
|
},
|
||||||
|
galleries: {},
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useGroupGalleries', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupGalleryTabs', () => {
|
||||||
|
test('returns empty array when no galleries', () => {
|
||||||
|
const groupDialog = createGroupDialog();
|
||||||
|
const { groupGalleryTabs } = useGroupGalleries(groupDialog);
|
||||||
|
expect(groupGalleryTabs.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maps galleries to tabs with index values', () => {
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
ref: {
|
||||||
|
galleries: [
|
||||||
|
{ id: 'g1', name: 'Photos' },
|
||||||
|
{ id: 'g2', name: 'Screenshots' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { groupGalleryTabs } = useGroupGalleries(groupDialog);
|
||||||
|
expect(groupGalleryTabs.value).toEqual([
|
||||||
|
{ value: '0', label: 'Photos' },
|
||||||
|
{ value: '1', label: 'Screenshots' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles galleries with null name', () => {
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
ref: {
|
||||||
|
galleries: [{ id: 'g1', name: null }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { groupGalleryTabs } = useGroupGalleries(groupDialog);
|
||||||
|
expect(groupGalleryTabs.value).toEqual([{ value: '0', label: '' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is reactive to gallery changes', () => {
|
||||||
|
const groupDialog = createGroupDialog();
|
||||||
|
const { groupGalleryTabs } = useGroupGalleries(groupDialog);
|
||||||
|
expect(groupGalleryTabs.value).toHaveLength(0);
|
||||||
|
|
||||||
|
groupDialog.value.ref.galleries = [
|
||||||
|
{ id: 'g1', name: 'New Gallery' }
|
||||||
|
];
|
||||||
|
expect(groupGalleryTabs.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupGalleryStatus', () => {
|
||||||
|
test('returns blue for non-members-only gallery', () => {
|
||||||
|
const groupDialog = createGroupDialog();
|
||||||
|
const { groupGalleryStatus } = useGroupGalleries(groupDialog);
|
||||||
|
expect(groupGalleryStatus({ membersOnly: false })).toEqual({
|
||||||
|
blue: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns green for members-only without role restriction', () => {
|
||||||
|
const groupDialog = createGroupDialog();
|
||||||
|
const { groupGalleryStatus } = useGroupGalleries(groupDialog);
|
||||||
|
expect(
|
||||||
|
groupGalleryStatus({ membersOnly: true, roleIdsToView: null })
|
||||||
|
).toEqual({ green: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns red for role-restricted gallery', () => {
|
||||||
|
const groupDialog = createGroupDialog();
|
||||||
|
const { groupGalleryStatus } = useGroupGalleries(groupDialog);
|
||||||
|
expect(
|
||||||
|
groupGalleryStatus({
|
||||||
|
membersOnly: true,
|
||||||
|
roleIdsToView: ['role1']
|
||||||
|
})
|
||||||
|
).toEqual({ red: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGroupGalleries', () => {
|
||||||
|
test('resets galleries and tab before loading', async () => {
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
galleries: { old: [1, 2, 3] }
|
||||||
|
});
|
||||||
|
const { getGroupGalleries, groupDialogGalleryCurrentName } =
|
||||||
|
useGroupGalleries(groupDialog);
|
||||||
|
groupDialogGalleryCurrentName.value = '2';
|
||||||
|
|
||||||
|
await getGroupGalleries();
|
||||||
|
|
||||||
|
expect(groupDialogGalleryCurrentName.value).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets loading state correctly during fetch', async () => {
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
ref: {
|
||||||
|
galleries: [{ id: 'g1', name: 'Gallery' }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
groupRequest.getCachedGroupGallery.mockResolvedValue({
|
||||||
|
json: [],
|
||||||
|
params: { groupId: 'grp_1' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getGroupGalleries, isGroupGalleryLoading } =
|
||||||
|
useGroupGalleries(groupDialog);
|
||||||
|
expect(isGroupGalleryLoading.value).toBe(false);
|
||||||
|
|
||||||
|
const promise = getGroupGalleries();
|
||||||
|
expect(isGroupGalleryLoading.value).toBe(true);
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
expect(isGroupGalleryLoading.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls getCachedGroupGallery for each gallery', async () => {
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
ref: {
|
||||||
|
galleries: [
|
||||||
|
{ id: 'g1', name: 'A' },
|
||||||
|
{ id: 'g2', name: 'B' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
groupRequest.getCachedGroupGallery.mockResolvedValue({
|
||||||
|
json: [],
|
||||||
|
params: { groupId: 'grp_1' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getGroupGalleries } = useGroupGalleries(groupDialog);
|
||||||
|
await getGroupGalleries();
|
||||||
|
|
||||||
|
expect(groupRequest.getCachedGroupGallery).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGroupGallery', () => {
|
||||||
|
test('populates gallery images from API response', async () => {
|
||||||
|
const groupDialog = createGroupDialog();
|
||||||
|
const { getGroupGallery } = useGroupGalleries(groupDialog);
|
||||||
|
|
||||||
|
groupRequest.getCachedGroupGallery.mockResolvedValueOnce({
|
||||||
|
json: [
|
||||||
|
{
|
||||||
|
groupId: 'grp_1',
|
||||||
|
galleryId: 'g1',
|
||||||
|
id: 'img1',
|
||||||
|
imageUrl: 'url1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
groupId: 'grp_1',
|
||||||
|
galleryId: 'g1',
|
||||||
|
id: 'img2',
|
||||||
|
imageUrl: 'url2'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
params: { groupId: 'grp_1' }
|
||||||
|
});
|
||||||
|
|
||||||
|
await getGroupGallery('grp_1', 'g1');
|
||||||
|
|
||||||
|
expect(groupDialog.value.galleries['g1']).toHaveLength(2);
|
||||||
|
expect(groupDialog.value.galleries['g1'][0].id).toBe('img1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores images from different groups', async () => {
|
||||||
|
const groupDialog = createGroupDialog();
|
||||||
|
const { getGroupGallery } = useGroupGalleries(groupDialog);
|
||||||
|
|
||||||
|
groupRequest.getCachedGroupGallery.mockResolvedValueOnce({
|
||||||
|
json: [
|
||||||
|
{
|
||||||
|
groupId: 'grp_other',
|
||||||
|
galleryId: 'g1',
|
||||||
|
id: 'img1',
|
||||||
|
imageUrl: 'url1'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
params: { groupId: 'grp_other' }
|
||||||
|
});
|
||||||
|
|
||||||
|
await getGroupGallery('grp_1', 'g1');
|
||||||
|
|
||||||
|
expect(groupDialog.value.galleries['g1']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stops pagination when fewer than 100 results returned', async () => {
|
||||||
|
const groupDialog = createGroupDialog();
|
||||||
|
const { getGroupGallery } = useGroupGalleries(groupDialog);
|
||||||
|
|
||||||
|
groupRequest.getCachedGroupGallery.mockResolvedValueOnce({
|
||||||
|
json: Array.from({ length: 50 }, (_, i) => ({
|
||||||
|
groupId: 'grp_1',
|
||||||
|
galleryId: 'g1',
|
||||||
|
id: `img${i}`,
|
||||||
|
imageUrl: `url${i}`
|
||||||
|
})),
|
||||||
|
params: { groupId: 'grp_1' }
|
||||||
|
});
|
||||||
|
|
||||||
|
await getGroupGallery('grp_1', 'g1');
|
||||||
|
|
||||||
|
expect(groupRequest.getCachedGroupGallery).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles API errors gracefully', async () => {
|
||||||
|
const groupDialog = createGroupDialog();
|
||||||
|
const { getGroupGallery } = useGroupGalleries(groupDialog);
|
||||||
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
groupRequest.getCachedGroupGallery.mockRejectedValueOnce(
|
||||||
|
new Error('API Error')
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
getGroupGallery('grp_1', 'g1')
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
vi.mock('../../../../api', () => ({
|
||||||
|
groupRequest: {
|
||||||
|
getGroupMembersSearch: vi.fn(),
|
||||||
|
getCachedGroupMember: vi.fn(),
|
||||||
|
getCachedGroupMembers: vi.fn()
|
||||||
|
},
|
||||||
|
userRequest: {}
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../plugin/router', () => {
|
||||||
|
const { ref } = require('vue');
|
||||||
|
return {
|
||||||
|
router: {
|
||||||
|
beforeEach: vi.fn(),
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
currentRoute: ref({ path: '/', name: '', meta: {} }),
|
||||||
|
isReady: vi.fn().mockResolvedValue(true)
|
||||||
|
},
|
||||||
|
initRouter: vi.fn()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
const { ref } = require('vue');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useRouter: vi.fn(() => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
currentRoute: ref({ path: '/', name: '', meta: {} })
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('../../../../plugin/interopApi', () => ({ initInteropApi: vi.fn() }));
|
||||||
|
vi.mock('../../../../service/database', () => ({
|
||||||
|
database: new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: (_target, prop) => {
|
||||||
|
if (prop === '__esModule') return false;
|
||||||
|
return vi.fn().mockResolvedValue(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../service/config', () => ({
|
||||||
|
default: {
|
||||||
|
init: vi.fn(),
|
||||||
|
getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'),
|
||||||
|
setString: vi.fn(),
|
||||||
|
getBool: vi.fn().mockImplementation((_k, d) => d ?? false),
|
||||||
|
setBool: vi.fn(),
|
||||||
|
getInt: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||||
|
setInt: vi.fn(),
|
||||||
|
getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||||
|
setFloat: vi.fn(),
|
||||||
|
getObject: vi.fn().mockReturnValue(null),
|
||||||
|
setObject: vi.fn(),
|
||||||
|
getArray: vi.fn().mockReturnValue([]),
|
||||||
|
setArray: vi.fn(),
|
||||||
|
remove: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../service/jsonStorage', () => ({ default: vi.fn() }));
|
||||||
|
vi.mock('../../../../service/watchState', () => ({
|
||||||
|
watchState: { isLoggedIn: false }
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../service/request', () => ({
|
||||||
|
request: vi.fn().mockResolvedValue({ json: {} }),
|
||||||
|
processBulk: vi.fn(),
|
||||||
|
buildRequestInit: vi.fn(),
|
||||||
|
parseResponse: vi.fn(),
|
||||||
|
shouldIgnoreError: vi.fn(),
|
||||||
|
$throw: vi.fn(),
|
||||||
|
failedGetRequests: new Map()
|
||||||
|
}));
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key) => key
|
||||||
|
}),
|
||||||
|
createI18n: () => ({
|
||||||
|
global: { t: (key) => key },
|
||||||
|
install: vi.fn()
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
vi.mock('worker-timers', () => ({
|
||||||
|
setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
|
||||||
|
clearTimeout: (id) => globalThis.clearTimeout(id)
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useGroupMembers } from '../useGroupMembers';
|
||||||
|
import { groupRequest } from '../../../../api';
|
||||||
|
import { groupDialogFilterOptions } from '../../../../shared/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param overrides
|
||||||
|
*/
|
||||||
|
function createGroupDialog(overrides = {}) {
|
||||||
|
return ref({
|
||||||
|
id: 'grp_1',
|
||||||
|
visible: true,
|
||||||
|
inGroup: false,
|
||||||
|
members: [],
|
||||||
|
memberSearch: '',
|
||||||
|
memberSearchResults: [],
|
||||||
|
memberSortOrder: { value: '' },
|
||||||
|
memberFilter: { id: null, name: 'Everyone' },
|
||||||
|
ref: { roles: [], memberCount: 0 },
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param overrides
|
||||||
|
*/
|
||||||
|
function createDeps(overrides = {}) {
|
||||||
|
return {
|
||||||
|
currentUser: ref({ id: 'usr_me' }),
|
||||||
|
applyGroupMember: vi.fn((json) => json),
|
||||||
|
handleGroupMember: vi.fn(),
|
||||||
|
t: (key) => key,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useGroupMembers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
groupRequest.getCachedGroupMembers.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupDialogMemberSortValue', () => {
|
||||||
|
test('returns current sort order value', () => {
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
memberSortOrder: { value: 'joinedAt:desc', name: 'sort.joined' }
|
||||||
|
});
|
||||||
|
const { groupDialogMemberSortValue } = useGroupMembers(
|
||||||
|
groupDialog,
|
||||||
|
createDeps()
|
||||||
|
);
|
||||||
|
expect(groupDialogMemberSortValue.value).toBe('joinedAt:desc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty string when no sort order', () => {
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
memberSortOrder: {}
|
||||||
|
});
|
||||||
|
const { groupDialogMemberSortValue } = useGroupMembers(
|
||||||
|
groupDialog,
|
||||||
|
createDeps()
|
||||||
|
);
|
||||||
|
expect(groupDialogMemberSortValue.value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupDialogMemberFilterKey', () => {
|
||||||
|
test('returns everyone when filter id is null', () => {
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
memberFilter: { id: null }
|
||||||
|
});
|
||||||
|
const { groupDialogMemberFilterKey } = useGroupMembers(
|
||||||
|
groupDialog,
|
||||||
|
createDeps()
|
||||||
|
);
|
||||||
|
expect(groupDialogMemberFilterKey.value).toBe('everyone');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns usersWithNoRole when filter id is empty string', () => {
|
||||||
|
const groupDialog = createGroupDialog({ memberFilter: { id: '' } });
|
||||||
|
const { groupDialogMemberFilterKey } = useGroupMembers(
|
||||||
|
groupDialog,
|
||||||
|
createDeps()
|
||||||
|
);
|
||||||
|
expect(groupDialogMemberFilterKey.value).toBe('usersWithNoRole');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns role:id for role-based filters', () => {
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
memberFilter: { id: 'role_123' }
|
||||||
|
});
|
||||||
|
const { groupDialogMemberFilterKey } = useGroupMembers(
|
||||||
|
groupDialog,
|
||||||
|
createDeps()
|
||||||
|
);
|
||||||
|
expect(groupDialogMemberFilterKey.value).toBe('role:role_123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when no filter', () => {
|
||||||
|
const groupDialog = createGroupDialog({ memberFilter: null });
|
||||||
|
const { groupDialogMemberFilterKey } = useGroupMembers(
|
||||||
|
groupDialog,
|
||||||
|
createDeps()
|
||||||
|
);
|
||||||
|
expect(groupDialogMemberFilterKey.value).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupDialogMemberFilterGroups', () => {
|
||||||
|
test('includes filter options and role groups', () => {
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
ref: {
|
||||||
|
roles: [
|
||||||
|
{ id: 'role_1', name: 'Admin', defaultRole: false },
|
||||||
|
{ id: 'role_2', name: 'Member', defaultRole: true }
|
||||||
|
],
|
||||||
|
memberCount: 10
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { groupDialogMemberFilterGroups } = useGroupMembers(
|
||||||
|
groupDialog,
|
||||||
|
createDeps()
|
||||||
|
);
|
||||||
|
const groups = groupDialogMemberFilterGroups.value;
|
||||||
|
|
||||||
|
expect(groups.length).toBeGreaterThanOrEqual(1);
|
||||||
|
// should have a filters group
|
||||||
|
const filtersGroup = groups.find((g) => g.key === 'filters');
|
||||||
|
expect(filtersGroup).toBeDefined();
|
||||||
|
expect(filtersGroup.items.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excludes default roles from role items', () => {
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
ref: {
|
||||||
|
roles: [
|
||||||
|
{ id: 'role_1', name: 'Admin', defaultRole: false },
|
||||||
|
{ id: 'role_2', name: 'Default', defaultRole: true }
|
||||||
|
],
|
||||||
|
memberCount: 10
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { groupDialogMemberFilterGroups } = useGroupMembers(
|
||||||
|
groupDialog,
|
||||||
|
createDeps()
|
||||||
|
);
|
||||||
|
const rolesGroup = groupDialogMemberFilterGroups.value.find(
|
||||||
|
(g) => g.key === 'roles'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rolesGroup) {
|
||||||
|
expect(rolesGroup.items).toHaveLength(1);
|
||||||
|
expect(rolesGroup.items[0].label).toBe('Admin');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omits roles group when no non-default roles exist', () => {
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
ref: {
|
||||||
|
roles: [
|
||||||
|
{ id: 'role_1', name: 'Default', defaultRole: true }
|
||||||
|
],
|
||||||
|
memberCount: 10
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { groupDialogMemberFilterGroups } = useGroupMembers(
|
||||||
|
groupDialog,
|
||||||
|
createDeps()
|
||||||
|
);
|
||||||
|
const rolesGroup = groupDialogMemberFilterGroups.value.find(
|
||||||
|
(g) => g.key === 'roles'
|
||||||
|
);
|
||||||
|
expect(rolesGroup).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupMembersSearch', () => {
|
||||||
|
test('clears results when search is less than 3 characters', () => {
|
||||||
|
const groupDialog = createGroupDialog({ memberSearch: 'ab' });
|
||||||
|
const { groupMembersSearch, isGroupMembersLoading } =
|
||||||
|
useGroupMembers(groupDialog, createDeps());
|
||||||
|
|
||||||
|
groupMembersSearch();
|
||||||
|
|
||||||
|
expect(groupDialog.value.memberSearchResults).toEqual([]);
|
||||||
|
expect(isGroupMembersLoading.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls API when search is 3 or more characters', async () => {
|
||||||
|
const groupDialog = createGroupDialog({ memberSearch: 'abc' });
|
||||||
|
groupRequest.getGroupMembersSearch.mockResolvedValue({
|
||||||
|
json: { results: [{ userId: 'usr_1' }] },
|
||||||
|
params: { groupId: 'grp_1' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const deps = createDeps();
|
||||||
|
const { groupMembersSearch } = useGroupMembers(groupDialog, deps);
|
||||||
|
groupMembersSearch();
|
||||||
|
|
||||||
|
// wait for the debounced call
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(groupRequest.getGroupMembersSearch).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
groupId: 'grp_1',
|
||||||
|
query: 'abc',
|
||||||
|
n: 100,
|
||||||
|
offset: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadMoreGroupMembers', () => {
|
||||||
|
test('does not load when already done', async () => {
|
||||||
|
const groupDialog = createGroupDialog();
|
||||||
|
const { loadMoreGroupMembers, isGroupMembersDone } =
|
||||||
|
useGroupMembers(groupDialog, createDeps());
|
||||||
|
isGroupMembersDone.value = true;
|
||||||
|
|
||||||
|
await loadMoreGroupMembers();
|
||||||
|
|
||||||
|
expect(groupRequest.getCachedGroupMembers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not load when already loading', async () => {
|
||||||
|
const groupDialog = createGroupDialog();
|
||||||
|
const { loadMoreGroupMembers, isGroupMembersLoading } =
|
||||||
|
useGroupMembers(groupDialog, createDeps());
|
||||||
|
isGroupMembersLoading.value = true;
|
||||||
|
|
||||||
|
await loadMoreGroupMembers();
|
||||||
|
|
||||||
|
expect(groupRequest.getCachedGroupMembers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks done when fewer than n results returned', async () => {
|
||||||
|
const groupDialog = createGroupDialog();
|
||||||
|
groupRequest.getCachedGroupMembers.mockResolvedValue({
|
||||||
|
json: [{ userId: 'usr_1' }],
|
||||||
|
params: { groupId: 'grp_1', n: 100, offset: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadMoreGroupMembers,
|
||||||
|
isGroupMembersDone,
|
||||||
|
loadMoreGroupMembersParams
|
||||||
|
} = useGroupMembers(groupDialog, createDeps());
|
||||||
|
|
||||||
|
loadMoreGroupMembersParams.value = {
|
||||||
|
n: 100,
|
||||||
|
offset: 0,
|
||||||
|
groupId: 'grp_1',
|
||||||
|
sort: 'joinedAt:desc'
|
||||||
|
};
|
||||||
|
|
||||||
|
await loadMoreGroupMembers();
|
||||||
|
|
||||||
|
expect(isGroupMembersDone.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('appends members to groupDialog.members', async () => {
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
members: [{ userId: 'existing' }]
|
||||||
|
});
|
||||||
|
groupRequest.getCachedGroupMembers.mockResolvedValue({
|
||||||
|
json: [{ userId: 'usr_new' }],
|
||||||
|
params: { groupId: 'grp_1', n: 100, offset: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { loadMoreGroupMembers, loadMoreGroupMembersParams } =
|
||||||
|
useGroupMembers(groupDialog, createDeps());
|
||||||
|
|
||||||
|
loadMoreGroupMembersParams.value = {
|
||||||
|
n: 100,
|
||||||
|
offset: 0,
|
||||||
|
groupId: 'grp_1',
|
||||||
|
sort: 'joinedAt:desc'
|
||||||
|
};
|
||||||
|
|
||||||
|
await loadMoreGroupMembers();
|
||||||
|
|
||||||
|
expect(groupDialog.value.members).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes duplicate current user from first position', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
members: [{ userId: 'usr_me' }]
|
||||||
|
});
|
||||||
|
groupRequest.getCachedGroupMembers.mockResolvedValue({
|
||||||
|
json: [{ userId: 'usr_me' }, { userId: 'usr_2' }],
|
||||||
|
params: { groupId: 'grp_1', n: 100, offset: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { loadMoreGroupMembers, loadMoreGroupMembersParams } =
|
||||||
|
useGroupMembers(groupDialog, deps);
|
||||||
|
|
||||||
|
loadMoreGroupMembersParams.value = {
|
||||||
|
n: 100,
|
||||||
|
offset: 0,
|
||||||
|
groupId: 'grp_1',
|
||||||
|
sort: 'joinedAt:desc'
|
||||||
|
};
|
||||||
|
|
||||||
|
await loadMoreGroupMembers();
|
||||||
|
|
||||||
|
// duplicate at position 0 should be removed
|
||||||
|
const userIds = groupDialog.value.members.map((m) => m.userId);
|
||||||
|
expect(userIds).toEqual(['usr_me', 'usr_2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks done on error', async () => {
|
||||||
|
const groupDialog = createGroupDialog();
|
||||||
|
groupRequest.getCachedGroupMembers.mockRejectedValue(
|
||||||
|
new Error('fail')
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadMoreGroupMembers,
|
||||||
|
isGroupMembersDone,
|
||||||
|
loadMoreGroupMembersParams
|
||||||
|
} = useGroupMembers(groupDialog, createDeps());
|
||||||
|
|
||||||
|
loadMoreGroupMembersParams.value = {
|
||||||
|
n: 100,
|
||||||
|
offset: 0,
|
||||||
|
groupId: 'grp_1',
|
||||||
|
sort: 'joinedAt:desc'
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(loadMoreGroupMembers()).rejects.toThrow('fail');
|
||||||
|
expect(isGroupMembersDone.value).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setGroupMemberSortOrder', () => {
|
||||||
|
test('does not reload when sort order unchanged', async () => {
|
||||||
|
const groupDialog = createGroupDialog({
|
||||||
|
memberSortOrder: { value: 'joinedAt:desc' }
|
||||||
|
});
|
||||||
|
const { setGroupMemberSortOrder } = useGroupMembers(
|
||||||
|
groupDialog,
|
||||||
|
createDeps()
|
||||||
|
);
|
||||||
|
|
||||||
|
await setGroupMemberSortOrder({ value: 'joinedAt:desc' });
|
||||||
|
|
||||||
|
expect(groupRequest.getCachedGroupMembers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setGroupMemberFilter', () => {
|
||||||
|
test('does not reload when filter unchanged', async () => {
|
||||||
|
const { markRaw } = require('vue');
|
||||||
|
const filter = markRaw(groupDialogFilterOptions.everyone);
|
||||||
|
const groupDialog = createGroupDialog();
|
||||||
|
// Use markRaw to prevent Vue from wrapping the filter in a Proxy
|
||||||
|
groupDialog.value.memberFilter = filter;
|
||||||
|
const { setGroupMemberFilter } = useGroupMembers(
|
||||||
|
groupDialog,
|
||||||
|
createDeps()
|
||||||
|
);
|
||||||
|
|
||||||
|
await setGroupMemberFilter(filter);
|
||||||
|
|
||||||
|
expect(groupRequest.getCachedGroupMembers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
import { reactive, ref } from 'vue';
|
||||||
|
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key) => key
|
||||||
|
}),
|
||||||
|
createI18n: () => ({
|
||||||
|
global: { t: (key) => key },
|
||||||
|
install: vi.fn()
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../plugin/router', () => {
|
||||||
|
const { ref: vRef } = require('vue');
|
||||||
|
return {
|
||||||
|
router: {
|
||||||
|
beforeEach: vi.fn(),
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
currentRoute: vRef({ path: '/', name: '', meta: {} }),
|
||||||
|
isReady: vi.fn().mockResolvedValue(true)
|
||||||
|
},
|
||||||
|
initRouter: vi.fn()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
const { ref: vRef } = require('vue');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useRouter: vi.fn(() => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
currentRoute: vRef({ path: '/', name: '', meta: {} })
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('../../../../plugin/interopApi', () => ({ initInteropApi: vi.fn() }));
|
||||||
|
vi.mock('../../../../service/database', () => ({
|
||||||
|
database: new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: (_target, prop) => {
|
||||||
|
if (prop === '__esModule') return false;
|
||||||
|
return vi.fn().mockResolvedValue(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../service/config', () => ({
|
||||||
|
default: {
|
||||||
|
init: vi.fn(),
|
||||||
|
getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'),
|
||||||
|
setString: vi.fn(),
|
||||||
|
getBool: vi.fn().mockImplementation((_k, d) => d ?? false),
|
||||||
|
setBool: vi.fn(),
|
||||||
|
getInt: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||||
|
setInt: vi.fn(),
|
||||||
|
getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||||
|
setFloat: vi.fn(),
|
||||||
|
getObject: vi.fn().mockReturnValue(null),
|
||||||
|
setObject: vi.fn(),
|
||||||
|
getArray: vi.fn().mockReturnValue([]),
|
||||||
|
setArray: vi.fn(),
|
||||||
|
remove: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../service/jsonStorage', () => ({ default: vi.fn() }));
|
||||||
|
vi.mock('../../../../service/watchState', () => ({
|
||||||
|
watchState: { isLoggedIn: false }
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../service/request', () => ({
|
||||||
|
request: vi.fn().mockResolvedValue({ json: {} }),
|
||||||
|
processBulk: vi.fn(),
|
||||||
|
buildRequestInit: vi.fn(),
|
||||||
|
parseResponse: vi.fn(),
|
||||||
|
shouldIgnoreError: vi.fn(),
|
||||||
|
$throw: vi.fn(),
|
||||||
|
failedGetRequests: new Map()
|
||||||
|
}));
|
||||||
|
vi.mock('../../../../api', () => ({
|
||||||
|
groupRequest: {},
|
||||||
|
userRequest: {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useGroupModerationData } from '../useGroupModerationData';
|
||||||
|
|
||||||
|
function createTables() {
|
||||||
|
return {
|
||||||
|
members: reactive({ data: [], pageSize: 15 }),
|
||||||
|
bans: reactive({ data: [], filters: [{ prop: ['$displayName'], value: '' }], pageSize: 15 }),
|
||||||
|
invites: reactive({ data: [], pageSize: 15 }),
|
||||||
|
joinRequests: reactive({ data: [], pageSize: 15 }),
|
||||||
|
blocked: reactive({ data: [], pageSize: 15 }),
|
||||||
|
logs: reactive({ data: [], filters: [{ prop: ['description'], value: '' }], pageSize: 15 })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeps(overrides = {}) {
|
||||||
|
const tables = createTables();
|
||||||
|
return {
|
||||||
|
groupMemberModeration: ref({
|
||||||
|
id: 'grp_test',
|
||||||
|
visible: true,
|
||||||
|
groupRef: { memberCount: 10, roles: [] }
|
||||||
|
}),
|
||||||
|
currentUser: ref({ id: 'usr_self' }),
|
||||||
|
applyGroupMember: vi.fn((json) => json),
|
||||||
|
handleGroupMember: vi.fn(),
|
||||||
|
tables,
|
||||||
|
selection: {
|
||||||
|
selectedUsers: {},
|
||||||
|
setSelectedUsers: vi.fn()
|
||||||
|
},
|
||||||
|
groupRequest: {
|
||||||
|
getGroupBans: vi.fn(),
|
||||||
|
getGroupLogs: vi.fn(),
|
||||||
|
getGroupInvites: vi.fn(),
|
||||||
|
getGroupJoinRequests: vi.fn(),
|
||||||
|
getGroupMember: vi.fn(),
|
||||||
|
getGroupMembers: vi.fn(),
|
||||||
|
getGroupMembersSearch: vi.fn()
|
||||||
|
},
|
||||||
|
userRequest: {
|
||||||
|
getCachedUser: vi.fn()
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useGroupModerationData', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllGroupBans', () => {
|
||||||
|
test('populates bans table with fetched data', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
const bans = [
|
||||||
|
{ userId: 'usr_1', user: { displayName: 'Alice' } },
|
||||||
|
{ userId: 'usr_2', user: { displayName: 'Bob' } }
|
||||||
|
];
|
||||||
|
deps.groupRequest.getGroupBans.mockResolvedValue({
|
||||||
|
json: bans,
|
||||||
|
params: { groupId: 'grp_test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getAllGroupBans } = useGroupModerationData(deps);
|
||||||
|
await getAllGroupBans('grp_test');
|
||||||
|
|
||||||
|
expect(deps.tables.bans.data).toHaveLength(2);
|
||||||
|
expect(deps.groupRequest.getGroupBans).toHaveBeenCalledWith({
|
||||||
|
groupId: 'grp_test',
|
||||||
|
n: 100,
|
||||||
|
offset: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('paginates through multiple pages', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
const page1 = Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
userId: `usr_${i}`,
|
||||||
|
user: { displayName: `User${i}` }
|
||||||
|
}));
|
||||||
|
const page2 = [{ userId: 'usr_100', user: { displayName: 'User100' } }];
|
||||||
|
|
||||||
|
deps.groupRequest.getGroupBans
|
||||||
|
.mockResolvedValueOnce({ json: page1, params: { groupId: 'grp_test' } })
|
||||||
|
.mockResolvedValueOnce({ json: page2, params: { groupId: 'grp_test' } });
|
||||||
|
|
||||||
|
const { getAllGroupBans } = useGroupModerationData(deps);
|
||||||
|
await getAllGroupBans('grp_test');
|
||||||
|
|
||||||
|
expect(deps.tables.bans.data).toHaveLength(101);
|
||||||
|
expect(deps.groupRequest.getGroupBans).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips data from wrong group', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
deps.groupRequest.getGroupBans.mockResolvedValue({
|
||||||
|
json: [{ userId: 'usr_1' }],
|
||||||
|
params: { groupId: 'grp_other' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getAllGroupBans } = useGroupModerationData(deps);
|
||||||
|
await getAllGroupBans('grp_test');
|
||||||
|
|
||||||
|
// Should have continued past the mismatched group and eventually exhausted pages
|
||||||
|
// The data won't contain the mismatched entry because it was skipped
|
||||||
|
expect(deps.tables.bans.data).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles API error gracefully', async () => {
|
||||||
|
const { toast } = await import('vue-sonner');
|
||||||
|
const deps = createDeps();
|
||||||
|
deps.groupRequest.getGroupBans.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
const { getAllGroupBans, isGroupMembersLoading } = useGroupModerationData(deps);
|
||||||
|
await getAllGroupBans('grp_test');
|
||||||
|
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Failed to get group bans');
|
||||||
|
expect(isGroupMembersLoading.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stops when dialog is no longer visible', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
const page1 = Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
userId: `usr_${i}`
|
||||||
|
}));
|
||||||
|
deps.groupRequest.getGroupBans
|
||||||
|
.mockResolvedValueOnce({ json: page1, params: { groupId: 'grp_test' } })
|
||||||
|
.mockImplementation(() => {
|
||||||
|
deps.groupMemberModeration.value.visible = false;
|
||||||
|
return Promise.resolve({ json: [{ userId: 'usr_extra' }], params: { groupId: 'grp_test' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getAllGroupBans } = useGroupModerationData(deps);
|
||||||
|
await getAllGroupBans('grp_test');
|
||||||
|
|
||||||
|
// Should stop after detecting visible=false
|
||||||
|
expect(deps.groupRequest.getGroupBans).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllGroupLogs', () => {
|
||||||
|
test('populates logs table and deduplicates', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
const logs = [
|
||||||
|
{ id: 'log_1', description: 'event 1' },
|
||||||
|
{ id: 'log_2', description: 'event 2' },
|
||||||
|
{ id: 'log_1', description: 'event 1 dup' }
|
||||||
|
];
|
||||||
|
deps.groupRequest.getGroupLogs.mockResolvedValue({
|
||||||
|
json: { results: logs, hasNext: false },
|
||||||
|
params: { groupId: 'grp_test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getAllGroupLogs } = useGroupModerationData(deps);
|
||||||
|
await getAllGroupLogs('grp_test');
|
||||||
|
|
||||||
|
expect(deps.tables.logs.data).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes eventTypes filter when provided', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
deps.groupRequest.getGroupLogs.mockResolvedValue({
|
||||||
|
json: { results: [], hasNext: false },
|
||||||
|
params: { groupId: 'grp_test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getAllGroupLogs } = useGroupModerationData(deps);
|
||||||
|
await getAllGroupLogs('grp_test', ['group.member.ban', 'group.member.kick']);
|
||||||
|
|
||||||
|
expect(deps.groupRequest.getGroupLogs).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
eventTypes: ['group.member.ban', 'group.member.kick']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllGroupInvitesAndJoinRequests', () => {
|
||||||
|
test('fetches invites, join requests, and blocked in parallel', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
deps.groupRequest.getGroupInvites.mockResolvedValue({
|
||||||
|
json: [{ userId: 'usr_inv' }],
|
||||||
|
params: { groupId: 'grp_test' }
|
||||||
|
});
|
||||||
|
deps.groupRequest.getGroupJoinRequests
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
json: [{ userId: 'usr_join' }],
|
||||||
|
params: { groupId: 'grp_test' }
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
json: [{ userId: 'usr_block' }],
|
||||||
|
params: { groupId: 'grp_test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getAllGroupInvitesAndJoinRequests } = useGroupModerationData(deps);
|
||||||
|
await getAllGroupInvitesAndJoinRequests('grp_test');
|
||||||
|
|
||||||
|
expect(deps.tables.invites.data).toHaveLength(1);
|
||||||
|
expect(deps.tables.joinRequests.data).toHaveLength(1);
|
||||||
|
expect(deps.tables.blocked.data).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selectGroupMemberUserId', () => {
|
||||||
|
test('parses multiple user IDs from input', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
deps.groupRequest.getGroupMember.mockResolvedValue({
|
||||||
|
json: { userId: 'usr_aaaa1111-2222-3333-4444-555566667777', user: { displayName: 'A' } },
|
||||||
|
params: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { selectGroupMemberUserId } = useGroupModerationData(deps);
|
||||||
|
await selectGroupMemberUserId(
|
||||||
|
'usr_aaaa1111-2222-3333-4444-555566667777 usr_bbbb1111-2222-3333-4444-555566667777'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(deps.groupRequest.getGroupMember).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to raw input when no usr_ pattern found', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
deps.groupRequest.getGroupMember.mockResolvedValue({
|
||||||
|
json: { userId: 'some_input', user: { displayName: 'Test' } },
|
||||||
|
params: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { selectGroupMemberUserId } = useGroupModerationData(deps);
|
||||||
|
await selectGroupMemberUserId('some_input');
|
||||||
|
|
||||||
|
expect(deps.groupRequest.getGroupMember).toHaveBeenCalledWith({
|
||||||
|
groupId: 'grp_test',
|
||||||
|
userId: 'some_input'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does nothing with empty input', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
const { selectGroupMemberUserId } = useGroupModerationData(deps);
|
||||||
|
await selectGroupMemberUserId('');
|
||||||
|
|
||||||
|
expect(deps.groupRequest.getGroupMember).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addGroupMemberToSelection', () => {
|
||||||
|
test('uses group member data when available', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
const member = { userId: 'usr_1', user: { displayName: 'Alice' } };
|
||||||
|
deps.groupRequest.getGroupMember.mockResolvedValue({
|
||||||
|
json: member,
|
||||||
|
params: {}
|
||||||
|
});
|
||||||
|
deps.applyGroupMember.mockReturnValue(member);
|
||||||
|
|
||||||
|
const { addGroupMemberToSelection } = useGroupModerationData(deps);
|
||||||
|
await addGroupMemberToSelection('usr_1');
|
||||||
|
|
||||||
|
expect(deps.selection.setSelectedUsers).toHaveBeenCalledWith('usr_1', member);
|
||||||
|
expect(deps.userRequest.getCachedUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to user API when member has no user object', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
deps.groupRequest.getGroupMember.mockResolvedValue({
|
||||||
|
json: { userId: 'usr_1' },
|
||||||
|
params: {}
|
||||||
|
});
|
||||||
|
deps.applyGroupMember.mockReturnValue({ userId: 'usr_1' });
|
||||||
|
deps.userRequest.getCachedUser.mockResolvedValue({
|
||||||
|
json: { id: 'usr_1', displayName: 'Alice' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { addGroupMemberToSelection } = useGroupModerationData(deps);
|
||||||
|
await addGroupMemberToSelection('usr_1');
|
||||||
|
|
||||||
|
expect(deps.userRequest.getCachedUser).toHaveBeenCalledWith({ userId: 'usr_1' });
|
||||||
|
expect(deps.selection.setSelectedUsers).toHaveBeenCalledWith('usr_1', expect.objectContaining({
|
||||||
|
userId: 'usr_1',
|
||||||
|
displayName: 'Alice'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetData', () => {
|
||||||
|
test('clears all table data and search state', () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
deps.tables.members.data = [{ userId: 'usr_1' }];
|
||||||
|
deps.tables.bans.data = [{ userId: 'usr_2' }];
|
||||||
|
|
||||||
|
const { resetData, memberSearch } = useGroupModerationData(deps);
|
||||||
|
memberSearch.value = 'test';
|
||||||
|
resetData();
|
||||||
|
|
||||||
|
expect(deps.tables.members.data).toHaveLength(0);
|
||||||
|
expect(deps.tables.bans.data).toHaveLength(0);
|
||||||
|
expect(deps.tables.invites.data).toHaveLength(0);
|
||||||
|
expect(deps.tables.joinRequests.data).toHaveLength(0);
|
||||||
|
expect(deps.tables.blocked.data).toHaveLength(0);
|
||||||
|
expect(deps.tables.logs.data).toHaveLength(0);
|
||||||
|
expect(memberSearch.value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('member search / sort / filter', () => {
|
||||||
|
test('groupMembersSearch clears table when search is too short', () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
deps.tables.members.data = [{ userId: 'usr_1' }];
|
||||||
|
|
||||||
|
const { groupMembersSearch, memberSearch, isGroupMembersLoading } = useGroupModerationData(deps);
|
||||||
|
memberSearch.value = 'ab';
|
||||||
|
groupMembersSearch();
|
||||||
|
|
||||||
|
expect(deps.tables.members.data).toHaveLength(0);
|
||||||
|
expect(isGroupMembersLoading.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setGroupMemberSortOrder does nothing when sort is the same', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
deps.groupRequest.getGroupMember.mockResolvedValue({ json: null, params: {} });
|
||||||
|
|
||||||
|
const { setGroupMemberSortOrder, memberSortOrder } = useGroupModerationData(deps);
|
||||||
|
const currentSort = memberSortOrder.value;
|
||||||
|
await setGroupMemberSortOrder(currentSort);
|
||||||
|
|
||||||
|
// getGroupMember should not have been called since sort didn't change
|
||||||
|
expect(deps.groupRequest.getGroupMember).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setGroupMemberFilter does nothing when filter is the same', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
|
||||||
|
const { setGroupMemberFilter, memberFilter } = useGroupModerationData(deps);
|
||||||
|
const currentFilter = memberFilter.value;
|
||||||
|
await setGroupMemberFilter(currentFilter);
|
||||||
|
|
||||||
|
expect(deps.groupRequest.getGroupMember).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadAllGroupMembers', () => {
|
||||||
|
test('does nothing when already loading', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
const { loadAllGroupMembers, isGroupMembersLoading } = useGroupModerationData(deps);
|
||||||
|
isGroupMembersLoading.value = true;
|
||||||
|
|
||||||
|
await loadAllGroupMembers();
|
||||||
|
|
||||||
|
expect(deps.groupRequest.getGroupMember).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for filtering group calendar events into past and upcoming,
|
||||||
|
* and updating follow state on individual events.
|
||||||
|
*
|
||||||
|
* @param {import('vue').Ref} groupDialog - reactive ref to the group dialog state
|
||||||
|
* @returns {{
|
||||||
|
* pastCalenderEvents: import('vue').ComputedRef<Array>,
|
||||||
|
* upcomingCalenderEvents: import('vue').ComputedRef<Array>,
|
||||||
|
* updateFollowingCalendarData: (event: Object) => void
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function useGroupCalendarEvents(groupDialog) {
|
||||||
|
const pastCalenderEvents = computed(() => {
|
||||||
|
if (!groupDialog.value.calendar) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
return groupDialog.value.calendar.filter((event) => {
|
||||||
|
const eventEnd = new Date(event.endsAt).getTime();
|
||||||
|
return eventEnd < now;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const upcomingCalenderEvents = computed(() => {
|
||||||
|
if (!groupDialog.value.calendar) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
return groupDialog.value.calendar.filter((event) => {
|
||||||
|
const eventEnd = new Date(event.endsAt).getTime();
|
||||||
|
return eventEnd >= now;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} event
|
||||||
|
*/
|
||||||
|
function updateFollowingCalendarData(event) {
|
||||||
|
const calendar = groupDialog.value.calendar;
|
||||||
|
for (let i = 0; i < calendar.length; i++) {
|
||||||
|
if (calendar[i].id === event.id) {
|
||||||
|
calendar[i] = {
|
||||||
|
...calendar[i],
|
||||||
|
...event
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pastCalenderEvents,
|
||||||
|
upcomingCalenderEvents,
|
||||||
|
updateFollowingCalendarData
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { groupRequest } from '../../../api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for managing group gallery loading and display state.
|
||||||
|
*
|
||||||
|
* @param {import('vue').Ref} groupDialog - reactive ref to the group dialog state
|
||||||
|
* @returns {{
|
||||||
|
* isGroupGalleryLoading: import('vue').Ref<boolean>,
|
||||||
|
* groupDialogGalleryCurrentName: import('vue').Ref<string>,
|
||||||
|
* groupGalleryTabs: import('vue').ComputedRef<Array>,
|
||||||
|
* groupGalleryStatus: (gallery: Object) => Object,
|
||||||
|
* getGroupGalleries: () => Promise<void>,
|
||||||
|
* getGroupGallery: (groupId: string, galleryId: string) => Promise<void>
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function useGroupGalleries(groupDialog) {
|
||||||
|
const groupDialogGalleryCurrentName = ref('0');
|
||||||
|
const isGroupGalleryLoading = ref(false);
|
||||||
|
|
||||||
|
const groupGalleryTabs = computed(() =>
|
||||||
|
(groupDialog.value?.ref?.galleries || []).map((gallery, index) => ({
|
||||||
|
value: String(index),
|
||||||
|
label: gallery?.name ?? ''
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} gallery
|
||||||
|
*/
|
||||||
|
function groupGalleryStatus(gallery) {
|
||||||
|
const style = {};
|
||||||
|
if (!gallery.membersOnly) {
|
||||||
|
style.blue = true;
|
||||||
|
} else if (!gallery.roleIdsToView) {
|
||||||
|
style.green = true;
|
||||||
|
} else {
|
||||||
|
style.red = true;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function updateGroupDialogData(obj) {
|
||||||
|
groupDialog.value = {
|
||||||
|
...groupDialog.value,
|
||||||
|
...obj
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
async function getGroupGalleries() {
|
||||||
|
updateGroupDialogData({ ...groupDialog.value, galleries: {} });
|
||||||
|
groupDialogGalleryCurrentName.value = '0';
|
||||||
|
isGroupGalleryLoading.value = true;
|
||||||
|
const groupId = groupDialog.value.id;
|
||||||
|
const tasks = (groupDialog.value.ref.galleries || []).map((gallery) =>
|
||||||
|
getGroupGallery(groupId, gallery.id)
|
||||||
|
);
|
||||||
|
await Promise.allSettled(tasks);
|
||||||
|
isGroupGalleryLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} groupId
|
||||||
|
* @param {string} galleryId
|
||||||
|
*/
|
||||||
|
async function getGroupGallery(groupId, galleryId) {
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
groupId,
|
||||||
|
galleryId,
|
||||||
|
n: 100,
|
||||||
|
offset: 0
|
||||||
|
};
|
||||||
|
const count = 50; // 5000 max
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const args = await groupRequest.getCachedGroupGallery(params);
|
||||||
|
if (args) {
|
||||||
|
for (const json of args.json) {
|
||||||
|
if (groupDialog.value.id === json.groupId) {
|
||||||
|
if (!groupDialog.value.galleries[json.galleryId]) {
|
||||||
|
groupDialog.value.galleries[json.galleryId] =
|
||||||
|
[];
|
||||||
|
}
|
||||||
|
groupDialog.value.galleries[json.galleryId].push(
|
||||||
|
json
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params.offset += 100;
|
||||||
|
if (args.json.length < 100) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isGroupGalleryLoading,
|
||||||
|
groupDialogGalleryCurrentName,
|
||||||
|
groupGalleryTabs,
|
||||||
|
groupGalleryStatus,
|
||||||
|
getGroupGalleries,
|
||||||
|
getGroupGallery
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { debounce } from '../../../shared/utils';
|
||||||
|
import {
|
||||||
|
groupDialogFilterOptions,
|
||||||
|
groupDialogSortingOptions
|
||||||
|
} from '../../../shared/constants';
|
||||||
|
import { groupRequest } from '../../../api';
|
||||||
|
import * as workerTimers from 'worker-timers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for managing group member loading, searching, sorting, and filtering.
|
||||||
|
*
|
||||||
|
* @param {import('vue').Ref} groupDialog - reactive ref to the group dialog state
|
||||||
|
* @param {Object} deps - external dependencies
|
||||||
|
* @param {import('vue').Ref} deps.currentUser - reactive ref to the current user
|
||||||
|
* @param {Function} deps.applyGroupMember - function to apply group member data
|
||||||
|
* @param {Function} deps.handleGroupMember - function to handle group member updates
|
||||||
|
* @param {Function} deps.t - i18n translation function
|
||||||
|
* @returns {Object} members composable API
|
||||||
|
*/
|
||||||
|
export function useGroupMembers(
|
||||||
|
groupDialog,
|
||||||
|
{ currentUser, applyGroupMember, handleGroupMember, t }
|
||||||
|
) {
|
||||||
|
const isGroupMembersDone = ref(false);
|
||||||
|
const isGroupMembersLoading = ref(false);
|
||||||
|
|
||||||
|
let loadMoreGroupMembersParams = ref({
|
||||||
|
n: 100,
|
||||||
|
offset: 0,
|
||||||
|
groupId: '',
|
||||||
|
sort: '',
|
||||||
|
roleId: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupDialogMemberSortValue = computed({
|
||||||
|
get() {
|
||||||
|
return groupDialog.value?.memberSortOrder?.value ?? '';
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
const option = Object.values(groupDialogSortingOptions).find(
|
||||||
|
(item) => item.value === value
|
||||||
|
);
|
||||||
|
if (option) {
|
||||||
|
setGroupMemberSortOrder(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupDialogMemberFilterKey = computed({
|
||||||
|
get() {
|
||||||
|
const filter = groupDialog.value?.memberFilter;
|
||||||
|
if (!filter) return null;
|
||||||
|
|
||||||
|
if (filter.id === null) return 'everyone';
|
||||||
|
if (filter.id === '') return 'usersWithNoRole';
|
||||||
|
return `role:${filter.id}`;
|
||||||
|
},
|
||||||
|
set(key) {
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
if (key === 'everyone') {
|
||||||
|
setGroupMemberFilter(groupDialogFilterOptions.everyone);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'usersWithNoRole') {
|
||||||
|
setGroupMemberFilter(groupDialogFilterOptions.usersWithNoRole);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.startsWith('role:')) {
|
||||||
|
const roleId = key.slice('role:'.length);
|
||||||
|
const role = groupDialog.value?.ref?.roles?.find(
|
||||||
|
(r) => r.id === roleId
|
||||||
|
);
|
||||||
|
if (role) {
|
||||||
|
setGroupMemberFilter(role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupDialogMemberFilterGroups = computed(() => {
|
||||||
|
const filterItems = Object.values(groupDialogFilterOptions).map(
|
||||||
|
(item) => ({
|
||||||
|
value:
|
||||||
|
item.id === null
|
||||||
|
? 'everyone'
|
||||||
|
: item.id === ''
|
||||||
|
? 'usersWithNoRole'
|
||||||
|
: `role:${item.id}`,
|
||||||
|
label: t(item.name),
|
||||||
|
search: t(item.name)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const roleItems = (groupDialog.value?.ref?.roles ?? [])
|
||||||
|
.filter((role) => !role.defaultRole)
|
||||||
|
.map((role) => ({
|
||||||
|
value: `role:${role.id}`,
|
||||||
|
label: role.name,
|
||||||
|
search: role.name
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'filters',
|
||||||
|
label: t('dialog.group.members.filter'),
|
||||||
|
items: filterItems
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roles',
|
||||||
|
label: 'Roles',
|
||||||
|
items: roleItems
|
||||||
|
}
|
||||||
|
].filter((group) => group.items.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function groupMembersSearch() {
|
||||||
|
if (groupDialog.value.memberSearch.length < 3) {
|
||||||
|
groupDialog.value.memberSearchResults = [];
|
||||||
|
isGroupMembersLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debounce(groupMembersSearchDebounced, 200)();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function groupMembersSearchDebounced() {
|
||||||
|
const D = groupDialog.value;
|
||||||
|
const search = D.memberSearch;
|
||||||
|
D.memberSearchResults = [];
|
||||||
|
if (!search || search.length < 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isGroupMembersLoading.value = true;
|
||||||
|
groupRequest
|
||||||
|
.getGroupMembersSearch({
|
||||||
|
groupId: D.id,
|
||||||
|
query: search,
|
||||||
|
n: 100,
|
||||||
|
offset: 0
|
||||||
|
})
|
||||||
|
.then((args) => {
|
||||||
|
for (const json of args.json.results) {
|
||||||
|
handleGroupMember({
|
||||||
|
json,
|
||||||
|
params: {
|
||||||
|
groupId: args.params.groupId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (D.id === args.params.groupId) {
|
||||||
|
D.memberSearchResults = args.json.results;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isGroupMembersLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
async function getGroupDialogGroupMembers() {
|
||||||
|
const D = groupDialog.value;
|
||||||
|
D.members = [];
|
||||||
|
isGroupMembersDone.value = false;
|
||||||
|
loadMoreGroupMembersParams.value = {
|
||||||
|
sort: 'joinedAt:desc',
|
||||||
|
roleId: '',
|
||||||
|
n: 100,
|
||||||
|
offset: 0,
|
||||||
|
groupId: D.id
|
||||||
|
};
|
||||||
|
if (D.memberSortOrder.value) {
|
||||||
|
loadMoreGroupMembersParams.value.sort = D.memberSortOrder.value;
|
||||||
|
}
|
||||||
|
if (D.memberFilter.id !== null) {
|
||||||
|
loadMoreGroupMembersParams.value.roleId = D.memberFilter.id;
|
||||||
|
}
|
||||||
|
if (D.inGroup) {
|
||||||
|
await groupRequest
|
||||||
|
.getCachedGroupMember({
|
||||||
|
groupId: D.id,
|
||||||
|
userId: currentUser.value.id
|
||||||
|
})
|
||||||
|
.then((args) => {
|
||||||
|
args.ref = applyGroupMember(args.json);
|
||||||
|
if (args.json) {
|
||||||
|
args.json.user = currentUser.value;
|
||||||
|
if (D.memberFilter.id === null) {
|
||||||
|
// when flitered by role don't include self
|
||||||
|
D.members.push(args.json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await loadMoreGroupMembers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
async function loadMoreGroupMembers() {
|
||||||
|
if (isGroupMembersDone.value || isGroupMembersLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const D = groupDialog.value;
|
||||||
|
const params = loadMoreGroupMembersParams.value;
|
||||||
|
if (params.roleId === '') {
|
||||||
|
delete params.roleId;
|
||||||
|
}
|
||||||
|
D.memberSearch = '';
|
||||||
|
isGroupMembersLoading.value = true;
|
||||||
|
await groupRequest
|
||||||
|
.getCachedGroupMembers(params)
|
||||||
|
.finally(() => {
|
||||||
|
isGroupMembersLoading.value = false;
|
||||||
|
})
|
||||||
|
.then((args) => {
|
||||||
|
for (const json of args.json) {
|
||||||
|
handleGroupMember({
|
||||||
|
json,
|
||||||
|
params: {
|
||||||
|
groupId: args.params.groupId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < args.json.length; i++) {
|
||||||
|
const member = args.json[i];
|
||||||
|
if (member.userId === currentUser.value.id) {
|
||||||
|
if (
|
||||||
|
D.members.length > 0 &&
|
||||||
|
D.members[0].userId === currentUser.value.id
|
||||||
|
) {
|
||||||
|
// remove duplicate and keep sort order
|
||||||
|
D.members.splice(0, 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args.json.length < params.n) {
|
||||||
|
isGroupMembersDone.value = true;
|
||||||
|
}
|
||||||
|
D.members = [...D.members, ...args.json];
|
||||||
|
params.offset += params.n;
|
||||||
|
return args;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
isGroupMembersDone.value = true;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
async function loadAllGroupMembers() {
|
||||||
|
if (isGroupMembersLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await getGroupDialogGroupMembers();
|
||||||
|
while (groupDialog.value.visible && !isGroupMembersDone.value) {
|
||||||
|
isGroupMembersLoading.value = true;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
workerTimers.setTimeout(resolve, 1000);
|
||||||
|
});
|
||||||
|
isGroupMembersLoading.value = false;
|
||||||
|
await loadMoreGroupMembers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} sortOrder
|
||||||
|
*/
|
||||||
|
async function setGroupMemberSortOrder(sortOrder) {
|
||||||
|
const D = groupDialog.value;
|
||||||
|
if (D.memberSortOrder?.value === sortOrder?.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
D.memberSortOrder = sortOrder;
|
||||||
|
await getGroupDialogGroupMembers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} filter
|
||||||
|
*/
|
||||||
|
async function setGroupMemberFilter(filter) {
|
||||||
|
const D = groupDialog.value;
|
||||||
|
if (D.memberFilter === filter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
D.memberFilter = filter;
|
||||||
|
await getGroupDialogGroupMembers();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isGroupMembersDone,
|
||||||
|
isGroupMembersLoading,
|
||||||
|
loadMoreGroupMembersParams,
|
||||||
|
groupDialogMemberSortValue,
|
||||||
|
groupDialogMemberFilterKey,
|
||||||
|
groupDialogMemberFilterGroups,
|
||||||
|
groupMembersSearch,
|
||||||
|
getGroupDialogGroupMembers,
|
||||||
|
loadMoreGroupMembers,
|
||||||
|
loadAllGroupMembers,
|
||||||
|
setGroupMemberSortOrder,
|
||||||
|
setGroupMemberFilter
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
import { debounce } from '../../../shared/utils';
|
||||||
|
import * as workerTimers from 'worker-timers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for group moderation data fetching, member management,
|
||||||
|
* searching, sorting and filtering.
|
||||||
|
*
|
||||||
|
* @param {object} deps
|
||||||
|
* @param {import('vue').Ref} deps.groupMemberModeration - store ref
|
||||||
|
* @param {import('vue').Ref} deps.currentUser - store ref
|
||||||
|
* @param {Function} deps.applyGroupMember - store action
|
||||||
|
* @param {Function} deps.handleGroupMember - store action
|
||||||
|
* @param {object} deps.tables - reactive table data objects
|
||||||
|
* @param {object} deps.tables.members
|
||||||
|
* @param {object} deps.tables.bans
|
||||||
|
* @param {object} deps.tables.invites
|
||||||
|
* @param {object} deps.tables.joinRequests
|
||||||
|
* @param {object} deps.tables.blocked
|
||||||
|
* @param {object} deps.tables.logs
|
||||||
|
* @param {object} deps.selection - from useGroupModerationSelection
|
||||||
|
* @param {object} deps.selection.selectedUsers
|
||||||
|
* @param {Function} deps.selection.setSelectedUsers
|
||||||
|
* @param {object} deps.groupRequest - API module
|
||||||
|
* @param {object} deps.userRequest - API module
|
||||||
|
*/
|
||||||
|
export function useGroupModerationData(deps) {
|
||||||
|
const {
|
||||||
|
groupMemberModeration,
|
||||||
|
currentUser,
|
||||||
|
applyGroupMember,
|
||||||
|
handleGroupMember,
|
||||||
|
tables,
|
||||||
|
selection,
|
||||||
|
groupRequest,
|
||||||
|
userRequest
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
const isGroupMembersLoading = ref(false);
|
||||||
|
const isGroupMembersDone = ref(false);
|
||||||
|
const memberFilter = ref({
|
||||||
|
id: null,
|
||||||
|
name: 'dialog.group.members.filters.everyone'
|
||||||
|
});
|
||||||
|
const memberSortOrder = ref({
|
||||||
|
id: '',
|
||||||
|
name: 'dialog.group.members.sorting.joined_at_desc',
|
||||||
|
value: 'joinedAt:desc'
|
||||||
|
});
|
||||||
|
const memberSearch = ref('');
|
||||||
|
const members = ref([]);
|
||||||
|
const loadMoreGroupMembersParams = ref({
|
||||||
|
n: 100,
|
||||||
|
offset: 0,
|
||||||
|
groupId: '',
|
||||||
|
sort: 'joinedAt:desc',
|
||||||
|
roleId: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Members ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getGroupMembers() {
|
||||||
|
members.value = [];
|
||||||
|
isGroupMembersDone.value = false;
|
||||||
|
loadMoreGroupMembersParams.value = {
|
||||||
|
sort: 'joinedAt:desc',
|
||||||
|
roleId: '',
|
||||||
|
n: 100,
|
||||||
|
offset: 0,
|
||||||
|
groupId: groupMemberModeration.value.id
|
||||||
|
};
|
||||||
|
if (memberSortOrder.value.value) {
|
||||||
|
loadMoreGroupMembersParams.value.sort = memberSortOrder.value.value;
|
||||||
|
}
|
||||||
|
if (memberFilter.value.id !== null) {
|
||||||
|
loadMoreGroupMembersParams.value.roleId = memberFilter.value.id;
|
||||||
|
}
|
||||||
|
await groupRequest
|
||||||
|
.getGroupMember({
|
||||||
|
groupId: groupMemberModeration.value.id,
|
||||||
|
userId: currentUser.value.id
|
||||||
|
})
|
||||||
|
.then((args) => {
|
||||||
|
args.ref = applyGroupMember(args.json);
|
||||||
|
if (args.json) {
|
||||||
|
args.json.user = currentUser.value;
|
||||||
|
if (memberFilter.value.id === null) {
|
||||||
|
members.value.push(args.json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
});
|
||||||
|
await loadMoreGroupMembers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreGroupMembers() {
|
||||||
|
if (isGroupMembersDone.value || isGroupMembersLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = loadMoreGroupMembersParams.value;
|
||||||
|
if (params.roleId === '') {
|
||||||
|
delete params.roleId;
|
||||||
|
}
|
||||||
|
memberSearch.value = '';
|
||||||
|
isGroupMembersLoading.value = true;
|
||||||
|
await groupRequest
|
||||||
|
.getGroupMembers(params)
|
||||||
|
.finally(() => {
|
||||||
|
isGroupMembersLoading.value = false;
|
||||||
|
})
|
||||||
|
.then((args) => {
|
||||||
|
for (const json of args.json) {
|
||||||
|
handleGroupMember({
|
||||||
|
json,
|
||||||
|
params: { groupId: args.params.groupId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < args.json.length; i++) {
|
||||||
|
const member = args.json[i];
|
||||||
|
if (member.userId === currentUser.value.id) {
|
||||||
|
if (members.value.length > 0 && members.value[0].userId === currentUser.value.id) {
|
||||||
|
members.value.splice(0, 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args.json.length < params.n) {
|
||||||
|
isGroupMembersDone.value = true;
|
||||||
|
}
|
||||||
|
members.value = [...members.value, ...args.json];
|
||||||
|
tables.members.data = members.value.map((member) => ({
|
||||||
|
...member,
|
||||||
|
$selected: Boolean(selection.selectedUsers[member.userId])
|
||||||
|
}));
|
||||||
|
params.offset += params.n;
|
||||||
|
return args;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
isGroupMembersDone.value = true;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllGroupMembers() {
|
||||||
|
if (isGroupMembersLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await getGroupMembers();
|
||||||
|
while (groupMemberModeration.value.visible && !isGroupMembersDone.value) {
|
||||||
|
isGroupMembersLoading.value = true;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
workerTimers.setTimeout(resolve, 1000);
|
||||||
|
});
|
||||||
|
isGroupMembersLoading.value = false;
|
||||||
|
await loadMoreGroupMembers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setGroupMemberSortOrder(sortOrder) {
|
||||||
|
if (memberSortOrder.value === sortOrder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memberSortOrder.value = sortOrder;
|
||||||
|
await getGroupMembers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setGroupMemberFilter(filter) {
|
||||||
|
if (memberFilter.value === filter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memberFilter.value = filter;
|
||||||
|
await getGroupMembers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMembersSearch() {
|
||||||
|
if (memberSearch.value.length < 3) {
|
||||||
|
tables.members.data = [];
|
||||||
|
isGroupMembersLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isGroupMembersLoading.value = true;
|
||||||
|
debounce(groupMembersSearchDebounced, 200)();
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMembersSearchDebounced() {
|
||||||
|
const groupId = groupMemberModeration.value.id;
|
||||||
|
const search = memberSearch.value;
|
||||||
|
tables.members.data = [];
|
||||||
|
if (memberSearch.value.length < 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isGroupMembersLoading.value = true;
|
||||||
|
groupRequest
|
||||||
|
.getGroupMembersSearch({
|
||||||
|
groupId,
|
||||||
|
query: search,
|
||||||
|
n: 100,
|
||||||
|
offset: 0
|
||||||
|
})
|
||||||
|
.then((args) => {
|
||||||
|
for (const json of args.json.results) {
|
||||||
|
handleGroupMember({
|
||||||
|
json,
|
||||||
|
params: { groupId: args.params.groupId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (groupId === args.params.groupId) {
|
||||||
|
tables.members.data = args.json.results.map((member) => ({
|
||||||
|
...member,
|
||||||
|
$selected: Boolean(selection.selectedUsers[member.userId])
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isGroupMembersLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bans ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getAllGroupBans(groupId) {
|
||||||
|
tables.bans.data = [];
|
||||||
|
const params = { groupId, n: 100, offset: 0 };
|
||||||
|
const count = 50; // 5000 max
|
||||||
|
isGroupMembersLoading.value = true;
|
||||||
|
const fetchedBans = [];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const args = await groupRequest.getGroupBans(params);
|
||||||
|
if (args && args.json) {
|
||||||
|
if (groupMemberModeration.value.id !== args.params.groupId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
args.json.forEach((json) => {
|
||||||
|
const ref = applyGroupMember(json);
|
||||||
|
fetchedBans.push(ref);
|
||||||
|
});
|
||||||
|
if (args.json.length < params.n) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
params.offset += params.n;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!groupMemberModeration.value.visible) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tables.bans.data = fetchedBans;
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to get group bans');
|
||||||
|
} finally {
|
||||||
|
isGroupMembersLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Invites / Join Requests / Blocked ────────────────────────
|
||||||
|
|
||||||
|
async function getAllGroupInvites(groupId) {
|
||||||
|
tables.invites.data = [];
|
||||||
|
const params = { groupId, n: 100, offset: 0 };
|
||||||
|
const count = 50; // 5000 max
|
||||||
|
isGroupMembersLoading.value = true;
|
||||||
|
let newData = [];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const args = await groupRequest.getGroupInvites(params);
|
||||||
|
if (args) {
|
||||||
|
if (groupMemberModeration.value.id !== args.params.groupId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const json of args.json) {
|
||||||
|
const ref = applyGroupMember(json);
|
||||||
|
newData.push(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params.offset += params.n;
|
||||||
|
if (args.json.length < params.n) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!groupMemberModeration.value.visible) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tables.invites.data = newData;
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to get group invites');
|
||||||
|
} finally {
|
||||||
|
isGroupMembersLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllGroupJoinRequests(groupId) {
|
||||||
|
tables.joinRequests.data = [];
|
||||||
|
const params = { groupId, n: 100, offset: 0, blocked: false };
|
||||||
|
const count = 50; // 5000 max
|
||||||
|
isGroupMembersLoading.value = true;
|
||||||
|
let newData = [];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const args = await groupRequest.getGroupJoinRequests(params);
|
||||||
|
if (groupMemberModeration.value.id !== args.params.groupId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const json of args.json) {
|
||||||
|
const ref = applyGroupMember(json);
|
||||||
|
newData.push(ref);
|
||||||
|
}
|
||||||
|
params.offset += params.n;
|
||||||
|
if (args.json.length < params.n) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!groupMemberModeration.value.visible) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tables.joinRequests.data = newData;
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to get group join requests');
|
||||||
|
} finally {
|
||||||
|
isGroupMembersLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllGroupBlockedRequests(groupId) {
|
||||||
|
tables.blocked.data = [];
|
||||||
|
const params = { groupId, n: 100, offset: 0, blocked: true };
|
||||||
|
const count = 50; // 5000
|
||||||
|
isGroupMembersLoading.value = true;
|
||||||
|
let newData = [];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const args = await groupRequest.getGroupJoinRequests(params);
|
||||||
|
if (groupMemberModeration.value.id !== args.params.groupId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const json of args.json) {
|
||||||
|
const ref = applyGroupMember(json);
|
||||||
|
newData.push(ref);
|
||||||
|
}
|
||||||
|
params.offset += params.n;
|
||||||
|
if (args.json.length < params.n) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!groupMemberModeration.value.visible) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tables.blocked.data = newData;
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to get group join requests');
|
||||||
|
} finally {
|
||||||
|
isGroupMembersLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllGroupInvitesAndJoinRequests(groupId) {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
getAllGroupInvites(groupId),
|
||||||
|
getAllGroupJoinRequests(groupId),
|
||||||
|
getAllGroupBlockedRequests(groupId)
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching group invites/requests:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Logs ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getAllGroupLogs(groupId, eventTypes = []) {
|
||||||
|
tables.logs.data = [];
|
||||||
|
const params = { groupId, n: 100, offset: 0 };
|
||||||
|
if (eventTypes.length) {
|
||||||
|
params.eventTypes = eventTypes;
|
||||||
|
}
|
||||||
|
const count = 50; // 5000 max
|
||||||
|
isGroupMembersLoading.value = true;
|
||||||
|
let newData = [];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const args = await groupRequest.getGroupLogs(params);
|
||||||
|
if (args) {
|
||||||
|
if (groupMemberModeration.value.id !== args.params.groupId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const json of args.json.results) {
|
||||||
|
const existsInData = newData.some((dataItem) => dataItem.id === json.id);
|
||||||
|
if (!existsInData) {
|
||||||
|
newData.push(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params.offset += params.n;
|
||||||
|
if (!args.json.hasNext) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!groupMemberModeration.value.visible) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tables.logs.data = newData;
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to get group logs');
|
||||||
|
} finally {
|
||||||
|
isGroupMembersLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User Selection ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async function addGroupMemberToSelection(userId) {
|
||||||
|
const D = groupMemberModeration.value;
|
||||||
|
let member = {};
|
||||||
|
const memberArgs = await groupRequest.getGroupMember({ groupId: D.id, userId });
|
||||||
|
if (memberArgs && memberArgs.json) {
|
||||||
|
member = applyGroupMember(memberArgs.json);
|
||||||
|
}
|
||||||
|
if (member && member.user) {
|
||||||
|
selection.setSelectedUsers(member.userId, member);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userArgs = await userRequest.getCachedUser({ userId });
|
||||||
|
member.userId = userArgs.json.id;
|
||||||
|
member.user = userArgs.json;
|
||||||
|
member.displayName = userArgs.json.displayName;
|
||||||
|
selection.setSelectedUsers(member.userId, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectGroupMemberUserId(userIdInput) {
|
||||||
|
if (!userIdInput) return;
|
||||||
|
const regexUserId = /usr_[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}/g;
|
||||||
|
let match;
|
||||||
|
const userIdList = new Set();
|
||||||
|
while ((match = regexUserId.exec(userIdInput)) !== null) {
|
||||||
|
userIdList.add(match[0]);
|
||||||
|
}
|
||||||
|
if (userIdList.size === 0) {
|
||||||
|
userIdList.add(userIdInput);
|
||||||
|
}
|
||||||
|
const promises = [];
|
||||||
|
userIdList.forEach((userId) => {
|
||||||
|
promises.push(addGroupMemberToSelection(userId));
|
||||||
|
});
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reset ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function resetData() {
|
||||||
|
tables.members.data = [];
|
||||||
|
tables.bans.data = [];
|
||||||
|
tables.invites.data = [];
|
||||||
|
tables.joinRequests.data = [];
|
||||||
|
tables.blocked.data = [];
|
||||||
|
tables.logs.data = [];
|
||||||
|
memberSearch.value = '';
|
||||||
|
members.value = [];
|
||||||
|
isGroupMembersDone.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isGroupMembersLoading,
|
||||||
|
isGroupMembersDone,
|
||||||
|
memberFilter,
|
||||||
|
memberSortOrder,
|
||||||
|
memberSearch,
|
||||||
|
members,
|
||||||
|
loadAllGroupMembers,
|
||||||
|
getGroupMembers,
|
||||||
|
setGroupMemberSortOrder,
|
||||||
|
setGroupMemberFilter,
|
||||||
|
groupMembersSearch,
|
||||||
|
selectGroupMemberUserId,
|
||||||
|
addGroupMemberToSelection,
|
||||||
|
getAllGroupBans,
|
||||||
|
getAllGroupLogs,
|
||||||
|
getAllGroupInvites,
|
||||||
|
getAllGroupJoinRequests,
|
||||||
|
getAllGroupBlockedRequests,
|
||||||
|
getAllGroupInvitesAndJoinRequests,
|
||||||
|
resetData
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -602,50 +602,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { Copy, Info, Languages, MoreHorizontal, Pencil, Trash2 } from 'lucide-vue-next';
|
||||||
AlertTriangle,
|
|
||||||
ArrowDown,
|
|
||||||
ArrowUp,
|
|
||||||
Copy,
|
|
||||||
Download,
|
|
||||||
DownloadIcon,
|
|
||||||
Eye,
|
|
||||||
Info,
|
|
||||||
Languages,
|
|
||||||
LogOut,
|
|
||||||
MoreHorizontal,
|
|
||||||
Pencil,
|
|
||||||
RefreshCw,
|
|
||||||
Tag,
|
|
||||||
Trash2
|
|
||||||
} from 'lucide-vue-next';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { computed, defineAsyncComponent, nextTick, ref, watch } from 'vue';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { DataTableEmpty } from '@/components/ui/data-table';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { TabsUnderline } from '@/components/ui/tabs';
|
import { TabsUnderline } from '@/components/ui/tabs';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import DeprecationAlert from '@/components/DeprecationAlert.vue';
|
|
||||||
import VueJsonPretty from 'vue-json-pretty';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useAdvancedSettingsStore,
|
useAdvancedSettingsStore,
|
||||||
useAppearanceSettingsStore,
|
useAppearanceSettingsStore,
|
||||||
useAuthStore,
|
|
||||||
useAvatarStore,
|
useAvatarStore,
|
||||||
useFavoriteStore,
|
useFavoriteStore,
|
||||||
useFriendStore,
|
useFriendStore,
|
||||||
@@ -657,19 +633,16 @@
|
|||||||
useModalStore,
|
useModalStore,
|
||||||
useModerationStore,
|
useModerationStore,
|
||||||
useNotificationStore,
|
useNotificationStore,
|
||||||
useUiStore,
|
|
||||||
useUserStore,
|
useUserStore,
|
||||||
useWorldStore
|
useWorldStore
|
||||||
} from '../../../stores';
|
} from '../../../stores';
|
||||||
import {
|
import {
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
downloadAndSaveJson,
|
|
||||||
formatDateFilter,
|
formatDateFilter,
|
||||||
getFaviconUrl,
|
getFaviconUrl,
|
||||||
isFriendOnline,
|
isFriendOnline,
|
||||||
isRealInstance,
|
isRealInstance,
|
||||||
openExternalLink,
|
openExternalLink,
|
||||||
parseLocation,
|
|
||||||
refreshInstancePlayerCount,
|
refreshInstancePlayerCount,
|
||||||
timeToText,
|
timeToText,
|
||||||
userImage,
|
userImage,
|
||||||
@@ -677,18 +650,9 @@
|
|||||||
userOnlineForTimestamp,
|
userOnlineForTimestamp,
|
||||||
userStatusClass
|
userStatusClass
|
||||||
} from '../../../shared/utils';
|
} from '../../../shared/utils';
|
||||||
import {
|
import { miscRequest, userRequest } from '../../../api';
|
||||||
favoriteRequest,
|
|
||||||
friendRequest,
|
|
||||||
miscRequest,
|
|
||||||
notificationRequest,
|
|
||||||
playerModerationRequest,
|
|
||||||
userRequest,
|
|
||||||
worldRequest
|
|
||||||
} from '../../../api';
|
|
||||||
import { database } from '../../../service/database';
|
|
||||||
import { formatJsonVars } from '../../../shared/utils/base/ui';
|
import { formatJsonVars } from '../../../shared/utils/base/ui';
|
||||||
import { processBulk } from '../../../service/request';
|
import { useUserDialogCommands } from './useUserDialogCommands';
|
||||||
|
|
||||||
import DialogJsonTab from '../DialogJsonTab.vue';
|
import DialogJsonTab from '../DialogJsonTab.vue';
|
||||||
import InstanceActionBar from '../../InstanceActionBar.vue';
|
import InstanceActionBar from '../../InstanceActionBar.vue';
|
||||||
@@ -757,6 +721,39 @@
|
|||||||
|
|
||||||
const { applyPlayerModeration, handlePlayerModerationDelete } = useModerationStore();
|
const { applyPlayerModeration, handlePlayerModerationDelete } = useModerationStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
sendInviteDialogVisible,
|
||||||
|
sendInviteDialog,
|
||||||
|
sendInviteRequestDialogVisible,
|
||||||
|
userDialogCommand,
|
||||||
|
registerCallbacks
|
||||||
|
} = useUserDialogCommands(userDialog, {
|
||||||
|
t,
|
||||||
|
toast,
|
||||||
|
modalStore,
|
||||||
|
currentUser,
|
||||||
|
cachedUsers,
|
||||||
|
friendLogTable,
|
||||||
|
lastLocation,
|
||||||
|
lastLocationDestination,
|
||||||
|
inviteGroupDialog,
|
||||||
|
showUserDialog,
|
||||||
|
showFavoriteDialog,
|
||||||
|
showAvatarDialog,
|
||||||
|
showAvatarAuthorDialog,
|
||||||
|
showModerateGroupDialog,
|
||||||
|
showSendBoopDialog,
|
||||||
|
showGalleryPage,
|
||||||
|
getFriendRequest,
|
||||||
|
handleFriendDelete,
|
||||||
|
applyPlayerModeration,
|
||||||
|
handlePlayerModerationDelete,
|
||||||
|
refreshInviteMessageTableData,
|
||||||
|
clearInviteImageUpload,
|
||||||
|
instanceStore,
|
||||||
|
useNotificationStore
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => userDialog.value.loading,
|
() => userDialog.value.loading,
|
||||||
() => {
|
() => {
|
||||||
@@ -778,14 +775,6 @@
|
|||||||
const userDialogLastWorld = ref('');
|
const userDialogLastWorld = ref('');
|
||||||
const userDialogLastFavoriteWorld = ref('');
|
const userDialogLastFavoriteWorld = ref('');
|
||||||
|
|
||||||
const sendInviteDialogVisible = ref(false);
|
|
||||||
const sendInviteDialog = ref({
|
|
||||||
messageSlot: {},
|
|
||||||
userId: '',
|
|
||||||
params: {}
|
|
||||||
});
|
|
||||||
const sendInviteRequestDialogVisible = ref(false);
|
|
||||||
|
|
||||||
const socialStatusDialog = ref({
|
const socialStatusDialog = ref({
|
||||||
visible: false,
|
visible: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -966,6 +955,17 @@
|
|||||||
D.visible = true;
|
D.visible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register simple dialog openers as callbacks for the command composable
|
||||||
|
registerCallbacks({
|
||||||
|
showSocialStatusDialog,
|
||||||
|
showLanguageDialog,
|
||||||
|
showBioDialog,
|
||||||
|
showPronounsDialog,
|
||||||
|
showEditNoteAndMemoDialog: () => {
|
||||||
|
isEditNoteAndMemoDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@@ -1028,450 +1028,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param userId
|
|
||||||
* @param type
|
|
||||||
*/
|
|
||||||
function setPlayerModeration(userId, type) {
|
|
||||||
const D = userDialog.value;
|
|
||||||
AppApi.SetVRChatUserModeration(currentUser.value.id, userId, type).then((result) => {
|
|
||||||
if (result) {
|
|
||||||
if (type === 4) {
|
|
||||||
D.isShowAvatar = false;
|
|
||||||
D.isHideAvatar = true;
|
|
||||||
} else if (type === 5) {
|
|
||||||
D.isShowAvatar = true;
|
|
||||||
D.isHideAvatar = false;
|
|
||||||
} else {
|
|
||||||
D.isShowAvatar = false;
|
|
||||||
D.isHideAvatar = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error(t('message.avatar.change_moderation_failed'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param params
|
|
||||||
* @param userId
|
|
||||||
*/
|
|
||||||
function showSendInviteDialog(params, userId) {
|
|
||||||
sendInviteDialog.value = {
|
|
||||||
params,
|
|
||||||
userId,
|
|
||||||
messageSlot: {}
|
|
||||||
};
|
|
||||||
refreshInviteMessageTableData('message');
|
|
||||||
clearInviteImageUpload();
|
|
||||||
sendInviteDialogVisible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param params
|
|
||||||
* @param userId
|
|
||||||
*/
|
|
||||||
function showSendInviteRequestDialog(params, userId) {
|
|
||||||
sendInviteDialog.value = {
|
|
||||||
params,
|
|
||||||
userId,
|
|
||||||
messageSlot: {}
|
|
||||||
};
|
|
||||||
refreshInviteMessageTableData('request');
|
|
||||||
clearInviteImageUpload();
|
|
||||||
sendInviteRequestDialogVisible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param groupId
|
|
||||||
* @param userId
|
|
||||||
*/
|
|
||||||
function showInviteGroupDialog(groupId, userId) {
|
|
||||||
inviteGroupDialog.value.groupId = groupId;
|
|
||||||
inviteGroupDialog.value.userId = userId;
|
|
||||||
inviteGroupDialog.value.visible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param command
|
|
||||||
*/
|
|
||||||
function userDialogCommand(command) {
|
|
||||||
let L;
|
|
||||||
const D = userDialog.value;
|
|
||||||
if (D.visible === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (command === 'Refresh') {
|
|
||||||
const userId = D.id;
|
|
||||||
D.id = '';
|
|
||||||
showUserDialog(userId);
|
|
||||||
} else if (command === 'Share') {
|
|
||||||
copyUserURL(D.id);
|
|
||||||
} else if (command === 'Add Favorite') {
|
|
||||||
showFavoriteDialog('friend', D.id);
|
|
||||||
} else if (command === 'Edit Social Status') {
|
|
||||||
showSocialStatusDialog();
|
|
||||||
} else if (command === 'Edit Language') {
|
|
||||||
showLanguageDialog();
|
|
||||||
} else if (command === 'Edit Bio') {
|
|
||||||
showBioDialog();
|
|
||||||
} else if (command === 'Edit Pronouns') {
|
|
||||||
showPronounsDialog();
|
|
||||||
} else if (command === 'Request Invite') {
|
|
||||||
notificationRequest
|
|
||||||
.sendRequestInvite(
|
|
||||||
{
|
|
||||||
platform: 'standalonewindows'
|
|
||||||
},
|
|
||||||
D.id
|
|
||||||
)
|
|
||||||
.then((args) => {
|
|
||||||
toast('Request invite sent');
|
|
||||||
return args;
|
|
||||||
});
|
|
||||||
} else if (command === 'Invite Message') {
|
|
||||||
L = parseLocation(lastLocation.value.location);
|
|
||||||
worldRequest
|
|
||||||
.getCachedWorld({
|
|
||||||
worldId: L.worldId
|
|
||||||
})
|
|
||||||
.then((args) => {
|
|
||||||
showSendInviteDialog(
|
|
||||||
{
|
|
||||||
instanceId: lastLocation.value.location,
|
|
||||||
worldId: lastLocation.value.location,
|
|
||||||
worldName: args.ref.name
|
|
||||||
},
|
|
||||||
D.id
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} else if (command === 'Request Invite Message') {
|
|
||||||
showSendInviteRequestDialog(
|
|
||||||
{
|
|
||||||
platform: 'standalonewindows'
|
|
||||||
},
|
|
||||||
D.id
|
|
||||||
);
|
|
||||||
} else if (command === 'Invite') {
|
|
||||||
let currentLocation = lastLocation.value.location;
|
|
||||||
if (lastLocation.value.location === 'traveling') {
|
|
||||||
currentLocation = lastLocationDestination.value;
|
|
||||||
}
|
|
||||||
L = parseLocation(currentLocation);
|
|
||||||
worldRequest
|
|
||||||
.getCachedWorld({
|
|
||||||
worldId: L.worldId
|
|
||||||
})
|
|
||||||
.then((args) => {
|
|
||||||
notificationRequest
|
|
||||||
.sendInvite(
|
|
||||||
{
|
|
||||||
instanceId: L.tag,
|
|
||||||
worldId: L.tag,
|
|
||||||
worldName: args.ref.name
|
|
||||||
},
|
|
||||||
D.id
|
|
||||||
)
|
|
||||||
.then((_args) => {
|
|
||||||
toast(t('message.invite.sent'));
|
|
||||||
return _args;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else if (command === 'Show Avatar Author') {
|
|
||||||
const { currentAvatarImageUrl } = D.ref;
|
|
||||||
showAvatarAuthorDialog(D.id, D.$avatarInfo.ownerId, currentAvatarImageUrl);
|
|
||||||
} else if (command === 'Show Fallback Avatar Details') {
|
|
||||||
const { fallbackAvatar } = D.ref;
|
|
||||||
if (fallbackAvatar) {
|
|
||||||
showAvatarDialog(fallbackAvatar);
|
|
||||||
} else {
|
|
||||||
toast.error('No fallback avatar set');
|
|
||||||
}
|
|
||||||
} else if (command === 'Previous Instances') {
|
|
||||||
showPreviousInstancesListDialog(D.ref);
|
|
||||||
} else if (command === 'Manage Gallery') {
|
|
||||||
userDialog.value.visible = false;
|
|
||||||
showGalleryPage();
|
|
||||||
} else if (command === 'Invite To Group') {
|
|
||||||
showInviteGroupDialog('', D.id);
|
|
||||||
} else if (command === 'Send Boop') {
|
|
||||||
showSendBoopDialog(D.id);
|
|
||||||
} else if (command === 'Group Moderation') {
|
|
||||||
showModerateGroupDialog(D.id);
|
|
||||||
} else if (command === 'Hide Avatar') {
|
|
||||||
if (D.isHideAvatar) {
|
|
||||||
setPlayerModeration(D.id, 0);
|
|
||||||
} else {
|
|
||||||
setPlayerModeration(D.id, 4);
|
|
||||||
}
|
|
||||||
} else if (command === 'Show Avatar') {
|
|
||||||
if (D.isShowAvatar) {
|
|
||||||
setPlayerModeration(D.id, 0);
|
|
||||||
} else {
|
|
||||||
setPlayerModeration(D.id, 5);
|
|
||||||
}
|
|
||||||
} else if (command === 'Edit Note Memo') {
|
|
||||||
isEditNoteAndMemoDialogVisible.value = true;
|
|
||||||
} else {
|
|
||||||
const i18nPreFix = 'dialog.user.actions.';
|
|
||||||
const formattedCommand = command.toLowerCase().replace(/ /g, '_');
|
|
||||||
const displayCommandText = t(`${i18nPreFix}${formattedCommand}`).includes('i18nPreFix')
|
|
||||||
? command
|
|
||||||
: t(`${i18nPreFix}${formattedCommand}`);
|
|
||||||
|
|
||||||
modalStore
|
|
||||||
.confirm({
|
|
||||||
description: t('confirm.message', {
|
|
||||||
command: displayCommandText
|
|
||||||
}),
|
|
||||||
title: t('confirm.title'),
|
|
||||||
confirmText: t('confirm.confirm_button'),
|
|
||||||
cancelText: t('confirm.cancel_button')
|
|
||||||
})
|
|
||||||
.then(({ ok }) => {
|
|
||||||
if (ok) {
|
|
||||||
performUserDialogCommand(command, D.id);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param args
|
|
||||||
*/
|
|
||||||
function handleSendFriendRequest(args) {
|
|
||||||
const ref = cachedUsers.get(args.params.userId);
|
|
||||||
if (typeof ref === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const friendLogHistory = {
|
|
||||||
created_at: new Date().toJSON(),
|
|
||||||
type: 'FriendRequest',
|
|
||||||
userId: ref.id,
|
|
||||||
displayName: ref.displayName
|
|
||||||
};
|
|
||||||
friendLogTable.value.data.push(friendLogHistory);
|
|
||||||
database.addFriendLogHistory(friendLogHistory);
|
|
||||||
|
|
||||||
const D = userDialog.value;
|
|
||||||
if (D.visible === false || D.id !== args.params.userId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (args.json.success) {
|
|
||||||
D.isFriend = true;
|
|
||||||
} else {
|
|
||||||
D.outgoingRequest = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param args
|
|
||||||
*/
|
|
||||||
function handleCancelFriendRequest(args) {
|
|
||||||
const ref = cachedUsers.get(args.params.userId);
|
|
||||||
if (typeof ref === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const friendLogHistory = {
|
|
||||||
created_at: new Date().toJSON(),
|
|
||||||
type: 'CancelFriendRequest',
|
|
||||||
userId: ref.id,
|
|
||||||
displayName: ref.displayName
|
|
||||||
};
|
|
||||||
friendLogTable.value.data.push(friendLogHistory);
|
|
||||||
database.addFriendLogHistory(friendLogHistory);
|
|
||||||
const D = userDialog.value;
|
|
||||||
if (D.visible === false || D.id !== args.params.userId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
D.outgoingRequest = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param args
|
|
||||||
*/
|
|
||||||
function handleSendPlayerModeration(args) {
|
|
||||||
const ref = applyPlayerModeration(args.json);
|
|
||||||
const D = userDialog.value;
|
|
||||||
if (D.visible === false || (ref.targetUserId !== D.id && ref.sourceUserId !== currentUser.value.id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ref.type === 'block') {
|
|
||||||
D.isBlock = true;
|
|
||||||
} else if (ref.type === 'mute') {
|
|
||||||
D.isMute = true;
|
|
||||||
} else if (ref.type === 'interactOff') {
|
|
||||||
D.isInteractOff = true;
|
|
||||||
} else if (ref.type === 'muteChat') {
|
|
||||||
D.isMuteChat = true;
|
|
||||||
}
|
|
||||||
toast.success(t('message.user.moderated'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param command
|
|
||||||
* @param userId
|
|
||||||
*/
|
|
||||||
async function performUserDialogCommand(command, userId) {
|
|
||||||
let args;
|
|
||||||
let key;
|
|
||||||
switch (command) {
|
|
||||||
case 'Delete Favorite':
|
|
||||||
favoriteRequest.deleteFavorite({
|
|
||||||
objectId: userId
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'Accept Friend Request':
|
|
||||||
key = getFriendRequest(userId);
|
|
||||||
if (key === '') {
|
|
||||||
const args = await friendRequest.sendFriendRequest({
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
handleSendFriendRequest(args);
|
|
||||||
} else {
|
|
||||||
notificationRequest
|
|
||||||
.acceptFriendRequestNotification({
|
|
||||||
notificationId: key
|
|
||||||
})
|
|
||||||
.then((args) => {
|
|
||||||
useNotificationStore().handleNotificationAccept(args);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err && err.message && err.message.includes('404')) {
|
|
||||||
useNotificationStore().handleNotificationHide(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'Decline Friend Request':
|
|
||||||
key = getFriendRequest(userId);
|
|
||||||
if (key === '') {
|
|
||||||
const args = await friendRequest.cancelFriendRequest({
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
handleCancelFriendRequest(args);
|
|
||||||
} else {
|
|
||||||
notificationRequest
|
|
||||||
.hideNotification({
|
|
||||||
notificationId: key
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
useNotificationStore().handleNotificationHide(key);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'Cancel Friend Request': {
|
|
||||||
args = await friendRequest.cancelFriendRequest({
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
handleCancelFriendRequest(args);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'Send Friend Request': {
|
|
||||||
args = await friendRequest.sendFriendRequest({
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
handleSendFriendRequest(args);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'Moderation Unblock':
|
|
||||||
args = await playerModerationRequest.deletePlayerModeration({
|
|
||||||
moderated: userId,
|
|
||||||
type: 'block'
|
|
||||||
});
|
|
||||||
handlePlayerModerationDelete(args);
|
|
||||||
break;
|
|
||||||
case 'Moderation Block': {
|
|
||||||
args = await playerModerationRequest.sendPlayerModeration({
|
|
||||||
moderated: userId,
|
|
||||||
type: 'block'
|
|
||||||
});
|
|
||||||
handleSendPlayerModeration(args);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'Moderation Unmute':
|
|
||||||
args = await playerModerationRequest.deletePlayerModeration({
|
|
||||||
moderated: userId,
|
|
||||||
type: 'mute'
|
|
||||||
});
|
|
||||||
handlePlayerModerationDelete(args);
|
|
||||||
break;
|
|
||||||
case 'Moderation Mute': {
|
|
||||||
args = await playerModerationRequest.sendPlayerModeration({
|
|
||||||
moderated: userId,
|
|
||||||
type: 'mute'
|
|
||||||
});
|
|
||||||
handleSendPlayerModeration(args);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'Moderation Enable Avatar Interaction':
|
|
||||||
args = await playerModerationRequest.deletePlayerModeration({
|
|
||||||
moderated: userId,
|
|
||||||
type: 'interactOff'
|
|
||||||
});
|
|
||||||
handlePlayerModerationDelete(args);
|
|
||||||
break;
|
|
||||||
case 'Moderation Disable Avatar Interaction': {
|
|
||||||
args = await playerModerationRequest.sendPlayerModeration({
|
|
||||||
moderated: userId,
|
|
||||||
type: 'interactOff'
|
|
||||||
});
|
|
||||||
handleSendPlayerModeration(args);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'Moderation Enable Chatbox':
|
|
||||||
args = await playerModerationRequest.deletePlayerModeration({
|
|
||||||
moderated: userId,
|
|
||||||
type: 'muteChat'
|
|
||||||
});
|
|
||||||
handlePlayerModerationDelete(args);
|
|
||||||
break;
|
|
||||||
case 'Moderation Disable Chatbox': {
|
|
||||||
args = await playerModerationRequest.sendPlayerModeration({
|
|
||||||
moderated: userId,
|
|
||||||
type: 'muteChat'
|
|
||||||
});
|
|
||||||
handleSendPlayerModeration(args);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'Report Hacking':
|
|
||||||
reportUserForHacking(userId);
|
|
||||||
break;
|
|
||||||
case 'Unfriend':
|
|
||||||
args = await friendRequest.deleteFriend(
|
|
||||||
{
|
|
||||||
userId
|
|
||||||
},
|
|
||||||
t('dialog.user.actions.unfriend_success_msg')
|
|
||||||
);
|
|
||||||
handleFriendDelete(args);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param userId
|
|
||||||
*/
|
|
||||||
function reportUserForHacking(userId) {
|
|
||||||
miscRequest.reportUser({
|
|
||||||
userId,
|
|
||||||
contentType: 'user',
|
|
||||||
reason: 'behavior-hacking',
|
|
||||||
type: 'report'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,532 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useUserDialogCommands } from '../useUserDialogCommands';
|
||||||
|
|
||||||
|
// Mock external modules
|
||||||
|
vi.mock('../../../../api', () => ({
|
||||||
|
favoriteRequest: {
|
||||||
|
deleteFavorite: vi.fn()
|
||||||
|
},
|
||||||
|
friendRequest: {
|
||||||
|
sendFriendRequest: vi.fn(),
|
||||||
|
cancelFriendRequest: vi.fn(),
|
||||||
|
deleteFriend: vi.fn()
|
||||||
|
},
|
||||||
|
miscRequest: {
|
||||||
|
reportUser: vi.fn()
|
||||||
|
},
|
||||||
|
notificationRequest: {
|
||||||
|
sendRequestInvite: vi.fn(() => Promise.resolve({})),
|
||||||
|
sendInvite: vi.fn(() => Promise.resolve({})),
|
||||||
|
acceptFriendRequestNotification: vi.fn(() => Promise.resolve({})),
|
||||||
|
hideNotification: vi.fn(() => Promise.resolve({}))
|
||||||
|
},
|
||||||
|
playerModerationRequest: {
|
||||||
|
sendPlayerModeration: vi.fn(),
|
||||||
|
deletePlayerModeration: vi.fn()
|
||||||
|
},
|
||||||
|
worldRequest: {
|
||||||
|
getCachedWorld: vi.fn(() =>
|
||||||
|
Promise.resolve({ ref: { name: 'TestWorld' } })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../shared/utils', () => ({
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
parseLocation: vi.fn(() => ({ worldId: 'wrld_test', tag: 'wrld_test~123' }))
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../service/database', () => ({
|
||||||
|
database: {
|
||||||
|
addFriendLogHistory: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import mocks after vi.mock
|
||||||
|
const { copyToClipboard } = await import('../../../../shared/utils');
|
||||||
|
const {
|
||||||
|
favoriteRequest,
|
||||||
|
friendRequest,
|
||||||
|
notificationRequest,
|
||||||
|
playerModerationRequest,
|
||||||
|
miscRequest
|
||||||
|
} = await import('../../../../api');
|
||||||
|
const { database } = await import('../../../../service/database');
|
||||||
|
|
||||||
|
function createMockUserDialog() {
|
||||||
|
return ref({
|
||||||
|
visible: true,
|
||||||
|
id: 'usr_test123',
|
||||||
|
ref: {
|
||||||
|
displayName: 'TestUser',
|
||||||
|
currentAvatarImageUrl: 'https://example.com/avatar.png',
|
||||||
|
fallbackAvatar: 'avtr_fallback',
|
||||||
|
location: 'wrld_test~123'
|
||||||
|
},
|
||||||
|
$avatarInfo: { ownerId: 'usr_owner' },
|
||||||
|
isFriend: true,
|
||||||
|
isBlock: false,
|
||||||
|
isMute: false,
|
||||||
|
isInteractOff: false,
|
||||||
|
isMuteChat: false,
|
||||||
|
isShowAvatar: false,
|
||||||
|
isHideAvatar: false,
|
||||||
|
outgoingRequest: false,
|
||||||
|
incomingRequest: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockDeps(overrides = {}) {
|
||||||
|
return {
|
||||||
|
t: vi.fn((key) => key),
|
||||||
|
toast: Object.assign(vi.fn(), {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn()
|
||||||
|
}),
|
||||||
|
modalStore: {
|
||||||
|
confirm: vi.fn(() => Promise.resolve({ ok: true }))
|
||||||
|
},
|
||||||
|
currentUser: ref({ id: 'usr_current', isBoopingEnabled: true }),
|
||||||
|
cachedUsers: new Map([
|
||||||
|
['usr_test123', { id: 'usr_test123', displayName: 'TestUser' }]
|
||||||
|
]),
|
||||||
|
friendLogTable: ref({ data: [] }),
|
||||||
|
lastLocation: ref({ location: 'wrld_test~123' }),
|
||||||
|
lastLocationDestination: ref('wrld_dest~456'),
|
||||||
|
inviteGroupDialog: ref({ groupId: '', userId: '', visible: false }),
|
||||||
|
showUserDialog: vi.fn(),
|
||||||
|
showFavoriteDialog: vi.fn(),
|
||||||
|
showAvatarDialog: vi.fn(),
|
||||||
|
showAvatarAuthorDialog: vi.fn(),
|
||||||
|
showModerateGroupDialog: vi.fn(),
|
||||||
|
showSendBoopDialog: vi.fn(),
|
||||||
|
showGalleryPage: vi.fn(),
|
||||||
|
getFriendRequest: vi.fn(() => ''),
|
||||||
|
handleFriendDelete: vi.fn(),
|
||||||
|
applyPlayerModeration: vi.fn((json) => json),
|
||||||
|
handlePlayerModerationDelete: vi.fn(),
|
||||||
|
refreshInviteMessageTableData: vi.fn(),
|
||||||
|
clearInviteImageUpload: vi.fn(),
|
||||||
|
instanceStore: {
|
||||||
|
showPreviousInstancesListDialog: vi.fn()
|
||||||
|
},
|
||||||
|
useNotificationStore: vi.fn(() => ({
|
||||||
|
handleNotificationAccept: vi.fn(),
|
||||||
|
handleNotificationHide: vi.fn()
|
||||||
|
})),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useUserDialogCommands', () => {
|
||||||
|
let userDialog;
|
||||||
|
let deps;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
userDialog = createMockUserDialog();
|
||||||
|
deps = createMockDeps();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('userDialogCommand — direct commands', () => {
|
||||||
|
it('should not execute when dialog is not visible', () => {
|
||||||
|
userDialog.value.visible = false;
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Refresh');
|
||||||
|
expect(deps.showUserDialog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Refresh: should reset id and reopen dialog', () => {
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Refresh');
|
||||||
|
expect(userDialog.value.id).toBe('');
|
||||||
|
expect(deps.showUserDialog).toHaveBeenCalledWith('usr_test123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Share: should copy user URL', () => {
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Share');
|
||||||
|
expect(copyToClipboard).toHaveBeenCalledWith(
|
||||||
|
'https://vrchat.com/home/user/usr_test123',
|
||||||
|
'User URL copied to clipboard'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Add Favorite: should call showFavoriteDialog', () => {
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Add Favorite');
|
||||||
|
expect(deps.showFavoriteDialog).toHaveBeenCalledWith(
|
||||||
|
'friend',
|
||||||
|
'usr_test123'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Show Avatar Author: should call showAvatarAuthorDialog', () => {
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Show Avatar Author');
|
||||||
|
expect(deps.showAvatarAuthorDialog).toHaveBeenCalledWith(
|
||||||
|
'usr_test123',
|
||||||
|
'usr_owner',
|
||||||
|
'https://example.com/avatar.png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Show Fallback Avatar Details: should call showAvatarDialog with fallback', () => {
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Show Fallback Avatar Details');
|
||||||
|
expect(deps.showAvatarDialog).toHaveBeenCalledWith('avtr_fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Show Fallback Avatar Details: should toast error when no fallback', () => {
|
||||||
|
userDialog.value.ref.fallbackAvatar = null;
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Show Fallback Avatar Details');
|
||||||
|
expect(deps.toast.error).toHaveBeenCalledWith(
|
||||||
|
'No fallback avatar set'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Send Boop: should call showSendBoopDialog', () => {
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Send Boop');
|
||||||
|
expect(deps.showSendBoopDialog).toHaveBeenCalledWith('usr_test123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Group Moderation: should call showModerateGroupDialog', () => {
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Group Moderation');
|
||||||
|
expect(deps.showModerateGroupDialog).toHaveBeenCalledWith(
|
||||||
|
'usr_test123'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Manage Gallery: should hide dialog and show gallery', () => {
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Manage Gallery');
|
||||||
|
expect(userDialog.value.visible).toBe(false);
|
||||||
|
expect(deps.showGalleryPage).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Previous Instances: should call instanceStore', () => {
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Previous Instances');
|
||||||
|
expect(
|
||||||
|
deps.instanceStore.showPreviousInstancesListDialog
|
||||||
|
).toHaveBeenCalledWith('user', userDialog.value.ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Invite To Group: should set invite group dialog state', () => {
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Invite To Group');
|
||||||
|
expect(deps.inviteGroupDialog.value.groupId).toBe('');
|
||||||
|
expect(deps.inviteGroupDialog.value.userId).toBe('usr_test123');
|
||||||
|
expect(deps.inviteGroupDialog.value.visible).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('userDialogCommand — string callback commands', () => {
|
||||||
|
it('should delegate string-type commands to registered callbacks', () => {
|
||||||
|
const showSocialStatusDialog = vi.fn();
|
||||||
|
const { userDialogCommand, registerCallbacks } =
|
||||||
|
useUserDialogCommands(userDialog, deps);
|
||||||
|
registerCallbacks({ showSocialStatusDialog });
|
||||||
|
userDialogCommand('Edit Social Status');
|
||||||
|
expect(showSocialStatusDialog).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw when callback is not registered', () => {
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
expect(() => userDialogCommand('Edit Bio')).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('userDialogCommand — confirmed commands', () => {
|
||||||
|
it('Delete Favorite: should confirm then delete', async () => {
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Delete Favorite');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(deps.modalStore.confirm).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(favoriteRequest.deleteFavorite).toHaveBeenCalledWith({
|
||||||
|
objectId: 'usr_test123'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirmed command should not execute when user cancels', async () => {
|
||||||
|
deps.modalStore.confirm = vi.fn(() =>
|
||||||
|
Promise.resolve({ ok: false })
|
||||||
|
);
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Delete Favorite');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(deps.modalStore.confirm).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(favoriteRequest.deleteFavorite).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Send Friend Request: should confirm then send', async () => {
|
||||||
|
friendRequest.sendFriendRequest.mockResolvedValue({
|
||||||
|
params: { userId: 'usr_test123' },
|
||||||
|
json: { success: true }
|
||||||
|
});
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Send Friend Request');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(friendRequest.sendFriendRequest).toHaveBeenCalledWith({
|
||||||
|
userId: 'usr_test123'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cancel Friend Request: should confirm then cancel', async () => {
|
||||||
|
friendRequest.cancelFriendRequest.mockResolvedValue({
|
||||||
|
params: { userId: 'usr_test123' },
|
||||||
|
json: {}
|
||||||
|
});
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Cancel Friend Request');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(friendRequest.cancelFriendRequest).toHaveBeenCalledWith({
|
||||||
|
userId: 'usr_test123'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Moderation Block: should confirm then send moderation', async () => {
|
||||||
|
playerModerationRequest.sendPlayerModeration.mockResolvedValue({
|
||||||
|
json: {
|
||||||
|
targetUserId: 'usr_test123',
|
||||||
|
sourceUserId: 'usr_current',
|
||||||
|
type: 'block'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Moderation Block');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(
|
||||||
|
playerModerationRequest.sendPlayerModeration
|
||||||
|
).toHaveBeenCalledWith({
|
||||||
|
moderated: 'usr_test123',
|
||||||
|
type: 'block'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Moderation Unblock: should confirm then delete moderation', async () => {
|
||||||
|
playerModerationRequest.deletePlayerModeration.mockResolvedValue(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Moderation Unblock');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(
|
||||||
|
playerModerationRequest.deletePlayerModeration
|
||||||
|
).toHaveBeenCalledWith({
|
||||||
|
moderated: 'usr_test123',
|
||||||
|
type: 'block'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Report Hacking: should confirm then report', async () => {
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Report Hacking');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(miscRequest.reportUser).toHaveBeenCalledWith({
|
||||||
|
userId: 'usr_test123',
|
||||||
|
contentType: 'user',
|
||||||
|
reason: 'behavior-hacking',
|
||||||
|
type: 'report'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Unfriend: should confirm then delete friend', async () => {
|
||||||
|
friendRequest.deleteFriend.mockResolvedValue({});
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Unfriend');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(friendRequest.deleteFriend).toHaveBeenCalledWith(
|
||||||
|
{ userId: 'usr_test123' },
|
||||||
|
'dialog.user.actions.unfriend_success_msg'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invite dialog state', () => {
|
||||||
|
it('Request Invite Message: should open send invite request dialog', () => {
|
||||||
|
const {
|
||||||
|
userDialogCommand,
|
||||||
|
sendInviteRequestDialogVisible,
|
||||||
|
sendInviteDialog
|
||||||
|
} = useUserDialogCommands(userDialog, deps);
|
||||||
|
userDialogCommand('Request Invite Message');
|
||||||
|
expect(sendInviteRequestDialogVisible.value).toBe(true);
|
||||||
|
expect(sendInviteDialog.value.userId).toBe('usr_test123');
|
||||||
|
expect(deps.refreshInviteMessageTableData).toHaveBeenCalledWith(
|
||||||
|
'request'
|
||||||
|
);
|
||||||
|
expect(deps.clearInviteImageUpload).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleSendFriendRequest (internal)', () => {
|
||||||
|
it('should add friend log and update dialog state on success', async () => {
|
||||||
|
friendRequest.sendFriendRequest.mockResolvedValue({
|
||||||
|
params: { userId: 'usr_test123' },
|
||||||
|
json: { success: true }
|
||||||
|
});
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Send Friend Request');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(database.addFriendLogHistory).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(userDialog.value.isFriend).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set outgoingRequest when not success', async () => {
|
||||||
|
friendRequest.sendFriendRequest.mockResolvedValue({
|
||||||
|
params: { userId: 'usr_test123' },
|
||||||
|
json: { success: false }
|
||||||
|
});
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Send Friend Request');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(database.addFriendLogHistory).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(userDialog.value.outgoingRequest).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleSendPlayerModeration (internal)', () => {
|
||||||
|
it('should update isBlock when moderation type is block', async () => {
|
||||||
|
deps.applyPlayerModeration = vi.fn(() => ({
|
||||||
|
targetUserId: 'usr_test123',
|
||||||
|
sourceUserId: 'usr_current',
|
||||||
|
type: 'block'
|
||||||
|
}));
|
||||||
|
playerModerationRequest.sendPlayerModeration.mockResolvedValue({
|
||||||
|
json: {
|
||||||
|
targetUserId: 'usr_test123',
|
||||||
|
sourceUserId: 'usr_current',
|
||||||
|
type: 'block'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Moderation Block');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(userDialog.value.isBlock).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update isMute when moderation type is mute', async () => {
|
||||||
|
deps.applyPlayerModeration = vi.fn(() => ({
|
||||||
|
targetUserId: 'usr_test123',
|
||||||
|
sourceUserId: 'usr_current',
|
||||||
|
type: 'mute'
|
||||||
|
}));
|
||||||
|
playerModerationRequest.sendPlayerModeration.mockResolvedValue({
|
||||||
|
json: {
|
||||||
|
targetUserId: 'usr_test123',
|
||||||
|
sourceUserId: 'usr_current',
|
||||||
|
type: 'mute'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
userDialogCommand('Moderation Mute');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(userDialog.value.isMute).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unknown command', () => {
|
||||||
|
it('should do nothing for unknown commands', () => {
|
||||||
|
const { userDialogCommand } = useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
expect(() => userDialogCommand('NonExistentCommand')).not.toThrow();
|
||||||
|
expect(deps.modalStore.confirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
favoriteRequest,
|
||||||
|
friendRequest,
|
||||||
|
miscRequest,
|
||||||
|
notificationRequest,
|
||||||
|
playerModerationRequest,
|
||||||
|
worldRequest
|
||||||
|
} from '../../../api';
|
||||||
|
import { copyToClipboard, parseLocation } from '../../../shared/utils';
|
||||||
|
import { database } from '../../../service/database';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for UserDialog command dispatch.
|
||||||
|
* Uses a command map pattern instead of if-else/switch-case chains.
|
||||||
|
* @param {import('vue').Ref} userDialog - reactive ref to the user dialog state
|
||||||
|
* @param {object} deps - external dependencies
|
||||||
|
* @param deps.t
|
||||||
|
* @param deps.toast
|
||||||
|
* @param deps.modalStore
|
||||||
|
* @param deps.currentUser
|
||||||
|
* @param deps.cachedUsers
|
||||||
|
* @param deps.friendLogTable
|
||||||
|
* @param deps.lastLocation
|
||||||
|
* @param deps.lastLocationDestination
|
||||||
|
* @param deps.inviteGroupDialog
|
||||||
|
* @param deps.showUserDialog
|
||||||
|
* @param deps.showFavoriteDialog
|
||||||
|
* @param deps.showAvatarDialog
|
||||||
|
* @param deps.showAvatarAuthorDialog
|
||||||
|
* @param deps.showModerateGroupDialog
|
||||||
|
* @param deps.showSendBoopDialog
|
||||||
|
* @param deps.showGalleryPage
|
||||||
|
* @param deps.getFriendRequest
|
||||||
|
* @param deps.handleFriendDelete
|
||||||
|
* @param deps.applyPlayerModeration
|
||||||
|
* @param deps.handlePlayerModerationDelete
|
||||||
|
* @param deps.refreshInviteMessageTableData
|
||||||
|
* @param deps.clearInviteImageUpload
|
||||||
|
* @param deps.instanceStore
|
||||||
|
* @param deps.useNotificationStore
|
||||||
|
* @returns {object} command composable API
|
||||||
|
*/
|
||||||
|
export function useUserDialogCommands(
|
||||||
|
userDialog,
|
||||||
|
{
|
||||||
|
t,
|
||||||
|
toast,
|
||||||
|
modalStore,
|
||||||
|
currentUser,
|
||||||
|
cachedUsers,
|
||||||
|
friendLogTable,
|
||||||
|
lastLocation,
|
||||||
|
lastLocationDestination,
|
||||||
|
inviteGroupDialog,
|
||||||
|
showUserDialog,
|
||||||
|
showFavoriteDialog,
|
||||||
|
showAvatarDialog,
|
||||||
|
showAvatarAuthorDialog,
|
||||||
|
showModerateGroupDialog,
|
||||||
|
showSendBoopDialog,
|
||||||
|
showGalleryPage,
|
||||||
|
getFriendRequest,
|
||||||
|
handleFriendDelete,
|
||||||
|
applyPlayerModeration,
|
||||||
|
handlePlayerModerationDelete,
|
||||||
|
refreshInviteMessageTableData,
|
||||||
|
clearInviteImageUpload,
|
||||||
|
instanceStore,
|
||||||
|
useNotificationStore
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
// --- Invite dialog state ---
|
||||||
|
const sendInviteDialogVisible = ref(false);
|
||||||
|
const sendInviteDialog = ref({
|
||||||
|
messageSlot: {},
|
||||||
|
userId: '',
|
||||||
|
params: {}
|
||||||
|
});
|
||||||
|
const sendInviteRequestDialogVisible = ref(false);
|
||||||
|
|
||||||
|
// --- Internal helpers ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} args
|
||||||
|
*/
|
||||||
|
function handleSendFriendRequest(args) {
|
||||||
|
const ref = cachedUsers.get(args.params.userId);
|
||||||
|
if (typeof ref === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const friendLogHistory = {
|
||||||
|
created_at: new Date().toJSON(),
|
||||||
|
type: 'FriendRequest',
|
||||||
|
userId: ref.id,
|
||||||
|
displayName: ref.displayName
|
||||||
|
};
|
||||||
|
friendLogTable.value.data.push(friendLogHistory);
|
||||||
|
database.addFriendLogHistory(friendLogHistory);
|
||||||
|
|
||||||
|
const D = userDialog.value;
|
||||||
|
if (D.visible === false || D.id !== args.params.userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (args.json.success) {
|
||||||
|
D.isFriend = true;
|
||||||
|
} else {
|
||||||
|
D.outgoingRequest = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} args
|
||||||
|
*/
|
||||||
|
function handleCancelFriendRequest(args) {
|
||||||
|
const ref = cachedUsers.get(args.params.userId);
|
||||||
|
if (typeof ref === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const friendLogHistory = {
|
||||||
|
created_at: new Date().toJSON(),
|
||||||
|
type: 'CancelFriendRequest',
|
||||||
|
userId: ref.id,
|
||||||
|
displayName: ref.displayName
|
||||||
|
};
|
||||||
|
friendLogTable.value.data.push(friendLogHistory);
|
||||||
|
database.addFriendLogHistory(friendLogHistory);
|
||||||
|
const D = userDialog.value;
|
||||||
|
if (D.visible === false || D.id !== args.params.userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
D.outgoingRequest = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} args
|
||||||
|
*/
|
||||||
|
function handleSendPlayerModeration(args) {
|
||||||
|
const ref = applyPlayerModeration(args.json);
|
||||||
|
const D = userDialog.value;
|
||||||
|
if (
|
||||||
|
D.visible === false ||
|
||||||
|
(ref.targetUserId !== D.id &&
|
||||||
|
ref.sourceUserId !== currentUser.value.id)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ref.type === 'block') {
|
||||||
|
D.isBlock = true;
|
||||||
|
} else if (ref.type === 'mute') {
|
||||||
|
D.isMute = true;
|
||||||
|
} else if (ref.type === 'interactOff') {
|
||||||
|
D.isInteractOff = true;
|
||||||
|
} else if (ref.type === 'muteChat') {
|
||||||
|
D.isMuteChat = true;
|
||||||
|
}
|
||||||
|
toast.success(t('message.user.moderated'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {number} type
|
||||||
|
*/
|
||||||
|
function setPlayerModeration(userId, type) {
|
||||||
|
const D = userDialog.value;
|
||||||
|
AppApi.SetVRChatUserModeration(currentUser.value.id, userId, type).then(
|
||||||
|
(result) => {
|
||||||
|
if (result) {
|
||||||
|
if (type === 4) {
|
||||||
|
D.isShowAvatar = false;
|
||||||
|
D.isHideAvatar = true;
|
||||||
|
} else if (type === 5) {
|
||||||
|
D.isShowAvatar = true;
|
||||||
|
D.isHideAvatar = false;
|
||||||
|
} else {
|
||||||
|
D.isShowAvatar = false;
|
||||||
|
D.isHideAvatar = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(t('message.avatar.change_moderation_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} params
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
function showSendInviteDialogFn(params, userId) {
|
||||||
|
sendInviteDialog.value = {
|
||||||
|
params,
|
||||||
|
userId,
|
||||||
|
messageSlot: {}
|
||||||
|
};
|
||||||
|
refreshInviteMessageTableData('message');
|
||||||
|
clearInviteImageUpload();
|
||||||
|
sendInviteDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} params
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
function showSendInviteRequestDialogFn(params, userId) {
|
||||||
|
sendInviteDialog.value = {
|
||||||
|
params,
|
||||||
|
userId,
|
||||||
|
messageSlot: {}
|
||||||
|
};
|
||||||
|
refreshInviteMessageTableData('request');
|
||||||
|
clearInviteImageUpload();
|
||||||
|
sendInviteRequestDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Command map ---
|
||||||
|
// Direct commands: function
|
||||||
|
// Confirmed commands: { confirm: true, handler: fn }
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function buildCommandMap() {
|
||||||
|
const D = () => userDialog.value;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// --- Direct commands ---
|
||||||
|
Refresh: () => {
|
||||||
|
const userId = D().id;
|
||||||
|
D().id = '';
|
||||||
|
showUserDialog(userId);
|
||||||
|
},
|
||||||
|
Share: () => {
|
||||||
|
copyToClipboard(
|
||||||
|
`https://vrchat.com/home/user/${D().id}`,
|
||||||
|
'User URL copied to clipboard'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'Add Favorite': () => {
|
||||||
|
showFavoriteDialog('friend', D().id);
|
||||||
|
},
|
||||||
|
'Edit Social Status': 'showSocialStatusDialog',
|
||||||
|
'Edit Language': 'showLanguageDialog',
|
||||||
|
'Edit Bio': 'showBioDialog',
|
||||||
|
'Edit Pronouns': 'showPronounsDialog',
|
||||||
|
'Request Invite': () => {
|
||||||
|
notificationRequest
|
||||||
|
.sendRequestInvite(
|
||||||
|
{
|
||||||
|
platform: 'standalonewindows'
|
||||||
|
},
|
||||||
|
D().id
|
||||||
|
)
|
||||||
|
.then((args) => {
|
||||||
|
toast('Request invite sent');
|
||||||
|
return args;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'Invite Message': () => {
|
||||||
|
const L = parseLocation(lastLocation.value.location);
|
||||||
|
worldRequest
|
||||||
|
.getCachedWorld({
|
||||||
|
worldId: L.worldId
|
||||||
|
})
|
||||||
|
.then((args) => {
|
||||||
|
showSendInviteDialogFn(
|
||||||
|
{
|
||||||
|
instanceId: lastLocation.value.location,
|
||||||
|
worldId: lastLocation.value.location,
|
||||||
|
worldName: args.ref.name
|
||||||
|
},
|
||||||
|
D().id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'Request Invite Message': () => {
|
||||||
|
showSendInviteRequestDialogFn(
|
||||||
|
{
|
||||||
|
platform: 'standalonewindows'
|
||||||
|
},
|
||||||
|
D().id
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Invite: () => {
|
||||||
|
let currentLocation = lastLocation.value.location;
|
||||||
|
if (lastLocation.value.location === 'traveling') {
|
||||||
|
currentLocation = lastLocationDestination.value;
|
||||||
|
}
|
||||||
|
const L = parseLocation(currentLocation);
|
||||||
|
worldRequest
|
||||||
|
.getCachedWorld({
|
||||||
|
worldId: L.worldId
|
||||||
|
})
|
||||||
|
.then((args) => {
|
||||||
|
notificationRequest
|
||||||
|
.sendInvite(
|
||||||
|
{
|
||||||
|
instanceId: L.tag,
|
||||||
|
worldId: L.tag,
|
||||||
|
worldName: args.ref.name
|
||||||
|
},
|
||||||
|
D().id
|
||||||
|
)
|
||||||
|
.then((_args) => {
|
||||||
|
toast(t('message.invite.sent'));
|
||||||
|
return _args;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'Show Avatar Author': () => {
|
||||||
|
const { currentAvatarImageUrl } = D().ref;
|
||||||
|
showAvatarAuthorDialog(
|
||||||
|
D().id,
|
||||||
|
D().$avatarInfo.ownerId,
|
||||||
|
currentAvatarImageUrl
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'Show Fallback Avatar Details': () => {
|
||||||
|
const { fallbackAvatar } = D().ref;
|
||||||
|
if (fallbackAvatar) {
|
||||||
|
showAvatarDialog(fallbackAvatar);
|
||||||
|
} else {
|
||||||
|
toast.error('No fallback avatar set');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Previous Instances': () => {
|
||||||
|
instanceStore.showPreviousInstancesListDialog('user', D().ref);
|
||||||
|
},
|
||||||
|
'Manage Gallery': () => {
|
||||||
|
userDialog.value.visible = false;
|
||||||
|
showGalleryPage();
|
||||||
|
},
|
||||||
|
'Invite To Group': () => {
|
||||||
|
inviteGroupDialog.value.groupId = '';
|
||||||
|
inviteGroupDialog.value.userId = D().id;
|
||||||
|
inviteGroupDialog.value.visible = true;
|
||||||
|
},
|
||||||
|
'Send Boop': () => {
|
||||||
|
showSendBoopDialog(D().id);
|
||||||
|
},
|
||||||
|
'Group Moderation': () => {
|
||||||
|
showModerateGroupDialog(D().id);
|
||||||
|
},
|
||||||
|
'Hide Avatar': () => {
|
||||||
|
if (D().isHideAvatar) {
|
||||||
|
setPlayerModeration(D().id, 0);
|
||||||
|
} else {
|
||||||
|
setPlayerModeration(D().id, 4);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Show Avatar': () => {
|
||||||
|
if (D().isShowAvatar) {
|
||||||
|
setPlayerModeration(D().id, 0);
|
||||||
|
} else {
|
||||||
|
setPlayerModeration(D().id, 5);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Edit Note Memo': 'showEditNoteAndMemoDialog',
|
||||||
|
|
||||||
|
// --- Confirmed commands ---
|
||||||
|
'Delete Favorite': {
|
||||||
|
confirm: true,
|
||||||
|
handler: (userId) => {
|
||||||
|
favoriteRequest.deleteFavorite({
|
||||||
|
objectId: userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Accept Friend Request': {
|
||||||
|
confirm: true,
|
||||||
|
handler: async (userId) => {
|
||||||
|
const key = getFriendRequest(userId);
|
||||||
|
if (key === '') {
|
||||||
|
const args = await friendRequest.sendFriendRequest({
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
handleSendFriendRequest(args);
|
||||||
|
} else {
|
||||||
|
notificationRequest
|
||||||
|
.acceptFriendRequestNotification({
|
||||||
|
notificationId: key
|
||||||
|
})
|
||||||
|
.then((args) => {
|
||||||
|
useNotificationStore().handleNotificationAccept(
|
||||||
|
args
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (
|
||||||
|
err &&
|
||||||
|
err.message &&
|
||||||
|
err.message.includes('404')
|
||||||
|
) {
|
||||||
|
useNotificationStore().handleNotificationHide(
|
||||||
|
key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Decline Friend Request': {
|
||||||
|
confirm: true,
|
||||||
|
handler: async (userId) => {
|
||||||
|
const key = getFriendRequest(userId);
|
||||||
|
if (key === '') {
|
||||||
|
const args = await friendRequest.cancelFriendRequest({
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
handleCancelFriendRequest(args);
|
||||||
|
} else {
|
||||||
|
notificationRequest
|
||||||
|
.hideNotification({
|
||||||
|
notificationId: key
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
useNotificationStore().handleNotificationHide(
|
||||||
|
key
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Cancel Friend Request': {
|
||||||
|
confirm: true,
|
||||||
|
handler: async (userId) => {
|
||||||
|
const args = await friendRequest.cancelFriendRequest({
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
handleCancelFriendRequest(args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Send Friend Request': {
|
||||||
|
confirm: true,
|
||||||
|
handler: async (userId) => {
|
||||||
|
const args = await friendRequest.sendFriendRequest({
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
handleSendFriendRequest(args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Moderation Unblock': {
|
||||||
|
confirm: true,
|
||||||
|
handler: async (userId) => {
|
||||||
|
const args =
|
||||||
|
await playerModerationRequest.deletePlayerModeration({
|
||||||
|
moderated: userId,
|
||||||
|
type: 'block'
|
||||||
|
});
|
||||||
|
handlePlayerModerationDelete(args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Moderation Block': {
|
||||||
|
confirm: true,
|
||||||
|
handler: async (userId) => {
|
||||||
|
const args =
|
||||||
|
await playerModerationRequest.sendPlayerModeration({
|
||||||
|
moderated: userId,
|
||||||
|
type: 'block'
|
||||||
|
});
|
||||||
|
handleSendPlayerModeration(args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Moderation Unmute': {
|
||||||
|
confirm: true,
|
||||||
|
handler: async (userId) => {
|
||||||
|
const args =
|
||||||
|
await playerModerationRequest.deletePlayerModeration({
|
||||||
|
moderated: userId,
|
||||||
|
type: 'mute'
|
||||||
|
});
|
||||||
|
handlePlayerModerationDelete(args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Moderation Mute': {
|
||||||
|
confirm: true,
|
||||||
|
handler: async (userId) => {
|
||||||
|
const args =
|
||||||
|
await playerModerationRequest.sendPlayerModeration({
|
||||||
|
moderated: userId,
|
||||||
|
type: 'mute'
|
||||||
|
});
|
||||||
|
handleSendPlayerModeration(args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Moderation Enable Avatar Interaction': {
|
||||||
|
confirm: true,
|
||||||
|
handler: async (userId) => {
|
||||||
|
const args =
|
||||||
|
await playerModerationRequest.deletePlayerModeration({
|
||||||
|
moderated: userId,
|
||||||
|
type: 'interactOff'
|
||||||
|
});
|
||||||
|
handlePlayerModerationDelete(args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Moderation Disable Avatar Interaction': {
|
||||||
|
confirm: true,
|
||||||
|
handler: async (userId) => {
|
||||||
|
const args =
|
||||||
|
await playerModerationRequest.sendPlayerModeration({
|
||||||
|
moderated: userId,
|
||||||
|
type: 'interactOff'
|
||||||
|
});
|
||||||
|
handleSendPlayerModeration(args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Moderation Enable Chatbox': {
|
||||||
|
confirm: true,
|
||||||
|
handler: async (userId) => {
|
||||||
|
const args =
|
||||||
|
await playerModerationRequest.deletePlayerModeration({
|
||||||
|
moderated: userId,
|
||||||
|
type: 'muteChat'
|
||||||
|
});
|
||||||
|
handlePlayerModerationDelete(args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Moderation Disable Chatbox': {
|
||||||
|
confirm: true,
|
||||||
|
handler: async (userId) => {
|
||||||
|
const args =
|
||||||
|
await playerModerationRequest.sendPlayerModeration({
|
||||||
|
moderated: userId,
|
||||||
|
type: 'muteChat'
|
||||||
|
});
|
||||||
|
handleSendPlayerModeration(args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Report Hacking': {
|
||||||
|
confirm: true,
|
||||||
|
handler: (userId) => {
|
||||||
|
miscRequest.reportUser({
|
||||||
|
userId,
|
||||||
|
contentType: 'user',
|
||||||
|
reason: 'behavior-hacking',
|
||||||
|
type: 'report'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Unfriend: {
|
||||||
|
confirm: true,
|
||||||
|
handler: async (userId) => {
|
||||||
|
const args = await friendRequest.deleteFriend(
|
||||||
|
{
|
||||||
|
userId
|
||||||
|
},
|
||||||
|
t('dialog.user.actions.unfriend_success_msg')
|
||||||
|
);
|
||||||
|
handleFriendDelete(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandMap = buildCommandMap();
|
||||||
|
|
||||||
|
// Callbacks for string-type commands (delegated to component)
|
||||||
|
let componentCallbacks = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register component-level callbacks for string-type commands.
|
||||||
|
* These are simple dialog openers that stay in the component.
|
||||||
|
* @param {object} callbacks
|
||||||
|
*/
|
||||||
|
function registerCallbacks(callbacks) {
|
||||||
|
componentCallbacks = callbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a user dialog command.
|
||||||
|
* @param {string} command
|
||||||
|
*/
|
||||||
|
function userDialogCommand(command) {
|
||||||
|
const D = userDialog.value;
|
||||||
|
if (D.visible === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 i18nPreFix = 'dialog.user.actions.';
|
||||||
|
const formattedCommand = command.toLowerCase().replace(/ /g, '_');
|
||||||
|
const displayCommandText = t(
|
||||||
|
`${i18nPreFix}${formattedCommand}`
|
||||||
|
).includes('i18nPreFix')
|
||||||
|
? command
|
||||||
|
: t(`${i18nPreFix}${formattedCommand}`);
|
||||||
|
|
||||||
|
modalStore
|
||||||
|
.confirm({
|
||||||
|
description: t('confirm.message', {
|
||||||
|
command: displayCommandText
|
||||||
|
}),
|
||||||
|
title: t('confirm.title'),
|
||||||
|
confirmText: t('confirm.confirm_button'),
|
||||||
|
cancelText: t('confirm.cancel_button')
|
||||||
|
})
|
||||||
|
.then(({ ok }) => {
|
||||||
|
if (ok) {
|
||||||
|
entry.handler(D.id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendInviteDialogVisible,
|
||||||
|
sendInviteDialog,
|
||||||
|
sendInviteRequestDialogVisible,
|
||||||
|
userDialogCommand,
|
||||||
|
registerCallbacks
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -59,10 +59,7 @@
|
|||||||
return props.worldAllowedDomainsDialog.visible;
|
return props.worldAllowedDomainsDialog.visible;
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
emit('update:worldAllowedDomainsDialog', {
|
props.worldAllowedDomainsDialog.visible = val;
|
||||||
...props.worldAllowedDomainsDialog,
|
|
||||||
visible: val
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,305 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-wrap items-start px-2.5" style="max-height: none">
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.memo') }}
|
||||||
|
</span>
|
||||||
|
<InputGroupTextareaField
|
||||||
|
v-model="memo"
|
||||||
|
class="text-xs"
|
||||||
|
:rows="2"
|
||||||
|
:placeholder="t('dialog.world.info.memo_placeholder')"
|
||||||
|
input-class="resize-none min-h-0"
|
||||||
|
@change="onWorldMemoChange" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="width: 100%; display: flex">
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.id') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs" style="display: inline">
|
||||||
|
{{ worldDialog.id }}
|
||||||
|
</span>
|
||||||
|
<TooltipWrapper side="top" :content="t('dialog.world.info.id_tooltip')">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button class="rounded-full text-xs" size="icon-sm" variant="ghost" @click.stop
|
||||||
|
><Copy class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem @click="copyWorldId()">
|
||||||
|
{{ t('dialog.world.info.copy_id') }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click="copyWorldUrl()">
|
||||||
|
{{ t('dialog.world.info.copy_url') }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click="copyWorldName()">
|
||||||
|
{{ t('dialog.world.info.copy_name') }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TooltipWrapper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="worldDialog.ref.previewYoutubeId"
|
||||||
|
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer"
|
||||||
|
style="width: 350px"
|
||||||
|
@click="openExternalLink(`https://www.youtube.com/watch?v=${worldDialog.ref.previewYoutubeId}`)">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.youtube_preview') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
https://www.youtube.com/watch?v={{ worldDialog.ref.previewYoutubeId }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.author_tags') }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="worldDialog.ref.tags?.filter((tag) => tag.startsWith('author_tag')).length > 0"
|
||||||
|
class="block truncate text-xs">
|
||||||
|
{{ worldTags }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="block truncate text-xs"> - </span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.players') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
{{ commaNumber(worldDialog.ref.occupants) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.favorites') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
{{ commaNumber(worldDialog.ref.favorites)
|
||||||
|
}}<span v-if="worldDialog.ref?.favorites > 0 && worldDialog.ref?.visits > 0" class="text-xs">
|
||||||
|
({{ favoriteRate }}%)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.visits') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
{{ commaNumber(worldDialog.ref.visits) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.capacity') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
{{ commaNumber(worldDialog.ref.recommendedCapacity) }} ({{ commaNumber(worldDialog.ref.capacity) }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.created_at') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
{{ formatDateFilter(worldDialog.ref.created_at, 'long') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]" style="display: inline">
|
||||||
|
{{ t('dialog.world.info.last_updated') }}
|
||||||
|
</span>
|
||||||
|
<TooltipWrapper v-if="Object.keys(worldDialog.fileAnalysis).length" side="top" style="margin-left: 6px">
|
||||||
|
<template #content>
|
||||||
|
<template v-for="(created_at, platform) in worldDialogPlatformCreatedAt" :key="platform">
|
||||||
|
<div class="flex justify-between w-full">
|
||||||
|
<span class="mr-1">{{ platform }}:</span>
|
||||||
|
<span>{{ formatDateFilter(created_at, 'long') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<ChevronDown class="inline-block" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
{{ formatDateFilter(worldDialog.ref.updated_at, 'long') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="worldDialog.ref.labsPublicationDate !== 'none'"
|
||||||
|
class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.labs_publication_date') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
{{ formatDateFilter(worldDialog.ref.labsPublicationDate, 'long') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="worldDialog.ref.publicationDate !== 'none'"
|
||||||
|
class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]" style="display: inline">
|
||||||
|
{{ t('dialog.world.info.publication_date') }}
|
||||||
|
</span>
|
||||||
|
<TooltipWrapper v-if="isTimeInLabVisible" side="top" style="margin-left: 6px">
|
||||||
|
<template #content>
|
||||||
|
<span>
|
||||||
|
{{ t('dialog.world.info.time_in_labs') }}
|
||||||
|
{{ timeInLab }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ChevronDown class="inline-block" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
{{ formatDateFilter(worldDialog.ref.publicationDate, 'long') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.version') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs" v-text="worldDialog.ref.version" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.heat') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
{{ commaNumber(worldDialog.ref.heat) }} {{ '🔥'.repeat(worldDialog.ref.heat) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.popularity') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
{{ commaNumber(worldDialog.ref.popularity) }}
|
||||||
|
{{ '💖'.repeat(worldDialog.ref.popularity) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.platform') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs" style="white-space: normal">{{ worldDialogPlatform }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.last_visited') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs">{{ formatDateFilter(worldDialog.lastVisit, 'long') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
|
||||||
|
@click="showPreviousInstancesListDialog(worldDialog.ref)">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="block truncate font-medium leading-[18px]"
|
||||||
|
style="display: flex; justify-content: space-between; align-items: center">
|
||||||
|
<div>
|
||||||
|
{{ t('dialog.world.info.visit_count') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TooltipWrapper side="top" :content="t('dialog.user.info.open_previous_instance')">
|
||||||
|
<MoreHorizontal style="margin-right: 16px" />
|
||||||
|
</TooltipWrapper>
|
||||||
|
</div>
|
||||||
|
<span v-if="worldDialog.visitCount === 0" class="block truncate text-xs">-</span>
|
||||||
|
<span v-else class="block truncate text-xs" v-text="worldDialog.visitCount"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span class="block truncate font-medium leading-[18px]">
|
||||||
|
{{ t('dialog.world.info.time_spent') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
{{ worldDialog.timeSpent === 0 ? ' - ' : timeSpent }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ChevronDown, Copy, MoreHorizontal } from 'lucide-vue-next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { InputGroupTextareaField } from '@/components/ui/input-group';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../ui/dropdown-menu';
|
||||||
|
import { useInstanceStore, useWorldStore } from '../../../stores';
|
||||||
|
import { openExternalLink } from '../../../shared/utils';
|
||||||
|
import { useWorldDialogInfo } from './useWorldDialogInfo';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const { worldDialog } = storeToRefs(useWorldStore());
|
||||||
|
const { showPreviousInstancesListDialog: openPreviousInstancesListDialog } = useInstanceStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
memo,
|
||||||
|
isTimeInLabVisible,
|
||||||
|
timeInLab,
|
||||||
|
favoriteRate,
|
||||||
|
worldTags,
|
||||||
|
timeSpent,
|
||||||
|
worldDialogPlatform,
|
||||||
|
worldDialogPlatformCreatedAt,
|
||||||
|
onWorldMemoChange,
|
||||||
|
copyWorldId,
|
||||||
|
copyWorldUrl,
|
||||||
|
copyWorldName,
|
||||||
|
commaNumber,
|
||||||
|
formatDateFilter
|
||||||
|
} = useWorldDialogInfo(worldDialog, { t, toast });
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param worldRef
|
||||||
|
*/
|
||||||
|
function showPreviousInstancesListDialog(worldRef) {
|
||||||
|
openPreviousInstancesListDialog('world', worldRef);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center text-sm">
|
||||||
|
<User />
|
||||||
|
{{ t('dialog.world.instances.public_count', { count: worldDialog.ref.publicOccupants }) }}
|
||||||
|
<User style="margin-left: 8px" />
|
||||||
|
{{
|
||||||
|
t('dialog.world.instances.private_count', {
|
||||||
|
count: worldDialog.ref.privateOccupants
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
<Check style="margin-left: 8px" />
|
||||||
|
{{
|
||||||
|
t('dialog.world.instances.capacity_count', {
|
||||||
|
count: worldDialog.ref.recommendedCapacity,
|
||||||
|
max: worldDialog.ref.capacity
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div v-for="room in worldDialog.rooms" :key="room.id">
|
||||||
|
<template v-if="isAgeGatedInstancesVisible || !(room.ageGate || room.location?.includes('~ageGate'))">
|
||||||
|
<div style="margin: 6px 0">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<LocationWorld
|
||||||
|
class="text-sm"
|
||||||
|
:locationobject="room.$location"
|
||||||
|
:currentuserid="currentUser.id"
|
||||||
|
:worlddialogshortname="worldDialog.$location.shortName" />
|
||||||
|
<InstanceActionBar
|
||||||
|
class="ml-1 text-sm"
|
||||||
|
:location="room.$location.tag"
|
||||||
|
:launch-location="room.tag"
|
||||||
|
:instance-location="room.tag"
|
||||||
|
:shortname="room.$location.shortName"
|
||||||
|
:currentlocation="lastLocation.location"
|
||||||
|
:instance="room.ref"
|
||||||
|
:friendcount="room.friendCount"
|
||||||
|
:refresh-tooltip="t('dialog.world.instances.refresh_instance_info')"
|
||||||
|
:show-history="!!instanceJoinHistory.get(room.$location.tag)"
|
||||||
|
:history-tooltip="t('dialog.previous_instances.info')"
|
||||||
|
:on-refresh="() => refreshInstancePlayerCount(room.tag)"
|
||||||
|
:on-history="() => showPreviousInstancesInfoDialog(room.location)" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="room.$location.userId || room.users.length"
|
||||||
|
class="flex flex-wrap items-start"
|
||||||
|
style="margin: 8px 0; max-height: unset">
|
||||||
|
<div
|
||||||
|
v-if="room.$location.userId"
|
||||||
|
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||||
|
@click="showUserDialog(room.$location.userId)">
|
||||||
|
<template v-if="room.$location.user">
|
||||||
|
<div
|
||||||
|
class="relative inline-block flex-none size-9 mr-2.5"
|
||||||
|
:class="userStatusClass(room.$location.user)">
|
||||||
|
<img
|
||||||
|
class="size-full rounded-full object-cover"
|
||||||
|
:src="userImage(room.$location.user, true)"
|
||||||
|
loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span
|
||||||
|
class="block truncate font-medium leading-[18px]"
|
||||||
|
:style="{ color: room.$location.user.$userColour }"
|
||||||
|
v-text="room.$location.user.displayName" />
|
||||||
|
<span class="block truncate text-xs">
|
||||||
|
{{ t('dialog.world.instances.instance_creator') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span v-else v-text="room.$location.userId" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="user in room.users"
|
||||||
|
:key="user.id"
|
||||||
|
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||||
|
@click="showUserDialog(user.id)">
|
||||||
|
<div class="relative inline-block flex-none size-9 mr-2.5" :class="userStatusClass(user)">
|
||||||
|
<img
|
||||||
|
class="size-full rounded-full object-cover"
|
||||||
|
:src="userImage(user, true)"
|
||||||
|
loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<span
|
||||||
|
class="block truncate font-medium leading-[18px]"
|
||||||
|
:style="{ color: user.$userColour }"
|
||||||
|
v-text="user.displayName" />
|
||||||
|
<span v-if="user.location === 'traveling'" class="block truncate text-xs">
|
||||||
|
<Spinner class="inline-block mr-1" />
|
||||||
|
<Timer :epoch="user.$travelingToTime" />
|
||||||
|
</span>
|
||||||
|
<span v-else class="block truncate text-xs">
|
||||||
|
<Timer :epoch="user.$location_at" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Check, User } from 'lucide-vue-next';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { refreshInstancePlayerCount, userImage, userStatusClass } from '../../../shared/utils';
|
||||||
|
import {
|
||||||
|
useAppearanceSettingsStore,
|
||||||
|
useInstanceStore,
|
||||||
|
useLocationStore,
|
||||||
|
useUserStore,
|
||||||
|
useWorldStore
|
||||||
|
} from '../../../stores';
|
||||||
|
|
||||||
|
import InstanceActionBar from '../../InstanceActionBar.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const { isAgeGatedInstancesVisible } = storeToRefs(useAppearanceSettingsStore());
|
||||||
|
const { showUserDialog } = useUserStore();
|
||||||
|
const { currentUser } = storeToRefs(useUserStore());
|
||||||
|
const { worldDialog } = storeToRefs(useWorldStore());
|
||||||
|
const { lastLocation } = storeToRefs(useLocationStore());
|
||||||
|
const { showPreviousInstancesInfoDialog } = useInstanceStore();
|
||||||
|
const { instanceJoinHistory } = storeToRefs(useInstanceStore());
|
||||||
|
</script>
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+5
-5
@@ -168,14 +168,11 @@ export const useGroupStore = defineStore('Group', () => {
|
|||||||
D.members = [];
|
D.members = [];
|
||||||
D.memberFilter = groupDialogFilterOptions.everyone;
|
D.memberFilter = groupDialogFilterOptions.everyone;
|
||||||
D.calendar = [];
|
D.calendar = [];
|
||||||
const loadGroupRequest = forceRefresh
|
const loadGroupRequest = groupRequest.getGroup({
|
||||||
? groupRequest.getGroup({
|
|
||||||
groupId,
|
groupId,
|
||||||
includeRoles: false
|
includeRoles: false
|
||||||
})
|
|
||||||
: groupRequest.getCachedGroup({
|
|
||||||
groupId
|
|
||||||
});
|
});
|
||||||
|
|
||||||
loadGroupRequest
|
loadGroupRequest
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
D.loading = false;
|
D.loading = false;
|
||||||
@@ -962,6 +959,9 @@ export const useGroupStore = defineStore('Group', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function clearGroupInstances() {
|
function clearGroupInstances() {
|
||||||
groupInstances.value = [];
|
groupInstances.value = [];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user