diff --git a/src/components/NavMenu.vue b/src/components/NavMenu.vue
index 35250706..083a29a0 100644
--- a/src/components/NavMenu.vue
+++ b/src/components/NavMenu.vue
@@ -1,134 +1,155 @@
-
-
-
-
-
-
-
-
-
-
- {{
- item.titleIsCustom ? item.title : t(item.title || '')
- }}
-
- {{ isMac ? '⌘' : 'Ctrl' }}
- D
-
-
-
-
-
- handleCollapsedDropdownOpenChange(item.index, value)">
-
+
+
+
+
+
+
+
+
+ :is-active="activeMenuIndex === item.index"
+ :tooltip="getItemTooltip(item)"
+ @click="handleMenuItemClick(item)">
+
+ class="notify-dot-not-collapsed"
+ :class="{ '-right-1!': isCollapsed }"
+ aria-hidden="true">
+
{{
item.titleIsCustom ? item.title : t(item.title || '')
}}
+
+ {{ isMac ? '⌘' : 'Ctrl' }}
+ D
+
-
-
- handleCollapsedSubmenuSelect(event, entry, item.index)">
-
- {{ t(entry.label) }}
-
-
-
+
-
-
-
-
-
- {{
- item.titleIsCustom ? item.title : t(item.title || '')
- }}
+
+ handleCollapsedDropdownOpenChange(item.index, value)
+ ">
+
+
+
+ {{
+ item.titleIsCustom ? item.title : t(item.title || '')
+ }}
+
+
+
+
+ handleCollapsedSubmenuSelect(event, entry, item.index)
+ ">
+
+ {{ t(entry.label) }}
+
+
+
-
-
-
-
-
-
-
+
+
+
+
- {{ t(entry.label) }}
-
-
-
-
-
-
-
-
-
-
-
-
+ {{
+ item.titleIsCustom ? item.title : t(item.title || '')
+ }}
+
+
+
+
+
+
+
+
+
+ {{ t(entry.label) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('nav_menu.mark_all_read') }}
+
+
+
@@ -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();
diff --git a/src/localization/en.json b/src/localization/en.json
index 6a534661..a0535b22 100644
--- a/src/localization/en.json
+++ b/src/localization/en.json
@@ -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",
diff --git a/src/stores/__tests__/uiNotifications.test.js b/src/stores/__tests__/uiNotifications.test.js
new file mode 100644
index 00000000..0b73a915
--- /dev/null
+++ b/src/stores/__tests__/uiNotifications.test.js
@@ -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: '' }
+}));
+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']);
+ });
+ });
+});
diff --git a/src/stores/ui.js b/src/stores/ui.js
index c7a68640..74b57b90 100644
--- a/src/stores/ui.js
+++ b/src/stores/ui.js
@@ -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,