From eb0f4369243452d3ba78f0d46e70f408f71ae4e4 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 13 May 2026 10:48:42 +0200 Subject: [PATCH] feat: DM per-contact notification settings and mark-as-read Long-press ENTER in the DM contact list opens a context menu with: - "Mark as read": clears unread counter for that contact - "Notif: default/OFF/ON": cycles notification state (same as channels) Notification state is persisted in NodePrefs (dm_notif[16] table, keyed by 4-byte pub_key prefix). When muted, the buzzer is silenced for that contact's messages; when force-on, buzzer plays even in quiet mode. Default follows the global buzzer setting. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 4 + examples/companion_radio/MyMesh.cpp | 1 + examples/companion_radio/NodePrefs.h | 3 + examples/companion_radio/ui-new/UITask.cpp | 122 ++++++++++++++++++++- examples/companion_radio/ui-new/UITask.h | 4 + 5 files changed, 131 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 2d719396..75380404 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -264,6 +264,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.clock_hide_seconds, sizeof(_prefs.clock_hide_seconds)); if (file.available()) { file.read((uint8_t *)&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto)); + if (file.available()) { + file.read((uint8_t *)_prefs.dm_notif, sizeof(_prefs.dm_notif)); + } } } } @@ -334,6 +337,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)_prefs.bot_reply_ch, sizeof(_prefs.bot_reply_ch)); file.write((uint8_t *)&_prefs.clock_hide_seconds, sizeof(_prefs.clock_hide_seconds)); file.write((uint8_t *)&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto)); + file.write((uint8_t *)_prefs.dm_notif, sizeof(_prefs.dm_notif)); file.close(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a670be14..5fc8abc9 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -884,6 +884,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.bot_reply_dm[0] = '\0'; _prefs.bot_reply_ch[0] = '\0'; _prefs.dm_show_all = 1; // show all contacts by default + memset(_prefs.dm_notif, 0, sizeof(_prefs.dm_notif)); _prefs.auto_off_secs = 15; // 15 seconds auto-off by default _prefs.tz_offset_hours = 0; // UTC by default _prefs.low_batt_mv = 3400; // auto-shutdown at 3.4V by default diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 4a8d0bcb..2e67665f 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -57,4 +57,7 @@ struct NodePrefs { // persisted to file char bot_reply_ch[140]; // auto-reply text for channel uint8_t clock_hide_seconds; // 0=show HH:MM:SS/refresh 1s (default), 1=hide/refresh 60s uint8_t buzzer_auto; // 0=manual (default), 1=auto-mute when BT connected + struct DmNotifEntry { uint8_t prefix[4]; uint8_t state; }; // state: 0=default,1=muted,2=force-on + static const int DM_NOTIF_TABLE_MAX = 16; + DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index b2eb75a5..6defb362 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1065,6 +1065,38 @@ class QuickMsgScreen : public UIScreen { } } + uint8_t dmNotifState(const uint8_t* pub_key) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) + if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) + return p->dm_notif[i].state; + return 0; + } + + void setDmNotifState(const uint8_t* pub_key, uint8_t state) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) { + if (state == 0) { memset(&p->dm_notif[i], 0, sizeof(p->dm_notif[i])); } + else p->dm_notif[i].state = state; + return; + } + } + if (state == 0) return; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (p->dm_notif[i].state == 0) { + memcpy(p->dm_notif[i].prefix, pub_key, 4); + p->dm_notif[i].state = state; + return; + } + } + // table full — overwrite slot 0 + memcpy(p->dm_notif[0].prefix, pub_key, 4); + p->dm_notif[0].state = state; + } + public: QuickMsgScreen(UITask* task) : _task(task), _phase(MODE_SELECT), _mode_sel(0), @@ -1236,6 +1268,39 @@ public: display.setColor(DisplayDriver::LIGHT); renderScrollHints(display, _contact_scroll, _num_contacts); + // Context menu overlay (long-press ENTER), DM mode only + if (_ctx_open && _num_contacts > 0 && !_room_mode) { + static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; + ContactInfo ci; + the_mesh.getContactByIdx(_sorted[_contact_sel], ci); + uint8_t nstate = dmNotifState(ci.id.pub_key); + char notif_item[22]; + snprintf(notif_item, sizeof(notif_item), "Notif: %s", NOTIF_LABELS[nstate]); + const char* items[] = { "Mark as read", notif_item }; + const int CTX_COUNT = 2; + + display.setColor(DisplayDriver::DARK); + display.fillRect(15, 14, 98, 34); + display.setColor(DisplayDriver::LIGHT); + display.drawRect(15, 14, 98, 34); + display.setCursor(19, 15); + display.print("Contact options"); + display.fillRect(15, 24, 98, 1); + for (int i = 0; i < CTX_COUNT; i++) { + int py = 27 + i * 10; + if (i == _ctx_sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(16, py - 1, 96, 10); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(19, py); + display.print(items[i]); + } + display.setColor(DisplayDriver::LIGHT); + } + } else if (_phase == CHANNEL_PICK) { display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL"); display.fillRect(0, 10, display.width(), 1); @@ -1651,6 +1716,29 @@ public: } } else if (_phase == CONTACT_PICK) { + // Context menu consumes all input while open + if (_ctx_open) { + if (c == KEY_CANCEL) { + if (_ctx_dirty) { the_mesh.savePrefs(); _ctx_dirty = false; } + _ctx_open = false; + return true; + } + if (c == KEY_UP && _ctx_sel > 0) { _ctx_sel--; return true; } + if (c == KEY_DOWN && _ctx_sel < 1) { _ctx_sel++; return true; } + if (c == KEY_ENTER && _num_contacts > 0) { + ContactInfo ci; + the_mesh.getContactByIdx(_sorted[_contact_sel], ci); + if (_ctx_sel == 0) { + _task->clearDMUnread(ci.id.pub_key); + } else { + uint8_t nstate = dmNotifState(ci.id.pub_key); + setDmNotifState(ci.id.pub_key, (nstate + 1) % 3); + _ctx_dirty = true; + } + return true; + } + return true; + } if (c == KEY_CANCEL) { _room_mode = false; _phase = MODE_SELECT; return true; } if (c == KEY_UP && _contact_sel > 0) { _contact_sel--; @@ -1671,6 +1759,12 @@ public: } return true; } + if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) { + _ctx_sel = 0; + _ctx_dirty = false; + _ctx_open = true; + return true; + } } else if (_phase == CHANNEL_PICK) { // Context menu consumes all input while open @@ -2580,10 +2674,30 @@ void UITask::showAlert(const char* text, int duration_millis) { void UITask::notify(UIEventType t) { #if defined(PIN_BUZZER) switch(t){ - case UIEventType::contactMessage: - // gemini's pick - buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + case UIEventType::contactMessage: { + bool play = false; + bool force = false; + if (_last_notif_dm_valid && _node_prefs) { + uint8_t state = 0; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (_node_prefs->dm_notif[i].state && + memcmp(_node_prefs->dm_notif[i].prefix, _last_notif_dm_prefix, 4) == 0) { + state = _node_prefs->dm_notif[i].state; break; + } + } + if (state == 2) { play = true; force = true; } // force-on + else if (state == 1) { /* muted */ } + else { play = !buzzer.isQuiet(); } // default: follow global + } else { + play = !buzzer.isQuiet(); + } + _last_notif_dm_valid = false; + if (play) { + if (force) buzzer.playForced("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + else buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + } break; + } case UIEventType::channelMessage: { bool play = false; bool force = false; @@ -2638,6 +2752,8 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i _msgcount = msgcount; if (contact_type == ADV_TYPE_ROOM && _room_unread < _msgcount) _room_unread++; if (contact_type == ADV_TYPE_CHAT && pub_key != nullptr) { + memcpy(_last_notif_dm_prefix, pub_key, 4); + _last_notif_dm_valid = true; int slot = -1, empty_slot = -1; for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) { if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) { slot = i; break; } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index f538d742..af95bce1 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -38,6 +38,8 @@ class UITask : public AbstractUITask { int _msgcount; int _room_unread; int _last_notif_ch_idx; + uint8_t _last_notif_dm_prefix[4]; + bool _last_notif_dm_valid; struct DMUnreadEntry { uint8_t prefix[4]; uint8_t count; }; static const int DM_UNREAD_TABLE_SIZE = 16; DMUnreadEntry _dm_unread_table[DM_UNREAD_TABLE_SIZE]; @@ -81,6 +83,8 @@ public: _batt_mv = 0; _msgcount = _room_unread = 0; _last_notif_ch_idx = -1; + _last_notif_dm_valid = false; + memset(_last_notif_dm_prefix, 0, sizeof(_last_notif_dm_prefix)); memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); curr = NULL; }