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
+30 -6
View File
@@ -1,5 +1,7 @@
<template> <template>
<Sidebar side="left" variant="sidebar" collapsible="icon"> <Sidebar side="left" variant="sidebar" collapsible="icon">
<ContextMenu>
<ContextMenuTrigger as-child>
<SidebarContent class="pt-2" style="container-type: inline-size"> <SidebarContent class="pt-2" style="container-type: inline-size">
<SidebarGroup> <SidebarGroup>
<SidebarGroupContent> <SidebarGroupContent>
@@ -35,7 +37,9 @@
<DropdownMenu <DropdownMenu
v-if="isCollapsed" v-if="isCollapsed"
:open="collapsedDropdownOpenId === item.index" :open="collapsedDropdownOpenId === item.index"
@update:open="(value) => handleCollapsedDropdownOpenChange(item.index, value)"> @update:open="
(value) => handleCollapsedDropdownOpenChange(item.index, value)
">
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<SidebarMenuButton <SidebarMenuButton
:is-active="item.children?.some((e) => e.index === activeMenuIndex)" :is-active="item.children?.some((e) => e.index === activeMenuIndex)"
@@ -57,7 +61,10 @@
<DropdownMenuItem <DropdownMenuItem
v-for="entry in item.children" v-for="entry in item.children"
:key="entry.index" :key="entry.index"
@select="(event) => handleCollapsedSubmenuSelect(event, entry, item.index)"> @select="
(event) =>
handleCollapsedSubmenuSelect(event, entry, item.index)
">
<i <i
v-if="entry.icon" v-if="entry.icon"
:class="entry.icon" :class="entry.icon"
@@ -76,13 +83,18 @@
v-else v-else
class="group/collapsible" class="group/collapsible"
:default-open=" :default-open="
activeMenuIndex && item.children?.some((e) => e.index === activeMenuIndex) activeMenuIndex &&
item.children?.some((e) => e.index === activeMenuIndex)
"> ">
<template #default="{ open }"> <template #default="{ open }">
<CollapsibleTrigger as-child> <CollapsibleTrigger as-child>
<SidebarMenuButton <SidebarMenuButton
:is-active="item.children?.some((e) => e.index === activeMenuIndex)" :is-active="
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')"> item.children?.some((e) => e.index === activeMenuIndex)
"
:tooltip="
item.titleIsCustom ? item.title : t(item.title || '')
">
<i <i
:class="item.icon" :class="item.icon"
class="inline-flex size-6 items-center justify-center text-lg relative" class="inline-flex size-6 items-center justify-center text-lg relative"
@@ -103,7 +115,9 @@
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<SidebarMenuSub> <SidebarMenuSub>
<SidebarMenuSubItem v-for="entry in item.children" :key="entry.index"> <SidebarMenuSubItem
v-for="entry in item.children"
:key="entry.index">
<SidebarMenuSubButton <SidebarMenuSubButton
:is-active="activeMenuIndex === entry.index" :is-active="activeMenuIndex === entry.index"
@click="handleSubmenuClick(entry, item.index)"> @click="handleSubmenuClick(entry, item.index)">
@@ -129,6 +143,13 @@
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem :disabled="!hasNotifications" @click="clearAllNotifications">
{{ t('nav_menu.mark_all_read') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<SidebarFooter class="px-2 py-3"> <SidebarFooter class="px-2 py-3">
<SidebarMenu> <SidebarMenu>
@@ -340,6 +361,7 @@
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { computed, defineAsyncComponent, h, onMounted, ref, watch } from 'vue'; 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronRight, Heart } from 'lucide-vue-next'; import { ChevronRight, Heart } from 'lucide-vue-next';
import { Kbd } from '@/components/ui/kbd'; import { Kbd } from '@/components/ui/kbd';
@@ -427,6 +449,8 @@
const { showVRCXUpdateDialog, showChangeLogDialog } = VRCXUpdaterStore; const { showVRCXUpdateDialog, showChangeLogDialog } = VRCXUpdaterStore;
const uiStore = useUiStore(); const uiStore = useUiStore();
const { notifiedMenus } = storeToRefs(uiStore); const { notifiedMenus } = storeToRefs(uiStore);
const { clearAllNotifications } = uiStore;
const hasNotifications = computed(() => notifiedMenus.value.length > 0);
const { directAccessPaste } = useSearchStore(); const { directAccessPaste } = useSearchStore();
const { logout } = useAuthStore(); const { logout } = useAuthStore();
const appearanceSettingsStore = useAppearanceSettingsStore(); const appearanceSettingsStore = useAppearanceSettingsStore();
+1
View File
@@ -56,6 +56,7 @@
"whats_new": "What's New?", "whats_new": "What's New?",
"update_available": "Update available", "update_available": "Update available",
"update": "Update", "update": "Update",
"mark_all_read": "Mark All as Read",
"custom_nav": { "custom_nav": {
"header": "Customize Navigation", "header": "Customize Navigation",
"dialog_title": "Customize Navigation Menu", "dialog_title": "Customize Navigation Menu",
@@ -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']);
});
});
});
+6
View File
@@ -308,6 +308,11 @@ export const useUiStore = defineStore('Ui', () => {
updateTrayIconNotify(); updateTrayIconNotify();
} }
function clearAllNotifications() {
notifiedMenus.value = [];
updateTrayIconNotify();
}
function updateTrayIconNotify(force = false) { function updateTrayIconNotify(force = false) {
const newState = const newState =
appearanceSettings.notificationIconDot && appearanceSettings.notificationIconDot &&
@@ -332,6 +337,7 @@ export const useUiStore = defineStore('Ui', () => {
notifyMenu, notifyMenu,
removeNotify, removeNotify,
clearAllNotifications,
showConsole, showConsole,
updateTrayIconNotify, updateTrayIconNotify,
pushDialogCrumb, pushDialogCrumb,