This commit is contained in:
pa
2026-03-09 14:24:00 +09:00
parent c26c562d0e
commit d2d3dc8f13
32 changed files with 1128 additions and 801 deletions

View File

@@ -226,9 +226,7 @@ describe('StatusBar.vue - Servers indicator', () => {
test('shows Servers indicator with green dot when no issues', () => {
const wrapper = mountStatusBar();
expect(wrapper.text()).toContain('Servers');
const serversDots = wrapper.findAll(
'.bg-\\[var\\(--status-online\\)\\]'
);
const serversDots = wrapper.findAll('.bg-status-online');
expect(serversDots.length).toBeGreaterThan(0);
expect(wrapper.find('.bg-\\[\\#e6a23c\\]').exists()).toBe(false);
});

View File

@@ -6,10 +6,11 @@ import { mount } from '@vue/test-utils';
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key)
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key),
locale: require('vue').ref('en')
}),
createI18n: () => ({
global: { t: (key) => key },
global: { t: (key) => key , locale: require('vue').ref('en') },
install: vi.fn()
})
}));

View File

@@ -4,15 +4,20 @@ 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('vue-i18n', () => {
const { ref } = require('vue');
return {
useI18n: () => ({
t: (key, params) =>
params ? `${key}:${JSON.stringify(params)}` : key,
locale: ref('en')
}),
createI18n: () => ({
global: { t: (key) => key, locale: ref('en') },
install: vi.fn()
})
};
});
vi.mock('../../../../plugin/router', () => {
const { ref } = require('vue');

View File

@@ -4,9 +4,11 @@ import { mount } from '@vue/test-utils';
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
}),
,
locale: require('vue').ref('en')
}),
createI18n: () => ({
global: { t: (key) => key },
global: { t: (key) => key , locale: require('vue').ref('en') },
install: vi.fn()
})
}));

View File

@@ -80,9 +80,11 @@ vi.mock('../../../../service/request', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
}),
,
locale: require('vue').ref('en')
}),
createI18n: () => ({
global: { t: (key) => key },
global: { t: (key) => key , locale: require('vue').ref('en') },
install: vi.fn()
})
}));

View File

@@ -5,9 +5,11 @@ vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
}),
,
locale: require('vue').ref('en')
}),
createI18n: () => ({
global: { t: (key) => key },
global: { t: (key) => key , locale: require('vue').ref('en') },
install: vi.fn()
})
}));

View File

@@ -19,538 +19,7 @@
:unmount-on-hide="false"
@update:modelValue="userDialogTabClick">
<template #Info>
<template v-if="isFriendOnline(userDialog.friend) || currentUser.id === userDialog.id">
<div
class="mb-2 pb-2 border-b border-border"
v-if="userDialog.ref.location"
style="display: flex; flex-direction: column">
<div style="flex: none">
<template v-if="isRealInstance(userDialog.$location.tag)">
<InstanceActionBar
class="mb-1"
:location="userDialog.$location.tag"
:shortname="userDialog.$location.shortName"
:currentlocation="lastLocation.location"
:instance="userDialog.instance.ref"
:friendcount="userDialog.instance.friendCount"
:refresh-tooltip="t('dialog.user.info.refresh_instance_info')"
:on-refresh="() => refreshInstancePlayerCount(userDialog.$location.tag)" />
</template>
<Location
class="text-sm"
:location="userDialog.ref.location"
:traveling="userDialog.ref.travelingToLocation" />
</div>
<div
class="flex flex-wrap items-start"
style="flex: 1; margin-top: 8px; max-height: 150px; overflow: auto">
<div
v-if="userDialog.$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(userDialog.$location.userId)">
<template v-if="userDialog.$location.user">
<div
class="relative inline-block flex-none size-9 mr-2.5"
:class="userStatusClass(userDialog.$location.user)">
<img
class="size-full rounded-full object-cover"
:src="userImage(userDialog.$location.user, true)"
loading="lazy" />
</div>
<div class="flex-1 overflow-hidden">
<span
class="block truncate font-medium leading-[18px]"
:style="{ color: userDialog.$location.user.$userColour }"
v-text="userDialog.$location.user.displayName"></span>
<span class="block truncate text-xs">{{
t('dialog.user.info.instance_creator')
}}</span>
</div>
</template>
<span v-else v-text="userDialog.$location.userId"></span>
</div>
<div
v-for="user in userDialog.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>
<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 class="flex flex-wrap items-start px-2.5" style="max-height: none">
<div
v-if="userDialog.note && !hideUserNotes"
class="box-border flex items-center p-1.5 text-[13px] w-full cursor-pointer">
<div class="flex-1 overflow-hidden" @click="isEditNoteAndMemoDialogVisible = true">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.user.info.note')
}}</span>
<pre
class="text-xs"
style="
font-family: inherit;
font-size: 12px;
white-space: pre-wrap;
margin: 0 0.5em 0 0;
max-height: 210px;
overflow-y: auto;
"
>{{ userDialog.note }}</pre
>
</div>
</div>
<div
v-if="userDialog.memo && !hideUserMemos"
class="box-border flex items-center p-1.5 text-[13px] w-full cursor-pointer">
<div class="flex-1 overflow-hidden" @click="isEditNoteAndMemoDialogVisible = true">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.user.info.memo')
}}</span>
<pre
class="text-xs"
style="
font-family: inherit;
font-size: 12px;
white-space: pre-wrap;
margin: 0 0.5em 0 0;
max-height: 210px;
overflow-y: auto;
"
>{{ userDialog.memo }}</pre
>
</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]">
{{
userDialog.id !== currentUser.id &&
userDialog.ref.profilePicOverride &&
userDialog.ref.currentAvatarImageUrl
? t('dialog.user.info.avatar_info_last_seen')
: t('dialog.user.info.avatar_info')
}}
<TooltipWrapper
v-if="userDialog.ref.profilePicOverride && !userDialog.ref.currentAvatarImageUrl"
side="top"
:content="t('dialog.user.info.vrcplus_hides_avatar')">
<Info class="inline-block" />
</TooltipWrapper>
</span>
<div class="text-xs">
<AvatarInfo
:key="userDialog.id"
:imageurl="userDialog.ref.currentAvatarImageUrl"
:userid="userDialog.id"
:avatartags="userDialog.ref.currentAvatarTags"
style="display: inline-block" />
</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]" style="margin-bottom: 6px">{{
t('dialog.user.info.represented_group')
}}</span>
<div
v-if="
userDialog.isRepresentedGroupLoading ||
(userDialog.representedGroup && userDialog.representedGroup.isRepresenting)
"
class="text-xs">
<div style="display: inline-block; flex: none; margin-right: 6px">
<Avatar
class="cursor-pointer size-15! rounded-lg!"
:style="{
background: userDialog.isRepresentedGroupLoading ? 'var(--muted)' : ''
}"
@click="showFullscreenImageDialog(userDialog.representedGroup.iconUrl)">
<AvatarImage
:src="userDialog.representedGroup.$thumbnailUrl"
@load="userDialog.isRepresentedGroupLoading = false"
@error="userDialog.isRepresentedGroupLoading = false" />
<AvatarFallback class="rounded-lg!" />
</Avatar>
</div>
<span
v-if="userDialog.representedGroup.isRepresenting"
style="vertical-align: top; cursor: pointer"
@click="showGroupDialog(userDialog.representedGroup.groupId)">
<span
v-if="userDialog.representedGroup.ownerId === userDialog.id"
style="margin-right: 6px"
>👑</span
>
<span style="margin-right: 6px" v-text="userDialog.representedGroup.name"></span>
<span>({{ userDialog.representedGroup.memberCount }})</span>
</span>
</div>
<div v-else class="text-xs">-</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.user.info.bio')
}}</span>
<pre
class="text-xs truncate"
style="
font-family: inherit;
font-size: 12px;
white-space: pre-wrap;
margin: 0 0.5em 0 0;
max-height: 210px;
overflow-y: auto;
"
>{{ bioCache.translated || userDialog.ref.bio || '-' }}</pre
>
<div style="float: right">
<Button
v-if="translationApi && userDialog.ref.bio"
class="w-3 h-6 text-xs mr-0.5"
size="icon-sm"
variant="ghost"
@click="translateBio">
<Spinner v-if="translateLoading" class="size-1" />
<Languages v-else class="h-3 w-3" />
</Button>
<Button
class="w-3 h-6 text-xs"
size="icon-sm"
variant="ghost"
v-if="userDialog.id === currentUser.id"
style="margin-left: 6px; padding: 0"
@click="showBioDialog"
><Pencil class="h-3 w-3" />
</Button>
</div>
<div style="margin-top: 6px" class="flex items-center">
<TooltipWrapper v-for="(link, index) in userDialog.ref.bioLinks" :key="index">
<template #content>
<span v-text="link"></span>
</template>
<!-- onerror="this.onerror=null;this.class='icon-error'" -->
<img
:src="getFaviconUrl(link)"
style="
width: 16px;
height: 16px;
vertical-align: middle;
margin-right: 6px;
cursor: pointer;
"
@click.stop="openExternalLink(link)"
loading="lazy" />
</TooltipWrapper>
</div>
</div>
</div>
<template v-if="currentUser.id !== userDialog.id">
<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.user.info.last_seen') }}
</span>
<span class="block truncate text-xs">{{
formatDateFilter(userDialog.lastSeen, 'long')
}}</span>
</div>
</div>
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="showPreviousInstancesListDialog(userDialog.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.user.info.join_count') }}
</div>
<TooltipWrapper side="top" :content="t('dialog.user.info.open_previous_instance')">
<MoreHorizontal style="margin-right: 16px" />
</TooltipWrapper>
</div>
<span v-if="userDialog.joinCount === 0" class="block truncate text-xs">-</span>
<span v-else class="block truncate text-xs" v-text="userDialog.joinCount"></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.user.info.time_together') }}
</span>
<span v-if="userDialog.timeSpent === 0" class="block truncate text-xs">-</span>
<span v-else class="block truncate text-xs">{{
timeToText(userDialog.timeSpent)
}}</span>
</div>
</div>
</template>
<template v-else>
<TooltipWrapper
:disabled="currentUser.id !== userDialog.id"
side="top"
:content="t('dialog.user.info.open_previous_instance')">
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="showPreviousInstancesListDialog(userDialog.ref)">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.user.info.play_time') }}
</span>
<span v-if="userDialog.timeSpent === 0" class="block truncate text-xs">-</span>
<span v-else class="block truncate text-xs">{{
timeToText(userDialog.timeSpent)
}}</span>
</div>
</div>
</TooltipWrapper>
</template>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<TooltipWrapper :side="currentUser.id !== userDialog.id ? 'bottom' : 'top'">
<template #content>
<span>{{ formatDateFilter(userOnlineForTimestamp(userDialog), 'short') }}</span>
</template>
<div class="flex-1 overflow-hidden">
<span
v-if="userDialog.ref.state === 'online' && userDialog.ref.$online_for"
class="block truncate font-medium leading-[18px]">
{{ t('dialog.user.info.online_for') }}
</span>
<span v-else class="block truncate font-medium leading-[18px]">
{{ t('dialog.user.info.offline_for') }}
</span>
<span class="block truncate text-xs">{{ userOnlineFor(userDialog.ref) }}</span>
</div>
</TooltipWrapper>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<TooltipWrapper :side="currentUser.id !== userDialog.id ? 'bottom' : 'top'">
<template #content>
<span
>{{ t('dialog.user.info.last_login') }}
{{ formatDateFilter(userDialog.ref.last_login, 'long') }}</span
>
<br />
<span
>{{ t('dialog.user.info.last_activity') }}
{{ formatDateFilter(userDialog.ref.last_activity, 'long') }}</span
>
</template>
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.user.info.last_activity')
}}</span>
<span v-if="userDialog.ref.last_activity" class="block truncate text-xs">{{
timeToText(Date.now() - Date.parse(userDialog.ref.last_activity))
}}</span>
<span v-else class="block truncate text-xs">-</span>
</div>
</TooltipWrapper>
</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.user.info.date_joined')
}}</span>
<span class="block truncate text-xs" v-text="userDialog.ref.date_joined"></span>
</div>
</div>
<div
v-if="currentUser.id !== userDialog.id"
class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<TooltipWrapper side="top" :disabled="userDialog.dateFriendedInfo.length < 2">
<template #content>
<template v-for="ref in userDialog.dateFriendedInfo" :key="ref.type">
<span>{{ ref.type }}: {{ formatDateFilter(ref.created_at, 'long') }}</span
><br />
</template>
</template>
<div class="flex-1 overflow-hidden">
<span v-if="userDialog.unFriended" class="block truncate font-medium leading-[18px]">
{{ t('dialog.user.info.unfriended') }}
</span>
<span v-else class="block truncate font-medium leading-[18px]">
{{ t('dialog.user.info.friended') }}
</span>
<span class="block truncate text-xs">{{
formatDateFilter(userDialog.dateFriended, 'long')
}}</span>
</div>
</TooltipWrapper>
</div>
<template v-if="currentUser.id === userDialog.id">
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="toggleAvatarCopying">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.user.info.avatar_cloning')
}}</span>
<span v-if="currentUser.allowAvatarCopying" class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_allow')
}}</span>
<span v-else class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_deny')
}}</span>
</div>
</div>
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="toggleAllowBooping">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.user.info.booping')
}}</span>
<span v-if="currentUser.isBoopingEnabled" class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_allow')
}}</span>
<span v-else class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_deny')
}}</span>
</div>
</div>
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="toggleSharedConnectionsOptOut">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.user.info.show_mutual_friends')
}}</span>
<span v-if="!currentUser.hasSharedConnectionsOptOut" class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_allow')
}}</span>
<span v-else class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_deny')
}}</span>
</div>
</div>
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="toggleDiscordFriendsOptOut">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.user.info.show_discord_connections')
}}</span>
<span v-if="!currentUser.hasDiscordFriendsOptOut" class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_allow')
}}</span>
<span v-else class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_deny')
}}</span>
</div>
</div>
</template>
<template v-else>
<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.user.info.avatar_cloning')
}}</span>
<span v-if="userDialog.ref.allowAvatarCopying" class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_allow')
}}</span>
<span v-else class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_deny')
}}</span>
</div>
</div>
</template>
<div
v-if="userDialog.ref.id === currentUser.id"
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="getVRChatCredits()">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('view.profile.profile.vrchat_credits')
}}</span>
<span class="block truncate text-xs">{{
vrchatCredit ?? t('view.profile.profile.refresh')
}}</span>
</div>
</div>
<div
v-if="userDialog.ref.id === currentUser.id && currentUser.homeLocation"
class="box-border flex items-center p-1.5 text-[13px] w-full cursor-pointer"
@click="showWorldDialog(currentUser.homeLocation)">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.user.info.home_location')
}}</span>
<span class="block truncate text-xs">
<span v-text="userDialog.$homeLocationName"></span>
<Button
class="rounded-full ml-1 text-xs"
size="icon-sm"
variant="ghost"
@click.stop="resetHome()"
><Trash2 class="h-4 w-4" />
</Button>
</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.user.info.id')
}}</span>
<span class="block truncate text-xs">
{{ userDialog.id }}
<TooltipWrapper side="top" :content="t('dialog.user.info.id_tooltip')">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
class="rounded-full ml-1 text-xs"
size="icon-sm"
variant="ghost"
@click.stop
><Copy class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="copyUserId(userDialog.id)">
{{ t('dialog.user.info.copy_id') }}
</DropdownMenuItem>
<DropdownMenuItem @click="copyUserURL(userDialog.id)">
{{ t('dialog.user.info.copy_url') }}
</DropdownMenuItem>
<DropdownMenuItem @click="copyUserDisplayName(userDialog.ref.displayName)">
{{ t('dialog.user.info.copy_display_name') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipWrapper>
</span>
</div>
</div>
</div>
<UserDialogInfoTab ref="infoTabRef" @show-bio-dialog="showBioDialog" />
</template>
<template v-if="userDialog.id !== currentUser.id && !currentUser.hasSharedConnectionsOptOut" #mutual>
@@ -597,31 +66,18 @@
<BioDialog :bio-dialog="bioDialog" />
<PronounsDialog :pronouns-dialog="pronounsDialog" />
<ModerateGroupDialog />
<EditNoteAndMemoDialog v-model:visible="isEditNoteAndMemoDialogVisible" />
</div>
</template>
<script setup>
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 { 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 {
useAdvancedSettingsStore,
useAppearanceSettingsStore,
useAvatarStore,
useFavoriteStore,
useFriendStore,
@@ -633,33 +89,19 @@
useModalStore,
useModerationStore,
useNotificationStore,
useUserStore,
useWorldStore
useUserStore
} from '../../../stores';
import {
copyToClipboard,
formatDateFilter,
getFaviconUrl,
isFriendOnline,
isRealInstance,
openExternalLink,
refreshInstancePlayerCount,
timeToText,
userImage,
userOnlineFor,
userOnlineForTimestamp,
userStatusClass
} from '../../../shared/utils';
import { miscRequest, userRequest } from '../../../api';
import { copyToClipboard } from '../../../shared/utils';
import { formatJsonVars } from '../../../shared/utils/base/ui';
import { miscRequest } from '../../../api';
import { useUserDialogCommands } from './useUserDialogCommands';
import DialogJsonTab from '../DialogJsonTab.vue';
import InstanceActionBar from '../../InstanceActionBar.vue';
import SendInviteDialog from '../InviteDialog/SendInviteDialog.vue';
import UserDialogAvatarsTab from './UserDialogAvatarsTab.vue';
import UserDialogFavoriteWorldsTab from './UserDialogFavoriteWorldsTab.vue';
import UserDialogGroupsTab from './UserDialogGroupsTab.vue';
import UserDialogInfoTab from './UserDialogInfoTab.vue';
import UserDialogMutualFriendsTab from './UserDialogMutualFriendsTab.vue';
import UserDialogWorldsTab from './UserDialogWorldsTab.vue';
import UserSummaryHeader from './UserSummaryHeader.vue';
@@ -670,7 +112,6 @@
const SendInviteRequestDialog = defineAsyncComponent(() => import('./SendInviteRequestDialog.vue'));
const SocialStatusDialog = defineAsyncComponent(() => import('./SocialStatusDialog.vue'));
const ModerateGroupDialog = defineAsyncComponent(() => import('../ModerateGroupDialog.vue'));
const EditNoteAndMemoDialog = defineAsyncComponent(() => import('./EditNoteAndMemoDialog.vue'));
const { t } = useI18n();
const userDialogTabs = computed(() => {
@@ -687,6 +128,7 @@
}
return tabs;
});
const infoTabRef = ref(null);
const favoriteWorldsTabRef = ref(null);
const mutualFriendsTabRef = ref(null);
const worldsTabRef = ref(null);
@@ -696,28 +138,18 @@
const modalStore = useModalStore();
const instanceStore = useInstanceStore();
const { hideUserNotes, hideUserMemos, isDarkMode } = storeToRefs(useAppearanceSettingsStore());
const { bioLanguage, translationApi, translationApiType } = storeToRefs(useAdvancedSettingsStore());
const { translateText } = useAdvancedSettingsStore();
const { userDialog, languageDialog, currentUser, isLocalUserVrcPlusSupporter } = storeToRefs(useUserStore());
const {
cachedUsers,
showUserDialog,
refreshUserDialogAvatars,
showSendBoopDialog,
toggleSharedConnectionsOptOut,
toggleDiscordFriendsOptOut
} = useUserStore();
const { cachedUsers, showUserDialog, refreshUserDialogAvatars, showSendBoopDialog } = useUserStore();
const { showFavoriteDialog } = useFavoriteStore();
const { showAvatarDialog, showAvatarAuthorDialog } = useAvatarStore();
const { showWorldDialog } = useWorldStore();
const { showGroupDialog, showModerateGroupDialog } = useGroupStore();
const { inviteGroupDialog } = storeToRefs(useGroupStore());
const { lastLocation, lastLocationDestination } = storeToRefs(useLocationStore());
const { refreshInviteMessageTableData } = useInviteStore();
const { friendLogTable } = storeToRefs(useFriendStore());
const { getFriendRequest, handleFriendDelete } = useFriendStore();
const { clearInviteImageUpload, showFullscreenImageDialog, showGalleryPage } = useGalleryStore();
const { clearInviteImageUpload, showGalleryPage } = useGalleryStore();
const { applyPlayerModeration, handlePlayerModerationDelete } = useModerationStore();
@@ -759,12 +191,6 @@
() => {
if (userDialog.value.visible) {
!userDialog.value.loading && loadLastActiveTab();
if (userDialog.value.id !== bioCache.value.userId) {
bioCache.value = {
userId: null,
translated: null
};
}
}
}
);
@@ -794,21 +220,11 @@
bioLinks: []
});
const translateLoading = ref(false);
const pronounsDialog = ref({
visible: false,
loading: false,
pronouns: ''
});
const bioCache = ref({
userId: null,
translated: null
});
const isEditNoteAndMemoDialogVisible = ref(false);
const vrchatCredit = ref(null);
const treeData = ref({});
/**
@@ -873,9 +289,7 @@
userDialog.value.lastActiveTab = tabName;
const userId = userDialog.value.id;
if (tabName === 'Info') {
if (vrchatCredit.value === null) {
getVRChatCredits();
}
infoTabRef.value?.onTabActivated();
} else if (tabName === 'mutual') {
if (userId === currentUser.value.id) {
userDialog.value.activeTab = 'Info';
@@ -962,7 +376,7 @@
showBioDialog,
showPronounsDialog,
showEditNoteAndMemoDialog: () => {
isEditNoteAndMemoDialogVisible.value = true;
infoTabRef.value?.showEditNoteAndMemoDialog();
}
});
@@ -1038,113 +452,6 @@
D.visible = true;
}
/**
*
*/
async function translateBio() {
if (translateLoading.value) {
return;
}
const bio = userDialog.value.ref.bio;
if (!bio) {
return;
}
const targetLang = bioLanguage.value;
if (bioCache.value.userId !== userDialog.value.id) {
bioCache.value.userId = userDialog.value.id;
bioCache.value.translated = null;
}
if (bioCache.value.translated) {
bioCache.value.translated = null;
return;
}
translateLoading.value = true;
try {
const providerLabel = translationApiType.value === 'openai' ? 'OpenAI' : 'Google';
const translated = await translateText(`${bio}\n\nTranslated by ${providerLabel}`, targetLang);
if (!translated) {
throw new Error('No translation returned');
}
bioCache.value.translated = translated;
} catch (err) {
console.error('Translation failed:', err);
} finally {
translateLoading.value = false;
}
}
/**
*
* @param userRef
*/
function showPreviousInstancesListDialog(userRef) {
instanceStore.showPreviousInstancesListDialog('user', userRef);
}
/**
*
*/
function toggleAvatarCopying() {
userRequest.saveCurrentUser({
allowAvatarCopying: !currentUser.value.allowAvatarCopying
});
}
/**
*
*/
function toggleAllowBooping() {
userRequest.saveCurrentUser({
isBoopingEnabled: !currentUser.value.isBoopingEnabled
});
}
/**
*
*/
function resetHome() {
modalStore
.confirm({
description: t('confirm.command_question', {
command: t('dialog.user.actions.reset_home')
}),
title: t('confirm.title')
})
.then(({ ok }) => {
if (!ok) return;
userRequest
.saveCurrentUser({
homeLocation: ''
})
.then((args) => {
toast.success('Home world has been reset');
return args;
});
})
.catch(() => {});
}
/**
*
* @param userId
*/
function copyUserId(userId) {
copyToClipboard(userId, 'User ID copied to clipboard');
}
/**
*
* @param userId
*/
function copyUserURL(userId) {
copyToClipboard(`https://vrchat.com/home/user/${userId}`, 'User URL copied to clipboard');
}
/**
*
* @param displayName
@@ -1159,11 +466,4 @@
function closeInviteDialog() {
clearInviteImageUpload();
}
/**
*
*/
function getVRChatCredits() {
miscRequest.getVRChatCredits().then((args) => (vrchatCredit.value = args.json?.balance));
}
</script>

View File

@@ -0,0 +1,712 @@
<template>
<template v-if="isFriendOnline(userDialog.friend) || currentUser.id === userDialog.id">
<div
class="mb-2 pb-2 border-b border-border"
v-if="userDialog.ref.location"
style="display: flex; flex-direction: column">
<div style="flex: none">
<template v-if="isRealInstance(userDialog.$location.tag)">
<InstanceActionBar
class="mb-1"
:location="userDialog.$location.tag"
:shortname="userDialog.$location.shortName"
:currentlocation="lastLocation.location"
:instance="userDialog.instance.ref"
:friendcount="userDialog.instance.friendCount"
:refresh-tooltip="t('dialog.user.info.refresh_instance_info')"
:on-refresh="() => refreshInstancePlayerCount(userDialog.$location.tag)" />
</template>
<Location
class="text-sm"
:location="userDialog.ref.location"
:traveling="userDialog.ref.travelingToLocation" />
</div>
<div class="flex flex-wrap items-start" style="flex: 1; margin-top: 8px; max-height: 150px; overflow: auto">
<div
v-if="userDialog.$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(userDialog.$location.userId)">
<template v-if="userDialog.$location.user">
<div
class="relative inline-block flex-none size-9 mr-2.5"
:class="userStatusClass(userDialog.$location.user)">
<img
class="size-full rounded-full object-cover"
:src="userImage(userDialog.$location.user, true)"
loading="lazy" />
</div>
<div class="flex-1 overflow-hidden">
<span
class="block truncate font-medium leading-[18px]"
:style="{ color: userDialog.$location.user.$userColour }"
v-text="userDialog.$location.user.displayName"></span>
<span class="block truncate text-xs">{{ t('dialog.user.info.instance_creator') }}</span>
</div>
</template>
<span v-else v-text="userDialog.$location.userId"></span>
</div>
<div
v-for="user in userDialog.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>
<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 class="flex flex-wrap items-start px-2.5" style="max-height: none">
<div
v-if="userDialog.note && !hideUserNotes"
class="box-border flex items-center p-1.5 text-[13px] w-full cursor-pointer">
<div class="flex-1 overflow-hidden" @click="isEditNoteAndMemoDialogVisible = true">
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.user.info.note') }}</span>
<pre
class="text-xs"
style="
font-family: inherit;
font-size: 12px;
white-space: pre-wrap;
margin: 0 0.5em 0 0;
max-height: 210px;
overflow-y: auto;
"
>{{ userDialog.note }}</pre
>
</div>
</div>
<div
v-if="userDialog.memo && !hideUserMemos"
class="box-border flex items-center p-1.5 text-[13px] w-full cursor-pointer">
<div class="flex-1 overflow-hidden" @click="isEditNoteAndMemoDialogVisible = true">
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.user.info.memo') }}</span>
<pre
class="text-xs"
style="
font-family: inherit;
font-size: 12px;
white-space: pre-wrap;
margin: 0 0.5em 0 0;
max-height: 210px;
overflow-y: auto;
"
>{{ userDialog.memo }}</pre
>
</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]">
{{
userDialog.id !== currentUser.id &&
userDialog.ref.profilePicOverride &&
userDialog.ref.currentAvatarImageUrl
? t('dialog.user.info.avatar_info_last_seen')
: t('dialog.user.info.avatar_info')
}}
<TooltipWrapper
v-if="userDialog.ref.profilePicOverride && !userDialog.ref.currentAvatarImageUrl"
side="top"
:content="t('dialog.user.info.vrcplus_hides_avatar')">
<Info class="inline-block" />
</TooltipWrapper>
</span>
<div class="text-xs">
<AvatarInfo
:key="userDialog.id"
:imageurl="userDialog.ref.currentAvatarImageUrl"
:userid="userDialog.id"
:avatartags="userDialog.ref.currentAvatarTags"
style="display: inline-block" />
</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]" style="margin-bottom: 6px">{{
t('dialog.user.info.represented_group')
}}</span>
<div
v-if="
userDialog.isRepresentedGroupLoading ||
(userDialog.representedGroup && userDialog.representedGroup.isRepresenting)
"
class="text-xs">
<div style="display: inline-block; flex: none; margin-right: 6px">
<Avatar
class="cursor-pointer size-15! rounded-lg!"
:style="{
background: userDialog.isRepresentedGroupLoading ? 'var(--muted)' : ''
}"
@click="showFullscreenImageDialog(userDialog.representedGroup.iconUrl)">
<AvatarImage
:src="userDialog.representedGroup.$thumbnailUrl"
@load="userDialog.isRepresentedGroupLoading = false"
@error="userDialog.isRepresentedGroupLoading = false" />
<AvatarFallback class="rounded-lg!" />
</Avatar>
</div>
<span
v-if="userDialog.representedGroup.isRepresenting"
style="vertical-align: top; cursor: pointer"
@click="showGroupDialog(userDialog.representedGroup.groupId)">
<span v-if="userDialog.representedGroup.ownerId === userDialog.id" style="margin-right: 6px"
>👑</span
>
<span style="margin-right: 6px" v-text="userDialog.representedGroup.name"></span>
<span>({{ userDialog.representedGroup.memberCount }})</span>
</span>
</div>
<div v-else class="text-xs">-</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.user.info.bio') }}</span>
<pre
class="text-xs truncate"
style="
font-family: inherit;
font-size: 12px;
white-space: pre-wrap;
margin: 0 0.5em 0 0;
max-height: 210px;
overflow-y: auto;
"
>{{ bioCache.translated || userDialog.ref.bio || '-' }}</pre
>
<div style="float: right">
<Button
v-if="translationApi && userDialog.ref.bio"
class="w-3 h-6 text-xs mr-0.5"
size="icon-sm"
variant="ghost"
@click="translateBio">
<Spinner v-if="translateLoading" class="size-1" />
<Languages v-else class="h-3 w-3" />
</Button>
<Button
class="w-3 h-6 text-xs"
size="icon-sm"
variant="ghost"
v-if="userDialog.id === currentUser.id"
style="margin-left: 6px; padding: 0"
@click="$emit('showBioDialog')"
><Pencil class="h-3 w-3" />
</Button>
</div>
<div style="margin-top: 6px" class="flex items-center">
<TooltipWrapper v-for="(link, index) in userDialog.ref.bioLinks" :key="index">
<template #content>
<span v-text="link"></span>
</template>
<!-- onerror="this.onerror=null;this.class='icon-error'" -->
<img
:src="getFaviconUrl(link)"
style="
width: 16px;
height: 16px;
vertical-align: middle;
margin-right: 6px;
cursor: pointer;
"
@click.stop="openExternalLink(link)"
loading="lazy" />
</TooltipWrapper>
</div>
</div>
</div>
<template v-if="currentUser.id !== userDialog.id">
<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.user.info.last_seen') }}
</span>
<span class="block truncate text-xs">{{ formatDateFilter(userDialog.lastSeen, 'long') }}</span>
</div>
</div>
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="showPreviousInstancesListDialog(userDialog.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.user.info.join_count') }}
</div>
<TooltipWrapper side="top" :content="t('dialog.user.info.open_previous_instance')">
<MoreHorizontal style="margin-right: 16px" />
</TooltipWrapper>
</div>
<span v-if="userDialog.joinCount === 0" class="block truncate text-xs">-</span>
<span v-else class="block truncate text-xs" v-text="userDialog.joinCount"></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.user.info.time_together') }}
</span>
<span v-if="userDialog.timeSpent === 0" class="block truncate text-xs">-</span>
<span v-else class="block truncate text-xs">{{ timeToText(userDialog.timeSpent) }}</span>
</div>
</div>
</template>
<template v-else>
<TooltipWrapper
:disabled="currentUser.id !== userDialog.id"
side="top"
:content="t('dialog.user.info.open_previous_instance')">
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="showPreviousInstancesListDialog(userDialog.ref)">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.user.info.play_time') }}
</span>
<span v-if="userDialog.timeSpent === 0" class="block truncate text-xs">-</span>
<span v-else class="block truncate text-xs">{{ timeToText(userDialog.timeSpent) }}</span>
</div>
</div>
</TooltipWrapper>
</template>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<TooltipWrapper :side="currentUser.id !== userDialog.id ? 'bottom' : 'top'">
<template #content>
<span>{{ formatDateFilter(userOnlineForTimestamp(userDialog), 'short') }}</span>
</template>
<div class="flex-1 overflow-hidden">
<span
v-if="userDialog.ref.state === 'online' && userDialog.ref.$online_for"
class="block truncate font-medium leading-[18px]">
{{ t('dialog.user.info.online_for') }}
</span>
<span v-else class="block truncate font-medium leading-[18px]">
{{ t('dialog.user.info.offline_for') }}
</span>
<span class="block truncate text-xs">{{ userOnlineFor(userDialog.ref) }}</span>
</div>
</TooltipWrapper>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<TooltipWrapper :side="currentUser.id !== userDialog.id ? 'bottom' : 'top'">
<template #content>
<span
>{{ t('dialog.user.info.last_login') }}
{{ formatDateFilter(userDialog.ref.last_login, 'long') }}</span
>
<br />
<span
>{{ t('dialog.user.info.last_activity') }}
{{ formatDateFilter(userDialog.ref.last_activity, 'long') }}</span
>
</template>
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.user.info.last_activity')
}}</span>
<span v-if="userDialog.ref.last_activity" class="block truncate text-xs">{{
timeToText(Date.now() - Date.parse(userDialog.ref.last_activity))
}}</span>
<span v-else class="block truncate text-xs">-</span>
</div>
</TooltipWrapper>
</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.user.info.date_joined') }}</span>
<span class="block truncate text-xs" v-text="userDialog.ref.date_joined"></span>
</div>
</div>
<div
v-if="currentUser.id !== userDialog.id"
class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<TooltipWrapper side="top" :disabled="userDialog.dateFriendedInfo.length < 2">
<template #content>
<template v-for="ref in userDialog.dateFriendedInfo" :key="ref.type">
<span>{{ ref.type }}: {{ formatDateFilter(ref.created_at, 'long') }}</span
><br />
</template>
</template>
<div class="flex-1 overflow-hidden">
<span v-if="userDialog.unFriended" class="block truncate font-medium leading-[18px]">
{{ t('dialog.user.info.unfriended') }}
</span>
<span v-else class="block truncate font-medium leading-[18px]">
{{ t('dialog.user.info.friended') }}
</span>
<span class="block truncate text-xs">{{ formatDateFilter(userDialog.dateFriended, 'long') }}</span>
</div>
</TooltipWrapper>
</div>
<template v-if="currentUser.id === userDialog.id">
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="toggleAvatarCopying">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.user.info.avatar_cloning')
}}</span>
<span v-if="currentUser.allowAvatarCopying" class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_allow')
}}</span>
<span v-else class="block truncate text-xs">{{ t('dialog.user.info.avatar_cloning_deny') }}</span>
</div>
</div>
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="toggleAllowBooping">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.user.info.booping') }}</span>
<span v-if="currentUser.isBoopingEnabled" class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_allow')
}}</span>
<span v-else class="block truncate text-xs">{{ t('dialog.user.info.avatar_cloning_deny') }}</span>
</div>
</div>
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="toggleSharedConnectionsOptOut">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.user.info.show_mutual_friends')
}}</span>
<span v-if="!currentUser.hasSharedConnectionsOptOut" class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_allow')
}}</span>
<span v-else class="block truncate text-xs">{{ t('dialog.user.info.avatar_cloning_deny') }}</span>
</div>
</div>
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="toggleDiscordFriendsOptOut">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.user.info.show_discord_connections')
}}</span>
<span v-if="!currentUser.hasDiscordFriendsOptOut" class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_allow')
}}</span>
<span v-else class="block truncate text-xs">{{ t('dialog.user.info.avatar_cloning_deny') }}</span>
</div>
</div>
</template>
<template v-else>
<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.user.info.avatar_cloning')
}}</span>
<span v-if="userDialog.ref.allowAvatarCopying" class="block truncate text-xs">{{
t('dialog.user.info.avatar_cloning_allow')
}}</span>
<span v-else class="block truncate text-xs">{{ t('dialog.user.info.avatar_cloning_deny') }}</span>
</div>
</div>
</template>
<div
v-if="userDialog.ref.id === currentUser.id"
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="getVRChatCredits()">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('view.profile.profile.vrchat_credits')
}}</span>
<span class="block truncate text-xs">{{ vrchatCredit ?? t('view.profile.profile.refresh') }}</span>
</div>
</div>
<div
v-if="userDialog.ref.id === currentUser.id && currentUser.homeLocation"
class="box-border flex items-center p-1.5 text-[13px] w-full cursor-pointer"
@click="showWorldDialog(currentUser.homeLocation)">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.user.info.home_location') }}</span>
<span class="block truncate text-xs">
<span v-text="userDialog.$homeLocationName"></span>
<Button class="rounded-full ml-1 text-xs" size="icon-sm" variant="ghost" @click.stop="resetHome()"
><Trash2 class="h-4 w-4" />
</Button>
</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.user.info.id') }}</span>
<span class="block truncate text-xs">
{{ userDialog.id }}
<TooltipWrapper side="top" :content="t('dialog.user.info.id_tooltip')">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button class="rounded-full ml-1 text-xs" size="icon-sm" variant="ghost" @click.stop
><Copy class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="copyUserId(userDialog.id)">
{{ t('dialog.user.info.copy_id') }}
</DropdownMenuItem>
<DropdownMenuItem @click="copyUserURL(userDialog.id)">
{{ t('dialog.user.info.copy_url') }}
</DropdownMenuItem>
<DropdownMenuItem @click="copyUserDisplayName(userDialog.ref.displayName)">
{{ t('dialog.user.info.copy_display_name') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipWrapper>
</span>
</div>
</div>
</div>
<EditNoteAndMemoDialog v-model:visible="isEditNoteAndMemoDialogVisible" />
</template>
<script setup>
import { Copy, Info, Languages, MoreHorizontal, Pencil, Trash2 } from 'lucide-vue-next';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { defineAsyncComponent, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
import InstanceActionBar from '../../InstanceActionBar.vue';
import {
copyToClipboard,
formatDateFilter,
getFaviconUrl,
isFriendOnline,
isRealInstance,
openExternalLink,
refreshInstancePlayerCount,
timeToText,
userImage,
userOnlineFor,
userOnlineForTimestamp,
userStatusClass
} from '../../../shared/utils';
import {
useAdvancedSettingsStore,
useAppearanceSettingsStore,
useGalleryStore,
useGroupStore,
useInstanceStore,
useLocationStore,
useModalStore,
useUserStore,
useWorldStore
} from '../../../stores';
import { miscRequest, userRequest } from '../../../api';
const EditNoteAndMemoDialog = defineAsyncComponent(() => import('./EditNoteAndMemoDialog.vue'));
defineEmits(['showBioDialog']);
const { t } = useI18n();
const modalStore = useModalStore();
const instanceStore = useInstanceStore();
const { hideUserNotes, hideUserMemos } = storeToRefs(useAppearanceSettingsStore());
const { bioLanguage, translationApi, translationApiType } = storeToRefs(useAdvancedSettingsStore());
const { translateText } = useAdvancedSettingsStore();
const { userDialog, currentUser } = storeToRefs(useUserStore());
const { showUserDialog, toggleSharedConnectionsOptOut, toggleDiscordFriendsOptOut } = useUserStore();
const { showWorldDialog } = useWorldStore();
const { showGroupDialog } = useGroupStore();
const { lastLocation } = storeToRefs(useLocationStore());
const { showFullscreenImageDialog } = useGalleryStore();
const bioCache = ref({
userId: null,
translated: null
});
const isEditNoteAndMemoDialogVisible = ref(false);
const vrchatCredit = ref(null);
const translateLoading = ref(false);
watch(
() => userDialog.value.loading,
() => {
if (userDialog.value.visible) {
if (userDialog.value.id !== bioCache.value.userId) {
bioCache.value = {
userId: null,
translated: null
};
}
}
}
);
/**
*
*/
function onTabActivated() {
if (vrchatCredit.value === null) {
getVRChatCredits();
}
}
/**
*
*/
function showEditNoteAndMemoDialog() {
isEditNoteAndMemoDialogVisible.value = true;
}
/**
*
*/
async function translateBio() {
if (translateLoading.value) {
return;
}
const bio = userDialog.value.ref.bio;
if (!bio) {
return;
}
const targetLang = bioLanguage.value;
if (bioCache.value.userId !== userDialog.value.id) {
bioCache.value.userId = userDialog.value.id;
bioCache.value.translated = null;
}
if (bioCache.value.translated) {
bioCache.value.translated = null;
return;
}
translateLoading.value = true;
try {
const providerLabel = translationApiType.value === 'openai' ? 'OpenAI' : 'Google';
const translated = await translateText(`${bio}\n\nTranslated by ${providerLabel}`, targetLang);
if (!translated) {
throw new Error('No translation returned');
}
bioCache.value.translated = translated;
} catch (err) {
console.error('Translation failed:', err);
} finally {
translateLoading.value = false;
}
}
/**
*
* @param userRef
*/
function showPreviousInstancesListDialog(userRef) {
instanceStore.showPreviousInstancesListDialog('user', userRef);
}
/**
*
*/
function toggleAvatarCopying() {
userRequest.saveCurrentUser({
allowAvatarCopying: !currentUser.value.allowAvatarCopying
});
}
/**
*
*/
function toggleAllowBooping() {
userRequest.saveCurrentUser({
isBoopingEnabled: !currentUser.value.isBoopingEnabled
});
}
/**
*
*/
function resetHome() {
modalStore
.confirm({
description: t('confirm.command_question', {
command: t('dialog.user.actions.reset_home')
}),
title: t('confirm.title')
})
.then(({ ok }) => {
if (!ok) return;
userRequest
.saveCurrentUser({
homeLocation: ''
})
.then((args) => {
toast.success('Home world has been reset');
return args;
});
})
.catch(() => {});
}
/**
*
* @param userId
*/
function copyUserId(userId) {
copyToClipboard(userId, 'User ID copied to clipboard');
}
/**
*
* @param userId
*/
function copyUserURL(userId) {
copyToClipboard(`https://vrchat.com/home/user/${userId}`, 'User URL copied to clipboard');
}
/**
*
* @param displayName
*/
function copyUserDisplayName(displayName) {
copyToClipboard(displayName, 'User DisplayName copied to clipboard');
}
/**
*
*/
function getVRChatCredits() {
miscRequest.getVRChatCredits().then((args) => (vrchatCredit.value = args.json?.balance));
}
defineExpose({
onTabActivated,
showEditNoteAndMemoDialog
});
</script>

View File

@@ -4,15 +4,20 @@ import { mount } from '@vue/test-utils';
// ─── Mocks (must be before any imports that use them) ────────────────
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key)
}),
createI18n: () => ({
global: { t: (key) => key },
install: vi.fn()
})
}));
vi.mock('vue-i18n', () => {
const { ref } = require('vue');
return {
useI18n: () => ({
t: (key, params) =>
params ? `${key}:${JSON.stringify(params)}` : key,
locale: ref('en')
}),
createI18n: () => ({
global: { t: (key) => key, locale: ref('en') },
install: vi.fn()
})
};
});
vi.mock('../../../../plugin/router', () => {
const { ref } = require('vue');

View File

@@ -0,0 +1,241 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
import { flushPromises, shallowMount } from '@vue/test-utils';
vi.mock('vue-i18n', () => ({
useI18n: () => {
const { ref } = require('vue');
return {
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key),
locale: ref('en')
};
},
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 UserDialogInfoTab from '../UserDialogInfoTab.vue';
import { miscRequest } from '../../../../api';
import {
useAdvancedSettingsStore,
useAppearanceSettingsStore,
useLocationStore,
useModalStore,
useUserStore
} from '../../../../stores';
/**
*
* @param overrides
*/
function mountComponent(overrides = {}) {
const pinia = createTestingPinia({
stubActions: true
});
const appearanceSettingsStore = useAppearanceSettingsStore(pinia);
appearanceSettingsStore.hideUserNotes = false;
appearanceSettingsStore.hideUserMemos = false;
const advancedSettingsStore = useAdvancedSettingsStore(pinia);
advancedSettingsStore.bioLanguage = 'en';
advancedSettingsStore.translationApi = '';
advancedSettingsStore.translationApiType = 'google';
advancedSettingsStore.translateText = vi.fn().mockResolvedValue('');
const userStore = useUserStore(pinia);
userStore.userDialog = {
id: 'usr_target',
friend: {
state: 'online',
ref: {
location: 'wrld_test:123'
}
},
ref: {
id: 'usr_target',
location: 'wrld_test:123',
travelingToLocation: '',
profilePicOverride: '',
currentAvatarImageUrl: '',
currentAvatarTags: [],
bio: '',
bioLinks: [],
state: 'online',
$online_for: 1000,
last_login: '2025-01-01T00:00:00.000Z',
last_activity: '2025-01-01T00:00:00.000Z',
date_joined: '2020-01-01',
allowAvatarCopying: true,
displayName: 'Target'
},
$location: {
tag: 'wrld_test:123',
shortName: 'Test',
userId: '',
user: null
},
instance: {
ref: {},
friendCount: 0
},
users: [
{
id: 'usr_friend_1',
displayName: 'Friend A',
$userColour: '#ffffff',
location: 'traveling',
$travelingToTime: Date.now(),
$location_at: Date.now()
}
],
note: '',
memo: '',
isRepresentedGroupLoading: false,
representedGroup: null,
lastSeen: '2025-01-01T00:00:00.000Z',
joinCount: 0,
timeSpent: 0,
dateFriendedInfo: [],
unFriended: false,
dateFriended: '2025-01-01T00:00:00.000Z',
$homeLocationName: '',
...overrides.userDialog
};
userStore.currentUser = {
id: 'usr_me',
allowAvatarCopying: true,
isBoopingEnabled: true,
hasSharedConnectionsOptOut: false,
hasDiscordFriendsOptOut: false,
homeLocation: '',
...overrides.currentUser
};
const locationStore = useLocationStore(pinia);
locationStore.lastLocation = {
location: 'wrld_test:123'
};
const modalStore = useModalStore(pinia);
modalStore.confirm = vi.fn().mockResolvedValue({ ok: false });
return shallowMount(UserDialogInfoTab, {
global: {
plugins: [pinia],
stubs: {
Location: true,
Timer: true,
TooltipWrapper: true,
AvatarInfo: true
}
}
});
}
describe('UserDialogInfoTab.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('unit behavior', () => {
test('onTabActivated fetches VRChat credits only once after first success', async () => {
const creditsSpy = vi
.spyOn(miscRequest, 'getVRChatCredits')
.mockResolvedValue({ json: { balance: 42 } });
const wrapper = mountComponent();
wrapper.vm.onTabActivated();
await flushPromises();
wrapper.vm.onTabActivated();
await flushPromises();
expect(creditsSpy).toHaveBeenCalledTimes(1);
});
});
describe('dom rendering', () => {
test('renders imported InstanceActionBar and Spinner components when conditions are met', () => {
const wrapper = mountComponent();
expect(wrapper.find('instance-action-bar-stub').exists()).toBe(true);
expect(wrapper.find('spinner-stub').exists()).toBe(true);
});
});
});

View File

@@ -6,10 +6,11 @@ import { mount } from '@vue/test-utils';
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key)
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key),
locale: require('vue').ref('en')
}),
createI18n: () => ({
global: { t: (key) => key },
global: { t: (key) => key , locale: require('vue').ref('en') },
install: vi.fn()
})
}));

View File

@@ -4,15 +4,20 @@ 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('vue-i18n', () => {
const { ref } = require('vue');
return {
useI18n: () => ({
t: (key, params) =>
params ? `${key}:${JSON.stringify(params)}` : key,
locale: ref('en')
}),
createI18n: () => ({
global: { t: (key) => key, locale: ref('en') },
install: vi.fn()
})
};
});
vi.mock('../../../../plugin/router', () => {
const { ref } = require('vue');

View File

@@ -5,7 +5,9 @@ vi.mock('vue-sonner', () => ({
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key) => key })
useI18n: () => ({ t: (key) => key ,
locale: require('vue').ref('en')
})
}));
import {

View File

@@ -39,7 +39,9 @@ vi.mock('../../shared/utils', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
,
locale: require('vue').ref('en')
})
}));
/**

View File

@@ -29,7 +29,9 @@ vi.mock('vue-sonner', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
,
locale: require('vue').ref('en')
})
}));
function flushPromises() {

View File

@@ -213,8 +213,9 @@ export const useFavoriteStore = defineStore('Favorite', () => {
/**
*
* @param list
* @param selectionRef
* @param {Array} list
* @param {object} selectionRef
* @returns {void}
*/
function syncFavoriteSelection(list, selectionRef) {
if (!Array.isArray(list)) {
@@ -263,7 +264,7 @@ export const useFavoriteStore = defineStore('Favorite', () => {
);
/**
*
* @returns {void}
*/
function getCachedFavoriteGroupsByTypeName() {
const group = {};
@@ -286,7 +287,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
/**
*
* @param objectId
* @param {string} objectId
* @returns {object | undefined}
*/
function getCachedFavoritesByObjectId(objectId) {
return cachedFavoritesByObjectId.get(objectId);
@@ -294,7 +296,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
/**
*
* @param args
* @param {object} args
* @returns {void}
*/
function handleFavoriteAdd(args) {
handleFavorite({
@@ -330,7 +333,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
/**
*
* @param args
* @param {object} args
* @returns {void}
*/
function handleFavorite(args) {
args.ref = applyFavoriteCached(args.json);
@@ -353,7 +357,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
/**
*
* @param objectId
* @param {string} objectId
* @returns {void}
*/
function handleFavoriteDelete(objectId) {
const ref = getCachedFavoritesByObjectId(objectId);
@@ -365,7 +370,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
/**
*
* @param args
* @param {object} args
* @returns {void}
*/
function handleFavoriteGroup(args) {
args.ref = applyFavoriteGroup(args.json);
@@ -373,7 +379,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
/**
*
* @param args
* @param {object} args
* @returns {void}
*/
function handleFavoriteGroupClear(args) {
const key = `${args.params.type}:${args.params.group}`;
@@ -387,7 +394,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
/**
*
* @param args
* @param {object} args
* @returns {void}
*/
function handleFavoriteWorldList(args) {
for (const json of args.json) {
@@ -400,7 +408,7 @@ export const useFavoriteStore = defineStore('Favorite', () => {
/**
*
* @param args
* @param {object} args
*/
function handleFavoriteAvatarList(args) {
for (const json of args.json) {
@@ -413,7 +421,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
/**
*
* @param ref
* @param {object} ref
* @returns {void}
*/
function handleFavoriteAtDelete(ref) {
const favorite = state.favoriteObjects.get(ref.favoriteId);
@@ -583,7 +592,7 @@ export const useFavoriteStore = defineStore('Favorite', () => {
}
/**
*
* @returns {void}
*/
function refreshFavoriteGroups() {
if (isFavoriteGroupLoading.value) {
@@ -813,8 +822,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
/**
*
* @param json
* @returns {any}
* @param {object} json
* @returns {object}
*/
function applyFavoriteGroup(json) {
let ref = cachedFavoriteGroups.value[json.id];
@@ -829,8 +838,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
/**
*
* @param json
* @returns {any}
* @param {object} json
* @returns {object}
*/
function applyFavoriteCached(json) {
let ref = cachedFavorites.get(json.id);
@@ -866,7 +875,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
/**
*
* @param tag
* @param {string} tag
* @returns {void}
*/
async function refreshFavoriteAvatars(tag) {
const params = {
@@ -879,7 +889,7 @@ export const useFavoriteStore = defineStore('Favorite', () => {
}
/**
*
* @returns {void}
*/
function refreshFavoriteItems() {
const types = {
@@ -929,21 +939,21 @@ export const useFavoriteStore = defineStore('Favorite', () => {
}
/**
*
* @returns {void}
*/
function showWorldImportDialog() {
worldImportDialogVisible.value = true;
}
/**
*
* @returns {void}
*/
function showAvatarImportDialog() {
avatarImportDialogVisible.value = true;
}
/**
*
* @returns {void}
*/
function showFriendImportDialog() {
friendImportDialogVisible.value = true;
@@ -1104,7 +1114,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
/**
*
* @param objectId
* @param {string} objectId
* @returns {void}
*/
function updateFavoriteDialog(objectId) {
const D = favoriteDialog.value;
@@ -1199,7 +1210,7 @@ export const useFavoriteStore = defineStore('Favorite', () => {
}
/**
*
* @returns {void}
*/
function sortLocalAvatarFavorites() {
if (!appearanceSettingsStore.sortFavorites) {
@@ -1389,7 +1400,7 @@ export const useFavoriteStore = defineStore('Favorite', () => {
}
/**
*
* @returns {void}
*/
function sortLocalWorldFavorites() {
if (!appearanceSettingsStore.sortFavorites) {
@@ -1450,6 +1461,10 @@ export const useFavoriteStore = defineStore('Favorite', () => {
});
await new Promise((resolve) => setTimeout(resolve, 500));
} catch (err) {
console.error(
`Failed to fetch avatar ${favorite.id}:`,
err
);
result.invalid++;
result.invalidIds.push(favorite.id);
}

View File

@@ -18,7 +18,9 @@ vi.mock('pinia', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
,
locale: require('vue').ref('en')
})
}));
vi.mock('@/components/ui/context-menu', () => ({

View File

@@ -46,7 +46,8 @@ vi.mock('pinia', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
t: (key) => key,
locale: require('vue').ref('en')
})
}));

View File

@@ -34,7 +34,9 @@ vi.mock('pinia', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
,
locale: require('vue').ref('en')
})
}));
vi.mock('../../../stores', () => ({

View File

@@ -44,7 +44,8 @@ vi.mock('pinia', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
t: (key) => key,
locale: require('vue').ref('en')
})
}));

View File

@@ -19,7 +19,9 @@ vi.mock('pinia', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
,
locale: require('vue').ref('en')
})
}));
vi.mock('../../../../stores', () => ({

View File

@@ -32,7 +32,9 @@ vi.mock('pinia', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
,
locale: require('vue').ref('en')
})
}));
vi.mock('../../../stores', () => ({

View File

@@ -28,7 +28,9 @@ vi.mock('pinia', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
,
locale: require('vue').ref('en')
})
}));
vi.mock('../../../stores', () => ({

View File

@@ -20,7 +20,9 @@ vi.mock('pinia', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
,
locale: require('vue').ref('en')
})
}));
vi.mock('../../../../stores', () => ({

View File

@@ -27,7 +27,9 @@ vi.mock('pinia', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
,
locale: require('vue').ref('en')
})
}));
vi.mock('../../../../stores', () => ({

View File

@@ -42,7 +42,8 @@ vi.mock('pinia', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key)
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key),
locale: require('vue').ref('en')
})
}));

View File

@@ -49,7 +49,8 @@ vi.mock('pinia', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key)
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key),
locale: require('vue').ref('en')
})
}));

View File

@@ -33,7 +33,9 @@ vi.mock('../../../../shared/utils', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
,
locale: require('vue').ref('en')
})
}));
vi.mock('@/components/ui/avatar', () => ({

View File

@@ -150,7 +150,8 @@ vi.mock('vue-sonner', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
t: (key) => key,
locale: require('vue').ref('en')
})
}));

View File

@@ -26,7 +26,9 @@ vi.mock('vue-router', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
,
locale: require('vue').ref('en')
})
}));
vi.mock('../../../../stores', () => ({

View File

@@ -52,7 +52,8 @@ vi.mock('../../../../shared/utils', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key,
te: () => false
te: () => false,
locale: require('vue').ref('en')
})
}));
@@ -72,13 +73,19 @@ vi.mock('@/components/ui/avatar', () => ({
props: ['src'],
template: '<img data-testid="avatar-image" :src="src" />'
},
AvatarFallback: { template: '<span data-testid="avatar-fallback"><slot /></span>' }
AvatarFallback: {
template: '<span data-testid="avatar-fallback"><slot /></span>'
}
}));
vi.mock('@/components/ui/hover-card', () => ({
HoverCard: { template: '<div data-testid="hover-card"><slot /></div>' },
HoverCardTrigger: { template: '<div data-testid="hover-trigger"><slot /></div>' },
HoverCardContent: { template: '<div data-testid="hover-content"><slot /></div>' }
HoverCardTrigger: {
template: '<div data-testid="hover-trigger"><slot /></div>'
},
HoverCardContent: {
template: '<div data-testid="hover-content"><slot /></div>'
}
}));
vi.mock('@/components/ui/badge', () => ({
@@ -197,9 +204,9 @@ describe('NotificationItem.vue', () => {
});
await wrapper.get('[data-icon="Link"]').trigger('click');
expect(mocks.notificationStore.openNotificationLink).toHaveBeenCalledWith(
'group:grp_123'
);
expect(
mocks.notificationStore.openNotificationLink
).toHaveBeenCalledWith('group:grp_123');
});
test('unmount queues mark-as-seen for unseen notification', () => {

View File

@@ -22,7 +22,9 @@ vi.mock('@tanstack/vue-virtual', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
,
locale: require('vue').ref('en')
})
}));
vi.mock('@/components/ui/button', () => ({