feat(ui): Favourites phase 3 — in-place pin picker on empty tile

Enter on an empty Favourites slot opens a PopupMenu listing candidate
contacts. Sourced first from upstream-favourited chat contacts (those
flagged with c.flags & 0x01), then from recent DM contacts (resolved
from the 4-byte _dm_hist prefix to a full 6-byte pub_key via the contact
list), deduped against the favourites list. Capped at 12 entries.

Selection writes the chosen 6-byte prefix into the target slot; if the
contact is already pinned elsewhere, the previous slot is vacated first
(one contact per dial, per the agreed pin-only semantics). savePrefs
commits to flash and a brief alert confirms.

Plumbing:
- New QuickMsgScreen::getRecentDMContacts() resolves recent DM senders
  to 6-byte pub_key prefixes, deduped, newest first
- UITask::getRecentDMContacts() wraps it so HomeScreen doesn't reach
  into the QuickMsg screen directly
- HomeScreen gains its own PopupMenu + per-entry pub_key / label buffers
  (12 × 6 B keys + 12 × 22 B labels), no shared state with QuickMsg's
  Contact-options menu

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-25 08:08:12 +02:00
parent c5faaf0afe
commit e1cbd959b2
3 changed files with 105 additions and 1 deletions

View File

@@ -475,6 +475,32 @@ public:
_viewing_max_seen = 0;
}
// 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;
}
// 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

View File

@@ -143,6 +143,58 @@ 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;
}
}
}
if (_pin_count == 0) {
_task->showAlert("No contacts available", 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]);
}
// 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;
int _pin_target_slot = -1;
UITask* _task;
mesh::RTCClock* _rtc;
SensorManager* _sensors;
@@ -811,6 +863,7 @@ public:
}
if (sel) display.setColor(DisplayDriver::LIGHT);
}
if (_pin_menu.active) _pin_menu.render(display);
} else if (_page == HomePage::SHUTDOWN) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
@@ -850,6 +903,25 @@ 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);
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 (res != PopupMenu::NONE) _pin_target_slot = -1;
return true;
}
DisplayDriver* d = _task->getDisplay();
const int cols = (d && d->isLandscape()) ? 3 : 2;
const int rows = NodePrefs::FAVOURITES_COUNT / cols;
@@ -879,7 +951,8 @@ public:
}
_task->showAlert("Contact not found", 800);
} else {
_task->showAlert("Pin from contact options", 1000);
// Empty slot → open in-place pin picker.
buildPinPicker(_fav_sel);
}
return true;
}
@@ -1071,6 +1144,10 @@ void UITask::openContactDM(const ContactInfo& ci) {
setCurrScreen(quick_msg);
}
int UITask::getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const {
return ((QuickMsgScreen*)quick_msg)->getRecentDMContacts(out, max);
}
void UITask::addChannelMsg(uint8_t channel_idx, const char* text) {
_last_notif_ch_idx = (int)channel_idx;
((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text);

View File

@@ -110,6 +110,7 @@ public:
void gotoSettingsScreen();
void gotoQuickMsgScreen();
void openContactDM(const ContactInfo& ci);
int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const;
void gotoToolsScreen();
void gotoRingtoneEditor(int slot = 0);
void gotoBotScreen();