feat(mesh): show which repeaters relayed/confirmed a message's path

Extends the existing single-boolean channel relay-echo marker into a
full count + list of distinct confirming repeaters, since each
repeater retransmit already appends its own identity hash to the
packet's path and the echo-matching hash deliberately ignores that
mutable path -- so every distinct repeater's echo of one send now
matches the same tracking slot instead of only the first.

Symmetrically captures the hop path a received DM/channel message
actually took, so a new "Path"/"Relayed by" row in the existing
Hold-Enter Options popup can show the resolved sequence of repeaters
(by contact name, or a hex fallback for an unknown one).

Also fixes a real bug caught during testing: the popup row's own
label ("Path (N hops)"/"Relayed by (N)") was built into a stack-local
buffer handed to PopupMenu, which only stores the pointer -- it
rendered as garbage once the building function returned. Moved to a
persistent member buffer.

Bumps the dev-build fallback version and adds release notes/docs
for this plus the two other 1.27 features already on this branch
(BLE retry backoff, marquee-scroll for selected long text).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-08-30 18:04:06 +02:00
co-authored by Claude Sonnet 5
parent 389f3f7a36
commit bdd2ed379d
10 changed files with 281 additions and 57 deletions
+11 -2
View File
@@ -104,8 +104,17 @@ Drawing helpers (all clip/measure for you):
in the header, so the menu is discoverable without already knowing the
shortcut; `menu_open` highlights it while the menu is actually up.
- `drawSelectionRow(x, y, w, h, sel)` — the highlight bar behind a list row.
- `drawTextEllipsized(x, y, max_w, str)` — truncates with ``; **use this for
any user string** (names, labels) so long/UTF-8 text can't overrun.
- `drawTextEllipsized(x, y, max_w, str, selected=false)` — truncates with ``;
**use this for any user string** (names, labels) so long/UTF-8 text can't
overrun. Pass `selected=true` for the currently-selected row and the text
that doesn't fit marquee-scrolls into view (pause → scroll to the end →
pause → scroll back), instead of just sitting behind the ellipsis; returns
the ms until the next redraw is needed for that animation to stay smooth
(0 when nothing is scrolling) — thread it into your screen's own `render()`
return value the same way you already clamp for anything else that needs a
faster redraw. Only one row UI-wide marquees at a time (whichever is
currently selected), so there's no risk of two animations racing each
other for the shared timing state.
- `drawTextCentered(mid_x, y, str)`.
- `translateUTF8ToBlocks(dst, src, n)` — map UTF-8 to the panel's glyph set for
*display only*. Never run text through it before sending it over the air or
@@ -64,7 +64,7 @@ Posting to a **room server** needs a login handshake — the device does this on
Messages appear as chat bubbles sized to their content — **right**-anchored for outgoing, **left** for incoming — with sender name and a compact age indicator (`3m`, `2h`, `>1d`) in the top-right corner. List runs **newest at the bottom**; opening a history starts at the latest message, scrolling up goes further back.
**Short Enter** on a message opens it in fullscreen. **Hold Enter** — on a history row or in fullscreen — opens the same options menu: Reply, plus **Navigate** / **Save waypoint** when the message contains a location (see Fullscreen message view). You don't need to open the message first.
**Short Enter** on a message opens it in fullscreen. **Hold Enter** — on a history row or in fullscreen — opens the same options menu: Reply, plus **Navigate** / **Save waypoint** when the message contains a location, and **Path** / **Relayed by** when hop data is available (see Fullscreen message view). You don't need to open the message first.
---
@@ -89,6 +89,13 @@ If the message is a reply addressed to someone (`@[nick]`), a **To: nick** bar i
A location is any `lat,lon` pair in the text — exactly what the `{loc}` placeholder inserts — so you can navigate to anything a contact shares. A `[WAY]lat,lon label` share also carries a name, used as the waypoint label. This works on DMs and channel messages, incoming or outgoing.
When the entry has hop data recorded, the menu also adds one more row:
- **Path (N hops)** — on a received message (DM or channel), lists every repeater the message actually travelled through to reach you, oldest hop first.
- **Relayed by (N)** — on your own channel post instead, lists every distinct repeater heard rebroadcasting it back into the mesh (order isn't meaningful here — each one heard it independently, not as a chain).
Selecting the row opens a read-only list of the resolved hops — each shown as the matching contact's name where one is known, or a short `?AABB`-style hex tag for an unrecognised repeater. Only repeaters within range of the message's actual travel — or, for **Relayed by**, within your own device's radio range — can ever be identified this way; a message with no recorded path (e.g. a zero-hop send, or one sent before any repeater relayed or echoed it) doesn't show this row at all.
---
### Context menu — contact list
+16 -4
View File
@@ -46,7 +46,12 @@ public:
virtual void onMsgAck(uint32_t ack_crc) { (void)ack_crc; }
// A repeater rebroadcast of one of our channel sends was heard (seq from
// lastChannelRelaySeq()) — drives the channel "relayed into mesh" marker.
virtual void onChannelRelayed(uint32_t seq) { (void)seq; }
// May fire once per distinct repeater within earshot for the same seq;
// repeater_hash/hash_size (when given) is that repeater's path hash, so the
// UI can list every repeater that confirmed, not just "was it heard at all".
virtual void onChannelRelayed(uint32_t seq, const uint8_t* repeater_hash = nullptr, uint8_t hash_size = 0) {
(void)seq; (void)repeater_hash; (void)hash_size;
}
// Result of an on-device-UI-triggered MyMesh::sendRoomLogin() arrived.
// pub_key is the contact's key prefix (>=4 bytes valid); permissions is the
// room/repeater ACL byte (only meaningful when success is true).
@@ -87,16 +92,23 @@ public:
// Returns the new entry's ring position (see MessageHistory::addChannelMsg),
// or -1 on a UI variant that doesn't track history (default no-op below) --
// callers that need it (to then arm a relay-echo tracker) should check for
// that instead of assuming a valid position.
virtual int addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0) { return -1; }
// that instead of assuming a valid position. path/path_len (packed
// (hash_size-1)<<6|hop_count, same as mesh::Packet::path_len) is the hop
// route this incoming post actually took -- nullptr/0 when not known (e.g.
// this is our own outgoing post).
virtual int addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0,
const uint8_t* path = nullptr, uint8_t path_len = 0) { return -1; }
// Arms the "relayed into mesh" tracker (a heard repeater rebroadcast) on the
// entry at ring position pos, e.g. right after addChannelMsg for a channel
// send this device just originated. seq: MyMesh::lastChannelRelaySeq().
virtual void armChannelRelay(int pos, uint32_t seq) {}
// ack_tag/ack_deadline_ms/resends: pending-ACK tracking for an outgoing DM
// (0 = none, e.g. incoming or "no ack expected") -- see MessageHistory::addDMMsg.
// path/path_len: the hop route an incoming DM actually took (nullptr/0 for
// outgoing -- a DM's delivery confirmation is the ack_tag above, not a path).
virtual void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0,
uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, uint8_t resends = 0) {}
uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, uint8_t resends = 0,
const uint8_t* path = nullptr, uint8_t path_len = 0) {}
// A node shared its current position via a [LOC] message. pub_key is the
// sender's key prefix for a verified DM share, or null for a channel share
// (keyed by name, best-effort). Default no-op so UI variants opt in.
+17 -6
View File
@@ -545,7 +545,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
// message fires the notification and reaches the app via the offline queue but
// never shows when the room is opened directly on the device.
if (from.type == ADV_TYPE_CHAT) {
_ui->addDMMsg(from.id.pub_key, false, text, sender_timestamp);
_ui->addDMMsg(from.id.pub_key, false, text, sender_timestamp, 0, 0, 0, pkt->path, (uint8_t)pkt->path_len);
} else if (from.type == ADV_TYPE_ROOM) {
// A room carries many guests, so prefix the post with its author so the UI
// can attribute each line. The signed message's `extra` holds the sender's
@@ -560,7 +560,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
} else {
snprintf(labeled, sizeof(labeled), "%s", text);
}
_ui->addDMMsg(from.id.pub_key, false, labeled, sender_timestamp);
_ui->addDMMsg(from.id.pub_key, false, labeled, sender_timestamp, 0, 0, 0, pkt->path, (uint8_t)pkt->path_len);
}
}
#endif
@@ -589,9 +589,20 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
if (!s.pending || s.len != packet->payload_len) continue;
if (!hashed) { packet->calculatePacketHash(h); hashed = true; }
if (memcmp(h, s.hash, MAX_HASH_SIZE) == 0) {
s.pending = false;
_relay_active--;
if (_ui) _ui->onChannelRelayed(s.seq);
// Slot stays pending (freed only by the deadline sweep elsewhere) so a
// DIFFERENT repeater's independent echo of this same send can still
// match here too -- onChannelRelayed()/markChannelRelayed() append each
// additionally heard repeater instead of just flipping a single flag.
if (_ui) {
uint8_t hash_size = packet->getPathHashSize();
uint8_t hop_count = packet->getPathHashCount();
// The repeater we just heard directly is always the LAST hop appended
// (Mesh.cpp appends its own hash on every retransmit) -- that's the
// one within our own earshot, regardless of how many further hops
// this same packet may go on to take beyond it.
const uint8_t* repeater_hash = hop_count > 0 ? &packet->path[(hop_count - 1) * hash_size] : nullptr;
_ui->onChannelRelayed(s.seq, repeater_hash, repeater_hash ? hash_size : 0);
}
break;
}
}
@@ -845,7 +856,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
_serial->writeFrame(frame, 1);
}
#ifdef DISPLAY_CLASS
if (_ui) _ui->addChannelMsg(channel_idx, text, timestamp);
if (_ui) _ui->addChannelMsg(channel_idx, text, timestamp, pkt->path, (uint8_t)pkt->path_len);
if (_ui) _ui->notify(UIEventType::channelMessage);
const char *channel_name = "Unknown";
ChannelDetails channel_details;
+1 -1
View File
@@ -20,7 +20,7 @@ class UITask;
// "dev-<commit>" otherwise; see build-solo-firmwares.yml). This default only
// shows up for a `pio run` invoked directly, bypassing build.sh entirely.
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "v1.26-dev"
#define FIRMWARE_VERSION "v1.27-dev"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -22,12 +22,40 @@ enum AckState : uint8_t { ACK_NONE = 0, ACK_PENDING, ACK_OK, ACK_FAIL };
// get their tail clipped.
static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1;
// Cap on the per-entry path/relay-hash buffer below. Real mesh hop depths are
// small in practice (a handful at most), so this comfortably covers any
// realistic path at any path_hash_mode (16 hops at 1-byte hashes, down to 4 at
// 4-byte) without the RAM cost of sizing every ring entry to MAX_PATH_SIZE (64).
// A path deeper than this is silently truncated to the first entries that fit
// (capturePath() below keeps the stored hop count consistent with what's
// actually copied, so a display walk over the buffer can never run past it).
static const uint8_t MAX_HIST_PATH_BYTES = 16;
// Copies up to MAX_HIST_PATH_BYTES of a mesh::Packet-style path into dest_path,
// capping the hop count encoded in dest_len so the two always agree. src_len_packed
// uses the same (hash_size-1)<<6|hash_count packing as mesh::Packet::path_len.
inline void capturePath(uint8_t& dest_len, uint8_t* dest_path, const uint8_t* src_path, uint8_t src_len_packed) {
uint8_t hash_size = (src_len_packed >> 6) + 1;
uint8_t hash_count = src_len_packed & 63;
uint8_t max_hops = MAX_HIST_PATH_BYTES / hash_size;
if (hash_count > max_hops) hash_count = max_hops;
memcpy(dest_path, src_path, (size_t)hash_count * hash_size);
dest_len = ((hash_size - 1) << 6) | hash_count;
}
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()
// Incoming: the hop path this post actually took to reach us (resolved to
// repeater names by the UI). Outgoing: repurposed to hold the distinct
// repeater hashes that have echoed this send back (see markChannelRelayed) --
// a message is never both directions at once, so one buffer serves either.
// path_len packs (hash_size-1)<<6|hop_count, same as mesh::Packet::path_len.
uint8_t path_len;
uint8_t path[MAX_HIST_PATH_BYTES];
};
struct DmHistEntry {
@@ -44,6 +72,11 @@ struct DmHistEntry {
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 ✗
// Incoming only -- the hop path this DM took to reach us. A DM's outgoing
// confirmation is the real end-to-end ack_status/ack_tag above (not a
// repeater-echo concept), so this stays unset (0) for outgoing entries.
uint8_t path_len;
uint8_t path[MAX_HIST_PATH_BYTES];
};
class MessageHistory {
@@ -65,7 +98,11 @@ public:
// 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) {
// path/path_len_packed: the hop path this incoming post actually took (from
// the received mesh::Packet), or nullptr/0 when not known (e.g. this is our
// own outgoing post, before any relay echo has arrived).
int addChannelMsg(uint8_t ch_idx, const char* text, bool viewing, uint32_t timestamp = 0,
const uint8_t* path = nullptr, uint8_t path_len_packed = 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.
@@ -95,6 +132,8 @@ public:
_hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0';
_hist[pos].relay_status = ACK_NONE;
_hist[pos].relay_seq = 0;
if (path && path_len_packed) capturePath(_hist[pos].path_len, _hist[pos].path, path, path_len_packed);
else _hist[pos].path_len = 0;
if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++;
return pos;
@@ -122,25 +161,49 @@ public:
return -1;
}
// Called when a repeater echo of one of our channel sends is heard.
void markChannelRelayed(uint32_t seq) {
// Called when a repeater echo of one of our channel sends is heard. May fire
// more than once per send -- every repeater within earshot that independently
// rebroadcasts triggers its own call with the same seq -- so this matches on
// relay_seq alone (not "still ACK_PENDING") and keeps appending. repeater_hash
// (when given) is that repeater's path hash, appended de-duplicated so
// "Hold Enter" can list every distinct repeater that confirmed the send.
void markChannelRelayed(uint32_t seq, const uint8_t* repeater_hash = nullptr, uint8_t hash_size = 0) {
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;
if (e.relay_seq != seq) continue;
e.relay_status = ACK_OK;
if (repeater_hash && hash_size) {
uint8_t cur_size = (e.path_len >> 6) + 1;
uint8_t cur_count = e.path_len & 63;
if (cur_count == 0) cur_size = hash_size; // first hash recorded sets the size for this entry
if (cur_size == hash_size) { // ignore a mismatch rather than corrupt the buffer
bool dup = false;
for (uint8_t h = 0; h < cur_count; h++) {
if (memcmp(&e.path[h * hash_size], repeater_hash, hash_size) == 0) { dup = true; break; }
}
uint8_t max_hops = MAX_HIST_PATH_BYTES / hash_size;
if (!dup && cur_count < max_hops) {
memcpy(&e.path[cur_count * hash_size], repeater_hash, hash_size);
cur_count++;
e.path_len = ((hash_size - 1) << 6) | cur_count;
}
}
}
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.
// heard repeater echo by seq. Clears path_len: this entry's path buffer now
// holds echoing-repeater hashes (see markChannelRelayed), not a received hop
// path, so any stale value from a reused ring slot must not linger.
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;
_hist[pos].path_len = 0;
}
ChHistEntry& chAtPos(int pos) { return _hist[pos]; }
@@ -184,9 +247,12 @@ public:
// 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.
// path/path_len_packed: the hop path this incoming DM actually took, or
// nullptr/0 for outgoing (no path concept there -- see DmHistEntry).
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) {
uint32_t msg_ts = 0, uint8_t resends = 0,
const uint8_t* path = nullptr, uint8_t path_len_packed = 0) {
int pos;
if (_dm_hist_count < DM_HIST_MAX) {
pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX;
@@ -210,6 +276,8 @@ public:
_dm_hist[pos].msg_ts = msg_ts;
_dm_hist[pos].attempt = 0;
_dm_hist[pos].resends_left = (outgoing && ack_tag) ? resends : 0;
if (path && path_len_packed) capturePath(_dm_hist[pos].path_len, _dm_hist[pos].path, path, path_len_packed);
else _dm_hist[pos].path_len = 0;
}
// ack_tag/ack_deadline_ms/resends let an outgoing DM (e.g. one the phone app
@@ -218,7 +286,8 @@ public:
// shows with no delivery status at all. Unused (0) for incoming.
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
uint32_t sender_timestamp = 0, uint32_t ack_tag = 0,
uint32_t ack_deadline_ms = 0, uint8_t resends = 0) {
uint32_t ack_deadline_ms = 0, uint8_t resends = 0,
const uint8_t* path = nullptr, uint8_t path_len_packed = 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.
@@ -230,7 +299,7 @@ public:
return; // duplicate retry — already in history
}
}
storeDMMsg(pub_key, outgoing, text, ack_tag, ack_deadline_ms, sender_timestamp, resends);
storeDMMsg(pub_key, outgoing, text, ack_tag, ack_deadline_ms, sender_timestamp, resends, path, path_len_packed);
}
int dmHistCountForContact(const uint8_t* prefix) const {
+122 -24
View File
@@ -71,11 +71,28 @@ class MessagesScreen : public UIScreen {
// Fullscreen-message context menu actions. Built per-message: Reply (when
// applicable) plus Navigate / Save waypoint when the message carries a
// location (a {loc} string or a [WAY] share). _fs_act maps each visible row
// back to an action so the index math survives the conditional layout.
enum FsAct : uint8_t { FS_REPLY, FS_NAV, FS_SAVE };
uint8_t _fs_act[3];
// location (a {loc} string or a [WAY] share), plus Path/Relayed by when the
// entry has hop data recorded. _fs_act maps each visible row back to an
// action so the index math survives the conditional layout.
enum FsAct : uint8_t { FS_REPLY, FS_NAV, FS_SAVE, FS_PATH };
uint8_t _fs_act[4];
int _fs_act_n = 0;
// Which history entry the currently-open Options menu (and, after FS_PATH is
// chosen, the path/relay detail popup) refers to -- set by buildFsMenu().
int _fs_menu_pos = -1;
bool _fs_menu_is_channel = false;
// True while _ctx_menu is showing the path/relay detail list (opened from
// FS_PATH) rather than the original Reply/Navigate/... Options menu -- the
// detail list is purely informational, so any input just dismisses it
// instead of being routed through dispatchFsAction()'s stale _fs_act mapping.
bool _showing_path_detail = false;
// Resolved hop labels for the path/relay detail popup -- PopupMenu only
// stores the pointers it's given (see PopupMenu.h), so these must outlive
// the popup across renders, hence a member buffer, not a stack-local one.
char _path_detail_names[MAX_HIST_PATH_BYTES][24];
// "Path (N hops)" / "Relayed by (N)" Options-row label -- same lifetime
// requirement as _path_detail_names above (PopupMenu stores the pointer).
char _fs_path_item[24];
// Inline navigate-to-location view layered over the fullscreen message.
bool _nav_active = false;
int32_t _nav_lat = 0, _nav_lon = 0;
@@ -267,19 +284,86 @@ class MessagesScreen : public UIScreen {
_phase = KEYBOARD;
}
// Build the fullscreen-message options popup: Reply (if allowed) plus
// Navigate / Save waypoint when `body` carries a location. Opens _ctx_menu
// only when there's at least one action. Parses the location once here and
// stashes it for the action handler.
void buildFsMenu(const char* body, bool reply_allowed) {
// Build the fullscreen-message options popup: Reply (if allowed), Navigate /
// Save waypoint when `body` carries a location, and Path / Relayed by when
// the entry (ring_pos, in the DM ring or the channel ring per is_channel)
// has hop data recorded. Opens _ctx_menu only when there's at least one
// action. Parses the location once here and stashes it for the action
// handler; stashes ring_pos/is_channel too, for showPathDetail().
void buildFsMenu(const char* body, bool reply_allowed, int ring_pos, bool is_channel) {
_fs_menu_pos = ring_pos;
_fs_menu_is_channel = is_channel;
_showing_path_detail = false;
bool has_loc = geo::parseLatLon(body, _nav_lat, _nav_lon, _nav_label, sizeof(_nav_label));
int n = (reply_allowed ? 1 : 0) + (has_loc ? 2 : 0);
uint8_t hop_count = 0;
bool path_is_relay = false; // outgoing channel: path[] holds echoing-repeater hashes, not a received route
if (ring_pos >= 0) {
uint8_t path_len_packed = is_channel ? _history.chAtPos(ring_pos).path_len : _history.dmAtPos(ring_pos).path_len;
hop_count = path_len_packed & 63;
if (is_channel) path_is_relay = (_history.chAtPos(ring_pos).relay_seq != 0);
}
bool has_path_item = hop_count > 0;
int n = (reply_allowed ? 1 : 0) + (has_loc ? 2 : 0) + (has_path_item ? 1 : 0);
if (n == 0) return;
_fs_act_n = 0;
_ctx_menu.begin("Options", n);
if (reply_allowed) { _ctx_menu.addItem("Reply"); _fs_act[_fs_act_n++] = FS_REPLY; }
if (has_loc) { _ctx_menu.addItem("Navigate"); _fs_act[_fs_act_n++] = FS_NAV;
_ctx_menu.addItem("Save waypoint"); _fs_act[_fs_act_n++] = FS_SAVE; }
if (has_path_item) {
if (path_is_relay) snprintf(_fs_path_item, sizeof(_fs_path_item), "Relayed by (%u)", (unsigned)hop_count);
else snprintf(_fs_path_item, sizeof(_fs_path_item), "Path (%u hop%s)", (unsigned)hop_count, hop_count == 1 ? "" : "s");
_ctx_menu.addItem(_fs_path_item);
_fs_act[_fs_act_n++] = FS_PATH;
}
}
// Resolve one hop's path hash to a contact name, falling back to a short hex
// tag ("?AABB") when no known contact matches -- an unnamed/unknown repeater,
// or one whose contact card has since been removed.
void resolveHopName(const uint8_t* hash, uint8_t hash_size, char* out, size_t out_sz) {
for (int idx = 0; ; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (c.id.isHashMatch(hash, hash_size)) {
snprintf(out, out_sz, "%s", c.name);
return;
}
}
char hex[9] = {0};
uint8_t n = hash_size > 4 ? 4 : hash_size;
for (uint8_t b = 0; b < n; b++) snprintf(hex + b * 2, 3, "%02X", hash[b]);
snprintf(out, out_sz, "?%s", hex);
}
// Opens the path/relay detail popup for the entry stashed by buildFsMenu().
// Incoming: hops listed oldest-hop-first (the order they were appended as the
// message travelled). Outgoing channel: each distinct repeater heard echoing
// the send, order not meaningful (they're independent, not a chain).
void showPathDetail() {
if (_fs_menu_pos < 0) return;
uint8_t path_len_packed;
const uint8_t* path;
bool path_is_relay = false;
if (_fs_menu_is_channel) {
ChHistEntry& e = _history.chAtPos(_fs_menu_pos);
path_len_packed = e.path_len; path = e.path; path_is_relay = (e.relay_seq != 0);
} else {
DmHistEntry& e = _history.dmAtPos(_fs_menu_pos);
path_len_packed = e.path_len; path = e.path;
}
uint8_t hash_size = (path_len_packed >> 6) + 1;
uint8_t hop_count = path_len_packed & 63;
if (hop_count == 0) return;
if (hop_count > MAX_HIST_PATH_BYTES) hop_count = MAX_HIST_PATH_BYTES; // matches _path_detail_names capacity
for (uint8_t i = 0; i < hop_count; i++) {
resolveHopName(&path[i * hash_size], hash_size, _path_detail_names[i], sizeof(_path_detail_names[i]));
}
_ctx_menu.begin(path_is_relay ? "Relayed by" : "Path", hop_count);
for (uint8_t i = 0; i < hop_count; i++) _ctx_menu.addItem(_path_detail_names[i]);
_showing_path_detail = true;
}
// Dispatch the selected fullscreen-options row. `channel` picks which
@@ -293,8 +377,10 @@ class MessagesScreen : public UIScreen {
startReply(channel);
} else if (a == FS_NAV) {
_nav_active = true; // keep the message view active underneath
} else {
} else if (a == FS_SAVE) {
saveSharedWaypoint();
} else if (a == FS_PATH) {
showPathDetail();
}
}
@@ -648,9 +734,10 @@ public:
// 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) {
int addChannelMsg(uint8_t ch_idx, const char* text, uint32_t timestamp = 0,
const uint8_t* path = nullptr, uint8_t path_len = 0) {
bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx);
int pos = _history.addChannelMsg(ch_idx, text, viewing, timestamp);
int pos = _history.addChannelMsg(ch_idx, text, viewing, timestamp, path, path_len);
// Ring entries are numbered newest-first (0 == newest), so a new insert
// shifts every older message's index up by one. If the user has scrolled
// up to an older message (_hist_sel > 0), re-point the selection at that
@@ -660,13 +747,16 @@ public:
if (viewing && _hist_sel > 0) { _hist_sel++; _hist_scroll++; }
return pos;
}
void markChannelRelayed(uint32_t seq) { _history.markChannelRelayed(seq); }
void markChannelRelayed(uint32_t seq, const uint8_t* repeater_hash = nullptr, uint8_t hash_size = 0) {
_history.markChannelRelayed(seq, repeater_hash, hash_size);
}
void armChannelRelay(int pos, uint32_t seq) { _history.armChannelRelay(pos, seq); }
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
uint32_t sender_timestamp = 0, uint32_t ack_tag = 0,
uint32_t ack_deadline_ms = 0, uint8_t resends = 0) {
uint32_t ack_deadline_ms = 0, uint8_t resends = 0,
const uint8_t* path = nullptr, uint8_t path_len = 0) {
bool viewing = (_phase == DM_HIST && memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0);
_history.addDMMsg(pub_key, outgoing, text, sender_timestamp, ack_tag, ack_deadline_ms, resends);
_history.addDMMsg(pub_key, outgoing, text, sender_timestamp, ack_tag, ack_deadline_ms, resends, path, path_len);
if (viewing && _dm_hist_sel > 0) { _dm_hist_sel++; _dm_hist_scroll++; } // see addChannelMsg
}
void markDmDelivered(uint32_t ack_crc) { _history.markDmDelivered(ack_crc); }
@@ -1823,7 +1913,9 @@ public:
if (_dm_fs.active) {
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
if (_showing_path_detail) {
if (res != PopupMenu::NONE) { _ctx_menu.active = false; _showing_path_detail = false; }
} else if (res == PopupMenu::SELECTED) {
dispatchFsAction(false);
} else if (res != PopupMenu::NONE) {
_ctx_menu.active = false;
@@ -1842,14 +1934,16 @@ public:
if (ring_pos >= 0) {
bool reply_ok = !_history.dmAtPos(ring_pos).outgoing;
if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos));
buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok);
buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok, ring_pos, false);
}
}
return true;
}
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
if (_showing_path_detail) {
if (res != PopupMenu::NONE) { _ctx_menu.active = false; _showing_path_detail = false; }
} else if (res == PopupMenu::SELECTED) {
dispatchFsAction(false);
} else if (res != PopupMenu::NONE) {
_ctx_menu.active = false;
@@ -1903,7 +1997,7 @@ public:
if (ring_pos >= 0) {
bool reply_ok = !_history.dmAtPos(ring_pos).outgoing;
if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos));
buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok);
buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok, ring_pos, false);
}
return true;
}
@@ -1913,7 +2007,9 @@ public:
if (_fs.active) {
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
if (_showing_path_detail) {
if (res != PopupMenu::NONE) { _ctx_menu.active = false; _showing_path_detail = false; }
} else if (res == PopupMenu::SELECTED) {
dispatchFsAction(true);
} else if (res != PopupMenu::NONE) {
_ctx_menu.active = false;
@@ -1930,13 +2026,15 @@ public:
} else if (res == FullscreenMsgView::REPLY) {
int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel);
if (ring_pos >= 0)
buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text));
buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text), ring_pos, true);
}
return true;
}
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
if (_showing_path_detail) {
if (res != PopupMenu::NONE) { _ctx_menu.active = false; _showing_path_detail = false; }
} else if (res == PopupMenu::SELECTED) {
dispatchFsAction(true);
} else if (res != PopupMenu::NONE) {
_ctx_menu.active = false;
@@ -1976,7 +2074,7 @@ public:
if (c == KEY_CONTEXT_MENU && _hist_sel >= 0) {
int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel);
if (ring_pos >= 0)
buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text));
buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text), ring_pos, true);
return true;
}
+8 -6
View File
@@ -1766,9 +1766,10 @@ int UITask::getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN],
return ((MessagesScreen*)messages_screen)->getRecentDMContacts(out, max);
}
int UITask::addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp) {
int UITask::addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp,
const uint8_t* path, uint8_t path_len) {
_last_notif_ch_idx = (int)channel_idx;
return ((MessagesScreen*)messages_screen)->addChannelMsg(channel_idx, text, timestamp);
return ((MessagesScreen*)messages_screen)->addChannelMsg(channel_idx, text, timestamp, path, path_len);
}
void UITask::armChannelRelay(int pos, uint32_t seq) {
@@ -1783,8 +1784,8 @@ void UITask::onMsgAck(uint32_t ack_crc) {
((MessagesScreen*)messages_screen)->markDmDelivered(ack_crc);
}
void UITask::onChannelRelayed(uint32_t seq) {
((MessagesScreen*)messages_screen)->markChannelRelayed(seq);
void UITask::onChannelRelayed(uint32_t seq, const uint8_t* repeater_hash, uint8_t hash_size) {
((MessagesScreen*)messages_screen)->markChannelRelayed(seq, repeater_hash, hash_size);
}
void UITask::onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) {
@@ -1806,8 +1807,9 @@ void UITask::onAdminReply(const uint8_t* pub_key, const char* text) {
}
void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp,
uint32_t ack_tag, uint32_t ack_deadline_ms, uint8_t resends) {
((MessagesScreen*)messages_screen)->addDMMsg(pub_key, outgoing, text, sender_timestamp, ack_tag, ack_deadline_ms, resends);
uint32_t ack_tag, uint32_t ack_deadline_ms, uint8_t resends,
const uint8_t* path, uint8_t path_len) {
((MessagesScreen*)messages_screen)->addDMMsg(pub_key, outgoing, text, sender_timestamp, ack_tag, ack_deadline_ms, resends, path, path_len);
}
int UITask::getDMUnreadTotal() const {
+5 -3
View File
@@ -385,12 +385,14 @@ public:
void stopMelody();
bool isMelodyPlaying();
void showAlert(const char* text, int duration_millis);
int addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0) override;
int addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0,
const uint8_t* path = nullptr, uint8_t path_len = 0) override;
void armChannelRelay(int pos, uint32_t seq) override;
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0,
uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, uint8_t resends = 0) override;
uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, uint8_t resends = 0,
const uint8_t* path = nullptr, uint8_t path_len = 0) override;
void onMsgAck(uint32_t ack_crc) override;
void onChannelRelayed(uint32_t seq) override;
void onChannelRelayed(uint32_t seq, const uint8_t* repeater_hash = nullptr, uint8_t hash_size = 0) override;
void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override;
void onAdminReply(const uint8_t* pub_key, const char* text) override;
int getDMUnreadTotal() const;
+14
View File
@@ -1,3 +1,17 @@
## MeshCore Solo Companion Firmware v1.27
### What's new
- **Long names and labels scroll into view when selected instead of staying stuck behind an ellipsis.** Any selected row that would otherwise truncate — contacts, channels, messages, settings values, and more — now pauses, scrolls to reveal the full text, then swings back to the start and repeats, for as long as it stays selected; unselected rows keep the plain static ellipsis. E-ink gets its own slower cadence to suit the panel.
- **Messages sent from the phone app now show up in the device's own chat history**, not just on the phone — open a DM, room, or channel on the device and a message you just sent from the app is right there, with the same delivery-confirmation markers (pending/delivered for DMs, pending/relayed for channels) a message composed on the device itself gets.
- **"Path" / "Relayed by" — see which repeaters actually carried a message.** Hold Enter on a message (list or fullscreen) and, when the data is available, the Options menu gains a new row: **Path (N hops)** on a received message lists the repeaters it actually travelled through, in order; **Relayed by (N)** on your own channel post lists every distinct repeater heard echoing it back into the mesh. Each hop resolves to a contact's name where known, or a short hex tag otherwise.
### Fixes
- **Bluetooth connect/disconnect/reconnect cycles (e.g. a phone drifting in and out of range) could make the device nearly unresponsive for a few seconds at a time**, including briefly after turning BLE off. A failed BLE notify was retried on a fixed short interval, repeatedly hitting a blocking wait deep in the BLE stack while the link stayed marginal. Failed sends now back off exponentially (capped at 2s) instead of hammering the same blocking call.
---
## MeshCore Solo Companion Firmware v1.26
### What's new