feat(ui-core,ui-lvgl): room sessions and contact helpers in the Core, L2 rooms, conversation options, message actions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-25 20:27:32 +02:00
co-authored by Claude Opus 5.5
parent 1c8206a510
commit 6d82da877e
11 changed files with 728 additions and 152 deletions
@@ -0,0 +1,87 @@
#pragma once
// Conversation-level helpers shared by the messages screens (ui-new's
// Messages, ui-lvgl's Chats / thread): per-contact notification and melody
// overrides, the favourite flag, the reply prefix, splitting a room post into
// author and text, and naming the hops of a message's path.
namespace contactctl {
// Per-contact overrides live in small {prefix[4], value} tables in NodePrefs:
// value 0 = no override = free slot. Full table: slot 0 is overwritten.
enum : uint8_t { NOTIF_DEFAULT = 0, NOTIF_MUTED = 1, NOTIF_ALWAYS = 2 };
template <class Entry>
inline uint8_t tableGet(const Entry* tbl, int n, const uint8_t* pub_key, uint8_t Entry::* val) {
for (int i = 0; i < n; i++)
if (tbl[i].*val && memcmp(tbl[i].prefix, pub_key, 4) == 0) return tbl[i].*val;
return 0;
}
template <class Entry>
inline void tableSet(Entry* tbl, int n, const uint8_t* pub_key, uint8_t Entry::* val, uint8_t v) {
for (int i = 0; i < n; i++)
if (tbl[i].*val && memcmp(tbl[i].prefix, pub_key, 4) == 0) {
if (v == 0) memset(&tbl[i], 0, sizeof(tbl[i])); else tbl[i].*val = v;
return;
}
if (v == 0) return;
for (int i = 0; i < n; i++)
if (tbl[i].*val == 0) { memcpy(tbl[i].prefix, pub_key, 4); tbl[i].*val = v; return; }
memcpy(tbl[0].prefix, pub_key, 4); tbl[0].*val = v;
}
inline uint8_t notif(const NodePrefs* p, const uint8_t* pub_key) {
return p ? tableGet(p->dm_notif, NodePrefs::DM_NOTIF_TABLE_MAX, pub_key, &NodePrefs::DmNotifEntry::state) : 0;
}
inline void setNotif(NodePrefs* p, const uint8_t* pub_key, uint8_t state) {
if (p) tableSet(p->dm_notif, NodePrefs::DM_NOTIF_TABLE_MAX, pub_key, &NodePrefs::DmNotifEntry::state, state);
}
// Melody slot: 0 = default, 1 / 2 = the user melodies.
inline uint8_t melody(const NodePrefs* p, const uint8_t* pub_key) {
return p ? tableGet(p->dm_melody, NodePrefs::DM_MELODY_TABLE_MAX, pub_key, &NodePrefs::DmMelodyEntry::slot) : 0;
}
inline void setMelody(NodePrefs* p, const uint8_t* pub_key, uint8_t slot) {
if (p) tableSet(p->dm_melody, NodePrefs::DM_MELODY_TABLE_MAX, pub_key, &NodePrefs::DmMelodyEntry::slot, slot);
}
// ContactInfo::flags bit 0, the same star the companion app sets.
inline bool favourite(const ContactInfo& ci) { return (ci.flags & 0x01) != 0; }
inline bool setFavourite(const uint8_t* pub_key, bool on) { return the_mesh.setContactFavourite(pub_key, on); }
// "@[nick] " -- how a reply names who it answers (raw UTF-8, over the air as is).
inline void replyPrefix(const char* nick, char* out, size_t n) { snprintf(out, n, "@[%.31s] ", nick); }
// A room post is filed "Author: text" (UiCore::onMessageRecvEx). Author into
// `author` ("" when there is none), returns the text after it.
inline const char* splitRoomPost(const char* text, char* author, size_t n) {
const char* sep = strstr(text, ": ");
if (!sep || (size_t)(sep - text) >= n) { if (n) author[0] = '\0'; return text; }
memcpy(author, text, sep - text);
author[sep - text] = '\0';
return sep + 2;
}
// Hops recorded with a history entry: path_len packs hash size (top 2 bits,
// minus 1) and hop count (low 6 bits).
inline uint8_t hopCount(uint8_t path_len_packed) {
uint8_t hash_size = (path_len_packed >> 6) + 1;
uint8_t n = path_len_packed & 63;
uint8_t max_hops = MAX_HIST_PATH_BYTES / hash_size; // what capturePath() stores at most
return n > max_hops ? max_hops : n;
}
// Hop i's name: the contact whose key starts with the hash, else "?AABB".
inline void hopName(uint8_t path_len_packed, const uint8_t* path, int i, char* out, size_t n) {
uint8_t hash_size = (path_len_packed >> 6) + 1;
const uint8_t* hash = &path[i * hash_size];
for (int idx = 0; ; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (c.id.isHashMatch(hash, hash_size)) { snprintf(out, n, "%s", c.name); return; }
}
char hex[9] = {0};
uint8_t b = hash_size > 4 ? 4 : hash_size;
for (uint8_t k = 0; k < b; k++) snprintf(hex + k * 2, 3, "%02X", hash[k]);
snprintf(out, n, "?%s", hex);
}
} // namespace contactctl
@@ -0,0 +1,124 @@
#pragma once
// Room server sessions for the on-device messages screens (ui-new's
// Messages > Room Servers, ui-lvgl's Messages > Rooms). Posting to a room needs
// a login handshake first, even with a blank password; this keeps which rooms
// were logged in to this power-on, the one login in flight, and the password
// memory (MyMesh::saveRoomPassword): a room logged in to on an earlier boot
// logs in again silently with the saved password, a failed login forgets it.
//
// RAM-only memo: the server's ACL is the real permission store and survives
// reboot on its own, but the device can't query it, so this only saves asking
// again for a password entered this session.
class RoomSessions {
public:
enum Open : uint8_t { OPEN_NOW, LOGGING_IN, NEED_PASSWORD, SEND_FAILED };
enum Outcome : uint8_t { NONE, LOGGED_IN, LOGIN_FAILED, LOGIN_TIMEOUT };
bool isLoggedIn(const uint8_t* pub_key) const {
for (int i = 0; i < _count; i++)
if (memcmp(_prefix[(_head + i) % TABLE_SIZE], pub_key, 4) == 0) return true;
return false;
}
// Opening a room: straight in when logged in this session, a background
// login with the saved password, else the frontend asks for one.
Open open(const ContactInfo& ci) {
if (isLoggedIn(ci.id.pub_key)) return OPEN_NOW;
char saved[sizeof(_pw)];
if (the_mesh.getRoomPassword(ci.id.pub_key, saved, sizeof(saved)))
return login(ci, saved) ? LOGGING_IN : SEND_FAILED;
return NEED_PASSWORD;
}
bool login(const ContactInfo& ci, const char* password) {
snprintf(_pw, sizeof(_pw), "%s", password);
uint32_t est = 0;
if (!the_mesh.sendRoomLogin(ci, password, est)) { _pending = false; return false; }
memcpy(_pending_key, ci.id.pub_key, 4);
_pending = true;
_deadline = millis() + est + 4000; // same margin as a DM's ACK wait
return true;
}
bool loggingIn() const { return _pending; }
bool loggingIn(const uint8_t* pub_key) const { return _pending && memcmp(_pending_key, pub_key, 4) == 0; }
const uint8_t* pendingKey() const { return _pending_key; }
// Local only: the room ACL has no session to tear down (MyMesh::logoutRoom()).
void logout(const uint8_t* pub_key) {
the_mesh.logoutRoom(pub_key);
forget(pub_key);
if (loggingIn(pub_key)) cancel();
}
void cancel() {
if (_pending) the_mesh.cancelUiPendingLogin(_pending_key);
_pending = false;
}
// From UiCore::onRoomLoginResult (after the admin session passed on it).
// True when it answers this session's login.
bool onLoginResult(const uint8_t* pub_key, bool success) {
if (!loggingIn(pub_key)) return false;
_pending = false;
if (success) {
markLoggedIn(pub_key);
the_mesh.saveRoomPassword(pub_key, _pw);
_outcome = LOGGED_IN;
} else {
// The saved password no longer works: forget it, so the next open asks
// instead of retrying the same bad one forever.
the_mesh.forgetRoomPassword(pub_key);
_outcome = LOGIN_FAILED;
}
memcpy(_outcome_key, pub_key, 4);
return true;
}
void loop() {
if (_pending && (int32_t)(millis() - _deadline) > 0) {
memcpy(_outcome_key, _pending_key, 4);
cancel();
_outcome = LOGIN_TIMEOUT;
}
}
// The last login's outcome, once (NONE when nothing new); key = its room.
Outcome take(uint8_t* key_out = nullptr) {
Outcome o = _outcome;
_outcome = NONE;
if (key_out) memcpy(key_out, _outcome_key, 4);
return o;
}
private:
static const int TABLE_SIZE = 8; // oldest dropped when full
void markLoggedIn(const uint8_t* pub_key) {
if (isLoggedIn(pub_key)) return;
int pos;
if (_count < TABLE_SIZE) pos = (_head + _count++) % TABLE_SIZE;
else { pos = _head; _head = (_head + 1) % TABLE_SIZE; }
memcpy(_prefix[pos], pub_key, 4);
}
void forget(const uint8_t* pub_key) {
for (int i = 0; i < _count; i++) {
if (memcmp(_prefix[(_head + i) % TABLE_SIZE], pub_key, 4) != 0) continue;
for (int j = i; j < _count - 1; j++)
memcpy(_prefix[(_head + j) % TABLE_SIZE], _prefix[(_head + j + 1) % TABLE_SIZE], 4);
_count--;
return;
}
}
uint8_t _prefix[TABLE_SIZE][4];
int _head = 0, _count = 0;
char _pw[16] = ""; // of the login in flight, saved on success
uint8_t _pending_key[4] = {0};
bool _pending = false;
uint32_t _deadline = 0;
Outcome _outcome = NONE;
uint8_t _outcome_key[4] = {0};
};
+9 -2
View File
@@ -25,6 +25,8 @@
#include "WaypointModel.h"
#include "ChannelControl.h"
#include "AdminSession.h"
#include "RoomSessions.h"
#include "ContactControl.h"
class UiCore : public MyMesh::Listener {
public:
@@ -53,6 +55,7 @@ public:
locator.loop();
trail.loop();
admin.loop();
rooms.loop();
}
UiEventQueue events; // Core → frontend; drained by the frontend's loop()
@@ -65,6 +68,7 @@ public:
LocatorEngine locator; // active target, geofence crossings, proximity beeper
TrailEngine trail; // GPS trail store, sampling, auto-pause, shutdown save
AdminSession admin; // remote repeater / room admin: login + CLI round trips
RoomSessions rooms; // room server logins for posting (messages screens)
// ── Models ────────────────────────────────────────────────────────────────
MessageHistory history; // channel + DM rings, delivery state, channel unread
@@ -240,9 +244,12 @@ public:
}
// Login answers go to the admin session when it's the one waiting, else to
// the frontend (room server logins from its messages screen).
// the room sessions (logins from the messages screen), then the frontend.
void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override {
if (!admin.onLoginResult(pub_key, success, permissions)) _host->onRoomLoginResult(pub_key, success, permissions);
if (!admin.onLoginResult(pub_key, success, permissions)) {
rooms.onLoginResult(pub_key, success);
_host->onRoomLoginResult(pub_key, success, permissions);
}
_host->onAdminStateChanged();
}
void onAdminReply(const uint8_t* pub_key, const char* text) override {