diff --git a/src/localization/en.json b/src/localization/en.json
index 4550f830..8bf3d9e7 100644
--- a/src/localization/en.json
+++ b/src/localization/en.json
@@ -12,7 +12,8 @@
"open": "Open",
"confirm": "Confirm",
"clear": "Clear",
- "reset": "Reset"
+ "reset": "Reset",
+ "view_details": "View Details"
},
"time_units": {
"y": "y",
diff --git a/src/views/Favorites/FavoritesFriend.vue b/src/views/Favorites/FavoritesFriend.vue
index efeacbef..958e3f78 100644
--- a/src/views/Favorites/FavoritesFriend.vue
+++ b/src/views/Favorites/FavoritesFriend.vue
@@ -246,8 +246,7 @@
:group="activeRemoteGroup"
:selected="selectedFavoriteFriends.includes(favorite.id)"
:edit-mode="friendEditMode"
- @toggle-select="toggleFriendSelection(favorite.id, $event)"
- @click="showUserDialog(favorite.id)" />
+ @toggle-select="toggleFriendSelection(favorite.id, $event)" />
@@ -268,8 +267,7 @@
:group="{ key: activeLocalGroupName, type: 'local' }"
:selected="selectedFavoriteFriends.includes(favorite.id)"
:edit-mode="friendEditMode"
- @toggle-select="toggleFriendSelection(favorite.id, $event)"
- @click="showUserDialog(favorite.id)" />
+ @toggle-select="toggleFriendSelection(favorite.id, $event)" />
diff --git a/src/views/Favorites/components/FavoritesFriendItem.vue b/src/views/Favorites/components/FavoritesFriendItem.vue
index f270098e..19dd70eb 100644
--- a/src/views/Favorites/components/FavoritesFriendItem.vue
+++ b/src/views/Favorites/components/FavoritesFriendItem.vue
@@ -1,101 +1,162 @@
-
-
-
-
-
![]()
-
-
-
- {{ favorite.ref.displayName }}
-
-
-
-
-
{{ favorite.ref.statusDescription }}
-
-
-
-
-
+
+
+
+ -
+
+
+
+ {{ avatarFallback }}
+
+
+
+ {{ displayName }}
+
+
+
+
+
+ {{ favorite.ref.statusDescription }}
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ favorite.name || favorite.id }}
-
-
-
-
-
+
+
+
+
+
+
+
+ {{ t('common.actions.view_details') }}
+
+
+ {{ t('dialog.user.actions.request_invite') }}
+
+
+ {{ t('dialog.user.actions.invite') }}
+
+
+ {{ t('dialog.user.actions.send_boop') }}
+
+
+
+ {{ t('dialog.user.info.launch_invite_tooltip') }}
+
+
+ {{ t('dialog.user.info.self_invite_tooltip') }}
+
+
+
+ {{ t('view.favorite.edit_favorite_tooltip') }}
+
+
+ {{ deleteMenuLabel }}
+
+
+
+
+
+
+ {{ t('common.actions.view_details') }}
+
+ {{ t('dialog.user.actions.request_invite') }}
+
+
+ {{ t('dialog.user.actions.invite') }}
+
+
+ {{ t('dialog.user.actions.send_boop') }}
+
+
+
+ {{ t('dialog.user.info.launch_invite_tooltip') }}
+
+
+ {{ t('dialog.user.info.self_invite_tooltip') }}
+
+
+
+ {{ t('view.favorite.edit_favorite_tooltip') }}
+
+
+ {{ deleteMenuLabel }}
+
+
+
+
+
+ -
+
+
+ {{ avatarFallback }}
+
+
+
+ {{ favorite.name || favorite.id }}
+ {{ favorite.id }}
+
+
+
+
+
+
-
diff --git a/src/views/Favorites/components/__tests__/FavoritesFriendItem.test.js b/src/views/Favorites/components/__tests__/FavoritesFriendItem.test.js
new file mode 100644
index 00000000..19ad8a1b
--- /dev/null
+++ b/src/views/Favorites/components/__tests__/FavoritesFriendItem.test.js
@@ -0,0 +1,313 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ showFavoriteDialog: vi.fn(),
+ showUserDialog: vi.fn(),
+ showSendBoopDialog: vi.fn(),
+ removeLocalFriendFavorite: vi.fn(),
+ deleteFavorite: vi.fn(),
+ sendRequestInvite: vi.fn(() => Promise.resolve()),
+ sendInvite: vi.fn(() => Promise.resolve()),
+ selfInvite: vi.fn(() => Promise.resolve()),
+ fetch: vi.fn(() => Promise.resolve({ ref: { name: 'World Name' } })),
+ showLaunchDialog: vi.fn(),
+ checkCanInvite: vi.fn(() => true),
+ checkCanInviteSelf: vi.fn(() => true),
+ isRealInstance: vi.fn(() => true),
+ currentUser: { isBoopingEnabled: true },
+ isGameRunning: { value: true },
+ lastLocation: { value: { location: 'wrld_here:123~private' } },
+ lastLocationDestination: { value: 'wrld_destination:456~private' },
+ toastSuccess: vi.fn()
+}));
+
+vi.mock('pinia', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ storeToRefs: (store) => store
+ };
+});
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key) => key
+ })
+}));
+
+vi.mock('vue-sonner', () => ({
+ toast: {
+ success: (...args) => mocks.toastSuccess(...args)
+ }
+}));
+
+vi.mock('../../../../stores', () => ({
+ useFavoriteStore: () => ({
+ showFavoriteDialog: (...args) => mocks.showFavoriteDialog(...args)
+ }),
+ useUserStore: () => ({
+ showSendBoopDialog: (...args) => mocks.showSendBoopDialog(...args),
+ currentUser: mocks.currentUser
+ }),
+ useGameStore: () => ({
+ isGameRunning: mocks.isGameRunning
+ }),
+ useLocationStore: () => ({
+ lastLocation: mocks.lastLocation,
+ lastLocationDestination: mocks.lastLocationDestination
+ }),
+ useLaunchStore: () => ({
+ showLaunchDialog: (...args) => mocks.showLaunchDialog(...args)
+ })
+}));
+
+vi.mock('../../../../api', () => ({
+ favoriteRequest: {
+ deleteFavorite: (...args) => mocks.deleteFavorite(...args)
+ },
+ notificationRequest: {
+ sendRequestInvite: (...args) => mocks.sendRequestInvite(...args),
+ sendInvite: (...args) => mocks.sendInvite(...args)
+ },
+ instanceRequest: {
+ selfInvite: (...args) => mocks.selfInvite(...args)
+ },
+ queryRequest: {
+ fetch: (...args) => mocks.fetch(...args)
+ }
+}));
+
+vi.mock('../../../../coordinators/favoriteCoordinator', () => ({
+ removeLocalFriendFavorite: (...args) => mocks.removeLocalFriendFavorite(...args)
+}));
+
+vi.mock('../../../../coordinators/userCoordinator', () => ({
+ showUserDialog: (...args) => mocks.showUserDialog(...args)
+}));
+
+vi.mock('../../../../composables/useInviteChecks', () => ({
+ useInviteChecks: () => ({
+ checkCanInvite: (...args) => mocks.checkCanInvite(...args),
+ checkCanInviteSelf: (...args) => mocks.checkCanInviteSelf(...args)
+ })
+}));
+
+vi.mock('../../../../composables/useUserDisplay', () => ({
+ useUserDisplay: () => ({
+ userImage: () => 'https://example.com/avatar.png'
+ })
+}));
+
+vi.mock('../../../../shared/utils', () => ({
+ parseLocation: () => ({ worldId: 'wrld_123', instanceId: '123', tag: '123~private' }),
+ isRealInstance: (...args) => mocks.isRealInstance(...args)
+}));
+
+vi.mock('../../../../components/Location.vue', () => ({
+ default: {
+ template: '
'
+ }
+}));
+
+vi.mock('@/components/ui/item', () => ({
+ Item: {
+ emits: ['click'],
+ template: '
'
+ },
+ ItemActions: { template: '
' },
+ ItemMedia: { template: '
' },
+ ItemContent: { template: '
' },
+ ItemTitle: { template: '
' },
+ ItemDescription: { template: '
' }
+}));
+
+vi.mock('@/components/ui/avatar', () => ({
+ Avatar: { template: '
' },
+ AvatarImage: { template: '
![]()
' },
+ AvatarFallback: { template: '
' }
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template: '
'
+ }
+}));
+
+vi.mock('@/components/ui/checkbox', () => ({
+ Checkbox: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ '
'
+ }
+}));
+
+vi.mock('@/components/ui/context-menu', () => ({
+ ContextMenu: { template: '
' },
+ ContextMenuTrigger: { template: '
' },
+ ContextMenuContent: { template: '
' },
+ ContextMenuSeparator: { template: '
' },
+ ContextMenuItem: {
+ emits: ['click'],
+ template: '
'
+ }
+}));
+
+vi.mock('@/components/ui/dropdown-menu', () => ({
+ DropdownMenu: { template: '
' },
+ DropdownMenuTrigger: { template: '
' },
+ DropdownMenuContent: { template: '
' },
+ DropdownMenuSeparator: { template: '
' },
+ DropdownMenuItem: {
+ emits: ['click'],
+ template: '
'
+ }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ MoreHorizontal: { template: '
' },
+ Trash2: { template: '
' }
+}));
+
+import FavoritesFriendItem from '../FavoritesFriendItem.vue';
+
+/**
+ *
+ * @param {Record
} props
+ */
+function mountItem(props = {}) {
+ return mount(FavoritesFriendItem, {
+ props: {
+ favorite: {
+ id: 'usr_1',
+ ref: {
+ id: 'usr_1',
+ displayName: 'Alice',
+ statusDescription: 'Hello',
+ location: 'wrld_aaa:1~private',
+ travelingToLocation: '',
+ state: 'online'
+ }
+ },
+ group: { key: 'g1', type: 'remote' },
+ editMode: false,
+ selected: false,
+ ...props
+ }
+ });
+}
+
+/**
+ *
+ * @param wrapper
+ * @param text
+ */
+async function clickMenuItem(wrapper, text) {
+ const buttons = wrapper.findAll('button');
+ const target = buttons.find((btn) => btn.text().includes(text));
+ expect(target, `menu item not found: ${text}`).toBeTruthy();
+ await target.trigger('click');
+}
+
+describe('FavoritesFriendItem.vue', () => {
+ beforeEach(() => {
+ mocks.showFavoriteDialog.mockReset();
+ mocks.showUserDialog.mockReset();
+ mocks.showSendBoopDialog.mockReset();
+ mocks.removeLocalFriendFavorite.mockReset();
+ mocks.deleteFavorite.mockReset();
+ mocks.sendRequestInvite.mockClear();
+ mocks.sendInvite.mockClear();
+ mocks.selfInvite.mockClear();
+ mocks.fetch.mockClear();
+ mocks.showLaunchDialog.mockReset();
+ mocks.checkCanInvite.mockReturnValue(true);
+ mocks.checkCanInviteSelf.mockReturnValue(true);
+ mocks.isRealInstance.mockReturnValue(true);
+ mocks.currentUser.isBoopingEnabled = true;
+ mocks.isGameRunning.value = true;
+ mocks.lastLocation.value = { location: 'wrld_here:123~private' };
+ mocks.lastLocationDestination.value = 'wrld_destination:456~private';
+ });
+
+ it('opens user dialog when item is clicked', async () => {
+ const wrapper = mountItem();
+
+ await wrapper.get('[data-testid="item"]').trigger('click');
+
+ expect(mocks.showUserDialog).toHaveBeenCalledWith('usr_1');
+ });
+
+ it('emits toggle-select in edit mode checkbox', async () => {
+ const wrapper = mountItem({ editMode: true });
+
+ await wrapper.get('[data-testid="checkbox"]').setValue(true);
+
+ expect(wrapper.emitted('toggle-select')).toEqual([[true]]);
+ });
+
+ it('uses local delete flow for local favorites', async () => {
+ const wrapper = mountItem({ group: { key: 'Local', type: 'local' } });
+
+ await clickMenuItem(wrapper, 'view.favorite.delete_tooltip');
+
+ expect(mocks.removeLocalFriendFavorite).toHaveBeenCalledWith('usr_1', 'Local');
+ expect(mocks.deleteFavorite).not.toHaveBeenCalled();
+ });
+
+ it('uses remote delete flow for remote favorites', async () => {
+ const wrapper = mountItem({ group: { key: 'Remote', type: 'remote' } });
+
+ await clickMenuItem(wrapper, 'view.favorite.unfavorite_tooltip');
+
+ expect(mocks.deleteFavorite).toHaveBeenCalledWith({ objectId: 'usr_1' });
+ expect(mocks.removeLocalFriendFavorite).not.toHaveBeenCalled();
+ });
+
+ it('renders invite/join actions only when online with instance', () => {
+ const wrapper = mountItem();
+
+ expect(wrapper.text()).toContain('dialog.user.actions.request_invite');
+ expect(wrapper.text()).toContain('dialog.user.actions.invite');
+ expect(wrapper.text()).toContain('dialog.user.info.launch_invite_tooltip');
+ expect(wrapper.text()).toContain('dialog.user.info.self_invite_tooltip');
+ });
+
+ it('hides invite/join actions when offline', () => {
+ const wrapper = mountItem({
+ favorite: {
+ id: 'usr_1',
+ ref: {
+ id: 'usr_1',
+ displayName: 'Alice',
+ statusDescription: 'Offline',
+ location: 'offline',
+ travelingToLocation: '',
+ state: 'offline'
+ }
+ }
+ });
+
+ expect(wrapper.text()).not.toContain('dialog.user.actions.request_invite');
+ expect(wrapper.text()).not.toContain('dialog.user.info.launch_invite_tooltip');
+ expect(wrapper.text()).not.toContain('dialog.user.info.self_invite_tooltip');
+ });
+
+ it('triggers request invite action', async () => {
+ const wrapper = mountItem();
+
+ await clickMenuItem(wrapper, 'dialog.user.actions.request_invite');
+
+ expect(mocks.sendRequestInvite).toHaveBeenCalledWith({ platform: 'standalonewindows' }, 'usr_1');
+ });
+
+ it('triggers join action', async () => {
+ const wrapper = mountItem();
+
+ await clickMenuItem(wrapper, 'dialog.user.info.launch_invite_tooltip');
+
+ expect(mocks.showLaunchDialog).toHaveBeenCalledWith('wrld_aaa:1~private');
+ });
+});