Files
MeshCore-Solo/examples/companion_radio/ui-lvgl/ConversationScreen.h
T
JakubandClaude Opus 5.5 a40a87d02c feat(ui-lvgl): settings kit, choice picker, reorganised Settings
group() / settingRow / listRow / actionRow / choiceRow / groupNote: one card
per section, hairlines between rows, hints that wrap. Dropdowns replaced by
a centred picker list. Settings regrouped (Device, Connections, Map & data,
System); Display & power split into Display, Power and Time on L2 only
(the shared schema is unchanged), Keyboard and About get their own pages.
Radio and Repeater share one LoRa parameter block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:17:48 +02:00

411 lines
18 KiB
C++

#pragma once
// Conversations beyond plain sending -- ui-new's Messages extras:
// - room servers: opening one logs in first (the saved password silently, else
// a password popup), then the thread shows each post under its author;
// - conversation options (hold a DM / room row, or the ⚙ in its thread):
// alerts and favourite for a contact; favourite, log in again / log out
// for a room;
// - message actions (hold a bubble): reply ("@[name] " in the compose field),
// the path it came by / the repeaters that relayed it, set as target;
// - the favourites-only filters of the Chats sections and the contact picker.
// Logic in the UI Core: RoomSessions (logins), contactctl (prefs, reply
// prefix, room post author, path hops), shared with ui-new.
//
// Single-TU fragment: included at the end of ui-lvgl/UITask.cpp (uses the
// thread's s_msg_meta).
namespace convview {
enum : uint8_t { A_FAV, A_READ, A_LOGIN, A_LOGOUT, A_PIN };
enum : uint8_t { M_REPLY, M_TARGET };
static uint8_t s_key[PUB_KEY_SIZE]; // the conversation the popup is about
static uint8_t s_login_key[PUB_KEY_SIZE];
static bool s_login_wait = false; // open the room once this login answers
static lv_obj_t* s_fav_btn = nullptr;
static int s_msg = -1; // s_msg_meta index the message popup is about
static lv_obj_t* actionRow(lv_obj_t* panel) {
lv_obj_t* acts = lv_obj_create(panel);
styleSurface(acts, theme::BG);
lv_obj_remove_flag(acts, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(acts, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(acts, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_column(acts, theme::GAP, 0);
return acts;
}
static lv_obj_t* actionButton(lv_obj_t* acts, const char* text, lv_event_cb_t cb, uint8_t act, bool on) {
lv_obj_t* bt = lv_button_create(acts);
lv_obj_set_height(bt, 40);
lv_obj_set_flex_grow(bt, 1);
lv_obj_set_style_pad_hor(bt, 4, 0);
lv_obj_set_style_radius(bt, theme::RADIUS, 0);
lv_obj_set_style_shadow_width(bt, 0, 0);
lv_obj_set_style_bg_color(bt, lv_color_hex(on ? theme::ACCENT_DIM : theme::SURFACE), 0);
lv_obj_add_event_cb(bt, cb, LV_EVENT_CLICKED, (void*)(uintptr_t)act);
lv_obj_center(label(bt, text, THEME_FONT_SMALL, theme::TEXT));
return bt;
}
} // namespace convview
static void onConvAction(lv_event_t* e) { s_ui->conversationAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onConvMelody(lv_event_t* e) {
s_ui->conversationMelody(choiceSelected((lv_obj_t*)lv_event_get_target(e)));
}
static void onConvNotif(lv_event_t* e) {
s_ui->conversationNotif(choiceSelected((lv_obj_t*)lv_event_get_target(e)));
}
static void onMsgAction(lv_event_t* e) { s_ui->messageAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onRoomLoginKb(lv_event_t* e) { s_ui->roomLoginDone(lv_event_get_code(e) == LV_EVENT_READY); }
static void onDMRowHold(lv_event_t* e) {
lv_indev_wait_release(lv_indev_active()); // the hold isn't also a tap that opens it
s_ui->conversationMenu(s_dm_rows[(uintptr_t)lv_event_get_user_data(e)]);
}
static void onRoomRow(lv_event_t* e) { s_ui->openRoom(s_room_rows[(uintptr_t)lv_event_get_user_data(e)]); }
static void onRoomRowHold(lv_event_t* e) {
lv_indev_wait_release(lv_indev_active());
s_ui->conversationMenu(s_room_rows[(uintptr_t)lv_event_get_user_data(e)]);
}
static void onConvThreadMenu(lv_event_t* e) { (void)e; s_ui->conversationMenu(nullptr); }
static void onMsgHold(lv_event_t* e) {
lv_indev_wait_release(lv_indev_active());
s_ui->messageMenu((int)(uintptr_t)lv_event_get_user_data(e));
}
static void onChatFilter(lv_event_t* e) { s_ui->toggleChatFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
// ── Rooms ─────────────────────────────────────────────────────────────────────
void UITask::openRoom(const uint8_t* pub_key) {
using namespace convview;
ContactInfo ci;
if (!MessageHistory::contactByPrefix(pub_key, ci)) return;
_core->clearRoomUnread();
switch (_core->rooms.open(ci)) {
case RoomSessions::OPEN_NOW:
openDM(ci.id.pub_key);
break;
case RoomSessions::LOGGING_IN: // saved password: opens when the room answers
memcpy(s_login_key, ci.id.pub_key, PUB_KEY_SIZE);
s_login_wait = true;
showToast(LV_SYMBOL_REFRESH " Logging in...", 8000);
break;
case RoomSessions::NEED_PASSWORD:
roomLoginPopup(ci.id.pub_key);
break;
default:
showToast("Login failed - not sent");
break;
}
}
void UITask::roomLoginPopup(const uint8_t* pub_key) {
using namespace convview;
ContactInfo ci;
if (!MessageHistory::contactByPrefix(pub_key, ci)) return;
memcpy(s_login_key, ci.id.pub_key, PUB_KEY_SIZE);
lv_obj_t* panel = navPopupPanel(ci.name, false);
lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard
_nav_ta = wifiField(panel, "Room password (may be empty)", true);
lv_obj_remove_event_cb(_nav_ta, onWifiField);
lv_textarea_set_max_length(_nav_ta, 15);
lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); // draws the cursor
_nav_kb = kb::create(_nav_overlay, _prefs);
lv_obj_set_size(_nav_kb, LV_PCT(100), 124);
lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_keyboard_set_textarea(_nav_kb, _nav_ta);
lv_obj_add_event_cb(_nav_kb, onRoomLoginKb, LV_EVENT_READY, NULL);
lv_obj_add_event_cb(_nav_kb, onRoomLoginKb, LV_EVENT_CANCEL, NULL);
}
void UITask::roomLoginDone(bool ok) {
using namespace convview;
ContactInfo ci;
if (!ok || !_nav_ta || !MessageHistory::contactByPrefix(s_login_key, ci)) { navClosePopup(); return; }
bool sent = _core->rooms.login(ci, lv_textarea_get_text(_nav_ta));
navClosePopup();
s_login_wait = sent;
showToast(sent ? LV_SYMBOL_REFRESH " Logging in..." : "Login failed - not sent", sent ? 8000 : 2500);
}
// From loop(), on any screen: a login answer (or its timeout).
void UITask::roomPoll() {
using namespace convview;
uint8_t key[4];
RoomSessions::Outcome o = _core->rooms.take(key);
if (o == RoomSessions::NONE) return;
bool mine = s_login_wait && memcmp(key, s_login_key, 4) == 0;
if (mine) s_login_wait = false;
bool picking = (_screen == SCR_CHATS || _screen == SCR_HOME) && !_nav_overlay && !locked(); // where it was tapped
if (o == RoomSessions::LOGGED_IN) {
showToast("Logged in", 1200);
if (mine && picking) openDM(s_login_key);
else if (_screen == SCR_CHATS && !_nav_overlay) buildChats();
} else if (o == RoomSessions::LOGIN_FAILED) {
showToast("Login failed - wrong password?", 3000);
if (mine && picking) roomLoginPopup(s_login_key);
} else {
showToast("No answer from the room", 3000);
}
}
// ── Conversation options ──────────────────────────────────────────────────────
void UITask::conversationMenu(const uint8_t* pub_key) {
using namespace convview;
ContactInfo ci;
if (!MessageHistory::contactByPrefix(pub_key ? pub_key : _thread_key, ci)) return;
memcpy(s_key, ci.id.pub_key, PUB_KEY_SIZE);
lv_obj_t* panel = navPopupPanel(ci.name, false);
bool room = ci.type == ADV_TYPE_ROOM;
if (!room) {
lv_obj_t* g = group(panel, nullptr);
choiceRow(g, "Alerts", nullptr, "Default\nMuted\nAlways", contactctl::notif(_prefs, ci.id.pub_key), onConvNotif);
choiceRow(g, "Sound", nullptr, "Default\nMelody 1\nMelody 2", contactctl::melody(_prefs, ci.id.pub_key), onConvMelody);
} else {
bool in = _core->rooms.isLoggedIn(ci.id.pub_key);
lv_obj_t* st = label(panel, in ? LV_SYMBOL_OK " Logged in: you can post"
: "Not logged in this session", THEME_FONT_SMALL, in ? theme::OK : theme::TEXT_MUTED);
lv_obj_set_width(st, LV_PCT(100));
}
lv_obj_t* acts = actionRow(panel);
s_fav_btn = actionButton(acts, UI_SYMBOL_STAR " Fav", onConvAction, A_FAV, contactctl::favourite(ci));
if (!room && _core->dmUnread(ci.id.pub_key) > 0) actionButton(acts, LV_SYMBOL_OK " Read", onConvAction, A_READ, false);
actionButton(acts, UI_SYMBOL_PIN, onConvAction, A_PIN, favslots::findContact(_prefs, ci.id.pub_key) >= 0);
if (room) {
actionButton(acts, LV_SYMBOL_EDIT " Login", onConvAction, A_LOGIN, false);
if (_core->rooms.isLoggedIn(ci.id.pub_key)) actionButton(acts, LV_SYMBOL_CLOSE " Logout", onConvAction, A_LOGOUT, false);
}
}
void UITask::conversationNotif(int v) {
if (!_prefs || v < 0 || v > 2) return;
contactctl::setNotif(_prefs, convview::s_key, (uint8_t)v);
the_mesh.savePrefs();
}
void UITask::conversationAction(uint8_t act) {
using namespace convview;
ContactInfo ci;
if (!MessageHistory::contactByPrefix(s_key, ci)) { navClosePopup(); return; }
switch (act) {
case A_FAV: {
bool on = !contactctl::favourite(ci);
if (!contactctl::setFavourite(ci.id.pub_key, on)) break;
if (s_fav_btn) lv_obj_set_style_bg_color(s_fav_btn, lv_color_hex(on ? theme::ACCENT_DIM : theme::SURFACE), 0);
showToast(on ? "Added to favourites" : "Removed from favourites", 1200);
if (_screen == SCR_CHATS) { buildChats(); conversationMenu(s_key); } // star / filter
break;
}
case A_READ:
_core->clearDMUnread(ci.id.pub_key);
navClosePopup();
if (_screen == SCR_CHATS) buildChats();
break;
case A_LOGIN:
roomLoginPopup(ci.id.pub_key);
break;
case A_PIN:
pinPopup(false, 0, ci.id.pub_key); // DeviceScreen.h
break;
case A_LOGOUT:
_core->rooms.logout(ci.id.pub_key);
navClosePopup();
showToast("Logged out - password forgotten", 2000);
if (_screen == SCR_THREAD) showChats();
else if (_screen == SCR_CHATS) buildChats();
break;
}
}
// ── Message actions ───────────────────────────────────────────────────────────
// One stop on the path diagram: a dot on a vertical line (the line runs on to
// the next stop unless `last`), the name beside it, what it is on the right.
static void pathStop(lv_obj_t* parent, const char* name, const char* role, bool first, bool last,
uint32_t col, bool end) {
lv_obj_t* row = lv_obj_create(parent);
lv_obj_remove_style_all(row);
lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(row, LV_PCT(100), 22);
if (!first || !last) {
lv_obj_t* line = lv_obj_create(row);
lv_obj_remove_style_all(line);
lv_obj_set_style_bg_color(line, lv_color_hex(theme::TEXT_MUTED), 0);
lv_obj_set_style_bg_opa(line, LV_OPA_50, 0);
lv_obj_set_size(line, 2, first || last ? 11 : 22);
lv_obj_set_pos(line, 6, first ? 11 : 0);
}
lv_obj_t* dot = lv_obj_create(row);
lv_obj_remove_style_all(dot);
lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(dot, lv_color_hex(end ? col : theme::SURFACE), 0);
lv_obj_set_style_border_color(dot, lv_color_hex(col), 0);
lv_obj_set_style_border_width(dot, end ? 0 : 2, 0);
lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0);
lv_obj_set_size(dot, end ? 10 : 8, end ? 10 : 8);
lv_obj_align(dot, LV_ALIGN_LEFT_MID, end ? 2 : 3, 0);
lv_obj_t* t = label(row, name, end ? THEME_FONT_BODY : THEME_FONT_SMALL, theme::TEXT);
lv_label_set_long_mode(t, LV_LABEL_LONG_DOT);
lv_obj_set_width(t, 190);
lv_obj_align(t, LV_ALIGN_LEFT_MID, 22, 0);
if (role && role[0]) lv_obj_align(label(row, role, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_RIGHT_MID, 0, 0);
}
// The stops of a path, in a box that scrolls once there are many (up to 64 hops).
static lv_obj_t* pathDiagram(lv_obj_t* panel) {
lv_obj_t* diag = lv_obj_create(panel);
lv_obj_remove_style_all(diag);
lv_obj_set_size(diag, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_max_height(diag, 5 * 22, 0);
lv_obj_set_style_pad_right(diag, 6, 0); // the roles clear this box's own scrollbar
lv_obj_set_flex_flow(diag, LV_FLEX_FLOW_COLUMN);
lv_obj_set_scroll_dir(diag, LV_DIR_VER);
return diag;
}
void UITask::messageMenu(int idx) {
using namespace convview;
if (idx < 0 || idx >= s_msg_meta_n) return;
const MsgMeta& m = s_msg_meta[idx];
s_msg = idx;
lv_obj_t* panel = navPopupPanel(m.own ? "My message" : (m.from[0] ? m.from : "Message"), false);
const MessageHistory& h = _core->history;
uint8_t packed = 0; const uint8_t* path = nullptr; bool relay = false;
uint32_t ts = 0; const char* body = "";
AckState dm_st = ACK_NONE;
if (m.pos >= 0) {
if (m.channel) {
const ChHistEntry& e = s_th_ch[m.pos];
packed = e.path_len; path = e.path; relay = m.own; ts = e.timestamp;
const char* sep = strstr(e.text, ": ");
body = sep ? sep + 2 : e.text;
} else {
const DmHistEntry& e = s_th_dm[m.pos];
packed = e.path_len; path = e.path; ts = e.timestamp; body = e.text;
if (e.outgoing) dm_st = h.dmEffectiveStatus(e);
else {
ContactInfo tc; // a room post is filed "Author: text"
if (MessageHistory::contactByPrefix(_thread_key, tc) && tc.type == ADV_TYPE_ROOM) {
const char* sep = strstr(e.text, ": ");
if (sep) body = sep + 2;
}
}
}
}
// The message itself, quoted short, and when.
char quote[MAX_TEXT_LEN + 1];
snprintf(quote, sizeof(quote), "%s", body);
plainMentions(quote);
lv_obj_t* q = label(panel, quote, THEME_FONT_SMALL, theme::TEXT);
lv_label_set_long_mode(q, LV_LABEL_LONG_DOT);
lv_obj_set_width(q, LV_PCT(100));
lv_obj_set_style_max_height(q, 34, 0); // two lines
lv_obj_set_style_border_side(q, LV_BORDER_SIDE_LEFT, 0);
lv_obj_set_style_border_width(q, 2, 0);
lv_obj_set_style_border_color(q, lv_color_hex(m.own ? theme::ACCENT : theme::SURFACE_2), 0);
lv_obj_set_style_pad_left(q, 8, 0);
char when[64], age[16];
uint32_t now = rtc_clock.getCurrentTime();
geo::fmtAgeShort(age, sizeof(age), now, ts ? ts : now);
if (ts > 1000000000UL) {
time_t t = (time_t)((int64_t)ts + (int64_t)(_prefs ? _prefs->tz_offset_hours : 0) * 3600);
struct tm ti = *gmtime(&t);
char clk[12];
fmtClock(clk, sizeof(clk), ti, _prefs, true);
if (clockBehind(now, ts)) { // the clock isn't set yet: the date instead of an age
char date[32];
fmtDate(date, sizeof(date), ti, nullptr);
snprintf(when, sizeof(when), "%s %s", date, clk);
} else snprintf(when, sizeof(when), "%s - %s ago", clk, age);
} else {
snprintf(when, sizeof(when), "%s ago", age);
}
uint8_t hops = contactctl::hopCount(packed);
if (!m.own) { // incoming: how far it came, on the same line
size_t o = strlen(when);
if (hops > 0) snprintf(when + o, sizeof(when) - o, " - %u hop%s", hops, hops == 1 ? "" : "s");
else snprintf(when + o, sizeof(when) - o, " - direct");
}
label(panel, when, THEME_FONT_SMALL, theme::TEXT_MUTED);
if (m.own && !m.channel) { // a DM we sent: its end-to-end delivery, in words
const char* st = "Sent"; uint32_t col = theme::TEXT_MUTED;
switch (dm_st) {
case ACK_OK: st = LV_SYMBOL_OK " Delivered"; col = theme::OK; break;
case ACK_FAIL: st = LV_SYMBOL_CLOSE " Not delivered"; col = theme::FAIL; break;
case ACK_PENDING: st = "Sending..."; break;
default: break;
}
label(panel, st, THEME_FONT_BODY, col);
} else if (relay) { // our channel post: the repeaters heard passing it on
char hd[48];
if (hops > 0) snprintf(hd, sizeof(hd), "Heard %u repeater%s pass it on", hops, hops == 1 ? "" : "s");
else snprintf(hd, sizeof(hd), "No repeater heard passing it on yet");
label(panel, hd, THEME_FONT_BODY, hops > 0 ? theme::OK : theme::TEXT_MUTED);
if (hops > 0) { // each one an echo of ours: a fan out from us, not a chain
lv_obj_t* diag = pathDiagram(panel);
pathStop(diag, the_mesh.getNodeName(), "you", true, false, theme::ACCENT, true);
for (uint8_t i = 0; i < hops; i++) {
char nm[33];
contactctl::hopName(packed, path, i, nm, sizeof(nm));
pathStop(diag, nm, UI_SYMBOL_RADIO, false, i == hops - 1, theme::OK, false);
}
}
} else { // incoming: sender, each hop, then us
lv_obj_t* diag = pathDiagram(panel);
pathStop(diag, m.from[0] ? m.from : "Sender", "sender", true, false, theme::ACCENT, true);
for (uint8_t i = 0; i < hops; i++) {
char nm[33], role[12];
contactctl::hopName(packed, path, i, nm, sizeof(nm));
snprintf(role, sizeof(role), "hop %u", (unsigned)(i + 1));
pathStop(diag, nm, role, false, false, theme::TEXT_MUTED, false);
}
pathStop(diag, the_mesh.getNodeName(), "you", false, true, theme::OK, true);
}
bool can_reply = !m.own && m.from[0] && _compose_ta;
if (!can_reply && m.loc < 0) return;
lv_obj_t* acts = actionRow(panel);
if (can_reply) actionButton(acts, LV_SYMBOL_EDIT " Reply", onMsgAction, M_REPLY, false);
if (m.loc >= 0) actionButton(acts, UI_SYMBOL_COMPASS " Set target", onMsgAction, M_TARGET, false);
}
void UITask::messageAction(uint8_t act) {
using namespace convview;
if (s_msg < 0 || s_msg >= s_msg_meta_n) { navClosePopup(); return; }
const MsgMeta& m = s_msg_meta[s_msg];
navClosePopup();
if (act == M_REPLY && _compose_ta) {
char pre[40];
contactctl::replyPrefix(m.from, pre, sizeof(pre));
lv_textarea_set_text(_compose_ta, pre);
setKeyboardVisible(true);
} else if (act == M_TARGET && m.loc >= 0 && m.loc < s_msg_loc_n) {
const MsgLoc& l = s_msg_locs[m.loc];
navSetTarget(0, nullptr, l.lat, l.lon, l.label); // a place snapshotted from the text
char msg[48];
snprintf(msg, sizeof(msg), "Target: %s", l.label);
showToast(msg);
}
}
// ── Filters ───────────────────────────────────────────────────────────────────
void UITask::toggleChatFilter(uint8_t which) {
if (!_prefs) return;
switch (which) {
case CF_CHANNELS: _prefs->ch_fav_only ^= 1; break;
case CF_ROOMS: _prefs->room_fav_only ^= 1; break;
case CF_CONTACTS: _prefs->dm_show_all ^= 1; break;
}
the_mesh.savePrefs();
if (_screen == SCR_CONTACTS) buildContacts(); else buildChats();
}