diff --git a/docs/solo_features/clock_screen/clock_screen.md b/docs/solo_features/clock_screen/clock_screen.md index f9d62b03..f4a46ad1 100644 --- a/docs/solo_features/clock_screen/clock_screen.md +++ b/docs/solo_features/clock_screen/clock_screen.md @@ -44,7 +44,7 @@ Up to three data fields are shown below the date separator. Each field displays | Luminosity | Lux | lux from onboard sensor | | CO₂ | CO2 | ppm from onboard sensor | | Contacts | Nodes | Total contacts in the mesh | -| Messages | Msgs | Total unread message count | +| Messages | Msgs | Total unread message count. A trailing **+** (e.g. `48+`) means at least one channel or DM has filled its on-device history ring while unread — the real total is higher than shown, but that's everything still recoverable; the rest was evicted before ever being seen. | | Satellites | Sats | GPS satellite count (or `--` without GPS) | | Altitude (GPS) | AltG | metres/feet (per Settings › System › Units) from the GPS fix (or `no fix`) | diff --git a/docs/solo_features/message_screen/message_screen.md b/docs/solo_features/message_screen/message_screen.md index 52b3c6a5..a98cb350 100644 --- a/docs/solo_features/message_screen/message_screen.md +++ b/docs/solo_features/message_screen/message_screen.md @@ -10,6 +10,8 @@ The Messages screen is split into three modes — **DMs**, **Channels**, and **Rooms** — selectable with UP/DOWN on the mode-select screen. Each mode shows the corresponding list of conversations with unread counters. +DM and channel history are each kept in a fixed-size on-device ring (32 DM / 48 channel entries). A busy conversation that outpaces reading can fill its ring — new messages keep arriving, evicting the oldest ones, including unread ones that were never opened. When that's happened, the unread badge for that conversation (and the DM/Channels row on the mode-select screen, and the clock/lock screen's Msgs field) gets a trailing **+** — e.g. `48+` — meaning the count is honest for what's still on the device but understates how many actually came in; the rest are gone for good. Rooms aren't ring-limited the same way, so they never show a **+**. + --- ### Sending messages diff --git a/examples/companion_radio/ui-new/MessageHistory.h b/examples/companion_radio/ui-new/MessageHistory.h index 69b52988..bf5e531f 100644 --- a/examples/companion_radio/ui-new/MessageHistory.h +++ b/examples/companion_radio/ui-new/MessageHistory.h @@ -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; diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 56b073e8..82bd29e7 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -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); } }); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 12fd862e..105f6a31 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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: { diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 48573d32..7b1c5ca0 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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 diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 969d94d7..dc055841 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -187,8 +187,16 @@ public: // Format a small unread count into buf: "1".."99", then "99+". count >= 1. // No stdio — DisplayDriver.h only pulls stdint/string. - static void fmtBadgeCount(char* buf, int count) { + // overflow: the count itself is honest (how many still-held entries are + // unread) but the underlying ring is capped, and at least one unread entry + // has already been evicted off the end before ever being seen -- there were + // genuinely more than this. Appends "+" (still within the 4-char buffer) so + // the badge doesn't silently understate a permanently-lost backlog as an + // exact count. + static void fmtBadgeCount(char* buf, int count, bool overflow = false) { if (count > 99) { buf[0]='9'; buf[1]='9'; buf[2]='+'; buf[3]=0; } + else if (overflow && count >= 10) { buf[0]=(char)('0'+count/10); buf[1]=(char)('0'+count%10); buf[2]='+'; buf[3]=0; } + else if (overflow) { buf[0]=(char)('0'+count); buf[1]='+'; buf[2]=0; } else if (count >= 10) { buf[0]=(char)('0'+count/10); buf[1]=(char)('0'+count%10); buf[2]=0; } else { buf[0]=(char)('0'+count); buf[1]=0; } } @@ -203,8 +211,8 @@ public: virtual int textWidthTrailingGap() const { return 0; } // Pixel width the pill from drawUnreadBadge(count) occupies — for reserving // the name column before it. Mirrors the pill's horizontal padding. - int unreadBadgeWidth(int count) { - char buf[5]; fmtBadgeCount(buf, count); + int unreadBadgeWidth(int count, bool overflow = false) { + char buf[5]; fmtBadgeCount(buf, count, overflow); int pad = sepH() + 1; return getTextWidth(buf) - textWidthTrailingGap() + pad * 2; } @@ -213,9 +221,10 @@ public: // knocked out. On a selected/inverted row pass sel=true so the pill inverts // too (paper capsule + ink digits) and stays visible. The four corners are // knocked back to the surrounding colour for a rounded-capsule look. + // overflow: see fmtBadgeCount -- appends "+" for a ring-capped, understated count. // Restores ink to LIGHT. Returns the pill width. - int drawUnreadBadge(int right_x, int y, int count, bool sel) { - char buf[5]; fmtBadgeCount(buf, count); + int drawUnreadBadge(int right_x, int y, int count, bool sel, bool overflow = false) { + char buf[5]; fmtBadgeCount(buf, count, overflow); int pad = sepH() + 1; int pw = getTextWidth(buf) - textWidthTrailingGap() + pad * 2; int ph = getLineHeight();