Merge branch 'refactor/quickmsg-history-store'

Extract the DM + channel message-history rings from QuickMsgScreen into a
self-contained MessageHistory store, dedupe the notif/melody override
accessors, share the history row-frame / [+ send] button between the two
history lists, and fix the [+ send] row vanishing in room/DM history.
Hardware-validated before merge.
This commit is contained in:
MarekZegare4
2026-06-29 23:10:54 +02:00
3 changed files with 491 additions and 454 deletions
@@ -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;
};
+166 -454
View File
@@ -26,7 +26,6 @@ class QuickMsgScreen : public UIScreen {
int _channel_sel, _channel_scroll; int _channel_sel, _channel_scroll;
int _num_channels; int _num_channels;
uint8_t _channel_indices[MAX_GROUP_CHANNELS]; uint8_t _channel_indices[MAX_GROUP_CHANNELS];
uint8_t _ch_unread[MAX_GROUP_CHANNELS];
int _sel_channel_idx; int _sel_channel_idx;
bool _sending_to_channel; bool _sending_to_channel;
@@ -41,17 +40,12 @@ class QuickMsgScreen : public UIScreen {
int _active_msgs[QUICK_MSGS_MAX]; int _active_msgs[QUICK_MSGS_MAX];
int _active_msg_count; 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; int _hist_sel, _hist_scroll;
FullscreenMsgView _fs; 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 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 // KEYBOARD
KeyboardWidget* _kb; KeyboardWidget* _kb;
@@ -90,48 +84,15 @@ class QuickMsgScreen : public UIScreen {
// auto-share target (channel / DM) instead of composing a message. // auto-share target (channel / DM) instead of composing a message.
bool _pick_target = false; bool _pick_target = false;
// History text holds a full received message. Channel messages carry the // The message-history rings (channel + DM), their per-entry delivery state and
// sender embedded as "Name: body" in the payload, so a message can be up to // per-channel unread counters live in this store (see MessageHistory.h). The
// the over-the-air maximum (MAX_TEXT_LEN). Size the buffers to that + NUL, // phase machine below keeps only the view state — selection, scroll, the
// otherwise long messages (and Polish text, where each accented char is two // fullscreen readers — and reaches entries through _history's accessors. The
// UTF-8 bytes) get their tail clipped. // shared types (AckState, ChHistEntry, DmHistEntry, MSG_TEXT_BUF) are file-
static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1; // 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 // DM_HIST view state (the ring itself is in _history).
// 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;
int _dm_hist_sel, _dm_hist_scroll; int _dm_hist_sel, _dm_hist_scroll;
FullscreenMsgView _dm_fs; 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 // 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 // font (see icons.h). Pending = a row of dots, one per send (so it grows with
// each auto-resend); delivered = ✓; failed = ✗; ACK_NONE = nothing. // each auto-resend); delivered = ✓; failed = ✗; ACK_NONE = nothing.
@@ -379,66 +317,32 @@ class QuickMsgScreen : public UIScreen {
} }
} }
// ack_tag != 0 marks an outgoing DM as awaiting an end-to-end ACK by // Selection frame for one history message box, shared by the DM/room and
// ack_deadline_ms; 0 means "sent, no confirmation possible" (no path / incoming). // channel history lists. Selected = solid fill; unselected = outline with a
// msg_ts = sender-perspective timestamp (send ts for outgoing / sender_timestamp // filled header strip. Leaves the ink DARK (for the sender row drawn next).
// for incoming); resends = remaining auto-resends for an outgoing pending DM. static void drawHistRowFrame(DisplayDriver& d, int y, int bh, int reserve, int lh, bool sel) {
void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, d.setColor(DisplayDriver::LIGHT);
uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, if (sel) {
uint32_t msg_ts = 0, uint8_t resends = 0) { d.fillRect(0, y, d.width() - reserve, bh);
int pos;
if (_dm_hist_count < DM_HIST_MAX) {
pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX;
_dm_hist_count++;
} else { } else {
pos = _dm_hist_head; d.drawRect(0, y, d.width() - reserve, bh);
_dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; d.fillRect(1, y + 1, d.width() - 2 - reserve, lh);
} }
memcpy(_dm_hist[pos].prefix, pub_key, 4); d.setColor(DisplayDriver::DARK);
_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 // Bottom-left "[+ send]" compose button, shared by both history lists:
// deadline has passed AND no auto-resends remain — while resends_left > 0 the // bordered when idle, inverted (filled) when selected. Always reset to LIGHT
// entry stays pending (tickDmResends() retries / finalises it). Safety net for // first so it stays visible regardless of the ink the message loop left.
// when the tick hasn't run yet; the tick is the authority that writes ACK_FAIL. static void drawComposeButton(DisplayDriver& d, int cby, int lh, bool sel) {
AckState dmEffectiveStatus(const DmHistEntry& e) const { const char* ctxt = "[+ send]";
if (e.ack_status == ACK_PENDING && e.resends_left == 0 && int ctw = d.getTextWidth(ctxt);
(int32_t)(millis() - e.ack_deadline_ms) >= 0) d.setColor(DisplayDriver::LIGHT);
return ACK_FAIL; if (sel) { d.fillRect(0, cby - 1, ctw + 4, lh + 2); d.setColor(DisplayDriver::DARK); }
return (AckState)e.ack_status; else d.drawRect(0, cby - 1, ctw + 4, lh + 2);
} d.setCursor(2, cby);
d.print(ctxt);
int dmHistCountForContact(const uint8_t* prefix) const { d.setColor(DisplayDriver::LIGHT);
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) { void afterSend(bool ok, const char* msg) {
@@ -453,21 +357,18 @@ class QuickMsgScreen : public UIScreen {
int pos = addChannelMsg(_sel_channel_idx, entry); int pos = addChannelMsg(_sel_channel_idx, entry);
// Arm the "relayed into mesh" marker on this exact entry — MyMesh tracked // 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. // the flood it just originated and reports a heard repeater echo by seq.
if (pos >= 0) { if (pos >= 0) _history.armChannelRelay(pos, the_mesh.lastChannelRelaySeq());
_hist[pos].relay_status = ACK_PENDING;
_hist[pos].relay_seq = the_mesh.lastChannelRelaySeq();
}
// After inserting sent msg at index 0, the unread index range is stale. // After inserting sent msg at index 0, the unread index range is stale.
// User is active in this channel — treat as fully read. // 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; _unread_at_entry = 0;
_viewing_max_seen = 0; _viewing_max_seen = 0;
_task->showAlert("Sent!", 600); _task->showAlert("Sent!", 600);
} else if (ok) { } else if (ok) {
NodePrefs* np = _task->getNodePrefs(); NodePrefs* np = _task->getNodePrefs();
uint8_t resends = np ? np->dm_resend_count : 0; uint8_t resends = np ? np->dm_resend_count : 0;
storeDMMsg(_sel_contact.id.pub_key, true, msg, _last_ack_tag, _history.storeDMMsg(_sel_contact.id.pub_key, true, msg, _last_ack_tag,
_last_ack_deadline_ms, _last_send_ts, resends); _last_ack_deadline_ms, _last_send_ts, resends);
_dm_hist_sel = 0; _dm_hist_sel = 0;
_dm_hist_scroll = 0; _dm_hist_scroll = 0;
_phase = DM_HIST; _phase = DM_HIST;
@@ -522,7 +423,7 @@ class QuickMsgScreen : public UIScreen {
for (int i = 0; i < total; i++) { for (int i = 0; i < total; i++) {
if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue;
if (!show_all && !(c.flags & 0x01)) 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; _sorted[_num_contacts++] = i;
} }
// Sort by message count descending; contacts with no messages keep original order. // Sort by message count descending; contacts with no messages keep original order.
@@ -551,115 +452,92 @@ class QuickMsgScreen : public UIScreen {
} }
// Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on // Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on
uint8_t chNotifState(uint8_t ch_idx) const { // Per-contact/channel notification + melody overrides share two storage
NodePrefs* p = _task->getNodePrefs(); // shapes, so the eight accessors below are thin wrappers over two primitives:
if (!p) return 0; //
uint64_t mask = 1ULL << ch_idx; // • Channels — a 3-state packed into a pair of channel-index bitmasks: a
if (!(p->ch_notif_override & mask)) return 0; // "presence" mask (is there an override at all) + a "variant" mask. The two
return (p->ch_notif_muted & mask) ? 1 : 2; // uses disagree on which state the variant bit means, so the caller passes
// the state value that corresponds to variant-set (v_set).
// • DMs — a small {prefix[4], value} table: find by 4-byte prefix, update or
// clear (clear frees the slot), else insert into the first free slot, else
// overwrite slot 0. value 0 == "no override" == empty slot.
static uint8_t maskPairGet(uint64_t presence, uint64_t variant, uint8_t idx,
uint8_t v_set, uint8_t v_clr) {
uint64_t m = 1ULL << idx;
if (!(presence & m)) return 0;
return (variant & m) ? v_set : v_clr;
}
static void maskPairSet(uint64_t& presence, uint64_t& variant, uint8_t idx,
uint8_t state, uint8_t v_set) {
uint64_t m = 1ULL << idx;
if (state == 0) { presence &= ~m; variant &= ~m; return; }
presence |= m;
if (state == v_set) variant |= m; else variant &= ~m;
} }
template <class Entry>
static uint8_t prefTableGet(const Entry* tbl, int n, const uint8_t* pub_key,
uint8_t Entry::* val) {
for (int i = 0; i < n; i++)
if (tbl[i].*val && memcmp(tbl[i].prefix, pub_key, 4) == 0) return tbl[i].*val;
return 0;
}
template <class Entry>
static void prefTableSet(Entry* tbl, int n, const uint8_t* pub_key,
uint8_t Entry::* val, uint8_t v) {
for (int i = 0; i < n; i++)
if (tbl[i].*val && memcmp(tbl[i].prefix, pub_key, 4) == 0) {
if (v == 0) memset(&tbl[i], 0, sizeof(tbl[i])); else tbl[i].*val = v;
return;
}
if (v == 0) return;
for (int i = 0; i < n; i++)
if (tbl[i].*val == 0) { memcpy(tbl[i].prefix, pub_key, 4); tbl[i].*val = v; return; }
memcpy(tbl[0].prefix, pub_key, 4); tbl[0].*val = v; // table full — overwrite slot 0
}
// Channel notif: state 1 = muted (variant bit set), 2 = force-on (variant clear).
uint8_t chNotifState(uint8_t ch_idx) const {
NodePrefs* p = _task->getNodePrefs();
return p ? maskPairGet(p->ch_notif_override, p->ch_notif_muted, ch_idx, 1, 2) : 0;
}
void setChNotifState(uint8_t ch_idx, uint8_t state) { void setChNotifState(uint8_t ch_idx, uint8_t state) {
NodePrefs* p = _task->getNodePrefs(); NodePrefs* p = _task->getNodePrefs();
if (!p) return; if (p) maskPairSet(p->ch_notif_override, p->ch_notif_muted, ch_idx, state, 1);
uint64_t mask = 1ULL << ch_idx;
if (state == 0) {
p->ch_notif_override &= ~mask;
p->ch_notif_muted &= ~mask;
} else if (state == 1) {
p->ch_notif_override |= mask;
p->ch_notif_muted |= mask;
} else {
p->ch_notif_override |= mask;
p->ch_notif_muted &= ~mask;
}
} }
uint8_t dmNotifState(const uint8_t* pub_key) const { uint8_t dmNotifState(const uint8_t* pub_key) const {
NodePrefs* p = _task->getNodePrefs(); NodePrefs* p = _task->getNodePrefs();
if (!p) return 0; return p ? prefTableGet(p->dm_notif, NodePrefs::DM_NOTIF_TABLE_MAX, pub_key,
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) &NodePrefs::DmNotifEntry::state) : 0;
if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0)
return p->dm_notif[i].state;
return 0;
} }
void setDmNotifState(const uint8_t* pub_key, uint8_t state) { void setDmNotifState(const uint8_t* pub_key, uint8_t state) {
NodePrefs* p = _task->getNodePrefs(); NodePrefs* p = _task->getNodePrefs();
if (!p) return; if (p) prefTableSet(p->dm_notif, NodePrefs::DM_NOTIF_TABLE_MAX, pub_key,
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { &NodePrefs::DmNotifEntry::state, state);
if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) {
if (state == 0) { memset(&p->dm_notif[i], 0, sizeof(p->dm_notif[i])); }
else p->dm_notif[i].state = state;
return;
}
}
if (state == 0) return;
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) {
if (p->dm_notif[i].state == 0) {
memcpy(p->dm_notif[i].prefix, pub_key, 4);
p->dm_notif[i].state = state;
return;
}
}
// table full — overwrite slot 0
memcpy(p->dm_notif[0].prefix, pub_key, 4);
p->dm_notif[0].state = state;
} }
// Channel melody: slot 1 = melody 1 (variant bit clear), 2 = melody 2 (set).
uint8_t chNotifMelody(uint8_t ch_idx) const { uint8_t chNotifMelody(uint8_t ch_idx) const {
NodePrefs* p = _task->getNodePrefs(); NodePrefs* p = _task->getNodePrefs();
if (!p) return 0; return p ? maskPairGet(p->ch_notif_melody_set, p->ch_notif_melody_2, ch_idx, 2, 1) : 0;
uint64_t mask = 1ULL << ch_idx;
if (!(p->ch_notif_melody_set & mask)) return 0;
return (p->ch_notif_melody_2 & mask) ? 2 : 1;
} }
void setChNotifMelody(uint8_t ch_idx, uint8_t slot) { void setChNotifMelody(uint8_t ch_idx, uint8_t slot) {
NodePrefs* p = _task->getNodePrefs(); NodePrefs* p = _task->getNodePrefs();
if (!p) return; if (p) maskPairSet(p->ch_notif_melody_set, p->ch_notif_melody_2, ch_idx, slot, 2);
uint64_t mask = 1ULL << ch_idx;
if (slot == 0) {
p->ch_notif_melody_set &= ~mask;
p->ch_notif_melody_2 &= ~mask;
} else if (slot == 1) {
p->ch_notif_melody_set |= mask;
p->ch_notif_melody_2 &= ~mask;
} else {
p->ch_notif_melody_set |= mask;
p->ch_notif_melody_2 |= mask;
}
} }
uint8_t dmMelodySlot(const uint8_t* pub_key) const { uint8_t dmMelodySlot(const uint8_t* pub_key) const {
NodePrefs* p = _task->getNodePrefs(); NodePrefs* p = _task->getNodePrefs();
if (!p) return 0; return p ? prefTableGet(p->dm_melody, NodePrefs::DM_MELODY_TABLE_MAX, pub_key,
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) &NodePrefs::DmMelodyEntry::slot) : 0;
if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0)
return p->dm_melody[i].slot;
return 0;
} }
void setDmMelody(const uint8_t* pub_key, uint8_t slot) { void setDmMelody(const uint8_t* pub_key, uint8_t slot) {
NodePrefs* p = _task->getNodePrefs(); NodePrefs* p = _task->getNodePrefs();
if (!p) return; if (p) prefTableSet(p->dm_melody, NodePrefs::DM_MELODY_TABLE_MAX, pub_key,
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { &NodePrefs::DmMelodyEntry::slot, slot);
if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) {
if (slot == 0) memset(&p->dm_melody[i], 0, sizeof(p->dm_melody[i]));
else p->dm_melody[i].slot = slot;
return;
}
}
if (slot == 0) return;
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) {
if (p->dm_melody[i].slot == 0) {
memcpy(p->dm_melody[i].prefix, pub_key, 4);
p->dm_melody[i].slot = slot;
return;
}
}
memcpy(p->dm_melody[0].prefix, pub_key, 4);
p->dm_melody[0].slot = slot;
} }
public: public:
@@ -671,88 +549,25 @@ public:
_msg_sel(0), _msg_scroll(0), _active_msg_count(0), _msg_sel(0), _msg_scroll(0), _active_msg_count(0),
_hist_sel(0), _hist_scroll(0), _hist_sel(0), _hist_scroll(0),
_unread_at_entry(0), _viewing_max_seen(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), _dm_hist_sel(-1), _dm_hist_scroll(0),
_ctx_dirty(false), _pin_picker_active(false), _dm_direct_entry(false), _reply_mode(false) { _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 // Public entry points (routed from MyMesh / the bot via UITask) — thin
// the outgoing path can attach a relay seq to that exact entry. `timestamp` // forwarders to the history store. addChannelMsg computes the "viewing" flag
// is the sender's own send time (0 = unknown/outgoing — use receipt time); // (a phase-machine fact the store can't see) and returns the ring position so
// see storeDMMsg() for why this matters for synced/queued backlog messages. // 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) { 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); bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx);
if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; return _history.addChannelMsg(ch_idx, text, viewing, timestamp);
return pos;
} }
void markChannelRelayed(uint32_t seq) { _history.markChannelRelayed(seq); }
// 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 addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
uint32_t sender_timestamp = 0) { uint32_t sender_timestamp = 0) {
// Drop retried copies of an incoming DM: a resend reuses the sender's _history.addDMMsg(pub_key, outgoing, text, sender_timestamp);
// 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;
}
}
} }
void markDmDelivered(uint32_t ack_crc) { _history.markDmDelivered(ack_crc); }
// Rooms successfully logged in to this power-on session. RAM-only — the // Rooms successfully logged in to this power-on session. RAM-only — the
// server's ACL (see ClientACL) is the real permission store and survives // server's ACL (see ClientACL) is the real permission store and survives
@@ -810,55 +625,15 @@ public:
_task->showAlert(success ? "Login OK" : "Login failed", 1200); _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 // 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 // drives auto-resend of on-device DMs — forwarded to the history store.
// with no ACK: resend with the next attempt# (reusing the original timestamp so void tickDmResends() { _history.tickDmResends(); }
// 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
}
}
}
int getDMUnreadTotal() const { int getDMUnreadTotal() const {
return _task->getDMUnreadTotal(); return _task->getDMUnreadTotal();
} }
int getTotalChannelUnread() const { int getTotalChannelUnread() const { return _history.getTotalChannelUnread(); }
int total = 0;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i];
return total;
}
void markReadAlert(int n) { void markReadAlert(int n) {
char msg[32]; char msg[32];
@@ -866,10 +641,11 @@ public:
_task->showAlert(msg, 800); _task->showAlert(msg, 800);
} }
void clearAllChannelUnread() { void clearAllChannelUnread() { _history.clearAllChannelUnread(); }
memset(_ch_unread, 0, sizeof(_ch_unread));
}
// 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() { void updateChannelUnread() {
if (_sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; if (_sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return;
// histEntryForChannel is newest-first: index 0 = newest (unread), higher = older. // histEntryForChannel is newest-first: index 0 = newest (unread), higher = older.
@@ -881,7 +657,7 @@ public:
if (seen_to > _viewing_max_seen) _viewing_max_seen = seen_to; 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. // Each step down from 0 sees one more message; seen count = max_seen + 1.
int remaining = _unread_at_entry - (_viewing_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() { void reset() {
@@ -908,30 +684,9 @@ public:
_viewing_max_seen = 0; _viewing_max_seen = 0;
} }
// Recent DM contacts, newest first, deduped. Resolves the 4-byte // Recent DM contacts, newest first, deduped (forwarded to the history store).
// _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 getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const {
int n = 0; return _history.getRecentDMContacts(out, max);
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;
} }
// Jump straight into a contact's DM history (used by the Favourites dial). // Jump straight into a contact's DM history (used by the Favourites dial).
@@ -1082,7 +837,7 @@ public:
drawRowSelection(display, y, sel, reserve); drawRowSelection(display, y, sel, reserve);
ChannelDetails ch; ChannelDetails ch;
if (the_mesh.getChannel(_channel_indices[list_idx], 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] = ""; char badge[5] = "";
int bw = 0; int bw = 0;
if (unread > 0) { if (unread > 0) {
@@ -1106,21 +861,21 @@ public:
display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name)); display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name));
if (_dm_fs.active && _dm_hist_sel >= 0) { 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) { if (ring_pos >= 0) {
const DmHistEntry& e = _dm_hist[ring_pos]; const DmHistEntry& e = _history.dmAtPos(ring_pos);
char sender_buf[33]; char sender_buf[33];
const char* body = dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM, const char* body = dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM,
filtered_name, sender_buf, sizeof(sender_buf)); filtered_name, sender_buf, sizeof(sender_buf));
const char* sender = 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, int ret = _dm_fs.render(display, sender, body,
_dm_hist_sel < dm_count - 1, _dm_hist_sel < dm_count - 1,
_dm_hist_sel > 0); _dm_hist_sel > 0);
if (e.outgoing) { // delivery marker in the (inverted) header bar if (e.outgoing) { // delivery marker in the (inverted) header bar
display.setColor(DisplayDriver::DARK); display.setColor(DisplayDriver::DARK);
drawAckGlyph(display, 2 + display.getTextWidth(sender) + 3, 1, drawAckGlyph(display, 2 + display.getTextWidth(sender) + 3, 1,
dmEffectiveStatus(e), e.attempt + 1); _history.dmEffectiveStatus(e), e.attempt + 1);
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
} }
if (_ctx_menu.active) _ctx_menu.render(display); if (_ctx_menu.active) _ctx_menu.render(display);
@@ -1138,7 +893,7 @@ public:
display.drawTextCentered(display.width()/2, 0, title); display.drawTextCentered(display.width()/2, 0, title);
display.fillRect(0, lh + 1, display.width(), display.sepH()); 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(); uint32_t now_ts = rtc_clock.getCurrentTime();
bool is_room = (_sel_contact.type == ADV_TYPE_ROOM); bool is_room = (_sel_contact.type == ADV_TYPE_ROOM);
@@ -1153,8 +908,8 @@ public:
HistScroll hs = computeHistScroll(display, portrait_expand, dm_count, _dm_hist_scroll, HistScroll hs = computeHistScroll(display, portrait_expand, dm_count, _dm_hist_scroll,
hist_start_y, cby, lh, hist_start_y, cby, lh,
[&](int idx) -> const char* { [&](int idx) -> const char* {
int rp = dmHistEntryForContact(_sel_contact.id.pub_key, idx); int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx);
return rp >= 0 ? _dm_hist[rp].text : nullptr; return rp >= 0 ? _history.dmAtPos(rp).text : nullptr;
}); });
int reserve = hs.reserve; int reserve = hs.reserve;
{ {
@@ -1163,10 +918,10 @@ public:
for (int ii = 0; ii < MAX_VIS_BOXES && (_dm_hist_scroll + ii) < dm_count; ii++) { for (int ii = 0; ii < MAX_VIS_BOXES && (_dm_hist_scroll + ii) < dm_count; ii++) {
int bh = fixed_bh; int bh = fixed_bh;
if (portrait_expand) { 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) { if (rp >= 0) {
char hsb[33]; 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)); 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); int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8);
bh = (1 + (nl > 0 ? nl : 1)) * lh + 1; bh = (1 + (nl > 0 ? nl : 1)) * lh + 1;
@@ -1184,10 +939,10 @@ public:
int y = box_ys[i]; int y = box_ys[i];
int bh = box_hs[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; if (ring_pos < 0) continue;
const DmHistEntry& e = _dm_hist[ring_pos]; const DmHistEntry& e = _history.dmAtPos(ring_pos);
char sender_buf[33]; char sender_buf[33];
const char* body = dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf)); const char* body = dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf));
const char* sender = sender_buf; const char* sender = sender_buf;
@@ -1195,20 +950,11 @@ public:
char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp); char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp);
int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; int age_w = age[0] ? display.getTextWidth(age) + 3 : 0;
if (sel) { drawHistRowFrame(display, y, bh, reserve, lh, sel);
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y, display.width() - reserve, bh);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, y, display.width() - reserve, bh);
display.fillRect(1, y + 1, display.width() - 2 - reserve, lh);
display.setColor(DisplayDriver::DARK);
}
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender); display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender);
if (e.outgoing) { // delivery marker after "Me" if (e.outgoing) { // delivery marker after "Me"
int gx = 3 + display.getTextWidth(sender) + 3; 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 (age[0]) { display.setCursor(display.width() - age_w - reserve, y + 1); display.print(age); }
if (!sel) display.setColor(DisplayDriver::LIGHT); if (!sel) display.setColor(DisplayDriver::LIGHT);
@@ -1232,26 +978,16 @@ public:
drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px, drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px,
hs.total_px, hs.view_px, hs.scroll_px); hs.total_px, hs.view_px, hs.scroll_px);
bool compose_sel = (_dm_hist_sel == -1); drawComposeButton(display, cby, lh, _dm_hist_sel == -1);
const char* ctxt = "[+ send]";
int ctw = display.getTextWidth(ctxt);
int cbx = 1;
if (compose_sel) {
display.fillRect(cbx, cby - 1, ctw + 4, lh + 1);
display.setColor(DisplayDriver::DARK);
}
display.setCursor(cbx + 2, cby);
display.print(ctxt);
display.setColor(DisplayDriver::LIGHT);
if (_ctx_menu.active) _ctx_menu.render(display); if (_ctx_menu.active) _ctx_menu.render(display);
return dm_count > 0 ? 500 : 2000; return dm_count > 0 ? 500 : 2000;
} else if (_phase == CHANNEL_HIST) { } else if (_phase == CHANNEL_HIST) {
if (_fs.active && _hist_sel >= 0) { if (_fs.active && _hist_sel >= 0) {
int fs_hist_count = histCountForChannel(_sel_channel_idx); int fs_hist_count = _history.histCountForChannel(_sel_channel_idx);
int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel);
if (ring_pos >= 0) { if (ring_pos >= 0) {
const char* ftext = _hist[ring_pos].text; const char* ftext = _history.chAtPos(ring_pos).text;
const char* fsep = strstr(ftext, ": "); const char* fsep = strstr(ftext, ": ");
char fsender[33], fmsg[MSG_TEXT_BUF]; char fsender[33], fmsg[MSG_TEXT_BUF];
if (fsep) { if (fsep) {
@@ -1266,7 +1002,7 @@ public:
_hist_sel < fs_hist_count - 1, _hist_sel < fs_hist_count - 1,
_hist_sel > 0); _hist_sel > 0);
// Channels: ✓ only once a repeater echo confirms relay (see list view). // 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); display.setColor(DisplayDriver::DARK);
drawAckGlyph(display, 2 + display.getTextWidth(fsender) + 3, 1, ACK_OK); drawAckGlyph(display, 2 + display.getTextWidth(fsender) + 3, 1, ACK_OK);
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
@@ -1287,7 +1023,7 @@ public:
display.drawTextCentered(display.width()/2, 0, title); display.drawTextCentered(display.width()/2, 0, title);
display.fillRect(0, lh + 1, display.width(), display.sepH()); 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(); uint32_t now_ts = rtc_clock.getCurrentTime();
// Portrait e-ink (height > width): variable-height boxes that show the full // Portrait e-ink (height > width): variable-height boxes that show the full
@@ -1299,9 +1035,9 @@ public:
HistScroll hs = computeHistScroll(display, portrait_expand, ch_hist_count, _hist_scroll, HistScroll hs = computeHistScroll(display, portrait_expand, ch_hist_count, _hist_scroll,
hist_start_y, cby, lh, hist_start_y, cby, lh,
[&](int idx) -> const char* { [&](int idx) -> const char* {
int rp = histEntryForChannel(_sel_channel_idx, idx); int rp = _history.histEntryForChannel(_sel_channel_idx, idx);
if (rp < 0) return nullptr; if (rp < 0) return nullptr;
const char* t = _hist[rp].text; const char* t = _history.chAtPos(rp).text;
const char* s = strstr(t, ": "); const char* s = strstr(t, ": ");
return s ? s + 2 : t; return s ? s + 2 : t;
}); });
@@ -1312,9 +1048,9 @@ public:
for (int ii = 0; ii < MAX_VIS_BOXES && (_hist_scroll + ii) < ch_hist_count; ii++) { for (int ii = 0; ii < MAX_VIS_BOXES && (_hist_scroll + ii) < ch_hist_count; ii++) {
int bh = fixed_bh; int bh = fixed_bh;
if (portrait_expand) { 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) { if (rp >= 0) {
const char* rtext = _hist[rp].text; const char* rtext = _history.chAtPos(rp).text;
const char* rsep = strstr(rtext, ": "); const char* rsep = strstr(rtext, ": ");
const char* rbody = rsep ? rsep + 2 : rtext; const char* rbody = rsep ? rsep + 2 : rtext;
display.translateUTF8ToBlocks(s_wrap_trans, skipReplyPrefix(rbody), sizeof(s_wrap_trans)); display.translateUTF8ToBlocks(s_wrap_trans, skipReplyPrefix(rbody), sizeof(s_wrap_trans));
@@ -1336,10 +1072,10 @@ public:
int y = box_ys[i]; int y = box_ys[i];
int bh = box_hs[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; 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, ": "); const char* sep = strstr(text, ": ");
char sender[33], msg_part[MSG_TEXT_BUF]; char sender[33], msg_part[MSG_TEXT_BUF];
if (sep) { if (sep) {
@@ -1353,24 +1089,15 @@ public:
msg_part[sizeof(msg_part) - 1] = '\0'; 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; int age_w = age[0] ? display.getTextWidth(age) + 3 : 0;
if (sel) { drawHistRowFrame(display, y, bh, reserve, lh, sel);
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y, display.width() - reserve, bh);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, y, display.width() - reserve, bh);
display.fillRect(1, y + 1, display.width() - 2 - reserve, lh);
display.setColor(DisplayDriver::DARK);
}
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender); display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender);
// Channels have no recipient ACK — only show ✓ once a repeater echo // Channels have no recipient ACK — only show ✓ once a repeater echo
// confirms the send was relayed into the mesh; otherwise no marker // confirms the send was relayed into the mesh; otherwise no marker
// (absence is normal, not a failure). // (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; int gx = 3 + display.getTextWidth(sender) + 3;
drawAckGlyph(display, gx, y + 1, ACK_OK); drawAckGlyph(display, gx, y + 1, ACK_OK);
} }
@@ -1395,22 +1122,7 @@ public:
drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px, drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px,
hs.total_px, hs.view_px, hs.scroll_px); hs.total_px, hs.view_px, hs.scroll_px);
// small compose button (bottom-left, always bordered, inverted when selected) drawComposeButton(display, cby, lh, _hist_sel == -1);
bool compose_sel = (_hist_sel == -1);
const char* ctxt = "[+ send]";
int ctw = display.getTextWidth(ctxt);
int cbx = 1;
if (compose_sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(cbx - 1, cby - 1, ctw + 4, lh + 2);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(cbx - 1, cby - 1, ctw + 4, lh + 2);
}
display.setCursor(cbx + 1, cby);
display.print(ctxt);
display.setColor(DisplayDriver::LIGHT);
if (_ctx_menu.active) _ctx_menu.render(display); if (_ctx_menu.active) _ctx_menu.render(display);
} else if (_phase == KEYBOARD) { } else if (_phase == KEYBOARD) {
@@ -1710,8 +1422,8 @@ public:
uint8_t ch_idx = _channel_indices[_channel_sel]; uint8_t ch_idx = _channel_indices[_channel_sel];
int sel = _ctx_menu.selectedIndex(); int sel = _ctx_menu.selectedIndex();
if (sel == 0) { if (sel == 0) {
int cleared = (int)_ch_unread[ch_idx]; int cleared = (int)_history.chUnread(ch_idx);
_ch_unread[ch_idx] = 0; _history.setChUnread(ch_idx, 0);
markReadAlert(cleared); markReadAlert(cleared);
} }
// sel 1/2/3 already handled by LEFT/RIGHT; ENTER just closes. // sel 1/2/3 already handled by LEFT/RIGHT; ENTER just closes.
@@ -1726,8 +1438,8 @@ public:
if (c == KEY_ENTER && _num_channels > 0) { if (c == KEY_ENTER && _num_channels > 0) {
_sel_channel_idx = _channel_indices[_channel_sel]; _sel_channel_idx = _channel_indices[_channel_sel];
if (_pick_target) { commitPickTargetChannel(_sel_channel_idx); return true; } if (_pick_target) { commitPickTargetChannel(_sel_channel_idx); return true; }
int hc = histCountForChannel(_sel_channel_idx); int hc = _history.histCountForChannel(_sel_channel_idx);
_unread_at_entry = (int)_ch_unread[_sel_channel_idx]; _unread_at_entry = (int)_history.chUnread(_sel_channel_idx);
_hist_scroll = 0; _hist_scroll = 0;
_hist_sel = hc > 0 ? 0 : -1; _hist_sel = hc > 0 ? 0 : -1;
_viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0; _viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0;
@@ -1760,7 +1472,7 @@ public:
} }
} else if (_phase == DM_HIST) { } 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 (_dm_fs.active) {
if (_ctx_menu.active) { if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c); auto res = _ctx_menu.handleInput(c);
@@ -1779,11 +1491,11 @@ public:
} else if (res == FullscreenMsgView::CLOSE) { } else if (res == FullscreenMsgView::CLOSE) {
_dm_fs.active = false; _dm_fs.active = false;
} else if (res == FullscreenMsgView::REPLY) { } 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) { if (ring_pos >= 0) {
bool reply_ok = !_dm_hist[ring_pos].outgoing; bool reply_ok = !_history.dmAtPos(ring_pos).outgoing;
if (reply_ok) buildDmReplyPrefix(_dm_hist[ring_pos]); if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos));
buildFsMenu(_dm_hist[ring_pos].text, reply_ok); buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok);
} }
} }
return true; return true;
@@ -1835,17 +1547,17 @@ public:
return true; return true;
} }
if (c == KEY_CONTEXT_MENU && _dm_hist_sel >= 0) { 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) { if (ring_pos >= 0) {
bool reply_ok = !_dm_hist[ring_pos].outgoing; bool reply_ok = !_history.dmAtPos(ring_pos).outgoing;
if (reply_ok) buildDmReplyPrefix(_dm_hist[ring_pos]); if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos));
buildFsMenu(_dm_hist[ring_pos].text, reply_ok); buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok);
} }
return true; return true;
} }
} else if (_phase == CHANNEL_HIST) { } 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 (_fs.active) {
if (_ctx_menu.active) { if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c); auto res = _ctx_menu.handleInput(c);
@@ -1864,9 +1576,9 @@ public:
} else if (res == FullscreenMsgView::CLOSE) { } else if (res == FullscreenMsgView::CLOSE) {
_fs.active = false; _fs.active = false;
} else if (res == FullscreenMsgView::REPLY) { } 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) 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; return true;
} }
@@ -1907,9 +1619,9 @@ public:
return true; return true;
} }
if (c == KEY_CONTEXT_MENU && _hist_sel >= 0) { 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) 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; return true;
} }
@@ -130,6 +130,7 @@ static const int QUICK_MSGS_MAX = 10;
#include "FullscreenMsgView.h" #include "FullscreenMsgView.h"
#include "SensorPlaceholders.h" #include "SensorPlaceholders.h"
#include "SettingsScreen.h" #include "SettingsScreen.h"
#include "MessageHistory.h" // RAM history rings (DM + channel) used by QuickMsgScreen
#include "QuickMsgScreen.h" #include "QuickMsgScreen.h"
// ── Custom screens (separate files to ease upstream merges) ─────────────────── // ── Custom screens (separate files to ease upstream merges) ───────────────────