diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index b17b43b..839e0c6 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -68,6 +68,7 @@ from meshchatx.src.backend.lxmf_message_fields import (
LxmfImageField,
)
from meshchatx.src.backend.lxmf_utils import (
+ compute_lxmf_conversation_unread_from_latest_row,
convert_db_lxmf_message_to_dict,
convert_lxmf_message_to_dict,
)
@@ -8296,15 +8297,7 @@ class ReticulumMeshChat:
row["contact_image"] if "contact_image" in row.keys() else None
)
- # check if is_unread (using last_read_at from join)
- is_unread = False
- if not row["last_read_at"]:
- is_unread = True
- else:
- last_read_at = datetime.fromisoformat(row["last_read_at"])
- if last_read_at.tzinfo is None:
- last_read_at = last_read_at.replace(tzinfo=UTC)
- is_unread = row["timestamp"] > last_read_at.timestamp()
+ is_unread = compute_lxmf_conversation_unread_from_latest_row(row)
# Add extra check for notification viewed state if unread
if is_unread and filter_unread:
diff --git a/meshchatx/src/backend/lxmf_utils.py b/meshchatx/src/backend/lxmf_utils.py
index e1c6da9..b1b2352 100644
--- a/meshchatx/src/backend/lxmf_utils.py
+++ b/meshchatx/src/backend/lxmf_utils.py
@@ -353,3 +353,24 @@ def convert_db_lxmf_message_to_dict(
"created_at": created_at,
"updated_at": updated_at,
}
+
+
+def compute_lxmf_conversation_unread_from_latest_row(row):
+ """
+ Whether the conversation list should show unread for this latest-message row,
+ using lxmf_conversation_read_state.last_read_at only.
+
+ Latest message must be incoming to be unread; if the last message is ours,
+ the thread is not unread (matches filter_unread SQL in MessageHandler.get_conversations).
+ """
+ from datetime import UTC, datetime
+
+ if not row.get("is_incoming"):
+ return False
+ last_read_at_raw = row.get("last_read_at")
+ if not last_read_at_raw:
+ return True
+ last_read_at = datetime.fromisoformat(last_read_at_raw)
+ if last_read_at.tzinfo is None:
+ last_read_at = last_read_at.replace(tzinfo=UTC)
+ return row["timestamp"] > last_read_at.timestamp()
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 8e471eb..207b0c1 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -1046,12 +1046,12 @@ export default {
async sendAnnounce() {
try {
await window.api.get(`/api/v1/announce`);
+ ToastUtils.success(this.$t("app.announce_sent"));
} catch (e) {
ToastUtils.error(this.$t("app.failed_announce"));
console.log(e);
}
- // fetch config so it updates last announced timestamp
await this.getConfig();
},
async copyValue(value, label) {
diff --git a/meshchatx/src/frontend/components/NotificationBell.vue b/meshchatx/src/frontend/components/NotificationBell.vue
index 8eea6e4..b7e1fd1 100644
--- a/meshchatx/src/frontend/components/NotificationBell.vue
+++ b/meshchatx/src/frontend/components/NotificationBell.vue
@@ -26,13 +26,27 @@
Notifications
+
-
No new notifications
+
+ {{
+ showHistory
+ ? $t("app.notifications_empty_history")
+ : $t("app.notifications_no_new")
+ }}
+
@@ -159,6 +179,7 @@ export default {
unreadCount: 0,
reloadInterval: null,
dropdownPosition: { top: 0, left: 0 },
+ showHistory: false,
};
},
computed: {
@@ -180,7 +201,7 @@ export default {
WebSocketConnection.on("message", this.onWebsocketMessage);
this.reloadInterval = setInterval(() => {
if (this.isDropdownOpen) {
- this.loadNotifications();
+ this.loadNotifications({ updateList: false });
}
}, 5000);
},
@@ -197,12 +218,14 @@ export default {
async toggleDropdown(event) {
this.isDropdownOpen = !this.isDropdownOpen;
if (this.isDropdownOpen) {
+ this.showHistory = false;
this.updateDropdownPosition(event);
await this.loadNotifications();
+ const hadNotifications = this.notifications.length > 0;
await this.markNotificationsAsViewed();
-
- // reset unread count locally once viewed
- this.unreadCount = 0;
+ if (hadNotifications) {
+ await this.loadNotifications({ updateList: false });
+ }
}
},
updateDropdownPosition(event) {
@@ -218,31 +241,51 @@ export default {
},
closeDropdown() {
this.isDropdownOpen = false;
+ this.showHistory = false;
},
- async loadNotifications() {
+ async toggleHistory() {
+ this.showHistory = !this.showHistory;
+ await this.loadNotifications();
+ if (!this.showHistory) {
+ const hadNotifications = this.notifications.length > 0;
+ await this.markNotificationsAsViewed();
+ if (hadNotifications) {
+ await this.loadNotifications({ updateList: false });
+ }
+ }
+ },
+ async loadNotifications(options = {}) {
+ const updateList = options.updateList !== false;
if (!this.shouldFetchNotifications()) {
this.notifications = [];
this.unreadCount = 0;
this.isLoading = false;
return;
}
- this.isLoading = true;
+ if (updateList) {
+ this.isLoading = true;
+ }
try {
const response = await window.api.get(`/api/v1/notifications`, {
params: {
- unread: true,
+ unread: !this.showHistory,
limit: 10,
},
});
const newNotifications = response.data.notifications || [];
-
- this.notifications = newNotifications;
+ if (updateList) {
+ this.notifications = newNotifications;
+ }
this.unreadCount = response.data.unread_count || 0;
} catch (e) {
console.error("Failed to load notifications", e);
- this.notifications = [];
+ if (updateList) {
+ this.notifications = [];
+ }
} finally {
- this.isLoading = false;
+ if (updateList) {
+ this.isLoading = false;
+ }
}
},
async markNotificationsAsViewed() {
@@ -293,6 +336,7 @@ export default {
GlobalState.unreadConversationsCount = 0;
+ this.showHistory = false;
await this.loadNotifications();
this.$emit("notifications-cleared");
} catch (e) {
@@ -347,14 +391,28 @@ export default {
return;
}
const json = JSON.parse(message.data);
- if (
- json.type === "lxmf.delivery" ||
- json.type === "telephone_missed_call" ||
- json.type === "new_voicemail"
- ) {
+ if (json.type === "lxmf.delivery") {
+ if (json.lxmf_message?.is_incoming !== true) {
+ return;
+ }
await this.loadNotifications();
if (this.isDropdownOpen) {
+ const hadNotifications = this.notifications.length > 0;
await this.markNotificationsAsViewed();
+ if (hadNotifications) {
+ await this.loadNotifications({ updateList: false });
+ }
+ }
+ return;
+ }
+ if (json.type === "telephone_missed_call" || json.type === "new_voicemail") {
+ await this.loadNotifications();
+ if (this.isDropdownOpen) {
+ const hadNotifications = this.notifications.length > 0;
+ await this.markNotificationsAsViewed();
+ if (hadNotifications) {
+ await this.loadNotifications({ updateList: false });
+ }
}
}
},
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index ce6e498..4486035 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -220,6 +220,10 @@
"announce_limit_nomadnet": "NomadNet",
"announce_limit_prop": "Prop-Knoten",
"failed_announce": "Ankündigung fehlgeschlagen",
+ "announce_sent": "Ankündigung gesendet",
+ "notifications_no_new": "Keine neuen Benachrichtigungen",
+ "notifications_empty_history": "Kein Benachrichtigungsverlauf",
+ "notifications_history_title": "Letzter Benachrichtigungsverlauf",
"search_settings": "Einstellungen suchen...",
"show_qr": "QR-Code anzeigen",
"csp_settings": "Content-Security-Policy (CSP)",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index bba08be..29f15b5 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -60,6 +60,10 @@
"announce_now": "Announce Now",
"show_qr": "Show QR Code",
"failed_announce": "failed to announce",
+ "announce_sent": "Announcement sent",
+ "notifications_no_new": "No new notifications",
+ "notifications_empty_history": "No notification history",
+ "notifications_history_title": "Recent notification history",
"last_announced": "Last announced: {time}",
"last_announced_never": "Last announced: Never",
"display_name_placeholder": "Display Name",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 8d57cf8..fb6f490 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -60,6 +60,10 @@
"announce_now": "Annuncia Ora",
"show_qr": "Mostra Codice QR",
"failed_announce": "impossibile annunciare",
+ "announce_sent": "Annuncio inviato",
+ "notifications_no_new": "Nessuna nuova notifica",
+ "notifications_empty_history": "Nessuna cronologia notifiche",
+ "notifications_history_title": "Cronologia notifiche recenti",
"last_announced": "Ultimo annuncio: {time}",
"last_announced_never": "Ultimo annuncio: Mai",
"display_name_placeholder": "Nome Visualizzato",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 66ee3a8..395c071 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -220,6 +220,10 @@
"announce_limit_nomadnet": "NomadNet",
"announce_limit_prop": "Prop-узлы",
"failed_announce": "ошибка анонса",
+ "announce_sent": "Анонс отправлен",
+ "notifications_no_new": "Нет новых уведомлений",
+ "notifications_empty_history": "Нет истории уведомлений",
+ "notifications_history_title": "Недавняя история уведомлений",
"search_settings": "Поиск настроек...",
"show_qr": "Показать QR-код",
"csp_settings": "Политика безопасности контента (CSP)",
diff --git a/tests/backend/test_lxmf_utils_extended.py b/tests/backend/test_lxmf_utils_extended.py
index 7c96164..2ace41a 100644
--- a/tests/backend/test_lxmf_utils_extended.py
+++ b/tests/backend/test_lxmf_utils_extended.py
@@ -1,10 +1,12 @@
import base64
import json
+from datetime import UTC, datetime
from unittest.mock import MagicMock
import LXMF
from meshchatx.src.backend.lxmf_utils import (
+ compute_lxmf_conversation_unread_from_latest_row,
convert_db_lxmf_message_to_dict,
convert_lxmf_message_to_dict,
convert_lxmf_state_to_string,
@@ -207,3 +209,43 @@ def test_convert_db_lxmf_message_to_dict_with_reply():
}
result = convert_db_lxmf_message_to_dict(db_msg)
assert result["reply_to_hash"] == "original_hash_hex"
+
+
+def test_compute_unread_outgoing_latest_never_unread_even_without_read_cursor():
+ row = {
+ "is_incoming": 0,
+ "last_read_at": None,
+ "timestamp": 1_700_000_000.0,
+ }
+ assert compute_lxmf_conversation_unread_from_latest_row(row) is False
+
+
+def test_compute_unread_incoming_no_read_cursor_is_unread():
+ row = {
+ "is_incoming": 1,
+ "last_read_at": None,
+ "timestamp": 1_700_000_000.0,
+ }
+ assert compute_lxmf_conversation_unread_from_latest_row(row) is True
+
+
+def test_compute_unread_incoming_not_unread_when_read_cursor_covers_message():
+ ts = 1_700_000_000.0
+ read_after_msg = datetime.fromtimestamp(ts + 60, UTC).isoformat()
+ row = {
+ "is_incoming": 1,
+ "last_read_at": read_after_msg,
+ "timestamp": ts,
+ }
+ assert compute_lxmf_conversation_unread_from_latest_row(row) is False
+
+
+def test_compute_unread_incoming_newer_than_read_cursor_unread():
+ ts = 1_700_000_000.0
+ read = datetime.fromtimestamp(ts - 120, UTC).isoformat()
+ row = {
+ "is_incoming": 1,
+ "last_read_at": read,
+ "timestamp": ts,
+ }
+ assert compute_lxmf_conversation_unread_from_latest_row(row) is True
diff --git a/tests/backend/test_notification_unread_semantics.py b/tests/backend/test_notification_unread_semantics.py
new file mode 100644
index 0000000..d58ba3a
--- /dev/null
+++ b/tests/backend/test_notification_unread_semantics.py
@@ -0,0 +1,114 @@
+"""
+Regression tests for conversation/bell unread semantics: no false positives from
+outgoing-latest threads or inconsistent read cursors.
+"""
+
+from datetime import UTC, datetime
+
+import pytest
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from meshchatx.src.backend.lxmf_utils import compute_lxmf_conversation_unread_from_latest_row
+
+
+def _row(incoming, last_read_iso, ts):
+ return {
+ "is_incoming": incoming,
+ "last_read_at": last_read_iso,
+ "timestamp": ts,
+ }
+
+
+class TestComputeLxmfUnreadFromRow:
+ def test_outgoing_never_unread(self):
+ base_ts = 1_700_000_000.0
+ for last in (None, datetime.fromtimestamp(base_ts - 1, UTC).isoformat()):
+ assert (
+ compute_lxmf_conversation_unread_from_latest_row(
+ _row(0, last, base_ts),
+ )
+ is False
+ )
+ assert (
+ compute_lxmf_conversation_unread_from_latest_row(
+ _row(False, last, base_ts),
+ )
+ is False
+ )
+
+ def test_incoming_unread_when_no_read_cursor(self):
+ assert (
+ compute_lxmf_conversation_unread_from_latest_row(
+ _row(1, None, 1_700_000_000.0),
+ )
+ is True
+ )
+
+ def test_incoming_unread_when_bool_true(self):
+ assert (
+ compute_lxmf_conversation_unread_from_latest_row(
+ _row(True, None, 1_700_000_000.0),
+ )
+ is True
+ )
+
+ @settings(max_examples=200)
+ @given(
+ ts=st.floats(
+ min_value=1_000_000_000.0,
+ max_value=2_000_000_000.0,
+ allow_nan=False,
+ allow_infinity=False,
+ ),
+ delta=st.floats(
+ min_value=1.0,
+ max_value=86_400.0,
+ allow_nan=False,
+ allow_infinity=False,
+ ),
+ )
+ def test_incoming_newer_than_read_always_unread(self, ts, delta):
+ read_at = datetime.fromtimestamp(ts - delta, UTC).isoformat()
+ assert (
+ compute_lxmf_conversation_unread_from_latest_row(
+ _row(1, read_at, ts),
+ )
+ is True
+ )
+
+ @settings(max_examples=200)
+ @given(
+ ts=st.floats(
+ min_value=1_000_000_100.0,
+ max_value=2_000_000_000.0,
+ allow_nan=False,
+ allow_infinity=False,
+ ),
+ delta=st.floats(
+ min_value=0.001,
+ max_value=86_400.0,
+ allow_nan=False,
+ allow_infinity=False,
+ ),
+ )
+ def test_incoming_older_or_equal_read_never_unread(self, ts, delta):
+ read_at = datetime.fromtimestamp(ts + delta, UTC).isoformat()
+ assert (
+ compute_lxmf_conversation_unread_from_latest_row(
+ _row(1, read_at, ts),
+ )
+ is False
+ )
+
+
+@pytest.mark.parametrize(
+ ("incoming", "expect_unread"),
+ [
+ (0, False),
+ (1, True),
+ ],
+)
+def test_sqlite_integer_incoming_flags(incoming, expect_unread):
+ row = _row(incoming, None, 1_700_000_000.0)
+ assert compute_lxmf_conversation_unread_from_latest_row(row) is expect_unread
diff --git a/tests/frontend/NotificationBell.test.js b/tests/frontend/NotificationBell.test.js
index 5e98a34..1f3da43 100644
--- a/tests/frontend/NotificationBell.test.js
+++ b/tests/frontend/NotificationBell.test.js
@@ -30,6 +30,14 @@ function mountBell(options = {}) {
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
mocks: {
$router: { push: vi.fn() },
+ $t: (key) => {
+ const map = {
+ "app.notifications_no_new": "No new notifications",
+ "app.notifications_empty_history": "No notification history",
+ "app.notifications_history_title": "Recent notification history",
+ };
+ return map[key] || key;
+ },
},
},
...options,
@@ -37,7 +45,11 @@ function mountBell(options = {}) {
}
function simulateWsMessage(type, extra = {}) {
- const data = JSON.stringify({ type, ...extra });
+ const payload = { type, ...extra };
+ if (type === "lxmf.delivery" && payload.lxmf_message === undefined) {
+ payload.lxmf_message = { is_incoming: true };
+ }
+ const data = JSON.stringify(payload);
(wsHandlers["message"] || []).forEach((h) => h({ data }));
}
@@ -104,10 +116,27 @@ describe("NotificationBell UI", () => {
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button").trigger("click");
await wrapper.vm.$nextTick();
+ await new Promise((r) => setTimeout(r, 150));
expect(document.body.textContent).toContain("No new notifications");
wrapper.unmount();
});
+ it("opening empty dropdown adds one notifications fetch after mount", async () => {
+ global.api.get = vi.fn().mockResolvedValue({
+ data: { notifications: [], unread_count: 0 },
+ });
+ const wrapper = mountBell({ attachTo: document.body });
+ await wrapper.vm.$nextTick();
+ const notifGetsAfterMount = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications")
+ .length;
+ await wrapper.find("button").trigger("click");
+ await new Promise((r) => setTimeout(r, 150));
+ const notifGetsAfterOpen = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications")
+ .length;
+ expect(notifGetsAfterOpen - notifGetsAfterMount).toBe(1);
+ wrapper.unmount();
+ });
+
it("dropdown has Notifications heading when open", async () => {
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button").trigger("click");
@@ -202,11 +231,37 @@ describe("NotificationBell websocket reliability", () => {
simulateWsMessage("telephone_ringing");
simulateWsMessage("telephone_call_ended");
simulateWsMessage("lxmf_message_state_updated");
+ simulateWsMessage("lxmf.delivery", { lxmf_message: { is_incoming: false } });
await new Promise((r) => setTimeout(r, 50));
expect(global.api.get.mock.calls.length).toBe(callsBefore);
});
+ it("does NOT reload on outbound lxmf.delivery (delivery confirmation path)", async () => {
+ const wrapper = mountBell();
+ await wrapper.vm.$nextTick();
+ const callsBefore = global.api.get.mock.calls.length;
+ simulateWsMessage("lxmf.delivery", {
+ lxmf_message: { is_incoming: false, state: "delivered" },
+ });
+ await new Promise((r) => setTimeout(r, 50));
+ expect(global.api.get.mock.calls.length).toBe(callsBefore);
+ });
+
+ it("reloads on inbound lxmf.delivery", async () => {
+ global.api.get = vi.fn().mockResolvedValue({
+ data: { notifications: [], unread_count: 0 },
+ });
+ const wrapper = mountBell();
+ await wrapper.vm.$nextTick();
+ const callsAfterMount = global.api.get.mock.calls.length;
+ simulateWsMessage("lxmf.delivery", { lxmf_message: { is_incoming: true } });
+ await new Promise((r) => setTimeout(r, 50));
+ expect(global.api.get.mock.calls.length).toBeGreaterThan(callsAfterMount);
+ const notifCalls = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications");
+ expect(notifCalls.length).toBeGreaterThan(0);
+ });
+
it("rapid sequential websocket events all trigger reloads", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
@@ -274,16 +329,29 @@ describe("NotificationBell badge accuracy", () => {
expect(wrapper.text()).toContain("9+");
});
- it("opening dropdown resets unread count to 0", async () => {
- global.api.get = vi.fn().mockResolvedValue({
- data: { notifications: [{ destination_hash: "d1", display_name: "A", content: "m" }], unread_count: 3 },
- });
+ it("opening dropdown syncs unread count from server after mark-as-viewed", async () => {
+ global.api.get = vi
+ .fn()
+ .mockResolvedValueOnce({
+ data: {
+ notifications: [{ destination_hash: "d1", display_name: "A", content: "m" }],
+ unread_count: 3,
+ },
+ })
+ .mockResolvedValueOnce({
+ data: {
+ notifications: [{ destination_hash: "d1", display_name: "A", content: "m" }],
+ unread_count: 3,
+ },
+ })
+ .mockResolvedValue({
+ data: { notifications: [], unread_count: 0 },
+ });
const wrapper = mountBell({ attachTo: document.body });
- wrapper.vm.unreadCount = 3;
await wrapper.vm.$nextTick();
await wrapper.find("button").trigger("click");
- await new Promise((r) => setTimeout(r, 50));
+ await new Promise((r) => setTimeout(r, 80));
expect(wrapper.vm.unreadCount).toBe(0);
wrapper.unmount();
@@ -354,6 +422,77 @@ describe("NotificationBell mark-as-viewed", () => {
});
});
+describe("NotificationBell history", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ wsHandlers = {};
+ global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
+ global.api.post = vi.fn().mockResolvedValue({ data: {} });
+ });
+
+ afterEach(() => {
+ wsHandlers = {};
+ });
+
+ it("shows history control when dropdown is open", async () => {
+ const wrapper = mountBell({ attachTo: document.body });
+ await wrapper.find("button.relative.rounded-full").trigger("click");
+ await wrapper.vm.$nextTick();
+ const historyBtn = document.body.querySelector('[aria-label="Recent notification history"]');
+ expect(historyBtn).toBeTruthy();
+ wrapper.unmount();
+ });
+
+ it("requests unread=false when toggling history on", async () => {
+ global.api.get = vi.fn().mockResolvedValue({
+ data: {
+ notifications: [
+ {
+ id: 9,
+ type: "telephone_missed_call",
+ destination_hash: "ab",
+ display_name: "X",
+ content: "missed",
+ },
+ ],
+ unread_count: 0,
+ },
+ });
+ const wrapper = mountBell({ attachTo: document.body });
+ await wrapper.vm.$nextTick();
+ await wrapper.find("button.relative.rounded-full").trigger("click");
+ await new Promise((r) => setTimeout(r, 120));
+ global.api.get.mockClear();
+ await wrapper.vm.toggleHistory();
+ await wrapper.vm.$nextTick();
+ const notifCalls = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications");
+ expect(notifCalls.length).toBeGreaterThan(0);
+ const lastParams = notifCalls[notifCalls.length - 1][1].params;
+ expect(lastParams.unread).toBe(false);
+ expect(wrapper.vm.showHistory).toBe(true);
+ wrapper.unmount();
+ });
+
+ it("resets history mode when dropdown closes", async () => {
+ const wrapper = mountBell({ attachTo: document.body });
+ wrapper.vm.showHistory = true;
+ wrapper.vm.closeDropdown();
+ expect(wrapper.vm.showHistory).toBe(false);
+ });
+
+ it("shows empty history copy in history mode", async () => {
+ global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
+ const wrapper = mountBell({ attachTo: document.body });
+ await wrapper.find("button.relative.rounded-full").trigger("click");
+ await new Promise((r) => setTimeout(r, 120));
+ await wrapper.vm.toggleHistory();
+ await wrapper.vm.$nextTick();
+ await new Promise((r) => setTimeout(r, 50));
+ expect(document.body.textContent).toContain("No notification history");
+ wrapper.unmount();
+ });
+});
+
describe("NotificationBell clear all", () => {
beforeEach(() => {
vi.clearAllMocks();