mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-05-11 16:04:42 +00:00
feat(notification): improve notification bell with history toggle and improve unread message logic
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -26,13 +26,27 @@
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="notifications.length > 0"
|
||||
v-if="notifications.length > 0 && !showHistory"
|
||||
type="button"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
@click.stop="clearAllNotifications"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 transition-colors"
|
||||
:class="
|
||||
showHistory
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950/40'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800'
|
||||
"
|
||||
:title="$t('app.notifications_history_title')"
|
||||
:aria-label="$t('app.notifications_history_title')"
|
||||
@click.stop="toggleHistory"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="history" class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
@@ -57,7 +71,13 @@
|
||||
icon-name="bell-off"
|
||||
class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">No new notifications</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
showHistory
|
||||
? $t("app.notifications_empty_history")
|
||||
: $t("app.notifications_no_new")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-gray-200 dark:divide-zinc-800">
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user