2026-05-17 00:12:40 +02:00
|
|
|
#pragma once
|
|
|
|
|
// Custom screen — not part of upstream UITask.cpp
|
|
|
|
|
// Included by UITask.cpp after SettingsScreen.h is defined.
|
|
|
|
|
|
feat(nav): navigate to / save a location shared in a message
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>
2026-06-04 11:28:17 +02:00
|
|
|
#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(nav): navigate to / save a location shared in a message
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>
2026-06-04 11:28:17 +02:00
|
|
|
|
2026-05-17 00:12:40 +02:00
|
|
|
class QuickMsgScreen : 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
|
2026-06-26 10:55:24 +02:00
|
|
|
bool _login_mode; // true while KEYBOARD is collecting a room-login password
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
// CHANNEL_PICK
|
|
|
|
|
int _channel_sel, _channel_scroll;
|
|
|
|
|
int _num_channels;
|
|
|
|
|
uint8_t _channel_indices[MAX_GROUP_CHANNELS];
|
|
|
|
|
uint8_t _ch_unread[MAX_GROUP_CHANNELS];
|
|
|
|
|
int _sel_channel_idx;
|
|
|
|
|
bool _sending_to_channel;
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-05-17 00:12:40 +02:00
|
|
|
// MSG_PICK (shared)
|
|
|
|
|
int _msg_sel, _msg_scroll;
|
|
|
|
|
int _active_msgs[QUICK_MSGS_MAX];
|
|
|
|
|
int _active_msg_count;
|
|
|
|
|
|
|
|
|
|
// CHANNEL_HIST
|
|
|
|
|
int _hist_sel, _hist_scroll;
|
|
|
|
|
FullscreenMsgView _fs;
|
|
|
|
|
int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST
|
|
|
|
|
int _viewing_max_seen; // highest _hist_sel reached in current session
|
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
|
|
|
// Shared ring for all channels combined. addChannelMsg() decrements the
|
2026-05-31 16:23:07 +02:00
|
|
|
// matching _ch_unread when an entry is evicted so the badge can't claim
|
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
|
|
|
// unread messages that no longer exist in the ring. Each slot carries a full
|
|
|
|
|
// MSG_TEXT_BUF, so this ring dominates the screen's RAM footprint — kept
|
|
|
|
|
// modest to leave heap headroom (see DM_HIST_MAX).
|
|
|
|
|
static const int CH_HIST_MAX = 48;
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
// KEYBOARD
|
2026-06-08 20:42:18 +02:00
|
|
|
KeyboardWidget* _kb;
|
2026-05-17 00:12:40 +02:00
|
|
|
|
2026-05-18 15:12:11 +02:00
|
|
|
// Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK / histories)
|
2026-05-17 00:12:40 +02:00
|
|
|
PopupMenu _ctx_menu;
|
|
|
|
|
bool _ctx_dirty;
|
|
|
|
|
char _ctx_notif_item[22];
|
|
|
|
|
char _ctx_melody_item[20];
|
2026-05-24 23:30:16 +02:00
|
|
|
char _ctx_pin_item[28]; // "Pin to dial" or "Unpin (slot N)"
|
2026-05-27 17:33:52 +02:00
|
|
|
char _ctx_ch_fav_item[12]; // "Fav" or "Unfav"
|
2026-05-24 23:30:16 +02:00
|
|
|
char _pin_slot_labels[NodePrefs::FAVOURITES_COUNT][22]; // per-slot picker labels
|
|
|
|
|
bool _pin_picker_active; // true while the slot-picker submenu is open
|
2026-05-25 07:58:45 +02:00
|
|
|
bool _dm_direct_entry; // entered DM_HIST via Favourites shortcut; CANCEL returns home
|
2026-05-18 18:18:45 +02:00
|
|
|
char _reply_prefix[36]; // "@[nick] " built when reply is triggered
|
2026-05-18 15:12:11 +02:00
|
|
|
bool _reply_mode; // true while composing a reply (prefix is prepended)
|
2026-05-17 00:12:40 +02:00
|
|
|
|
feat(nav): navigate to / save a location shared in a message
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>
2026-06-04 11:28:17 +02:00
|
|
|
// 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] = "";
|
|
|
|
|
|
2026-06-04 11:38:12 +02:00
|
|
|
// 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;
|
2026-06-04 11:38:12 +02:00
|
|
|
|
2026-06-04 12:10:26 +02:00
|
|
|
// History text holds a full received message. Channel messages carry the
|
|
|
|
|
// sender embedded as "Name: body" in the payload, so a message can be up to
|
|
|
|
|
// the over-the-air maximum (MAX_TEXT_LEN). Size the buffers to that + NUL,
|
|
|
|
|
// otherwise long messages (and Polish text, where each accented char is two
|
|
|
|
|
// UTF-8 bytes) get their tail clipped.
|
|
|
|
|
static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1;
|
|
|
|
|
|
2026-06-14 23:33:16 +02:00
|
|
|
// Outgoing-message delivery state. DM: a real end-to-end ACK (✓ delivered to
|
|
|
|
|
// the recipient). Channel: only a "relayed into mesh" echo from a repeater (no
|
|
|
|
|
// recipient ACK exists for floods), so a missing echo is NOT shown as failure.
|
|
|
|
|
enum AckState : uint8_t { ACK_NONE = 0, ACK_PENDING, ACK_OK, ACK_FAIL };
|
|
|
|
|
|
|
|
|
|
struct ChHistEntry {
|
|
|
|
|
uint8_t ch_idx;
|
|
|
|
|
char text[MSG_TEXT_BUF];
|
|
|
|
|
uint32_t timestamp;
|
|
|
|
|
uint8_t relay_status; // AckState; only PENDING/OK used (no failure for floods)
|
|
|
|
|
uint32_t relay_seq; // MyMesh relay seq to match against onChannelRelayed()
|
|
|
|
|
};
|
2026-05-17 00:12:40 +02:00
|
|
|
ChHistEntry _hist[CH_HIST_MAX];
|
|
|
|
|
int _hist_head, _hist_count;
|
|
|
|
|
|
|
|
|
|
// DM_HIST
|
2026-06-14 23:33:16 +02:00
|
|
|
struct DmHistEntry {
|
|
|
|
|
uint8_t prefix[4];
|
|
|
|
|
uint8_t outgoing;
|
|
|
|
|
char text[MSG_TEXT_BUF];
|
|
|
|
|
uint32_t timestamp;
|
|
|
|
|
uint8_t ack_status; // AckState; meaningful only when outgoing
|
|
|
|
|
uint32_t ack_tag; // expected_ack CRC to match against onMsgAck()
|
|
|
|
|
uint32_t ack_deadline_ms; // millis() by which a pending ACK must arrive
|
|
|
|
|
// Sender-perspective message timestamp: the send timestamp for outgoing
|
|
|
|
|
// (reused verbatim on resend so the recipient treats it as a retry), or the
|
|
|
|
|
// sender_timestamp for incoming (used to dedup retried copies). 0 = unknown.
|
|
|
|
|
uint32_t msg_ts;
|
|
|
|
|
uint8_t attempt; // last attempt number sent (outgoing); next resend = attempt+1
|
|
|
|
|
uint8_t resends_left; // remaining auto-resends before the marker shows ✗
|
|
|
|
|
};
|
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
|
|
|
// Each slot carries a full MSG_TEXT_BUF; kept modest to bound heap use.
|
|
|
|
|
static const int DM_HIST_MAX = 32;
|
2026-05-17 00:12:40 +02:00
|
|
|
DmHistEntry _dm_hist[DM_HIST_MAX];
|
|
|
|
|
int _dm_hist_head, _dm_hist_count;
|
|
|
|
|
int _dm_hist_sel, _dm_hist_scroll;
|
|
|
|
|
FullscreenMsgView _dm_fs;
|
|
|
|
|
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
int _hist_visible = 2; // updated in render(); for history list scroll clamping
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 09:32:09 +02:00
|
|
|
// 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.
|
|
|
|
|
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, skipReplyPrefix(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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 16:37:25 +02:00
|
|
|
// Strip "@[nick] " reply prefix from a message body for compact list display.
|
|
|
|
|
static const char* skipReplyPrefix(const char* text) {
|
|
|
|
|
if (text[0] == '@' && text[1] == '[') {
|
|
|
|
|
const char* close = strchr(text + 2, ']');
|
2026-05-23 14:45:24 +02:00
|
|
|
if (close && close[1] == ' ' && close[2]) text = close + 2;
|
2026-05-21 16:37:25 +02:00
|
|
|
}
|
2026-05-23 14:45:24 +02:00
|
|
|
while (*text == '\n' || *text == '\r' || *text == ' ') text++;
|
2026-05-21 16:37:25 +02:00
|
|
|
return text;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 18:31:47 +02:00
|
|
|
// 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.
|
|
|
|
|
const char* dmDisplayParts(const DmHistEntry& e, bool is_room, const char* contact_name,
|
|
|
|
|
char* sender_buf, int sender_cap) const {
|
|
|
|
|
const char* body = skipReplyPrefix(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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 18:20:42 +02:00
|
|
|
// Build "@[nick] " prefix from a channel message text ("nick: body") into _reply_prefix.
|
2026-05-18 15:12:11 +02:00
|
|
|
// 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;
|
2026-05-18 18:18:45 +02:00
|
|
|
if (slen > 31) slen = 31;
|
|
|
|
|
snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.*s] ", slen, text);
|
2026-05-18 15:12:11 +02:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void startReply(bool to_channel) {
|
|
|
|
|
_sending_to_channel = to_channel;
|
|
|
|
|
_reply_mode = true;
|
|
|
|
|
setupMsgPick();
|
|
|
|
|
_phase = MSG_PICK;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 11:38:12 +02:00
|
|
|
// Recipient chosen while sharing — open the keyboard with the prepared text.
|
|
|
|
|
void beginShareCompose(bool channel) {
|
|
|
|
|
_sending_to_channel = channel;
|
|
|
|
|
_reply_mode = false;
|
2026-06-08 20:42:18 +02:00
|
|
|
_kb->begin(_share_text);
|
2026-06-04 11:38:12 +02:00
|
|
|
_phase = KEYBOARD;
|
|
|
|
|
}
|
|
|
|
|
|
feat(nav): navigate to / save a location shared in a message
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>
2026-06-04 11:28:17 +02:00
|
|
|
// 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) {
|
2026-06-14 13:37:03 +02:00
|
|
|
int csel = _ctx_menu.selectedIndex();
|
|
|
|
|
FsAct a = (FsAct)_fs_act[(csel >= 0 && csel < _fs_act_n) ? csel : 0];
|
feat(nav): navigate to / save a location shared in a message
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>
2026-06-04 11:28:17 +02:00
|
|
|
_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);
|
|
|
|
|
}
|
2026-06-09 15:09:57 +02:00
|
|
|
_task->addWaypoint(_nav_lat, _nav_lon, label);
|
feat(nav): navigate to / save a location shared in a message
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>
2026-06-04 11:28:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 00:12:40 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
refactor(ui): apply code review fixes — bugs, risk, optimisations, cleanup
Fixes 7 bugs (timing UB on unsigned long, abs() no-op, XBM CRC PROGMEM
scan, CayenneLPP heap thrash ×2, UTF-8 reply prefix, lock-seq reset on
wake), 3 risk items (notif melody stop guard, bot trigger pre-lowercase,
lock-seq cleared on display wake), plus optimisations (fmtMsgAge/
getTextWidth caching, RTTTL builder consolidated to NodePrefs, HP_*
constants and homePageLabel table moved to NodePrefs, DataStore flat
lambda schema, translateUTF8Static factored out) and minor cleanup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:03:45 +02:00
|
|
|
static void fmtMsgAge(char* buf, int n, uint32_t timestamp, uint32_t now) {
|
2026-05-19 20:21:32 +02:00
|
|
|
if (timestamp == 0 || now < timestamp) { buf[0] = '\0'; return; }
|
|
|
|
|
uint32_t age = now - timestamp;
|
|
|
|
|
if (age < 60) snprintf(buf, n, "%us", age);
|
|
|
|
|
else if (age < 3600) snprintf(buf, n, "%um", age / 60);
|
|
|
|
|
else if (age < 86400) snprintf(buf, n, "%uh", age / 3600);
|
|
|
|
|
else snprintf(buf, n, ">1d");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 00:12:40 +02:00
|
|
|
// count history entries for a specific channel
|
|
|
|
|
int histCountForChannel(int ch_idx) const {
|
|
|
|
|
int n = 0;
|
|
|
|
|
for (int i = 0; i < _hist_count; i++) {
|
|
|
|
|
if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++;
|
|
|
|
|
}
|
|
|
|
|
return n;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// get ring-buffer position of j-th history entry for channel (newest first)
|
|
|
|
|
int histEntryForChannel(int ch_idx, int j) const {
|
|
|
|
|
int n = 0;
|
|
|
|
|
for (int i = _hist_count - 1; i >= 0; i--) {
|
|
|
|
|
int pos = (_hist_head + i) % CH_HIST_MAX;
|
|
|
|
|
if (_hist[pos].ch_idx == (uint8_t)ch_idx) {
|
|
|
|
|
if (n == j) return pos;
|
|
|
|
|
n++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
2026-06-15 09:42:29 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ack_tag != 0 marks an outgoing DM as awaiting an end-to-end ACK by
|
|
|
|
|
// ack_deadline_ms; 0 means "sent, no confirmation possible" (no path / incoming).
|
|
|
|
|
// msg_ts = sender-perspective timestamp (send ts for outgoing / sender_timestamp
|
|
|
|
|
// for incoming); resends = remaining auto-resends for an outgoing pending DM.
|
|
|
|
|
void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
|
|
|
|
|
uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0,
|
|
|
|
|
uint32_t msg_ts = 0, uint8_t resends = 0) {
|
2026-05-17 00:12:40 +02:00
|
|
|
int pos;
|
|
|
|
|
if (_dm_hist_count < DM_HIST_MAX) {
|
|
|
|
|
pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX;
|
|
|
|
|
_dm_hist_count++;
|
|
|
|
|
} else {
|
|
|
|
|
pos = _dm_hist_head;
|
|
|
|
|
_dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX;
|
|
|
|
|
}
|
|
|
|
|
memcpy(_dm_hist[pos].prefix, pub_key, 4);
|
|
|
|
|
_dm_hist[pos].outgoing = outgoing ? 1 : 0;
|
2026-06-28 11:00:45 +02:00
|
|
|
// Prefer the sender's own timestamp — a room-sync replay or an
|
|
|
|
|
// offline-queued message held by a repeater can arrive long after it was
|
|
|
|
|
// actually sent, so "now" would mislabel every backlog message as fresh.
|
|
|
|
|
// Fall back to receipt time only when the sender's timestamp is unknown.
|
|
|
|
|
_dm_hist[pos].timestamp = msg_ts ? msg_ts : rtc_clock.getCurrentTime();
|
2026-05-17 00:12:40 +02:00
|
|
|
strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1);
|
|
|
|
|
_dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0';
|
2026-06-14 23:33:16 +02:00
|
|
|
_dm_hist[pos].ack_status = (outgoing && ack_tag) ? ACK_PENDING : ACK_NONE;
|
|
|
|
|
_dm_hist[pos].ack_tag = ack_tag;
|
|
|
|
|
_dm_hist[pos].ack_deadline_ms = ack_deadline_ms;
|
|
|
|
|
_dm_hist[pos].msg_ts = msg_ts;
|
|
|
|
|
_dm_hist[pos].attempt = 0;
|
|
|
|
|
_dm_hist[pos].resends_left = (outgoing && ack_tag) ? resends : 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Effective status for display. A pending ACK only reads as failed once its
|
|
|
|
|
// deadline has passed AND no auto-resends remain — while resends_left > 0 the
|
|
|
|
|
// entry stays pending (tickDmResends() retries / finalises it). Safety net for
|
|
|
|
|
// when the tick hasn't run yet; the tick is the authority that writes ACK_FAIL.
|
|
|
|
|
AckState dmEffectiveStatus(const DmHistEntry& e) const {
|
|
|
|
|
if (e.ack_status == ACK_PENDING && e.resends_left == 0 &&
|
|
|
|
|
(int32_t)(millis() - e.ack_deadline_ms) >= 0)
|
|
|
|
|
return ACK_FAIL;
|
|
|
|
|
return (AckState)e.ack_status;
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int dmHistCountForContact(const uint8_t* prefix) const {
|
|
|
|
|
int n = 0;
|
|
|
|
|
for (int i = 0; i < _dm_hist_count; i++)
|
|
|
|
|
if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++;
|
|
|
|
|
return n;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest
|
|
|
|
|
int n = 0;
|
|
|
|
|
for (int i = _dm_hist_count - 1; i >= 0; i--) {
|
|
|
|
|
int pos = (_dm_hist_head + i) % DM_HIST_MAX;
|
|
|
|
|
if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) {
|
|
|
|
|
if (n == j) return pos;
|
|
|
|
|
n++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void afterSend(bool ok, const char* msg) {
|
2026-05-18 15:12:11 +02:00
|
|
|
_reply_mode = false;
|
2026-06-04 11:38:12 +02:00
|
|
|
_share_mode = false;
|
2026-05-17 00:12:40 +02:00
|
|
|
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) {
|
|
|
|
|
_hist[pos].relay_status = ACK_PENDING;
|
|
|
|
|
_hist[pos].relay_seq = the_mesh.lastChannelRelaySeq();
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
// After inserting sent msg at index 0, the unread index range is stale.
|
|
|
|
|
// User is active in this channel — treat as fully read.
|
|
|
|
|
_ch_unread[_sel_channel_idx] = 0;
|
|
|
|
|
_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;
|
|
|
|
|
storeDMMsg(_sel_contact.id.pub_key, true, msg, _last_ack_tag,
|
|
|
|
|
_last_ack_deadline_ms, _last_send_ts, resends);
|
2026-05-17 00:12:40 +02:00
|
|
|
_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;
|
2026-05-17 00:12:40 +02:00
|
|
|
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();
|
2026-05-17 00:12:40 +02:00
|
|
|
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;
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-05-24 20:07:46 +02:00
|
|
|
// Build _sorted and counts[] in one pass — avoids a second getContactByIdx loop.
|
|
|
|
|
int counts[MAX_CONTACTS];
|
2026-05-17 00:12:40 +02:00
|
|
|
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;
|
2026-05-24 20:07:46 +02:00
|
|
|
counts[_num_contacts] = dmHistCountForContact(c.id.pub_key);
|
2026-05-17 00:12:40 +02:00
|
|
|
_sorted[_num_contacts++] = i;
|
|
|
|
|
}
|
2026-05-23 09:19:27 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void buildChannelList() {
|
2026-05-27 17:33:52 +02:00
|
|
|
NodePrefs* p = _task->getNodePrefs();
|
|
|
|
|
bool fav_only = (p && p->ch_fav_only);
|
2026-05-17 00:12:40 +02:00
|
|
|
_num_channels = 0;
|
|
|
|
|
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
|
|
|
|
ChannelDetails ch;
|
2026-05-27 17:33:52 +02:00
|
|
|
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;
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on
|
|
|
|
|
uint8_t chNotifState(uint8_t ch_idx) const {
|
|
|
|
|
NodePrefs* p = _task->getNodePrefs();
|
|
|
|
|
if (!p) return 0;
|
|
|
|
|
uint64_t mask = 1ULL << ch_idx;
|
|
|
|
|
if (!(p->ch_notif_override & mask)) return 0;
|
|
|
|
|
return (p->ch_notif_muted & mask) ? 1 : 2;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void setChNotifState(uint8_t ch_idx, uint8_t state) {
|
|
|
|
|
NodePrefs* p = _task->getNodePrefs();
|
|
|
|
|
if (!p) return;
|
|
|
|
|
uint64_t mask = 1ULL << ch_idx;
|
|
|
|
|
if (state == 0) {
|
|
|
|
|
p->ch_notif_override &= ~mask;
|
|
|
|
|
p->ch_notif_muted &= ~mask;
|
|
|
|
|
} else if (state == 1) {
|
|
|
|
|
p->ch_notif_override |= mask;
|
|
|
|
|
p->ch_notif_muted |= mask;
|
|
|
|
|
} else {
|
|
|
|
|
p->ch_notif_override |= mask;
|
|
|
|
|
p->ch_notif_muted &= ~mask;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uint8_t dmNotifState(const uint8_t* pub_key) const {
|
|
|
|
|
NodePrefs* p = _task->getNodePrefs();
|
|
|
|
|
if (!p) return 0;
|
|
|
|
|
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++)
|
|
|
|
|
if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0)
|
|
|
|
|
return p->dm_notif[i].state;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void setDmNotifState(const uint8_t* pub_key, uint8_t state) {
|
|
|
|
|
NodePrefs* p = _task->getNodePrefs();
|
|
|
|
|
if (!p) return;
|
|
|
|
|
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) {
|
|
|
|
|
if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) {
|
|
|
|
|
if (state == 0) { memset(&p->dm_notif[i], 0, sizeof(p->dm_notif[i])); }
|
|
|
|
|
else p->dm_notif[i].state = state;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (state == 0) return;
|
|
|
|
|
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) {
|
|
|
|
|
if (p->dm_notif[i].state == 0) {
|
|
|
|
|
memcpy(p->dm_notif[i].prefix, pub_key, 4);
|
|
|
|
|
p->dm_notif[i].state = state;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// table full — overwrite slot 0
|
|
|
|
|
memcpy(p->dm_notif[0].prefix, pub_key, 4);
|
|
|
|
|
p->dm_notif[0].state = state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uint8_t chNotifMelody(uint8_t ch_idx) const {
|
|
|
|
|
NodePrefs* p = _task->getNodePrefs();
|
|
|
|
|
if (!p) return 0;
|
|
|
|
|
uint64_t mask = 1ULL << ch_idx;
|
|
|
|
|
if (!(p->ch_notif_melody_set & mask)) return 0;
|
|
|
|
|
return (p->ch_notif_melody_2 & mask) ? 2 : 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void setChNotifMelody(uint8_t ch_idx, uint8_t slot) {
|
|
|
|
|
NodePrefs* p = _task->getNodePrefs();
|
|
|
|
|
if (!p) return;
|
|
|
|
|
uint64_t mask = 1ULL << ch_idx;
|
|
|
|
|
if (slot == 0) {
|
|
|
|
|
p->ch_notif_melody_set &= ~mask;
|
|
|
|
|
p->ch_notif_melody_2 &= ~mask;
|
|
|
|
|
} else if (slot == 1) {
|
|
|
|
|
p->ch_notif_melody_set |= mask;
|
|
|
|
|
p->ch_notif_melody_2 &= ~mask;
|
|
|
|
|
} else {
|
|
|
|
|
p->ch_notif_melody_set |= mask;
|
|
|
|
|
p->ch_notif_melody_2 |= mask;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uint8_t dmMelodySlot(const uint8_t* pub_key) const {
|
|
|
|
|
NodePrefs* p = _task->getNodePrefs();
|
|
|
|
|
if (!p) return 0;
|
|
|
|
|
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++)
|
|
|
|
|
if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0)
|
|
|
|
|
return p->dm_melody[i].slot;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void setDmMelody(const uint8_t* pub_key, uint8_t slot) {
|
|
|
|
|
NodePrefs* p = _task->getNodePrefs();
|
|
|
|
|
if (!p) return;
|
|
|
|
|
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) {
|
|
|
|
|
if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) {
|
|
|
|
|
if (slot == 0) memset(&p->dm_melody[i], 0, sizeof(p->dm_melody[i]));
|
|
|
|
|
else p->dm_melody[i].slot = slot;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (slot == 0) return;
|
|
|
|
|
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) {
|
|
|
|
|
if (p->dm_melody[i].slot == 0) {
|
|
|
|
|
memcpy(p->dm_melody[i].prefix, pub_key, 4);
|
|
|
|
|
p->dm_melody[i].slot = slot;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
memcpy(p->dm_melody[0].prefix, pub_key, 4);
|
|
|
|
|
p->dm_melody[0].slot = slot;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public:
|
2026-06-08 20:42:18 +02:00
|
|
|
QuickMsgScreen(UITask* task, KeyboardWidget* kb)
|
|
|
|
|
: _task(task), _kb(kb), _phase(MODE_SELECT), _mode_sel(0),
|
2026-06-26 10:55:24 +02:00
|
|
|
_contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), _login_mode(false),
|
2026-05-17 00:12:40 +02:00
|
|
|
_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),
|
|
|
|
|
_hist_head(0), _hist_count(0),
|
|
|
|
|
_dm_hist_head(0), _dm_hist_count(0),
|
|
|
|
|
_dm_hist_sel(-1), _dm_hist_scroll(0),
|
2026-05-25 07:58:45 +02:00
|
|
|
_ctx_dirty(false), _pin_picker_active(false), _dm_direct_entry(false), _reply_mode(false) {
|
2026-05-17 00:12:40 +02:00
|
|
|
memset(_ch_unread, 0, sizeof(_ch_unread));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 23:33:16 +02:00
|
|
|
// Returns the ring position the message was stored at, or -1 if rejected, so
|
2026-06-28 11:00:45 +02:00
|
|
|
// the outgoing path can attach a relay seq to that exact entry. `timestamp`
|
|
|
|
|
// is the sender's own send time (0 = unknown/outgoing — use receipt time);
|
|
|
|
|
// see storeDMMsg() for why this matters for synced/queued backlog messages.
|
|
|
|
|
int addChannelMsg(uint8_t ch_idx, const char* text, uint32_t timestamp = 0) {
|
2026-05-30 23:57:12 +02:00
|
|
|
// Guard against bogus channel indices (e.g. findChannelIdx() returned -1
|
|
|
|
|
// and was cast to uint8_t → 255). Storing such an entry would burn a ring
|
|
|
|
|
// slot for a message that no visible channel can ever surface.
|
2026-06-14 23:33:16 +02:00
|
|
|
if (ch_idx >= MAX_GROUP_CHANNELS) return -1;
|
2026-05-17 00:12:40 +02:00
|
|
|
int pos;
|
|
|
|
|
if (_hist_count < CH_HIST_MAX) {
|
|
|
|
|
pos = (_hist_head + _hist_count) % CH_HIST_MAX;
|
|
|
|
|
_hist_count++;
|
|
|
|
|
} else {
|
|
|
|
|
pos = _hist_head;
|
2026-05-31 16:23:07 +02:00
|
|
|
// Evicting the oldest entry — drop its share of the unread counter so
|
|
|
|
|
// the badge can't claim a message the ring no longer holds.
|
|
|
|
|
uint8_t evicted = _hist[pos].ch_idx;
|
|
|
|
|
if (evicted < MAX_GROUP_CHANNELS && _ch_unread[evicted] > 0) {
|
|
|
|
|
_ch_unread[evicted]--;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
_hist_head = (_hist_head + 1) % CH_HIST_MAX;
|
|
|
|
|
}
|
|
|
|
|
_hist[pos].ch_idx = ch_idx;
|
2026-06-28 11:00:45 +02:00
|
|
|
_hist[pos].timestamp = timestamp ? timestamp : rtc_clock.getCurrentTime();
|
2026-05-17 00:12:40 +02:00
|
|
|
strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1);
|
|
|
|
|
_hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0';
|
2026-06-14 23:33:16 +02:00
|
|
|
_hist[pos].relay_status = ACK_NONE;
|
|
|
|
|
_hist[pos].relay_seq = 0;
|
2026-05-17 00:12:40 +02:00
|
|
|
|
2026-05-30 23:57:12 +02:00
|
|
|
bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx);
|
|
|
|
|
if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++;
|
2026-06-14 23:33:16 +02:00
|
|
|
return pos;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Called when a repeater echo of one of our channel sends is heard.
|
|
|
|
|
void markChannelRelayed(uint32_t seq) {
|
|
|
|
|
if (seq == 0) return;
|
|
|
|
|
for (int i = 0; i < _hist_count; i++) {
|
|
|
|
|
ChHistEntry& e = _hist[(_hist_head + i) % CH_HIST_MAX];
|
|
|
|
|
if (e.relay_status == ACK_PENDING && e.relay_seq == seq) {
|
|
|
|
|
e.relay_status = ACK_OK;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
|
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) {
|
|
|
|
|
// Drop retried copies of an incoming DM: a resend reuses the sender's
|
|
|
|
|
// timestamp and text but carries a fresh packet hash, so the mesh dup-filter
|
|
|
|
|
// lets it through. Match on prefix + sender_timestamp + text to suppress it.
|
|
|
|
|
if (!outgoing && sender_timestamp != 0) {
|
|
|
|
|
for (int i = 0; i < _dm_hist_count; i++) {
|
|
|
|
|
const DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
|
|
|
|
|
if (!e.outgoing && e.msg_ts == sender_timestamp &&
|
|
|
|
|
memcmp(e.prefix, pub_key, 4) == 0 && strcmp(e.text, text) == 0)
|
|
|
|
|
return; // duplicate retry — already in history
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
storeDMMsg(pub_key, outgoing, text, 0, 0, outgoing ? 0 : sender_timestamp, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Called when an end-to-end ACK arrives (routed from MyMesh::onAckRecv).
|
|
|
|
|
// Marks the matching pending outgoing DM as delivered.
|
|
|
|
|
void markDmDelivered(uint32_t ack_crc) {
|
|
|
|
|
if (ack_crc == 0) return;
|
|
|
|
|
for (int i = 0; i < _dm_hist_count; i++) {
|
|
|
|
|
DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
|
|
|
|
|
if (e.outgoing && e.ack_status == ACK_PENDING && e.ack_tag == ack_crc) {
|
|
|
|
|
e.ack_status = ACK_OK;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 10:55:24 +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 {
|
|
|
|
|
for (int i = 0; i < _room_login_count; i++)
|
|
|
|
|
if (memcmp(_room_login_prefix[i], 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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
} 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 23:33:16 +02:00
|
|
|
// Look up a contact by 4-byte pub_key prefix (as stored in DmHistEntry).
|
|
|
|
|
bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) const {
|
|
|
|
|
int total = the_mesh.getNumContacts();
|
|
|
|
|
for (int i = 0; i < total; i++) {
|
|
|
|
|
ContactInfo c;
|
|
|
|
|
if (the_mesh.getContactByIdx(i, c) && memcmp(c.id.pub_key, prefix, 4) == 0) {
|
|
|
|
|
out = c;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Background tick (called every UI loop, regardless of the active screen) that
|
|
|
|
|
// drives auto-resend of on-device DMs. When a pending DM passes its deadline
|
|
|
|
|
// with no ACK: resend with the next attempt# (reusing the original timestamp so
|
|
|
|
|
// the recipient dedups) while resends remain, else mark it failed (✗).
|
|
|
|
|
void tickDmResends() {
|
|
|
|
|
uint32_t now = millis();
|
|
|
|
|
for (int i = 0; i < _dm_hist_count; i++) {
|
|
|
|
|
DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
|
|
|
|
|
if (!e.outgoing || e.ack_status != ACK_PENDING) continue;
|
|
|
|
|
if ((int32_t)(now - e.ack_deadline_ms) < 0) continue; // still waiting
|
|
|
|
|
if (e.resends_left == 0) { e.ack_status = ACK_FAIL; continue; }
|
|
|
|
|
ContactInfo c;
|
|
|
|
|
if (!contactByPrefix(e.prefix, c)) { e.ack_status = ACK_FAIL; continue; }
|
|
|
|
|
uint32_t expected_ack = 0, est_timeout = 0;
|
|
|
|
|
uint8_t next_attempt = e.attempt + 1;
|
|
|
|
|
if (the_mesh.sendMessage(c, e.msg_ts, next_attempt, e.text,
|
|
|
|
|
expected_ack, est_timeout) > 0 && expected_ack) {
|
|
|
|
|
e.attempt = next_attempt;
|
|
|
|
|
e.ack_tag = expected_ack; // each attempt has a distinct ACK CRC
|
|
|
|
|
e.ack_deadline_ms = now + est_timeout + 4000;
|
|
|
|
|
e.resends_left--;
|
|
|
|
|
} else {
|
|
|
|
|
e.ack_status = ACK_FAIL; // couldn't compose/send — give up
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int getDMUnreadTotal() const {
|
|
|
|
|
return _task->getDMUnreadTotal();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int getTotalChannelUnread() const {
|
|
|
|
|
int total = 0;
|
|
|
|
|
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i];
|
|
|
|
|
return total;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 07:45:40 +02:00
|
|
|
void markReadAlert(int n) {
|
|
|
|
|
char msg[32];
|
|
|
|
|
snprintf(msg, sizeof(msg), "%d marked read", n);
|
|
|
|
|
_task->showAlert(msg, 800);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 00:12:40 +02:00
|
|
|
void clearAllChannelUnread() {
|
|
|
|
|
memset(_ch_unread, 0, sizeof(_ch_unread));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-05-17 00:12:40 +02:00
|
|
|
// 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;
|
2026-05-17 00:12:40 +02:00
|
|
|
// Each step down from 0 sees one more message; seen count = max_seen + 1.
|
|
|
|
|
int remaining = _unread_at_entry - (_viewing_max_seen + 1);
|
|
|
|
|
_ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-06-26 10:55:24 +02:00
|
|
|
_login_mode = false;
|
2026-05-17 00:12:40 +02:00
|
|
|
buildContactList();
|
|
|
|
|
buildChannelList();
|
|
|
|
|
|
|
|
|
|
_ctx_menu.active = false;
|
|
|
|
|
_ctx_dirty = false;
|
feat(nav): navigate to / save a location shared in a message
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>
2026-06-04 11:28:17 +02:00
|
|
|
_nav_active = false;
|
2026-06-04 11:38:12 +02:00
|
|
|
_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;
|
2026-05-24 23:30:16 +02:00
|
|
|
_pin_picker_active = false;
|
2026-05-25 07:58:45 +02:00
|
|
|
_dm_direct_entry = false;
|
2026-05-17 00:12:40 +02:00
|
|
|
_unread_at_entry = 0;
|
|
|
|
|
_viewing_max_seen = 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 08:08:12 +02:00
|
|
|
// Recent DM contacts, newest first, deduped. Resolves the 4-byte
|
|
|
|
|
// _dm_hist prefix to a 6-byte pub_key prefix by walking the contact
|
|
|
|
|
// list once per unique sender. Returns the number filled.
|
|
|
|
|
int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const {
|
|
|
|
|
int n = 0;
|
|
|
|
|
for (int i = _dm_hist_count - 1; i >= 0 && n < max; i--) {
|
|
|
|
|
int pos = (_dm_hist_head + i) % DM_HIST_MAX;
|
|
|
|
|
const uint8_t* p4 = _dm_hist[pos].prefix;
|
|
|
|
|
// Skip if already collected.
|
|
|
|
|
bool dup = false;
|
|
|
|
|
for (int j = 0; j < n; j++) if (memcmp(out[j], p4, 4) == 0) { dup = true; break; }
|
|
|
|
|
if (dup) continue;
|
|
|
|
|
// Find a real contact whose pub_key starts with this 4-byte prefix.
|
|
|
|
|
for (int idx = 0; ; idx++) {
|
|
|
|
|
ContactInfo c;
|
|
|
|
|
if (!the_mesh.getContactByIdx(idx, c)) break;
|
|
|
|
|
if (memcmp(c.id.pub_key, p4, 4) == 0) {
|
|
|
|
|
memcpy(out[n], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
|
|
|
|
|
n++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return n;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 07:52:56 +02:00
|
|
|
// Jump straight into a contact's DM history (used by the Favourites dial).
|
2026-05-25 07:58:45 +02:00
|
|
|
// 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.
|
2026-06-04 11:38:12 +02:00
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 07:52:56 +02:00
|
|
|
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;
|
2026-05-25 07:58:45 +02:00
|
|
|
_dm_direct_entry = true;
|
2026-05-25 07:52:56 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-17 00:12:40 +02:00
|
|
|
int render(DisplayDriver& display) override {
|
|
|
|
|
display.setTextSize(1);
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
feat(nav): navigate to / save a location shared in a message
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>
2026-06-04 11:28:17 +02:00
|
|
|
// Navigate-to-location view sits over everything else while active.
|
|
|
|
|
if (_nav_active) { renderNav(display); return 1000; }
|
|
|
|
|
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
int lh = display.getLineHeight();
|
|
|
|
|
int item_h = display.lineStep();
|
|
|
|
|
int start_y = display.listStart();
|
|
|
|
|
int cw = display.getCharWidth();
|
|
|
|
|
|
2026-05-17 00:12:40 +02:00
|
|
|
if (_phase == MODE_SELECT) {
|
refactor(ui): shared drawList + header + key-decode helpers
- 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>
2026-06-15 21:23:00 +02:00
|
|
|
display.drawCenteredHeader("MESSAGE");
|
2026-05-17 00:12:40 +02:00
|
|
|
const char* opts[] = { "Direct message", "Channels", "Room Servers" };
|
|
|
|
|
int badges[3] = {
|
|
|
|
|
getDMUnreadTotal(),
|
|
|
|
|
_task->getChannelUnreadCount(),
|
|
|
|
|
_task->getRoomUnreadCount()
|
|
|
|
|
};
|
|
|
|
|
for (int i = 0; i < 3; i++) {
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
int y = start_y + i * item_h;
|
2026-05-17 00:12:40 +02:00
|
|
|
bool sel = (i == _mode_sel);
|
refactor(ui): drawSelectionRow helper for the 5-line invert/restore pattern
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>
2026-05-24 20:33:02 +02:00
|
|
|
display.drawSelectionRow(0, y - 1, display.width(), item_h - 1, sel);
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.setCursor(2, y);
|
2026-05-17 00:12:40 +02:00
|
|
|
display.print(opts[i]);
|
|
|
|
|
if (badges[i] > 0) {
|
|
|
|
|
char badge[5];
|
|
|
|
|
snprintf(badge, sizeof(badge), "%d", badges[i]);
|
|
|
|
|
int bw = display.getTextWidth(badge) + 2;
|
|
|
|
|
display.setCursor(display.width() - bw, y);
|
|
|
|
|
display.print(badge);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-24 22:43:31 +02:00
|
|
|
if (_ctx_menu.active) _ctx_menu.render(display);
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
} else if (_phase == CONTACT_PICK) {
|
refactor(ui): shared drawList + header + key-decode helpers
- 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>
2026-06-15 21:23:00 +02:00
|
|
|
display.drawCenteredHeader(_room_mode ? "SELECT ROOM" : "SELECT CONTACT");
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
if (_num_contacts == 0) {
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
display.drawTextCentered(display.width()/2, display.height()/2, _room_mode ? "No room servers" : "No favourites");
|
2026-05-17 00:12:40 +02:00
|
|
|
return 5000;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 08:54:11 +02:00
|
|
|
drawList(display, _num_contacts, _contact_sel, _contact_scroll, [&](int list_idx, int y, bool sel, int reserve) {
|
2026-05-17 00:12:40 +02:00
|
|
|
int mesh_idx = _sorted[list_idx];
|
|
|
|
|
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
char badge[5] = "";
|
|
|
|
|
int bw = 0;
|
|
|
|
|
if (dm_unread > 0) {
|
|
|
|
|
snprintf(badge, sizeof(badge), "%d", (int)dm_unread);
|
|
|
|
|
bw = display.getTextWidth(badge) + 2;
|
|
|
|
|
}
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.drawTextEllipsized(2, y, display.width() - 2 - bw - 1 - reserve, filtered);
|
2026-05-17 00:12:40 +02:00
|
|
|
if (dm_unread > 0) {
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.setCursor(display.width() - bw + 1 - reserve, y);
|
2026-05-17 00:12:40 +02:00
|
|
|
display.print(badge);
|
|
|
|
|
}
|
|
|
|
|
}
|
refactor(ui): shared drawList + header + key-decode helpers
- 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>
2026-06-15 21:23:00 +02:00
|
|
|
});
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
// Context menu overlay
|
|
|
|
|
if (_ctx_menu.active) _ctx_menu.render(display);
|
|
|
|
|
|
|
|
|
|
} else if (_phase == CHANNEL_PICK) {
|
refactor(ui): shared drawList + header + key-decode helpers
- 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>
2026-06-15 21:23:00 +02:00
|
|
|
display.drawCenteredHeader("SELECT CHANNEL");
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
if (_num_channels == 0) {
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
display.drawTextCentered(display.width()/2, display.height()/2, "No channels");
|
2026-05-17 00:12:40 +02:00
|
|
|
return 5000;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 08:54:11 +02:00
|
|
|
drawList(display, _num_channels, _channel_sel, _channel_scroll, [&](int list_idx, int y, bool sel, int reserve) {
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
|
2026-05-17 00:12:40 +02:00
|
|
|
ChannelDetails ch;
|
|
|
|
|
if (the_mesh.getChannel(_channel_indices[list_idx], ch)) {
|
|
|
|
|
uint8_t unread = _ch_unread[_channel_indices[list_idx]];
|
|
|
|
|
char badge[5] = "";
|
|
|
|
|
int bw = 0;
|
|
|
|
|
if (unread > 0) {
|
|
|
|
|
snprintf(badge, sizeof(badge), "%d", (int)unread);
|
|
|
|
|
bw = display.getTextWidth(badge) + 2;
|
|
|
|
|
}
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.drawTextEllipsized(2, y, display.width() - 4 - bw - reserve, ch.name);
|
2026-05-17 00:12:40 +02:00
|
|
|
if (unread > 0) {
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.setCursor(display.width() - bw - reserve, y);
|
2026-05-17 00:12:40 +02:00
|
|
|
display.print(badge);
|
|
|
|
|
}
|
|
|
|
|
}
|
refactor(ui): shared drawList + header + key-decode helpers
- 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>
2026-06-15 21:23:00 +02:00
|
|
|
});
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
// 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 = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
|
|
|
|
|
if (ring_pos >= 0) {
|
|
|
|
|
const DmHistEntry& e = _dm_hist[ring_pos];
|
2026-06-22 18:31:47 +02:00
|
|
|
char sender_buf[33];
|
|
|
|
|
const char* body = dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM,
|
|
|
|
|
filtered_name, sender_buf, sizeof(sender_buf));
|
|
|
|
|
const char* sender = sender_buf;
|
2026-05-17 00:12:40 +02:00
|
|
|
int dm_count = dmHistCountForContact(_sel_contact.id.pub_key);
|
2026-06-22 18:31:47 +02:00
|
|
|
int ret = _dm_fs.render(display, sender, body,
|
2026-05-18 16:11:25 +02:00
|
|
|
_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,
|
|
|
|
|
dmEffectiveStatus(e), e.attempt + 1);
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
}
|
2026-05-18 16:11:25 +02:00
|
|
|
if (_ctx_menu.active) _ctx_menu.render(display);
|
|
|
|
|
return ret;
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
return 500;
|
|
|
|
|
}
|
|
|
|
|
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
int hist_start_y = display.headerH();
|
2026-05-22 23:13:27 +02:00
|
|
|
int cby = display.height() - lh - 2;
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
|
2026-05-17 00:12:40 +02:00
|
|
|
char title[24];
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
snprintf(title, sizeof(title), "%.23s", filtered_name);
|
|
|
|
|
display.drawTextCentered(display.width()/2, 0, title);
|
2026-05-22 23:13:27 +02:00
|
|
|
display.fillRect(0, lh + 1, display.width(), display.sepH());
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
int dm_count = dmHistCountForContact(_sel_contact.id.pub_key);
|
refactor(ui): apply code review fixes — bugs, risk, optimisations, cleanup
Fixes 7 bugs (timing UB on unsigned long, abs() no-op, XBM CRC PROGMEM
scan, CayenneLPP heap thrash ×2, UTF-8 reply prefix, lock-seq reset on
wake), 3 risk items (notif melody stop guard, bot trigger pre-lowercase,
lock-seq cleared on display wake), plus optimisations (fmtMsgAge/
getTextWidth caching, RTTTL builder consolidated to NodePrefs, HP_*
constants and homePageLabel table moved to NodePrefs, DataStore flat
lambda schema, translateUTF8Static factored out) and minor cleanup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:03:45 +02:00
|
|
|
uint32_t now_ts = rtc_clock.getCurrentTime();
|
2026-06-22 18:31:47 +02:00
|
|
|
bool is_room = (_sel_contact.type == ADV_TYPE_ROOM);
|
2026-05-17 00:12:40 +02:00
|
|
|
|
2026-05-27 23:47:30 +02:00
|
|
|
// 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;
|
2026-06-16 09:32:09 +02:00
|
|
|
// 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 = dmHistEntryForContact(_sel_contact.id.pub_key, idx);
|
|
|
|
|
return rp >= 0 ? _dm_hist[rp].text : nullptr;
|
|
|
|
|
});
|
|
|
|
|
int reserve = hs.reserve;
|
2026-05-27 23:47:30 +02:00
|
|
|
{
|
|
|
|
|
const int fixed_bh = 2 * lh + 1;
|
|
|
|
|
int cur_y = hist_start_y;
|
|
|
|
|
for (int ii = 0; ii < MAX_VIS_BOXES && (_dm_hist_scroll + ii) < dm_count; ii++) {
|
|
|
|
|
int bh = fixed_bh;
|
|
|
|
|
if (portrait_expand) {
|
|
|
|
|
int rp = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_scroll + ii);
|
|
|
|
|
if (rp >= 0) {
|
2026-06-22 18:31:47 +02:00
|
|
|
char hsb[33];
|
|
|
|
|
const char* hbody = dmDisplayParts(_dm_hist[rp], is_room, filtered_name, hsb, sizeof(hsb));
|
|
|
|
|
display.translateUTF8ToBlocks(s_wrap_trans, hbody, sizeof(s_wrap_trans));
|
2026-06-16 08:54:11 +02:00
|
|
|
int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8);
|
2026-05-27 23:47:30 +02:00
|
|
|
bh = (1 + (nl > 0 ? nl : 1)) * lh + 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (cur_y + bh > cby) break;
|
|
|
|
|
box_ys[n_vis] = cur_y; box_hs[n_vis] = bh; n_vis++;
|
|
|
|
|
cur_y += bh + (portrait_expand ? 2 : 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_hist_visible = n_vis;
|
|
|
|
|
for (int i = 0; i < n_vis && (_dm_hist_scroll + i) < dm_count; i++) {
|
2026-05-17 00:12:40 +02:00
|
|
|
int item = _dm_hist_scroll + i;
|
|
|
|
|
bool sel = (item == _dm_hist_sel);
|
2026-05-27 23:47:30 +02:00
|
|
|
int y = box_ys[i];
|
|
|
|
|
int bh = box_hs[i];
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, item);
|
|
|
|
|
if (ring_pos < 0) continue;
|
|
|
|
|
|
|
|
|
|
const DmHistEntry& e = _dm_hist[ring_pos];
|
2026-06-22 18:31:47 +02:00
|
|
|
char sender_buf[33];
|
|
|
|
|
const char* body = dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf));
|
|
|
|
|
const char* sender = sender_buf;
|
2026-05-17 00:12:40 +02:00
|
|
|
|
refactor(ui): apply code review fixes — bugs, risk, optimisations, cleanup
Fixes 7 bugs (timing UB on unsigned long, abs() no-op, XBM CRC PROGMEM
scan, CayenneLPP heap thrash ×2, UTF-8 reply prefix, lock-seq reset on
wake), 3 risk items (notif melody stop guard, bot trigger pre-lowercase,
lock-seq cleared on display wake), plus optimisations (fmtMsgAge/
getTextWidth caching, RTTTL builder consolidated to NodePrefs, HP_*
constants and homePageLabel table moved to NodePrefs, DataStore flat
lambda schema, translateUTF8Static factored out) and minor cleanup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:03:45 +02:00
|
|
|
char age[6]; fmtMsgAge(age, sizeof(age), e.timestamp, now_ts);
|
2026-05-19 20:21:32 +02:00
|
|
|
int age_w = age[0] ? display.getTextWidth(age) + 3 : 0;
|
|
|
|
|
|
2026-05-17 00:12:40 +02:00
|
|
|
if (sel) {
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.fillRect(0, y, display.width() - reserve, bh);
|
2026-05-17 00:12:40 +02:00
|
|
|
display.setColor(DisplayDriver::DARK);
|
|
|
|
|
} else {
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.drawRect(0, y, display.width() - reserve, bh);
|
|
|
|
|
display.fillRect(1, y + 1, display.width() - 2 - reserve, lh);
|
2026-05-17 00:12:40 +02:00
|
|
|
display.setColor(DisplayDriver::DARK);
|
2026-05-27 23:47:30 +02:00
|
|
|
}
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender);
|
2026-06-14 23:33:16 +02:00
|
|
|
if (e.outgoing) { // delivery marker after "Me"
|
|
|
|
|
int gx = 3 + display.getTextWidth(sender) + 3;
|
|
|
|
|
drawAckGlyph(display, gx, y + 1, dmEffectiveStatus(e), e.attempt + 1);
|
|
|
|
|
}
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
if (age[0]) { display.setCursor(display.width() - age_w - reserve, y + 1); display.print(age); }
|
2026-05-27 23:47:30 +02:00
|
|
|
if (!sel) display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
if (portrait_expand) {
|
2026-06-22 18:31:47 +02:00
|
|
|
display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans));
|
2026-06-16 08:54:11 +02:00
|
|
|
int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8);
|
|
|
|
|
for (int li = 0; li < nl; li++) { display.setCursor(3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); }
|
2026-05-27 23:47:30 +02:00
|
|
|
} else {
|
2026-06-22 18:31:47 +02:00
|
|
|
display.drawTextEllipsized(3, y + lh + 1, display.width() - cw - 2, body);
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dm_count == 0) {
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet");
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-16 09:32:09 +02:00
|
|
|
// Scrollbar: track pinned to the full list area; thumb sized/positioned
|
|
|
|
|
// from hs's pixel metrics (stable while scrolling the same list).
|
|
|
|
|
if (hs.need)
|
|
|
|
|
drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px,
|
|
|
|
|
hs.total_px, hs.view_px, hs.scroll_px);
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
bool compose_sel = (_dm_hist_sel == -1);
|
|
|
|
|
const char* ctxt = "[+ send]";
|
|
|
|
|
int ctw = display.getTextWidth(ctxt);
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
int cbx = 1;
|
2026-05-17 00:12:40 +02:00
|
|
|
if (compose_sel) {
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
display.fillRect(cbx, cby - 1, ctw + 4, lh + 1);
|
2026-05-17 00:12:40 +02:00
|
|
|
display.setColor(DisplayDriver::DARK);
|
|
|
|
|
}
|
|
|
|
|
display.setCursor(cbx + 2, cby);
|
|
|
|
|
display.print(ctxt);
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-18 16:11:25 +02:00
|
|
|
if (_ctx_menu.active) _ctx_menu.render(display);
|
2026-05-17 00:12:40 +02:00
|
|
|
return dm_count > 0 ? 500 : 2000;
|
|
|
|
|
|
|
|
|
|
} else if (_phase == CHANNEL_HIST) {
|
|
|
|
|
if (_fs.active && _hist_sel >= 0) {
|
|
|
|
|
int fs_hist_count = histCountForChannel(_sel_channel_idx);
|
|
|
|
|
int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel);
|
|
|
|
|
if (ring_pos >= 0) {
|
|
|
|
|
const char* ftext = _hist[ring_pos].text;
|
|
|
|
|
const char* fsep = strstr(ftext, ": ");
|
2026-06-04 12:10:26 +02:00
|
|
|
char fsender[33], fmsg[MSG_TEXT_BUF];
|
2026-05-17 00:12:40 +02:00
|
|
|
if (fsep) {
|
2026-06-04 12:10:26 +02:00
|
|
|
int nl = fsep - ftext; if (nl > (int)sizeof(fsender) - 1) nl = sizeof(fsender) - 1;
|
2026-05-17 00:12:40 +02:00
|
|
|
strncpy(fsender, ftext, nl); fsender[nl] = '\0';
|
|
|
|
|
strncpy(fmsg, fsep + 2, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0';
|
|
|
|
|
} else {
|
2026-05-31 00:11:43 +02:00
|
|
|
strcpy(fsender, "?");
|
2026-05-17 00:12:40 +02:00
|
|
|
strncpy(fmsg, ftext, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0';
|
|
|
|
|
}
|
2026-05-18 16:11:25 +02:00
|
|
|
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 && _hist[ring_pos].relay_status == ACK_OK) {
|
|
|
|
|
display.setColor(DisplayDriver::DARK);
|
|
|
|
|
drawAckGlyph(display, 2 + display.getTextWidth(fsender) + 3, 1, ACK_OK);
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
}
|
2026-05-18 16:11:25 +02:00
|
|
|
if (_ctx_menu.active) _ctx_menu.render(display);
|
|
|
|
|
return ret;
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
return 2000;
|
|
|
|
|
}
|
|
|
|
|
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
int hist_start_y = display.headerH();
|
2026-05-22 23:13:27 +02:00
|
|
|
int cby = display.height() - lh - 2;
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
|
2026-05-17 00:12:40 +02:00
|
|
|
ChannelDetails ch;
|
|
|
|
|
the_mesh.getChannel(_sel_channel_idx, ch);
|
|
|
|
|
char title[24];
|
|
|
|
|
snprintf(title, sizeof(title), "%.23s", ch.name);
|
|
|
|
|
display.drawTextCentered(display.width()/2, 0, title);
|
2026-05-22 23:13:27 +02:00
|
|
|
display.fillRect(0, lh + 1, display.width(), display.sepH());
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
int ch_hist_count = histCountForChannel(_sel_channel_idx);
|
refactor(ui): apply code review fixes — bugs, risk, optimisations, cleanup
Fixes 7 bugs (timing UB on unsigned long, abs() no-op, XBM CRC PROGMEM
scan, CayenneLPP heap thrash ×2, UTF-8 reply prefix, lock-seq reset on
wake), 3 risk items (notif melody stop guard, bot trigger pre-lowercase,
lock-seq cleared on display wake), plus optimisations (fmtMsgAge/
getTextWidth caching, RTTTL builder consolidated to NodePrefs, HP_*
constants and homePageLabel table moved to NodePrefs, DataStore flat
lambda schema, translateUTF8Static factored out) and minor cleanup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:03:45 +02:00
|
|
|
uint32_t now_ts = rtc_clock.getCurrentTime();
|
2026-05-17 00:12:40 +02:00
|
|
|
|
2026-05-27 23:47:30 +02:00
|
|
|
// 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;
|
2026-06-16 09:32:09 +02:00
|
|
|
// 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 = histEntryForChannel(_sel_channel_idx, idx);
|
|
|
|
|
if (rp < 0) return nullptr;
|
|
|
|
|
const char* t = _hist[rp].text;
|
|
|
|
|
const char* s = strstr(t, ": ");
|
|
|
|
|
return s ? s + 2 : t;
|
|
|
|
|
});
|
|
|
|
|
int reserve = hs.reserve;
|
2026-05-27 23:47:30 +02:00
|
|
|
{
|
|
|
|
|
const int fixed_bh = 2 * lh + 1;
|
|
|
|
|
int cur_y = hist_start_y;
|
|
|
|
|
for (int ii = 0; ii < MAX_VIS_BOXES && (_hist_scroll + ii) < ch_hist_count; ii++) {
|
|
|
|
|
int bh = fixed_bh;
|
|
|
|
|
if (portrait_expand) {
|
|
|
|
|
int rp = histEntryForChannel(_sel_channel_idx, _hist_scroll + ii);
|
|
|
|
|
if (rp >= 0) {
|
|
|
|
|
const char* rtext = _hist[rp].text;
|
|
|
|
|
const char* rsep = strstr(rtext, ": ");
|
|
|
|
|
const char* rbody = rsep ? rsep + 2 : rtext;
|
2026-06-16 08:54:11 +02:00
|
|
|
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);
|
2026-05-27 23:47:30 +02:00
|
|
|
bh = (1 + (nl > 0 ? nl : 1)) * lh + 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (cur_y + bh > cby) break;
|
|
|
|
|
box_ys[n_vis] = cur_y; box_hs[n_vis] = bh; n_vis++;
|
|
|
|
|
cur_y += bh + (portrait_expand ? 2 : 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_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
|
2026-05-27 23:47:30 +02:00
|
|
|
|
|
|
|
|
for (int i = 0; i < n_vis && (_hist_scroll + i) < ch_hist_count; i++) {
|
2026-05-17 00:12:40 +02:00
|
|
|
int item = _hist_scroll + i;
|
|
|
|
|
bool sel = (item == _hist_sel);
|
2026-05-27 23:47:30 +02:00
|
|
|
int y = box_ys[i];
|
|
|
|
|
int bh = box_hs[i];
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
int ring_pos = histEntryForChannel(_sel_channel_idx, item);
|
|
|
|
|
if (ring_pos < 0) continue;
|
|
|
|
|
|
|
|
|
|
const char* text = _hist[ring_pos].text;
|
|
|
|
|
const char* sep = strstr(text, ": ");
|
2026-06-04 12:10:26 +02:00
|
|
|
char sender[33], msg_part[MSG_TEXT_BUF];
|
2026-05-17 00:12:40 +02:00
|
|
|
if (sep) {
|
2026-06-04 12:10:26 +02:00
|
|
|
int nl = sep - text; if (nl > (int)sizeof(sender) - 1) nl = sizeof(sender) - 1;
|
2026-05-17 00:12:40 +02:00
|
|
|
strncpy(sender, text, nl); sender[nl] = '\0';
|
|
|
|
|
strncpy(msg_part, sep + 2, sizeof(msg_part) - 1);
|
|
|
|
|
msg_part[sizeof(msg_part) - 1] = '\0';
|
|
|
|
|
} else {
|
2026-05-31 00:11:43 +02:00
|
|
|
strcpy(sender, "?");
|
2026-05-17 00:12:40 +02:00
|
|
|
strncpy(msg_part, text, sizeof(msg_part) - 1);
|
|
|
|
|
msg_part[sizeof(msg_part) - 1] = '\0';
|
|
|
|
|
}
|
|
|
|
|
|
refactor(ui): apply code review fixes — bugs, risk, optimisations, cleanup
Fixes 7 bugs (timing UB on unsigned long, abs() no-op, XBM CRC PROGMEM
scan, CayenneLPP heap thrash ×2, UTF-8 reply prefix, lock-seq reset on
wake), 3 risk items (notif melody stop guard, bot trigger pre-lowercase,
lock-seq cleared on display wake), plus optimisations (fmtMsgAge/
getTextWidth caching, RTTTL builder consolidated to NodePrefs, HP_*
constants and homePageLabel table moved to NodePrefs, DataStore flat
lambda schema, translateUTF8Static factored out) and minor cleanup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:03:45 +02:00
|
|
|
char age[6]; fmtMsgAge(age, sizeof(age), _hist[ring_pos].timestamp, now_ts);
|
2026-05-19 20:21:32 +02:00
|
|
|
int age_w = age[0] ? display.getTextWidth(age) + 3 : 0;
|
|
|
|
|
|
2026-05-17 00:12:40 +02:00
|
|
|
if (sel) {
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.fillRect(0, y, display.width() - reserve, bh);
|
2026-05-17 00:12:40 +02:00
|
|
|
display.setColor(DisplayDriver::DARK);
|
|
|
|
|
} else {
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.drawRect(0, y, display.width() - reserve, bh);
|
|
|
|
|
display.fillRect(1, y + 1, display.width() - 2 - reserve, lh);
|
2026-05-17 00:12:40 +02:00
|
|
|
display.setColor(DisplayDriver::DARK);
|
2026-05-27 23:47:30 +02:00
|
|
|
}
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender);
|
2026-06-14 23:33:16 +02:00
|
|
|
// Channels have no recipient ACK — only show ✓ once a repeater echo
|
|
|
|
|
// confirms the send was relayed into the mesh; otherwise no marker
|
|
|
|
|
// (absence is normal, not a failure).
|
|
|
|
|
if (strcmp(sender, "Me") == 0 && _hist[ring_pos].relay_status == ACK_OK) {
|
|
|
|
|
int gx = 3 + display.getTextWidth(sender) + 3;
|
|
|
|
|
drawAckGlyph(display, gx, y + 1, ACK_OK);
|
|
|
|
|
}
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
if (age[0]) { display.setCursor(display.width() - age_w - reserve, y + 1); display.print(age); }
|
2026-05-27 23:47:30 +02:00
|
|
|
if (!sel) display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
if (portrait_expand) {
|
2026-06-16 08:54:11 +02:00
|
|
|
display.translateUTF8ToBlocks(s_wrap_trans, skipReplyPrefix(msg_part), sizeof(s_wrap_trans));
|
|
|
|
|
int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8);
|
|
|
|
|
for (int li = 0; li < nl; li++) { display.setCursor(3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); }
|
2026-05-27 23:47:30 +02:00
|
|
|
} else {
|
2026-05-23 14:45:24 +02:00
|
|
|
display.drawTextEllipsized(3, y + lh + 1, display.width() - cw - 2, skipReplyPrefix(msg_part));
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (ch_hist_count == 0) {
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet");
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-16 09:32:09 +02:00
|
|
|
// Scrollbar: track pinned to the full list area; thumb from hs metrics.
|
|
|
|
|
if (hs.need)
|
|
|
|
|
drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px,
|
|
|
|
|
hs.total_px, hs.view_px, hs.scroll_px);
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
// small compose button (bottom-left, always bordered, inverted when selected)
|
|
|
|
|
bool compose_sel = (_hist_sel == -1);
|
|
|
|
|
const char* ctxt = "[+ send]";
|
|
|
|
|
int ctw = display.getTextWidth(ctxt);
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
int cbx = 1;
|
2026-05-17 00:12:40 +02:00
|
|
|
if (compose_sel) {
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
display.fillRect(cbx - 1, cby - 1, ctw + 4, lh + 2);
|
2026-05-17 00:12:40 +02:00
|
|
|
display.setColor(DisplayDriver::DARK);
|
|
|
|
|
} else {
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
display.drawRect(cbx - 1, cby - 1, ctw + 4, lh + 2);
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
display.setCursor(cbx + 1, cby);
|
|
|
|
|
display.print(ctxt);
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-18 16:11:25 +02:00
|
|
|
if (_ctx_menu.active) _ctx_menu.render(display);
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
} else if (_phase == KEYBOARD) {
|
2026-06-08 20:42:18 +02:00
|
|
|
return _kb->render(display);
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
} else { // MSG_PICK
|
|
|
|
|
char title[24];
|
2026-05-18 15:12:11 +02:00
|
|
|
if (_reply_mode) {
|
2026-05-18 18:18:45 +02:00
|
|
|
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);
|
2026-05-18 15:12:11 +02:00
|
|
|
} else if (_sending_to_channel) {
|
2026-05-17 00:12:40 +02:00
|
|
|
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);
|
|
|
|
|
}
|
refactor(ui): shared drawList + header + key-decode helpers
- 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>
2026-06-15 21:23:00 +02:00
|
|
|
display.drawCenteredHeader(title);
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
int total_msg_items = 1 + _active_msg_count;
|
2026-06-16 08:54:11 +02:00
|
|
|
drawList(display, total_msg_items, _msg_sel, _msg_scroll, [&](int idx, int y, bool sel, int reserve) {
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
if (idx == 0) {
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.setCursor(2, y);
|
2026-05-17 00:12:40 +02:00
|
|
|
display.print("Custom message...");
|
|
|
|
|
} else {
|
|
|
|
|
NodePrefs* p = _task->getNodePrefs();
|
|
|
|
|
int slot = _active_msgs[idx - 1];
|
|
|
|
|
const char* tmpl = p ? p->custom_msgs[slot] : "";
|
feat(ui): proportional scrollbar with mini-icon caps; reclaim row space
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>
2026-06-15 14:41:46 +02:00
|
|
|
display.drawTextEllipsized(2, y, display.width() - 4 - reserve, tmpl);
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
refactor(ui): shared drawList + header + key-decode helpers
- 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>
2026-06-15 21:23:00 +02:00
|
|
|
});
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
return 2000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool handleInput(char c) override {
|
feat(nav): navigate to / save a location shared in a message
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>
2026-06-04 11:28:17 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
if (_phase == MODE_SELECT) {
|
2026-05-24 22:43:31 +02:00
|
|
|
// Context menu (Mark-all-read) takes precedence while active.
|
|
|
|
|
if (_ctx_menu.active) {
|
|
|
|
|
auto res = _ctx_menu.handleInput(c);
|
|
|
|
|
if (res == PopupMenu::SELECTED) {
|
2026-05-24 22:46:54 +02:00
|
|
|
int cleared = 0;
|
2026-05-24 22:43:31 +02:00
|
|
|
if (_mode_sel == 0) {
|
2026-05-24 22:46:54 +02:00
|
|
|
cleared = getDMUnreadTotal();
|
2026-05-24 22:43:31 +02:00
|
|
|
_task->clearAllDMUnread();
|
|
|
|
|
} else if (_mode_sel == 1) {
|
2026-05-24 22:46:54 +02:00
|
|
|
cleared = _task->getChannelUnreadCount();
|
2026-05-24 22:43:31 +02:00
|
|
|
clearAllChannelUnread();
|
|
|
|
|
} else {
|
2026-05-24 22:46:54 +02:00
|
|
|
cleared = _task->getRoomUnreadCount();
|
2026-05-24 22:43:31 +02:00
|
|
|
_task->clearRoomUnread();
|
|
|
|
|
}
|
2026-06-09 07:45:40 +02:00
|
|
|
markReadAlert(cleared);
|
2026-05-24 22:43:31 +02:00
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; }
|
2026-06-18 09:16:57 +02:00
|
|
|
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; }
|
2026-05-24 22:43:31 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
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) {
|
2026-06-26 10:55:24 +02:00
|
|
|
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)) {
|
|
|
|
|
_login_mode = true;
|
|
|
|
|
_kb->begin("", 15); // room/repeater password: max 15 chars
|
|
|
|
|
_phase = KEYBOARD;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-29 00:25:02 +02:00
|
|
|
// LEFT/RIGHT cycle Notif/Melody in-place (menu stays open).
|
|
|
|
|
if (!_pin_picker_active && _num_contacts > 0) {
|
|
|
|
|
bool left = (c == KEY_LEFT || c == KEY_PREV);
|
|
|
|
|
bool right = (c == KEY_RIGHT || c == KEY_NEXT);
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
auto res = _ctx_menu.handleInput(c);
|
2026-05-24 23:30:16 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
if (res == PopupMenu::SELECTED && _num_contacts > 0) {
|
|
|
|
|
ContactInfo ci;
|
|
|
|
|
if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) {
|
2026-05-29 00:25:02 +02:00
|
|
|
int sel = _ctx_menu.selectedIndex();
|
|
|
|
|
if (sel == 0) {
|
2026-06-09 07:43:51 +02:00
|
|
|
int cleared = (int)_task->getDMUnread(ci.id.pub_key);
|
2026-05-17 00:12:40 +02:00
|
|
|
_task->clearDMUnread(ci.id.pub_key);
|
2026-06-09 07:45:40 +02:00
|
|
|
markReadAlert(cleared);
|
2026-05-29 00:25:02 +02:00
|
|
|
} else if (sel == 3) {
|
2026-05-24 23:30:16 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
2026-05-29 00:25:02 +02:00
|
|
|
// sel == 1 (Notif) and sel == 2 (Melody): already cycled via LEFT/RIGHT; ENTER just closes.
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (res != PopupMenu::NONE && _ctx_dirty) {
|
|
|
|
|
the_mesh.savePrefs(); _ctx_dirty = false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (c == KEY_CANCEL) { _room_mode = false; _phase = MODE_SELECT; return true; }
|
2026-06-16 08:54:11 +02:00
|
|
|
// drawList() reclamps _contact_scroll from _contact_sel every render.
|
2026-06-18 09:16:57 +02:00
|
|
|
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; }
|
2026-05-17 00:12:40 +02:00
|
|
|
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; }
|
2026-06-26 10:55:24 +02:00
|
|
|
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
|
|
|
|
|
_phase = KEYBOARD;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
_task->clearDMUnread(_sel_contact.id.pub_key);
|
|
|
|
|
_dm_hist_sel = -1;
|
|
|
|
|
_dm_hist_scroll = 0;
|
|
|
|
|
_dm_fs.active = false;
|
|
|
|
|
_phase = DM_HIST;
|
2026-06-04 11:38:12 +02:00
|
|
|
if (_share_mode) beginShareCompose(false);
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-06-26 10:55:24 +02:00
|
|
|
if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && _room_mode) {
|
|
|
|
|
_ctx_menu.begin("Room options", 1);
|
|
|
|
|
_ctx_menu.addItem("Login...");
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
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)]); }
|
2026-05-24 23:30:16 +02:00
|
|
|
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");
|
2026-05-17 00:12:40 +02:00
|
|
|
_ctx_menu.begin("Contact options", 3);
|
|
|
|
|
_ctx_menu.addItem("Mark as read");
|
|
|
|
|
_ctx_menu.addItem(_ctx_notif_item);
|
|
|
|
|
_ctx_menu.addItem(_ctx_melody_item);
|
2026-05-24 23:30:16 +02:00
|
|
|
_ctx_menu.addItem(_ctx_pin_item);
|
2026-05-17 00:12:40 +02:00
|
|
|
_ctx_dirty = false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} else if (_phase == CHANNEL_PICK) {
|
|
|
|
|
// Context menu consumes all input while open
|
|
|
|
|
if (_ctx_menu.active) {
|
2026-05-29 00:25:02 +02:00
|
|
|
// LEFT/RIGHT cycle Notif/Melody/Fav in-place (menu stays open).
|
|
|
|
|
if (_num_channels > 0) {
|
|
|
|
|
bool left = (c == KEY_LEFT || c == KEY_PREV);
|
|
|
|
|
bool right = (c == KEY_RIGHT || c == KEY_NEXT);
|
|
|
|
|
if (left || right) {
|
|
|
|
|
static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" };
|
|
|
|
|
static const char* ML[] = { "global", "M1", "M2" };
|
|
|
|
|
uint8_t ch_idx = _channel_indices[_channel_sel];
|
|
|
|
|
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;
|
|
|
|
|
buildChannelList();
|
|
|
|
|
if (_channel_sel >= _num_channels) _channel_sel = _num_channels > 0 ? _num_channels - 1 : 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
auto res = _ctx_menu.handleInput(c);
|
|
|
|
|
if (res == PopupMenu::SELECTED && _num_channels > 0) {
|
|
|
|
|
uint8_t ch_idx = _channel_indices[_channel_sel];
|
2026-05-29 00:25:02 +02:00
|
|
|
int sel = _ctx_menu.selectedIndex();
|
|
|
|
|
if (sel == 0) {
|
2026-06-09 07:43:51 +02:00
|
|
|
int cleared = (int)_ch_unread[ch_idx];
|
2026-05-17 00:12:40 +02:00
|
|
|
_ch_unread[ch_idx] = 0;
|
2026-06-09 07:45:40 +02:00
|
|
|
markReadAlert(cleared);
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
2026-05-29 00:25:02 +02:00
|
|
|
// sel 1/2/3 already handled by LEFT/RIGHT; ENTER just closes.
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
if (res != PopupMenu::NONE && _ctx_dirty) {
|
|
|
|
|
the_mesh.savePrefs(); _ctx_dirty = false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; }
|
2026-06-16 08:54:11 +02:00
|
|
|
// drawList() reclamps _channel_scroll from _channel_sel every render.
|
2026-06-18 09:16:57 +02:00
|
|
|
if (c == KEY_UP && _num_channels > 0) { _channel_sel = (_channel_sel > 0) ? _channel_sel - 1 : _num_channels - 1; return true; }
|
|
|
|
|
if (c == KEY_DOWN && _num_channels > 0) { _channel_sel = (_channel_sel < _num_channels - 1) ? _channel_sel + 1 : 0; return true; }
|
2026-05-17 00:12:40 +02:00
|
|
|
if (c == KEY_ENTER && _num_channels > 0) {
|
|
|
|
|
_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; }
|
2026-05-17 00:12:40 +02:00
|
|
|
int hc = histCountForChannel(_sel_channel_idx);
|
|
|
|
|
_unread_at_entry = (int)_ch_unread[_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.
|
2026-06-04 11:38:12 +02:00
|
|
|
if (_share_mode) beginShareCompose(true);
|
2026-05-17 00:12:40 +02:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (c == KEY_CONTEXT_MENU && _num_channels > 0) {
|
|
|
|
|
uint8_t ch_idx = _channel_indices[_channel_sel];
|
|
|
|
|
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)]); }
|
2026-05-27 17:33:52 +02:00
|
|
|
{ NodePrefs* p2 = _task->getNodePrefs();
|
|
|
|
|
bool is_fav = p2 && (p2->ch_fav_bitmask & (1ULL << ch_idx));
|
2026-05-27 23:47:30 +02:00
|
|
|
snprintf(_ctx_ch_fav_item, sizeof(_ctx_ch_fav_item), is_fav ? "Fav: yes" : "Fav: no"); }
|
2026-05-27 17:33:52 +02:00
|
|
|
_ctx_menu.begin("Channel options", 4);
|
2026-05-17 00:12:40 +02:00
|
|
|
_ctx_menu.addItem("Mark all read");
|
|
|
|
|
_ctx_menu.addItem(_ctx_notif_item);
|
|
|
|
|
_ctx_menu.addItem(_ctx_melody_item);
|
2026-05-27 17:33:52 +02:00
|
|
|
_ctx_menu.addItem(_ctx_ch_fav_item);
|
2026-05-17 00:12:40 +02:00
|
|
|
_ctx_dirty = false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} else if (_phase == DM_HIST) {
|
|
|
|
|
int dm_count = dmHistCountForContact(_sel_contact.id.pub_key);
|
|
|
|
|
if (_dm_fs.active) {
|
2026-05-18 15:12:11 +02:00
|
|
|
if (_ctx_menu.active) {
|
|
|
|
|
auto res = _ctx_menu.handleInput(c);
|
|
|
|
|
if (res == PopupMenu::SELECTED) {
|
feat(nav): navigate to / save a location shared in a message
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>
2026-06-04 11:28:17 +02:00
|
|
|
dispatchFsAction(false);
|
2026-05-18 15:12:11 +02:00
|
|
|
} else if (res != PopupMenu::NONE) {
|
|
|
|
|
_ctx_menu.active = false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
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;
|
2026-05-18 15:12:11 +02:00
|
|
|
} else if (res == FullscreenMsgView::REPLY) {
|
|
|
|
|
int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
|
feat(nav): navigate to / save a location shared in a message
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>
2026-06-04 11:28:17 +02:00
|
|
|
if (ring_pos >= 0) {
|
|
|
|
|
bool reply_ok = !_dm_hist[ring_pos].outgoing;
|
|
|
|
|
if (reply_ok) {
|
|
|
|
|
char _tname[32]; DisplayDriver::translateUTF8Static(_tname, _sel_contact.name, sizeof(_tname));
|
|
|
|
|
snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _tname);
|
|
|
|
|
}
|
|
|
|
|
buildFsMenu(_dm_hist[ring_pos].text, reply_ok);
|
2026-05-18 15:12:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (_ctx_menu.active) {
|
|
|
|
|
auto res = _ctx_menu.handleInput(c);
|
|
|
|
|
if (res == PopupMenu::SELECTED) {
|
2026-06-04 11:57:29 +02:00
|
|
|
dispatchFsAction(false);
|
2026-05-18 15:12:11 +02:00
|
|
|
} else if (res != PopupMenu::NONE) {
|
|
|
|
|
_ctx_menu.active = false;
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-25 07:58:45 +02:00
|
|
|
if (c == KEY_CANCEL) {
|
|
|
|
|
if (_dm_direct_entry) {
|
|
|
|
|
_dm_direct_entry = false;
|
|
|
|
|
_task->gotoHomeScreen();
|
|
|
|
|
} else {
|
|
|
|
|
_phase = CONTACT_PICK;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
if (c == KEY_UP) {
|
|
|
|
|
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_DOWN) {
|
|
|
|
|
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++;
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
if (_dm_hist_sel >= _dm_hist_scroll + _hist_visible)
|
|
|
|
|
_dm_hist_scroll = _dm_hist_sel - _hist_visible + 1;
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-05-18 15:12:11 +02:00
|
|
|
if (c == KEY_CONTEXT_MENU && _dm_hist_sel >= 0) {
|
|
|
|
|
int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
|
2026-06-04 11:57:29 +02:00
|
|
|
if (ring_pos >= 0) {
|
|
|
|
|
bool reply_ok = !_dm_hist[ring_pos].outgoing;
|
|
|
|
|
if (reply_ok) {
|
|
|
|
|
char _tname[32]; DisplayDriver::translateUTF8Static(_tname, _sel_contact.name, sizeof(_tname));
|
|
|
|
|
snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _tname);
|
|
|
|
|
}
|
|
|
|
|
buildFsMenu(_dm_hist[ring_pos].text, reply_ok);
|
2026-05-18 15:12:11 +02:00
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
} else if (_phase == CHANNEL_HIST) {
|
|
|
|
|
int ch_hist_count = histCountForChannel(_sel_channel_idx);
|
|
|
|
|
if (_fs.active) {
|
2026-05-18 15:12:11 +02:00
|
|
|
if (_ctx_menu.active) {
|
|
|
|
|
auto res = _ctx_menu.handleInput(c);
|
|
|
|
|
if (res == PopupMenu::SELECTED) {
|
feat(nav): navigate to / save a location shared in a message
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>
2026-06-04 11:28:17 +02:00
|
|
|
dispatchFsAction(true);
|
2026-05-18 15:12:11 +02:00
|
|
|
} else if (res != PopupMenu::NONE) {
|
|
|
|
|
_ctx_menu.active = false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
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;
|
2026-05-18 15:12:11 +02:00
|
|
|
} else if (res == FullscreenMsgView::REPLY) {
|
|
|
|
|
int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel);
|
feat(nav): navigate to / save a location shared in a message
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>
2026-06-04 11:28:17 +02:00
|
|
|
if (ring_pos >= 0)
|
|
|
|
|
buildFsMenu(_hist[ring_pos].text, buildChannelReplyPrefix(_hist[ring_pos].text));
|
2026-05-18 15:12:11 +02:00
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (_ctx_menu.active) {
|
|
|
|
|
auto res = _ctx_menu.handleInput(c);
|
|
|
|
|
if (res == PopupMenu::SELECTED) {
|
2026-06-04 11:57:29 +02:00
|
|
|
dispatchFsAction(true);
|
2026-05-18 15:12:11 +02:00
|
|
|
} else if (res != PopupMenu::NONE) {
|
|
|
|
|
_ctx_menu.active = false;
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (c == KEY_CANCEL) { _phase = CHANNEL_PICK; return true; }
|
|
|
|
|
if (c == KEY_UP) {
|
|
|
|
|
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_DOWN) {
|
|
|
|
|
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++;
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
if (_hist_sel >= _hist_scroll + _hist_visible) _hist_scroll = _hist_sel - _hist_visible + 1;
|
2026-05-17 00:12:40 +02:00
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-05-18 15:12:11 +02:00
|
|
|
if (c == KEY_CONTEXT_MENU && _hist_sel >= 0) {
|
|
|
|
|
int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel);
|
2026-06-04 11:57:29 +02:00
|
|
|
if (ring_pos >= 0)
|
|
|
|
|
buildFsMenu(_hist[ring_pos].text, buildChannelReplyPrefix(_hist[ring_pos].text));
|
2026-05-18 15:12:11 +02:00
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
|
|
|
|
|
} else if (_phase == KEYBOARD) {
|
2026-06-08 20:42:18 +02:00
|
|
|
auto res = _kb->handleInput(c);
|
2026-06-26 10:55:24 +02:00
|
|
|
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);
|
|
|
|
|
_phase = CONTACT_PICK;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
if (res == KeyboardWidget::CANCELLED) {
|
2026-06-04 11:38:12 +02:00
|
|
|
if (_share_mode) { _share_mode = false; _task->gotoHomeScreen(); }
|
|
|
|
|
else { _phase = MSG_PICK; }
|
2026-05-17 00:12:40 +02:00
|
|
|
} else if (res == KeyboardWidget::DONE) {
|
2026-05-24 19:53:05 +02:00
|
|
|
int prefix_len = _reply_mode ? (int)strlen(_reply_prefix) : 0;
|
2026-06-08 20:42:18 +02:00
|
|
|
if (_kb->len > prefix_len) {
|
2026-05-24 19:53:05 +02:00
|
|
|
// Expand only the body — prefix "@[nick] " is preserved verbatim, so a nick
|
|
|
|
|
// that happens to contain a placeholder token isn't substituted.
|
2026-05-17 00:12:40 +02:00
|
|
|
char expanded[KB_MAX_LEN + 1];
|
2026-05-24 19:53:05 +02:00
|
|
|
if (prefix_len > 0) {
|
2026-06-08 20:42:18 +02:00
|
|
|
memcpy(expanded, _kb->buf, prefix_len);
|
|
|
|
|
expandMsg(_kb->buf + prefix_len, expanded + prefix_len, sizeof(expanded) - prefix_len);
|
2026-05-24 19:53:05 +02:00
|
|
|
} else {
|
2026-06-08 20:42:18 +02:00
|
|
|
expandMsg(_kb->buf, expanded, sizeof(expanded));
|
2026-05-24 19:53:05 +02:00
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
bool ok = sendText(expanded);
|
|
|
|
|
afterSend(ok, expanded);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
|
|
} else { // MSG_PICK
|
|
|
|
|
int total_msg_items = 1 + _active_msg_count;
|
|
|
|
|
if (c == KEY_CANCEL) {
|
2026-05-18 15:12:11 +02:00
|
|
|
_reply_mode = false;
|
2026-05-17 00:12:40 +02:00
|
|
|
_phase = _sending_to_channel ? CHANNEL_HIST : DM_HIST;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-06-16 08:54:11 +02:00
|
|
|
// drawList() reclamps _msg_scroll from _msg_sel every render.
|
2026-06-18 09:16:57 +02:00
|
|
|
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; }
|
2026-05-17 00:12:40 +02:00
|
|
|
if (c == KEY_ENTER) {
|
|
|
|
|
if (_msg_sel == 0) {
|
2026-06-08 20:42:18 +02:00
|
|
|
_kb->begin(_reply_mode ? _reply_prefix : "");
|
2026-06-09 15:09:57 +02:00
|
|
|
kbAddSensorPlaceholders(*_kb, &sensors);
|
2026-05-17 00:12:40 +02:00
|
|
|
_phase = KEYBOARD;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
NodePrefs* p = _task->getNodePrefs();
|
|
|
|
|
int slot = _active_msgs[_msg_sel - 1];
|
|
|
|
|
const char* tmpl = p ? p->custom_msgs[slot] : "OK";
|
2026-06-04 12:15:30 +02:00
|
|
|
char msg[MSG_TEXT_BUF];
|
2026-05-18 15:12:11 +02:00
|
|
|
if (_reply_mode) {
|
2026-06-04 12:15:30 +02:00
|
|
|
char body[MSG_TEXT_BUF];
|
2026-05-18 15:12:11 +02:00
|
|
|
expandMsg(tmpl, body, sizeof(body));
|
|
|
|
|
snprintf(msg, sizeof(msg), "%s%s", _reply_prefix, body);
|
|
|
|
|
} else {
|
|
|
|
|
expandMsg(tmpl, msg, sizeof(msg));
|
|
|
|
|
}
|
2026-05-17 00:12:40 +02:00
|
|
|
bool ok = sendText(msg);
|
|
|
|
|
afterSend(ok, msg);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|