Hold-Enter context menus were invisible. Add a menu affordance to every screen
that has one:
- A small ≡ glyph in the header's top-right, via new DisplayDriver::
drawContextMenuHint() and an optional menu_hint arg on drawCenteredHeader() /
drawInvertedHeader() (reserves the corner so the title never runs under it).
- The glyph highlights (corner cell filled, bars knocked out) while the menu is
open, tying the hint to the popup it spawned — driven by a menu_open flag the
screens pass from their live popup state.
- The context menu now drops out of that corner: PopupMenu gains an opt-in
top-right anchor (begin(..., anchor_top_right)); it right-aligns under the
header instead of centring, so the menu reads as emerging from the ≡. Default
stays centred, so non-context popups (Tools/Settings/etc.) are untouched.
Enabled on Nodes (list/scan/detail) and Messages (mode select, contact/room and
channel pickers, DM and channel history).
Both solo envs build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UI-polish trio from CODE_REVIEW (biggest "feels finished" gain per line):
- Pill unread badges: new DisplayDriver::drawUnreadBadge()/unreadBadgeWidth()
draw a filled capsule with the count knocked out (corners knocked back for a
rounded look; inverts on a selected row). Replaces the bare right-aligned
digits in MODE_SELECT, the contact/channel pickers and favourites tiles.
No <stdio.h> in the header — fmtBadgeCount formats manually, clamps to 99+.
- Unified DM/CH history headers: DM_HIST and CHANNEL_HIST drew their titles by
hand (drawTextCentered + fillRect at lh+1), a different height/separator than
every other screen. Both now route through drawCenteredHeader().
- Trimmed default home carousel: new NodePrefs::HP_DEFAULT (Clock, Tools,
Shutdown, Favourites, Map; Messages + Settings always visible = 7 pages).
applyDefaults() seeds it instead of HP_ALL. Recent/Radio/BT/Advert/GPS/Sensors
are opt-in via Settings > Home Pages. Existing users keep their saved mask —
factory default only; no migration, no schema bump.
Both solo envs build green (OLED RAM 69.9%/Flash 62.8%; e-ink SUCCESS).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Logging in to a room server (first-time prompt or a remembered password) used
to leave the user on the room list, needing a second Enter to open the chat.
onRoomLoginResult() now opens the room history on success via a shared
openDmHistory() helper (extracted from the Enter-on-contact path).
The login result is async, so the auto-enter is gated on the user still being
on that room in the picker: phase CONTACT_PICK, room mode, no context menu /
share / pick-target sub-flow active, and the result pubkey matching the
selected contact. Otherwise it stays put (no yanking the user into a screen
they navigated away from).
Resolves the "auto-enter after login" code-review item. Builds green:
WioTrackerL1_companion_solo_dual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared keyboard seeds {loc}/{time} on begin() for message composition, but
a room/repeater login password is not a message — a placeholder token there is
nonsensical and a footgun (picking one inserts literal "{time}" into the
password). Clear them right after opening the keyboard in both room-login paths
(context-menu Login… and the Enter-on-room prompt), matching the same fix
already applied to preset names and waypoint labels.
Builds green: WioTrackerL1_companion_solo_dual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- locked device: a ringing alarm/timer was invisible — wakeForAlarm() now
holds the lock wake window for the whole ring, the lock screen draws the
alert overlay, and a key-dismissed ring falls back to a 5 s glance
- status bar: A / live-share / trail / repeater icons no longer vanish when
Bluetooth is off (moved outside the isSerialEnabled() gate)
- alert overlay: long text wraps to up to 3 lines inside the box instead of
overflowing the border (new UITask::renderAlertOverlay, shared with lock)
- MyMesh: force NUL on contact.name copied from an app frame (unterminated
name could overrun the AdvertPath strcpy)
- clock tools: ring/timer deadline compares now wrap-safe (signed diff),
matching the trail/loc-share timers
- messages: channel context menu freezes its target channel at open; fav
toggle no longer retargets the menu when the fav-only filter drops the row
All three solo envs build green (WioTrackerL1 OLED/Eink, GAT562-30S).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The DM/room and channel history lists drew each message box's selection frame
and the bottom-left [+ send] button with byte-identical inline code. Extract
two small static helpers (drawHistRowFrame, drawComposeButton) and route both
lists through them.
Only the parts that were already identical are unified — the divergent
per-type logic (sender parsing, ACK semantics, body handling) stays inline, so
this is behaviour-preserving. The DM compose button now also gets the channel
list's border (and its always-reset-to-LIGHT), folding in the earlier
[+ send] visibility fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In the DM/room message history, the message loop leaves the ink DARK when the
last drawn box is the selected one, so the unselected [+ send] row printed
dark-on-dark and vanished. Most visible in room servers, where there's always
a selected message. Reset to LIGHT before drawing the row (matching the
channel-history version). Pre-existing bug, independent of the history-store
refactor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The eight per-contact/channel notification + melody accessors were four
near-identical copies of two storage shapes. Collapse them onto two
primitives:
- maskPairGet/Set — a 3-state packed in a (presence, variant) channel-index
bitmask pair. The notif and melody uses disagree on which state the variant
bit means, so the caller passes the state value for variant-set (v_set).
- prefTableGet/Set<Entry> — the {prefix[4], value} DM table find/update/clear/
insert/overwrite-slot-0 logic, templated over the entry struct + a member
pointer to its value field (DmNotifEntry::state / DmMelodyEntry::slot).
The eight accessors are now thin wrappers; behaviour identical (verified
state↔bit mappings). QuickMsgScreen 1738 -> 1715 lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the two RAM history rings (channel + DM) out of the 1988-line
QuickMsgScreen into a self-contained MessageHistory.h component, mirroring
the WaypointsView extraction from TrailScreen. The store owns the ring
buffers, per-entry delivery state (channel relay echo, DM end-to-end ACK +
auto-resend) and the per-channel unread counters; the screen keeps all
view state (selection, scroll, fullscreen readers, the unread "viewing
session" bookkeeping) and reaches entries through accessors.
Behaviour-preserving:
- The shared types (AckState, ChHistEntry, DmHistEntry, MSG_TEXT_BUF) are
file-scope in MessageHistory.h, so the phase machine still refers to them
unqualified — only data + storage logic moved.
- addChannelMsg keeps a thin screen forwarder that computes the "viewing"
flag (a phase fact the store can't see) and returns the ring pos;
afterSend uses armChannelRelay(pos, seq) instead of poking the ring.
- The public API called via UITask/MyMesh/the bot (addChannelMsg, addDMMsg,
markDmDelivered, markChannelRelayed, tickDmResends, clearAllChannelUnread,
getTotalChannelUnread, getRecentDMContacts) stays on QuickMsgScreen as
forwarders, so no caller changes.
QuickMsgScreen 1988 -> 1738 lines; MessageHistory.h 324. Both solo boards
build. Single-TU fragment: included by UITask.cpp before QuickMsgScreen.h.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every drawList/AccordionList row opened with the same hand-written
display.drawSelectionRow(0, y-1, width-reserve, lineStep-1, sel) line —
14 copies of the same geometry and magic offsets. Add a drawRowSelection(d,
y, sel, reserve) helper in icons.h next to drawList and route the canonical
sites through it (Bot/LiveShare/Locator/Repeater/Settings/Tools/Nearby/
QuickMsg/Waypoints).
Rows that intentionally differ (full-width DashboardConfig/QuickMsg, the
keyboard/ringtone grids) keep their explicit drawSelectionRow() call — the
helper is opt-in rather than baked into drawList, so legitimate variants
aren't forced into one geometry. Framework doc updated to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "_dirty bool, then if (_dirty) the_mesh.savePrefs() on exit" pattern was
duplicated across 9 screens with subtle divergence: some cleared the flag
after saving, some left it set and relied on onShow() to reset. Replace all
12 exit sites with a single _task->savePrefsIfDirty(flag) helper that saves
once only if dirty and always clears the flag, so the "did we touch flash?"
answer lives in one place and the reset is consistent.
Edit sites still mark the flag manually (inherent to change tracking); only
the persist-on-exit boilerplate is centralised.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audit pass against docs/design/solo_ui_framework.md, converging deviations:
- prev/next: replace hand-rolled (c == KEY_LEFT || c == KEY_PREV) with
keyIsPrev()/keyIsNext() in QuickMsgScreen, RingtoneEditor, TrailScreen,
FullscreenMsgView. Two were real rotary-encoder gaps, not just style:
NearbyScreen's filter and WaypointsView's hemisphere toggle used raw
KEY_LEFT/RIGHT with no encoder twin, so the encoder couldn't drive them.
- NearbyScreen's list right column used a manual setCursor(width -
getTextWidth(...)) instead of the existing drawTextRightAlign() helper
(which also UTF-8-translates) — now uses it, like Diagnostics/Repeater.
- RepeaterScreen hand-rolled the whole drawList skeleton (scroll clamp +
reserve + row loop + indicator); collapsed onto drawList().
No D-pad behaviour change; more uniform encoder support. Net -19 lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidation pass over the message/nearby UI, no functional change beyond
one intentional display tweak:
- Age tags: NearbyScreen::fmtAge, the nearby list's inline column, and
QuickMsgScreen::fmtMsgAge each reimplemented the same s/m/h bucket ladder
on top of geo::fmtAgeShort. All now delegate to it (fmtMsgAge removed).
Visible effect: ages over 24h render as "Nd" instead of capped hours,
matching the Locator target picker which already used fmtAgeShort.
- Reply prefix: the "@[nick] " parse was duplicated in skipReplyPrefix() and
FullscreenMsgView::render(). Extracted to one msgReplyBody() helper (body,
plus optional addressee nick) — one place to handle its edge cases.
- LiveTrack: the expiry predicate was duplicated in expire()/isActive();
extracted to a private expired() helper.
- Removed a dead M_PI define (and unused <math.h>) in NearbyScreen.h, and a
comment pointing at a CODE_REVIEW.md that doesn't exist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A room is viewed through DM history, where each post is stored "Author:
text" and shown attributed to that author. The reply path, though, built
the @[nick] prefix from _sel_contact.name — the room server's own name —
so replying to someone's post in a room addressed the room, not the person.
New buildDmReplyPrefix() derives the addressee from the same author the
history shows (via dmDisplayParts): the post's author in a room, the
contact name in a 1:1 DM. It also stores the nick raw (UTF-8), like the
channel reply path, instead of running it through the lossy display
transliterator — the prefix is sent over the air verbatim, so a non-ASCII
nick was previously corrupted on the wire.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DM, room and channel history always stamped a message with receipt time
(rtc_clock.getCurrentTime()), even though the sender's real timestamp was
already available (and, for DMs/rooms, already threaded through to
addDMMsg -- just never used for display). Live-received messages hid this
because receipt lags origination by only seconds, but a room-sync replay or
an offline-queued message held by a repeater can arrive long after it was
sent, so a burst of backlog messages all showed as "just now".
storeDMMsg() now prefers msg_ts (falling back to receipt time only when
unknown). addChannelMsg() gained a timestamp parameter, threaded from
onChannelMessageRecv() down through AbstractUITask/UITask, with the same
fallback. The DM dedup check and outgoing-message timestamps are unaffected
(they use msg_ts directly, already correct).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Log in to a room server from the device UI with no phone app: picking a
room prompts for its password (blank allowed for open rooms), and a
context-menu "Login..." allows re-login. Successful passwords are
persisted to a dedicated /room_pw file so a previously-used room logs
back in after reboot without retyping; a failed login forgets the
(now-stale) saved password so the next attempt prompts again.
The room-password file is written via a temp file + atomic rename
(new DataStore::commitFile helper), matching the crash-safety of
contacts/channels persistence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Incoming room-server posts fired the notification and reached the app via
the offline queue, but never appeared when the room was opened directly on
the device: the on-device history (addDMMsg) was gated to ADV_TYPE_CHAT only,
while rooms (ADV_TYPE_ROOM) share that same history list (keyed by the
server's pubkey). Include ADV_TYPE_ROOM so room posts are stored too.
A room carries many guests, so prefix each stored post with its author —
resolved from the signed message's sender pubkey prefix (fallback: short
hex) — and have the DM history view split "Sender: text" for room contacts
so each line is attributed to the guest who wrote it, instead of showing the
room server's name for every message.
Builds verified: WioTrackerL1 + GAT562 30S solo.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Trail map dots/diamonds/cross/start markers and the north indicator were
drawn as raw unscaled pixels, so they shrank to near-invisible specks on
landscape e-ink's larger font scale; they now route through the mini-icon
framework (icons.h) like the rest of the UI, and the grid intersection dots
scale with it too. The keyboard's preview/grid separator had the same bug
(hardcoded 1px) and now uses display.sepH().
Also makes UP/DOWN (and the keyboard's LEFT/RIGHT/UP/DOWN) wrap top<->bottom
across every list, menu and form selector, matching the behavior PopupMenu
already had.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Portrait e-ink message history had two scrollbar glitches:
- Track length tracked the last visible box's bottom, so a long message at
the bottom that left empty space shrank the whole bar. Pin the track to the
full list area (hist_start_y..cby); only the thumb sizes/moves.
- Thumb size was derived from the per-frame visible-box count, which fluctuates
with variable-height boxes. Drive it off pixel sums with a constant viewport
(view_px = track_h) so the thumb stays stable while scrolling one list.
- Gutter reserve used last frame's _hist_visible, so an incoming message briefly
toggled the gutter and reflowed box widths. Decide it from a whole-list fit
test at the widest layout instead — stable, no flicker.
Unify both fixes in computeHistScroll() (shared by DM + channel views) and
split drawScrollIndicator into a pixel core (drawScrollIndicatorPx) + an
item-count wrapper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes from a full ui-new audit (OLED + e-ink both build green):
- remove dead DisplayDriver::drawScrollArrows (all lists use drawScrollIndicator)
- SettingsScreen selection bar -> lineStep()-1, matching the drawList screens
- HomeScreen: don't force the 1s blink refresh on the CLOCK page (status icons
are hidden there anyway)
- FullscreenMsgView: clamp KEY_DOWN scroll to a cached _max_scroll instead of
over-incrementing and leaning on the next render to clamp
- DataStore: clamp use_lemon_font (>1 -> 0) on load, like the other enum fields
- move the ~1.5KB wrap scratch (trans/lines) off the render stack into shared
file-scope statics in FullscreenMsgView (single-threaded UI; the fullscreen
view and history list never lay out in the same frame)
Cleanup:
- drop dead members RingtoneEditorScreen::DUR_VALS and HomeScreen::sensors_scroll
- delete redundant manual scroll-clamps in handleInput across the drawList
screens (drawList already reclamps each render); remove the now-unused _visible
from QuickMsgScreen/NearbyScreen/BotScreen and BotScreen::scrollToSel()
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- drawList(): scrollable list skeleton (visible window, keep-sel-in-view,
scroll-column reserve, per-row callback, indicator). Migrate Tools, Bot,
Nearby, Waypoints list and the three QuickMsg lists onto it; centralises the
scroll-clamp/reserve/indicator that each had open-coded (and inconsistently).
- drawCenteredHeader(): centred title + separator, replacing the duplicated
two-liner across ~10 screens (incl. QuickMsg, which used width()/2 spacing and
was missed before); unifies the separator at headerH()-sepH().
- keyIsPrev()/keyIsNext() in UIScreen.h for the LEFT||PREV / RIGHT||NEXT idiom;
applied to 6 screens, behaviour-preserving. Cancel/context-menu stay
per-screen (they mean different things on different screens).
- rotLabel() dedups the 0/90/180/270 array in Settings.
- Unify selection-row height to lineStep()-1 (Tools/Bot matched the rest).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the text ^/v scroll arrows with a font-scaling indicator drawn in
icons.h: a proportional thumb between fixed up/down triangle mini-icon caps.
drawScrollIndicator() lives in a ~5px right-edge column and scales via
miniIconScale (1x OLED, 2x landscape e-ink).
- Mini-icons: ICON_SCROLL_UP/DOWN; miniIconDrawTop (exact placement) and
miniIconDrawHalo (shape-hugging 1px DARK halo, clip_top to spare the header
separator) so caps stay visible on the LIGHT selection bar without a box.
- Thumb is 3*s wide so it centres on the triangle column; caps are static end
markers (always drawn) so neither vanishes at the extremes.
- scrollIndicatorReserve(): right-edge gutter (0 when the list fits). Content,
the selection bar and the message-history bubbles are all narrowed by it so
nothing renders under the scrollbar. Portrait e-ink wrap width subtracts the
reserve in both the box-height pass and render so wrapped text can't spill.
- Drop the redundant ">" selection marker (the highlight bar already shows
selection) and shift rows to x=2, reclaiming left-edge space.
- Wire the indicator into every scrollable list (Bot, Settings, Nearby, Tools,
Trail, QuickMsg lists + DM/channel history) and add one to WaypointsView,
which scrolled with no indicator before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author mini-icons as ASCII-art rows packed to bytes at compile time via
constexpr packRow(), so the glyph shape is visible in source while the
binary holds only the packed bytes (strings never reach flash, identical
to the previous hand-written hex).
Bundle each icon's width/height with its data through MiniIcon and the
MINI_ICON(name, width, ...) macro, so draw sites carry no magic numbers:
miniIconDraw(d, x, y, ICON_CHECK). Also add an unused-by-design skeleton
for full-size page icons (≤32 px): packRow32 / BigIcon / BIG_ICON /
bigIconDraw, for authoring a future big glyph the same readable way.
Existing XBM bitmaps left untouched.
Flash size unchanged; verified no ASCII-art strings reach the .elf.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
End-to-end delivery indicators on the device UI, drawn next to outgoing
messages and auto-scaled to the font (legible on landscape e-ink).
DM (and room servers, which share the DM path):
- Pending / delivered / failed marker driven by the real end-to-end ACK.
Pending shows a row of dots — one per send — so auto-resend progress is
visible before it resolves to ✓ / ✗.
- Auto-resend: new pref dm_resend_count (0-5, default 2), Settings ›
Messages › Resend. A pending DM whose ACK times out is re-sent (reusing
the original timestamp) until resends run out, then ✗. Driven from
UITask::loop so it completes in the background, independent of screen.
- Incoming dedup: a retry reuses the sender timestamp + text but carries a
fresh packet hash, so addDMMsg drops copies matching prefix+ts+text.
Channels (flood, no recipient ACK):
- ✓ only once a repeater echo confirms the send was relayed into the mesh;
no echo is normal, not a failure (no pending/fail shown). A small ring
tracks a burst of sends so each matches its echo. Receive-path hashing is
gated so the hot flood path is untouched when idle.
Shared:
- Markers shown in both the history list and the fullscreen message view.
- Reusable scalable mini-icon facility in icons.h (bitmap + auto-scale);
adding a new status icon is a bitmap plus one draw call.
No changes to the upstream mesh library (src/).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Persist.h: shared writeHeader()/readHeader() for the magic+version+count
on-disk header; Waypoint and Trail snapshots now share one implementation
(byte layout unchanged; each keeps its own count clamp/reject + extra fields).
- TrailScreen: pushAction() guards _act_map / PopupMenu capacity, so adding a
new action can't silently overrun the fixed array.
- PopupMenu: add count()/setSelected() accessors; replace the remaining direct
_sel/_count field pokes in NearbyScreen, TrailScreen and QuickMsgScreen.
- Trail summary: unit-suffixed time ("1h 05m" / "5m 03s") so hours can't be
misread as minutes.
- Trail::haversineMeters delegates to geo::haversineKm — single Haversine
implementation (also trims a little flash).
Verified: GAT562 (SSD1306) and WioTrackerL1Eink (GxEPD) solo builds compile clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pull repeated UI idioms into DisplayDriver and remove duplicated logic:
- DisplayDriver: add drawScrollArrows() and drawInvertedHeader(); replaces 11
copy-pasted scroll-arrow blocks across 5 screens and 3 inverted title-bar
blocks (NearbyScreen x2, NavView). Standardises the detail-view separator.
- useImperial(): single source on UITask; NearbyScreen/TrailScreen delegate
instead of each re-reading NodePrefs.units_imperial.
- NearbyScreen: extract saveSelectedWaypoint() (two identical blocks -> one);
drop the redundant local label buffer (WaypointStore truncates).
- UITask::shutdown(): power GPS off through LocationProvider::stop() instead of
poking PIN_GPS_EN directly — centralises enable/reset-pin + active-level logic.
Net -58 lines. Verified: GAT562 (SSD1306) and WioTrackerL1Eink (GxEPD) solo
builds compile clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NearbyScreen:
- Hold Enter on a list row now shows Navigate and Save waypoint directly,
without entering the detail view first
- TIME added as the last filter slot (LEFT/RIGHT cycles Fav→ALL→…→Snsr→TIME);
time-sort shows age in right column and sorts by last-heard descending
- Save waypoint also available from detail view Options menu
UITask:
- Extract addWaypoint() helper (two overloads: with/without explicit timestamp)
consolidating the full()+add()+saveWaypoints()+alert pattern; TrailScreen
and QuickMsgScreen updated to use it
Fixes:
- SoundNotifier.h: correct NodePrefs.h include path (../NodePrefs.h)
- kbAddSensorPlaceholders: dereference _kb pointer in SettingsScreen,
QuickMsgScreen and BotScreen (signature changed to KeyboardWidget&)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Deduplicate the snprintf+showAlert pattern used in three mark-as-read
sites into a single private method.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MODE_SELECT already showed the count; CONTACT_PICK ("Mark as read") and
CHANNEL_PICK ("Mark all read") were silently clearing unread without
any feedback. Count is read before zeroing so the alert is accurate.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves KeyboardWidget from a per-screen value member to a single instance
owned by UITask, passed to SettingsScreen, QuickMsgScreen, and BotScreen
as a pointer. Only one screen is ever active at a time, so the shared
state is safe. Saves ~1.5 KB of always-resident heap.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After widening the history buffers, the compose side was still the
bottleneck: the keyboard capped input at 139 bytes and the quick-message
expand buffers were 140. Raise KB_MAX_LEN to 160 (MeshCore's MAX_TEXT_LEN)
and size the expand/compose buffers to MSG_TEXT_BUF, so a message can be
typed, expanded, sent, stored and displayed at the full protocol length on
every path.
The custom-message editor now passes an explicit max bound to its 140-byte
store, so the wider keyboard buffer can't overflow it; the bot editor
already passed per-field maxima.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Message history buffers were smaller than the over-the-air maximum, so long
messages lost their tail on display. Channel messages carry the sender
embedded as "Name: body" in the payload (up to MAX_TEXT_LEN = 160 B), but
ChHistEntry::text was only 140 B; DmHistEntry::text was just 80 B — and with
Polish text each accented char is two UTF-8 bytes, so the effective limit was
roughly half the visible characters.
Size both history buffers (and the fullscreen/preview split copies) to
MAX_TEXT_LEN + 1 so a full-length message is stored and shown intact. Sender
split buffers widened to the 32-char name limit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously a shared location was only actionable after opening the message
fullscreen; Hold Enter on a history row only ever offered Reply. Route the
DM and channel list context menus through the same buildFsMenu /
dispatchFsAction path as the fullscreen view, so Navigate / Save waypoint
appear directly on the list row when the selected message carries a
location (Navigate returns to the list on Back).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Waypoints list → Hold Enter → Send hands the point to the Messages screen
as "[WAY]<lat>,<lon> <label>" (same text format geo::parseLatLon already
reads). The user picks a contact or channel, the text lands prefilled in
the keyboard to confirm/edit, then sends — closing the loop with the
Navigate / Save waypoint actions on the receiving end.
- QuickMsgScreen: share-compose mode (startShare/beginShareCompose). Picking
a recipient jumps straight to the prefilled keyboard; cancel returns home;
afterSend clears the mode.
- UITask::shareToMessage hands off from TrailScreen to the Messages screen.
- TrailScreen: "Send" added to the waypoint Rename/Delete popup; builds the
[WAY] payload at the {loc} precision (5 dp).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Locations already travel as plain text ({loc} expands to "lat,lon"), so any
message carrying coordinates can now be acted on. In the fullscreen message
view, the context-menu (Hold Enter) gains Navigate and Save waypoint
entries whenever the message contains a coordinate — alongside Reply.
- geo::parseLatLon: shared scanner for an embedded "lat,lon" (the {loc}
format), with an optional [WAY] tag that supplies a label. Range-checked,
requires a decimal point to avoid matching plain integer pairs. Will also
back the waypoint-share feature later.
- QuickMsgScreen: Navigate opens NavView inline (To/Hdg/distance, honouring
the global Units setting) over the message, back returns to it; Save
waypoint stores the point (label from [WAY], else auto-named). Works in
both DM and channel histories, and on incoming or outgoing messages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The channel history is a single ring shared by all channels (was 32
entries), while unread counts live in a separate per-channel array.
Once 32 messages had accumulated across channels, the oldest entry was
evicted on each new message — but the matching _ch_unread[] counter was
left untouched. The Messages badge then claimed unread messages the ring
could no longer surface, so a channel showed "N unread" but opened empty.
(The companion app stores its own copy over the protocol, which is why
the phone could still read them.)
Two changes:
- addChannelMsg() now decrements _ch_unread[evicted.ch_idx] when an entry
is pushed out of the ring, keeping the badge consistent with what is
actually displayable.
- CH_HIST_MAX raised 32 → 96 (~14 KB RAM) so eviction is far less frequent
under normal multi-channel traffic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
M2: CMD_SET_DEFAULT_FLOOD_SCOPE used strlen() on the frame's 31-byte name
slot, which is not required to be NUL-terminated. Switched to strnlen()
so the search can't run past the field into the 16-byte key.
M4: NearbyScreen::renderDiscoverDetail computed strncpy(b64, ..., max_chars - 3)
where max_chars came from display width. On very narrow displays (width < ~28
at 6 px font) this became negative. Skip the pub-key line entirely when
max_chars < 4 so we don't risk a negative count and a bogus terminator.
L1: SNR was shown as truncated integer dB. Switched the detail view and
the 2-line discover cards to %.1f so they keep the 0.25 dB resolution
(consistent with the ping popup, which already used %.1f).
L4: Two fallback "?" sender placeholders used strncpy(buf, "?", sizeof(buf))
— functional but it memsets 21 unused bytes for a one-character string.
Replaced with strcpy.
M1 marked as not-a-bug after re-check: default_scope_name is char[31],
so the n < 31 guard correctly admits the max 30-char string + NUL.
FEATURES.md audit section updated with current status.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
C1/C2: onChannelMessageRecv and onChannelDataRecv both passed
findChannelIdx() through (uint8_t), so an unknown-secret packet
turned -1 into 255 and the message flowed into the offline queue,
UI history, notification and bot reply with a bogus channel index.
Drop such packets at the recv path; also harden addChannelMsg with
an MAX_GROUP_CHANNELS bounds check so a future caller can't poison
the channel-history ring buffer either.
H4: loadPrefsInt always reset trail_units_idx on sentinel mismatch,
even for jumps where the field was saved correctly (e.g. 0xC0DE0004
→ 0xC0DE0005). Scope the reset to sentinel == 0xC0DE0003, which is
the only case where the byte at that offset is actually the stale
sentinel low byte.
FEATURES.md: mark these three items as ✅ in the audit backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PopupMenu:
- _cap now uses max_by_height as a hard ceiling (never draw outside screen).
Previously max(_visible, max_by_height) let callers exceed the screen on
OLED 64px. Now: OLED→4 items, landscape e-ink→10, portrait e-ink→22.
QuickMsgScreen:
- Contact context menu: Notif and Melody cycle with LEFT/RIGHT in-place;
ENTER closes without re-cycling.
- Channel context menu: Notif, Melody and Fav same pattern.
RingtoneEditorScreen:
- Migrated from bespoke menu to PopupMenu (same pattern as everywhere else).
- Duration (1/4…1/32) and BPM (60…180) are now single rows that cycle with
LEFT/RIGHT; separate BPM+/BPM- rows removed.
- Fixed _menu_dur_label buffer too small for "Duration: 1/16" (14→16 bytes).
NearbyScreen:
- Removed redundant "Back" item from context menu (Cancel key navigates back).
TrailScreen:
- Grid toggle responds to LEFT/RIGHT in addition to ENTER.
- Export labels: "Export GPX (live/saved)" → "Export (live/saved)" to fit PM_BW.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous fix set joystick_rotation=0 before reading from file, but
the subsequent rd() call overwrote it with whatever was stored (e.g. 2
from a prior e-ink firmware). Move the hardware-enforce block to after
rd() so stale values in flash are always corrected on OLED builds
(FEAT_JOYSTICK_ROTATION_SETTING=0). Remove the now-redundant pre-read
block.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add per-channel favourite marking and a Settings filter (all/fav) for
the channel list, symmetric with the existing DM and Room filters.
NodePrefs:
- ch_fav_bitmask (uint64_t): bit i = channel i is marked favourite
- ch_fav_only (uint8_t): 0=show all channels, 1=favourites only
- SCHEMA_SENTINEL bumped to 0xC0DE0005
DataStore: save/load both new fields before the sentinel.
SettingsScreen: new CH_FILTER item ('Channels: all/fav') in the
Contacts section, positioned between DM_FILTER and ROOM_FILTER.
QuickMsgScreen:
- buildChannelList() skips non-favourite channels when ch_fav_only=1
- Channel context menu gains a 4th option 'Fav'/'Unfav' that toggles
ch_fav_bitmask for the selected channel and rebuilds the list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Enter on an empty Favourites slot opens a PopupMenu listing candidate
contacts. Sourced first from upstream-favourited chat contacts (those
flagged with c.flags & 0x01), then from recent DM contacts (resolved
from the 4-byte _dm_hist prefix to a full 6-byte pub_key via the contact
list), deduped against the favourites list. Capped at 12 entries.
Selection writes the chosen 6-byte prefix into the target slot; if the
contact is already pinned elsewhere, the previous slot is vacated first
(one contact per dial, per the agreed pin-only semantics). savePrefs
commits to flash and a brief alert confirms.
Plumbing:
- New QuickMsgScreen::getRecentDMContacts() resolves recent DM senders
to 6-byte pub_key prefixes, deduped, newest first
- UITask::getRecentDMContacts() wraps it so HomeScreen doesn't reach
into the QuickMsg screen directly
- HomeScreen gains its own PopupMenu + per-entry pub_key / label buffers
(12 × 6 B keys + 12 × 22 B labels), no shared state with QuickMsg's
Contact-options menu
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
QuickMsgScreen::enterDM (used by openContactDM from the Favourites dial)
now sets _dm_direct_entry so KEY_CANCEL in DM_HIST jumps back to the
home screen instead of falling through CONTACT_PICK → MODE_SELECT. The
flag clears on every reset() and after the cancel returns.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Enter on a populated slot of the Favourites grid now jumps straight into
that contact's DM history — resolves the pinned 6-byte pub_key prefix
against the contact list, then routes through new
UITask::openContactDM(ci) which resets QuickMsgScreen and calls
enterDM() to land directly in DM_HIST.
Empty slots still show the "pin from contact options" hint until phase 3
wires the in-grid mini-picker.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a fourth item to the QuickMsg Contact options popup that toggles
between "Pin to dial" (when the contact isn't pinned) and
"Unpin (slot N)" (when it is). Pinning opens a six-slot picker submenu
labeled "Slot N: <name>" or "Slot N: empty"; the chosen slot stores the
6-byte pub_key prefix in NodePrefs::favourite_contacts and savePrefs
commits to flash with a brief confirmation alert.
New UITask helpers backing the flow:
- findFavouriteSlot(pub_key) — returns 0..5 if pinned, -1 otherwise
- isFavouriteSlotEmpty(slot)
- setFavouriteSlot(slot, pub_key) / clearFavouriteSlot(slot)
PopupMenu reuse: a single _ctx_menu instance hosts both the Contact
options menu and the slot picker, gated by _pin_picker_active so input
routes correctly. Reset on screen reset() and on any non-NONE menu
result.
Slot labels live in a member buffer (6×22 B) because PopupMenu stores
the string pointers verbatim.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sample the unread total for the active mode (DM/Channels/Rooms) before
clearing, then alert "N marked read". Matches the original FEATURES.md
design note.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hold Enter (context menu) on the DM / Channels / Rooms picker opens a
single-item "Mark all read" menu that clears every unread counter of
the highlighted type. Per-mode title is a static-const string because
PopupMenu stores the title pointer verbatim.
New UITask::clearAllDMUnread() resets the whole _dm_unread_table;
clearAllChannelUnread() and clearRoomUnread() already existed.
Also document the GPS breadcrumb storage decision in FEATURES.md:
RAM-only ring with explicit per-slot saves, no background flash writes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Eight screens repeated the same if/else block to invert the row
background for selected items. Add DisplayDriver::drawSelectionRow that
sets LIGHT, optionally fills the rect, and leaves the colour as DARK
when sel (so the caller's text renders inverted). 61 lines removed
across SettingsScreen, KeyboardWidget (cells + special row), ToolsScreen,
DashboardConfigScreen, BotScreen, RingtoneEditorScreen (note slots +
menu), NearbyScreen, QuickMsgScreen (4 list views).
Card-style rows (QuickMsg hist, NearbyScreen discover, RingtoneEditor
notes display) keep their original drawRect/partial-fill outline for
unselected — they don't match the simple-invert pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>