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>
<Sidebar side="left" variant="sidebar" collapsible="icon">
<ContextMenu>
<ContextMenuTrigger as-child>
<SidebarContent class="pt-2" style="container-type: inline-size">
<SidebarGroup>
<SidebarGroupContent>
@@ -35,7 +37,9 @@
<DropdownMenu
v-if="isCollapsed"
:open="collapsedDropdownOpenId === item.index"
@update:open="(value) => handleCollapsedDropdownOpenChange(item.index, value)">
@update:open="
(value) => handleCollapsedDropdownOpenChange(item.index, value)
">
<DropdownMenuTrigger as-child>
<SidebarMenuButton
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
@@ -57,7 +61,10 @@
<DropdownMenuItem
v-for="entry in item.children"
:key="entry.index"
@select="(event) => handleCollapsedSubmenuSelect(event, entry, item.index)">
@select="
(event) =>
handleCollapsedSubmenuSelect(event, entry, item.index)
">
<i
v-if="entry.icon"
:class="entry.icon"
@@ -76,13 +83,18 @@
v-else
class="group/collapsible"
:default-open="
activeMenuIndex && item.children?.some((e) => e.index === activeMenuIndex)
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 || '')">
: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"
@@ -103,7 +115,9 @@
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="entry in item.children" :key="entry.index">
<SidebarMenuSubItem
v-for="entry in item.children"
:key="entry.index">
<SidebarMenuSubButton
:is-active="activeMenuIndex === entry.index"
@click="handleSubmenuClick(entry, item.index)">
@@ -129,6 +143,13 @@
</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();
+1
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",
@@ -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();
}
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,