From f6baa5c38cc19f77d7773b94e4a1d6e2cb8f45e7 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 22:41:01 +0200 Subject: [PATCH] refactor(companion): extract MessageHistory store from QuickMsgScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the two RAM history rings (channel + DM) out of the 1988-line QuickMsgScreen into a self-contained MessageHistory.h component, mirroring the WaypointsView extraction from TrailScreen. The store owns the ring buffers, per-entry delivery state (channel relay echo, DM end-to-end ACK + auto-resend) and the per-channel unread counters; the screen keeps all view state (selection, scroll, fullscreen readers, the unread "viewing session" bookkeeping) and reaches entries through accessors. Behaviour-preserving: - The shared types (AckState, ChHistEntry, DmHistEntry, MSG_TEXT_BUF) are file-scope in MessageHistory.h, so the phase machine still refers to them unqualified — only data + storage logic moved. - addChannelMsg keeps a thin screen forwarder that computes the "viewing" flag (a phase fact the store can't see) and returns the ring pos; afterSend uses armChannelRelay(pos, seq) instead of poking the ring. - The public API called via UITask/MyMesh/the bot (addChannelMsg, addDMMsg, markDmDelivered, markChannelRelayed, tickDmResends, clearAllChannelUnread, getTotalChannelUnread, getRecentDMContacts) stays on QuickMsgScreen as forwarders, so no caller changes. QuickMsgScreen 1988 -> 1738 lines; MessageHistory.h 324. Both solo boards build. Single-TU fragment: included by UITask.cpp before QuickMsgScreen.h. Co-Authored-By: Claude Opus 4.8 --- .../companion_radio/ui-new/MessageHistory.h | 324 ++++++++++++++ .../companion_radio/ui-new/QuickMsgScreen.h | 408 ++++-------------- examples/companion_radio/ui-new/UITask.cpp | 1 + 3 files changed, 404 insertions(+), 329 deletions(-) create mode 100644 examples/companion_radio/ui-new/MessageHistory.h diff --git a/examples/companion_radio/ui-new/MessageHistory.h b/examples/companion_radio/ui-new/MessageHistory.h new file mode 100644 index 00000000..1bdb5cb4 --- /dev/null +++ b/examples/companion_radio/ui-new/MessageHistory.h @@ -0,0 +1,324 @@ +#pragma once +// Message history store for QuickMsgScreen: two RAM ring buffers (channel + DM) +// with their per-entry delivery state (channel "relayed into mesh" echo, DM +// end-to-end ACK + auto-resend) and the per-channel unread counters. Pure +// storage + queries — no UI/phase state lives here. The screen keeps selection, +// scroll, the unread "viewing session" bookkeeping, the room-login table, and +// all rendering, and reaches entries through the accessors below. +// +// Single-TU fragment: included by UITask.cpp before QuickMsgScreen.h. AckState, +// MSG_TEXT_BUF and the two entry structs are file-scope (not nested) so the +// phase machine in QuickMsgScreen keeps referring to them unqualified. + +// Outgoing-message delivery state. DM: a real end-to-end ACK (✓ delivered to +// the recipient). Channel: only a "relayed into mesh" echo from a repeater (no +// recipient ACK exists for floods), so a missing echo is NOT shown as failure. +enum AckState : uint8_t { ACK_NONE = 0, ACK_PENDING, ACK_OK, ACK_FAIL }; + +// History text holds a full received message. Channel messages carry the sender +// embedded as "Name: body" in the payload, so a message can be up to the +// over-the-air maximum (MAX_TEXT_LEN). Size the buffers to that + NUL, otherwise +// long messages (and Polish text, where each accented char is two UTF-8 bytes) +// get their tail clipped. +static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1; + +struct ChHistEntry { + uint8_t ch_idx; + char text[MSG_TEXT_BUF]; + uint32_t timestamp; + uint8_t relay_status; // AckState; only PENDING/OK used (no failure for floods) + uint32_t relay_seq; // MyMesh relay seq to match against onChannelRelayed() +}; + +struct DmHistEntry { + uint8_t prefix[4]; + uint8_t outgoing; + char text[MSG_TEXT_BUF]; + uint32_t timestamp; + uint8_t ack_status; // AckState; meaningful only when outgoing + uint32_t ack_tag; // expected_ack CRC to match against onMsgAck() + uint32_t ack_deadline_ms; // millis() by which a pending ACK must arrive + // Sender-perspective message timestamp: the send timestamp for outgoing + // (reused verbatim on resend so the recipient treats it as a retry), or the + // sender_timestamp for incoming (used to dedup retried copies). 0 = unknown. + uint32_t msg_ts; + uint8_t attempt; // last attempt number sent (outgoing); next resend = attempt+1 + uint8_t resends_left; // remaining auto-resends before the marker shows ✗ +}; + +class MessageHistory { +public: + // Shared ring for all channels combined; each slot carries a full MSG_TEXT_BUF, + // so this ring dominates RAM — kept modest to leave heap headroom (see + // DM_HIST_MAX). The DM ring is likewise bounded. + static const int CH_HIST_MAX = 48; + static const int DM_HIST_MAX = 32; + + MessageHistory() + : _hist_head(0), _hist_count(0), _dm_hist_head(0), _dm_hist_count(0) { + memset(_ch_unread, 0, sizeof(_ch_unread)); + } + + // ── Channel ring ────────────────────────────────────────────────────────── + + // Append a channel message. `viewing` = the user is currently in this + // channel's history (so it isn't counted unread). `timestamp` is the sender's + // own send time (0 = unknown — use receipt time). Returns the ring position + // (an opaque handle for armChannelRelay / chAtPos), or -1 if rejected. + int addChannelMsg(uint8_t ch_idx, const char* text, bool viewing, uint32_t timestamp = 0) { + // Guard against bogus channel indices (e.g. findChannelIdx() returned -1 + // and was cast to uint8_t → 255). Storing such an entry would burn a ring + // slot for a message that no visible channel can ever surface. + if (ch_idx >= MAX_GROUP_CHANNELS) return -1; + int pos; + if (_hist_count < CH_HIST_MAX) { + pos = (_hist_head + _hist_count) % CH_HIST_MAX; + _hist_count++; + } else { + pos = _hist_head; + // Evicting the oldest entry — drop its share of the unread counter so + // the badge can't claim a message the ring no longer holds. + uint8_t evicted = _hist[pos].ch_idx; + if (evicted < MAX_GROUP_CHANNELS && _ch_unread[evicted] > 0) { + _ch_unread[evicted]--; + } + _hist_head = (_hist_head + 1) % CH_HIST_MAX; + } + _hist[pos].ch_idx = ch_idx; + _hist[pos].timestamp = timestamp ? timestamp : rtc_clock.getCurrentTime(); + strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); + _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; + _hist[pos].relay_status = ACK_NONE; + _hist[pos].relay_seq = 0; + + if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; + return pos; + } + + // count history entries for a specific channel + int histCountForChannel(int ch_idx) const { + int n = 0; + for (int i = 0; i < _hist_count; i++) { + if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++; + } + return n; + } + + // get ring-buffer position of j-th history entry for channel (newest first) + int histEntryForChannel(int ch_idx, int j) const { + int n = 0; + for (int i = _hist_count - 1; i >= 0; i--) { + int pos = (_hist_head + i) % CH_HIST_MAX; + if (_hist[pos].ch_idx == (uint8_t)ch_idx) { + if (n == j) return pos; + n++; + } + } + return -1; + } + + // Called when a repeater echo of one of our channel sends is heard. + void markChannelRelayed(uint32_t seq) { + if (seq == 0) return; + for (int i = 0; i < _hist_count; i++) { + ChHistEntry& e = _hist[(_hist_head + i) % CH_HIST_MAX]; + if (e.relay_status == ACK_PENDING && e.relay_seq == seq) { + e.relay_status = ACK_OK; + return; + } + } + } + + // Arm the "relayed into mesh" marker on a just-sent entry (pos from + // addChannelMsg) — MyMesh tracked the flood it originated and will report a + // heard repeater echo by seq. + void armChannelRelay(int pos, uint32_t seq) { + if (pos < 0 || pos >= CH_HIST_MAX) return; + _hist[pos].relay_status = ACK_PENDING; + _hist[pos].relay_seq = seq; + } + + ChHistEntry& chAtPos(int pos) { return _hist[pos]; } + const ChHistEntry& chAtPos(int pos) const { return _hist[pos]; } + + // ── Per-channel unread counters ───────────────────────────────────────────── + uint8_t chUnread(int ch) const { + return (ch >= 0 && ch < MAX_GROUP_CHANNELS) ? _ch_unread[ch] : 0; + } + void setChUnread(int ch, uint8_t v) { + if (ch >= 0 && ch < MAX_GROUP_CHANNELS) _ch_unread[ch] = v; + } + void clearAllChannelUnread() { memset(_ch_unread, 0, sizeof(_ch_unread)); } + int getTotalChannelUnread() const { + int total = 0; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i]; + return total; + } + + // ── DM ring ───────────────────────────────────────────────────────────────── + + // ack_tag != 0 marks an outgoing DM as awaiting an end-to-end ACK by + // ack_deadline_ms; 0 means "sent, no confirmation possible" (no path / incoming). + // msg_ts = sender-perspective timestamp (send ts for outgoing / sender_timestamp + // for incoming); resends = remaining auto-resends for an outgoing pending DM. + void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, + uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, + uint32_t msg_ts = 0, uint8_t resends = 0) { + int pos; + if (_dm_hist_count < DM_HIST_MAX) { + pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; + _dm_hist_count++; + } else { + pos = _dm_hist_head; + _dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; + } + memcpy(_dm_hist[pos].prefix, pub_key, 4); + _dm_hist[pos].outgoing = outgoing ? 1 : 0; + // Prefer the sender's own timestamp — a room-sync replay or an + // offline-queued message held by a repeater can arrive long after it was + // actually sent, so "now" would mislabel every backlog message as fresh. + // Fall back to receipt time only when the sender's timestamp is unknown. + _dm_hist[pos].timestamp = msg_ts ? msg_ts : rtc_clock.getCurrentTime(); + strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); + _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; + _dm_hist[pos].ack_status = (outgoing && ack_tag) ? ACK_PENDING : ACK_NONE; + _dm_hist[pos].ack_tag = ack_tag; + _dm_hist[pos].ack_deadline_ms = ack_deadline_ms; + _dm_hist[pos].msg_ts = msg_ts; + _dm_hist[pos].attempt = 0; + _dm_hist[pos].resends_left = (outgoing && ack_tag) ? resends : 0; + } + + void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, + uint32_t sender_timestamp = 0) { + // Drop retried copies of an incoming DM: a resend reuses the sender's + // timestamp and text but carries a fresh packet hash, so the mesh dup-filter + // lets it through. Match on prefix + sender_timestamp + text to suppress it. + if (!outgoing && sender_timestamp != 0) { + for (int i = 0; i < _dm_hist_count; i++) { + const DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; + if (!e.outgoing && e.msg_ts == sender_timestamp && + memcmp(e.prefix, pub_key, 4) == 0 && strcmp(e.text, text) == 0) + return; // duplicate retry — already in history + } + } + storeDMMsg(pub_key, outgoing, text, 0, 0, outgoing ? 0 : sender_timestamp, 0); + } + + int dmHistCountForContact(const uint8_t* prefix) const { + int n = 0; + for (int i = 0; i < _dm_hist_count; i++) + if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++; + return n; + } + + int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest + int n = 0; + for (int i = _dm_hist_count - 1; i >= 0; i--) { + int pos = (_dm_hist_head + i) % DM_HIST_MAX; + if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) { + if (n == j) return pos; + n++; + } + } + return -1; + } + + // Effective status for display. A pending ACK only reads as failed once its + // deadline has passed AND no auto-resends remain — while resends_left > 0 the + // entry stays pending (tickDmResends() retries / finalises it). Safety net for + // when the tick hasn't run yet; the tick is the authority that writes ACK_FAIL. + AckState dmEffectiveStatus(const DmHistEntry& e) const { + if (e.ack_status == ACK_PENDING && e.resends_left == 0 && + (int32_t)(millis() - e.ack_deadline_ms) >= 0) + return ACK_FAIL; + return (AckState)e.ack_status; + } + + // Called when an end-to-end ACK arrives (routed from MyMesh::onAckRecv). + // Marks the matching pending outgoing DM as delivered. + void markDmDelivered(uint32_t ack_crc) { + if (ack_crc == 0) return; + for (int i = 0; i < _dm_hist_count; i++) { + DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; + if (e.outgoing && e.ack_status == ACK_PENDING && e.ack_tag == ack_crc) { + e.ack_status = ACK_OK; + return; + } + } + } + + // Periodic resend driver for outgoing DMs whose ACK deadline lapsed with no + // ACK: resend with the next attempt# (reusing the original timestamp so the + // recipient dedups) while resends remain, else mark it failed (✗). + void tickDmResends() { + uint32_t now = millis(); + for (int i = 0; i < _dm_hist_count; i++) { + DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; + if (!e.outgoing || e.ack_status != ACK_PENDING) continue; + if ((int32_t)(now - e.ack_deadline_ms) < 0) continue; // still waiting + if (e.resends_left == 0) { e.ack_status = ACK_FAIL; continue; } + ContactInfo c; + if (!contactByPrefix(e.prefix, c)) { e.ack_status = ACK_FAIL; continue; } + uint32_t expected_ack = 0, est_timeout = 0; + uint8_t next_attempt = e.attempt + 1; + if (the_mesh.sendMessage(c, e.msg_ts, next_attempt, e.text, + expected_ack, est_timeout) > 0 && expected_ack) { + e.attempt = next_attempt; + e.ack_tag = expected_ack; // each attempt has a distinct ACK CRC + e.ack_deadline_ms = now + est_timeout + 4000; + e.resends_left--; + } else { + e.ack_status = ACK_FAIL; // couldn't compose/send — give up + } + } + } + + // Recent DM contacts, newest first, deduped. Resolves the 4-byte _dm_hist + // prefix to a 6-byte pub_key prefix by walking the contact list once per + // unique sender. Returns the number filled. + int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const { + int n = 0; + for (int i = _dm_hist_count - 1; i >= 0 && n < max; i--) { + int pos = (_dm_hist_head + i) % DM_HIST_MAX; + const uint8_t* p4 = _dm_hist[pos].prefix; + // Skip if already collected. + bool dup = false; + for (int j = 0; j < n; j++) if (memcmp(out[j], p4, 4) == 0) { dup = true; break; } + if (dup) continue; + // Find a real contact whose pub_key starts with this 4-byte prefix. + for (int idx = 0; ; idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (memcmp(c.id.pub_key, p4, 4) == 0) { + memcpy(out[n], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN); + n++; + break; + } + } + } + return n; + } + + DmHistEntry& dmAtPos(int pos) { return _dm_hist[pos]; } + const DmHistEntry& dmAtPos(int pos) const { return _dm_hist[pos]; } + +private: + // Look up a contact by 4-byte pub_key prefix (as stored in DmHistEntry). + bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) const { + int total = the_mesh.getNumContacts(); + for (int i = 0; i < total; i++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(i, c)) continue; + if (memcmp(c.id.pub_key, prefix, 4) == 0) { out = c; return true; } + } + return false; + } + + ChHistEntry _hist[CH_HIST_MAX]; + int _hist_head, _hist_count; + uint8_t _ch_unread[MAX_GROUP_CHANNELS]; + + DmHistEntry _dm_hist[DM_HIST_MAX]; + int _dm_hist_head, _dm_hist_count; +}; diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index fa0bcd2d..d54a1a3b 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -26,7 +26,6 @@ class QuickMsgScreen : public UIScreen { int _channel_sel, _channel_scroll; int _num_channels; uint8_t _channel_indices[MAX_GROUP_CHANNELS]; - uint8_t _ch_unread[MAX_GROUP_CHANNELS]; int _sel_channel_idx; bool _sending_to_channel; @@ -41,17 +40,12 @@ class QuickMsgScreen : public UIScreen { int _active_msgs[QUICK_MSGS_MAX]; int _active_msg_count; - // CHANNEL_HIST + // CHANNEL_HIST — selection + the unread "viewing session" bookkeeping. The + // history ring itself and the per-channel unread counters live in _history. int _hist_sel, _hist_scroll; FullscreenMsgView _fs; - int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST + int _unread_at_entry; // channel unread count when entering CHANNEL_HIST int _viewing_max_seen; // highest _hist_sel reached in current session - // Shared ring for all channels combined. addChannelMsg() decrements the - // matching _ch_unread when an entry is evicted so the badge can't claim - // unread messages that no longer exist in the ring. Each slot carries a full - // MSG_TEXT_BUF, so this ring dominates the screen's RAM footprint — kept - // modest to leave heap headroom (see DM_HIST_MAX). - static const int CH_HIST_MAX = 48; // KEYBOARD KeyboardWidget* _kb; @@ -90,48 +84,15 @@ class QuickMsgScreen : public UIScreen { // auto-share target (channel / DM) instead of composing a message. bool _pick_target = false; - // History text holds a full received message. Channel messages carry the - // sender embedded as "Name: body" in the payload, so a message can be up to - // the over-the-air maximum (MAX_TEXT_LEN). Size the buffers to that + NUL, - // otherwise long messages (and Polish text, where each accented char is two - // UTF-8 bytes) get their tail clipped. - static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1; + // The message-history rings (channel + DM), their per-entry delivery state and + // per-channel unread counters live in this store (see MessageHistory.h). The + // phase machine below keeps only the view state — selection, scroll, the + // fullscreen readers — and reaches entries through _history's accessors. The + // shared types (AckState, ChHistEntry, DmHistEntry, MSG_TEXT_BUF) are file- + // scope, so they're still referred to unqualified throughout this screen. + MessageHistory _history; - // Outgoing-message delivery state. DM: a real end-to-end ACK (✓ delivered to - // the recipient). Channel: only a "relayed into mesh" echo from a repeater (no - // recipient ACK exists for floods), so a missing echo is NOT shown as failure. - enum AckState : uint8_t { ACK_NONE = 0, ACK_PENDING, ACK_OK, ACK_FAIL }; - - struct ChHistEntry { - uint8_t ch_idx; - char text[MSG_TEXT_BUF]; - uint32_t timestamp; - uint8_t relay_status; // AckState; only PENDING/OK used (no failure for floods) - uint32_t relay_seq; // MyMesh relay seq to match against onChannelRelayed() - }; - ChHistEntry _hist[CH_HIST_MAX]; - int _hist_head, _hist_count; - - // DM_HIST - struct DmHistEntry { - uint8_t prefix[4]; - uint8_t outgoing; - char text[MSG_TEXT_BUF]; - uint32_t timestamp; - uint8_t ack_status; // AckState; meaningful only when outgoing - uint32_t ack_tag; // expected_ack CRC to match against onMsgAck() - uint32_t ack_deadline_ms; // millis() by which a pending ACK must arrive - // Sender-perspective message timestamp: the send timestamp for outgoing - // (reused verbatim on resend so the recipient treats it as a retry), or the - // sender_timestamp for incoming (used to dedup retried copies). 0 = unknown. - uint32_t msg_ts; - uint8_t attempt; // last attempt number sent (outgoing); next resend = attempt+1 - uint8_t resends_left; // remaining auto-resends before the marker shows ✗ - }; - // Each slot carries a full MSG_TEXT_BUF; kept modest to bound heap use. - static const int DM_HIST_MAX = 32; - DmHistEntry _dm_hist[DM_HIST_MAX]; - int _dm_hist_head, _dm_hist_count; + // DM_HIST view state (the ring itself is in _history). int _dm_hist_sel, _dm_hist_scroll; FullscreenMsgView _dm_fs; @@ -344,29 +305,6 @@ class QuickMsgScreen : public UIScreen { } } - - // count history entries for a specific channel - int histCountForChannel(int ch_idx) const { - int n = 0; - for (int i = 0; i < _hist_count; i++) { - if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++; - } - return n; - } - - // get ring-buffer position of j-th history entry for channel (newest first) - int histEntryForChannel(int ch_idx, int j) const { - int n = 0; - for (int i = _hist_count - 1; i >= 0; i--) { - int pos = (_hist_head + i) % CH_HIST_MAX; - if (_hist[pos].ch_idx == (uint8_t)ch_idx) { - if (n == j) return pos; - n++; - } - } - return -1; - } - // Delivery marker, drawn with the current ink colour and auto-scaled to the // font (see icons.h). Pending = a row of dots, one per send (so it grows with // each auto-resend); delivered = ✓; failed = ✗; ACK_NONE = nothing. @@ -379,68 +317,6 @@ class QuickMsgScreen : public UIScreen { } } - // ack_tag != 0 marks an outgoing DM as awaiting an end-to-end ACK by - // ack_deadline_ms; 0 means "sent, no confirmation possible" (no path / incoming). - // msg_ts = sender-perspective timestamp (send ts for outgoing / sender_timestamp - // for incoming); resends = remaining auto-resends for an outgoing pending DM. - void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, - uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, - uint32_t msg_ts = 0, uint8_t resends = 0) { - int pos; - if (_dm_hist_count < DM_HIST_MAX) { - pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; - _dm_hist_count++; - } else { - pos = _dm_hist_head; - _dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; - } - memcpy(_dm_hist[pos].prefix, pub_key, 4); - _dm_hist[pos].outgoing = outgoing ? 1 : 0; - // Prefer the sender's own timestamp — a room-sync replay or an - // offline-queued message held by a repeater can arrive long after it was - // actually sent, so "now" would mislabel every backlog message as fresh. - // Fall back to receipt time only when the sender's timestamp is unknown. - _dm_hist[pos].timestamp = msg_ts ? msg_ts : rtc_clock.getCurrentTime(); - strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); - _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; - _dm_hist[pos].ack_status = (outgoing && ack_tag) ? ACK_PENDING : ACK_NONE; - _dm_hist[pos].ack_tag = ack_tag; - _dm_hist[pos].ack_deadline_ms = ack_deadline_ms; - _dm_hist[pos].msg_ts = msg_ts; - _dm_hist[pos].attempt = 0; - _dm_hist[pos].resends_left = (outgoing && ack_tag) ? resends : 0; - } - - // Effective status for display. A pending ACK only reads as failed once its - // deadline has passed AND no auto-resends remain — while resends_left > 0 the - // entry stays pending (tickDmResends() retries / finalises it). Safety net for - // when the tick hasn't run yet; the tick is the authority that writes ACK_FAIL. - AckState dmEffectiveStatus(const DmHistEntry& e) const { - if (e.ack_status == ACK_PENDING && e.resends_left == 0 && - (int32_t)(millis() - e.ack_deadline_ms) >= 0) - return ACK_FAIL; - return (AckState)e.ack_status; - } - - int dmHistCountForContact(const uint8_t* prefix) const { - int n = 0; - for (int i = 0; i < _dm_hist_count; i++) - if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++; - return n; - } - - int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest - int n = 0; - for (int i = _dm_hist_count - 1; i >= 0; i--) { - int pos = (_dm_hist_head + i) % DM_HIST_MAX; - if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) { - if (n == j) return pos; - n++; - } - } - return -1; - } - void afterSend(bool ok, const char* msg) { _reply_mode = false; _share_mode = false; @@ -453,21 +329,18 @@ class QuickMsgScreen : public UIScreen { int pos = addChannelMsg(_sel_channel_idx, entry); // Arm the "relayed into mesh" marker on this exact entry — MyMesh tracked // the flood it just originated and reports a heard repeater echo by seq. - if (pos >= 0) { - _hist[pos].relay_status = ACK_PENDING; - _hist[pos].relay_seq = the_mesh.lastChannelRelaySeq(); - } + if (pos >= 0) _history.armChannelRelay(pos, the_mesh.lastChannelRelaySeq()); // After inserting sent msg at index 0, the unread index range is stale. // User is active in this channel — treat as fully read. - _ch_unread[_sel_channel_idx] = 0; + _history.setChUnread(_sel_channel_idx, 0); _unread_at_entry = 0; _viewing_max_seen = 0; _task->showAlert("Sent!", 600); } else if (ok) { NodePrefs* np = _task->getNodePrefs(); uint8_t resends = np ? np->dm_resend_count : 0; - storeDMMsg(_sel_contact.id.pub_key, true, msg, _last_ack_tag, - _last_ack_deadline_ms, _last_send_ts, resends); + _history.storeDMMsg(_sel_contact.id.pub_key, true, msg, _last_ack_tag, + _last_ack_deadline_ms, _last_send_ts, resends); _dm_hist_sel = 0; _dm_hist_scroll = 0; _phase = DM_HIST; @@ -522,7 +395,7 @@ class QuickMsgScreen : public UIScreen { for (int i = 0; i < total; i++) { if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; if (!show_all && !(c.flags & 0x01)) continue; - counts[_num_contacts] = dmHistCountForContact(c.id.pub_key); + counts[_num_contacts] = _history.dmHistCountForContact(c.id.pub_key); _sorted[_num_contacts++] = i; } // Sort by message count descending; contacts with no messages keep original order. @@ -671,88 +544,25 @@ public: _msg_sel(0), _msg_scroll(0), _active_msg_count(0), _hist_sel(0), _hist_scroll(0), _unread_at_entry(0), _viewing_max_seen(0), - _hist_head(0), _hist_count(0), - _dm_hist_head(0), _dm_hist_count(0), _dm_hist_sel(-1), _dm_hist_scroll(0), _ctx_dirty(false), _pin_picker_active(false), _dm_direct_entry(false), _reply_mode(false) { - memset(_ch_unread, 0, sizeof(_ch_unread)); + // The history rings + per-channel unread counters init in MessageHistory. } - // Returns the ring position the message was stored at, or -1 if rejected, so - // the outgoing path can attach a relay seq to that exact entry. `timestamp` - // is the sender's own send time (0 = unknown/outgoing — use receipt time); - // see storeDMMsg() for why this matters for synced/queued backlog messages. + // Public entry points (routed from MyMesh / the bot via UITask) — thin + // forwarders to the history store. addChannelMsg computes the "viewing" flag + // (a phase-machine fact the store can't see) and returns the ring position so + // the outgoing path can attach a relay seq to that exact entry. int addChannelMsg(uint8_t ch_idx, const char* text, uint32_t timestamp = 0) { - // Guard against bogus channel indices (e.g. findChannelIdx() returned -1 - // and was cast to uint8_t → 255). Storing such an entry would burn a ring - // slot for a message that no visible channel can ever surface. - if (ch_idx >= MAX_GROUP_CHANNELS) return -1; - int pos; - if (_hist_count < CH_HIST_MAX) { - pos = (_hist_head + _hist_count) % CH_HIST_MAX; - _hist_count++; - } else { - pos = _hist_head; - // Evicting the oldest entry — drop its share of the unread counter so - // the badge can't claim a message the ring no longer holds. - uint8_t evicted = _hist[pos].ch_idx; - if (evicted < MAX_GROUP_CHANNELS && _ch_unread[evicted] > 0) { - _ch_unread[evicted]--; - } - _hist_head = (_hist_head + 1) % CH_HIST_MAX; - } - _hist[pos].ch_idx = ch_idx; - _hist[pos].timestamp = timestamp ? timestamp : rtc_clock.getCurrentTime(); - strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); - _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; - _hist[pos].relay_status = ACK_NONE; - _hist[pos].relay_seq = 0; - bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx); - if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; - return pos; + return _history.addChannelMsg(ch_idx, text, viewing, timestamp); } - - // Called when a repeater echo of one of our channel sends is heard. - void markChannelRelayed(uint32_t seq) { - if (seq == 0) return; - for (int i = 0; i < _hist_count; i++) { - ChHistEntry& e = _hist[(_hist_head + i) % CH_HIST_MAX]; - if (e.relay_status == ACK_PENDING && e.relay_seq == seq) { - e.relay_status = ACK_OK; - return; - } - } - } - + void markChannelRelayed(uint32_t seq) { _history.markChannelRelayed(seq); } void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) { - // Drop retried copies of an incoming DM: a resend reuses the sender's - // timestamp and text but carries a fresh packet hash, so the mesh dup-filter - // lets it through. Match on prefix + sender_timestamp + text to suppress it. - if (!outgoing && sender_timestamp != 0) { - for (int i = 0; i < _dm_hist_count; i++) { - const DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; - if (!e.outgoing && e.msg_ts == sender_timestamp && - memcmp(e.prefix, pub_key, 4) == 0 && strcmp(e.text, text) == 0) - return; // duplicate retry — already in history - } - } - storeDMMsg(pub_key, outgoing, text, 0, 0, outgoing ? 0 : sender_timestamp, 0); - } - - // Called when an end-to-end ACK arrives (routed from MyMesh::onAckRecv). - // Marks the matching pending outgoing DM as delivered. - void markDmDelivered(uint32_t ack_crc) { - if (ack_crc == 0) return; - for (int i = 0; i < _dm_hist_count; i++) { - DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; - if (e.outgoing && e.ack_status == ACK_PENDING && e.ack_tag == ack_crc) { - e.ack_status = ACK_OK; - return; - } - } + _history.addDMMsg(pub_key, outgoing, text, sender_timestamp); } + void markDmDelivered(uint32_t ack_crc) { _history.markDmDelivered(ack_crc); } // Rooms successfully logged in to this power-on session. RAM-only — the // server's ACL (see ClientACL) is the real permission store and survives @@ -810,55 +620,15 @@ public: _task->showAlert(success ? "Login OK" : "Login failed", 1200); } - // Look up a contact by 4-byte pub_key prefix (as stored in DmHistEntry). - bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) const { - int total = the_mesh.getNumContacts(); - for (int i = 0; i < total; i++) { - ContactInfo c; - if (the_mesh.getContactByIdx(i, c) && memcmp(c.id.pub_key, prefix, 4) == 0) { - out = c; - return true; - } - } - return false; - } - // Background tick (called every UI loop, regardless of the active screen) that - // drives auto-resend of on-device DMs. When a pending DM passes its deadline - // with no ACK: resend with the next attempt# (reusing the original timestamp so - // the recipient dedups) while resends remain, else mark it failed (✗). - void tickDmResends() { - uint32_t now = millis(); - for (int i = 0; i < _dm_hist_count; i++) { - DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; - if (!e.outgoing || e.ack_status != ACK_PENDING) continue; - if ((int32_t)(now - e.ack_deadline_ms) < 0) continue; // still waiting - if (e.resends_left == 0) { e.ack_status = ACK_FAIL; continue; } - ContactInfo c; - if (!contactByPrefix(e.prefix, c)) { e.ack_status = ACK_FAIL; continue; } - uint32_t expected_ack = 0, est_timeout = 0; - uint8_t next_attempt = e.attempt + 1; - if (the_mesh.sendMessage(c, e.msg_ts, next_attempt, e.text, - expected_ack, est_timeout) > 0 && expected_ack) { - e.attempt = next_attempt; - e.ack_tag = expected_ack; // each attempt has a distinct ACK CRC - e.ack_deadline_ms = now + est_timeout + 4000; - e.resends_left--; - } else { - e.ack_status = ACK_FAIL; // couldn't compose/send — give up - } - } - } + // drives auto-resend of on-device DMs — forwarded to the history store. + void tickDmResends() { _history.tickDmResends(); } int getDMUnreadTotal() const { return _task->getDMUnreadTotal(); } - int getTotalChannelUnread() const { - int total = 0; - for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i]; - return total; - } + int getTotalChannelUnread() const { return _history.getTotalChannelUnread(); } void markReadAlert(int n) { char msg[32]; @@ -866,10 +636,11 @@ public: _task->showAlert(msg, 800); } - void clearAllChannelUnread() { - memset(_ch_unread, 0, sizeof(_ch_unread)); - } + void clearAllChannelUnread() { _history.clearAllChannelUnread(); } + // Update the viewing-session unread bookkeeping (UI state) and push the + // resulting count down into the store. The ring lives in _history, but this + // "what has the user seen on screen" logic is pure phase-machine state. void updateChannelUnread() { if (_sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; // histEntryForChannel is newest-first: index 0 = newest (unread), higher = older. @@ -881,7 +652,7 @@ public: if (seen_to > _viewing_max_seen) _viewing_max_seen = seen_to; // Each step down from 0 sees one more message; seen count = max_seen + 1. int remaining = _unread_at_entry - (_viewing_max_seen + 1); - _ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0); + _history.setChUnread(_sel_channel_idx, (uint8_t)(remaining > 0 ? remaining : 0)); } void reset() { @@ -908,30 +679,9 @@ public: _viewing_max_seen = 0; } - // Recent DM contacts, newest first, deduped. Resolves the 4-byte - // _dm_hist prefix to a 6-byte pub_key prefix by walking the contact - // list once per unique sender. Returns the number filled. + // Recent DM contacts, newest first, deduped (forwarded to the history store). int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const { - int n = 0; - for (int i = _dm_hist_count - 1; i >= 0 && n < max; i--) { - int pos = (_dm_hist_head + i) % DM_HIST_MAX; - const uint8_t* p4 = _dm_hist[pos].prefix; - // Skip if already collected. - bool dup = false; - for (int j = 0; j < n; j++) if (memcmp(out[j], p4, 4) == 0) { dup = true; break; } - if (dup) continue; - // Find a real contact whose pub_key starts with this 4-byte prefix. - for (int idx = 0; ; idx++) { - ContactInfo c; - if (!the_mesh.getContactByIdx(idx, c)) break; - if (memcmp(c.id.pub_key, p4, 4) == 0) { - memcpy(out[n], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN); - n++; - break; - } - } - } - return n; + return _history.getRecentDMContacts(out, max); } // Jump straight into a contact's DM history (used by the Favourites dial). @@ -1082,7 +832,7 @@ public: drawRowSelection(display, y, sel, reserve); ChannelDetails ch; if (the_mesh.getChannel(_channel_indices[list_idx], ch)) { - uint8_t unread = _ch_unread[_channel_indices[list_idx]]; + uint8_t unread = _history.chUnread(_channel_indices[list_idx]); char badge[5] = ""; int bw = 0; if (unread > 0) { @@ -1106,21 +856,21 @@ public: display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name)); if (_dm_fs.active && _dm_hist_sel >= 0) { - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0) { - const DmHistEntry& e = _dm_hist[ring_pos]; + const DmHistEntry& e = _history.dmAtPos(ring_pos); char sender_buf[33]; const char* body = dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM, filtered_name, sender_buf, sizeof(sender_buf)); const char* sender = sender_buf; - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key); int ret = _dm_fs.render(display, sender, body, _dm_hist_sel < dm_count - 1, _dm_hist_sel > 0); if (e.outgoing) { // delivery marker in the (inverted) header bar display.setColor(DisplayDriver::DARK); drawAckGlyph(display, 2 + display.getTextWidth(sender) + 3, 1, - dmEffectiveStatus(e), e.attempt + 1); + _history.dmEffectiveStatus(e), e.attempt + 1); display.setColor(DisplayDriver::LIGHT); } if (_ctx_menu.active) _ctx_menu.render(display); @@ -1138,7 +888,7 @@ public: display.drawTextCentered(display.width()/2, 0, title); display.fillRect(0, lh + 1, display.width(), display.sepH()); - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key); uint32_t now_ts = rtc_clock.getCurrentTime(); bool is_room = (_sel_contact.type == ADV_TYPE_ROOM); @@ -1153,8 +903,8 @@ public: HistScroll hs = computeHistScroll(display, portrait_expand, dm_count, _dm_hist_scroll, hist_start_y, cby, lh, [&](int idx) -> const char* { - int rp = dmHistEntryForContact(_sel_contact.id.pub_key, idx); - return rp >= 0 ? _dm_hist[rp].text : nullptr; + int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx); + return rp >= 0 ? _history.dmAtPos(rp).text : nullptr; }); int reserve = hs.reserve; { @@ -1163,10 +913,10 @@ public: for (int ii = 0; ii < MAX_VIS_BOXES && (_dm_hist_scroll + ii) < dm_count; ii++) { int bh = fixed_bh; if (portrait_expand) { - int rp = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_scroll + ii); + int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_scroll + ii); if (rp >= 0) { char hsb[33]; - const char* hbody = dmDisplayParts(_dm_hist[rp], is_room, filtered_name, hsb, sizeof(hsb)); + const char* hbody = dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, hsb, sizeof(hsb)); display.translateUTF8ToBlocks(s_wrap_trans, hbody, sizeof(s_wrap_trans)); int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8); bh = (1 + (nl > 0 ? nl : 1)) * lh + 1; @@ -1184,10 +934,10 @@ public: int y = box_ys[i]; int bh = box_hs[i]; - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, item); + int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, item); if (ring_pos < 0) continue; - const DmHistEntry& e = _dm_hist[ring_pos]; + const DmHistEntry& e = _history.dmAtPos(ring_pos); char sender_buf[33]; const char* body = dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf)); const char* sender = sender_buf; @@ -1208,7 +958,7 @@ public: display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender); if (e.outgoing) { // delivery marker after "Me" int gx = 3 + display.getTextWidth(sender) + 3; - drawAckGlyph(display, gx, y + 1, dmEffectiveStatus(e), e.attempt + 1); + drawAckGlyph(display, gx, y + 1, _history.dmEffectiveStatus(e), e.attempt + 1); } if (age[0]) { display.setCursor(display.width() - age_w - reserve, y + 1); display.print(age); } if (!sel) display.setColor(DisplayDriver::LIGHT); @@ -1248,10 +998,10 @@ public: } else if (_phase == CHANNEL_HIST) { if (_fs.active && _hist_sel >= 0) { - int fs_hist_count = histCountForChannel(_sel_channel_idx); - int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + int fs_hist_count = _history.histCountForChannel(_sel_channel_idx); + int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel); if (ring_pos >= 0) { - const char* ftext = _hist[ring_pos].text; + const char* ftext = _history.chAtPos(ring_pos).text; const char* fsep = strstr(ftext, ": "); char fsender[33], fmsg[MSG_TEXT_BUF]; if (fsep) { @@ -1266,7 +1016,7 @@ public: _hist_sel < fs_hist_count - 1, _hist_sel > 0); // Channels: ✓ only once a repeater echo confirms relay (see list view). - if (strcmp(fsender, "Me") == 0 && _hist[ring_pos].relay_status == ACK_OK) { + if (strcmp(fsender, "Me") == 0 && _history.chAtPos(ring_pos).relay_status == ACK_OK) { display.setColor(DisplayDriver::DARK); drawAckGlyph(display, 2 + display.getTextWidth(fsender) + 3, 1, ACK_OK); display.setColor(DisplayDriver::LIGHT); @@ -1287,7 +1037,7 @@ public: display.drawTextCentered(display.width()/2, 0, title); display.fillRect(0, lh + 1, display.width(), display.sepH()); - int ch_hist_count = histCountForChannel(_sel_channel_idx); + int ch_hist_count = _history.histCountForChannel(_sel_channel_idx); uint32_t now_ts = rtc_clock.getCurrentTime(); // Portrait e-ink (height > width): variable-height boxes that show the full @@ -1299,9 +1049,9 @@ public: HistScroll hs = computeHistScroll(display, portrait_expand, ch_hist_count, _hist_scroll, hist_start_y, cby, lh, [&](int idx) -> const char* { - int rp = histEntryForChannel(_sel_channel_idx, idx); + int rp = _history.histEntryForChannel(_sel_channel_idx, idx); if (rp < 0) return nullptr; - const char* t = _hist[rp].text; + const char* t = _history.chAtPos(rp).text; const char* s = strstr(t, ": "); return s ? s + 2 : t; }); @@ -1312,9 +1062,9 @@ public: for (int ii = 0; ii < MAX_VIS_BOXES && (_hist_scroll + ii) < ch_hist_count; ii++) { int bh = fixed_bh; if (portrait_expand) { - int rp = histEntryForChannel(_sel_channel_idx, _hist_scroll + ii); + int rp = _history.histEntryForChannel(_sel_channel_idx, _hist_scroll + ii); if (rp >= 0) { - const char* rtext = _hist[rp].text; + const char* rtext = _history.chAtPos(rp).text; const char* rsep = strstr(rtext, ": "); const char* rbody = rsep ? rsep + 2 : rtext; display.translateUTF8ToBlocks(s_wrap_trans, skipReplyPrefix(rbody), sizeof(s_wrap_trans)); @@ -1336,10 +1086,10 @@ public: int y = box_ys[i]; int bh = box_hs[i]; - int ring_pos = histEntryForChannel(_sel_channel_idx, item); + int ring_pos = _history.histEntryForChannel(_sel_channel_idx, item); if (ring_pos < 0) continue; - const char* text = _hist[ring_pos].text; + const char* text = _history.chAtPos(ring_pos).text; const char* sep = strstr(text, ": "); char sender[33], msg_part[MSG_TEXT_BUF]; if (sep) { @@ -1353,7 +1103,7 @@ public: msg_part[sizeof(msg_part) - 1] = '\0'; } - char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, _hist[ring_pos].timestamp); + char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, _history.chAtPos(ring_pos).timestamp); int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; if (sel) { @@ -1370,7 +1120,7 @@ public: // Channels have no recipient ACK — only show ✓ once a repeater echo // confirms the send was relayed into the mesh; otherwise no marker // (absence is normal, not a failure). - if (strcmp(sender, "Me") == 0 && _hist[ring_pos].relay_status == ACK_OK) { + if (strcmp(sender, "Me") == 0 && _history.chAtPos(ring_pos).relay_status == ACK_OK) { int gx = 3 + display.getTextWidth(sender) + 3; drawAckGlyph(display, gx, y + 1, ACK_OK); } @@ -1710,8 +1460,8 @@ public: uint8_t ch_idx = _channel_indices[_channel_sel]; int sel = _ctx_menu.selectedIndex(); if (sel == 0) { - int cleared = (int)_ch_unread[ch_idx]; - _ch_unread[ch_idx] = 0; + int cleared = (int)_history.chUnread(ch_idx); + _history.setChUnread(ch_idx, 0); markReadAlert(cleared); } // sel 1/2/3 already handled by LEFT/RIGHT; ENTER just closes. @@ -1726,8 +1476,8 @@ public: if (c == KEY_ENTER && _num_channels > 0) { _sel_channel_idx = _channel_indices[_channel_sel]; if (_pick_target) { commitPickTargetChannel(_sel_channel_idx); return true; } - int hc = histCountForChannel(_sel_channel_idx); - _unread_at_entry = (int)_ch_unread[_sel_channel_idx]; + int hc = _history.histCountForChannel(_sel_channel_idx); + _unread_at_entry = (int)_history.chUnread(_sel_channel_idx); _hist_scroll = 0; _hist_sel = hc > 0 ? 0 : -1; _viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0; @@ -1760,7 +1510,7 @@ public: } } else if (_phase == DM_HIST) { - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key); if (_dm_fs.active) { if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); @@ -1779,11 +1529,11 @@ public: } else if (res == FullscreenMsgView::CLOSE) { _dm_fs.active = false; } else if (res == FullscreenMsgView::REPLY) { - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0) { - bool reply_ok = !_dm_hist[ring_pos].outgoing; - if (reply_ok) buildDmReplyPrefix(_dm_hist[ring_pos]); - buildFsMenu(_dm_hist[ring_pos].text, reply_ok); + bool reply_ok = !_history.dmAtPos(ring_pos).outgoing; + if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos)); + buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok); } } return true; @@ -1835,17 +1585,17 @@ public: return true; } if (c == KEY_CONTEXT_MENU && _dm_hist_sel >= 0) { - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0) { - bool reply_ok = !_dm_hist[ring_pos].outgoing; - if (reply_ok) buildDmReplyPrefix(_dm_hist[ring_pos]); - buildFsMenu(_dm_hist[ring_pos].text, reply_ok); + bool reply_ok = !_history.dmAtPos(ring_pos).outgoing; + if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos)); + buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok); } return true; } } else if (_phase == CHANNEL_HIST) { - int ch_hist_count = histCountForChannel(_sel_channel_idx); + int ch_hist_count = _history.histCountForChannel(_sel_channel_idx); if (_fs.active) { if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); @@ -1864,9 +1614,9 @@ public: } else if (res == FullscreenMsgView::CLOSE) { _fs.active = false; } else if (res == FullscreenMsgView::REPLY) { - int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel); if (ring_pos >= 0) - buildFsMenu(_hist[ring_pos].text, buildChannelReplyPrefix(_hist[ring_pos].text)); + buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text)); } return true; } @@ -1907,9 +1657,9 @@ public: return true; } if (c == KEY_CONTEXT_MENU && _hist_sel >= 0) { - int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel); if (ring_pos >= 0) - buildFsMenu(_hist[ring_pos].text, buildChannelReplyPrefix(_hist[ring_pos].text)); + buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text)); return true; } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index d17a813e..95334873 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -130,6 +130,7 @@ static const int QUICK_MSGS_MAX = 10; #include "FullscreenMsgView.h" #include "SensorPlaceholders.h" #include "SettingsScreen.h" +#include "MessageHistory.h" // RAM history rings (DM + channel) used by QuickMsgScreen #include "QuickMsgScreen.h" // ── Custom screens (separate files to ease upstream merges) ───────────────────