This commit is contained in:
pa
2026-03-09 02:49:59 +09:00
parent 64b27ce7f1
commit 90a17bb0ba
39 changed files with 9487 additions and 4384 deletions

View File

@@ -602,50 +602,26 @@
</template>
<script setup>
import {
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 { Copy, Info, Languages, MoreHorizontal, Pencil, Trash2 } from 'lucide-vue-next';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
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 { TabsUnderline } from '@/components/ui/tabs';
import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
import DeprecationAlert from '@/components/DeprecationAlert.vue';
import VueJsonPretty from 'vue-json-pretty';
import {
useAdvancedSettingsStore,
useAppearanceSettingsStore,
useAuthStore,
useAvatarStore,
useFavoriteStore,
useFriendStore,
@@ -657,19 +633,16 @@
useModalStore,
useModerationStore,
useNotificationStore,
useUiStore,
useUserStore,
useWorldStore
} from '../../../stores';
import {
copyToClipboard,
downloadAndSaveJson,
formatDateFilter,
getFaviconUrl,
isFriendOnline,
isRealInstance,
openExternalLink,
parseLocation,
refreshInstancePlayerCount,
timeToText,
userImage,
@@ -677,18 +650,9 @@
userOnlineForTimestamp,
userStatusClass
} from '../../../shared/utils';
import {
favoriteRequest,
friendRequest,
miscRequest,
notificationRequest,
playerModerationRequest,
userRequest,
worldRequest
} from '../../../api';
import { database } from '../../../service/database';
import { miscRequest, userRequest } from '../../../api';
import { formatJsonVars } from '../../../shared/utils/base/ui';
import { processBulk } from '../../../service/request';
import { useUserDialogCommands } from './useUserDialogCommands';
import DialogJsonTab from '../DialogJsonTab.vue';
import InstanceActionBar from '../../InstanceActionBar.vue';
@@ -757,6 +721,39 @@
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(
() => userDialog.value.loading,
() => {
@@ -778,14 +775,6 @@
const userDialogLastWorld = ref('');
const userDialogLastFavoriteWorld = ref('');
const sendInviteDialogVisible = ref(false);
const sendInviteDialog = ref({
messageSlot: {},
userId: '',
params: {}
});
const sendInviteRequestDialogVisible = ref(false);
const socialStatusDialog = ref({
visible: false,
loading: false,
@@ -966,6 +955,17 @@
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'
});
}
/**
*
*/

View File

@@ -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();
});
});
});

View File

@@ -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
};
}