Files
pyxis/lib/tdeck_ui/UI/LXMF/ChatScreen.cpp
T
Torlando a1c2ec8569 fix(lxmf): set send marker in the LVGL lock section (close completion race)
Greptile round on ba5af76 (4/5) correctly rejected the first attempt:
the submitted-text marker was assigned in ChatScreen::on_send_clicked
AFTER the mailbox publish returned, so the main loop could take() +
admit the send and enter clear_composer() while the marker was still
empty — neither clearing the submitted text nor associating the commit
with its submission.

The marker is now recorded by the send callback itself
(UIManager::on_send_message_from_chat) immediately after the mailbox
accept, in the same LVGL lock section as the publish. The click handler
runs on the LVGL task with the LVGL mutex held (LVGLInit.cpp:160-179
wraps the whole lv_task_handler in the recursive mutex), so the marker
is visible to the main loop only after the mailbox entry is — the
take() + admit + clear sequence can never observe an empty marker for
an accepted send. clear_composer() additionally no-ops on an empty
marker, which is the retained-text path for rejected/retry sends.

The contract test is tightened to assert the marker is NOT assigned in
the click handler and IS assigned in the callback, so the race cannot
silently regress.

Verification: 181/181 contracts, tdeck + tdeck-release green.
2026-09-07 01:16:34 +00:00

1073 lines
42 KiB
C++

// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#include "ChatScreen.h"
#include "Theme.h"
#ifdef ARDUINO
#include <microReticulum/Log.h>
#include <microReticulum/Identity.h>
#include "../LVGL/LVGLInit.h"
#include "../LVGL/LVGLLock.h"
#include "../Clipboard.h"
#include <MsgPack.h>
using namespace RNS;
namespace UI {
namespace LXMF {
ChatScreen::ChatScreen(lv_obj_t* parent)
: _screen(nullptr), _header(nullptr), _message_list(nullptr), _input_area(nullptr),
_text_area(nullptr), _btn_send(nullptr), _btn_back(nullptr), _btn_call(nullptr),
_btn_location(nullptr), _message_store(nullptr), _display_start_idx(0), _loading_more(false) {
LVGL_LOCK();
// Create screen object
if (parent) {
_screen = lv_obj_create(parent);
} else {
_screen = lv_obj_create(lv_scr_act());
}
lv_obj_set_size(_screen, LV_PCT(100), LV_PCT(100));
lv_obj_clear_flag(_screen, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_color(_screen, Theme::surface(), 0);
lv_obj_set_style_bg_opa(_screen, LV_OPA_COVER, 0);
lv_obj_set_style_pad_all(_screen, 0, 0);
lv_obj_set_style_border_width(_screen, 0, 0);
lv_obj_set_style_radius(_screen, 0, 0);
// Create UI components
create_header();
create_message_list();
create_input_area();
// Hide by default
hide();
TRACE("ChatScreen created");
}
ChatScreen::~ChatScreen() {
LVGL_LOCK();
if (_screen) {
lv_obj_del(_screen);
}
}
void ChatScreen::create_header() {
_header = lv_obj_create(_screen);
lv_obj_set_size(_header, LV_PCT(100), 36);
lv_obj_align(_header, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_set_style_bg_color(_header, Theme::surfaceHeader(), 0);
lv_obj_set_style_border_width(_header, 0, 0);
lv_obj_set_style_radius(_header, 0, 0);
lv_obj_set_style_pad_all(_header, 0, 0);
// Back button
_btn_back = lv_btn_create(_header);
lv_obj_set_size(_btn_back, 50, 28);
lv_obj_align(_btn_back, LV_ALIGN_LEFT_MID, 4, 0);
lv_obj_set_style_bg_color(_btn_back, Theme::btnSecondary(), 0);
lv_obj_set_style_bg_color(_btn_back, Theme::btnSecondaryPressed(), LV_STATE_PRESSED);
lv_obj_add_event_cb(_btn_back, on_back_clicked, LV_EVENT_CLICKED, this);
lv_obj_t* label_back = lv_label_create(_btn_back);
lv_label_set_text(label_back, LV_SYMBOL_LEFT);
lv_obj_center(label_back);
lv_obj_set_style_text_color(label_back, Theme::textSecondary(), 0);
// Peer name/hash (will be set when conversation is loaded)
lv_obj_t* label_peer = lv_label_create(_header);
lv_label_set_text(label_peer, "Chat");
lv_obj_align(label_peer, LV_ALIGN_LEFT_MID, 60, 0);
lv_obj_set_style_text_color(label_peer, Theme::textPrimary(), 0);
lv_obj_set_style_text_font(label_peer, &lv_font_montserrat_16, 0);
lv_obj_set_width(label_peer, 145);
lv_label_set_long_mode(label_peer, LV_LABEL_LONG_DOT);
// Voice call button (right side of header)
_btn_call = lv_btn_create(_header);
lv_obj_set_size(_btn_call, 50, 28);
lv_obj_align(_btn_call, LV_ALIGN_RIGHT_MID, -2, 0);
lv_obj_set_style_bg_color(_btn_call, Theme::successDark(), 0);
lv_obj_set_style_bg_color(_btn_call, Theme::successPressed(), LV_STATE_PRESSED);
lv_obj_add_event_cb(_btn_call, on_call_clicked, LV_EVENT_CLICKED, this);
lv_obj_t* label_call = lv_label_create(_btn_call);
lv_label_set_text(label_call, LV_SYMBOL_CALL);
lv_obj_center(label_call);
lv_obj_set_style_text_color(label_call, Theme::textPrimary(), 0);
// Peer-scoped location sharing is reachable only from an active chat.
_btn_location = lv_btn_create(_header);
lv_obj_set_size(_btn_location, 44, 28);
lv_obj_align(_btn_location, LV_ALIGN_RIGHT_MID, -56, 0);
lv_obj_set_style_bg_color(_btn_location, Theme::btnSecondary(), 0);
lv_obj_add_event_cb(_btn_location, on_location_clicked, LV_EVENT_CLICKED, this);
lv_obj_t* label_location = lv_label_create(_btn_location);
lv_label_set_text(label_location, LV_SYMBOL_GPS);
lv_obj_center(label_location);
}
void ChatScreen::create_message_list() {
_message_list = lv_obj_create(_screen);
lv_obj_set_size(_message_list, LV_PCT(100), 152); // 240 - 36 (header) - 52 (input)
lv_obj_align(_message_list, LV_ALIGN_TOP_MID, 0, 36);
lv_obj_set_style_pad_all(_message_list, 4, 0);
lv_obj_set_style_pad_gap(_message_list, 4, 0);
lv_obj_set_style_bg_color(_message_list, lv_color_hex(0x0d0d0d), 0); // Slightly darker
lv_obj_set_style_border_width(_message_list, 0, 0);
lv_obj_set_style_radius(_message_list, 0, 0);
lv_obj_set_flex_flow(_message_list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(_message_list, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
// Add scroll event for infinite scroll (load more when at top)
lv_obj_add_event_cb(_message_list, on_scroll, LV_EVENT_SCROLL_END, this);
}
void ChatScreen::create_input_area() {
_input_area = lv_obj_create(_screen);
lv_obj_set_size(_input_area, LV_PCT(100), 52);
lv_obj_align(_input_area, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_set_style_bg_color(_input_area, Theme::surfaceHeader(), 0);
lv_obj_set_style_border_width(_input_area, 0, 0);
lv_obj_set_style_radius(_input_area, 0, 0);
lv_obj_set_style_pad_all(_input_area, 0, 0);
lv_obj_clear_flag(_input_area, LV_OBJ_FLAG_SCROLLABLE);
// Text area for message input
_text_area = lv_textarea_create(_input_area);
lv_obj_set_size(_text_area, 241, 40);
lv_obj_align(_text_area, LV_ALIGN_LEFT_MID, 4, 0);
lv_textarea_set_placeholder_text(_text_area, "Type message...");
lv_textarea_set_one_line(_text_area, false);
lv_textarea_set_max_length(_text_area, 500);
lv_obj_set_style_bg_color(_text_area, Theme::surfaceInput(), 0);
lv_obj_set_style_text_color(_text_area, Theme::textPrimary(), 0);
lv_obj_set_style_border_color(_text_area, Theme::border(), 0);
// Add long-press for paste
lv_obj_add_event_cb(_text_area, on_textarea_long_pressed, LV_EVENT_LONG_PRESSED, this);
// Send button
_btn_send = lv_btn_create(_input_area);
lv_obj_set_size(_btn_send, 67, 40);
lv_obj_align(_btn_send, LV_ALIGN_RIGHT_MID, -4, 0);
lv_obj_set_style_bg_color(_btn_send, Theme::successDark(), 0);
lv_obj_set_style_bg_color(_btn_send, Theme::successPressed(), LV_STATE_PRESSED);
lv_obj_add_event_cb(_btn_send, on_send_clicked, LV_EVENT_CLICKED, this);
lv_obj_t* label_send = lv_label_create(_btn_send);
lv_label_set_text(label_send, "Send");
lv_obj_center(label_send);
lv_obj_set_style_text_color(label_send, Theme::textPrimary(), 0);
}
void ChatScreen::load_conversation(const Bytes& peer_hash, ::LXMF::MessageStore& store) {
LVGL_LOCK();
_message_store = &store;
// Same-peer re-open (back to the list and re-tap): the content is
// already committed by prepare_conversation() and the rows are still
// built — usually nothing to do, keeping re-opens free of store I/O.
// Exception: a message for this peer persisted while the chat was
// hidden (on_message_received only appends to the visible chat). The
// live count is one in-memory index read (no LittleFS); on a mismatch
// fall through to the peer-change path, which resets the list and
// re-arms prepare_conversation() so the main loop re-gathers off-lock.
if (_peer_hash == peer_hash && _prepared_peer_hash == peer_hash) {
size_t live_count = store.get_messages_for_conversation(peer_hash).size();
if (live_count == _prepared_message_count) {
return;
}
INFO("Same-peer re-open: new message(s) since prepare; re-gathering");
_prepared_message_count = 0;
}
_peer_hash = peer_hash;
// A peer change cancels any in-flight background fill from the
// previous conversation (it would otherwise prepend the previous
// conversation's older rows into the new one). prepare_conversation()
// re-arms the fill for the new peer once its metadata is gathered.
if (_bg_fill_active.exchange(false)) {
_keep_bottom_during_background_fill.store(false);
_bg_fill_target = _display_start_idx; // fill is a no-op now
}
// LVGL-task side of a conversation open: navigation + list reset only.
// The store reads (identity recall, display name, message index, per-
// message metadata) moved to prepare_conversation() on the main loop —
// a cold open does dozens of LittleFS/ustore reads that take seconds on
// a degraded filesystem, and running them here (under the LVGL lock,
// held by replace_route) held the mutex past the 5s deadlock guard and
// rebooted the device (assert at LVGLLock.h:45). Same defect class as
// the send path; same fix (see OutgoingSendMailbox.h / UIManager::
// service_pending_sends).
{
char log_buf[64];
snprintf(log_buf, sizeof(log_buf), "Opening conversation with peer %.8s...",
peer_hash.toHex().c_str());
INFO(log_buf);
}
// Clear existing messages and row tracking (rows are rebuilt by
// prepare_conversation()'s commit once the metadata is gathered).
lv_obj_clean(_message_list);
_messages.clear();
_message_rows.clear();
_all_message_hashes.clear();
_display_start_idx = 0;
// Header shows the truncated hash immediately; prepare_conversation()
// upgrades it to the resolved display name once recall completes.
lv_obj_t* label_peer = lv_obj_get_child(_header, 1); // Second child is peer label
{
char hash_buf[20];
snprintf(hash_buf, sizeof(hash_buf), "%.12s...", peer_hash.toHex().c_str());
lv_label_set_text(label_peer, hash_buf);
}
// Arm a prepare for this peer. Bumping the generation discards any
// in-flight prepare for a previous (or same) peer: its commit re-checks
// the generation before touching the UI. The fill generation is bumped
// too, so a background-fill batch in flight while the list is reset
// drops its (now stale) result.
_prepared_peer_hash = Bytes();
_prepared_message_count = 0;
_prepare_generation++;
_fill_generation++;
}
void ChatScreen::prepare_conversation() {
// Called from UIManager::update() on the main loop. The guard and the
// commit are short locked sections; the store I/O between them (identity
// recall, display name, message index, per-message metadata) runs OFF the
// LVGL lock, so a slow cold open can't hold the mutex past the 5s
// deadlock guard.
Bytes peer_hash;
uint32_t generation = 0;
::LXMF::MessageStore* store = nullptr;
{
LVGL_LOCK();
if (!_message_store || _peer_hash.size() == 0) {
return;
}
if (_prepared_peer_hash == _peer_hash) {
return; // already prepared for this peer
}
peer_hash = _peer_hash;
generation = _prepare_generation;
store = _message_store;
}
// ── slow I/O, off the LVGL lock ────────────────────────────────────────
// Three-tier display name resolution (mirrors ConversationListScreen):
// 1. Live announce cache (Identity::recall_app_data)
// 2. MessageStore-persisted name (survives reboots)
// 3. Truncated hash (already shown by load_conversation)
// When (1) hits, write through to the persistent cache so future
// cold boots get the name back without waiting for a re-announce.
String peer_name;
Bytes app_data = Identity::recall_app_data(peer_hash);
if (app_data && app_data.size() > 0) {
peer_name = parse_display_name(app_data);
if (peer_name.length() > 0) {
store->set_display_name(peer_hash, std::string(peer_name.c_str()));
}
}
if (peer_name.length() == 0) {
std::string cached = store->get_display_name(peer_hash);
if (!cached.empty()) {
peer_name = String(cached.c_str());
}
}
// Load all message hashes from store (sorted by timestamp).
std::vector<Bytes> all_hashes = store->get_messages_for_conversation(peer_hash);
// Gather only the few NEWEST messages (the rest of the page is streamed
// in by tick_background_fill() a couple per main-loop tick).
size_t display_start_idx = 0;
if (all_hashes.size() > INITIAL_RENDER) {
display_start_idx = all_hashes.size() - INITIAL_RENDER;
}
std::vector<MessageItem> items;
items.reserve(INITIAL_RENDER);
for (size_t i = display_start_idx; i < all_hashes.size(); i++) {
// Fast metadata loader (cache hit: O(1) in-memory; miss: one
// LittleFS read that warms the cache for every later touch).
::LXMF::MessageStore::MessageMetadata meta =
store->load_message_metadata(all_hashes[i]);
if (!meta.valid) {
continue;
}
MessageItem item;
item.message_hash = all_hashes[i];
item.content = String(meta.content.c_str());
format_timestamp(meta.timestamp, item.timestamp_str, sizeof(item.timestamp_str));
item.outgoing = !meta.incoming;
item.delivered = (meta.state == static_cast<int>(::LXMF::Type::Message::DELIVERED));
item.failed = (meta.state == static_cast<int>(::LXMF::Type::Message::FAILED));
items.push_back(item);
}
{
char log_buf[80];
snprintf(log_buf, sizeof(log_buf), " Found %zu messages, displaying %zu",
all_hashes.size(), items.size());
INFO(log_buf);
}
// ───────────────────────────────────────────────────────────────────────
// ── commit, brief LVGL lock ────────────────────────────────────────────
{
LVGL_LOCK();
// The conversation changed (or this peer was re-opened) while the
// I/O ran; the newer open owns the UI now.
if (_peer_hash != peer_hash || _prepare_generation != generation) {
return;
}
if (peer_name.length() > 0) {
lv_obj_t* label_peer = lv_obj_get_child(_header, 1); // Second child is peer label
lv_label_set_text(label_peer, peer_name.c_str());
}
_all_message_hashes = std::move(all_hashes);
for (const auto& item : items) {
_messages.push_back(item);
create_message_bubble(item);
}
_display_start_idx = display_start_idx;
// Queue the rest of the first page to stream in on the main loop. Set
// the target before activating so tick sees a consistent target.
_bg_fill_target = (_all_message_hashes.size() > MESSAGES_PER_PAGE)
? _all_message_hashes.size() - MESSAGES_PER_PAGE
: 0;
const bool initial_fill_active = _display_start_idx > _bg_fill_target;
_keep_bottom_during_background_fill.store(initial_fill_active);
_bg_fill_active.store(initial_fill_active);
// The commit rebuilt the list; any in-flight fill batch is stale.
_fill_generation++;
_prepared_peer_hash = peer_hash;
_prepared_message_count = _all_message_hashes.size();
scroll_to_bottom();
}
}
void ChatScreen::refresh() {
// Request a full re-gather of this conversation's content: the main
// loop's prepare_conversation() does the store reads off the LVGL lock
// and commits the result under a brief lock. (The old synchronous
// implementation re-read the index + every page of metadata here under
// the lock — the same stall class this fix removes.) Bumping the
// generation discards any in-flight prepare so the re-gather is fresh.
LVGL_LOCK();
if (!_message_store || _peer_hash.size() == 0) {
return;
}
INFO("Refreshing chat messages");
_prepared_peer_hash = Bytes();
_prepared_message_count = 0;
_prepare_generation++;
}
// Stream older messages in a few at a time, called from UIManager::update() on
// the main loop. Each tick prepends a small batch under a brief LVGL lock, so a
// large conversation fills in without freezing the UI or holding the lock long.
void ChatScreen::tick_background_fill() {
if (!_bg_fill_active.load()) {
return;
}
if (_display_start_idx <= _bg_fill_target) {
_bg_fill_active.store(false);
_keep_bottom_during_background_fill.store(false);
return;
}
size_t remaining = _display_start_idx - _bg_fill_target;
load_more_messages(remaining < BG_FILL_BATCH ? remaining : BG_FILL_BATCH);
if (_keep_bottom_during_background_fill.load()) {
scroll_to_bottom();
}
if (_display_start_idx <= _bg_fill_target) {
_bg_fill_active.store(false);
_keep_bottom_during_background_fill.store(false);
}
}
// Stream older messages in a few at a time, called from UIManager::update() on
// the main loop. Each batch's metadata reads run OFF the LVGL lock (a cold
// batch is 2+ LittleFS reads, which on a degraded filesystem can approach the
// 5s guard), and only the bubble prepend takes a brief lock.
void ChatScreen::load_more_messages(size_t batch) {
std::vector<Bytes> hashes;
RNS::Bytes peer_hash;
uint32_t generation = 0;
size_t new_start_idx = 0;
{
LVGL_LOCK();
if (_loading_more || _display_start_idx == 0 || !_message_store) {
return; // Already at the beginning or already loading
}
_loading_more = true;
generation = _fill_generation;
peer_hash = _peer_hash;
// Calculate how many more to load
size_t load_count = batch;
if (_display_start_idx < load_count) {
load_count = _display_start_idx;
}
new_start_idx = _display_start_idx - load_count;
INFO("Loading more messages...");
// Copy the batch range out (newest first, matching the old loop
// order); it is only read off-lock afterwards.
for (size_t n = 0; n < load_count; n++) {
hashes.push_back(_all_message_hashes[_display_start_idx - 1 - n]);
}
}
// ── metadata I/O, off the LVGL lock ─────────────────────────────────
std::vector<MessageItem> items;
items.reserve(hashes.size());
for (const auto& msg_hash : hashes) {
// Fast metadata loader (cache hit: O(1) in-memory; miss: one
// LittleFS read that warms the cache for every later touch).
::LXMF::MessageStore::MessageMetadata meta =
_message_store->load_message_metadata(msg_hash);
if (!meta.valid) {
continue;
}
MessageItem item;
item.message_hash = msg_hash;
item.content = String(meta.content.c_str());
format_timestamp(meta.timestamp, item.timestamp_str, sizeof(item.timestamp_str));
item.outgoing = !meta.incoming;
item.delivered = (meta.state == static_cast<int>(::LXMF::Type::Message::DELIVERED));
item.failed = (meta.state == static_cast<int>(::LXMF::Type::Message::FAILED));
items.push_back(item);
}
// ─────────────────────────────────────────────────────────────────────
// ── commit, brief LVGL lock ─────────────────────────────────────────
LVGL_LOCK();
if (_peer_hash != peer_hash || _fill_generation != generation) {
// Conversation changed or was re-armed while the reads ran; the
// newer state owns the list. Drop the stale batch.
_loading_more = false;
return;
}
// Push in read order (newest first): each push_front lands the next
// older message at the top, leaving oldest→newest, matching the old
// loop's semantics.
for (const auto& item : items) {
// Create bubble at index 0 (top of list)
create_message_bubble(item);
lv_obj_t* bubble_row = lv_obj_get_child(_message_list, lv_obj_get_child_cnt(_message_list) - 1);
lv_obj_move_to_index(bubble_row, 0);
// Prepend to deque (O(1) operation)
_messages.push_front(item);
}
// Advance past the whole batch (invalid entries included — they are
// dropped, same as the old loop), so they are not re-read on the next
// fill.
_display_start_idx = new_start_idx;
_loading_more = false;
{
char log_buf[48];
snprintf(log_buf, sizeof(log_buf), " Now displaying %zu messages", _messages.size());
INFO(log_buf);
}
}
void ChatScreen::scroll_to_bottom() {
LVGL_LOCK();
// Flex children are laid out lazily. Resolve their final heights before
// asking LVGL for the maximum offset, otherwise a just-opened chat can
// scroll against the old zero-height layout and remain at the top.
lv_obj_update_layout(_message_list);
lv_obj_scroll_to_y(_message_list, LV_COORD_MAX, LV_ANIM_OFF);
}
void ChatScreen::on_scroll(lv_event_t* event) {
ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event);
// Check if scrolled to top
lv_coord_t scroll_y = lv_obj_get_scroll_y(screen->_message_list);
if (scroll_y <= 5 && screen->_display_start_idx > 0 && !screen->_bg_fill_active.load()) {
// Near the top: stream the next page in incrementally on the main loop
// (tick_background_fill) rather than loading a full batch synchronously
// under the LVGL lock here, which froze scrolling. Target before active.
screen->_keep_bottom_during_background_fill.store(false);
screen->_bg_fill_target = (screen->_display_start_idx > MESSAGES_PER_PAGE)
? screen->_display_start_idx - MESSAGES_PER_PAGE
: 0;
screen->_bg_fill_active.store(true);
}
}
void ChatScreen::create_message_bubble(const MessageItem& item) {
// Create a full-width row container for alignment
lv_obj_t* row = lv_obj_create(_message_list);
lv_obj_set_width(row, LV_PCT(100));
lv_obj_set_height(row, LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(row, 0, 0);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
// Track row for targeted status updates
_message_rows[item.message_hash] = row;
// Create the actual message bubble inside the row
lv_obj_t* bubble = lv_obj_create(row);
lv_obj_set_width(bubble, LV_PCT(80));
lv_obj_set_height(bubble, LV_SIZE_CONTENT);
// Style based on incoming/outgoing
if (item.outgoing) {
// Outgoing: purple, align right
lv_obj_set_style_bg_color(bubble, Theme::primary(), 0);
lv_obj_align(bubble, LV_ALIGN_RIGHT_MID, 0, 0);
} else {
// Incoming: gray, align left
lv_obj_set_style_bg_color(bubble, lv_color_hex(0x424242), 0);
lv_obj_align(bubble, LV_ALIGN_LEFT_MID, 0, 0);
}
lv_obj_set_style_radius(bubble, 10, 0);
lv_obj_set_style_pad_all(bubble, 8, 0);
lv_obj_clear_flag(bubble, LV_OBJ_FLAG_SCROLLABLE);
// Enable clickable for long-press detection
lv_obj_add_flag(bubble, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(bubble, on_message_long_pressed, LV_EVENT_LONG_PRESSED, this);
// Build status text using char buffer to avoid String fragmentation
char status_text[32];
build_status_text(status_text, sizeof(status_text), item.timestamp_str,
item.outgoing, item.delivered, item.failed);
// Cap very long messages for display — laying out and re-drawing a multi-KB
// wrapped bubble is slow and memory-heavy. The full content stays stored.
String display_text = item.content;
if (display_text.length() > MAX_DISPLAY_CHARS) {
display_text = display_text.substring(0, MAX_DISPLAY_CHARS) + "...";
}
// Calculate text widths to decide layout
// Bubble is 80% of 320 = 256px, minus 16px padding = 240px usable
const lv_coord_t bubble_inner_width = 240;
const lv_font_t* font = &lv_font_montserrat_14;
const lv_coord_t gap = 12; // Space between message and timestamp
lv_coord_t msg_width = lv_txt_get_width(
display_text.c_str(), display_text.length(), font, 0, LV_TEXT_FLAG_NONE);
lv_coord_t status_width = lv_txt_get_width(
status_text, strlen(status_text), font, 0, LV_TEXT_FLAG_NONE);
// Use single-line layout if message + timestamp fit on one row
bool single_line = (msg_width + status_width + gap) <= bubble_inner_width;
if (single_line) {
// Row layout: message and timestamp side by side
lv_obj_set_flex_flow(bubble, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(bubble, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Message content
lv_obj_t* label_content = lv_label_create(bubble);
lv_label_set_text(label_content, display_text.c_str());
lv_obj_set_style_text_color(label_content, lv_color_white(), 0);
// Timestamp on same row
lv_obj_t* label_status = lv_label_create(bubble);
lv_label_set_text(label_status, status_text);
lv_obj_set_style_text_color(label_status, Theme::textTertiary(), 0);
lv_obj_set_style_text_font(label_status, font, 0);
} else {
// Column layout: message above, timestamp below (for longer messages)
lv_obj_set_flex_flow(bubble, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(bubble, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
// Message content with wrapping
lv_obj_t* label_content = lv_label_create(bubble);
lv_label_set_text(label_content, display_text.c_str());
lv_label_set_long_mode(label_content, LV_LABEL_LONG_WRAP);
lv_obj_set_width(label_content, LV_PCT(100));
lv_obj_set_style_text_color(label_content, lv_color_white(), 0);
// Timestamp on its own row
lv_obj_t* label_status = lv_label_create(bubble);
lv_label_set_text(label_status, status_text);
lv_obj_set_width(label_status, LV_PCT(100));
lv_obj_set_style_text_align(label_status, LV_TEXT_ALIGN_RIGHT, 0);
lv_obj_set_style_text_color(label_status, Theme::textTertiary(), 0);
lv_obj_set_style_text_font(label_status, font, 0);
}
}
void ChatScreen::add_message(const ::LXMF::LXMessage& message, bool outgoing) {
LVGL_LOCK();
MessageItem item;
item.message_hash = message.hash();
String content((const char*)message.content().data(), message.content().size());
item.content = content;
format_timestamp(message.timestamp(), item.timestamp_str, sizeof(item.timestamp_str));
item.outgoing = outgoing;
item.delivered = false;
item.failed = false;
// Remove oldest messages if we exceed the limit
while (_messages.size() >= MAX_DISPLAYED_MESSAGES) {
// Remove from tracking map
_message_rows.erase(_messages.front().message_hash);
_messages.erase(_messages.begin());
// Remove first child (oldest) from message list
lv_obj_t* first_row = lv_obj_get_child(_message_list, 0);
if (first_row) {
lv_obj_del(first_row);
}
}
_messages.push_back(item);
create_message_bubble(item);
// Scroll to bottom
lv_obj_scroll_to_y(_message_list, LV_COORD_MAX, LV_ANIM_ON);
}
void ChatScreen::update_message_status(const Bytes& message_hash, bool delivered) {
LVGL_LOCK();
// Find message and update status in our data
for (auto& msg : _messages) {
if (msg.message_hash == message_hash) {
msg.delivered = delivered;
msg.failed = !delivered;
// Update just the status label instead of full refresh
auto row_it = _message_rows.find(message_hash);
if (row_it != _message_rows.end()) {
lv_obj_t* row = row_it->second;
// Structure: row -> bubble -> [content_label, status_label]
lv_obj_t* bubble = lv_obj_get_child(row, 0);
if (bubble) {
// Status label is always the last child
uint32_t child_count = lv_obj_get_child_cnt(bubble);
if (child_count > 0) {
lv_obj_t* status_label = lv_obj_get_child(bubble, child_count - 1);
if (status_label) {
char status_text[32];
build_status_text(status_text, sizeof(status_text), msg.timestamp_str,
msg.outgoing, msg.delivered, msg.failed);
lv_label_set_text(status_label, status_text);
}
}
}
}
break;
}
}
}
void ChatScreen::set_back_callback(BackCallback callback) {
_back_callback = callback;
}
void ChatScreen::set_send_message_callback(SendMessageCallback callback) {
_send_message_callback = callback;
}
void ChatScreen::set_call_callback(CallCallback callback) {
_call_callback = callback;
}
void ChatScreen::set_location_callback(LocationCallback callback) {
_location_callback = callback;
}
void ChatScreen::show() {
LVGL_LOCK();
lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(_screen); // Bring to front for touch events
// refresh() runs while this screen is hidden, so resolve the now-visible
// flex layout once more before selecting the newest message offset.
scroll_to_bottom();
// Add buttons to default group for trackball navigation
// Note: text area not included since edit mode consumes arrow keys
lv_group_t* group = LVGL::LVGLInit::get_default_group();
if (group) {
if (_btn_back) lv_group_add_obj(group, _btn_back);
if (_btn_location) lv_group_add_obj(group, _btn_location);
if (_btn_call) lv_group_add_obj(group, _btn_call);
if (_btn_send) lv_group_add_obj(group, _btn_send);
// Focus on back button
if (_btn_back) {
lv_group_focus_obj(_btn_back);
}
}
}
void ChatScreen::hide() {
LVGL_LOCK();
// Remove from focus group when hiding
lv_group_t* group = LVGL::LVGLInit::get_default_group();
if (group) {
if (_btn_back) lv_group_remove_obj(_btn_back);
if (_btn_location) lv_group_remove_obj(_btn_location);
if (_btn_call) lv_group_remove_obj(_btn_call);
if (_btn_send) lv_group_remove_obj(_btn_send);
}
lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN);
}
lv_obj_t* ChatScreen::get_object() {
return _screen;
}
void ChatScreen::on_back_clicked(lv_event_t* event) {
ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event);
if (screen->_back_callback) {
screen->_back_callback();
}
}
void ChatScreen::on_call_clicked(lv_event_t* event) {
ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event);
if (screen->_call_callback) {
screen->_call_callback();
}
}
void ChatScreen::on_location_clicked(lv_event_t* event) {
ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event);
if (screen->_location_callback) {
screen->_location_callback();
}
}
void ChatScreen::on_send_clicked(lv_event_t* event) {
ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event);
// Get message text
const char* text = lv_textarea_get_text(screen->_text_area);
String message(text);
if (message.length() > 0 && screen->_send_message_callback) {
// Publish to the main loop. On acceptance the callback records the
// exact composer state that was submitted (inside the callback, so
// the marker is set under this same LVGL lock section, BEFORE the
// main loop can observe the mailbox entry). The composer is cleared
// only after persistence and queue admission succeed AND only when
// it still holds that same text. A rejected send keeps the text for
// a normal re-send, and anything typed after submission is never
// wiped by the later completion commit.
screen->_send_message_callback(message);
}
}
void ChatScreen::set_pending_submitted_text(const std::string& text) {
// Recursive lock: the send callback runs on the LVGL task under the
// LVGL lock (on_send_message_from_chat holds it via the click handler's
// event context); this keeps the marker consistent with the composer.
LVGL_LOCK();
_pending_submitted_text = text;
}
void ChatScreen::clear_composer() {
// Recursive lock: apply_outbound_result() calls this while already
// holding the LVGL lock.
LVGL_LOCK();
// Only clear when a submission is pending (non-empty marker) and the
// composer still holds exactly the submitted text. Persistence + router
// admission run on the main loop off the LVGL lock, so the user may
// have started typing the next message before the commit lands; wiping
// an edited composer here would erase fresh input. An empty marker
// (rejected/retained send, or no submission) clears nothing — the
// retained text is kept deliberately for a normal re-send.
if (_pending_submitted_text.empty()) {
return;
}
const char* current = lv_textarea_get_text(_text_area);
if (current == nullptr || _pending_submitted_text != current) {
return;
}
_pending_submitted_text.clear();
lv_textarea_set_text(_text_area, "");
lv_group_focus_obj(_text_area);
}
void ChatScreen::format_timestamp(double timestamp, char* buf, size_t buf_size) {
// Convert to time_t for formatting
time_t time = (time_t)timestamp;
struct tm* timeinfo = localtime(&time);
strftime(buf, buf_size, "%I:%M %p", timeinfo);
}
const char* ChatScreen::get_delivery_indicator(bool outgoing, bool delivered, bool failed) {
if (!outgoing) {
return ""; // No indicator for incoming messages
}
if (failed) {
return LV_SYMBOL_CLOSE; // X for failed
} else if (delivered) {
return LV_SYMBOL_OK LV_SYMBOL_OK; // Double check for delivered
} else {
return LV_SYMBOL_OK; // Single check for sent
}
}
void ChatScreen::build_status_text(char* buf, size_t buf_size, const char* timestamp,
bool outgoing, bool delivered, bool failed) {
const char* indicator = get_delivery_indicator(outgoing, delivered, failed);
if (indicator[0] != '\0') {
snprintf(buf, buf_size, "%s %s", timestamp, indicator);
} else {
snprintf(buf, buf_size, "%s", timestamp);
}
}
String ChatScreen::parse_display_name(const Bytes& app_data) {
if (app_data.size() == 0) {
return String();
}
// Check first byte to determine format
uint8_t first_byte = app_data.data()[0];
// Msgpack fixarray (0x90-0x9f) or array16 (0xdc) indicates LXMF 0.5.0+ format
if ((first_byte >= 0x90 && first_byte <= 0x9f) || first_byte == 0xdc) {
// Msgpack encoded: [display_name, stamp_cost]
MsgPack::Unpacker unpacker;
unpacker.feed(app_data.data(), app_data.size());
// Read array header
MsgPack::arr_size_t arr_size;
if (!unpacker.deserialize(arr_size)) {
return String();
}
if (arr_size.size() < 1) {
return String();
}
// First element is display_name (can be nil or bytes)
if (unpacker.isNil()) {
unpacker.unpackNil();
return String();
}
// Try to read as binary (bytes)
MsgPack::bin_t<uint8_t> name_bin;
if (unpacker.deserialize(name_bin)) {
return String((const char*)name_bin.data(), name_bin.size());
}
return String();
} else {
// Legacy format: raw UTF-8 bytes
return String(app_data.toString().c_str());
}
}
void ChatScreen::on_message_long_pressed(lv_event_t* event) {
ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event);
lv_obj_t* bubble = lv_event_get_target(event);
lv_obj_t* row = lv_obj_get_parent(bubble);
// Bubbles render a display-capped copy (metadata cache / MAX_DISPLAY_
// CHARS), so the full text must be recovered from the store. This
// handler runs on the LVGL task and the store is main-loop-only, so
// record the request and let tick_pending_full_message() (main loop)
// do the disk read + modal build.
RNS::Bytes hash;
for (const auto& kv : screen->_message_rows) {
if (kv.second == row) {
hash = kv.first;
break;
}
}
if (hash.size() == 0) {
return;
}
if (screen->_pending_full_message.load()) {
// A request is already queued; the next modal supersedes this one.
return;
}
// Runs while the LVGL task holds the mutex (lvgl_task wraps
// lv_task_handler in it), so these writes serialize with the main
// loop's copy of _pending_full_message_hash under LVGL_LOCK.
screen->_pending_full_message_hash = hash;
screen->_pending_copy_text = "";
screen->_pending_full_message.store(true);
}
// Complete a pending long-press full-message request (see
// on_message_long_pressed). Runs on the main loop: the disk-bound
// load_message_content() happens OUTSIDE the LVGL lock (same task that
// does all other store I/O), then the modal is built under LVGL_LOCK().
// This path is user-triggered (once per long press), not per frame.
void ChatScreen::tick_pending_full_message() {
if (!_pending_full_message.load()) {
return;
}
// Copy the hash under the LVGL lock: the LVGL task writes it while
// holding that same lock (see on_message_long_pressed), so this is a
// safe handoff. Then the disk read happens OUTSIDE the lock.
RNS::Bytes hash;
{
LVGL_LOCK();
if (!_pending_full_message.load()) {
return;
}
hash = _pending_full_message_hash;
// Clear before the read: a superseding long-press can re-arm while
// we are here and must not be lost.
_pending_full_message.store(false);
}
if (!_message_store) {
return;
}
String full = String(_message_store->load_message_content(hash).c_str());
if (full.length() == 0) {
return;
}
LVGL_LOCK();
_pending_copy_text = full;
show_full_message(full);
}
// Full-screen scrollable view of a single message's complete text, with Copy.
// Opened by long-pressing a (possibly truncated) bubble.
void ChatScreen::show_full_message(const String& content) {
lv_obj_t* modal = lv_obj_create(lv_scr_act());
lv_obj_set_size(modal, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(modal, Theme::surface(), 0);
lv_obj_set_style_bg_opa(modal, LV_OPA_COVER, 0);
lv_obj_set_style_pad_all(modal, 8, 0);
lv_obj_set_style_radius(modal, 0, 0);
lv_obj_set_style_border_width(modal, 0, 0);
lv_obj_set_flex_flow(modal, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(modal, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Scrollable content area filling the space above the buttons
lv_obj_t* scroll = lv_obj_create(modal);
lv_obj_set_width(scroll, LV_PCT(100));
lv_obj_set_flex_grow(scroll, 1);
lv_obj_set_style_bg_opa(scroll, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(scroll, 0, 0);
lv_obj_set_style_pad_all(scroll, 4, 0);
lv_obj_t* label = lv_label_create(scroll);
lv_label_set_text(label, content.c_str());
lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP);
lv_obj_set_width(label, LV_PCT(100));
lv_obj_set_style_text_color(label, Theme::textPrimary(), 0);
// Button row: Copy + Close
lv_obj_t* btns = lv_obj_create(modal);
lv_obj_set_size(btns, LV_PCT(100), 40);
lv_obj_set_style_bg_opa(btns, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(btns, 0, 0);
lv_obj_set_style_pad_all(btns, 0, 0);
lv_obj_set_flex_flow(btns, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(btns, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_t* copy_btn = lv_btn_create(btns);
lv_obj_set_style_bg_color(copy_btn, Theme::btnSecondary(), 0);
lv_obj_add_event_cb(copy_btn, on_full_message_copy, LV_EVENT_CLICKED, this);
lv_obj_t* copy_lbl = lv_label_create(copy_btn);
lv_label_set_text(copy_lbl, "Copy");
lv_obj_center(copy_lbl);
lv_obj_t* close_btn = lv_btn_create(btns);
lv_obj_set_style_bg_color(close_btn, Theme::primary(), 0);
lv_obj_add_event_cb(close_btn, on_full_message_close, LV_EVENT_CLICKED, modal);
lv_obj_t* close_lbl = lv_label_create(close_btn);
lv_label_set_text(close_lbl, "Close");
lv_obj_center(close_lbl);
}
void ChatScreen::on_full_message_copy(lv_event_t* event) {
ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event);
Clipboard::copy(screen->_pending_copy_text);
}
void ChatScreen::on_full_message_close(lv_event_t* event) {
lv_obj_t* modal = (lv_obj_t*)lv_event_get_user_data(event);
if (modal) {
// Async: the close button is a descendant of `modal`, so deleting it
// synchronously here would free the button whose callback is still
// running (use-after-free as LVGL keeps dispatching the event).
lv_obj_del_async(modal);
}
}
void ChatScreen::on_copy_dialog_action(lv_event_t* event) {
lv_obj_t* mbox = lv_event_get_current_target(event);
ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event);
uint16_t btn_id = lv_msgbox_get_active_btn(mbox);
if (btn_id == 0) { // Copy button
Clipboard::copy(screen->_pending_copy_text);
}
screen->_pending_copy_text = "";
lv_msgbox_close(mbox);
}
void ChatScreen::on_textarea_long_pressed(lv_event_t* event) {
ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event);
// Only show paste if clipboard has content
if (!Clipboard::has_content()) {
return;
}
// Show paste dialog
static const char* btns[] = {"Paste", "Cancel", ""};
lv_obj_t* mbox = lv_msgbox_create(NULL, "Paste",
"Paste from clipboard?", btns, false);
lv_obj_center(mbox);
lv_obj_add_event_cb(mbox, on_paste_dialog_action, LV_EVENT_VALUE_CHANGED, screen);
}
void ChatScreen::on_paste_dialog_action(lv_event_t* event) {
lv_obj_t* mbox = lv_event_get_current_target(event);
ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event);
uint16_t btn_id = lv_msgbox_get_active_btn(mbox);
if (btn_id == 0) { // Paste button
const String& content = Clipboard::paste();
if (content.length() > 0) {
// Insert at cursor position
lv_textarea_add_text(screen->_text_area, content.c_str());
}
}
lv_msgbox_close(mbox);
}
} // namespace LXMF
} // namespace UI
#endif // ARDUINO