diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index b3a5c13d..52d731a3 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -289,6 +289,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no if (file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)) != sizeof(_prefs.default_scope_key)) { memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key)); } + if (file.read((uint8_t *)_prefs.channel_notif, sizeof(_prefs.channel_notif)) != sizeof(_prefs.channel_notif)) { + memset(_prefs.channel_notif, 0, sizeof(_prefs.channel_notif)); // default: NOTIF_ALL + } // Clamp to valid ranges if (_prefs.dark_mode > 1) _prefs.dark_mode = 0; @@ -298,6 +301,10 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no if (_prefs.ui_font_style > 2) _prefs.ui_font_style = 0; if (_prefs.tx_fail_reset_threshold > 10) _prefs.tx_fail_reset_threshold = 3; if (_prefs.rx_fail_reboot_threshold > 10) _prefs.rx_fail_reboot_threshold = 3; + // Clamp channel notification preferences to valid range + for (int i = 0; i < (int)sizeof(_prefs.channel_notif); i++) { + if (_prefs.channel_notif[i] > 2) _prefs.channel_notif[i] = 0; + } // auto_lock_minutes: only accept known options (0, 2, 5, 10, 15, 30) { uint8_t alm = _prefs.auto_lock_minutes; @@ -357,6 +364,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.ui_font_style, sizeof(_prefs.ui_font_style)); // 105 file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 106 file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 137 + file.write((uint8_t *)_prefs.channel_notif, sizeof(_prefs.channel_notif)); // 153 file.close(); } diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 1cc3687b..713fd1c8 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -8,6 +8,11 @@ #define ADVERT_LOC_NONE 0 #define ADVERT_LOC_SHARE 1 +// Per-channel notification preferences (stored in channel_notif[]) +#define NOTIF_ALL 0 // Notify on all messages (default) +#define NOTIF_MENTIONS 1 // Notify only when @nodename appears in message +#define NOTIF_NONE 2 // No notifications (muted) + struct NodePrefs { // persisted to file float airtime_factor; char node_name[32]; @@ -53,6 +58,12 @@ struct NodePrefs { // persisted to file char default_scope_name[31]; // e.g. "au-nsw", empty = unscoped uint8_t default_scope_key[16]; // TransportKey derived from "#" + name + // --- Per-channel notification preferences --- + // Index 0..MAX_GROUP_CHANNELS-1 for group channels, index MAX_GROUP_CHANNELS for DMs. + // Values: NOTIF_ALL (0), NOTIF_MENTIONS (1), NOTIF_NONE (2). + // Defaults to NOTIF_ALL for all channels. + uint8_t channel_notif[21]; // 20 group channels + 1 DM slot + // --- Font helpers (inline, no overhead) --- // Returns the DisplayDriver text-size index for "small/body" text. // T-Deck Pro: 0 = built-in 6×8 (or 7pt with custom fonts), 1 = 9pt. diff --git a/examples/companion_radio/ui-new/ChannelScreen.h b/examples/companion_radio/ui-new/ChannelScreen.h index 8828e8b1..800fba6a 100644 --- a/examples/companion_radio/ui-new/ChannelScreen.h +++ b/examples/companion_radio/ui-new/ChannelScreen.h @@ -148,8 +148,10 @@ public: // Add a new message to the history // peer_name: for DMs, the contact this message belongs to (sender for received, recipient for sent) + // suppressUnread: if true, do not increment the unread counter for this message void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text, - const uint8_t* path_bytes = nullptr, int8_t snr = 0, const char* peer_name = nullptr) { + const uint8_t* path_bytes = nullptr, int8_t snr = 0, const char* peer_name = nullptr, + bool suppressUnread = false) { // Move to next slot in circular buffer _newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE; @@ -191,8 +193,9 @@ public: _replySelectPos = -1; // Track unread count for this channel (only for received messages, not sent) - // path_len == 0 means locally sent - if (path_len != 0) { + // path_len == 0 means locally sent. + // suppressUnread: per-channel notification preference says not to count this message. + if (path_len != 0 && !suppressUnread) { int unreadSlot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : channel_idx; if (unreadSlot >= 0 && unreadSlot <= MAX_GROUP_CHANNELS) { _unread[unreadSlot]++; diff --git a/examples/companion_radio/ui-new/Settingsscreen.h b/examples/companion_radio/ui-new/Settingsscreen.h index 227332f6..6a85e4ea 100644 --- a/examples/companion_radio/ui-new/Settingsscreen.h +++ b/examples/companion_radio/ui-new/Settingsscreen.h @@ -1930,15 +1930,27 @@ public: snprintf(tmp, sizeof(tmp), " %s [*]", ch.name); } if (selected) { - // Show edit/delete hints on right + // Build hint with notification state + actions + uint8_t nPref = _prefs->channel_notif[chIdx]; + const char* nTag = (nPref == NOTIF_NONE) ? "Off" : + (nPref == NOTIF_MENTIONS) ? "@" : "All"; + char hintBuf[40]; #if defined(LilyGo_T5S3_EPaper_Pro) - const char* hint = chIdx > 0 ? "Ent:Region Hold:Del" : "Ent:Region"; + if (chIdx > 0) { + snprintf(hintBuf, sizeof(hintBuf), "Notif:%s Ent:Region Hold:Del", nTag); + } else { + snprintf(hintBuf, sizeof(hintBuf), "Notif:%s Ent:Region", nTag); + } #else - const char* hint = chIdx > 0 ? "Ent:Region X:Del" : "Ent:Region"; + if (chIdx > 0) { + snprintf(hintBuf, sizeof(hintBuf), "N:%s Ent:Region X:Del", nTag); + } else { + snprintf(hintBuf, sizeof(hintBuf), "N:%s Ent:Region", nTag); + } #endif - int hintW = display.getTextWidth(hint); + int hintW = display.getTextWidth(hintBuf); display.setCursor(display.width() - hintW - 2, y); - display.print(hint); + display.print(hintBuf); display.setCursor(0, y); } } @@ -3286,6 +3298,20 @@ public: } } + // N: cycle notification preference (All -> Mentions -> None -> All) + if (c == 'n' || c == 'N') { + if (_rows[_cursor].type == ROW_CHANNEL) { + uint8_t chIdx = _rows[_cursor].param; + uint8_t cur = _prefs->channel_notif[chIdx]; + _prefs->channel_notif[chIdx] = (cur + 1) % 3; + the_mesh.savePrefs(); + const char* labels[] = {"All", "Mentions", "Off"}; + Serial.printf("Settings: Channel %d notif -> %s\n", + chIdx, labels[_prefs->channel_notif[chIdx]]); + return true; + } + } + // Q: back -- if in sub-screen, return to top level; else exit settings if (c == 'q' || c == 'Q') { if (_subScreen != SUB_NONE) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 62a2361b..3a464422 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -66,6 +66,12 @@ #include "ModemManager.h" #endif +// Per-channel notification suppression flag. +// Set by newMsg() based on channel_notif preference, checked by notify() +// to suppress buzzer/vibration. Safe because both are called sequentially +// from the same mesh callback on the same thread. +static bool s_lastMsgSuppressed = false; + class SplashScreen : public UIScreen { UITask* _task; unsigned long dismiss_after; @@ -1454,6 +1460,15 @@ void UITask::dismissBootHint() { } void UITask::notify(UIEventType t) { + // Per-channel notification gating: if the last message was from a + // muted channel (or mentions-only without an @mention), suppress + // buzzer and vibration. Ack events are never suppressed. + if (s_lastMsgSuppressed && t != UIEventType::ack) { + s_lastMsgSuppressed = false; // Consume the flag + return; + } + s_lastMsgSuppressed = false; + #if defined(PIN_BUZZER) switch(t){ case UIEventType::contactMessage: @@ -1520,6 +1535,44 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i break; } } + + // --- Per-channel notification preference check --- + // Determines whether to suppress toast, buzzer, keyboard flash, vibration, + // display wake, and unread counter for this message. Messages are ALWAYS + // stored in history regardless -- only alerts and unread badges are gated. + bool suppressNotif = false; + { + int notifSlot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : (int)channel_idx; + if (notifSlot >= 0 && notifSlot < (int)sizeof(_node_prefs->channel_notif)) { + uint8_t pref = _node_prefs->channel_notif[notifSlot]; + if (pref == NOTIF_NONE) { + suppressNotif = true; + } else if (pref == NOTIF_MENTIONS) { + // Check for @nodename or @[nodename] in message text (case-insensitive). + // MeshCore companion app sends mentions as @[node name] with brackets. + suppressNotif = true; // Suppress unless mention found + if (_node_prefs->node_name[0] != '\0') { + char tagPlain[36]; + char tagBracket[38]; + snprintf(tagPlain, sizeof(tagPlain), "@%s", _node_prefs->node_name); + snprintf(tagBracket, sizeof(tagBracket), "@[%s]", _node_prefs->node_name); + int lenPlain = strlen(tagPlain); + int lenBracket = strlen(tagBracket); + const char* p = text; + while (*p) { + if (strncasecmp(p, tagBracket, lenBracket) == 0 || + strncasecmp(p, tagPlain, lenPlain) == 0) { + suppressNotif = false; // Mentioned -- notify + break; + } + p++; + } + } + } + } + } + // Set the flag for notify() which is called immediately after newMsg() + s_lastMsgSuppressed = suppressNotif; // Add to channel history screen with channel index, path data, and SNR // For DMs (channel_idx == 0xFF): @@ -1541,15 +1594,15 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i if (isRoomMsg) { // Room server: text already has "Poster: message" format — store as-is // Tag with room server name for conversation filtering - ((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr, from_name); + ((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr, from_name, suppressNotif); } else { // Regular DM: prefix with sender name char dmFormatted[CHANNEL_MSG_TEXT_LEN]; snprintf(dmFormatted, sizeof(dmFormatted), "%s: %s", from_name, text); - ((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, dmFormatted, path, snr); + ((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, dmFormatted, path, snr, nullptr, suppressNotif); } } else { - ((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr); + ((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr, nullptr, suppressNotif); } // If user is currently viewing this channel on the device, or companion @@ -1572,11 +1625,11 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i } } } - + // Don't interrupt user with popup - just show brief notification // Messages are stored in channel history, accessible via tile/key // Suppress toasts for room server messages (bulk sync would spam toasts) - if (!isOnRepeaterAdmin() && !isRoomMsg) { + if (!isOnRepeaterAdmin() && !isRoomMsg && !suppressNotif) { char alertBuf[40]; snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name); showAlert(alertBuf, 2000); @@ -1586,7 +1639,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i forceRefresh(); } - if (_display != NULL) { + if (_display != NULL && !suppressNotif) { if (!_display->isOn() && !hasConnection()) { _display->turnOn(); } @@ -1602,9 +1655,9 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i } } - // Keyboard flash notification (suppress for room sync) + // Keyboard flash notification (suppress for room sync and muted channels) #ifdef KB_BL_PIN - if (_node_prefs->kb_flash_notify && !isRoomMsg) { + if (_node_prefs->kb_flash_notify && !isRoomMsg && !suppressNotif) { digitalWrite(KB_BL_PIN, HIGH); _kb_flash_off_at = millis() + 200; // 200ms flash }