Files
MeshCore-Solo/examples/companion_radio/ui-new/MessagesScreen.h

2031 lines
89 KiB
C
Raw Normal View History

#pragma once
// Custom screen — not part of upstream UITask.cpp
// Included by UITask.cpp after SettingsScreen.h is defined.
#include "NavView.h" // navigate to a location shared inside a message
2026-06-14 23:33:16 +02:00
#include "icons.h" // scalable mini-icons (delivery markers)
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
#include "ChannelsView.h" // on-device channel add/edit form (Channels tab)
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
class MessagesScreen : public UIScreen {
UITask* _task;
enum Phase { MODE_SELECT, CONTACT_PICK, DM_HIST, MSG_PICK, CHANNEL_PICK, CHANNEL_HIST, KEYBOARD };
Phase _phase;
// MODE_SELECT
int _mode_sel; // 0=Direct, 1=Channel, 2=Room Servers
// CONTACT_PICK
int _contact_sel, _contact_scroll;
int _num_contacts;
uint16_t _sorted[MAX_CONTACTS];
ContactInfo _sel_contact;
bool _room_mode; // true = picking a room server, false = picking a DM contact
bool _login_mode; // true while KEYBOARD is collecting a room-login password
// CHANNEL_PICK
int _channel_sel, _channel_scroll;
int _num_channels;
uint8_t _channel_indices[MAX_GROUP_CHANNELS];
int _sel_channel_idx;
bool _sending_to_channel;
2026-06-14 23:33:16 +02:00
// Carries the just-sent DM's ACK tag + deadline + send timestamp from
// sendText() to afterSend().
uint32_t _last_ack_tag = 0;
uint32_t _last_ack_deadline_ms = 0;
uint32_t _last_send_ts = 0;
// MSG_PICK (shared)
int _msg_sel, _msg_scroll;
int _active_msgs[QUICK_MSGS_MAX];
int _active_msg_count;
// CHANNEL_HIST — selection + the unread "viewing session" bookkeeping. The
// history ring itself and the per-channel unread counters live in _history.
int _hist_sel, _hist_scroll;
FullscreenMsgView _fs;
int _unread_at_entry; // channel unread count when entering CHANNEL_HIST
int _viewing_max_seen; // highest _hist_sel reached in current session
// KEYBOARD
KeyboardWidget* _kb;
// Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK / histories)
PopupMenu _ctx_menu;
bool _ctx_dirty;
// Channel the open channel-context menu acts on, frozen at open time. The
// Fav toggle can remove the highlighted channel from a fav-only list, so
// re-reading _channel_indices[_channel_sel] mid-interaction could silently
// retarget the menu at a different channel.
uint8_t _ctx_ch_idx = 0;
char _ctx_notif_item[22];
char _ctx_melody_item[20];
char _ctx_pin_item[28]; // "Pin to dial" or "Unpin (slot N)"
char _ctx_ch_fav_item[12]; // "Fav" or "Unfav"
char _pin_slot_labels[NodePrefs::FAVOURITES_COUNT][22]; // per-slot picker labels
bool _pin_picker_active; // true while the slot-picker submenu is open
bool _dm_direct_entry; // entered DM_HIST via Favourites shortcut; CANCEL returns home
char _reply_prefix[36]; // "@[nick] " built when reply is triggered
bool _reply_mode; // true while composing a reply (prefix is prepended)
// 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];
int _fs_act_n = 0;
// Inline navigate-to-location view layered over the fullscreen message.
bool _nav_active = false;
int32_t _nav_lat = 0, _nav_lon = 0;
char _nav_label[24] = "";
// Share-compose mode: launched from elsewhere (e.g. a waypoint) with a
// prepared message; the user picks a recipient and the text lands prefilled
// in the keyboard to confirm/edit before sending.
bool _share_mode = false;
char _share_text[160] = "";
feat(companion): live location sharing, Locator geofencing, trail auto-pause Squash merge of feat/location-beacon-alerts-autopause (v1.21). Features: - Live Location Sharing — broadcast position as movement-gated [LOC] messages to a channel or contact; live shares show as map pins with distance/bearing in Nearby Nodes and a status-bar indicator. [LOC] is parsed in DMs, channel messages and room messages; DM shares name the sender. - Locator (geofence) — arm a geofence around a saved waypoint or a person (live [LOC] or last-known position), alert on arrive/leave or near/far, with an optional homing beeper (gated to arrive/both modes). Arm from Tools > Locator, Nearby Nodes, or Waypoints; target picker lists favourites first, clearable via a "None" entry. Active target is drawn as a flag on the map. - One active target shared across Locator / Navigate / Map via a single resolver (resolvePersonPos / activeTargetPos) that prefers a live [LOC] share over the last-advertised GPS fix. - Follow live contacts — Navigate to a live-sharing contact follows them as they move and adds an ETA line; quick-share your own position from the Map. - Map & status-bar upgrades — home mini-map gets a north marker, scale tick, and a connected trail line (was disconnected dots); status line shows tracked-node count, an arrow + distance to the active Locator/Nav target (falling back to the nearest live-tracked contact); GPS fix icon in the top status bar, shown only on GPS boards with GPS enabled. - Trail auto-pause — recording freezes on stops (banking elapsed time, breaking the map line across the idle gap) and resumes on movement without ending the session. - Streaming trail simplification — GPS points are simplified in-stream via a fixed-corridor (Reumann-Witkam) pass tuned for fidelity: straight runs collapse to their endpoints, curves stay bounded to the Min-dist tolerance, so the 512-point buffer covers a far longer route than a flat point budget would suggest. - Collapsible Tools (Location / Comms / System sections, fold-in-place like Settings) and page-indicator icons on the home carousel. - Waypoint coordinate editor — add a waypoint by scroll-editing lat/lon digit by digit. Fixes: - Critical: low-heap hang and contact loss on RAM-tight builds. Halved message-history scrollback rings (recovering ~14 KB free heap) and made contacts/channels/prefs persistence atomic (temp file + rename), so an interrupted save can no longer corrupt or wipe the store. - Serial.write() bounded so a stalled USB host can't hang the device. - Nearby Nodes: live [LOC] senders respect the type filter, sort by shared position, and the list refreshes so live shares bubble up. - Map: live contacts are labelled before waypoints. - GPS status icon hidden when GPS is off in Settings. - Splash screen no longer truncates a pre-release tag's own dash (e.g. v1.21-rc1) when stripping the build's commit-hash suffix. - Null-guarded the Locator target picker; clamped loc-share channel index on load. Under the hood: - -Os size optimisation on the e-ink and GAT562 30S solo envs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:04:21 +02:00
// Live-share target picker: reuse the recipient chooser to set the persistent
// auto-share target (channel / DM) instead of composing a message.
bool _pick_target = false;
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
// Bot channel picker: same idea, but jumps straight to CHANNEL_PICK (the
// bot only ever targets a channel, never a DM) to set bot_channel_idx.
bool _pick_bot_channel = false;
// Bot room picker: jumps straight to CONTACT_PICK with room_mode on (the
// bot's room target is always a room server) to set bot_room_prefix.
bool _pick_bot_room = false;
// The message-history rings (channel + DM), their per-entry delivery state and
// per-channel unread counters live in this store (see MessageHistory.h). The
// phase machine below keeps only the view state — selection, scroll, the
// fullscreen readers — and reaches entries through _history's accessors. The
// shared types (AckState, ChHistEntry, DmHistEntry, MSG_TEXT_BUF) are file-
// scope, so they're still referred to unqualified throughout this screen.
MessageHistory _history;
// DM_HIST view state (the ring itself is in _history).
int _dm_hist_sel, _dm_hist_scroll;
FullscreenMsgView _dm_fs;
int _hist_visible = 2; // updated in render(); for history list scroll clamping
void expandMsg(const char* tmpl, char* out, int out_len) const {
double lat = 0, lon = 0;
bool gps_valid = false;
#if ENV_INCLUDE_GPS == 1
LocationProvider* loc = sensors.getLocationProvider();
if (loc && loc->isValid()) {
lat = loc->getLatitude() / 1000000.0;
lon = loc->getLongitude() / 1000000.0;
gps_valid = true;
}
#endif
NodePrefs* np = _task->getNodePrefs();
float batt = (float)board.getBattMilliVolts() / 1000.0f;
::expandMsg(tmpl, out, out_len, lat, lon, gps_valid,
rtc_clock.getCurrentTime(),
np ? np->tz_offset_hours : 0,
&sensors, batt);
}
// Scrollbar metrics for a history list. The track is pinned to the full list
// area (top_y..cby) so it never resizes with content; only the thumb sizes and
// moves. `need` also drives the gutter reserve so wide message boxes don't
// reflow as messages arrive.
struct HistScroll { bool need; int reserve; long total_px, scroll_px; int view_px; };
// `getBody(idx)` returns the body text for list item idx (the part that wraps),
// or nullptr to fall back to a fixed 2-line box. Must return exactly what the
// real per-item render pass wraps (sender split off, reply prefix stripped) --
// this function doesn't reprocess it, so a mismatch here just means this sizing
// pass and the real render disagree on line count.
template <class GetBody>
HistScroll computeHistScroll(DisplayDriver& display, bool portrait, int count, int scroll,
int hist_start_y, int cby, int lh, GetBody getBody) {
HistScroll r{};
const int fixed_bh = 2 * lh + 1;
const int top_y = hist_start_y + 1;
r.view_px = cby - top_y; // fixed track height = full list area
if (r.view_px < 1) r.view_px = 1;
const int col = scrollIndicatorColWidth(display);
if (!portrait) { // uniform 2-line boxes → exact pixel math
const int box = fixed_bh + 1;
r.total_px = (long)count * box;
r.scroll_px = (long)scroll * box;
r.need = r.total_px > r.view_px;
r.reserve = r.need ? col : 0;
if (!r.need) r.scroll_px = 0;
return r;
}
const int sp = 2; // portrait inter-box spacing
auto boxH = [&](int idx, int rsv) -> int {
const char* body = getBody(idx);
if (!body) return fixed_bh;
display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans));
int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - rsv, s_wrap_lines, 8);
return (1 + (nl > 0 ? nl : 1)) * lh + 1;
};
// Scrollbar-needed test at the WIDEST layout (reserve 0 → fewest wrap lines →
// shortest total). If even this overflows the list area the gutter is truly
// needed, so it stays put and can't flicker in/out as messages arrive.
int cur = hist_start_y, fit = 0;
for (int i = 0; i < count; i++) { int bh = boxH(i, 0); if (cur + bh > cby) break; fit++; cur += bh + sp; }
r.need = fit < count;
r.reserve = r.need ? col : 0;
if (r.need) { // sum real box heights at the final width
for (int i = 0; i < count; i++) {
long ext = boxH(i, r.reserve) + sp;
r.total_px += ext;
if (i < scroll) r.scroll_px += ext;
}
}
return r;
}
// Strip the "@[nick] " reply prefix for compact list display (body only).
// Shares the one parser with the fullscreen view — see msgReplyBody().
static const char* skipReplyPrefix(const char* text) { return msgReplyBody(text); }
// Split a DM-history entry into the name to show as the author and the body to
// show beneath it. Room servers carry many guests, so incoming room posts are
// stored "Sender: text" (MyMesh::queueMessage); split that off so each line is
// attributed to its guest. Outgoing → "Me"; plain DMs (or no separator) keep
// the contact name and the text unchanged.
//
// Does NOT strip a leading "@[nick] " reply prefix — that's the caller's call,
// and it must be made exactly once: FullscreenMsgView::render() parses it
// itself (for the "To:" header), so callers feeding it must pass this
// function's result straight through; the compact list view has no such
// parsing of its own, so those callers must wrap the result in
// skipReplyPrefix(). Stripping it in here unconditionally used to double-strip
// the DM case (hiding FullscreenMsgView's "To:" header entirely) while never
// stripping the room case (the split happens after this used to run), which is
// why replies' addressee went undetected inconsistently between DM/room/list/
// fullscreen.
const char* dmDisplayParts(const DmHistEntry& e, bool is_room, const char* contact_name,
char* sender_buf, int sender_cap) const {
const char* body = e.text;
if (e.outgoing) {
strncpy(sender_buf, "Me", sender_cap - 1);
} else if (is_room) {
const char* sep = strstr(body, ": ");
if (sep) {
int nl = (int)(sep - body);
if (nl > sender_cap - 1) nl = sender_cap - 1;
strncpy(sender_buf, body, nl);
sender_buf[nl] = '\0';
return sep + 2; // body after "Sender: "
}
strncpy(sender_buf, contact_name, sender_cap - 1);
} else {
strncpy(sender_buf, contact_name, sender_cap - 1);
}
sender_buf[sender_cap - 1] = '\0';
return body;
}
// Build "@[nick] " prefix from a channel message text ("nick: body") into _reply_prefix.
// Returns false if sender is "Me" (own message — no reply prefix needed).
bool buildChannelReplyPrefix(const char* text) {
const char* sep = strstr(text, ": ");
if (!sep) return false;
int slen = (int)(sep - text);
if (slen == 2 && strncmp(text, "Me", 2) == 0) return false;
if (slen > 31) slen = 31;
snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.*s] ", slen, text);
return true;
}
// Build "@[nick] " into _reply_prefix for a reply to a DM/room post, raw
// (UTF-8) so it goes out over the air intact — like buildChannelReplyPrefix,
// and unlike the old per-site code which ran the name through the lossy
// display transliterator. Addresses the same author the history shows
// (dmDisplayParts): in a room every post is stored "Author: text", so the
// addressee is that author, not the room server's own name (_sel_contact.name);
// a plain 1:1 DM uses the contact name. Caller ensures the post is incoming.
void buildDmReplyPrefix(const DmHistEntry& e) {
char nick[32];
dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM, _sel_contact.name, nick, sizeof(nick));
snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", nick);
}
void startReply(bool to_channel) {
_sending_to_channel = to_channel;
_reply_mode = true;
setupMsgPick();
_phase = MSG_PICK;
}
// Recipient chosen while sharing — open the keyboard with the prepared text.
void beginShareCompose(bool channel) {
_sending_to_channel = channel;
_reply_mode = false;
_kb->begin(_share_text);
_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) {
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);
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; }
}
// Dispatch the selected fullscreen-options row. `channel` picks which
// fullscreen view to close when starting a reply.
void dispatchFsAction(bool channel) {
int csel = _ctx_menu.selectedIndex();
FsAct a = (FsAct)_fs_act[(csel >= 0 && csel < _fs_act_n) ? csel : 0];
_ctx_menu.active = false;
if (a == FS_REPLY) {
(channel ? _fs : _dm_fs).active = false;
startReply(channel);
} else if (a == FS_NAV) {
_nav_active = true; // keep the message view active underneath
} else {
saveSharedWaypoint();
}
}
// Save the location parsed from the open message as a waypoint. Uses the
// [WAY] label when present, else auto-names it like a manually-marked point.
void saveSharedWaypoint() {
char label[WAYPOINT_LABEL_LEN];
if (_nav_label[0]) {
strncpy(label, _nav_label, sizeof(label) - 1);
label[sizeof(label) - 1] = '\0';
} else {
snprintf(label, sizeof(label), "WP%d", _task->waypoints().count() + 1);
}
_task->addWaypoint(_nav_lat, _nav_lon, label);
}
void renderNav(DisplayDriver& display) {
int32_t mylat, mylon; bool have = _task->currentLocation(mylat, mylon);
int cog; bool cogv = _task->currentCourse(cog);
NodePrefs* p = _task->getNodePrefs();
navview::draw(display, have, mylat, mylon, _nav_lat, _nav_lon,
_nav_label[0] ? _nav_label : "Msg loc", cogv, cog, p && p->units_imperial);
}
void setupMsgPick() {
_msg_sel = _msg_scroll = 0;
_active_msg_count = 0;
NodePrefs* p = _task->getNodePrefs();
if (p) {
for (int i = 0; i < QUICK_MSGS_MAX; i++) {
if (p->custom_msgs[i][0] != '\0')
_active_msgs[_active_msg_count++] = i;
}
}
}
2026-06-14 23:33:16 +02:00
// Delivery marker, drawn with the current ink colour and auto-scaled to the
// font (see icons.h). Pending = a row of dots, one per send (so it grows with
// each auto-resend); delivered = ✓; failed = ✗; ACK_NONE = nothing.
static void drawAckGlyph(DisplayDriver& d, int x, int top_y, AckState s, int sends = 1) {
switch (s) {
case ACK_PENDING: miniIconDotRow(d, x, top_y, sends); break;
case ACK_OK: miniIconDraw(d, x, top_y, ICON_CHECK); break;
case ACK_FAIL: miniIconDraw(d, x, top_y, ICON_CROSS); break;
2026-06-14 23:33:16 +02:00
default: break; // ACK_NONE → nothing
}
}
// Selection frame for one history message box, shared by the DM/room and
// channel history lists. Selected = solid fill; unselected = outline with a
// filled header strip. Leaves the ink DARK (for the sender row drawn next).
// Spans exactly [box_x, box_x+box_w) — the caller sizes/positions the bubble
// (see computeBubbleBox below), this just draws whatever box it's given.
static void drawHistRowFrame(DisplayDriver& d, int box_x, int box_w, int y, int bh, int lh, bool sel) {
d.setColor(DisplayDriver::LIGHT);
if (sel) {
d.fillRect(box_x, y, box_w, bh);
} else {
d.drawRect(box_x, y, box_w, bh);
d.fillRect(box_x + 1, y + 1, box_w - 2, lh);
}
d.setColor(DisplayDriver::DARK);
}
// Width of an ack/delivery glyph (see drawAckGlyph) — needed up front to size
// an outgoing bubble before it's drawn.
static int ackGlyphWidth(DisplayDriver& d, AckState s, int sends) {
const int sc = miniIconScale(d);
switch (s) {
case ACK_PENDING: return sends * 3 * sc; // dot+gap pitch (icons.h), slightly generous
case ACK_OK: return ICON_CHECK.w * sc;
case ACK_FAIL: return ICON_CROSS.w * sc;
default: return 0;
}
}
// A chat bubble's horizontal extent: shrunk to fit its own content (the
// sender+ack+age header line vs the wrapped/measured body, whichever is
// wider), capped at the full available width, and anchored to the right for
// outgoing ("Me") messages or the left for incoming ones — so which side a
// bubble sits on shows who sent it, the same convention as a typical
// messenger. Everything else in the row (sender, age, ack glyph, body) is
// then drawn relative to this box's own x instead of the screen edge.
struct BubbleBox { int x, w; };
static BubbleBox computeBubbleBox(int full_avail, bool outgoing, int header_w, int body_w) {
int w = header_w > body_w ? header_w : body_w;
if (w > full_avail) w = full_avail;
if (w < 1) w = 1;
return { outgoing ? (full_avail - w) : 0, w };
}
// Bottom-right "[+ send]" compose button, shared by both history lists:
// bordered when idle, inverted (filled) when selected. Right-aligned to
// match the outgoing ("Me") bubbles anchored on that side, so composing
// sits under your own messages rather than the other side's. Box is exactly
// lh tall (no added border padding) — the text has no descenders ("[+
// send]"), so padding sized against the font's full ascent+descent cell
// read as a lopsided 1px-top/3px-bottom gap instead of an even border;
// tight framing avoids that and gives the history list back the 2px. Always
// reset to LIGHT first so it stays visible regardless of the ink the
// message loop left.
static void drawComposeButton(DisplayDriver& d, int cby, int lh, bool sel) {
const char* ctxt = "[+ send]";
int ctw = d.getTextWidth(ctxt);
int bw = ctw + 4;
int bx = d.width() - bw;
d.setColor(DisplayDriver::LIGHT);
if (sel) { d.fillRect(bx, cby, bw, lh); d.setColor(DisplayDriver::DARK); }
else d.drawRect(bx, cby, bw, lh);
d.setCursor(bx + 2, cby);
d.print(ctxt);
d.setColor(DisplayDriver::LIGHT);
}
void afterSend(bool ok, const char* msg) {
_reply_mode = false;
_share_mode = false;
if (ok && _sending_to_channel) {
_hist_sel = 0;
_hist_scroll = 0;
_phase = CHANNEL_HIST; // set before addChannelMsg so viewing=true, no unread bump
char entry[sizeof(ChHistEntry::text)];
snprintf(entry, sizeof(entry), "Me: %s", msg);
2026-06-14 23:33:16 +02:00
int pos = addChannelMsg(_sel_channel_idx, entry);
// Arm the "relayed into mesh" marker on this exact entry — MyMesh tracked
// the flood it just originated and reports a heard repeater echo by seq.
if (pos >= 0) _history.armChannelRelay(pos, the_mesh.lastChannelRelaySeq());
// After inserting sent msg at index 0, the unread index range is stale.
// User is active in this channel — treat as fully read.
_history.setChUnread(_sel_channel_idx, 0);
_unread_at_entry = 0;
_viewing_max_seen = 0;
_task->showAlert("Sent!", 600);
} else if (ok) {
2026-06-14 23:33:16 +02:00
NodePrefs* np = _task->getNodePrefs();
uint8_t resends = np ? np->dm_resend_count : 0;
_history.storeDMMsg(_sel_contact.id.pub_key, true, msg, _last_ack_tag,
_last_ack_deadline_ms, _last_send_ts, resends);
_dm_hist_sel = 0;
_dm_hist_scroll = 0;
_phase = DM_HIST;
_task->showAlert("Sent!", 600);
} else {
_task->showAlert("Send failed", 1500);
_task->gotoHomeScreen();
}
}
bool sendText(const char* msg) {
2026-06-14 23:33:16 +02:00
_last_ack_tag = 0;
_last_ack_deadline_ms = 0;
_last_send_ts = 0;
if (_sending_to_channel) {
ChannelDetails ch;
if (!the_mesh.getChannel(_sel_channel_idx, ch)) return false;
return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel,
the_mesh.getNodeName(), msg, strlen(msg));
} else {
2026-06-14 23:33:16 +02:00
uint32_t send_ts = rtc_clock.getCurrentTime();
uint32_t expected_ack = 0, est_timeout = 0;
2026-06-14 23:33:16 +02:00
bool ok = the_mesh.sendMessage(_sel_contact, send_ts, 0,
msg, expected_ack, est_timeout) > 0;
if (ok && expected_ack) {
_last_send_ts = send_ts;
_last_ack_tag = expected_ack;
// Generous margin over the base estimate so a slow multi-hop ACK isn't
// prematurely shown as failed.
_last_ack_deadline_ms = millis() + est_timeout + 4000;
}
return ok;
}
}
void buildContactList() {
NodePrefs* p = _task->getNodePrefs();
ContactInfo c;
int total = the_mesh.getNumContacts();
_num_contacts = 0;
if (_room_mode) {
bool fav_only = (p && p->room_fav_only);
for (int i = 0; i < total; i++) {
if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_ROOM) continue;
if (fav_only && !(c.flags & 0x01)) continue;
_sorted[_num_contacts++] = i;
}
} else {
bool show_all = (p && p->dm_show_all);
// Build _sorted and counts[] in one pass — avoids a second getContactByIdx loop.
int counts[MAX_CONTACTS];
for (int i = 0; i < total; i++) {
if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue;
if (!show_all && !(c.flags & 0x01)) continue;
counts[_num_contacts] = _history.dmHistCountForContact(c.id.pub_key);
_sorted[_num_contacts++] = i;
}
// Sort by message count descending; contacts with no messages keep original order.
for (int i = 1; i < _num_contacts; i++) {
if (counts[i] == 0) continue;
uint16_t key = _sorted[i]; int kc = counts[i];
int j = i;
while (j > 0 && counts[j-1] < kc) {
_sorted[j] = _sorted[j-1]; counts[j] = counts[j-1]; j--;
}
_sorted[j] = key; counts[j] = kc;
}
}
}
void buildChannelList() {
NodePrefs* p = _task->getNodePrefs();
bool fav_only = (p && p->ch_fav_only);
_num_channels = 0;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue;
if (fav_only && !(p->ch_fav_bitmask & (1ULL << i))) continue;
_channel_indices[_num_channels++] = (uint8_t)i;
}
}
// Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on
// Per-contact/channel notification + melody overrides share two storage
// shapes, so the eight accessors below are thin wrappers over two primitives:
//
// • Channels — a 3-state packed into a pair of channel-index bitmasks: a
// "presence" mask (is there an override at all) + a "variant" mask. The two
// 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) {
NodePrefs* p = _task->getNodePrefs();
if (p) maskPairSet(p->ch_notif_override, p->ch_notif_muted, ch_idx, state, 1);
}
uint8_t dmNotifState(const uint8_t* pub_key) const {
NodePrefs* p = _task->getNodePrefs();
return p ? prefTableGet(p->dm_notif, NodePrefs::DM_NOTIF_TABLE_MAX, pub_key,
&NodePrefs::DmNotifEntry::state) : 0;
}
void setDmNotifState(const uint8_t* pub_key, uint8_t state) {
NodePrefs* p = _task->getNodePrefs();
if (p) prefTableSet(p->dm_notif, NodePrefs::DM_NOTIF_TABLE_MAX, pub_key,
&NodePrefs::DmNotifEntry::state, state);
}
// Channel melody: slot 1 = melody 1 (variant bit clear), 2 = melody 2 (set).
uint8_t chNotifMelody(uint8_t ch_idx) const {
NodePrefs* p = _task->getNodePrefs();
return p ? maskPairGet(p->ch_notif_melody_set, p->ch_notif_melody_2, ch_idx, 2, 1) : 0;
}
void setChNotifMelody(uint8_t ch_idx, uint8_t slot) {
NodePrefs* p = _task->getNodePrefs();
if (p) maskPairSet(p->ch_notif_melody_set, p->ch_notif_melody_2, ch_idx, slot, 2);
}
uint8_t dmMelodySlot(const uint8_t* pub_key) const {
NodePrefs* p = _task->getNodePrefs();
return p ? prefTableGet(p->dm_melody, NodePrefs::DM_MELODY_TABLE_MAX, pub_key,
&NodePrefs::DmMelodyEntry::slot) : 0;
}
void setDmMelody(const uint8_t* pub_key, uint8_t slot) {
NodePrefs* p = _task->getNodePrefs();
if (p) prefTableSet(p->dm_melody, NodePrefs::DM_MELODY_TABLE_MAX, pub_key,
&NodePrefs::DmMelodyEntry::slot, slot);
}
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
// On-device channel Add/Edit form (Channels tab) — owned by this screen and
// delegated to while active(), the same relationship WaypointsView has with
// TrailScreen.
ChannelsView _ch_view;
public:
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
MessagesScreen(UITask* task, KeyboardWidget* kb)
: _task(task), _kb(kb), _phase(MODE_SELECT), _mode_sel(0),
_contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), _login_mode(false),
_channel_sel(0), _channel_scroll(0), _num_channels(0),
_sel_channel_idx(0), _sending_to_channel(false),
_msg_sel(0), _msg_scroll(0), _active_msg_count(0),
_hist_sel(0), _hist_scroll(0),
_unread_at_entry(0), _viewing_max_seen(0),
_dm_hist_sel(-1), _dm_hist_scroll(0),
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
_ctx_dirty(false), _pin_picker_active(false), _dm_direct_entry(false), _reply_mode(false),
_ch_view(task) {
// The history rings + per-channel unread counters init in MessageHistory.
}
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
// First free channel slot (existing config or blank name), or -1 if full.
int findFreeChannelSlot() const {
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') return i;
}
return -1;
}
// CHANNEL_PICK row count including the synthetic "+ Add channel" row
// (suppressed while picking a channel for the bot).
int channelPickTotal() const { return _num_channels + (_pick_bot_channel ? 0 : 1); }
// Public entry points (routed from MyMesh / the bot via UITask) — thin
// forwarders to the history store. addChannelMsg computes the "viewing" flag
// (a phase-machine fact the store can't see) and returns the ring position so
// the outgoing path can attach a relay seq to that exact entry.
int addChannelMsg(uint8_t ch_idx, const char* text, uint32_t timestamp = 0) {
bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx);
feat(admin): typed Radio/Routing field editors; pre-release audit fixes Tools > Admin: split the Radio tab's single "f,bw,sf,cr" comma-string field (free-text keyboard) into 4 independent, type-appropriate rows -- Frequency (same digit-cursor DigitEditor Settings/Repeater use locally), Bandwidth/Spreading factor/Coding rate (discrete-set LEFT/RIGHT stepping), plus TX power and the 3 Routing numeric fields as number steppers and Repeat as an ON/OFF toggle. Edits happen locally (zero mesh traffic per keystroke); only Enter sends one combined `set`, Cancel sends nothing. Full pre-release audit of all 16 commits since v1.22 (5 parallel focus areas: display/font, keyboard/messages, Nodes/Admin, Bot/channels, UITask+NodePrefs schema) turned up and fixed: - Admin: fetched FK_NUMBER values weren't clamped to the field's range, so a value already out-of-range could get stuck unreachable; fixed commit-time float format noise (%.6f -> %.3f); Cancel/failed-login always returned to the Nodes picker even when Admin was opened directly from a node's Hold-Enter action -- now returns to wherever it was actually opened from (AdminScreen::_from_picker). - Keyboard: one-shot Shift was consumed after the *first* T9 multi-tap, so cycling to the 2nd/3rd candidate always came out lowercase -- fixed by caching the cycle's caps state (t9_caps). - Messages: history is numbered newest-first, so a message arriving while scrolled up to an older one silently relabeled the view onto a different message -- selection now shifts with the insert. - Channels: onChannelRemoved() didn't clear ch_notif_override/ ch_notif_muted/ch_fav_bitmask despite its own contract comment requiring it (now far more reachable via the on-device Delete); an all-zero hex secret silently self-deleted the channel it was just saved into (collides with the empty-slot sentinel) -- rejected. - Room login: isRoomLoggedIn() indexed the login-tracking ring directly instead of via its head offset, silently wrong once the ring wraps (8+ rooms/session) -- the new on-device Logout depends on this being right. - Font/display: removed the now fully-inert Settings > Display > Font toggle and the dead LemonFont.h (retired by the earlier misc-fixed font unification, zero remaining includes); fixed a copy-pasted "5x7" comment (font is 6x9), two meaningless dead ternaries, and an OLED/e-ink inconsistency in the undefined-glyph fallback box offset. - AdminField's `kind`/bounds fields no longer rely on default member initializers inside aggregate-init: this toolchain's actual nRF52 build (unlike env:native) has no explicit -std= override, so it predates C++14's aggregate-with-default-member-initializer rule. Given an explicit constructor instead -- portable regardless of standard, all existing field-table literals unchanged. Docs updated to match: tools_screen.md (Diagnostics as a 3-tab carousel, was documented as one flat screen), message_screen.md (chat bubbles, newest-at-bottom, cursor mode, secret validation), settings_screen.md (dropped the dead Font row), solo_ui_framework.md (header menu_hint signatures, icon priority-drop, KeyboardWidget's T9/alphabets/cursor-mode). release-notes.md gains the v1.23 section covering all of the above plus the other 15 commits since v1.22. Build-verified on WioTrackerL1_companion_solo_dual. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 19:38:51 +02:00
int pos = _history.addChannelMsg(ch_idx, text, viewing, timestamp);
// 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
// same message instead of silently relabeling a different one in under
// them. At _hist_sel <= 0 (already at newest, or -1 == compose button
// focused) there's nothing to preserve.
if (viewing && _hist_sel > 0) { _hist_sel++; _hist_scroll++; }
return pos;
2026-06-14 23:33:16 +02:00
}
void markChannelRelayed(uint32_t seq) { _history.markChannelRelayed(seq); }
2026-06-14 23:33:16 +02:00
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
uint32_t sender_timestamp = 0) {
feat(admin): typed Radio/Routing field editors; pre-release audit fixes Tools > Admin: split the Radio tab's single "f,bw,sf,cr" comma-string field (free-text keyboard) into 4 independent, type-appropriate rows -- Frequency (same digit-cursor DigitEditor Settings/Repeater use locally), Bandwidth/Spreading factor/Coding rate (discrete-set LEFT/RIGHT stepping), plus TX power and the 3 Routing numeric fields as number steppers and Repeat as an ON/OFF toggle. Edits happen locally (zero mesh traffic per keystroke); only Enter sends one combined `set`, Cancel sends nothing. Full pre-release audit of all 16 commits since v1.22 (5 parallel focus areas: display/font, keyboard/messages, Nodes/Admin, Bot/channels, UITask+NodePrefs schema) turned up and fixed: - Admin: fetched FK_NUMBER values weren't clamped to the field's range, so a value already out-of-range could get stuck unreachable; fixed commit-time float format noise (%.6f -> %.3f); Cancel/failed-login always returned to the Nodes picker even when Admin was opened directly from a node's Hold-Enter action -- now returns to wherever it was actually opened from (AdminScreen::_from_picker). - Keyboard: one-shot Shift was consumed after the *first* T9 multi-tap, so cycling to the 2nd/3rd candidate always came out lowercase -- fixed by caching the cycle's caps state (t9_caps). - Messages: history is numbered newest-first, so a message arriving while scrolled up to an older one silently relabeled the view onto a different message -- selection now shifts with the insert. - Channels: onChannelRemoved() didn't clear ch_notif_override/ ch_notif_muted/ch_fav_bitmask despite its own contract comment requiring it (now far more reachable via the on-device Delete); an all-zero hex secret silently self-deleted the channel it was just saved into (collides with the empty-slot sentinel) -- rejected. - Room login: isRoomLoggedIn() indexed the login-tracking ring directly instead of via its head offset, silently wrong once the ring wraps (8+ rooms/session) -- the new on-device Logout depends on this being right. - Font/display: removed the now fully-inert Settings > Display > Font toggle and the dead LemonFont.h (retired by the earlier misc-fixed font unification, zero remaining includes); fixed a copy-pasted "5x7" comment (font is 6x9), two meaningless dead ternaries, and an OLED/e-ink inconsistency in the undefined-glyph fallback box offset. - AdminField's `kind`/bounds fields no longer rely on default member initializers inside aggregate-init: this toolchain's actual nRF52 build (unlike env:native) has no explicit -std= override, so it predates C++14's aggregate-with-default-member-initializer rule. Given an explicit constructor instead -- portable regardless of standard, all existing field-table literals unchanged. Docs updated to match: tools_screen.md (Diagnostics as a 3-tab carousel, was documented as one flat screen), message_screen.md (chat bubbles, newest-at-bottom, cursor mode, secret validation), settings_screen.md (dropped the dead Font row), solo_ui_framework.md (header menu_hint signatures, icon priority-drop, KeyboardWidget's T9/alphabets/cursor-mode). release-notes.md gains the v1.23 section covering all of the above plus the other 15 commits since v1.22. Build-verified on WioTrackerL1_companion_solo_dual. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 19:38:51 +02:00
bool viewing = (_phase == DM_HIST && memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0);
_history.addDMMsg(pub_key, outgoing, text, sender_timestamp);
feat(admin): typed Radio/Routing field editors; pre-release audit fixes Tools > Admin: split the Radio tab's single "f,bw,sf,cr" comma-string field (free-text keyboard) into 4 independent, type-appropriate rows -- Frequency (same digit-cursor DigitEditor Settings/Repeater use locally), Bandwidth/Spreading factor/Coding rate (discrete-set LEFT/RIGHT stepping), plus TX power and the 3 Routing numeric fields as number steppers and Repeat as an ON/OFF toggle. Edits happen locally (zero mesh traffic per keystroke); only Enter sends one combined `set`, Cancel sends nothing. Full pre-release audit of all 16 commits since v1.22 (5 parallel focus areas: display/font, keyboard/messages, Nodes/Admin, Bot/channels, UITask+NodePrefs schema) turned up and fixed: - Admin: fetched FK_NUMBER values weren't clamped to the field's range, so a value already out-of-range could get stuck unreachable; fixed commit-time float format noise (%.6f -> %.3f); Cancel/failed-login always returned to the Nodes picker even when Admin was opened directly from a node's Hold-Enter action -- now returns to wherever it was actually opened from (AdminScreen::_from_picker). - Keyboard: one-shot Shift was consumed after the *first* T9 multi-tap, so cycling to the 2nd/3rd candidate always came out lowercase -- fixed by caching the cycle's caps state (t9_caps). - Messages: history is numbered newest-first, so a message arriving while scrolled up to an older one silently relabeled the view onto a different message -- selection now shifts with the insert. - Channels: onChannelRemoved() didn't clear ch_notif_override/ ch_notif_muted/ch_fav_bitmask despite its own contract comment requiring it (now far more reachable via the on-device Delete); an all-zero hex secret silently self-deleted the channel it was just saved into (collides with the empty-slot sentinel) -- rejected. - Room login: isRoomLoggedIn() indexed the login-tracking ring directly instead of via its head offset, silently wrong once the ring wraps (8+ rooms/session) -- the new on-device Logout depends on this being right. - Font/display: removed the now fully-inert Settings > Display > Font toggle and the dead LemonFont.h (retired by the earlier misc-fixed font unification, zero remaining includes); fixed a copy-pasted "5x7" comment (font is 6x9), two meaningless dead ternaries, and an OLED/e-ink inconsistency in the undefined-glyph fallback box offset. - AdminField's `kind`/bounds fields no longer rely on default member initializers inside aggregate-init: this toolchain's actual nRF52 build (unlike env:native) has no explicit -std= override, so it predates C++14's aggregate-with-default-member-initializer rule. Given an explicit constructor instead -- portable regardless of standard, all existing field-table literals unchanged. Docs updated to match: tools_screen.md (Diagnostics as a 3-tab carousel, was documented as one flat screen), message_screen.md (chat bubbles, newest-at-bottom, cursor mode, secret validation), settings_screen.md (dropped the dead Font row), solo_ui_framework.md (header menu_hint signatures, icon priority-drop, KeyboardWidget's T9/alphabets/cursor-mode). release-notes.md gains the v1.23 section covering all of the above plus the other 15 commits since v1.22. Build-verified on WioTrackerL1_companion_solo_dual. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 19:38:51 +02:00
if (viewing && _dm_hist_sel > 0) { _dm_hist_sel++; _dm_hist_scroll++; } // see addChannelMsg
2026-06-14 23:33:16 +02:00
}
void markDmDelivered(uint32_t ack_crc) { _history.markDmDelivered(ack_crc); }
2026-06-14 23:33:16 +02:00
// Rooms successfully logged in to this power-on session. RAM-only — the
// server's ACL (see ClientACL) is the real permission store and survives
// reboot on its own, but the device has no way to query it, so this is just
// a local memo to skip re-prompting for a password already entered this
// session when the same room is picked again.
static const int ROOM_LOGIN_TABLE_SIZE = 8;
uint8_t _room_login_prefix[ROOM_LOGIN_TABLE_SIZE][4];
int _room_login_head = 0, _room_login_count = 0;
bool isRoomLoggedIn(const uint8_t* pub_key) const {
feat(admin): typed Radio/Routing field editors; pre-release audit fixes Tools > Admin: split the Radio tab's single "f,bw,sf,cr" comma-string field (free-text keyboard) into 4 independent, type-appropriate rows -- Frequency (same digit-cursor DigitEditor Settings/Repeater use locally), Bandwidth/Spreading factor/Coding rate (discrete-set LEFT/RIGHT stepping), plus TX power and the 3 Routing numeric fields as number steppers and Repeat as an ON/OFF toggle. Edits happen locally (zero mesh traffic per keystroke); only Enter sends one combined `set`, Cancel sends nothing. Full pre-release audit of all 16 commits since v1.22 (5 parallel focus areas: display/font, keyboard/messages, Nodes/Admin, Bot/channels, UITask+NodePrefs schema) turned up and fixed: - Admin: fetched FK_NUMBER values weren't clamped to the field's range, so a value already out-of-range could get stuck unreachable; fixed commit-time float format noise (%.6f -> %.3f); Cancel/failed-login always returned to the Nodes picker even when Admin was opened directly from a node's Hold-Enter action -- now returns to wherever it was actually opened from (AdminScreen::_from_picker). - Keyboard: one-shot Shift was consumed after the *first* T9 multi-tap, so cycling to the 2nd/3rd candidate always came out lowercase -- fixed by caching the cycle's caps state (t9_caps). - Messages: history is numbered newest-first, so a message arriving while scrolled up to an older one silently relabeled the view onto a different message -- selection now shifts with the insert. - Channels: onChannelRemoved() didn't clear ch_notif_override/ ch_notif_muted/ch_fav_bitmask despite its own contract comment requiring it (now far more reachable via the on-device Delete); an all-zero hex secret silently self-deleted the channel it was just saved into (collides with the empty-slot sentinel) -- rejected. - Room login: isRoomLoggedIn() indexed the login-tracking ring directly instead of via its head offset, silently wrong once the ring wraps (8+ rooms/session) -- the new on-device Logout depends on this being right. - Font/display: removed the now fully-inert Settings > Display > Font toggle and the dead LemonFont.h (retired by the earlier misc-fixed font unification, zero remaining includes); fixed a copy-pasted "5x7" comment (font is 6x9), two meaningless dead ternaries, and an OLED/e-ink inconsistency in the undefined-glyph fallback box offset. - AdminField's `kind`/bounds fields no longer rely on default member initializers inside aggregate-init: this toolchain's actual nRF52 build (unlike env:native) has no explicit -std= override, so it predates C++14's aggregate-with-default-member-initializer rule. Given an explicit constructor instead -- portable regardless of standard, all existing field-table literals unchanged. Docs updated to match: tools_screen.md (Diagnostics as a 3-tab carousel, was documented as one flat screen), message_screen.md (chat bubbles, newest-at-bottom, cursor mode, secret validation), settings_screen.md (dropped the dead Font row), solo_ui_framework.md (header menu_hint signatures, icon priority-drop, KeyboardWidget's T9/alphabets/cursor-mode). release-notes.md gains the v1.23 section covering all of the above plus the other 15 commits since v1.22. Build-verified on WioTrackerL1_companion_solo_dual. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 19:38:51 +02:00
// Indices are relative to _room_login_head, same as forgetRoomLoggedIn() --
// direct 0.._room_login_count indexing only happens to work before the
// ring has wrapped once (head==0); after that it silently checks the wrong
// slots.
for (int i = 0; i < _room_login_count; i++) {
int pos = (_room_login_head + i) % ROOM_LOGIN_TABLE_SIZE;
if (memcmp(_room_login_prefix[pos], pub_key, 4) == 0) return true;
}
return false;
}
void markRoomLoggedIn(const uint8_t* pub_key) {
if (isRoomLoggedIn(pub_key)) return;
int pos;
if (_room_login_count < ROOM_LOGIN_TABLE_SIZE) {
pos = (_room_login_head + _room_login_count) % ROOM_LOGIN_TABLE_SIZE;
_room_login_count++;
} else {
pos = _room_login_head;
_room_login_head = (_room_login_head + 1) % ROOM_LOGIN_TABLE_SIZE;
}
memcpy(_room_login_prefix[pos], pub_key, 4);
}
// Reverses markRoomLoggedIn() on explicit Logout -- shifts the ring buffer
// closed over the removed slot so isRoomLoggedIn() goes back to false and
// the next room open prompts for a password instead of skipping it.
void forgetRoomLoggedIn(const uint8_t* pub_key) {
for (int i = 0; i < _room_login_count; i++) {
int pos = (_room_login_head + i) % ROOM_LOGIN_TABLE_SIZE;
if (memcmp(_room_login_prefix[pos], pub_key, 4) == 0) {
for (int j = i; j < _room_login_count - 1; j++) {
int from = (_room_login_head + j + 1) % ROOM_LOGIN_TABLE_SIZE;
int to = (_room_login_head + j) % ROOM_LOGIN_TABLE_SIZE;
memcpy(_room_login_prefix[to], _room_login_prefix[from], 4);
}
_room_login_count--;
return;
}
}
}
// Password of the room-login attempt currently in flight -- set right
// before sendRoomLogin(), read back in onRoomLoginResult() so a successful
// attempt can be persisted (see MyMesh::saveRoomPassword()).
char _login_pw[16];
void startRoomLogin(const char* password) {
strncpy(_login_pw, password, sizeof(_login_pw) - 1);
_login_pw[sizeof(_login_pw) - 1] = 0;
bool sent = the_mesh.sendRoomLogin(_sel_contact, password);
_task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500);
}
// Result of an on-device sendRoomLogin() (MyMesh::onContactResponse(), routed
// via AbstractUITask::onRoomLoginResult()). Surfaces as a transient alert.
void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) {
(void)permissions;
if (success) {
markRoomLoggedIn(pub_key);
the_mesh.saveRoomPassword(pub_key, _login_pw);
// Auto-enter the room's chat right after a successful login so the user
// doesn't have to press Enter a second time. The login result is async,
// so only do it if they're still sitting on this same room in the picker
// (didn't navigate away, open a menu, or enter a share/pick sub-flow).
if (_phase == CONTACT_PICK && _room_mode && !_ctx_menu.active
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
&& !_share_mode && !_pick_target && !_pick_bot_room
&& memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0) {
openDmHistory();
}
} else {
// Saved password (if any) no longer works -- forget it so the next
// ENTER on this room falls back to a manual prompt instead of
// silently retrying the same bad password forever.
the_mesh.forgetRoomPassword(pub_key);
}
_task->showAlert(success ? "Login OK" : "Login failed", 1200);
}
// Open the message history for the currently selected contact/room and reset
// the scroll/selection view state. Shared by the Enter-on-contact path and the
// post-login auto-enter.
void openDmHistory() {
_task->clearDMUnread(_sel_contact.id.pub_key);
_dm_hist_sel = -1;
_dm_hist_scroll = 0;
_dm_fs.active = false;
_phase = DM_HIST;
}
2026-06-14 23:33:16 +02:00
// Background tick (called every UI loop, regardless of the active screen) that
// drives auto-resend of on-device DMs — forwarded to the history store.
void tickDmResends() { _history.tickDmResends(); }
int getDMUnreadTotal() const {
return _task->getDMUnreadTotal();
}
int getTotalChannelUnread() const { return _history.getTotalChannelUnread(); }
void markReadAlert(int n) {
char msg[32];
snprintf(msg, sizeof(msg), "%d marked read", n);
_task->showAlert(msg, 800);
}
void clearAllChannelUnread() { _history.clearAllChannelUnread(); }
// Update the viewing-session unread bookkeeping (UI state) and push the
// resulting count down into the store. The ring lives in _history, but this
// "what has the user seen on screen" logic is pure phase-machine state.
void updateChannelUnread() {
feat(repeater): politeness controls, dedicated radio profile, and diagnostics Settings > Radio gains a repeater toggle, 16 community-suggested radio presets plus manual Freq/SF/BW/CR tuning (digit-by-digit Freq editor), 4 persisted user preset slots, and the packet pool bumped 16->32 so queued retransmits stop starving incoming-packet allocation while relaying. Four opt-in politeness knobs (skip-advert, max-hops, yield, min-SNR), all flood-only and off by default, plus overhear suppression that cancels a queued retransmit if a peer relays the same packet first. Adaptive Power Control is suppressed while relaying (pins TX power to the ceiling) and duty-cycle RX is forced off, since a repeater needs to hear and relay at consistent power. A dedicated Tools > Repeater screen consolidates the toggle, the politeness knobs, and live forwarding stats, plus an optional "Custom" radio profile — a dedicated frequency/SF/BW/CR for relaying, separate from the companion's own network, band-matched to the companion frequency by default and used everywhere a radio change can happen (boot, on-device toggle, app-driven CMD_SET_RADIO_PARAMS, Settings radio edits) so the device never silently falls back to the wrong params mid-relay. Diagnostics gains the actually-forwarded packet count, real heap-free via mallinfo(), a reset-counters popup, and hardened loop-detect bounds. Also: a frequency-floor fix and a float-equality preset-match fix in the repeater profile logic, deduped BW-table/valCol/profile- seeding helpers shared between Settings and the Repeater screen, and a build.sh fix tolerating control characters in `pio project config`'s JSON dump. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 09:07:00 +02:00
if (_sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return;
// histEntryForChannel is newest-first: index 0 = newest (unread), higher = older.
feat(repeater): politeness controls, dedicated radio profile, and diagnostics Settings > Radio gains a repeater toggle, 16 community-suggested radio presets plus manual Freq/SF/BW/CR tuning (digit-by-digit Freq editor), 4 persisted user preset slots, and the packet pool bumped 16->32 so queued retransmits stop starving incoming-packet allocation while relaying. Four opt-in politeness knobs (skip-advert, max-hops, yield, min-SNR), all flood-only and off by default, plus overhear suppression that cancels a queued retransmit if a peer relays the same packet first. Adaptive Power Control is suppressed while relaying (pins TX power to the ceiling) and duty-cycle RX is forced off, since a repeater needs to hear and relay at consistent power. A dedicated Tools > Repeater screen consolidates the toggle, the politeness knobs, and live forwarding stats, plus an optional "Custom" radio profile — a dedicated frequency/SF/BW/CR for relaying, separate from the companion's own network, band-matched to the companion frequency by default and used everywhere a radio change can happen (boot, on-device toggle, app-driven CMD_SET_RADIO_PARAMS, Settings radio edits) so the device never silently falls back to the wrong params mid-relay. Diagnostics gains the actually-forwarded packet count, real heap-free via mallinfo(), a reset-counters popup, and hardened loop-detect bounds. Also: a frequency-floor fix and a float-equality preset-match fix in the repeater profile logic, deduped BW-table/valCol/profile- seeding helpers shared between Settings and the Repeater screen, and a build.sh fix tolerating control characters in `pio project config`'s JSON dump. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 09:07:00 +02:00
// Count everything actually rendered on screen as seen — not just the
// highlighted row — so a taller screen that fits more boxes at once marks
// more read up front, instead of requiring a press per row.
int seen_to = _hist_scroll + _hist_visible - 1;
if (_hist_sel > seen_to) seen_to = _hist_sel;
if (seen_to > _viewing_max_seen) _viewing_max_seen = seen_to;
// Each step down from 0 sees one more message; seen count = max_seen + 1.
int remaining = _unread_at_entry - (_viewing_max_seen + 1);
_history.setChUnread(_sel_channel_idx, (uint8_t)(remaining > 0 ? remaining : 0));
}
void reset() {
_phase = MODE_SELECT;
_mode_sel = 0;
_contact_sel = _contact_scroll = 0;
_msg_sel = _msg_scroll = 0;
_channel_sel = _channel_scroll = 0;
_sending_to_channel = false;
_room_mode = false;
_login_mode = false;
buildContactList();
buildChannelList();
_ctx_menu.active = false;
_ctx_dirty = false;
_nav_active = false;
_share_mode = false;
feat(companion): live location sharing, Locator geofencing, trail auto-pause Squash merge of feat/location-beacon-alerts-autopause (v1.21). Features: - Live Location Sharing — broadcast position as movement-gated [LOC] messages to a channel or contact; live shares show as map pins with distance/bearing in Nearby Nodes and a status-bar indicator. [LOC] is parsed in DMs, channel messages and room messages; DM shares name the sender. - Locator (geofence) — arm a geofence around a saved waypoint or a person (live [LOC] or last-known position), alert on arrive/leave or near/far, with an optional homing beeper (gated to arrive/both modes). Arm from Tools > Locator, Nearby Nodes, or Waypoints; target picker lists favourites first, clearable via a "None" entry. Active target is drawn as a flag on the map. - One active target shared across Locator / Navigate / Map via a single resolver (resolvePersonPos / activeTargetPos) that prefers a live [LOC] share over the last-advertised GPS fix. - Follow live contacts — Navigate to a live-sharing contact follows them as they move and adds an ETA line; quick-share your own position from the Map. - Map & status-bar upgrades — home mini-map gets a north marker, scale tick, and a connected trail line (was disconnected dots); status line shows tracked-node count, an arrow + distance to the active Locator/Nav target (falling back to the nearest live-tracked contact); GPS fix icon in the top status bar, shown only on GPS boards with GPS enabled. - Trail auto-pause — recording freezes on stops (banking elapsed time, breaking the map line across the idle gap) and resumes on movement without ending the session. - Streaming trail simplification — GPS points are simplified in-stream via a fixed-corridor (Reumann-Witkam) pass tuned for fidelity: straight runs collapse to their endpoints, curves stay bounded to the Min-dist tolerance, so the 512-point buffer covers a far longer route than a flat point budget would suggest. - Collapsible Tools (Location / Comms / System sections, fold-in-place like Settings) and page-indicator icons on the home carousel. - Waypoint coordinate editor — add a waypoint by scroll-editing lat/lon digit by digit. Fixes: - Critical: low-heap hang and contact loss on RAM-tight builds. Halved message-history scrollback rings (recovering ~14 KB free heap) and made contacts/channels/prefs persistence atomic (temp file + rename), so an interrupted save can no longer corrupt or wipe the store. - Serial.write() bounded so a stalled USB host can't hang the device. - Nearby Nodes: live [LOC] senders respect the type filter, sort by shared position, and the list refreshes so live shares bubble up. - Map: live contacts are labelled before waypoints. - GPS status icon hidden when GPS is off in Settings. - Splash screen no longer truncates a pre-release tag's own dash (e.g. v1.21-rc1) when stripping the build's commit-hash suffix. - Null-guarded the Locator target picker; clamped loc-share channel index on load. Under the hood: - -Os size optimisation on the e-ink and GAT562 30S solo envs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:04:21 +02:00
_pick_target = false;
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
_pick_bot_channel = false;
_pick_bot_room = false;
_pin_picker_active = false;
_dm_direct_entry = false;
_unread_at_entry = 0;
_viewing_max_seen = 0;
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
_ch_view.reset();
}
// Recent DM contacts, newest first, deduped (forwarded to the history store).
int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const {
return _history.getRecentDMContacts(out, max);
}
// Jump straight into a contact's DM history (used by the Favourites dial).
// Caller must have already reset() the screen. Marks the entry so KEY_CANCEL
// from DM_HIST returns to the home screen instead of falling back through
// CONTACT_PICK → MODE_SELECT.
// Enter the screen pre-loaded to share `text` (e.g. a "[WAY]lat,lon label"
// waypoint). The user picks Direct/Channel then a recipient; selecting one
// jumps straight to the keyboard prefilled with the text (see beginShareCompose).
void startShare(const char* text) {
reset();
_share_mode = true;
strncpy(_share_text, text, sizeof(_share_text) - 1);
_share_text[sizeof(_share_text) - 1] = '\0';
_phase = MODE_SELECT;
}
feat(companion): live location sharing, Locator geofencing, trail auto-pause Squash merge of feat/location-beacon-alerts-autopause (v1.21). Features: - Live Location Sharing — broadcast position as movement-gated [LOC] messages to a channel or contact; live shares show as map pins with distance/bearing in Nearby Nodes and a status-bar indicator. [LOC] is parsed in DMs, channel messages and room messages; DM shares name the sender. - Locator (geofence) — arm a geofence around a saved waypoint or a person (live [LOC] or last-known position), alert on arrive/leave or near/far, with an optional homing beeper (gated to arrive/both modes). Arm from Tools > Locator, Nearby Nodes, or Waypoints; target picker lists favourites first, clearable via a "None" entry. Active target is drawn as a flag on the map. - One active target shared across Locator / Navigate / Map via a single resolver (resolvePersonPos / activeTargetPos) that prefers a live [LOC] share over the last-advertised GPS fix. - Follow live contacts — Navigate to a live-sharing contact follows them as they move and adds an ETA line; quick-share your own position from the Map. - Map & status-bar upgrades — home mini-map gets a north marker, scale tick, and a connected trail line (was disconnected dots); status line shows tracked-node count, an arrow + distance to the active Locator/Nav target (falling back to the nearest live-tracked contact); GPS fix icon in the top status bar, shown only on GPS boards with GPS enabled. - Trail auto-pause — recording freezes on stops (banking elapsed time, breaking the map line across the idle gap) and resumes on movement without ending the session. - Streaming trail simplification — GPS points are simplified in-stream via a fixed-corridor (Reumann-Witkam) pass tuned for fidelity: straight runs collapse to their endpoints, curves stay bounded to the Min-dist tolerance, so the 512-point buffer covers a far longer route than a flat point budget would suggest. - Collapsible Tools (Location / Comms / System sections, fold-in-place like Settings) and page-indicator icons on the home carousel. - Waypoint coordinate editor — add a waypoint by scroll-editing lat/lon digit by digit. Fixes: - Critical: low-heap hang and contact loss on RAM-tight builds. Halved message-history scrollback rings (recovering ~14 KB free heap) and made contacts/channels/prefs persistence atomic (temp file + rename), so an interrupted save can no longer corrupt or wipe the store. - Serial.write() bounded so a stalled USB host can't hang the device. - Nearby Nodes: live [LOC] senders respect the type filter, sort by shared position, and the list refreshes so live shares bubble up. - Map: live contacts are labelled before waypoints. - GPS status icon hidden when GPS is off in Settings. - Splash screen no longer truncates a pre-release tag's own dash (e.g. v1.21-rc1) when stripping the build's commit-hash suffix. - Null-guarded the Locator target picker; clamped loc-share channel index on load. Under the hood: - -Os size optimisation on the e-ink and GAT562 30S solo envs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:04:21 +02:00
// Open the recipient chooser to set the live-share target (channel/DM). On
// selection the target is stored in NodePrefs and the Map screen is restored.
void startPickTarget() {
reset();
_pick_target = true;
_phase = MODE_SELECT;
}
void commitPickTargetChannel(int ch_idx) {
NodePrefs* p = _task->getNodePrefs();
if (p) {
p->loc_share_target_type = 0;
p->loc_share_channel_idx = (uint8_t)ch_idx;
the_mesh.savePrefs();
}
_pick_target = false;
_task->showAlert("Share target set", 1200);
_task->gotoLiveShareScreen();
}
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
// Open the channel chooser to set the auto-reply bot's channel. Skips
// MODE_SELECT (the bot only ever targets a channel) and lands straight on
// CHANNEL_PICK, browsing real channel names instead of a bare index cycle.
void startPickBotChannel() {
reset();
_pick_bot_channel = true;
_phase = CHANNEL_PICK;
}
void commitPickBotChannel(int ch_idx) {
NodePrefs* p = _task->getNodePrefs();
if (p) {
p->bot_channel_enabled = 1;
p->bot_channel_idx = (uint8_t)ch_idx;
the_mesh.savePrefs();
}
_pick_bot_channel = false;
_task->showAlert("Bot channel set", 1200);
_task->gotoBotScreen();
}
// Open the room chooser to set the auto-reply bot's room. Enter routes
// through the same login handling the normal room-open flow uses (see the
// CONTACT_PICK/room_mode Enter handler below) — the bot can't ever post to
// a room it has no password for, so picking one is a good moment to prompt.
void startPickBotRoom() {
reset();
_pick_bot_room = true;
_room_mode = true;
buildContactList(); // rebuild now that _room_mode is on (reset() built the DM list)
_contact_sel = _contact_scroll = 0;
_phase = CONTACT_PICK;
}
void commitPickBotRoom(const ContactInfo& ci) {
NodePrefs* p = _task->getNodePrefs();
if (p) {
p->bot_room_enabled = 1;
memcpy(p->bot_room_prefix, ci.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
the_mesh.savePrefs();
}
_pick_bot_room = false;
_room_mode = false;
_task->showAlert("Bot room set", 1200);
_task->gotoBotScreen();
}
feat(companion): live location sharing, Locator geofencing, trail auto-pause Squash merge of feat/location-beacon-alerts-autopause (v1.21). Features: - Live Location Sharing — broadcast position as movement-gated [LOC] messages to a channel or contact; live shares show as map pins with distance/bearing in Nearby Nodes and a status-bar indicator. [LOC] is parsed in DMs, channel messages and room messages; DM shares name the sender. - Locator (geofence) — arm a geofence around a saved waypoint or a person (live [LOC] or last-known position), alert on arrive/leave or near/far, with an optional homing beeper (gated to arrive/both modes). Arm from Tools > Locator, Nearby Nodes, or Waypoints; target picker lists favourites first, clearable via a "None" entry. Active target is drawn as a flag on the map. - One active target shared across Locator / Navigate / Map via a single resolver (resolvePersonPos / activeTargetPos) that prefers a live [LOC] share over the last-advertised GPS fix. - Follow live contacts — Navigate to a live-sharing contact follows them as they move and adds an ETA line; quick-share your own position from the Map. - Map & status-bar upgrades — home mini-map gets a north marker, scale tick, and a connected trail line (was disconnected dots); status line shows tracked-node count, an arrow + distance to the active Locator/Nav target (falling back to the nearest live-tracked contact); GPS fix icon in the top status bar, shown only on GPS boards with GPS enabled. - Trail auto-pause — recording freezes on stops (banking elapsed time, breaking the map line across the idle gap) and resumes on movement without ending the session. - Streaming trail simplification — GPS points are simplified in-stream via a fixed-corridor (Reumann-Witkam) pass tuned for fidelity: straight runs collapse to their endpoints, curves stay bounded to the Min-dist tolerance, so the 512-point buffer covers a far longer route than a flat point budget would suggest. - Collapsible Tools (Location / Comms / System sections, fold-in-place like Settings) and page-indicator icons on the home carousel. - Waypoint coordinate editor — add a waypoint by scroll-editing lat/lon digit by digit. Fixes: - Critical: low-heap hang and contact loss on RAM-tight builds. Halved message-history scrollback rings (recovering ~14 KB free heap) and made contacts/channels/prefs persistence atomic (temp file + rename), so an interrupted save can no longer corrupt or wipe the store. - Serial.write() bounded so a stalled USB host can't hang the device. - Nearby Nodes: live [LOC] senders respect the type filter, sort by shared position, and the list refreshes so live shares bubble up. - Map: live contacts are labelled before waypoints. - GPS status icon hidden when GPS is off in Settings. - Splash screen no longer truncates a pre-release tag's own dash (e.g. v1.21-rc1) when stripping the build's commit-hash suffix. - Null-guarded the Locator target picker; clamped loc-share channel index on load. Under the hood: - -Os size optimisation on the e-ink and GAT562 30S solo envs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:04:21 +02:00
void commitPickTargetDM(const ContactInfo& ci) {
// A room server can't be a live-share target — a [LOC] DM to it is never
// reposted to the room's members. Reject the pick and keep the chooser open.
if (ci.type == ADV_TYPE_ROOM) {
_task->showAlert("Rooms not supported", 1400);
return;
}
NodePrefs* p = _task->getNodePrefs();
if (p) {
p->loc_share_target_type = 1;
memcpy(p->loc_share_dm_prefix, ci.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
the_mesh.savePrefs();
}
_pick_target = false;
_task->showAlert("Share target set", 1200);
_task->gotoLiveShareScreen();
}
void enterDM(const ContactInfo& ci) {
_sel_contact = ci;
_task->clearDMUnread(ci.id.pub_key);
_dm_hist_sel = -1;
_dm_hist_scroll = 0;
_dm_fs.active = false;
_room_mode = false;
_phase = DM_HIST;
_dm_direct_entry = true;
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
// Channel Add/Edit form owns the screen while active.
if (_ch_view.active()) return _ch_view.render(display);
// Navigate-to-location view sits over everything else while active.
if (_nav_active) { renderNav(display); return 1000; }
int lh = display.getLineHeight();
int item_h = display.lineStep();
int start_y = display.listStart();
if (_phase == MODE_SELECT) {
display.drawCenteredHeader("MESSAGE", true, _ctx_menu.active);
const char* opts[] = { "Direct message", "Channels", "Room Servers" };
int badges[3] = {
getDMUnreadTotal(),
_task->getChannelUnreadCount(),
_task->getRoomUnreadCount()
};
for (int i = 0; i < 3; i++) {
int y = start_y + i * item_h;
bool sel = (i == _mode_sel);
display.drawSelectionRow(0, y - 1, display.width(), item_h - 1, sel);
display.setCursor(2, y);
display.print(opts[i]);
if (badges[i] > 0)
display.drawUnreadBadge(display.width() - 1, y, badges[i], sel);
}
display.setColor(DisplayDriver::LIGHT);
if (_ctx_menu.active) _ctx_menu.render(display);
} else if (_phase == CONTACT_PICK) {
display.drawCenteredHeader(_room_mode ? "SELECT ROOM" : "SELECT CONTACT", true, _ctx_menu.active);
if (_num_contacts == 0) {
display.drawTextCentered(display.width()/2, display.height()/2, _room_mode ? "No room servers" : "No favourites");
return 5000;
}
drawList(display, _num_contacts, _contact_sel, _contact_scroll, [&](int list_idx, int y, bool sel, int reserve) {
int mesh_idx = _sorted[list_idx];
drawRowSelection(display, y, sel, reserve);
ContactInfo c;
if (the_mesh.getContactByIdx(mesh_idx, c)) {
char filtered[sizeof(c.name)];
display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered));
uint8_t dm_unread = _task->getDMUnread(c.id.pub_key);
int bw = dm_unread > 0 ? display.unreadBadgeWidth(dm_unread) + 2 : 0;
display.drawTextEllipsized(2, y, display.width() - 2 - bw - reserve, filtered);
if (dm_unread > 0)
display.drawUnreadBadge(display.width() - reserve, y, dm_unread, sel);
}
});
// Context menu overlay
if (_ctx_menu.active) _ctx_menu.render(display);
} else if (_phase == CHANNEL_PICK) {
display.drawCenteredHeader("SELECT CHANNEL", true, _ctx_menu.active);
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
// "+ Add channel" is a synthetic trailing row — suppressed while picking
// a channel for the bot, so that picker's list stays unchanged.
bool show_add = !_pick_bot_channel;
int total = _num_channels + (show_add ? 1 : 0);
if (total == 0) {
display.drawTextCentered(display.width()/2, display.height()/2, "No channels");
return 5000;
}
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
drawList(display, total, _channel_sel, _channel_scroll, [&](int list_idx, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
if (list_idx == _num_channels) { // the synthetic "Add" row
display.setCursor(2, y); display.print("+ Add channel");
display.setColor(DisplayDriver::LIGHT);
return;
}
ChannelDetails ch;
if (the_mesh.getChannel(_channel_indices[list_idx], ch)) {
uint8_t unread = _history.chUnread(_channel_indices[list_idx]);
int bw = unread > 0 ? display.unreadBadgeWidth(unread) + 2 : 0;
display.drawTextEllipsized(2, y, display.width() - 4 - bw - reserve, ch.name);
if (unread > 0)
display.drawUnreadBadge(display.width() - reserve, y, unread, sel);
}
});
// Context menu overlay
if (_ctx_menu.active) _ctx_menu.render(display);
} else if (_phase == DM_HIST) {
display.setTextSize(1);
char filtered_name[sizeof(_sel_contact.name)];
display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name));
if (_dm_fs.active && _dm_hist_sel >= 0) {
int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
if (ring_pos >= 0) {
const DmHistEntry& e = _history.dmAtPos(ring_pos);
char sender_buf[33];
// No skipReplyPrefix() here -- _dm_fs.render() parses "@[nick] " itself
// (for the "To:" header); stripping it here first would hide it there.
const char* body = dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM,
filtered_name, sender_buf, sizeof(sender_buf));
const char* sender = sender_buf;
int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key);
int ret = _dm_fs.render(display, sender, body,
_dm_hist_sel < dm_count - 1,
_dm_hist_sel > 0);
2026-06-14 23:33:16 +02:00
if (e.outgoing) { // delivery marker in the (inverted) header bar
display.setColor(DisplayDriver::DARK);
drawAckGlyph(display, 2 + display.getTextWidth(sender) + 3, 1,
_history.dmEffectiveStatus(e), e.attempt + 1);
2026-06-14 23:33:16 +02:00
display.setColor(DisplayDriver::LIGHT);
}
if (_ctx_menu.active) _ctx_menu.render(display);
return ret;
}
return 500;
}
int hist_start_y = display.headerH();
int cby = display.height() - lh; // compose button is now exactly lh tall, no added border padding
char title[24];
snprintf(title, sizeof(title), "%.23s", filtered_name);
display.drawCenteredHeader(title, true, _ctx_menu.active);
int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key);
uint32_t now_ts = rtc_clock.getCurrentTime();
bool is_room = (_sel_contact.type == ADV_TYPE_ROOM);
// Portrait e-ink (height > width): variable-height boxes that show the full
// wrapped message text. All other displays/orientations: compact 2-line boxes.
bool portrait_expand = (display.height() > display.width());
const int MAX_VIS_BOXES = 8;
int box_ys[MAX_VIS_BOXES], box_hs[MAX_VIS_BOXES], n_vis = 0;
// Fixed-track scrollbar metrics + a stable gutter reserve (decided by a
// whole-list fit test, not last frame's visible count) so message boxes
// don't reflow their width as messages arrive.
HistScroll hs = computeHistScroll(display, portrait_expand, dm_count, _dm_hist_scroll,
hist_start_y, cby, lh,
[&](int idx) -> const char* {
int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx);
if (rp < 0) return nullptr;
// Must match the per-item body extraction below (dmDisplayParts +
// skipReplyPrefix) exactly, or this sizing pass and the real render
// pass disagree on wrapped line count for room posts -- previously
// this returned the raw "Sender: text" unsplit, wrapping the sender
// name in with the body and mis-sizing the box.
char tmp_sender[33];
return skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name,
tmp_sender, sizeof(tmp_sender)));
});
int reserve = hs.reserve;
{
// Stack boxes upward from just above the compose row, so item 0 (the
// newest message) lands at the bottom of the list and older messages
// sit progressively higher — same anchor convention as a typical
// messenger, instead of newest-at-top. box_ys[i] still corresponds
// to item (_dm_hist_scroll + i), same as before; only its y flips.
const int fixed_bh = 2 * lh + 1;
const int box_gap = portrait_expand ? 2 : 1;
int cur_y = cby - box_gap; // reserve the same gap against compose as between boxes
for (int ii = 0; ii < MAX_VIS_BOXES && (_dm_hist_scroll + ii) < dm_count; ii++) {
int bh = fixed_bh;
if (portrait_expand) {
int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_scroll + ii);
if (rp >= 0) {
char hsb[33];
const char* hbody = skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, hsb, sizeof(hsb)));
display.translateUTF8ToBlocks(s_wrap_trans, hbody, sizeof(s_wrap_trans));
int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8);
bh = (1 + (nl > 0 ? nl : 1)) * lh + 1;
}
}
int box_top = cur_y - bh;
if (box_top < hist_start_y) break;
box_ys[n_vis] = box_top; box_hs[n_vis] = bh; n_vis++;
cur_y = box_top - box_gap;
}
}
_hist_visible = n_vis;
for (int i = 0; i < n_vis && (_dm_hist_scroll + i) < dm_count; i++) {
int item = _dm_hist_scroll + i;
bool sel = (item == _dm_hist_sel);
int y = box_ys[i];
int bh = box_hs[i];
int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, item);
if (ring_pos < 0) continue;
const DmHistEntry& e = _history.dmAtPos(ring_pos);
char sender_buf[33];
const char* body = skipReplyPrefix(dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf)));
const char* sender = sender_buf;
char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp);
int age_w = age[0] ? display.getTextWidth(age) + 3 : 0;
// Size the bubble to its own content before drawing anything (see
// computeBubbleBox): the header (sender+ack+age) vs the body, measured
// once here and reused below instead of re-wrapping.
int full_avail = display.width() - reserve;
int ack_w = e.outgoing ? (3 + ackGlyphWidth(display, _history.dmEffectiveStatus(e), e.attempt + 1)) : 0;
int header_w = 3 + display.getTextWidth(sender) + ack_w + age_w + 3;
int body_w, nl = 0;
if (portrait_expand) {
display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans));
nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, full_avail - 6, s_wrap_lines, 8);
body_w = 0;
for (int li = 0; li < nl; li++) { int w = display.getTextWidth(s_wrap_lines[li]); if (w > body_w) body_w = w; }
body_w += 6;
} else {
int raw_w = display.getTextWidth(body);
body_w = (raw_w > full_avail - 6 ? full_avail - 6 : raw_w) + 6;
}
BubbleBox box = computeBubbleBox(full_avail, e.outgoing, header_w, body_w);
drawHistRowFrame(display, box.x, box.w, y, bh, lh, sel);
display.drawTextEllipsized(box.x + 3, y + 1, box.w - 6 - age_w, sender);
2026-06-14 23:33:16 +02:00
if (e.outgoing) { // delivery marker after "Me"
int gx = box.x + 3 + display.getTextWidth(sender) + 3;
drawAckGlyph(display, gx, y + 1, _history.dmEffectiveStatus(e), e.attempt + 1);
2026-06-14 23:33:16 +02:00
}
if (age[0]) { display.setCursor(box.x + box.w - age_w, y + 1); display.print(age); }
if (!sel) display.setColor(DisplayDriver::LIGHT);
if (portrait_expand) {
for (int li = 0; li < nl; li++) { display.setCursor(box.x + 3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); }
} else {
display.drawTextEllipsized(box.x + 3, y + lh + 1, box.w - 6, body);
}
}
if (dm_count == 0) {
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet");
}
// Scrollbar: track pinned to the full list area; thumb sized/positioned
// from hs's pixel metrics (stable while scrolling the same list).
// hs.scroll_px counts index-space distance from item 0 (newest); since
// item 0 now renders at the bottom, invert it here so the thumb sits at
// the bottom for the newest and rises as you scroll into older history.
if (hs.need) {
long span = hs.total_px - hs.view_px;
long inv_scroll_px = span - hs.scroll_px;
if (inv_scroll_px < 0) inv_scroll_px = 0;
drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px,
hs.total_px, hs.view_px, inv_scroll_px);
}
drawComposeButton(display, cby, lh, _dm_hist_sel == -1);
if (_ctx_menu.active) _ctx_menu.render(display);
return dm_count > 0 ? 500 : 2000;
} else if (_phase == CHANNEL_HIST) {
if (_fs.active && _hist_sel >= 0) {
int fs_hist_count = _history.histCountForChannel(_sel_channel_idx);
int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel);
if (ring_pos >= 0) {
const char* ftext = _history.chAtPos(ring_pos).text;
const char* fsep = strstr(ftext, ": ");
char fsender[33], fmsg[MSG_TEXT_BUF];
if (fsep) {
int nl = fsep - ftext; if (nl > (int)sizeof(fsender) - 1) nl = sizeof(fsender) - 1;
strncpy(fsender, ftext, nl); fsender[nl] = '\0';
strncpy(fmsg, fsep + 2, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0';
} else {
strcpy(fsender, "?");
strncpy(fmsg, ftext, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0';
}
int ret = _fs.render(display, fsender, fmsg,
_hist_sel < fs_hist_count - 1,
_hist_sel > 0);
2026-06-14 23:33:16 +02:00
// Channels: ✓ only once a repeater echo confirms relay (see list view).
if (strcmp(fsender, "Me") == 0 && _history.chAtPos(ring_pos).relay_status == ACK_OK) {
2026-06-14 23:33:16 +02:00
display.setColor(DisplayDriver::DARK);
drawAckGlyph(display, 2 + display.getTextWidth(fsender) + 3, 1, ACK_OK);
display.setColor(DisplayDriver::LIGHT);
}
if (_ctx_menu.active) _ctx_menu.render(display);
return ret;
}
return 2000;
}
int hist_start_y = display.headerH();
int cby = display.height() - lh; // compose button is now exactly lh tall, no added border padding
ChannelDetails ch;
the_mesh.getChannel(_sel_channel_idx, ch);
char title[24];
snprintf(title, sizeof(title), "%.23s", ch.name);
display.drawCenteredHeader(title, true, _ctx_menu.active);
int ch_hist_count = _history.histCountForChannel(_sel_channel_idx);
uint32_t now_ts = rtc_clock.getCurrentTime();
// Portrait e-ink (height > width): variable-height boxes that show the full
// wrapped message text. All other displays/orientations: compact 2-line boxes.
bool portrait_expand = (display.height() > display.width());
const int MAX_VIS_BOXES = 8;
int box_ys[MAX_VIS_BOXES], box_hs[MAX_VIS_BOXES], n_vis = 0;
// Fixed-track scrollbar metrics + stable gutter reserve (see DM history above).
HistScroll hs = computeHistScroll(display, portrait_expand, ch_hist_count, _hist_scroll,
hist_start_y, cby, lh,
[&](int idx) -> const char* {
int rp = _history.histEntryForChannel(_sel_channel_idx, idx);
if (rp < 0) return nullptr;
const char* t = _history.chAtPos(rp).text;
const char* s = strstr(t, ": ");
return skipReplyPrefix(s ? s + 2 : t);
});
int reserve = hs.reserve;
{
// Stack boxes upward from just above the compose row — see the DM
// history block above for why (newest at the bottom, like a typical
// messenger). box_ys[i] still corresponds to item (_hist_scroll + i).
const int fixed_bh = 2 * lh + 1;
const int box_gap = portrait_expand ? 2 : 1;
int cur_y = cby - box_gap; // reserve the same gap against compose as between boxes
for (int ii = 0; ii < MAX_VIS_BOXES && (_hist_scroll + ii) < ch_hist_count; ii++) {
int bh = fixed_bh;
if (portrait_expand) {
int rp = _history.histEntryForChannel(_sel_channel_idx, _hist_scroll + ii);
if (rp >= 0) {
const char* rtext = _history.chAtPos(rp).text;
const char* rsep = strstr(rtext, ": ");
const char* rbody = rsep ? rsep + 2 : rtext;
display.translateUTF8ToBlocks(s_wrap_trans, skipReplyPrefix(rbody), sizeof(s_wrap_trans));
int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8);
bh = (1 + (nl > 0 ? nl : 1)) * lh + 1;
}
}
int box_top = cur_y - bh;
if (box_top < hist_start_y) break;
box_ys[n_vis] = box_top; box_hs[n_vis] = bh; n_vis++;
cur_y = box_top - box_gap;
}
}
_hist_visible = n_vis;
feat(repeater): politeness controls, dedicated radio profile, and diagnostics Settings > Radio gains a repeater toggle, 16 community-suggested radio presets plus manual Freq/SF/BW/CR tuning (digit-by-digit Freq editor), 4 persisted user preset slots, and the packet pool bumped 16->32 so queued retransmits stop starving incoming-packet allocation while relaying. Four opt-in politeness knobs (skip-advert, max-hops, yield, min-SNR), all flood-only and off by default, plus overhear suppression that cancels a queued retransmit if a peer relays the same packet first. Adaptive Power Control is suppressed while relaying (pins TX power to the ceiling) and duty-cycle RX is forced off, since a repeater needs to hear and relay at consistent power. A dedicated Tools > Repeater screen consolidates the toggle, the politeness knobs, and live forwarding stats, plus an optional "Custom" radio profile — a dedicated frequency/SF/BW/CR for relaying, separate from the companion's own network, band-matched to the companion frequency by default and used everywhere a radio change can happen (boot, on-device toggle, app-driven CMD_SET_RADIO_PARAMS, Settings radio edits) so the device never silently falls back to the wrong params mid-relay. Diagnostics gains the actually-forwarded packet count, real heap-free via mallinfo(), a reset-counters popup, and hardened loop-detect bounds. Also: a frequency-floor fix and a float-equality preset-match fix in the repeater profile logic, deduped BW-table/valCol/profile- seeding helpers shared between Settings and the Repeater screen, and a build.sh fix tolerating control characters in `pio project config`'s JSON dump. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 09:07:00 +02:00
updateChannelUnread(); // mark everything in the just-computed visible window as seen
for (int i = 0; i < n_vis && (_hist_scroll + i) < ch_hist_count; i++) {
int item = _hist_scroll + i;
bool sel = (item == _hist_sel);
int y = box_ys[i];
int bh = box_hs[i];
int ring_pos = _history.histEntryForChannel(_sel_channel_idx, item);
if (ring_pos < 0) continue;
const char* text = _history.chAtPos(ring_pos).text;
const char* sep = strstr(text, ": ");
char sender[33], msg_part[MSG_TEXT_BUF];
if (sep) {
int nl = sep - text; if (nl > (int)sizeof(sender) - 1) nl = sizeof(sender) - 1;
strncpy(sender, text, nl); sender[nl] = '\0';
strncpy(msg_part, sep + 2, sizeof(msg_part) - 1);
msg_part[sizeof(msg_part) - 1] = '\0';
} else {
strcpy(sender, "?");
strncpy(msg_part, text, sizeof(msg_part) - 1);
msg_part[sizeof(msg_part) - 1] = '\0';
}
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;
const char* body = skipReplyPrefix(msg_part);
// Size the bubble to its own content before drawing anything (see
// computeBubbleBox): the header (sender+ack+age) vs the body, measured
// once here and reused below instead of re-wrapping. Channels have no
// recipient ACK — only show ✓ once a repeater echo confirms the send
// was relayed into the mesh; otherwise no marker (absence is normal).
bool outgoing = strcmp(sender, "Me") == 0;
bool show_ack = outgoing && _history.chAtPos(ring_pos).relay_status == ACK_OK;
int full_avail = display.width() - reserve;
int ack_w = show_ack ? (3 + ackGlyphWidth(display, ACK_OK, 1)) : 0;
int header_w = 3 + display.getTextWidth(sender) + ack_w + age_w + 3;
int body_w, nl = 0;
if (portrait_expand) {
display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans));
nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, full_avail - 6, s_wrap_lines, 8);
body_w = 0;
for (int li = 0; li < nl; li++) { int w = display.getTextWidth(s_wrap_lines[li]); if (w > body_w) body_w = w; }
body_w += 6;
} else {
int raw_w = display.getTextWidth(body);
body_w = (raw_w > full_avail - 6 ? full_avail - 6 : raw_w) + 6;
}
BubbleBox box = computeBubbleBox(full_avail, outgoing, header_w, body_w);
drawHistRowFrame(display, box.x, box.w, y, bh, lh, sel);
display.drawTextEllipsized(box.x + 3, y + 1, box.w - 6 - age_w, sender);
if (show_ack) {
int gx = box.x + 3 + display.getTextWidth(sender) + 3;
2026-06-14 23:33:16 +02:00
drawAckGlyph(display, gx, y + 1, ACK_OK);
}
if (age[0]) { display.setCursor(box.x + box.w - age_w, y + 1); display.print(age); }
if (!sel) display.setColor(DisplayDriver::LIGHT);
if (portrait_expand) {
for (int li = 0; li < nl; li++) { display.setCursor(box.x + 3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); }
} else {
display.drawTextEllipsized(box.x + 3, y + lh + 1, box.w - 6, body);
}
}
if (ch_hist_count == 0) {
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet");
}
// Scrollbar: track pinned to the full list area; thumb from hs metrics.
// Inverted for the same reason as the DM history block above — item 0
// (newest) renders at the bottom, so the thumb should start at the
// bottom too and rise as you scroll into older history.
if (hs.need) {
long span = hs.total_px - hs.view_px;
long inv_scroll_px = span - hs.scroll_px;
if (inv_scroll_px < 0) inv_scroll_px = 0;
drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px,
hs.total_px, hs.view_px, inv_scroll_px);
}
drawComposeButton(display, cby, lh, _hist_sel == -1);
if (_ctx_menu.active) _ctx_menu.render(display);
} else if (_phase == KEYBOARD) {
return _kb->render(display);
} else { // MSG_PICK
char title[24];
if (_reply_mode) {
int rlen = (int)strlen(_reply_prefix) - 4; // exclude "@[" and "] "
if (rlen < 0) rlen = 0;
if (rlen > 20) rlen = 20;
char nick_raw[32], nick_trans[32];
snprintf(nick_raw, sizeof(nick_raw), "%.*s", rlen, _reply_prefix + 2);
display.translateUTF8ToBlocks(nick_trans, nick_raw, sizeof(nick_trans));
snprintf(title, sizeof(title), "RE:%s", nick_trans);
} else if (_sending_to_channel) {
ChannelDetails ch;
the_mesh.getChannel(_sel_channel_idx, ch);
snprintf(title, sizeof(title), "%.23s", ch.name);
} else {
snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name);
}
display.drawCenteredHeader(title);
int total_msg_items = 1 + _active_msg_count;
drawList(display, total_msg_items, _msg_sel, _msg_scroll, [&](int idx, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
if (idx == 0) {
display.setCursor(2, y);
display.print("Custom message...");
} else {
NodePrefs* p = _task->getNodePrefs();
int slot = _active_msgs[idx - 1];
const char* tmpl = p ? p->custom_msgs[slot] : "";
display.drawTextEllipsized(2, y, display.width() - 4 - reserve, tmpl);
}
});
}
return 2000;
}
bool handleInput(char c) override {
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
// Channel Add/Edit form consumes all input while active.
if (_ch_view.active()) return _ch_view.handleInput(c);
// Navigate view: any back key returns to the message it was opened from.
if (_nav_active) {
if (c == KEY_CANCEL || c == KEY_ENTER || c == KEY_CONTEXT_MENU) _nav_active = false;
return true;
}
if (_phase == MODE_SELECT) {
// Context menu (Mark-all-read) takes precedence while active.
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
int cleared = 0;
if (_mode_sel == 0) {
cleared = getDMUnreadTotal();
_task->clearAllDMUnread();
} else if (_mode_sel == 1) {
cleared = _task->getChannelUnreadCount();
clearAllChannelUnread();
} else {
cleared = _task->getRoomUnreadCount();
_task->clearRoomUnread();
}
markReadAlert(cleared);
}
return true;
}
if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; }
if (c == KEY_UP) { _mode_sel = (_mode_sel > 0) ? _mode_sel - 1 : 2; return true; }
if (c == KEY_DOWN) { _mode_sel = (_mode_sel < 2) ? _mode_sel + 1 : 0; return true; }
if (c == KEY_CONTEXT_MENU) {
// PopupMenu stores the title pointer verbatim — use static strings.
static const char* MODE_TITLES[] = { "DM options", "Channel options", "Room options" };
_ctx_menu.begin(MODE_TITLES[_mode_sel < 3 ? _mode_sel : 0], 1);
_ctx_menu.addItem("Mark all read");
return true;
}
if (c == KEY_ENTER) {
if (_mode_sel == 1) {
_phase = CHANNEL_PICK;
} else {
_room_mode = (_mode_sel == 2);
if (_room_mode) _task->clearRoomUnread();
buildContactList();
_contact_sel = _contact_scroll = 0;
_phase = CONTACT_PICK;
}
return true;
}
} else if (_phase == CONTACT_PICK) {
// Context menu consumes all input while open
if (_ctx_menu.active) {
if (_room_mode) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED && _num_contacts > 0) {
if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) {
int sel = _ctx_menu.selectedIndex();
if (sel == 0) {
_login_mode = true;
_kb->begin("", 15); // room/repeater password: max 15 chars
_kb->clearPlaceholders(); // {loc}/{time} are for messages, not a password
_phase = KEYBOARD;
} else {
// Logout: only reachable when isRoomLoggedIn() added this item.
the_mesh.logoutRoom(_sel_contact.id.pub_key);
forgetRoomLoggedIn(_sel_contact.id.pub_key);
_task->showAlert("Logged out", 1000);
}
}
}
return true;
}
// LEFT/RIGHT cycle Notif/Melody in-place (menu stays open).
if (!_pin_picker_active && _num_contacts > 0) {
bool left = keyIsPrev(c);
bool right = keyIsNext(c);
if (left || right) {
static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" };
static const char* ML[] = { "global", "M1", "M2" };
ContactInfo ci;
if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) {
int sel = _ctx_menu.selectedIndex();
if (sel == 1) {
uint8_t v = dmNotifState(ci.id.pub_key);
v = right ? (v + 1) % 3 : (v + 2) % 3;
setDmNotifState(ci.id.pub_key, v);
snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", NOTIF_LABELS[v]);
_ctx_dirty = true;
} else if (sel == 2) {
uint8_t v = dmMelodySlot(ci.id.pub_key);
v = right ? (v + 1) % 3 : (v + 2) % 3;
setDmMelody(ci.id.pub_key, v);
snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", ML[v]);
_ctx_dirty = true;
}
}
return true;
}
}
auto res = _ctx_menu.handleInput(c);
if (_pin_picker_active) {
// Slot picker sub-menu: index 0..FAVOURITES_COUNT-1 maps directly to slot.
if (res == PopupMenu::SELECTED && _num_contacts > 0) {
ContactInfo ci;
if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) {
int slot = _ctx_menu.selectedIndex();
_task->setFavouriteSlot(slot, ci.id.pub_key);
the_mesh.savePrefs();
char alert[24];
snprintf(alert, sizeof(alert), "Pinned to slot %d", slot + 1);
_task->showAlert(alert, 800);
}
}
if (res != PopupMenu::NONE) _pin_picker_active = false;
return true;
}
if (res == PopupMenu::SELECTED && _num_contacts > 0) {
ContactInfo ci;
if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) {
int sel = _ctx_menu.selectedIndex();
if (sel == 0) {
int cleared = (int)_task->getDMUnread(ci.id.pub_key);
_task->clearDMUnread(ci.id.pub_key);
markReadAlert(cleared);
} else if (sel == 3) {
// Pin / Unpin
int pinned_slot = _task->findFavouriteSlot(ci.id.pub_key);
if (pinned_slot >= 0) {
_task->clearFavouriteSlot(pinned_slot);
the_mesh.savePrefs();
char alert[24];
snprintf(alert, sizeof(alert), "Unpinned (slot %d)", pinned_slot + 1);
_task->showAlert(alert, 800);
} else {
for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) {
if (_task->isFavouriteSlotEmpty(s)) {
snprintf(_pin_slot_labels[s], sizeof(_pin_slot_labels[s]), "Slot %d: empty", s + 1);
} else {
char nm[16] = "?";
NodePrefs* p = _task->getNodePrefs();
if (p) {
const uint8_t* pfx = p->favourite_contacts[s];
for (int idx = 0; ; idx++) {
ContactInfo c2;
if (!the_mesh.getContactByIdx(idx, c2)) break;
if (memcmp(c2.id.pub_key, pfx, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
DisplayDriver::translateUTF8Static(nm, c2.name, sizeof(nm));
break;
}
}
}
snprintf(_pin_slot_labels[s], sizeof(_pin_slot_labels[s]), "Slot %d: %s", s + 1, nm);
}
}
_ctx_menu.begin("Pick slot", 3);
for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) _ctx_menu.addItem(_pin_slot_labels[s]);
_pin_picker_active = true;
}
}
// sel == 1 (Notif) and sel == 2 (Melody): already cycled via LEFT/RIGHT; ENTER just closes.
}
}
if (res != PopupMenu::NONE) _task->savePrefsIfDirty(_ctx_dirty);
return true;
}
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
if (c == KEY_CANCEL) {
if (_pick_bot_room) { _pick_bot_room = false; _room_mode = false; _task->gotoBotScreen(); return true; }
_room_mode = false;
_phase = MODE_SELECT;
return true;
}
// drawList() reclamps _contact_scroll from _contact_sel every render.
if (c == KEY_UP && _num_contacts > 0) { _contact_sel = (_contact_sel > 0) ? _contact_sel - 1 : _num_contacts - 1; return true; }
if (c == KEY_DOWN && _num_contacts > 0) { _contact_sel = (_contact_sel < _num_contacts - 1) ? _contact_sel + 1 : 0; return true; }
if (c == KEY_ENTER && _num_contacts > 0) {
if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) {
feat(companion): live location sharing, Locator geofencing, trail auto-pause Squash merge of feat/location-beacon-alerts-autopause (v1.21). Features: - Live Location Sharing — broadcast position as movement-gated [LOC] messages to a channel or contact; live shares show as map pins with distance/bearing in Nearby Nodes and a status-bar indicator. [LOC] is parsed in DMs, channel messages and room messages; DM shares name the sender. - Locator (geofence) — arm a geofence around a saved waypoint or a person (live [LOC] or last-known position), alert on arrive/leave or near/far, with an optional homing beeper (gated to arrive/both modes). Arm from Tools > Locator, Nearby Nodes, or Waypoints; target picker lists favourites first, clearable via a "None" entry. Active target is drawn as a flag on the map. - One active target shared across Locator / Navigate / Map via a single resolver (resolvePersonPos / activeTargetPos) that prefers a live [LOC] share over the last-advertised GPS fix. - Follow live contacts — Navigate to a live-sharing contact follows them as they move and adds an ETA line; quick-share your own position from the Map. - Map & status-bar upgrades — home mini-map gets a north marker, scale tick, and a connected trail line (was disconnected dots); status line shows tracked-node count, an arrow + distance to the active Locator/Nav target (falling back to the nearest live-tracked contact); GPS fix icon in the top status bar, shown only on GPS boards with GPS enabled. - Trail auto-pause — recording freezes on stops (banking elapsed time, breaking the map line across the idle gap) and resumes on movement without ending the session. - Streaming trail simplification — GPS points are simplified in-stream via a fixed-corridor (Reumann-Witkam) pass tuned for fidelity: straight runs collapse to their endpoints, curves stay bounded to the Min-dist tolerance, so the 512-point buffer covers a far longer route than a flat point budget would suggest. - Collapsible Tools (Location / Comms / System sections, fold-in-place like Settings) and page-indicator icons on the home carousel. - Waypoint coordinate editor — add a waypoint by scroll-editing lat/lon digit by digit. Fixes: - Critical: low-heap hang and contact loss on RAM-tight builds. Halved message-history scrollback rings (recovering ~14 KB free heap) and made contacts/channels/prefs persistence atomic (temp file + rename), so an interrupted save can no longer corrupt or wipe the store. - Serial.write() bounded so a stalled USB host can't hang the device. - Nearby Nodes: live [LOC] senders respect the type filter, sort by shared position, and the list refreshes so live shares bubble up. - Map: live contacts are labelled before waypoints. - GPS status icon hidden when GPS is off in Settings. - Splash screen no longer truncates a pre-release tag's own dash (e.g. v1.21-rc1) when stripping the build's commit-hash suffix. - Null-guarded the Locator target picker; clamped loc-share channel index on load. Under the hood: - -Os size optimisation on the e-ink and GAT562 30S solo envs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:04:21 +02:00
if (_pick_target) { commitPickTargetDM(_sel_contact); return true; }
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
if (_pick_bot_room) {
if (!isRoomLoggedIn(_sel_contact.id.pub_key)) {
char saved_pw[sizeof(_login_pw)];
if (the_mesh.getRoomPassword(_sel_contact.id.pub_key, saved_pw, sizeof(saved_pw))) {
// Known password, just not re-established this boot — retry
// silently in the background; the bot target is set below
// regardless of this attempt's outcome (self-heals like any
// other saved room password would on the next real open).
startRoomLogin(saved_pw);
} else {
// Never logged in — the bot could never post here without a
// password, so prompt for one now instead of picking an
// unusable target. Commits once submitted (see the KEYBOARD/
// _login_mode handler), whether or not login itself succeeds.
_login_mode = true;
_kb->begin("", 15); // room/repeater password: max 15 chars
_kb->clearPlaceholders();
_phase = KEYBOARD;
return true;
}
}
commitPickBotRoom(_sel_contact);
return true;
}
if (_room_mode && !isRoomLoggedIn(_sel_contact.id.pub_key)) {
// Posting to a room requires a login handshake first (even with a
// blank password) — go straight to the password prompt instead of
// a history view that would silently fail to send.
char saved_pw[sizeof(_login_pw)];
if (the_mesh.getRoomPassword(_sel_contact.id.pub_key, saved_pw, sizeof(saved_pw))) {
// Logged in to this room before, on an earlier boot -- retry
// with the remembered password instead of prompting again.
startRoomLogin(saved_pw);
} else {
_login_mode = true;
_kb->begin("", 15); // room/repeater password: max 15 chars
_kb->clearPlaceholders(); // {loc}/{time} are for messages, not a password
_phase = KEYBOARD;
}
return true;
}
openDmHistory();
if (_share_mode) beginShareCompose(false);
}
return true;
}
if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && _room_mode) {
ContactInfo ci;
bool logged_in = the_mesh.getContactByIdx(_sorted[_contact_sel], ci) && isRoomLoggedIn(ci.id.pub_key);
_ctx_menu.begin("Room options", logged_in ? 2 : 1);
_ctx_menu.addItem("Login...");
if (logged_in) _ctx_menu.addItem("Logout");
return true;
}
if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) {
static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" };
ContactInfo ci;
the_mesh.getContactByIdx(_sorted[_contact_sel], ci);
snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s",
NOTIF_LABELS[dmNotifState(ci.id.pub_key)]);
{ static const char* ML[] = { "global", "M1", "M2" };
snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s",
ML[dmMelodySlot(ci.id.pub_key)]); }
int pinned_slot = _task->findFavouriteSlot(ci.id.pub_key);
if (pinned_slot >= 0) snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Unpin (slot %d)", pinned_slot + 1);
else snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Pin to dial");
_ctx_menu.begin("Contact options", 3);
_ctx_menu.addItem("Mark as read");
_ctx_menu.addItem(_ctx_notif_item);
_ctx_menu.addItem(_ctx_melody_item);
_ctx_menu.addItem(_ctx_pin_item);
_ctx_dirty = false;
return true;
}
} else if (_phase == CHANNEL_PICK) {
// Context menu consumes all input while open
if (_ctx_menu.active) {
// LEFT/RIGHT cycle Notif/Melody/Fav in-place (menu stays open).
if (_num_channels > 0) {
bool left = keyIsPrev(c);
bool right = keyIsNext(c);
if (left || right) {
static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" };
static const char* ML[] = { "global", "M1", "M2" };
uint8_t ch_idx = _ctx_ch_idx; // frozen at menu open — see declaration
int sel = _ctx_menu.selectedIndex();
if (sel == 1) {
uint8_t v = chNotifState(ch_idx);
v = right ? (v + 1) % 3 : (v + 2) % 3;
setChNotifState(ch_idx, v);
snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", NOTIF_LABELS[v]);
_ctx_dirty = true;
} else if (sel == 2) {
uint8_t v = chNotifMelody(ch_idx);
v = right ? (v + 1) % 3 : (v + 2) % 3;
setChNotifMelody(ch_idx, v);
snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", ML[v]);
_ctx_dirty = true;
} else if (sel == 3) {
NodePrefs* p2 = _task->getNodePrefs();
if (p2) {
p2->ch_fav_bitmask ^= (1ULL << ch_idx);
bool is_fav = (p2->ch_fav_bitmask & (1ULL << ch_idx));
snprintf(_ctx_ch_fav_item, sizeof(_ctx_ch_fav_item), is_fav ? "Fav: yes" : "Fav: no");
_ctx_dirty = true;
// List rebuild is deferred to menu close: with the fav-only
// filter on, un-favouriting this channel removes it from the
// list, and rebuilding under the open menu would shift
// _channel_sel onto a different channel mid-interaction.
}
}
return true;
}
}
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED && _num_channels > 0) {
uint8_t ch_idx = _ctx_ch_idx; // frozen at menu open — see declaration
int sel = _ctx_menu.selectedIndex();
if (sel == 0) {
int cleared = (int)_history.chUnread(ch_idx);
_history.setChUnread(ch_idx, 0);
markReadAlert(cleared);
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
} else if (sel == 4) { // Edit
ChannelDetails ch;
if (the_mesh.getChannel(ch_idx, ch)) _ch_view.openEdit(ch_idx, ch.name);
} else if (sel == 5) { // Delete
ChannelDetails ch;
memset(&ch, 0, sizeof(ch));
the_mesh.setChannelLocal(ch_idx, ch);
_task->showAlert("Channel deleted", 1000);
}
// sel 1/2/3 already handled by LEFT/RIGHT; ENTER just closes.
}
if (res != PopupMenu::NONE) {
_task->savePrefsIfDirty(_ctx_dirty);
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
// Apply any Fav/Edit/Delete change to the visible list now that the menu is done.
buildChannelList();
if (_channel_sel >= _num_channels) _channel_sel = _num_channels > 0 ? _num_channels - 1 : 0;
}
return true;
}
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
if (c == KEY_CANCEL) {
if (_pick_bot_channel) { _pick_bot_channel = false; _task->gotoBotScreen(); return true; }
_phase = MODE_SELECT;
return true;
}
// drawList() reclamps _channel_scroll from _channel_sel every render.
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
{ int total = channelPickTotal();
if (c == KEY_UP && total > 0) { _channel_sel = (_channel_sel > 0) ? _channel_sel - 1 : total - 1; return true; }
if (c == KEY_DOWN && total > 0) { _channel_sel = (_channel_sel < total - 1) ? _channel_sel + 1 : 0; return true; }
}
if (c == KEY_ENTER && _channel_sel == _num_channels && !_pick_bot_channel) {
int idx = findFreeChannelSlot();
if (idx < 0) _task->showAlert("Channels full", 1200);
else _ch_view.openAdd(idx);
return true;
}
if (c == KEY_ENTER && _num_channels > 0 && _channel_sel < _num_channels) {
_sel_channel_idx = _channel_indices[_channel_sel];
feat(companion): live location sharing, Locator geofencing, trail auto-pause Squash merge of feat/location-beacon-alerts-autopause (v1.21). Features: - Live Location Sharing — broadcast position as movement-gated [LOC] messages to a channel or contact; live shares show as map pins with distance/bearing in Nearby Nodes and a status-bar indicator. [LOC] is parsed in DMs, channel messages and room messages; DM shares name the sender. - Locator (geofence) — arm a geofence around a saved waypoint or a person (live [LOC] or last-known position), alert on arrive/leave or near/far, with an optional homing beeper (gated to arrive/both modes). Arm from Tools > Locator, Nearby Nodes, or Waypoints; target picker lists favourites first, clearable via a "None" entry. Active target is drawn as a flag on the map. - One active target shared across Locator / Navigate / Map via a single resolver (resolvePersonPos / activeTargetPos) that prefers a live [LOC] share over the last-advertised GPS fix. - Follow live contacts — Navigate to a live-sharing contact follows them as they move and adds an ETA line; quick-share your own position from the Map. - Map & status-bar upgrades — home mini-map gets a north marker, scale tick, and a connected trail line (was disconnected dots); status line shows tracked-node count, an arrow + distance to the active Locator/Nav target (falling back to the nearest live-tracked contact); GPS fix icon in the top status bar, shown only on GPS boards with GPS enabled. - Trail auto-pause — recording freezes on stops (banking elapsed time, breaking the map line across the idle gap) and resumes on movement without ending the session. - Streaming trail simplification — GPS points are simplified in-stream via a fixed-corridor (Reumann-Witkam) pass tuned for fidelity: straight runs collapse to their endpoints, curves stay bounded to the Min-dist tolerance, so the 512-point buffer covers a far longer route than a flat point budget would suggest. - Collapsible Tools (Location / Comms / System sections, fold-in-place like Settings) and page-indicator icons on the home carousel. - Waypoint coordinate editor — add a waypoint by scroll-editing lat/lon digit by digit. Fixes: - Critical: low-heap hang and contact loss on RAM-tight builds. Halved message-history scrollback rings (recovering ~14 KB free heap) and made contacts/channels/prefs persistence atomic (temp file + rename), so an interrupted save can no longer corrupt or wipe the store. - Serial.write() bounded so a stalled USB host can't hang the device. - Nearby Nodes: live [LOC] senders respect the type filter, sort by shared position, and the list refreshes so live shares bubble up. - Map: live contacts are labelled before waypoints. - GPS status icon hidden when GPS is off in Settings. - Splash screen no longer truncates a pre-release tag's own dash (e.g. v1.21-rc1) when stripping the build's commit-hash suffix. - Null-guarded the Locator target picker; clamped loc-share channel index on load. Under the hood: - -Os size optimisation on the e-ink and GAT562 30S solo envs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:04:21 +02:00
if (_pick_target) { commitPickTargetChannel(_sel_channel_idx); return true; }
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
if (_pick_bot_channel) { commitPickBotChannel(_sel_channel_idx); return true; }
int hc = _history.histCountForChannel(_sel_channel_idx);
_unread_at_entry = (int)_history.chUnread(_sel_channel_idx);
_hist_scroll = 0;
_hist_sel = hc > 0 ? 0 : -1;
_viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0;
_phase = CHANNEL_HIST;
feat(repeater): politeness controls, dedicated radio profile, and diagnostics Settings > Radio gains a repeater toggle, 16 community-suggested radio presets plus manual Freq/SF/BW/CR tuning (digit-by-digit Freq editor), 4 persisted user preset slots, and the packet pool bumped 16->32 so queued retransmits stop starving incoming-packet allocation while relaying. Four opt-in politeness knobs (skip-advert, max-hops, yield, min-SNR), all flood-only and off by default, plus overhear suppression that cancels a queued retransmit if a peer relays the same packet first. Adaptive Power Control is suppressed while relaying (pins TX power to the ceiling) and duty-cycle RX is forced off, since a repeater needs to hear and relay at consistent power. A dedicated Tools > Repeater screen consolidates the toggle, the politeness knobs, and live forwarding stats, plus an optional "Custom" radio profile — a dedicated frequency/SF/BW/CR for relaying, separate from the companion's own network, band-matched to the companion frequency by default and used everywhere a radio change can happen (boot, on-device toggle, app-driven CMD_SET_RADIO_PARAMS, Settings radio edits) so the device never silently falls back to the wrong params mid-relay. Diagnostics gains the actually-forwarded packet count, real heap-free via mallinfo(), a reset-counters popup, and hardened loop-detect bounds. Also: a frequency-floor fix and a float-equality preset-match fix in the repeater profile logic, deduped BW-table/valCol/profile- seeding helpers shared between Settings and the Repeater screen, and a build.sh fix tolerating control characters in `pio project config`'s JSON dump. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 09:07:00 +02:00
// Not updateChannelUnread() here: _hist_visible is still whatever this
// channel's first render() hasn't computed yet (stale, shared with
// DM_HIST) — calling it now could ratchet _viewing_max_seen past what's
// actually about to be shown. render() calls it once that's fresh.
if (_share_mode) beginShareCompose(true);
return true;
}
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
if (c == KEY_CONTEXT_MENU && _num_channels > 0 && _channel_sel < _num_channels) {
uint8_t ch_idx = _channel_indices[_channel_sel];
_ctx_ch_idx = ch_idx; // freeze the menu's target channel
static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" };
snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s",
NOTIF_LABELS[chNotifState(ch_idx)]);
{ static const char* ML[] = { "global", "M1", "M2" };
snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s",
ML[chNotifMelody(ch_idx)]); }
{ NodePrefs* p2 = _task->getNodePrefs();
bool is_fav = p2 && (p2->ch_fav_bitmask & (1ULL << ch_idx));
snprintf(_ctx_ch_fav_item, sizeof(_ctx_ch_fav_item), is_fav ? "Fav: yes" : "Fav: no"); }
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
_ctx_menu.begin("Channel options", 6);
_ctx_menu.addItem("Mark all read");
_ctx_menu.addItem(_ctx_notif_item);
_ctx_menu.addItem(_ctx_melody_item);
_ctx_menu.addItem(_ctx_ch_fav_item);
feat(ui): on-device channel management, remote admin tool, per-language keyboards Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
_ctx_menu.addItem("Edit");
_ctx_menu.addItem("Delete");
_ctx_dirty = false;
return true;
}
} else if (_phase == DM_HIST) {
int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key);
if (_dm_fs.active) {
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
dispatchFsAction(false);
} else if (res != PopupMenu::NONE) {
_ctx_menu.active = false;
}
return true;
}
auto res = _dm_fs.handleInput(c);
if (res == FullscreenMsgView::PREV) {
if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_fs.scroll = 0; }
} else if (res == FullscreenMsgView::NEXT) {
if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_fs.scroll = 0; }
} else if (res == FullscreenMsgView::CLOSE) {
_dm_fs.active = false;
} else if (res == FullscreenMsgView::REPLY) {
int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
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);
}
}
return true;
}
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
dispatchFsAction(false);
} else if (res != PopupMenu::NONE) {
_ctx_menu.active = false;
}
return true;
}
if (c == KEY_CANCEL) {
if (_dm_direct_entry) {
_dm_direct_entry = false;
_task->gotoHomeScreen();
} else {
_phase = CONTACT_PICK;
}
return true;
}
// Newest (index 0) now renders at the bottom, oldest at the top (see the
// render block above), so UP/DOWN swap which direction walks the index:
// UP now climbs toward older (higher index, physically upward); DOWN
// now walks toward newer (lower index, physically downward) and off the
// bottom (index 0) reaches the compose row, which sits right below it.
if (c == KEY_UP) {
if (_dm_hist_sel == -1 && dm_count > 0) { _dm_hist_sel = 0; _dm_hist_scroll = 0; }
else if (_dm_hist_sel >= 0 && _dm_hist_sel < dm_count - 1) {
_dm_hist_sel++;
if (_dm_hist_sel >= _dm_hist_scroll + _hist_visible)
_dm_hist_scroll = _dm_hist_sel - _hist_visible + 1;
}
return true;
}
if (c == KEY_DOWN) {
if (_dm_hist_sel > 0) {
_dm_hist_sel--;
if (_dm_hist_sel < _dm_hist_scroll) _dm_hist_scroll = _dm_hist_sel;
} else if (_dm_hist_sel == 0) {
_dm_hist_sel = -1;
}
return true;
}
if (c == KEY_ENTER) {
if (_dm_hist_sel >= 0) {
_dm_fs.begin();
} else {
_sending_to_channel = false;
setupMsgPick();
_phase = MSG_PICK;
}
return true;
}
if (c == KEY_CONTEXT_MENU && _dm_hist_sel >= 0) {
int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
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);
}
return true;
}
} else if (_phase == CHANNEL_HIST) {
int ch_hist_count = _history.histCountForChannel(_sel_channel_idx);
if (_fs.active) {
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
dispatchFsAction(true);
} else if (res != PopupMenu::NONE) {
_ctx_menu.active = false;
}
return true;
}
auto res = _fs.handleInput(c);
if (res == FullscreenMsgView::PREV) {
if (_hist_sel < ch_hist_count - 1) { _hist_sel++; _fs.scroll = 0; updateChannelUnread(); }
} else if (res == FullscreenMsgView::NEXT) {
if (_hist_sel > 0) { _hist_sel--; _fs.scroll = 0; updateChannelUnread(); }
} else if (res == FullscreenMsgView::CLOSE) {
_fs.active = false;
} 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));
}
return true;
}
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
dispatchFsAction(true);
} else if (res != PopupMenu::NONE) {
_ctx_menu.active = false;
}
return true;
}
if (c == KEY_CANCEL) { _phase = CHANNEL_PICK; return true; }
// Newest (index 0) now renders at the bottom, oldest at the top (see the
// render block above) — UP/DOWN swap direction accordingly, same as the
// DM history handler above.
if (c == KEY_UP) {
if (_hist_sel == -1 && ch_hist_count > 0) { _hist_sel = 0; _hist_scroll = 0; }
else if (_hist_sel >= 0 && _hist_sel < ch_hist_count - 1) {
_hist_sel++;
if (_hist_sel >= _hist_scroll + _hist_visible) _hist_scroll = _hist_sel - _hist_visible + 1;
}
updateChannelUnread();
return true;
}
if (c == KEY_DOWN) {
if (_hist_sel > 0) { _hist_sel--; if (_hist_sel < _hist_scroll) _hist_scroll = _hist_sel; }
else if (_hist_sel == 0) _hist_sel = -1;
updateChannelUnread();
return true;
}
if (c == KEY_ENTER) {
if (_hist_sel >= 0) {
_fs.begin();
updateChannelUnread();
} else {
_sending_to_channel = true;
setupMsgPick();
_phase = MSG_PICK;
}
return true;
}
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));
return true;
}
} else if (_phase == KEYBOARD) {
auto res = _kb->handleInput(c);
if (_login_mode) {
if (res == KeyboardWidget::CANCELLED) {
_login_mode = false;
_phase = CONTACT_PICK;
} else if (res == KeyboardWidget::DONE) {
// Blank password is valid (guest/no-password rooms) — unlike normal
// message text, an empty submit here is a deliberate "log in with no
// password" attempt, so it isn't suppressed like an empty message is.
_login_mode = false;
startRoomLogin(_kb->buf);
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
if (_pick_bot_room) { commitPickBotRoom(_sel_contact); return true; }
_phase = CONTACT_PICK;
}
return true;
}
if (res == KeyboardWidget::CANCELLED) {
if (_share_mode) { _share_mode = false; _task->gotoHomeScreen(); }
else { _phase = MSG_PICK; }
} else if (res == KeyboardWidget::DONE) {
int prefix_len = _reply_mode ? (int)strlen(_reply_prefix) : 0;
if (_kb->len > prefix_len) {
// Expand only the body — prefix "@[nick] " is preserved verbatim, so a nick
// that happens to contain a placeholder token isn't substituted.
char expanded[KB_MAX_LEN + 1];
if (prefix_len > 0) {
memcpy(expanded, _kb->buf, prefix_len);
expandMsg(_kb->buf + prefix_len, expanded + prefix_len, sizeof(expanded) - prefix_len);
} else {
expandMsg(_kb->buf, expanded, sizeof(expanded));
}
bool ok = sendText(expanded);
afterSend(ok, expanded);
}
}
return true;
} else { // MSG_PICK
int total_msg_items = 1 + _active_msg_count;
if (c == KEY_CANCEL) {
_reply_mode = false;
_phase = _sending_to_channel ? CHANNEL_HIST : DM_HIST;
return true;
}
// drawList() reclamps _msg_scroll from _msg_sel every render.
if (c == KEY_UP) { _msg_sel = (_msg_sel > 0) ? _msg_sel - 1 : total_msg_items - 1; return true; }
if (c == KEY_DOWN) { _msg_sel = (_msg_sel < total_msg_items - 1) ? _msg_sel + 1 : 0; return true; }
if (c == KEY_ENTER) {
if (_msg_sel == 0) {
_kb->begin(_reply_mode ? _reply_prefix : "");
kbAddSensorPlaceholders(*_kb, &sensors);
_phase = KEYBOARD;
return true;
}
NodePrefs* p = _task->getNodePrefs();
int slot = _active_msgs[_msg_sel - 1];
const char* tmpl = p ? p->custom_msgs[slot] : "OK";
char msg[MSG_TEXT_BUF];
if (_reply_mode) {
char body[MSG_TEXT_BUF];
expandMsg(tmpl, body, sizeof(body));
snprintf(msg, sizeof(msg), "%s%s", _reply_prefix, body);
} else {
expandMsg(tmpl, msg, sizeof(msg));
}
bool ok = sendText(msg);
afterSend(ok, msg);
return true;
}
}
return false;
}
};