feat(ui): make favourites mean one thing across every list

Three names had grown around one idea. "Favourite" was a filter in
Settings, an invisible app-only flag on a contact, a device-settable bit on
a channel, and — on the Nodes screen — a menu row that actually pinned to
the Favourites dial. Nothing marked a favourite on screen, and the dial
only took chat contacts.

A favourite is now the starred flag (ContactInfo::flags bit 0 for contacts
and rooms, ch_fav_bitmask for channels), settable on the device everywhere
via a Fav: ON/OFF row, marked with a star on its row, and sorted to the top
of the list — in Messages, Tools > Nodes and the Locator target picker.
Settings > Contacts > "Favs top" turns the sorting off; it defaults on, and
is stored inverted so an upgraded prefs file reads back as on rather than
off.

MyMesh::setContactFavourite() writes the same bit the app sets and bumps
lastmod, so the two stay in sync. The DMs/Rooms = Fav list filters no
longer depend on having starred someone in the app first.

Pinning is now separate and explicit, and the dial takes contacts, room
servers and channels. Slots carry a kind (NodePrefs::favourite_kinds,
schema 0xC0DE0029) — a channel slot holds an index, so emptiness is decided
by the kind first, since channel 0's payload is all zeroes. Choosing what
to pin reuses the Messages screen's own Direct/Channels/Rooms browse
instead of a second picker built on the dial, which drops that picker, its
key/label tables and the now-unused getRecentDMContacts(). A filled tile
gained Unpin/Replace under Hold Enter.

Fixes a room server being pinnable and then unremovable: the picker's
recent-conversations tier didn't filter by contact type and room posts
share the DM history, opening one from the dial skipped the login
handshake, and Unpin only existed in the chat contact list that rooms never
appear in.

Also: PopupMenu::_visible was written and never read (render recomputes the
cap from display height), which is why menus with more items than the
"visible" argument always worked; Settings' all/fav values and the
dm_show_all comment said things that were not true.

Built for Heltec V3, Wio Tracker L1, Wio Tracker L1 e-ink and T-Echo Card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-08-31 15:02:01 +02:00
co-authored by Claude Opus 5
parent 355816eafd
commit 57851627cf
18 changed files with 660 additions and 338 deletions
@@ -8,7 +8,7 @@
| :------------------------: | :------------------------: |
| ![](./overview_oled.png) | ![](./overview_eink.png) |
A dedicated home page showing a grid of up to 6 pinned contacts for quick access. The layout adapts to the display orientation:
A dedicated home page showing a grid of up to 6 pinned conversations — chat contacts, room servers or channels — for quick access. The layout adapts to the display orientation:
- **Portrait** (OLED, e-ink portrait) — 2 columns × 3 rows
- **Landscape** (e-ink landscape) — 3 columns × 2 rows
@@ -19,45 +19,53 @@ A dedicated home page showing a grid of up to 6 pinned contacts for quick access
Navigate tiles with **UP / DOWN / LEFT / RIGHT**. Pressing a directional key at the edge of the grid switches to the adjacent home page instead of wrapping.
**Enter on a filled tile** — opens that contact's DM directly.
**Enter on a filled tile** — opens that conversation directly: a contact's DM, a channel's history, or a room server (running the room's login handshake first if it isn't logged in yet).
**Enter on an empty tile (`+`)**opens a contact picker to fill the slot.
**Enter on an empty tile (`+`)**starts the picker to fill the slot.
**Hold Enter on a filled tile** — opens **Unpin** / **Replace** for that slot.
Channel tiles show their name with a leading `#`, so they read apart from contacts and rooms sharing the same grid.
---
### Unread badge
Filled tiles show an unread message count in the top-right corner when there are unread DMs from that contact. The contact name is ellipsized to make room for the badge.
Filled tiles show an unread message count in the top-right corner — unread DMs for a contact or room, unread posts for a channel. The name is ellipsized to make room for the badge.
If a pinned contact is removed from the contacts list — explicitly, or auto-evicted to make room when the table is full — its slot is freed automatically and goes back to an empty `+` tile.
If a pinned target disappears — a contact removed explicitly or auto-evicted to make room when the table is full, or a deleted channel — its slot is freed automatically and goes back to an empty `+` tile.
---
### Pinning a contact
### Pinning
**From the Favourites Dial** — press **Enter** on an empty tile (`+`). A picker opens showing:
1. Contacts marked as favourites in the upstream app (starred contacts) — listed first
2. Recent DM contacts — listed after
3. All remaining chat contacts — fallback when the first two tiers are empty (e.g. fresh install before any DMs)
**From the Favourites Dial** — press **Enter** on an empty tile (`+`), or **Hold Enter** on a filled one and choose **Replace**. This opens the **Messages** screen in its normal Direct / Channels / Rooms browse; pick an entry and you land back on the dial with that slot filled. It's the same list you already use to open a conversation, rather than a second browser of its own.
| OLED | E-Ink |
| :------------------------: | :------------------------: |
| ![](./picker_oled.png) | ![](./picker_eink.png) |
<!-- screenshot pending: Pin-to-dial contact picker (favourites first, then recent DMs, then all contacts) -->
<!-- screenshot pending: Messages browse entered from an empty dial tile -->
Select a contact to pin it to that slot.
**From the Messages lists****Hold Enter** on a contact, room or channel entry **Pin to dial**, then choose a slot from the slot picker (Slot 16, showing the current occupant or "empty").
**From a DM conversation****Hold Enter** context menu **Pin to dial**, then choose a slot from the slot picker (Slot 16, showing the current occupant name or "empty").
**From Tools Nodes****Hold Enter** on a node **Pin to dial**. This one doesn't ask which slot; it takes the first free one (and says which), since that menu is already long.
If the selected contact is already pinned in another slot, it is moved to the new slot automatically.
If the target is already pinned in another slot, it is moved to the new slot automatically.
---
### Unpinning a contact
### Unpinning
Open the contact's DM, **Hold Enter** context menu **Unpin (slot N)**.
**From the Favourites Dial** **Hold Enter** on the tile **Unpin**.
**From the Messages lists or Tools Nodes****Hold Enter** context menu **Unpin (slot N)**.
---
### Pinning is not the same as favouriting
A pinned tile and a **Fav: ON** entry are two independent things. Pinning puts something on this page. Favouriting marks it with a ★ and sorts it to the top of every list it appears in, and is what the **Settings Contacts** list filters read. Either can be used without the other.
---
@@ -109,18 +109,23 @@ Selecting the row opens a read-only list of the resolved hops — each shown as
| Item | Action |
| ---------------------------- | ------------------------------------------------------------------------------ |
| Mark as read | Clears unread counter for this contact |
| Notif: default / OFF / ON | Per-contact notification override — **LEFT/RIGHT** to cycle |
| Melody: global / M1 / M2 | Per-contact melody override — **LEFT/RIGHT** to cycle |
| Notif: Default / OFF / ON | Per-contact notification override — **LEFT/RIGHT** to cycle |
| Melody: Global / M1 / M2 | Per-contact melody override — **LEFT/RIGHT** to cycle |
| Fav: ON / OFF | Mark this contact as a favourite — **LEFT/RIGHT** to toggle |
| Pin to dial / Unpin (slot N) | Pin this contact to a Favourites Dial slot; if already pinned shows which slot |
**Fav** and **Pin to dial** are separate. **Fav** is the starred flag shared with the companion app: it marks the row with a ★, sorts it above the rest of the list (unless **Settings Contacts Favs top** is off), and drives the `DMs = Fav` list filter. **Pin to dial** puts the contact on the [Favourites Dial](../favourites_dial/favourites_dial.md) page and changes nothing about the list.
When **Pin to dial** is selected, a slot picker opens (Slot 16 showing current occupant name or "empty"). Choosing a slot that already holds another contact moves the new contact there.
In the **Rooms** list the context menu instead offers:
| Item | Action |
| ------- | ---------------------------------------------------------------------------- |
| ------------- | ---------------------------------------------------------------------------- |
| Login… | Opens the password prompt to (re-)log in to this room (see Rooms — logging in) |
| Logout | Only shown once logged in. Forgets the saved password so the next open prompts for one again |
| Fav: ON / OFF | Mark this room as a favourite — **LEFT/RIGHT** to toggle; drives the **Rooms = Fav** list filter |
| Pin to dial / Unpin (slot N) | Pin this room to a [Favourites Dial](../favourites_dial/favourites_dial.md) slot |
---
@@ -135,9 +140,10 @@ In the **Rooms** list the context menu instead offers:
| Item | Action |
| ------------------------- | --------------------------------------------------------------------- |
| Mark all read | Clears all unread for this channel |
| Notif: default / OFF / ON | Per-channel notification override — **LEFT/RIGHT** to cycle |
| Melody: global / M1 / M2 | Per-channel melody override — **LEFT/RIGHT** to cycle |
| Notif: Default / OFF / ON | Per-channel notification override — **LEFT/RIGHT** to cycle |
| Melody: Global / M1 / M2 | Per-channel melody override — **LEFT/RIGHT** to cycle |
| Fav: ON / OFF | Add or remove this channel from favourites — **LEFT/RIGHT** to toggle |
| Pin to dial / Unpin (slot N) | Pin this channel to a [Favourites Dial](../favourites_dial/favourites_dial.md) slot |
| Edit | Opens the Add/Edit form below, pre-filled with the channel's name |
| Delete | Removes the channel immediately (no confirm prompt) |
@@ -115,10 +115,15 @@ European Latin-diacritic letters (Polish, Czech, Slovak, German, French, Spanish
### Contacts
| Setting | Options | Notes |
| -------- | ---------------- | ---------------------------------------------------- |
| DMs | all / favourites | Show all chat contacts or only upstream-starred ones |
| Channels | all / favourites | Show all channels or only favourited ones |
| Rooms | all / favourites | Show all room servers or only favourited ones |
| -------- | --------- | ------------------------------------------- |
| DMs | All / Fav | Show all chat contacts or only favourited ones |
| Channels | All / Fav | Show all channels or only favourited ones |
| Rooms | All / Fav | Show all room servers or only favourited ones |
| Favs top | ON / OFF | Sort favourites to the top of every list (default ON) |
Favourites are set per item in its context menu (**Hold Enter** **Fav: ON / OFF**) — see [Message Screen](../message_screen/message_screen.md), and the same row exists in Tools Nodes. A contact's or room's favourite flag is the same one the companion app shows as a starred contact, so it syncs both ways; a channel's is device-only.
A favourite is marked with a ★ on its row wherever it is listed, and — unless **Favs top** is off — sorted above everything else. The three filters above are independent of that: they control what's *listed at all*, the sort only controls the order.
---
@@ -25,13 +25,13 @@ Filter by category with **LEFT/RIGHT** (one coherent axis — type only):
| Filter | Shows |
| ------ | ------------------------------ |
| All | All known nodes |
| Fav | Upstream-starred contacts only |
| Fav | Favourites only (★) |
| Comp | Companion (chat) nodes |
| Rpt | Repeaters |
| Room | Room servers |
| Snsr | Sensors |
Select a node to see its coordinates, distance, bearing with cardinal direction, type, and last-heard time. A node that is **broadcasting its position** via Live Share is marked with a **♦ diamond** beside its name in the list (the same marker the map uses), and its detail shows `Sharing pos:` with the share age and whether it's DM-verified or channel-only.
Select a node to see its coordinates, distance, bearing with cardinal direction, type, and last-heard time. A node that is **broadcasting its position** via Live Share is marked with a **♦ diamond** beside its name in the list (the same marker the map uses), and its detail shows `Sharing pos:` with the share age and whether it's DM-verified or channel-only. A **★ star** marks a favourite, which is also sorted to the top of the list unless **Settings Contacts Favs top** is off.
**Hold Enter** opens the same **Options** menu everywhere (list and detail), in a fixed order — only the actions that apply appear:
@@ -41,6 +41,8 @@ Select a node to see its coordinates, distance, bearing with cardinal direction,
| Ping | a public key is known for the node |
| Save waypoint | selected node has GPS |
| Set as target | selected node has GPS **and** a public key — pins it as the active **Locator/Nav target** right away (see **Locator**) |
| Fav: ON / OFF | selected node is a saved contact — the same starred flag the Messages lists use, shared with the companion app |
| Pin to dial / Unpin (slot N) | selected node is a saved contact — puts it on the [Favourites Dial](../favourites_dial/favourites_dial.md), taking the first free slot |
| Admin | selected node is a saved **repeater or room server** contact — opens **Tools Admin** for it directly (see **Admin**) |
| Sort: Dist/Recent | browsing stored nodes — **LEFT/RIGHT** on the row flips distance ↔ last-heard in place |
| Discover scan / Rescan | always (live `NODE_DISCOVER_REQ` scan) |
@@ -272,7 +274,7 @@ Navigate with **UP/DOWN**, change a value with **LEFT/RIGHT** (or **Enter**); **
| Setting | Options | Notes |
| ------- | -------------------------------- | -------------------------------------------------------------------------------------- |
| Alert | ON / OFF | Master switch. Enabling without a target prompts you to pick one. |
| Target | none / person / waypoint | **Enter** opens a picker — **None** first (clears the target), then **favourites** (offered even with no known position yet, so you can arm ahead of time), then any other contact with a currently-resolvable position (live-sharing *or* just last-advertised, e.g. a repeater), then waypoints; **UP/DOWN** + **Enter** to choose. **LEFT/RIGHT** quick-cycles the same set in place, including back to **None**. A person is shown with an `@` prefix, plus a compact **age tag** (e.g. `@Bob (5m)`) when the position is last-advertised rather than a live share. Shows `none` until set. |
| Target | none / person / waypoint | **Enter** opens a picker — **None** first (clears the target), then **dial-pinned people** (offered even with no known position yet, so you can arm ahead of time), then any other contact with a currently-resolvable position (live-sharing *or* just last-advertised, e.g. a repeater), then waypoints; **UP/DOWN** + **Enter** to choose. **LEFT/RIGHT** quick-cycles the same set in place, including back to **None**. A person is shown with an `@` prefix, a ★ if they're a favourite, plus a compact **age tag** (e.g. `@Bob (5m)`) when the position is last-advertised rather than a live share. Shows `none` until set. |
| Radius | 50 / 100 / 250 / 500 m / 1 km | Geofence size. |
| Mode | Arrive / Leave / Both | Which crossing fires the alert — entering the radius, leaving it, or both. |
| Beeper | ON / OFF | Optional homing tone — shown only in **Arrive** / **Both** modes (see below). |
+18
View File
@@ -593,6 +593,22 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
rd(_prefs.repeat_extra_scopes, sizeof(_prefs.repeat_extra_scopes));
_prefs.repeat_extra_scopes[sizeof(_prefs.repeat_extra_scopes) - 1] = '\0';
// → 0xC0DE0028: append favourite_kinds. A pre-0x28 file has that file's own
// 4-byte sentinel tail sitting here, so the first slots read back as 0x27/
// 0x00/0xDE/0xC0 — clamp anything unknown to CONTACT, which is what every
// slot saved before this bump actually was.
rd(_prefs.favourite_kinds, sizeof(_prefs.favourite_kinds));
for (uint8_t i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) {
if (_prefs.favourite_kinds[i] > NodePrefs::FAV_KIND_MAX)
_prefs.favourite_kinds[i] = NodePrefs::FAV_KIND_CONTACT;
}
// → 0xC0DE0029: append fav_sort_off. Inverted (see NodePrefs), so both a
// pre-0x29 file's stray sentinel byte here and a file that ends before this
// field clamp/zero to 0 = favourites on top, which is the default.
rd(&_prefs.fav_sort_off, sizeof(_prefs.fav_sort_off));
if (_prefs.fav_sort_off > 1) _prefs.fav_sort_off = 0;
// Schema sentinel: bumped on layout changes. Mismatch means an older file
// (or a different schema); rd() already zero-inits any fields not present,
// so we just log it — next savePrefs writes the current sentinel.
@@ -810,6 +826,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.cad_enabled, sizeof(_prefs.cad_enabled));
file.write((uint8_t *)&_prefs.repeat_scope_only, sizeof(_prefs.repeat_scope_only));
file.write((uint8_t *)_prefs.repeat_extra_scopes, sizeof(_prefs.repeat_extra_scopes));
file.write((uint8_t *)_prefs.favourite_kinds, sizeof(_prefs.favourite_kinds));
file.write((uint8_t *)&_prefs.fav_sort_off, sizeof(_prefs.fav_sort_off));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
// the one we check: once the flash fills, writes return 0, so a good
+11
View File
@@ -463,6 +463,17 @@ bool MyMesh::deleteContactByKey(const uint8_t* pub_key) {
return true;
}
// lastmod is bumped so the app's next 'since'-filtered contact sync picks the
// change up; the write itself is lazy, like every other contact edit.
bool MyMesh::setContactFavourite(const uint8_t* pub_key, bool fav) {
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c) return false;
if (fav) c->flags |= 0x01; else c->flags &= ~0x01;
c->lastmod = getRTCClock()->getCurrentTime();
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
return true;
}
void MyMesh::onContactPathUpdated(const ContactInfo &contact) {
out_frame[0] = PUSH_CODE_PATH_UPDATED;
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
+5
View File
@@ -268,6 +268,11 @@ public:
// CMD_SET_CHANNEL BLE handler already performs, so both paths stay in sync.
bool setChannelLocal(uint8_t idx, const ChannelDetails& ch);
// On-device favourite toggle (Messages > Contacts/Rooms > Fav). Writes the
// same ContactInfo::flags bit 0 the app's starred-contact flag uses, so the
// "fav" list filters no longer depend on the app having starred anything.
bool setContactFavourite(const uint8_t* pub_key, bool fav);
// On-device "remote admin" (Tools > Admin): send a CLI command to a node
// you're logged into with admin permission (see ClientACL::isAdmin()). The
// reply is a text frame (TXT_TYPE_CLI_DATA) delivered via onCommandDataRecv();
+29 -6
View File
@@ -87,7 +87,7 @@ struct NodePrefs { // persisted to file
char custom_msgs[10][140]; // user-defined quick messages (supports {loc}, {time})
uint64_t ch_notif_override; // bitmask: bit i = channel i has explicit notification setting [del→onChannelRemoved]
uint64_t ch_notif_muted; // bitmask: bit i = channel i muted (only if override bit set) [del→onChannelRemoved]
uint8_t dm_show_all; // 0=favourites only (default), 1=all chat contacts
uint8_t dm_show_all; // 0=favourites only, 1=all chat contacts (default)
uint8_t room_fav_only; // 0=all room servers (default), 1=favourites only
uint8_t ringtone_bpm_idx; // index into {60,90,120,150,180}
uint8_t ringtone_len; // number of notes in custom ringtone (0 = use default)
@@ -143,11 +143,19 @@ struct NodePrefs { // persisted to file
uint8_t page_order_set; // 0xA5 = page_order is user-configured; anything else = use default
static const uint8_t PAGE_ORDER_MAGIC = 0xA5;
// Favourites dial: 6 pinned contacts, stored as first 6 bytes of pub_key per slot.
// All-zero = empty slot (probability a real pub_key starts with 6 zero bytes is 2^-48).
// Layout transposes between landscape (3×2) and portrait (2×3).
// Favourites dial: 6 pinned targets. Layout transposes between landscape
// (3×2) and portrait (2×3). What a slot holds depends on favourite_kinds[]
// (stored at the struct tail, see there):
// FAV_KIND_CONTACT — first 6 bytes of pub_key; covers chat contacts and
// room servers alike. All-zero = empty slot (a real pub_key starts with
// 6 zero bytes with probability 2^-48).
// FAV_KIND_CHANNEL — channel index in byte 0, rest zero. Never empty:
// channel 0 is all-zero, so emptiness is decided by the kind first.
static const uint8_t FAVOURITES_COUNT = 6;
static const uint8_t FAVOURITE_PREFIX_LEN = 6;
static const uint8_t FAV_KIND_CONTACT = 0;
static const uint8_t FAV_KIND_CHANNEL = 1;
static const uint8_t FAV_KIND_MAX = 1;
uint8_t favourite_contacts[FAVOURITES_COUNT][FAVOURITE_PREFIX_LEN]; // [del→onContactRemoved]
// GPS trail cadence. Logging on/off is a runtime state (Tools Trail),
@@ -470,6 +478,16 @@ struct NodePrefs { // persisted to file
// companion's own messages send under, only what repeat_scope_only accepts.
char repeat_extra_scopes[24];
// What each favourite_contacts[] slot holds (see FAV_KIND_* by that field).
// Appended here, not next to it, because the on-disk format is append-only.
// Zero-init = every slot is a contact, which is what pre-0x28 saves are.
uint8_t favourite_kinds[FAVOURITES_COUNT]; // [del→onChannelRemoved]
// Settings > Contacts > "Favs top". Stored inverted so that both a fresh
// memset and an older prefs file (no bytes here at all) mean "on", which is
// the default -- a positive flag would read back as off for every upgrader.
uint8_t fav_sort_off; // 0 = favourites first in every list (default), 1 = natural order
// Single source of truth for the live-share option tables (shared by the Map
// UI labels and the auto-send engine in UITask).
static const uint8_t LOC_SHARE_MOVE_COUNT = 4;
@@ -537,7 +555,7 @@ struct NodePrefs { // persisted to file
// repeat_* fields) instead of at the tail, which shifted every field after
// them by 25 bytes when loading an older file. Never released, but a dev
// build wrote it, so the number must not be reused for anything else.
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0027;
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0029;
// Bit-index for each home page. Used by page_order (entries store bit+1) and
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
@@ -662,7 +680,12 @@ struct NodePrefs { // persisted to file
// the struct tail (the 0xC0DE0026 mid-stream layout was unreadable for older
// files, see the sentinel comment) -- padding worked out identically either
// way, so sizeof stays 2752. Confirmed via a real Heltec_v3 build.
static_assert(sizeof(NodePrefs) == 2752,
// favourite_kinds[6] (0xC0DE0028) added 8 bytes, not 6 -- the struct had no
// spare tail padding left after 0xC0DE0026, so the 6 real bytes rounded up to
// the next alignment boundary. Confirmed via a real Heltec_v3 build.
// fav_sort_off (0xC0DE0029) landed in the 2 bytes of padding the 0xC0DE0028
// bump left over -- confirmed via a real Heltec_v3 build, sizeof unchanged.
static_assert(sizeof(NodePrefs) == 2760,
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
"SCHEMA_SENTINEL, then update this size (see steps above).");
@@ -41,6 +41,7 @@ class LocatorScreen : public UIScreen {
char name[20];
uint32_t ts; // last position update (0 = unknown), for the age tag
bool live; // true = an active [LOC] share right now
bool fav; // person is a favourite — ★ on the row, as in every other list
};
static const int TARGET_MAX = 40;
Target _targets[TARGET_MAX];
@@ -170,7 +171,7 @@ public:
// precedence UITask::locatorDistance() uses at evaluation time. When
// `require_position` is false (favourites), a contact with neither is still
// added with no position — "arm ahead of time", per the existing feature.
bool addPersonTarget(const uint8_t* key, const char* name, bool require_position) {
bool addPersonTarget(const uint8_t* key, const char* name, bool require_position, bool fav) {
if (_target_n >= TARGET_MAX) return false;
for (int j = 0; j < _target_n; j++)
if (_targets[j].kind == 1 && memcmp(_targets[j].key, key, 6) == 0) return false; // already added
@@ -180,7 +181,7 @@ public:
if (require_position && !has_pos) return false; // nothing to navigate to yet
Target& t = _targets[_target_n++];
t.kind = 1; t.lat = lat; t.lon = lon; t.ts = ts; t.live = live;
t.kind = 1; t.lat = lat; t.lon = lon; t.ts = ts; t.live = live; t.fav = fav;
memcpy(t.key, key, 6);
snprintf(t.name, sizeof(t.name), "%s", name);
return true;
@@ -196,7 +197,7 @@ public:
void buildTargets() {
_target_n = 0;
Target& none = _targets[_target_n++];
none.kind = 2; none.lat = 0; none.lon = 0; none.ts = 0; none.live = false;
none.kind = 2; none.lat = 0; none.lon = 0; none.ts = 0; none.live = false; none.fav = false;
memset(none.key, 0, 6);
snprintf(none.name, sizeof(none.name), "(none)");
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) {
@@ -206,18 +207,18 @@ public:
if (empty) continue;
ContactInfo* c = the_mesh.lookupContactByPubKey(pre, NodePrefs::FAVOURITE_PREFIX_LEN);
if (!c) continue;
addPersonTarget(pre, c->name, /*require_position=*/false);
addPersonTarget(pre, c->name, /*require_position=*/false, (c->flags & 0x01) != 0);
}
for (int idx = 0; _target_n < TARGET_MAX; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
addPersonTarget(c.id.pub_key, c.name, /*require_position=*/true);
addPersonTarget(c.id.pub_key, c.name, /*require_position=*/true, (c.flags & 0x01) != 0);
}
WaypointStore& wp = _task->waypoints();
for (int i = 0; i < wp.count() && _target_n < TARGET_MAX; i++) {
const Waypoint& w = wp.at(i);
Target& t = _targets[_target_n++];
t.kind = 0; t.lat = w.lat_1e6; t.lon = w.lon_1e6;
t.kind = 0; t.lat = w.lat_1e6; t.lon = w.lon_1e6; t.fav = false;
memset(t.key, 0, 6);
snprintf(t.name, sizeof(t.name), "%s", w.label);
}
@@ -286,8 +287,10 @@ public:
} else {
snprintf(row, sizeof(row), "@%s", t.name); // favourite, no position known yet
}
int mqr = display.drawTextEllipsized(2, y, display.width() - 2 - reserve, row, sel);
int sw = t.fav ? favStarWidth(display) : 0;
int mqr = display.drawTextEllipsized(2, y, display.width() - 2 - sw - reserve, row, sel);
if (sel && mqr > 0) mq_delay = mqr;
if (sw) drawFavStar(display, display.width() - reserve - sw + 1, y);
});
return mq_delay;
}
@@ -381,29 +381,6 @@ public:
// Recent DM contacts, newest first, deduped. Resolves the 4-byte _dm_hist
// prefix to a 6-byte pub_key prefix by walking the contact list once per
// unique sender. Returns the number filled.
int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const {
int n = 0;
for (int i = _dm_hist_count - 1; i >= 0 && n < max; i--) {
int pos = (_dm_hist_head + i) % DM_HIST_MAX;
const uint8_t* p4 = _dm_hist[pos].prefix;
// Skip if already collected.
bool dup = false;
for (int j = 0; j < n; j++) if (memcmp(out[j], p4, 4) == 0) { dup = true; break; }
if (dup) continue;
// Find a real contact whose pub_key starts with this 4-byte prefix.
for (int idx = 0; ; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (memcmp(c.id.pub_key, p4, 4) == 0) {
memcpy(out[n], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
n++;
break;
}
}
}
return n;
}
DmHistEntry& dmAtPos(int pos) { return _dm_hist[pos]; }
const DmHistEntry& dmAtPos(int pos) const { return _dm_hist[pos]; }
+320 -114
View File
@@ -62,10 +62,17 @@ class MessagesScreen : public UIScreen {
char _ctx_notif_item[22];
char _ctx_melody_item[20];
char _ctx_pin_item[28]; // "Pin to dial" or "Unpin (slot N)"
char _ctx_ch_fav_item[12]; // "Fav" or "Unfav"
char _ctx_fav_item[12]; // "Fav: ON" / "Fav: OFF" — shared by the channel,
// contact and room menus (never open at once)
int _ctx_fav_idx = -1; // row the Fav toggle sits on, frozen at menu open
int _ctx_pin_idx = -1; // ...and the Pin row, in the room menu (it sits after Fav,
// whose own row depends on whether Logout is shown)
char _pin_slot_labels[NodePrefs::FAVOURITES_COUNT][22]; // per-slot picker labels
bool _pin_picker_active; // true while the slot-picker submenu is open
bool _dm_direct_entry; // entered DM_HIST via Favourites shortcut; CANCEL returns home
int _pick_fav_slot = -1; // >=0 = browsing to fill that Favourites dial slot
int _pin_slot_ch_idx = -1; // >=0 = that submenu is pinning this channel, not a contact
bool _direct_entry; // entered a history view straight from the Favourites dial;
// CANCEL returns home instead of to the picker
char _reply_prefix[36]; // "@[nick] " built when reply is triggered
bool _reply_mode; // true while composing a reply (prefix is prepended)
@@ -561,41 +568,136 @@ class MessagesScreen : public UIScreen {
}
}
// Fill _pin_slot_labels for the "Pick slot" submenu: each dial slot with its
// current occupant, contact or channel.
void buildSlotLabels() {
NodePrefs* p = _task->getNodePrefs();
for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) {
if (!p || _task->isFavouriteSlotEmpty(s)) {
snprintf(_pin_slot_labels[s], sizeof(_pin_slot_labels[s]), "Slot %d: empty", s + 1);
continue;
}
char nm[16] = "?";
const uint8_t* pfx = p->favourite_contacts[s];
if (_task->favouriteSlotKind(s) == NodePrefs::FAV_KIND_CHANNEL) {
ChannelDetails ch;
if (the_mesh.getChannel(pfx[0], ch) && ch.name[0]) {
nm[0] = '#';
DisplayDriver::translateUTF8Static(nm + 1, ch.name, sizeof(nm) - 1);
}
} else {
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);
}
}
void pinContactToSlot(const ContactInfo& ci, int slot) {
if (slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return;
int existing = _task->findFavouriteSlot(ci.id.pub_key);
if (existing >= 0 && existing != slot) _task->clearFavouriteSlot(existing);
_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);
}
// Pin/Unpin row picked in a contact or room menu: unpins if already pinned,
// otherwise hands the menu over to the slot picker.
void pinContactAction(const ContactInfo& ci) {
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);
return;
}
buildSlotLabels();
_ctx_menu.begin("Pick slot", 3);
for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) _ctx_menu.addItem(_pin_slot_labels[s]);
_pin_slot_ch_idx = -1; // pinning a contact, not a channel
_pin_picker_active = true;
}
void pinChannelToSlot(uint8_t ch_idx, int slot) {
if (slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return;
int existing = _task->findFavouriteChannelSlot(ch_idx);
if (existing >= 0 && existing != slot) _task->clearFavouriteSlot(existing);
_task->setFavouriteChannelSlot(slot, ch_idx);
the_mesh.savePrefs();
char alert[24];
snprintf(alert, sizeof(alert), "Pinned to slot %d", slot + 1);
_task->showAlert(alert, 800);
}
// Posting to a room requires a login handshake first (even with a blank
// password), so get one under way instead of opening a history view that
// would silently fail to send. Targets _sel_contact.
void requireRoomLogin() {
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);
return;
}
_login_mode = true;
_kb->begin("", 15); // room/repeater password: max 15 chars
_kb->clearPlaceholders(); // {loc}/{time} are for messages, not a password
_phase = KEYBOARD;
}
// Flip a contact's/room's favourite flag and refresh the menu label. The list
// rebuild is deferred to menu close (see the channel Fav toggle for why).
void toggleContactFav(const ContactInfo& ci) {
bool now_fav = !(ci.flags & 0x01);
if (!the_mesh.setContactFavourite(ci.id.pub_key, now_fav)) return;
snprintf(_ctx_fav_item, sizeof(_ctx_fav_item), now_fav ? "Fav: ON" : "Fav: OFF");
}
void buildContactList() {
NodePrefs* p = _task->getNodePrefs();
ContactInfo c;
int total = the_mesh.getNumContacts();
const bool rooms = _room_mode;
_num_contacts = 0;
if (_room_mode) {
bool fav_only = (p && p->room_fav_only);
// Sort key per row: favourites first, then most-messaged first (DMs only).
// uint8_t, not int: DM history is capped at MessageHistory::DM_HIST_MAX (32),
// well under the 128 the favourite bit adds, and this array is 1400 B at this
// build's MAX_CONTACTS=350 as an int[] -- a sizeable slice of the 4 KB loop()
// task stack for one local array.
uint8_t keys[MAX_CONTACTS];
bool fav_only = rooms ? (p && p->room_fav_only) : !(p && p->dm_show_all);
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;
if (!the_mesh.getContactByIdx(i, c)) continue;
if (c.type != (rooms ? ADV_TYPE_ROOM : ADV_TYPE_CHAT)) continue;
bool fav = (c.flags & 0x01) != 0;
if (fav_only && !fav) continue;
uint8_t k = rooms ? 0 : _history.dmHistCountForContact(c.id.pub_key);
if (k > 127) k = 127;
if (fav && !(p && p->fav_sort_off)) k += 128;
keys[_num_contacts] = k;
_sorted[_num_contacts++] = i;
}
} else {
bool show_all = (p && p->dm_show_all);
// Build _sorted and counts[] in one pass — avoids a second getContactByIdx loop.
// uint8_t, not int: values are bounded by MessageHistory::DM_HIST_MAX (32), and
// this array is 1400 B at this build's MAX_CONTACTS=350 as an int[] -- a sizeable
// slice of the 4 KB loop() task stack for one local array.
uint8_t counts[MAX_CONTACTS];
for (int i = 0; i < total; i++) {
if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue;
if (!show_all && !(c.flags & 0x01)) continue;
counts[_num_contacts] = _history.dmHistCountForContact(c.id.pub_key);
_sorted[_num_contacts++] = i;
}
// Sort by message count descending; contacts with no messages keep original order.
// Descending insertion sort; rows with key 0 keep their contact-table order.
for (int i = 1; i < _num_contacts; i++) {
if (counts[i] == 0) continue;
uint16_t key = _sorted[i]; int kc = counts[i];
if (keys[i] == 0) continue;
uint16_t idx = _sorted[i]; uint8_t k = keys[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;
while (j > 0 && keys[j-1] < k) {
_sorted[j] = _sorted[j-1]; keys[j] = keys[j-1]; j--;
}
_sorted[j] = idx; keys[j] = k;
}
}
@@ -609,6 +711,20 @@ class MessagesScreen : public UIScreen {
if (fav_only && !(p->ch_fav_bitmask & (1ULL << i))) continue;
_channel_indices[_num_channels++] = (uint8_t)i;
}
// Favourites to the front, everything else keeping channel-slot order.
if (!p || p->fav_sort_off) return;
int front = 0;
for (int i = 0; i < _num_channels; i++) {
if (!chIsFav(_channel_indices[i])) continue;
uint8_t v = _channel_indices[i];
for (int j = i; j > front; j--) _channel_indices[j] = _channel_indices[j - 1];
_channel_indices[front++] = v;
}
}
bool chIsFav(uint8_t ch_idx) const {
NodePrefs* p = _task->getNodePrefs();
return p && (p->ch_fav_bitmask & (1ULL << ch_idx)) != 0;
}
// Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on
@@ -715,7 +831,7 @@ public:
_hist_sel(0), _hist_scroll(0),
_unread_at_entry(0), _viewing_max_seen(0),
_dm_hist_sel(-1), _dm_hist_scroll(0),
_ctx_dirty(false), _pin_picker_active(false), _dm_direct_entry(false), _reply_mode(false),
_ctx_dirty(false), _pin_picker_active(false), _direct_entry(false), _reply_mode(false),
_ch_view(task) {
// The history rings + per-channel unread counters init in MessageHistory.
}
@@ -932,17 +1048,15 @@ public:
_pick_bot_channel = false;
_pick_bot_room = false;
_pin_picker_active = false;
_dm_direct_entry = false;
_pin_slot_ch_idx = -1;
_pick_fav_slot = -1;
_direct_entry = false;
_unread_at_entry = 0;
_viewing_max_seen = 0;
_ch_view.reset();
}
// Recent DM contacts, newest first, deduped (forwarded to the history store).
int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const {
return _history.getRecentDMContacts(out, max);
}
// Jump straight into a contact's DM history (used by the Favourites dial).
// Caller must have already reset() the screen. Marks the entry so KEY_CANCEL
// from DM_HIST returns to the home screen instead of falling back through
@@ -978,6 +1092,28 @@ public:
_task->gotoLiveShareScreen();
}
// Open the chooser to fill Favourites dial slot `slot`. Reuses this screen's
// own Direct/Channels/Rooms browse rather than the dial carrying a second
// picker of its own.
void startPickFavourite(int slot) {
reset();
_pick_fav_slot = slot;
_phase = MODE_SELECT;
}
void commitPickFavContact(const ContactInfo& ci) {
pinContactToSlot(ci, _pick_fav_slot);
_pick_fav_slot = -1;
_room_mode = false;
_task->gotoHomeScreen();
}
void commitPickFavChannel(int ch_idx) {
pinChannelToSlot((uint8_t)ch_idx, _pick_fav_slot);
_pick_fav_slot = -1;
_task->gotoHomeScreen();
}
// Open the channel chooser to set the auto-reply bot's channel. Skips
// MODE_SELECT (the bot only ever targets a channel) and lands straight on
// CHANNEL_PICK, browsing real channel names instead of a bare index cycle.
@@ -1051,9 +1187,39 @@ public:
_dm_fs.active = false;
_room_mode = false;
_phase = DM_HIST;
_dm_direct_entry = true;
_direct_entry = true;
}
// Same as enterDM, but keeps room framing on (sender-prefixed lines, room
// compose) and runs the login handshake a room needs before it can be posted
// to. Caller must have already reset() the screen.
void enterRoom(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 = true;
_phase = DM_HIST;
_direct_entry = true;
if (!isRoomLoggedIn(ci.id.pub_key)) requireRoomLogin();
}
// Open a channel's history directly (used by the Favourites dial). Caller
// must have already reset() the screen.
void enterChannel(uint8_t channel_idx) {
_sel_channel_idx = channel_idx;
_unread_at_entry = (int)_history.chUnread(channel_idx);
_hist_scroll = 0;
_hist_sel = _history.histCountForChannel(channel_idx) > 0 ? 0 : -1;
_viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0;
_fs.active = false;
_phase = CHANNEL_HIST;
_direct_entry = true;
}
uint8_t chUnread(uint8_t channel_idx) const { return _history.chUnread(channel_idx); }
int render(DisplayDriver& display) override {
int mq_delay = 0; // >0 while a selected row's text is marquee-scrolling
display.setTextSize(1);
@@ -1070,7 +1236,12 @@ public:
int start_y = display.listStart();
if (_phase == MODE_SELECT) {
display.drawCenteredHeader("MESSAGE", true, _ctx_menu.active);
// Say which dial slot is being filled -- entered from the Favourites page,
// a bare "MESSAGE" gives no sign that this browse is a pick.
char hdr[16];
if (_pick_fav_slot >= 0) snprintf(hdr, sizeof(hdr), "PIN SLOT %d", _pick_fav_slot + 1);
else snprintf(hdr, sizeof(hdr), "MESSAGE");
display.drawCenteredHeader(hdr, true, _ctx_menu.active);
const char* opts[] = { "Direct message", "Channels", "Room Servers" };
int badges[3] = {
getDMUnreadTotal(),
@@ -1108,8 +1279,10 @@ public:
display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered));
uint8_t dm_unread = _task->getDMUnread(c.id.pub_key);
int bw = dm_unread > 0 ? display.unreadBadgeWidth(dm_unread) + 2 : 0;
int r = display.drawTextEllipsized(2, y, display.width() - 2 - bw - reserve, filtered, sel);
int sw = (c.flags & 0x01) ? favStarWidth(display) : 0;
int r = display.drawTextEllipsized(2, y, display.width() - 2 - bw - sw - reserve, filtered, sel);
if (sel && r > 0) mq_delay = r;
if (sw) drawFavStar(display, display.width() - reserve - bw - sw + 1, y);
if (dm_unread > 0)
display.drawUnreadBadge(display.width() - reserve, y, dm_unread, sel);
}
@@ -1142,8 +1315,10 @@ public:
if (the_mesh.getChannel(_channel_indices[list_idx], ch)) {
uint8_t unread = _history.chUnread(_channel_indices[list_idx]);
int bw = unread > 0 ? display.unreadBadgeWidth(unread) + 2 : 0;
int r = display.drawTextEllipsized(2, y, display.width() - 4 - bw - reserve, ch.name, sel);
int sw = chIsFav(_channel_indices[list_idx]) ? favStarWidth(display) : 0;
int r = display.drawTextEllipsized(2, y, display.width() - 4 - bw - sw - reserve, ch.name, sel);
if (sel && r > 0) mq_delay = r;
if (sw) drawFavStar(display, display.width() - reserve - bw - sw + 1, y);
if (unread > 0)
display.drawUnreadBadge(display.width() - reserve, y, unread, sel);
}
@@ -1597,7 +1772,28 @@ public:
} else if (_phase == CONTACT_PICK) {
// Context menu consumes all input while open
if (_ctx_menu.active) {
if (_pin_picker_active) {
// Slot picker sub-menu: index 0..FAVOURITES_COUNT-1 maps directly to slot.
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED && _num_contacts > 0) {
ContactInfo ci;
if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci))
pinContactToSlot(ci, _ctx_menu.selectedIndex());
}
if (res != PopupMenu::NONE) {
_pin_picker_active = false;
_task->savePrefsIfDirty(_ctx_dirty); // Notif/Melody edits made before Pin was picked
}
return true;
}
if (_room_mode) {
// LEFT/RIGHT toggle Fav in-place (menu stays open), as in the other menus.
if ((keyIsPrev(c) || keyIsNext(c)) && _num_contacts > 0 &&
_ctx_menu.selectedIndex() == _ctx_fav_idx) {
ContactInfo ci;
if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) toggleContactFav(ci);
return true;
}
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED && _num_contacts > 0) {
if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) {
@@ -1607,18 +1803,27 @@ public:
_kb->begin("", 15); // room/repeater password: max 15 chars
_kb->clearPlaceholders(); // {loc}/{time} are for messages, not a password
_phase = KEYBOARD;
} else {
} else if (sel == _ctx_pin_idx) {
pinContactAction(_sel_contact);
if (_pin_picker_active) return true; // rebuild below would close the submenu
} else if (sel != _ctx_fav_idx) {
// Logout: only reachable when isRoomLoggedIn() added this item.
the_mesh.logoutRoom(_sel_contact.id.pub_key);
forgetRoomLoggedIn(_sel_contact.id.pub_key);
_task->showAlert("Logged out", 1000);
}
// Fav row: already toggled by LEFT/RIGHT, ENTER just closes.
}
}
if (res != PopupMenu::NONE && _phase == CONTACT_PICK) {
// A room un-favourited under a fav-only filter drops off the list.
buildContactList();
if (_contact_sel >= _num_contacts) _contact_sel = _num_contacts > 0 ? _num_contacts - 1 : 0;
}
return true;
}
// LEFT/RIGHT cycle Notif/Melody in-place (menu stays open).
if (!_pin_picker_active && _num_contacts > 0) {
if (_num_contacts > 0) {
bool left = keyIsPrev(c);
bool right = keyIsNext(c);
if (left || right) {
@@ -1639,28 +1844,14 @@ public:
setDmMelody(ci.id.pub_key, v);
snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", ML[v]);
_ctx_dirty = true;
} else if (sel == _ctx_fav_idx) {
toggleContactFav(ci);
}
}
return true;
}
}
auto res = _ctx_menu.handleInput(c);
if (_pin_picker_active) {
// Slot picker sub-menu: index 0..FAVOURITES_COUNT-1 maps directly to slot.
if (res == PopupMenu::SELECTED && _num_contacts > 0) {
ContactInfo ci;
if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) {
int slot = _ctx_menu.selectedIndex();
_task->setFavouriteSlot(slot, ci.id.pub_key);
the_mesh.savePrefs();
char alert[24];
snprintf(alert, sizeof(alert), "Pinned to slot %d", slot + 1);
_task->showAlert(alert, 800);
}
}
if (res != PopupMenu::NONE) _pin_picker_active = false;
return true;
}
if (res == PopupMenu::SELECTED && _num_contacts > 0) {
ContactInfo ci;
if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) {
@@ -1669,45 +1860,20 @@ public:
int cleared = (int)_task->getDMUnread(ci.id.pub_key);
_task->clearDMUnread(ci.id.pub_key);
markReadAlert(cleared);
} else if (sel == 3) {
// Pin / Unpin
int pinned_slot = _task->findFavouriteSlot(ci.id.pub_key);
if (pinned_slot >= 0) {
_task->clearFavouriteSlot(pinned_slot);
the_mesh.savePrefs();
char alert[24];
snprintf(alert, sizeof(alert), "Unpinned (slot %d)", pinned_slot + 1);
_task->showAlert(alert, 800);
} else {
for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) {
if (_task->isFavouriteSlotEmpty(s)) {
snprintf(_pin_slot_labels[s], sizeof(_pin_slot_labels[s]), "Slot %d: empty", s + 1);
} else {
char nm[16] = "?";
NodePrefs* p = _task->getNodePrefs();
if (p) {
const uint8_t* pfx = p->favourite_contacts[s];
for (int idx = 0; ; idx++) {
ContactInfo c2;
if (!the_mesh.getContactByIdx(idx, c2)) break;
if (memcmp(c2.id.pub_key, pfx, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
DisplayDriver::translateUTF8Static(nm, c2.name, sizeof(nm));
break;
} else if (sel == 4) {
pinContactAction(ci);
}
// sel 1 (Notif), 2 (Melody), 3 (Fav): already cycled via LEFT/RIGHT; ENTER just closes.
}
}
}
snprintf(_pin_slot_labels[s], sizeof(_pin_slot_labels[s]), "Slot %d: %s", s + 1, nm);
if (res != PopupMenu::NONE) {
_task->savePrefsIfDirty(_ctx_dirty);
if (!_pin_picker_active) {
// A contact un-favourited under a fav-only filter drops off the list.
buildContactList();
if (_contact_sel >= _num_contacts) _contact_sel = _num_contacts > 0 ? _num_contacts - 1 : 0;
}
}
_ctx_menu.begin("Pick slot", 3);
for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) _ctx_menu.addItem(_pin_slot_labels[s]);
_pin_picker_active = true;
}
}
// sel == 1 (Notif) and sel == 2 (Melody): already cycled via LEFT/RIGHT; ENTER just closes.
}
}
if (res != PopupMenu::NONE) _task->savePrefsIfDirty(_ctx_dirty);
return true;
}
if (c == KEY_CANCEL) {
@@ -1722,6 +1888,7 @@ public:
if (c == KEY_ENTER && _num_contacts > 0) {
if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) {
if (_pick_target) { commitPickTargetDM(_sel_contact); return true; }
if (_pick_fav_slot >= 0) { commitPickFavContact(_sel_contact); return true; }
if (_pick_bot_room) {
if (!isRoomLoggedIn(_sel_contact.id.pub_key)) {
char saved_pw[sizeof(_login_pw)];
@@ -1747,20 +1914,7 @@ public:
return true;
}
if (_room_mode && !isRoomLoggedIn(_sel_contact.id.pub_key)) {
// Posting to a room requires a login handshake first (even with a
// blank password) — go straight to the password prompt instead of
// a history view that would silently fail to send.
char saved_pw[sizeof(_login_pw)];
if (the_mesh.getRoomPassword(_sel_contact.id.pub_key, saved_pw, sizeof(saved_pw))) {
// Logged in to this room before, on an earlier boot -- retry
// with the remembered password instead of prompting again.
startRoomLogin(saved_pw);
} else {
_login_mode = true;
_kb->begin("", 15); // room/repeater password: max 15 chars
_kb->clearPlaceholders(); // {loc}/{time} are for messages, not a password
_phase = KEYBOARD;
}
requireRoomLogin();
return true;
}
openDmHistory();
@@ -1770,10 +1924,20 @@ public:
}
if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && _room_mode) {
ContactInfo ci;
bool logged_in = the_mesh.getContactByIdx(_sorted[_contact_sel], ci) && isRoomLoggedIn(ci.id.pub_key);
bool have = the_mesh.getContactByIdx(_sorted[_contact_sel], ci);
bool logged_in = have && isRoomLoggedIn(ci.id.pub_key);
snprintf(_ctx_fav_item, sizeof(_ctx_fav_item),
(have && (ci.flags & 0x01)) ? "Fav: ON" : "Fav: OFF");
{ int pinned_slot = have ? _task->findFavouriteSlot(ci.id.pub_key) : -1;
if (pinned_slot >= 0) snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Unpin (slot %d)", pinned_slot + 1);
else snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Pin to dial"); }
_ctx_menu.begin("Room options", logged_in ? 2 : 1);
_ctx_menu.addItem("Login...");
if (logged_in) _ctx_menu.addItem("Logout");
_ctx_fav_idx = logged_in ? 2 : 1;
_ctx_menu.addItem(_ctx_fav_item);
_ctx_pin_idx = _ctx_fav_idx + 1;
_ctx_menu.addItem(_ctx_pin_item);
return true;
}
if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) {
@@ -1788,10 +1952,14 @@ public:
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");
snprintf(_ctx_fav_item, sizeof(_ctx_fav_item),
(ci.flags & 0x01) ? "Fav: ON" : "Fav: OFF");
_ctx_menu.begin("Contact options", 3);
_ctx_menu.addItem("Mark as read");
_ctx_menu.addItem(_ctx_notif_item);
_ctx_menu.addItem(_ctx_melody_item);
_ctx_fav_idx = 3;
_ctx_menu.addItem(_ctx_fav_item);
_ctx_menu.addItem(_ctx_pin_item);
_ctx_dirty = false;
return true;
@@ -1801,7 +1969,7 @@ public:
// Context menu consumes all input while open
if (_ctx_menu.active) {
// LEFT/RIGHT cycle Notif/Melody/Fav in-place (menu stays open).
if (_num_channels > 0) {
if (!_pin_picker_active && _num_channels > 0) {
bool left = keyIsPrev(c);
bool right = keyIsNext(c);
if (left || right) {
@@ -1821,12 +1989,12 @@ public:
setChNotifMelody(ch_idx, v);
snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", ML[v]);
_ctx_dirty = true;
} else if (sel == 3) {
} else if (sel == _ctx_fav_idx) {
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: ON" : "Fav: OFF");
snprintf(_ctx_fav_item, sizeof(_ctx_fav_item), is_fav ? "Fav: ON" : "Fav: OFF");
_ctx_dirty = true;
// List rebuild is deferred to menu close: with the fav-only
// filter on, un-favouriting this channel removes it from the
@@ -1838,6 +2006,18 @@ public:
}
}
auto res = _ctx_menu.handleInput(c);
if (_pin_picker_active) {
// Slot picker sub-menu: index 0..FAVOURITES_COUNT-1 maps directly to slot.
if (res == PopupMenu::SELECTED && _pin_slot_ch_idx >= 0) {
pinChannelToSlot((uint8_t)_pin_slot_ch_idx, _ctx_menu.selectedIndex());
}
if (res != PopupMenu::NONE) {
_pin_picker_active = false;
_pin_slot_ch_idx = -1;
_task->savePrefsIfDirty(_ctx_dirty); // Notif/Melody/Fav edits made before Pin was picked
}
return true;
}
if (res == PopupMenu::SELECTED && _num_channels > 0) {
uint8_t ch_idx = _ctx_ch_idx; // frozen at menu open — see declaration
int sel = _ctx_menu.selectedIndex();
@@ -1845,10 +2025,26 @@ public:
int cleared = (int)_history.chUnread(ch_idx);
_history.setChUnread(ch_idx, 0);
markReadAlert(cleared);
} else if (sel == 4) { // Edit
} else if (sel == 4) { // Pin / Unpin
int pinned_slot = _task->findFavouriteChannelSlot(ch_idx);
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 {
buildSlotLabels();
_ctx_menu.begin("Pick slot", 3);
for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) _ctx_menu.addItem(_pin_slot_labels[s]);
_pin_slot_ch_idx = ch_idx;
_pin_picker_active = true;
return true; // list rebuild below would close the submenu
}
} else if (sel == 5) { // Edit
ChannelDetails ch;
if (the_mesh.getChannel(ch_idx, ch)) _ch_view.openEdit(ch_idx, ch.name);
} else if (sel == 5) { // Delete
} else if (sel == 6) { // Delete
ChannelDetails ch;
memset(&ch, 0, sizeof(ch));
the_mesh.setChannelLocal(ch_idx, ch);
@@ -1883,6 +2079,7 @@ public:
if (c == KEY_ENTER && _num_channels > 0 && _channel_sel < _num_channels) {
_sel_channel_idx = _channel_indices[_channel_sel];
if (_pick_target) { commitPickTargetChannel(_sel_channel_idx); return true; }
if (_pick_fav_slot >= 0) { commitPickFavChannel(_sel_channel_idx); return true; }
if (_pick_bot_channel) { commitPickBotChannel(_sel_channel_idx); return true; }
int hc = _history.histCountForChannel(_sel_channel_idx);
_unread_at_entry = (int)_history.chUnread(_sel_channel_idx);
@@ -1908,12 +2105,17 @@ public:
ML[chNotifMelody(ch_idx)]); }
{ NodePrefs* p2 = _task->getNodePrefs();
bool is_fav = p2 && (p2->ch_fav_bitmask & (1ULL << ch_idx));
snprintf(_ctx_ch_fav_item, sizeof(_ctx_ch_fav_item), is_fav ? "Fav: ON" : "Fav: OFF"); }
snprintf(_ctx_fav_item, sizeof(_ctx_fav_item), is_fav ? "Fav: ON" : "Fav: OFF"); }
{ int pinned_slot = _task->findFavouriteChannelSlot(ch_idx);
if (pinned_slot >= 0) snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Unpin (slot %d)", pinned_slot + 1);
else snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Pin to dial"); }
_ctx_menu.begin("Channel options", 6);
_ctx_menu.addItem("Mark all read");
_ctx_menu.addItem(_ctx_notif_item);
_ctx_menu.addItem(_ctx_melody_item);
_ctx_menu.addItem(_ctx_ch_fav_item);
_ctx_fav_idx = 3;
_ctx_menu.addItem(_ctx_fav_item);
_ctx_menu.addItem(_ctx_pin_item);
_ctx_menu.addItem("Edit");
_ctx_menu.addItem("Delete");
_ctx_dirty = false;
@@ -1963,8 +2165,8 @@ public:
return true;
}
if (c == KEY_CANCEL) {
if (_dm_direct_entry) {
_dm_direct_entry = false;
if (_direct_entry) {
_direct_entry = false;
_task->gotoHomeScreen();
} else {
_phase = CONTACT_PICK;
@@ -2053,7 +2255,11 @@ public:
}
return true;
}
if (c == KEY_CANCEL) { _phase = CHANNEL_PICK; return true; }
if (c == KEY_CANCEL) {
if (_direct_entry) { _direct_entry = false; _task->gotoHomeScreen(); }
else { _phase = CHANNEL_PICK; }
return true;
}
// Newest (index 0) now renders at the bottom, oldest at the top (see the
// render block above) — UP/DOWN swap direction accordingly, same as the
// DM history handler above.
+40 -11
View File
@@ -25,7 +25,7 @@ class NearbyScreen : public UIScreen {
// ── action-menu actions (matched by id, not by row index) ────────────────────
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR,
ACT_ADD, ACT_DELETE, ACT_FAV, ACT_ADMIN, ACT_SORT, ACT_SCAN };
ACT_ADD, ACT_DELETE, ACT_FAV, ACT_PIN, ACT_ADMIN, ACT_SORT, ACT_SCAN };
// Set by UITask::pickAdminTarget() (Tools > Admin, which is remote-only):
// while true, ENTER on an eligible row (a stored repeater/room contact) hands
@@ -55,6 +55,9 @@ class NearbyScreen : public UIScreen {
// false for a channel (name, best-effort) share.
bool is_live;
bool live_verified;
// Favourite (ContactInfo::flags bit 0) -- always false for a scan/live row
// that isn't a contact, since only a contact can carry the flag.
bool fav;
};
static const int MAX_NEARBY = 32;
@@ -197,6 +200,7 @@ class NearbyScreen : public UIScreen {
? geo::haversineKm(_own_lat, _own_lon, ci.gps_lat, ci.gps_lon)
: -1.0f;
e.type = ci.type;
e.fav = (ci.flags & 0x01) != 0;
e.contact_idx = i + MAX_ANON_CONTACTS; // raw index -- other lookups re-key off this directly
e.lastmod = ci.lastmod;
e.is_known = true;
@@ -324,9 +328,15 @@ class NearbyScreen : public UIScreen {
void sortStored() {
uint32_t now_ts = rtc_clock.getCurrentTime();
NodePrefs* p = _task->getNodePrefs();
const bool fav_first = !(p && p->fav_sort_off);
for (int i = 0; i < _count - 1; i++) {
int best = i;
for (int j = i + 1; j < _count; j++) {
if (fav_first && _entries[j].fav != _entries[best].fav) {
if (_entries[j].fav) best = j; // favourites outrank the time/distance key
continue;
}
if (_sort == SORT_TIME) {
// lastmod=0 or lastmod>now (RTC not synced) → "unknown" → sort to bottom.
uint32_t tj = (_entries[j].lastmod > 0 && now_ts >= _entries[j].lastmod) ? _entries[j].lastmod : 0;
@@ -357,6 +367,7 @@ class NearbyScreen : public UIScreen {
e.snr_x4 = dr[i].snr_x4;
e.remote_snr_x4 = dr[i].remote_snr_x4;
e.is_known = dr[i].is_known;
e.fav = false;
e.lat_e6 = e.lon_e6 = 0;
e.dist_km = -1.0f;
e.lastmod = 0;
@@ -411,23 +422,28 @@ class NearbyScreen : public UIScreen {
// Toggle the given contact in the Favourites dial: unpin if already pinned, else
// pin to the first empty slot. Persisted immediately (same as the Messages picker).
void toggleFavourite(const uint8_t* pub_key) {
// Unlike the Messages lists, this one doesn't ask which slot -- it takes the
// first free one. The menu here is already long, and the dial itself can
// rearrange afterwards.
void togglePinToDial(const uint8_t* pub_key) {
int slot = _task->findFavouriteSlot(pub_key);
if (slot >= 0) {
_task->clearFavouriteSlot(slot);
the_mesh.savePrefs();
_task->showAlert("Unfavourited", 1000);
_task->showAlert("Unpinned", 1000);
return;
}
for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) {
if (_task->isFavouriteSlotEmpty(s)) {
_task->setFavouriteSlot(s, pub_key);
the_mesh.savePrefs();
_task->showAlert("Favourited", 1000);
char alert[24];
snprintf(alert, sizeof(alert), "Pinned to slot %d", s + 1);
_task->showAlert(alert, 1000);
return;
}
}
_task->showAlert("Favourites full", 1200);
_task->showAlert("Dial full", 1200);
}
// Deleting a contact is destructive → confirm first (default highlight = Cancel).
@@ -551,6 +567,8 @@ class NearbyScreen : public UIScreen {
return e && ((e->contact_idx >= 0) || (_source == SRC_SCAN && e->is_known));
}
char _pin_label[24]; // "Pin to dial" / "Unpin (slot N)" -- _menu stores the pointer
void openActionMenu() {
const Entry* e = selected();
bool stored = (_source == SRC_STORED);
@@ -558,7 +576,7 @@ class NearbyScreen : public UIScreen {
bool has_key = e && e->has_key;
bool is_contact = entryIsContact(e);
bool can_add = e && has_key && !is_contact; // a new node we can save
bool is_fav = e && has_key && _task->findFavouriteSlot(e->pub_key) >= 0;
bool is_pinned = e && has_key && _task->findFavouriteSlot(e->pub_key) >= 0;
// Admin needs a real saved contact (repeater/room), not a scan result or a
// name-only live-share row -- same gating as startPickAdminTarget()'s ENTER.
bool is_admin_target = e && stored && e->contact_idx >= 0
@@ -579,7 +597,13 @@ class NearbyScreen : public UIScreen {
// by pubkey prefix, so a name-only live-scan/channel row can't offer this.
if (has_gps && has_key) add("Set as target", ACT_LOCATOR);
if (can_add) add("Add contact", ACT_ADD);
if (is_contact && has_key) add(is_fav ? "Unfavourite" : "Favourite", ACT_FAV);
if (is_contact && has_key) add(e->fav ? "Fav: ON" : "Fav: OFF", ACT_FAV);
if (is_contact && has_key) {
if (is_pinned) snprintf(_pin_label, sizeof(_pin_label), "Unpin (slot %d)",
_task->findFavouriteSlot(e->pub_key) + 1);
else snprintf(_pin_label, sizeof(_pin_label), "Pin to dial");
add(_pin_label, ACT_PIN);
}
if (is_admin_target) add("Admin", ACT_ADMIN);
if (is_contact && has_key) add("Delete contact", ACT_DELETE);
if (stored) add(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows
@@ -622,7 +646,12 @@ class NearbyScreen : public UIScreen {
}
case ACT_FAV: {
const Entry* e = selected();
if (e && e->has_key) toggleFavourite(e->pub_key);
if (e && e->has_key && the_mesh.setContactFavourite(e->pub_key, !e->fav)) refreshKeepingSelection();
break;
}
case ACT_PIN: {
const Entry* e = selected();
if (e && e->has_key) togglePinToDial(e->pub_key);
break;
}
case ACT_DELETE: startDeleteConfirm(); break;
@@ -866,9 +895,9 @@ public:
miniIconDrawCentered(display, tx + iw / 2, y + display.getLineHeight() / 2 - 1, ICON_MAP_CONTACT);
tx += iw + 2;
}
// Star = pinned to the Favourites dial (same glyph as the Favourites page
// icon). Shown next to any live diamond so both states read at once.
if (e.has_key && _task->findFavouriteSlot(e.pub_key) >= 0) {
// Star = favourite, the same marker every other list uses. Shown next to
// any live diamond so both states read at once.
if (e.fav) {
int iw = ICON_PG_STAR.w * miniIconScale(display);
miniIconDrawCentered(display, tx + iw / 2, y + display.getLineHeight() / 2 - 1, ICON_PG_STAR);
tx += iw + 2;
+5 -4
View File
@@ -13,18 +13,19 @@ struct PopupMenu {
int _count;
int _sel;
int _scroll;
int _visible; // caller hint (min visible items)
int _cap; // actual visible cap, updated each render()
int _cap; // actual visible cap, recomputed each render()
bool active;
const char* _title;
enum Result { NONE, SELECTED, CANCELLED };
PopupMenu() : _count(0), _sel(0), _scroll(0), _visible(3), _cap(3), active(false), _title(nullptr) {}
PopupMenu() : _count(0), _sel(0), _scroll(0), _cap(3), active(false), _title(nullptr) {}
// `visible` is only a seed for the first frame: render() recomputes _cap from
// the live display height, so it does not cap or pad the item list.
void begin(const char* title, int visible = 3) {
_count = 0; _sel = 0; _scroll = 0;
_visible = visible; _cap = visible; active = true; _title = title;
_cap = visible; active = true; _title = title;
}
void addItem(const char* item) {
@@ -81,7 +81,7 @@ class SettingsScreen : public UIScreen {
KEYBOARD_CARDKB_COMPACT,
#endif
// Contacts section
SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER,
SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, FAV_SORT,
// Messages section
SECTION_MESSAGES,
DM_RESEND,
@@ -659,17 +659,21 @@ class SettingsScreen : public UIScreen {
display.print(EINK_FULL_REFRESH_LABELS[idx]); }
#endif
} else if (item == DM_FILTER) {
display.print("DM");
display.print("DMs");
display.setCursor(valCol(display), y);
display.print((p && p->dm_show_all) ? "all" : "fav");
display.print((p && p->dm_show_all) ? "All" : "Fav");
} else if (item == CH_FILTER) {
display.print("Channels");
display.setCursor(valCol(display), y);
display.print((p && p->ch_fav_only) ? "fav" : "all");
display.print((p && p->ch_fav_only) ? "Fav" : "All");
} else if (item == ROOM_FILTER) {
display.print("Rooms");
display.setCursor(valCol(display), y);
display.print((p && p->room_fav_only) ? "fav" : "all");
display.print((p && p->room_fav_only) ? "Fav" : "All");
} else if (item == FAV_SORT) {
display.print("Favs top");
display.setCursor(valCol(display), y);
display.print((p && p->fav_sort_off) ? "OFF" : "ON");
} else if (item == DM_RESEND) {
display.print("Resend");
display.setCursor(valCol(display), y);
@@ -1102,6 +1106,11 @@ public:
_dirty = true;
return true;
}
if (_selected == FAV_SORT && p && (left || right || enter)) {
p->fav_sort_off = p->fav_sort_off ? 0 : 1;
_dirty = true;
return true;
}
if (isMsgSlot(_selected) && enter) {
int slot = msgSlotIndex(_selected);
_edit_slot = slot;
+96 -119
View File
@@ -280,71 +280,18 @@ class HomeScreen : public UIScreen {
// Selected slot on the Favourites page (0..FAVOURITES_COUNT - 1).
uint8_t _fav_sel = 0;
// Build the in-place pin picker list for an empty slot. Favourited chat
// contacts first (`c.flags & 0x01`), then recent DM contacts deduped
// against the favourites list. Up to PIN_PICKER_MAX.
void buildPinPicker(int slot) {
_pin_target_slot = slot;
_pin_count = 0;
// 1) Upstream-favourited chat contacts.
for (int idx = 0; _pin_count < PIN_PICKER_MAX; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (c.type != ADV_TYPE_CHAT) continue;
if (!(c.flags & 0x01)) continue;
memcpy(_pin_keys[_pin_count], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
DisplayDriver::translateUTF8Static(_pin_labels[_pin_count], c.name, sizeof(_pin_labels[_pin_count]));
_pin_count++;
}
// 2) Recent DM contacts (deduped).
uint8_t recent[PIN_PICKER_MAX][NodePrefs::FAVOURITE_PREFIX_LEN];
int rn = _task->getRecentDMContacts(recent, PIN_PICKER_MAX);
for (int i = 0; i < rn && _pin_count < PIN_PICKER_MAX; i++) {
bool dup = false;
for (int j = 0; j < _pin_count; j++)
if (memcmp(_pin_keys[j], recent[i], NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { dup = true; break; }
if (dup) continue;
for (int idx = 0; ; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (memcmp(c.id.pub_key, recent[i], NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
memcpy(_pin_keys[_pin_count], recent[i], NodePrefs::FAVOURITE_PREFIX_LEN);
DisplayDriver::translateUTF8Static(_pin_labels[_pin_count], c.name, sizeof(_pin_labels[_pin_count]));
_pin_count++;
break;
}
}
}
// 3) Fallback: all remaining chat contacts not already in the list.
if (_pin_count == 0) {
for (int idx = 0; _pin_count < PIN_PICKER_MAX; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (c.type != ADV_TYPE_CHAT) continue;
bool dup = false;
for (int j = 0; j < _pin_count; j++)
if (memcmp(_pin_keys[j], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { dup = true; break; }
if (dup) continue;
memcpy(_pin_keys[_pin_count], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
DisplayDriver::translateUTF8Static(_pin_labels[_pin_count], c.name, sizeof(_pin_labels[_pin_count]));
_pin_count++;
}
}
if (_pin_count == 0) {
_task->showAlert("No contacts", 1000);
_pin_target_slot = -1;
return;
}
_pin_menu.begin("Pick contact", 3);
for (int i = 0; i < _pin_count; i++) _pin_menu.addItem(_pin_labels[i]);
// Slot payload (pubkey prefix, or channel index in byte 0 — see the slot's
// kind), or nullptr when the slot is empty.
const uint8_t* favSlotPrefix(int slot) const {
NodePrefs* p = _task->getNodePrefs();
if (!p || _task->isFavouriteSlotEmpty(slot)) return nullptr;
return p->favourite_contacts[slot];
}
// In-place pin picker (opens when Enter hits an empty Favourites tile).
static const int PIN_PICKER_MAX = 12;
PopupMenu _pin_menu;
uint8_t _pin_keys[PIN_PICKER_MAX][NodePrefs::FAVOURITE_PREFIX_LEN];
char _pin_labels[PIN_PICKER_MAX][22];
int _pin_count = 0;
// Unpin/Replace menu for a filled tile. Choosing what to pin is the Messages
// screen's job (see UITask::pickFavouriteTarget) -- the dial doesn't carry a
// second browser for contacts, rooms and channels.
PopupMenu _tile_menu;
int _pin_target_slot = -1;
UITask* _task;
@@ -1160,41 +1107,47 @@ public:
bool sel = (i == _fav_sel);
display.drawSelectionRow(cx, cy, cell_w - 1, cell_h - 1, sel);
// Empty slot → all-zero prefix. Real keys collide with this with probability 2^-48.
const uint8_t* prefix = _node_prefs ? _node_prefs->favourite_contacts[i] : nullptr;
bool filled = false;
if (prefix) {
for (uint8_t b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++)
if (prefix[b] != 0) { filled = true; break; }
}
const uint8_t* prefix = favSlotPrefix(i);
char name[26];
uint8_t unread = 0;
bool resolved = false;
ContactInfo ci;
bool has_contact = false;
if (filled) {
if (prefix && _task->favouriteSlotKind(i) == NodePrefs::FAV_KIND_CHANNEL) {
uint8_t ch_idx = prefix[0];
ChannelDetails ch;
if (the_mesh.getChannel(ch_idx, ch) && ch.name[0]) {
// '#' marks a channel apart from a contact tile — the two share the
// grid and Enter does something different on each.
name[0] = '#';
display.translateUTF8ToBlocks(name + 1, ch.name, sizeof(name) - 1);
unread = _task->getChannelUnread(ch_idx);
resolved = true;
}
} else if (prefix) {
for (int idx = 0; ; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (memcmp(c.id.pub_key, prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
ci = c; has_contact = true; break;
display.translateUTF8ToBlocks(name, c.name, sizeof(name));
unread = _task->getDMUnread(c.id.pub_key);
resolved = true;
break;
}
}
if (!has_contact && _node_prefs) {
// Pinned contact is gone — prefs outlived the contact list (e.g. a
// wiped /contacts3; onContactRemoved only catches a live delete).
// Clear the stale slot so it renders as an empty "+" tile (below)
// instead of "(gone)". Persisted once after the loop.
memset(_node_prefs->favourite_contacts[i], 0, NodePrefs::FAVOURITE_PREFIX_LEN);
}
if (prefix && !resolved) {
// The pinned target is gone — prefs outlived the contact/channel list
// (e.g. a wiped /contacts3; onContactRemoved/onChannelRemoved only
// catch a live delete). Clear the stale slot so it renders as an empty
// "+" tile instead of a blank one. Persisted once after the loop.
_task->clearFavouriteSlot(i);
fav_changed = true;
}
}
if (has_contact) {
char name[24];
display.translateUTF8ToBlocks(name, ci.name, sizeof(name));
if (resolved) {
// Reserve space for the unread badge so the name's ellipsis lands
// before it instead of underneath. Badge and name share one baseline.
uint8_t unread = _task->getDMUnread(ci.id.pub_key);
int bw = unread > 0 ? display.unreadBadgeWidth(unread) + 3 : 0; // badge + 3 px gap
int name_y = cy + (cell_h - line_h) / 2;
int name_max_w = cell_w - 4 - bw;
@@ -1213,7 +1166,7 @@ public:
// several gone tiles but only one flash write is needed. Self-healing: once
// cleared, the slot is empty next frame so this can't re-fire per frame.
if (fav_changed) the_mesh.savePrefs();
if (_pin_menu.active) _pin_menu.render(display);
if (_tile_menu.active) _tile_menu.render(display);
} else if (_page == HomePage::SHUTDOWN) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
@@ -1262,21 +1215,18 @@ public:
// left column and RIGHT at the right column fall through to page nav so the
// user can still leave the page sideways.
if (_page == HomePage::FAVOURITES) {
// Pin picker consumes all input while open.
if (_pin_menu.active) {
auto res = _pin_menu.handleInput(c);
// The tile menu consumes all input while open.
if (_tile_menu.active) {
auto res = _tile_menu.handleInput(c);
if (res == PopupMenu::SELECTED && _pin_target_slot >= 0) {
int idx = _pin_menu.selectedIndex();
if (idx >= 0 && idx < _pin_count) {
// If this contact is already pinned elsewhere, vacate that slot first.
int existing = _task->findFavouriteSlot(_pin_keys[idx]);
if (existing >= 0 && existing != _pin_target_slot) _task->clearFavouriteSlot(existing);
_task->setFavouriteSlot(_pin_target_slot, _pin_keys[idx]);
the_mesh.savePrefs();
char alert[24];
snprintf(alert, sizeof(alert), "Pinned to slot %d", _pin_target_slot + 1);
_task->showAlert(alert, 800);
if (_tile_menu.selectedIndex() == 1) { // Replace
_task->pickFavouriteTarget(_pin_target_slot);
_pin_target_slot = -1;
return true;
}
_task->clearFavouriteSlot(_pin_target_slot);
the_mesh.savePrefs();
_task->showAlert("Unpinned", 800);
}
if (res != PopupMenu::NONE) _pin_target_slot = -1;
return true;
@@ -1290,29 +1240,37 @@ public:
if ((c == KEY_RIGHT || c == KEY_NEXT) && col < cols - 1) { _fav_sel++; return true; }
if (c == KEY_UP && row > 0) { _fav_sel -= cols; return true; }
if (c == KEY_DOWN && row < rows - 1) { _fav_sel += cols; return true; }
if (c == KEY_CONTEXT_MENU) {
// Filled tile → Unpin / Replace. An empty tile has nothing to offer:
// its Enter already opens the picker.
if (favSlotPrefix(_fav_sel)) {
_pin_target_slot = _fav_sel;
_tile_menu.begin("Slot options", 2);
_tile_menu.addItem("Unpin");
_tile_menu.addItem("Replace");
}
return true;
}
if (c == KEY_ENTER) {
// Filled slot → open the DM directly. Empty slot waits for phase 3
// (mini-picker); for now show the pin hint.
NodePrefs* p = _task->getNodePrefs();
const uint8_t* pfx = (p && _fav_sel < NodePrefs::FAVOURITES_COUNT)
? p->favourite_contacts[_fav_sel] : nullptr;
bool filled = false;
if (pfx) for (uint8_t b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++)
if (pfx[b]) { filled = true; break; }
if (filled) {
// Filled slot → open its conversation, empty slot → in-place pin picker.
const uint8_t* pfx = favSlotPrefix(_fav_sel);
if (!pfx) { _task->pickFavouriteTarget(_fav_sel); return true; }
if (_task->favouriteSlotKind(_fav_sel) == NodePrefs::FAV_KIND_CHANNEL) {
_task->openChannelHistory(pfx[0]);
return true;
}
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) {
_task->openContactDM(c2);
// A room opens through its own entry point: posting to one needs a
// login handshake that a plain DM view would skip.
if (c2.type == ADV_TYPE_ROOM) _task->openRoomServer(c2);
else _task->openContactDM(c2);
return true;
}
}
_task->showAlert("Contact not found", 800);
} else {
// Empty slot → open in-place pin picker.
buildPinPicker(_fav_sel);
}
return true;
}
// Edge LEFT/RIGHT and unhandled keys fall through to page nav below.
@@ -1742,6 +1700,18 @@ void UITask::openContactDM(const ContactInfo& ci) {
setCurrScreen(messages_screen);
}
void UITask::openChannelHistory(uint8_t channel_idx) {
((MessagesScreen*)messages_screen)->reset();
((MessagesScreen*)messages_screen)->enterChannel(channel_idx);
setCurrScreen(messages_screen);
}
void UITask::openRoomServer(const ContactInfo& ci) {
((MessagesScreen*)messages_screen)->reset();
((MessagesScreen*)messages_screen)->enterRoom(ci);
setCurrScreen(messages_screen);
}
void UITask::shareToMessage(const char* text) {
((MessagesScreen*)messages_screen)->startShare(text);
setCurrScreen(messages_screen);
@@ -1752,6 +1722,11 @@ void UITask::pickLocShareTarget() {
setCurrScreen(messages_screen);
}
void UITask::pickFavouriteTarget(int slot) {
((MessagesScreen*)messages_screen)->startPickFavourite(slot);
setCurrScreen(messages_screen);
}
void UITask::pickBotChannelTarget() {
((MessagesScreen*)messages_screen)->startPickBotChannel();
setCurrScreen(messages_screen);
@@ -1762,10 +1737,6 @@ void UITask::pickBotRoomTarget() {
setCurrScreen(messages_screen);
}
int UITask::getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const {
return ((MessagesScreen*)messages_screen)->getRecentDMContacts(out, max);
}
int UITask::addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp,
const uint8_t* path, uint8_t path_len, bool own_message) {
_last_notif_ch_idx = (int)channel_idx;
@@ -1780,6 +1751,10 @@ int UITask::getChannelUnreadCount() const {
return ((MessagesScreen*)messages_screen)->getTotalChannelUnread();
}
uint8_t UITask::getChannelUnread(uint8_t channel_idx) const {
return ((MessagesScreen*)messages_screen)->chUnread(channel_idx);
}
void UITask::onMsgAck(uint32_t ack_crc) {
((MessagesScreen*)messages_screen)->markDmDelivered(ack_crc);
}
@@ -2951,7 +2926,7 @@ void UITask::onContactRemoved(const uint8_t* pub_key) {
// so a channel re-added at a freed slot can't inherit the old one's settings.
// If you add such a field, add its cleanup below (and mark it in NodePrefs.h).
// Currently covered: bot_channel_idx, loc_share_channel_idx, ch_notif_melody_*,
// ch_notif_override/ch_notif_muted, ch_fav_bitmask.
// ch_notif_override/ch_notif_muted, ch_fav_bitmask, favourite_contacts/_kinds.
void UITask::onChannelRemoved(uint8_t channel_idx) {
if (!_node_prefs) return;
bool changed = false;
@@ -2980,6 +2955,8 @@ void UITask::onChannelRemoved(uint8_t channel_idx) {
_node_prefs->ch_fav_bitmask &= ~mask;
changed = true;
}
int fav_slot = findFavouriteChannelSlot(channel_idx);
if (fav_slot >= 0) { clearFavouriteSlot(fav_slot); changed = true; }
if (changed) the_mesh.savePrefs();
}
+30 -2
View File
@@ -276,12 +276,14 @@ public:
void gotoSettingsScreen();
void gotoMessagesScreen();
void openContactDM(const ContactInfo& ci);
void openChannelHistory(uint8_t channel_idx); // Favourites dial: open a pinned channel
void openRoomServer(const ContactInfo& ci); // Favourites dial: open a pinned room (logs in first)
void shareToMessage(const char* text); // open Messages pre-loaded to share `text`
void quickShareMyLocation(); // Home Map Hold-Enter: one-shot position share
void pickLocShareTarget(); // open Messages to choose the live-share target
void pickFavouriteTarget(int slot); // open Messages to fill a Favourites dial slot
void pickBotChannelTarget(); // open Messages to choose the auto-reply bot's channel
void pickBotRoomTarget(); // open Messages to choose the auto-reply bot's room
int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const;
void gotoToolsScreen();
void gotoRingtoneEditor(int slot = 0);
void gotoBotScreen();
@@ -399,6 +401,7 @@ public:
int getDMUnreadTotal() const;
int getMsgCount() const { return _msgcount; }
int getChannelUnreadCount() const;
uint8_t getChannelUnread(uint8_t channel_idx) const;
int getRoomUnreadCount() const { return _room_unread; }
void clearRoomUnread() { _room_unread = 0; }
// Clamped to the DM ring's actual occupancy for this contact -- defined in
@@ -429,10 +432,17 @@ public:
void clearPing();
void handlePingResult(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms);
// Favourites dial helpers. Slot index 0..FAVOURITES_COUNT-1.
// Favourites dial helpers. Slot index 0..FAVOURITES_COUNT-1. A slot holds
// either a contact/room (pubkey prefix) or a channel (index), per
// favourite_kinds[] — see NodePrefs.
uint8_t favouriteSlotKind(int slot) const {
if (!_node_prefs || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return NodePrefs::FAV_KIND_CONTACT;
return _node_prefs->favourite_kinds[slot];
}
int findFavouriteSlot(const uint8_t* pub_key) const {
if (!_node_prefs || !pub_key) return -1;
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) {
if (_node_prefs->favourite_kinds[i] != NodePrefs::FAV_KIND_CONTACT) continue;
if (memcmp(_node_prefs->favourite_contacts[i], pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
// All-zero prefix is "empty" — never matches a real key.
bool any = false;
@@ -443,8 +453,18 @@ public:
}
return -1;
}
int findFavouriteChannelSlot(uint8_t ch_idx) const {
if (!_node_prefs) return -1;
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) {
if (_node_prefs->favourite_kinds[i] == NodePrefs::FAV_KIND_CHANNEL &&
_node_prefs->favourite_contacts[i][0] == ch_idx) return i;
}
return -1;
}
bool isFavouriteSlotEmpty(int slot) const {
if (!_node_prefs || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return true;
// A channel slot is never empty: channel 0's prefix is all zeroes.
if (_node_prefs->favourite_kinds[slot] == NodePrefs::FAV_KIND_CHANNEL) return false;
for (uint8_t b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++)
if (_node_prefs->favourite_contacts[slot][b]) return false;
return true;
@@ -452,10 +472,18 @@ public:
void setFavouriteSlot(int slot, const uint8_t* pub_key) {
if (!_node_prefs || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT || !pub_key) return;
memcpy(_node_prefs->favourite_contacts[slot], pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
_node_prefs->favourite_kinds[slot] = NodePrefs::FAV_KIND_CONTACT;
}
void setFavouriteChannelSlot(int slot, uint8_t ch_idx) {
if (!_node_prefs || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return;
memset(_node_prefs->favourite_contacts[slot], 0, NodePrefs::FAVOURITE_PREFIX_LEN);
_node_prefs->favourite_contacts[slot][0] = ch_idx;
_node_prefs->favourite_kinds[slot] = NodePrefs::FAV_KIND_CHANNEL;
}
void clearFavouriteSlot(int slot) {
if (!_node_prefs || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return;
memset(_node_prefs->favourite_contacts[slot], 0, NodePrefs::FAVOURITE_PREFIX_LEN);
_node_prefs->favourite_kinds[slot] = NodePrefs::FAV_KIND_CONTACT;
}
bool isButtonPressed() const;
+10
View File
@@ -766,3 +766,13 @@ static const uint8_t advert_icon[] = {
0x04, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
};
// Favourite marker for a list row, on every screen that lists something
// starrable. Reuses the Favourites page's own icon so the two read as one idea.
// Width includes the gap that separates it from the text to its left.
inline int favStarWidth(DisplayDriver& d) {
return ICON_PG_STAR.w * miniIconScale(d) + 2;
}
inline void drawFavStar(DisplayDriver& d, int x, int top_y) {
miniIconDraw(d, x, top_y, ICON_PG_STAR);
}
+4
View File
@@ -4,10 +4,14 @@
- **Long names and labels scroll into view when selected instead of staying stuck behind an ellipsis.** Any selected row that would otherwise truncate — contacts, channels, messages, settings values, and more — now pauses, scrolls to reveal the full text, then swings back to the start and repeats, for as long as it stays selected; unselected rows keep the plain static ellipsis. E-ink gets its own slower cadence to suit the panel.
- **Messages sent from the phone app now show up in the device's own chat history**, not just on the phone — open a DM, room, or channel on the device and a message you just sent from the app is right there, with the same delivery-confirmation markers (pending/delivered for DMs, pending/relayed for channels) a message composed on the device itself gets.
- **The Favourites Dial takes channels and room servers, not just contacts.** Pinning an empty tile now opens the Messages screen's own Direct/Channels/Rooms browse — the same list you already use to open a conversation — instead of a picker built on the dial, and the tile opens the right thing: a DM, a channel history, or a room (running its login handshake first, which the dial used to skip). Channel tiles are marked with a leading `#` and carry their own unread badge. You can also pin straight from the Messages context menus, which every kind now has, and the dial itself gained **Unpin** / **Replace** under Hold Enter on a filled tile.
- **Favourites now behave the same on every list.** A favourite is marked with a ★ on its row and sorted above everything else — in Messages (contacts, rooms, channels) and in Tools Nodes alike. New **Settings Contacts Favs top** turns the sorting off if you'd rather keep the natural order; it's on by default. On Nodes, the old `Favourite` menu row actually pinned to the dial despite its name — it's now split into a real `Fav: ON/OFF` and a separate `Pin to dial`.
- **Contacts and rooms can be marked as favourites from the device.** `Fav: ON/OFF` joins the contact and room context menus, alongside the one channels already had. It's the same starred flag the phone app shows, so it syncs both ways — and it's what the Settings Contacts `DMs`/`Rooms` = `Fav` list filters read, which previously could only be satisfied by starring someone in the app.
- **"Path" / "Relayed by" — see which repeaters actually carried a message.** Hold Enter on a message (list or fullscreen) and, when the data is available, the Options menu gains a new row: **Path (N hops)** on a received message lists the repeaters it actually travelled through, in order; **Relayed by (N)** on your own channel post lists every distinct repeater heard echoing it back into the mesh. Each hop resolves to a contact's name where known, or a short hex tag otherwise.
### Fixes
- **A room server could end up pinned to the Favourites Dial and then never be removed.** The pin picker's "recent conversations" tier didn't filter by contact type, and room posts share the DM history, so a room could appear there; opening it from the dial then skipped the login handshake, and Unpin only existed in the chat contact list, which rooms never appear in. Rooms are now pinned deliberately (and open correctly), and any tile can be unpinned from the dial itself.
- **Bluetooth connect/disconnect/reconnect cycles (e.g. a phone drifting in and out of range) could make the device nearly unresponsive for a few seconds at a time**, including briefly after turning BLE off. A failed BLE notify was retried on a fixed short interval, repeatedly hitting a blocking wait deep in the BLE stack while the link stayed marginal. Failed sends now back off exponentially (capped at 2s) instead of hammering the same blocking call.
---