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

+
@@ -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();