feat(nodes): on-device add/delete/favourite + circular filter tabs, rename to "Nodes"

Turn Nearby Nodes into the single node hub and make its navigation legible.

Node management (no phone app needed):
- MyMesh gains addDiscoveredContact() and deleteContactByKey(); CMD_REMOVE_CONTACT
  now reuses deleteContactByKey() (one delete path: contact + blob + room password
  + UI cleanup + lazy write).
- Action menu (Hold Enter) gains Add contact (a new scanned node), Favourite /
  Unfavourite (pin to the first free dial slot), and Delete contact (confirm first,
  defaults to Cancel). Actions are offered contextually per row.
- A pinned contact shows a star in the list row, next to any live-share diamond.

Absorb passively-heard adverts: refreshStored() folds getRecentlyHeard() nodes that
aren't already listed as name+age rows (All filter only — AdvertPath has no type),
so "recently heard" no longer needs its own page.

Legible filter navigation: the filter (LEFT/RIGHT) is now a visible tab strip — the
active filter is a centred inverted pill, neighbours fan out and wrap around
(circular, first<->last). Only whole tabs are drawn so labels never wrap a line.

Rename Tools > "Nearby Nodes" to "Nodes".

Both solo envs build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-07-07 01:52:51 +02:00
parent 8e5b02f0a6
commit 97493b490e
4 changed files with 227 additions and 21 deletions

View File

@@ -424,6 +424,35 @@ int MyMesh::getRecentlyHeard(AdvertPath dest[], int max_num) {
return max_num;
}
bool MyMesh::addDiscoveredContact(const uint8_t* pub_key, const char* name, uint8_t type) {
if (lookupContactByPubKey(pub_key, PUB_KEY_SIZE)) return true; // already a contact
ContactInfo c;
memset(&c, 0, sizeof(c));
c.id = mesh::Identity(pub_key);
strncpy(c.name, name ? name : "", sizeof(c.name) - 1);
c.name[sizeof(c.name) - 1] = '\0';
c.type = type;
c.out_path_len = OUT_PATH_UNKNOWN; // no path yet → flood until one is learned
c.lastmod = getRTCClock()->getCurrentTime();
c.shared_secret_valid = false;
if (!addContact(c)) return false; // contacts table full
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
return true;
}
// Full delete sequence shared by CMD_REMOVE_CONTACT and the on-device Nearby menu:
// drop the contact, its persisted blob and any saved room login, run the UI cleanup
// (favourite slot / Locator / Live Share target), then schedule the lazy write.
bool MyMesh::deleteContactByKey(const uint8_t* pub_key) {
ContactInfo* recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!recipient || !removeContact(*recipient)) return false;
_store->deleteBlobByKey(pub_key, PUB_KEY_SIZE);
forgetRoomPassword(pub_key);
if (_ui) _ui->onContactRemoved(pub_key);
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);
@@ -1991,17 +2020,8 @@ void MyMesh::handleCmdFrame(size_t len) {
}
}
} else if (cmd_frame[0] == CMD_REMOVE_CONTACT) {
uint8_t *pub_key = &cmd_frame[1];
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (recipient && removeContact(*recipient)) {
_store->deleteBlobByKey(pub_key, PUB_KEY_SIZE);
forgetRoomPassword(pub_key); // drop any saved room login -- useless without the contact
if (_ui) _ui->onContactRemoved(pub_key); // drop any favourite slot / Locator / Live Share target pointed at it
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_NOT_FOUND); // not found, or unable to remove
}
if (deleteContactByKey(&cmd_frame[1])) writeOKFrame();
else writeErrFrame(ERR_CODE_NOT_FOUND); // not found, or unable to remove
} else if (cmd_frame[0] == CMD_SHARE_CONTACT) {
uint8_t *pub_key = &cmd_frame[1];
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);