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 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-13 10:48:42 +02:00
parent b50f21060c
commit eb0f436924
5 changed files with 131 additions and 3 deletions

View File

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

View File

@@ -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

View File

@@ -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
};

View File

@@ -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; }

View File

@@ -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;
}