add mark as read to nav menu

This commit is contained in:
pa
2026-03-08 00:01:15 +09:00
parent be854bcd03
commit 4b74e9df5a
4 changed files with 301 additions and 115 deletions

View File

@@ -1,134 +1,155 @@
<template>
<Sidebar side="left" variant="sidebar" collapsible="icon">
<SidebarContent class="pt-2" style="container-type: inline-size">
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu v-if="navLayoutReady">
<template v-for="item in menuItems" :key="item.index">
<SidebarMenuItem v-if="!item.children?.length">
<SidebarMenuButton
:is-active="activeMenuIndex === item.index"
:tooltip="getItemTooltip(item)"
@click="handleMenuItemClick(item)">
<i
:class="item.icon"
class="inline-flex size-6 items-center justify-center text-lg relative">
<span
v-if="isNavItemNotified(item)"
class="notify-dot-not-collapsed"
:class="{ '-right-1!': isCollapsed }"
aria-hidden="true"></span>
</i>
<span v-show="!isCollapsed">{{
item.titleIsCustom ? item.title : t(item.title || '')
}}</span>
<span
v-if="item.action === 'direct-access' && !isCollapsed"
class="nav-shortcut-hint ml-auto inline-flex items-center gap-0.5">
<Kbd>{{ isMac ? '⌘' : 'Ctrl' }}</Kbd>
<Kbd>D</Kbd>
</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-else>
<DropdownMenu
v-if="isCollapsed"
:open="collapsedDropdownOpenId === item.index"
@update:open="(value) => handleCollapsedDropdownOpenChange(item.index, value)">
<DropdownMenuTrigger as-child>
<ContextMenu>
<ContextMenuTrigger as-child>
<SidebarContent class="pt-2" style="container-type: inline-size">
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu v-if="navLayoutReady">
<template v-for="item in menuItems" :key="item.index">
<SidebarMenuItem v-if="!item.children?.length">
<SidebarMenuButton
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')">
:is-active="activeMenuIndex === item.index"
:tooltip="getItemTooltip(item)"
@click="handleMenuItemClick(item)">
<i
:class="item.icon"
class="inline-flex size-6 items-center justify-center text-lg relative"
><span
class="inline-flex size-6 items-center justify-center text-lg relative">
<span
v-if="isNavItemNotified(item)"
class="notify-dot -right-1!"
aria-hidden="true"></span
></i>
class="notify-dot-not-collapsed"
:class="{ '-right-1!': isCollapsed }"
aria-hidden="true"></span>
</i>
<span v-show="!isCollapsed">{{
item.titleIsCustom ? item.title : t(item.title || '')
}}</span>
<span
v-if="item.action === 'direct-access' && !isCollapsed"
class="nav-shortcut-hint ml-auto inline-flex items-center gap-0.5">
<Kbd>{{ isMac ? '⌘' : 'Ctrl' }}</Kbd>
<Kbd>D</Kbd>
</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" class="w-56">
<DropdownMenuItem
v-for="entry in item.children"
:key="entry.index"
@select="(event) => handleCollapsedSubmenuSelect(event, entry, item.index)">
<i
v-if="entry.icon"
:class="entry.icon"
class="inline-flex size-4 items-center justify-center text-base relative"
><span
v-if="isEntryNotified(entry)"
class="notify-dot -right-1! top-0.5!"
aria-hidden="true"></span
></i>
<span>{{ t(entry.label) }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<Collapsible
v-else
class="group/collapsible"
:default-open="
activeMenuIndex && item.children?.some((e) => e.index === activeMenuIndex)
">
<template #default="{ open }">
<CollapsibleTrigger as-child>
<SidebarMenuButton
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')">
<i
:class="item.icon"
class="inline-flex size-6 items-center justify-center text-lg relative"
><span
v-if="isNavItemNotified(item)"
class="notify-dot"
aria-hidden="true"></span
></i>
<span v-show="!isCollapsed">{{
item.titleIsCustom ? item.title : t(item.title || '')
}}</span>
<SidebarMenuItem v-else>
<DropdownMenu
v-if="isCollapsed"
:open="collapsedDropdownOpenId === item.index"
@update:open="
(value) => handleCollapsedDropdownOpenChange(item.index, value)
">
<DropdownMenuTrigger as-child>
<SidebarMenuButton
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')">
<i
:class="item.icon"
class="inline-flex size-6 items-center justify-center text-lg relative"
><span
v-if="isNavItemNotified(item)"
class="notify-dot -right-1!"
aria-hidden="true"></span
></i>
<span v-show="!isCollapsed">{{
item.titleIsCustom ? item.title : t(item.title || '')
}}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" class="w-56">
<DropdownMenuItem
v-for="entry in item.children"
:key="entry.index"
@select="
(event) =>
handleCollapsedSubmenuSelect(event, entry, item.index)
">
<i
v-if="entry.icon"
:class="entry.icon"
class="inline-flex size-4 items-center justify-center text-base relative"
><span
v-if="isEntryNotified(entry)"
class="notify-dot -right-1! top-0.5!"
aria-hidden="true"></span
></i>
<span>{{ t(entry.label) }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChevronRight
v-show="!isCollapsed"
class="ml-auto transition-transform"
:class="open ? 'rotate-90' : ''" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="entry in item.children" :key="entry.index">
<SidebarMenuSubButton
:is-active="activeMenuIndex === entry.index"
@click="handleSubmenuClick(entry, item.index)">
<Collapsible
v-else
class="group/collapsible"
:default-open="
activeMenuIndex &&
item.children?.some((e) => e.index === activeMenuIndex)
">
<template #default="{ open }">
<CollapsibleTrigger as-child>
<SidebarMenuButton
:is-active="
item.children?.some((e) => e.index === activeMenuIndex)
"
:tooltip="
item.titleIsCustom ? item.title : t(item.title || '')
">
<i
v-if="entry.icon"
:class="entry.icon"
class="inline-flex size-5 items-center justify-center text-base relative"
:class="item.icon"
class="inline-flex size-6 items-center justify-center text-lg relative"
><span
v-if="isEntryNotified(entry)"
class="notify-dot -right-0.5!"
v-if="isNavItemNotified(item)"
class="notify-dot"
aria-hidden="true"></span
></i>
<span>{{ t(entry.label) }}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</template>
</Collapsible>
</SidebarMenuItem>
</template>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<span v-show="!isCollapsed">{{
item.titleIsCustom ? item.title : t(item.title || '')
}}</span>
<ChevronRight
v-show="!isCollapsed"
class="ml-auto transition-transform"
:class="open ? 'rotate-90' : ''" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem
v-for="entry in item.children"
:key="entry.index">
<SidebarMenuSubButton
:is-active="activeMenuIndex === entry.index"
@click="handleSubmenuClick(entry, item.index)">
<i
v-if="entry.icon"
:class="entry.icon"
class="inline-flex size-5 items-center justify-center text-base relative"
><span
v-if="isEntryNotified(entry)"
class="notify-dot -right-0.5!"
aria-hidden="true"></span
></i>
<span>{{ t(entry.label) }}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</template>
</Collapsible>
</SidebarMenuItem>
</template>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem :disabled="!hasNotifications" @click="clearAllNotifications">
{{ t('nav_menu.mark_all_read') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<SidebarFooter class="px-2 py-3">
<SidebarMenu>
@@ -340,6 +361,7 @@
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { computed, defineAsyncComponent, h, onMounted, ref, watch } from 'vue';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronRight, Heart } from 'lucide-vue-next';
import { Kbd } from '@/components/ui/kbd';
@@ -427,6 +449,8 @@
const { showVRCXUpdateDialog, showChangeLogDialog } = VRCXUpdaterStore;
const uiStore = useUiStore();
const { notifiedMenus } = storeToRefs(uiStore);
const { clearAllNotifications } = uiStore;
const hasNotifications = computed(() => notifiedMenus.value.length > 0);
const { directAccessPaste } = useSearchStore();
const { logout } = useAuthStore();
const appearanceSettingsStore = useAppearanceSettingsStore();

View File

@@ -56,6 +56,7 @@
"whats_new": "What's New?",
"update_available": "Update available",
"update": "Update",
"mark_all_read": "Mark All as Read",
"custom_nav": {
"header": "Customize Navigation",
"dialog_title": "Customize Navigation Menu",

View File

@@ -0,0 +1,155 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { ref } from 'vue';
import en from '../../localization/en.json';
vi.mock('../../views/Feed/Feed.vue', () => ({
default: { template: '<div />' }
}));
vi.mock('../../views/Feed/columns.jsx', () => ({ columns: [] }));
vi.mock('../../plugin/router', () => ({
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();
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('vue-i18n', async (importOriginal) => {
const actual = await importOriginal();
const i18n = actual.createI18n({
locale: 'en',
fallbackLocale: 'en',
legacy: false,
missingWarn: false,
fallbackWarn: false,
messages: { en }
});
return {
...actual,
useI18n: () => i18n.global
};
});
import { useUiStore } from '../ui';
describe('useUiStore - notification methods', () => {
let store;
beforeEach(() => {
setActivePinia(createPinia());
store = useUiStore();
store.notifiedMenus = [];
});
describe('notifyMenu', () => {
test('adds a menu key to notifiedMenus', () => {
store.notifyMenu('feed');
expect(store.notifiedMenus).toContain('feed');
});
test('does not add duplicate keys', () => {
store.notifyMenu('feed');
store.notifyMenu('feed');
expect(
store.notifiedMenus.filter((k) => k === 'feed')
).toHaveLength(1);
});
test('adds multiple different keys', () => {
store.notifyMenu('feed');
store.notifyMenu('notification');
store.notifyMenu('friend-log');
expect(store.notifiedMenus).toEqual([
'feed',
'notification',
'friend-log'
]);
});
});
describe('removeNotify', () => {
test('removes a specific menu key', () => {
store.notifiedMenus = ['feed', 'notification', 'friend-log'];
store.removeNotify('notification');
expect(store.notifiedMenus).toEqual(['feed', 'friend-log']);
});
test('does nothing when key is not present', () => {
store.notifiedMenus = ['feed'];
store.removeNotify('notification');
expect(store.notifiedMenus).toEqual(['feed']);
});
});
describe('clearAllNotifications', () => {
test('clears all notified menus', () => {
store.notifiedMenus = ['feed', 'notification', 'friend-log'];
store.clearAllNotifications();
expect(store.notifiedMenus).toEqual([]);
});
test('works when already empty', () => {
store.notifiedMenus = [];
store.clearAllNotifications();
expect(store.notifiedMenus).toEqual([]);
});
test('menus can be re-added after clearing', () => {
store.notifiedMenus = ['feed', 'notification'];
store.clearAllNotifications();
expect(store.notifiedMenus).toEqual([]);
store.notifyMenu('friend-log');
expect(store.notifiedMenus).toEqual(['friend-log']);
});
});
});

View File

@@ -308,6 +308,11 @@ export const useUiStore = defineStore('Ui', () => {
updateTrayIconNotify();
}
function clearAllNotifications() {
notifiedMenus.value = [];
updateTrayIconNotify();
}
function updateTrayIconNotify(force = false) {
const newState =
appearanceSettings.notificationIconDot &&
@@ -332,6 +337,7 @@ export const useUiStore = defineStore('Ui', () => {
notifyMenu,
removeNotify,
clearAllNotifications,
showConsole,
updateTrayIconNotify,
pushDialogCrumb,