mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-10-03 08:16:38 +00:00
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:
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user