feat(ui): flag unread counts understated by a full history ring

DM and channel history each live in a fixed-size ring (32 DM / 48
channel entries). A conversation that outpaces reading can fill its
ring, evicting the oldest entries — including unread ones that were
never opened — so the unread badge silently caps at ring capacity
instead of reflecting how many messages actually arrived.

MessageHistory now tracks a sticky per-channel overflow flag, set at
the exact point an unread channel entry gets evicted (mirroring the
existing unread-decrement logic) and cleared once the count reads back
down to 0. DMUnreadEntry gains the same bit for DM contacts, detected
by comparing the raw per-contact counter against actual ring occupancy
on each new message (the DM ring, unlike the channel ring, doesn't
proactively decrement on eviction).

DisplayDriver::drawUnreadBadge/unreadBadgeWidth/fmtBadgeCount take an
optional overflow flag (default false, existing call sites unchanged)
that appends "+" to the badge. Wired into every place an unread count
is shown: DM/favourites and channel lists, the DM/Channels/Rooms
mode-select screen, favourite tiles, and the aggregate "Msgs" field on
the clock and lock dashboards plus the Messages home-carousel page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-23 13:45:07 +02:00
co-authored by Claude Sonnet 5
parent 26e6bc877a
commit 81cee3846f
7 changed files with 122 additions and 26 deletions
@@ -90,6 +90,7 @@ public:
MessageHistory()
: _hist_head(0), _hist_count(0), _dm_hist_head(0), _dm_hist_count(0) {
memset(_ch_unread, 0, sizeof(_ch_unread));
memset(_ch_unread_overflow, 0, sizeof(_ch_unread_overflow));
}
// ── Channel ring ──────────────────────────────────────────────────────────
@@ -130,6 +131,10 @@ public:
if (evicted < MAX_GROUP_CHANNELS && _ch_unread[evicted] > 0 &&
_ch_unread[evicted] >= histCountForChannel(evicted)) {
_ch_unread[evicted]--;
// The entry just evicted was itself still unread (every held entry for
// this channel was) -- it's gone for good, so the badge can never again
// show the true total. Sticky until chUnread() reads back down to 0.
_ch_unread_overflow[evicted] = true;
}
_hist_head = (_hist_head + 1) % CH_HIST_MAX;
}
@@ -229,10 +234,26 @@ public:
int held = histCountForChannel(ch);
return _ch_unread[ch] < held ? _ch_unread[ch] : (uint8_t)held;
}
// True once an unread entry for this channel has been evicted off the ring
// (see addChannelMsg) -- the count above is honest but understates the real
// total. Clears itself once the user reads back down to 0 (setChUnread(ch,0)
// below): at that point there's no backlog left to warn about.
bool chUnreadOverflow(int ch) const {
return ch >= 0 && ch < MAX_GROUP_CHANNELS && _ch_unread_overflow[ch];
}
bool anyChannelUnreadOverflow() const {
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) if (_ch_unread_overflow[i]) return true;
return false;
}
void setChUnread(int ch, uint8_t v) {
if (ch >= 0 && ch < MAX_GROUP_CHANNELS) _ch_unread[ch] = v;
if (ch < 0 || ch >= MAX_GROUP_CHANNELS) return;
_ch_unread[ch] = v;
if (v == 0) _ch_unread_overflow[ch] = false;
}
void clearAllChannelUnread() {
memset(_ch_unread, 0, sizeof(_ch_unread));
memset(_ch_unread_overflow, 0, sizeof(_ch_unread_overflow));
}
void clearAllChannelUnread() { memset(_ch_unread, 0, sizeof(_ch_unread)); }
int getTotalChannelUnread() const {
// Same clamp as chUnread(), but counting ring occupancy for every channel
// in one pass instead of re-walking the ring once per channel.
@@ -415,6 +436,7 @@ private:
ChHistEntry _hist[CH_HIST_MAX];
int _hist_head, _hist_count;
uint8_t _ch_unread[MAX_GROUP_CHANNELS];
bool _ch_unread_overflow[MAX_GROUP_CHANNELS];
DmHistEntry _dm_hist[DM_HIST_MAX];
int _dm_hist_head, _dm_hist_count;
@@ -1108,6 +1108,7 @@ public:
}
int getTotalChannelUnread() const { return _history.getTotalChannelUnread(); }
bool anyChannelUnreadOverflow() const { return _history.anyChannelUnreadOverflow(); }
// How many DM ring entries this contact/room currently holds -- lets UITask
// clamp its separate _dm_unread_table counters to what the shared 32-slot DM
@@ -1334,6 +1335,7 @@ public:
}
uint8_t chUnread(uint8_t channel_idx) const { return _history.chUnread(channel_idx); }
bool chUnreadOverflow(uint8_t channel_idx) const { return _history.chUnreadOverflow(channel_idx); }
int render(DisplayDriver& display) override {
int mq_delay = 0; // >0 while a selected row's text is marquee-scrolling
@@ -1372,6 +1374,8 @@ public:
_task->getChannelUnreadCount(),
_task->getRoomUnreadCount()
};
// Rooms have no local ring to overflow the same way (see UITask::getAnyUnreadOverflow).
bool overflow[3] = { _task->getAnyDMUnreadOverflow(), anyChannelUnreadOverflow(), false };
for (int i = 0; i < 3; i++) {
int y = start_y + i * item_h;
bool sel = (i == _mode_sel);
@@ -1379,7 +1383,7 @@ public:
display.setCursor(2, y);
display.print(opts[i]);
if (badges[i] > 0)
display.drawUnreadBadge(display.width() - 1, y, badges[i], sel);
display.drawUnreadBadge(display.width() - 1, y, badges[i], sel, overflow[i]);
}
display.setColor(DisplayDriver::LIGHT);
// Fold the popup's own suggested redraw delay into mq_delay -- otherwise
@@ -1406,7 +1410,8 @@ public:
char filtered[sizeof(c.name)];
display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered));
uint8_t dm_unread = _task->getDMUnread(c.id.pub_key);
int bw = dm_unread > 0 ? display.unreadBadgeWidth(dm_unread) + 2 : 0;
bool dm_overflow = dm_unread > 0 && _task->getDMUnreadOverflow(c.id.pub_key);
int bw = dm_unread > 0 ? display.unreadBadgeWidth(dm_unread, dm_overflow) + 2 : 0;
int sw = (c.flags & 0x01) ? favStarWidth(display) : 0;
// See the channel/DM history bodies' identical comment: suppress this
// row's own marquee while a context menu covers it, so the two don't
@@ -1416,7 +1421,7 @@ public:
if (name_marquee && r > 0) mq_delay = r;
if (sw) drawFavStar(display, display.width() - reserve - bw - sw + 1, y);
if (dm_unread > 0)
display.drawUnreadBadge(display.width() - reserve, y, dm_unread, sel);
display.drawUnreadBadge(display.width() - reserve, y, dm_unread, sel, dm_overflow);
}
});
@@ -1450,7 +1455,8 @@ public:
ChannelDetails ch;
if (the_mesh.getChannel(_channel_indices[list_idx], ch)) {
uint8_t unread = _history.chUnread(_channel_indices[list_idx]);
int bw = unread > 0 ? display.unreadBadgeWidth(unread) + 2 : 0;
bool ch_overflow = unread > 0 && _history.chUnreadOverflow(_channel_indices[list_idx]);
int bw = unread > 0 ? display.unreadBadgeWidth(unread, ch_overflow) + 2 : 0;
int sw = chIsFav(_channel_indices[list_idx]) ? favStarWidth(display) : 0;
// See the channel/DM history bodies' identical comment: suppress this
// row's own marquee while a context menu covers it, so the two don't
@@ -1460,7 +1466,7 @@ public:
if (name_marquee && r > 0) mq_delay = r;
if (sw) drawFavStar(display, display.width() - reserve - bw - sw + 1, y);
if (unread > 0)
display.drawUnreadBadge(display.width() - reserve, y, unread, sel);
display.drawUnreadBadge(display.width() - reserve, y, unread, sel, ch_overflow);
}
});
+48 -11
View File
@@ -299,7 +299,7 @@ static int drawClockTime(DisplayDriver& d, int top_y, const struct tm* ti,
// ── HomeScreen ────────────────────────────────────────────────────────────────
// Forward declaration to be able to call formatDashVal from HomeScreen::render()
static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv,
uint16_t low_batt_mv, int unread, bool imperial, CayenneLPP* lpp = nullptr);
uint16_t low_batt_mv, int unread, bool unread_overflow, bool imperial, CayenneLPP* lpp = nullptr);
// Altitude (baro or GPS) respects Settings > System > Units, same as every
// other distance in the UI -- unlike geo::fmtDist, never switches to km/mi
@@ -925,7 +925,10 @@ public:
} else if (field == DASH_MSGS) {
strcpy(label, "Msgs");
int unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount();
snprintf(val, sizeof(val), "%d", unread);
// "+" when the true total is understated -- at least one unread
// message has already been evicted off a ring before ever being
// seen (see UITask::getAnyUnreadOverflow()).
snprintf(val, sizeof(val), _task->getAnyUnreadOverflow() ? "%d+" : "%d", unread);
} else {
uint8_t lpp_type = 0;
switch (field) {
@@ -1002,11 +1005,13 @@ public:
if (isLPP(f0) || isLPP(f1)) {
sensors_lpp.reset(); sensors.querySensors(0xFF, sensors_lpp); lpp_ptr = &sensors_lpp;
}
int unread = (f0 == DASH_MSGS || f1 == DASH_MSGS)
bool show_msgs = f0 == DASH_MSGS || f1 == DASH_MSGS;
int unread = show_msgs
? _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount() : 0;
bool unread_overflow = show_msgs && _task->getAnyUnreadOverflow();
uint16_t batt_mv = _task->getBattMilliVolts();
formatDashVal(f0, v0, sizeof(v0), batt_mv, _node_prefs->low_batt_mv, unread, _node_prefs->units_imperial, lpp_ptr);
formatDashVal(f1, v1, sizeof(v1), batt_mv, _node_prefs->low_batt_mv, unread, _node_prefs->units_imperial, lpp_ptr);
formatDashVal(f0, v0, sizeof(v0), batt_mv, _node_prefs->low_batt_mv, unread, unread_overflow, _node_prefs->units_imperial, lpp_ptr);
formatDashVal(f1, v1, sizeof(v1), batt_mv, _node_prefs->low_batt_mv, unread, unread_overflow, _node_prefs->units_imperial, lpp_ptr);
if (v0[0] || v1[0]) {
int sv_y = date_y + step;
display.setColor(DisplayDriver::LIGHT);
@@ -1268,7 +1273,7 @@ public:
int total_unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount();
if (total_unread > 0) {
char badge[20];
snprintf(badge, sizeof(badge), "%d unread", total_unread);
snprintf(badge, sizeof(badge), _task->getAnyUnreadOverflow() ? "%d+ unread" : "%d unread", total_unread);
display.drawTextCentered(display.width() / 2, content_y + step, badge);
}
display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open");
@@ -1303,6 +1308,7 @@ public:
const uint8_t* prefix = favSlotPrefix(i);
char name[26];
uint8_t unread = 0;
bool overflow = false;
bool resolved = false;
if (prefix && _task->favouriteSlotKind(i) == NodePrefs::FAV_KIND_CHANNEL) {
@@ -1314,6 +1320,7 @@ public:
name[0] = '#';
display.translateUTF8ToBlocks(name + 1, ch.name, sizeof(name) - 1);
unread = _task->getChannelUnread(ch_idx);
overflow = unread > 0 && _task->getChannelUnreadOverflow(ch_idx);
resolved = true;
}
} else if (prefix) {
@@ -1323,6 +1330,7 @@ public:
if (memcmp(c.id.pub_key, prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
display.translateUTF8ToBlocks(name, c.name, sizeof(name));
unread = _task->getDMUnread(c.id.pub_key);
overflow = unread > 0 && _task->getDMUnreadOverflow(c.id.pub_key);
resolved = true;
break;
}
@@ -1341,14 +1349,14 @@ public:
if (resolved) {
// Reserve space for the unread badge so the name's ellipsis lands
// before it instead of underneath. Badge and name share one baseline.
int bw = unread > 0 ? display.unreadBadgeWidth(unread) + 3 : 0; // badge + 3 px gap
int bw = unread > 0 ? display.unreadBadgeWidth(unread, overflow) + 3 : 0; // badge + 3 px gap
int name_y = cy + (cell_h - line_h) / 2;
int name_max_w = cell_w - 4 - bw;
if (name_max_w < 6) name_max_w = 6;
int r = display.drawTextEllipsized(cx + 2, name_y, name_max_w, name, sel);
if (sel && r > 0) mq_delay = r;
if (unread > 0)
display.drawUnreadBadge(cx + cell_w - 2, name_y, unread, sel);
display.drawUnreadBadge(cx + cell_w - 2, name_y, unread, sel, overflow);
} else {
int plus_y = cy + (cell_h - line_h) / 2;
display.drawTextCentered(cx + cell_w / 2, plus_y, "+");
@@ -1960,6 +1968,18 @@ uint8_t UITask::getChannelUnread(uint8_t channel_idx) const {
return ((MessagesScreen*)messages_screen)->chUnread(channel_idx);
}
bool UITask::getChannelUnreadOverflow(uint8_t channel_idx) const {
return ((MessagesScreen*)messages_screen)->chUnreadOverflow(channel_idx);
}
bool UITask::getAnyChannelUnreadOverflow() const {
return ((MessagesScreen*)messages_screen)->anyChannelUnreadOverflow();
}
bool UITask::getAnyUnreadOverflow() const {
return getAnyChannelUnreadOverflow() || getAnyDMUnreadOverflow();
}
void UITask::onMsgAck(uint32_t ack_crc) {
((MessagesScreen*)messages_screen)->markDmDelivered(ack_crc);
}
@@ -2015,8 +2035,10 @@ uint8_t UITask::getDMUnread(const uint8_t* pub_key) const {
void UITask::reconcileDMUnread() {
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
if (_dm_unread_table[i].count == 0) continue;
if (((MessagesScreen*)messages_screen)->dmHistCountForContact(_dm_unread_table[i].prefix) == 0)
if (((MessagesScreen*)messages_screen)->dmHistCountForContact(_dm_unread_table[i].prefix) == 0) {
_dm_unread_table[i].count = 0; // ring no longer holds anything for this sender -- free the slot
_dm_unread_table[i].overflow = false;
}
}
}
@@ -2091,6 +2113,21 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
} else if (empty_slot >= 0) {
memcpy(_dm_unread_table[empty_slot].prefix, pub_key, 4);
_dm_unread_table[empty_slot].count = 1;
_dm_unread_table[empty_slot].overflow = false; // fresh contact -- don't inherit a stale flag from whoever held this slot before
slot = empty_slot;
}
// The DM ring (unlike the channel ring) doesn't proactively decrement this
// counter as it evicts old entries, so it can only be caught here: if the
// raw count now claims more unread than the ring actually still holds for
// this contact, an unread entry for them was just evicted (this insert, by
// definition, can only have evicted at most one entry). Clamp back to the
// honest value and flag it -- mirrors MessageHistory's channel-side fix.
if (slot >= 0) {
int held = ((MessagesScreen*)messages_screen)->dmHistCountForContact(pub_key);
if (_dm_unread_table[slot].count > held) {
_dm_unread_table[slot].count = (uint8_t)held;
_dm_unread_table[slot].overflow = true;
}
}
}
@@ -2260,7 +2297,7 @@ bool UITask::isButtonPressed() const {
}
static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv,
uint16_t low_batt_mv, int unread, bool imperial, CayenneLPP* lpp) {
uint16_t low_batt_mv, int unread, bool unread_overflow, bool imperial, CayenneLPP* lpp) {
val[0] = '\0';
switch (field) {
case DASH_NONE: return;
@@ -2276,7 +2313,7 @@ static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_m
snprintf(val, val_len, "%d nodes", the_mesh.getNumContacts());
return;
case DASH_MSGS:
snprintf(val, val_len, "%d msgs", unread);
snprintf(val, val_len, unread_overflow ? "%d+ msgs" : "%d msgs", unread);
return;
#if ENV_INCLUDE_GPS == 1
case DASH_GPS: {
+22 -2
View File
@@ -71,7 +71,10 @@ class UITask : public AbstractUITask {
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; };
// overflow: an unread entry for this contact was evicted off the DM ring
// before ever being seen (see newMsg()) -- count is honest but understates
// the real total. Cleared with the slot (memset) or on a read back to 0.
struct DMUnreadEntry { uint8_t prefix[4]; uint8_t count; bool overflow; };
static const int DM_UNREAD_TABLE_SIZE = 16;
DMUnreadEntry _dm_unread_table[DM_UNREAD_TABLE_SIZE];
unsigned long ui_started_at, next_batt_chck;
@@ -446,16 +449,33 @@ public:
int getMsgCount() const { return _msgcount; }
int getChannelUnreadCount() const;
uint8_t getChannelUnread(uint8_t channel_idx) const;
bool getChannelUnreadOverflow(uint8_t channel_idx) const;
bool getAnyChannelUnreadOverflow() const;
int getRoomUnreadCount() const { return _room_unread; }
void clearRoomUnread() { _room_unread = 0; }
// Clamped to the DM ring's actual occupancy for this contact -- defined in
// UITask.cpp (needs MessagesScreen to be a complete type). Same self-healing
// shape as MessageHistory::chUnread() for channels.
uint8_t getDMUnread(const uint8_t* pub_key) const;
bool getDMUnreadOverflow(const uint8_t* pub_key) const {
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)
return _dm_unread_table[i].overflow;
return false;
}
bool getAnyDMUnreadOverflow() const {
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++)
if (_dm_unread_table[i].count > 0 && _dm_unread_table[i].overflow) return true;
return false;
}
// Aggregate for the clock/lock dashboard's single "Msgs" field -- true if
// ANY channel or DM contact has permanently lost an unread entry to its
// ring cap (rooms have no local ring to overflow the same way).
bool getAnyUnreadOverflow() const;
void clearDMUnread(const uint8_t* pub_key) {
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)
{ _dm_unread_table[i].count = 0; return; }
{ _dm_unread_table[i].count = 0; _dm_unread_table[i].overflow = false; return; }
}
void clearAllDMUnread() { memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); }
// Frees any table slot whose ring occupancy has dropped to zero (evicted or