feat(notification): improve notification bell with history toggle and improve unread message logic

This commit is contained in:
Ivan
2026-04-09 03:42:58 -05:00
parent 61c7f544f0
commit bb910f288b
11 changed files with 418 additions and 35 deletions
+2 -9
View File
@@ -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:
+21
View File
@@ -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()
+1 -1
View File
@@ -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 });
}
}
}
},
+4
View File
@@ -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)",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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)",
+42
View File
@@ -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
+146 -7
View File
@@ -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();