Files
pyxis/lib/tdeck_ui/UI/LXMF/ChatScreen.h
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

330 lines
13 KiB
C++

// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#ifndef UI_LXMF_CHATSCREEN_H
#define UI_LXMF_CHATSCREEN_H
#ifdef ARDUINO
#include <Arduino.h>
#include <lvgl.h>
#include <vector>
#include <deque>
#include <map>
#include <functional>
#include <atomic>
#include <microReticulum/Bytes.h>
#include "LXMF/LXMessage.h"
#include "LXMF/MessageStore.h"
namespace UI {
namespace LXMF {
/**
* Chat Screen
*
* Shows messages in a conversation with:
* - Scrollable message list
* - Message bubbles (incoming/outgoing styled differently)
* - Delivery status indicators (✓ sent, ✓✓ delivered)
* - Message input area
* - Send button
*
* Layout:
* ┌─────────────────────────────────────┐
* │ ← Alice (a1b2c3d4...) │ 32px Header
* ├─────────────────────────────────────┤
* │ [Hey there!] │ Outgoing (right)
* │ [10:23 AM ✓] │
* │ [How are you doing?] │ Incoming (left)
* │ [10:25 AM] │ 156px scrollable
* │ [I'm good, thanks!] │
* │ [10:26 AM ✓✓] │
* ├─────────────────────────────────────┤
* │ [Type message... ] [Send] │ 52px Input area
* └─────────────────────────────────────┘
*/
class ChatScreen {
public:
/**
* Message item data
*/
struct MessageItem {
RNS::Bytes message_hash;
String content;
char timestamp_str[16]; // "12:34 PM" format - fixed buffer to avoid fragmentation
bool outgoing; // true if sent by us
bool delivered; // true if delivery confirmed
bool failed; // true if delivery failed
};
/**
* Callback types
*/
using BackCallback = std::function<void()>;
using SendMessageCallback = std::function<bool(const String& content)>;
using CallCallback = std::function<void()>;
using LocationCallback = std::function<void()>;
/**
* Create chat screen
* @param parent Parent LVGL object (usually lv_scr_act())
*/
ChatScreen(lv_obj_t* parent = nullptr);
/**
* Destructor
*/
~ChatScreen();
/**
* Load conversation with a specific peer
* @param peer_hash Peer destination hash
* @param store Message store to load from
*/
void load_conversation(const RNS::Bytes& peer_hash, ::LXMF::MessageStore& store);
/**
* Prepare the current conversation's content. Call from
* UIManager::update() on the main loop after the chat route is active.
*
* load_conversation() (LVGL task) only navigates and clears the list;
* the store reads (identity recall, display-name lookup, message index,
* per-message metadata) are slow on a degraded LittleFS — a cold open
* can take several seconds — so running them under the LVGL lock (as the
* old synchronous path did) held the mutex past the 5s deadlock guard
* and rebooted the device (assert at LVGLLock.h:45). This method does
* that I/O on the main loop, then commits header + initial bubbles
* under a brief LVGL_LOCK. No-op when this peer is already prepared.
*/
void prepare_conversation();
/**
* Add a new message to the chat
* @param message LXMF message to add
* @param outgoing true if message is outgoing
*/
void add_message(const ::LXMF::LXMessage& message, bool outgoing);
/**
* Clear the composer text input and refocus it. Called from the main
* loop once an outgoing send has been persisted and admitted (the LVGL
* send callback no longer clears synchronously — it defers to this).
* Takes the LVGL lock internally; call with the lock already held
* (recursive) or from the main loop before UI work.
*/
void clear_composer();
/**
* Update delivery status of a message
* @param message_hash Hash of message to update
* @param delivered true if delivered, false if failed
*/
void update_message_status(const RNS::Bytes& message_hash, bool delivered);
/**
* Refresh message list (reload from store)
*/
void refresh();
/**
* Stream the rest of the first page in, a few messages per call. Call from
* UIManager::update() on the main loop after the chat screen is shown.
*/
void tick_background_fill();
/**
* Complete a pending long-press full-message request. Call from
* UIManager::update() on the main loop after the chat screen is shown.
*
* The long-press handler (LVGL task) only records which message was
* pressed; the actual full content read
* (MessageStore::load_message_content — one bounded JSON read, no
* msgpack unpack) and the modal build happen here, off the LVGL task.
* The store is main-loop-only (it shares one JsonDocument between
* save + load), and the rendered rows only hold the display-capped
* content (the metadata cache caps at 600 chars), so the full view
* must come from disk.
*/
void tick_pending_full_message();
/**
* Set callback for back button
* @param callback Function to call when back button is pressed
*/
void set_back_callback(BackCallback callback);
/**
* Set callback for sending messages
* @param callback Function that sends the message; return true when the
* send was accepted into the main-loop mailbox. The callback
* (UIManager::on_send_message_from_chat) records the submitted
* text via set_pending_submitted_text() in the same LVGL lock
* section as the publish, so the main loop can never observe
* the mailbox entry before the marker is set.
*/
void set_send_message_callback(SendMessageCallback callback);
/**
* Record the composer text that was just published to the main-loop
* send mailbox. Must be called by the send callback in the same LVGL
* lock section as the publish so the completion commit (main loop) can
* match its clear to the exact submission. See ChatScreen.h field
* _pending_submitted_text.
*/
void set_pending_submitted_text(const std::string& text);
/**
* Set callback for voice call button
* @param callback Function to call when call button is pressed
*/
void set_call_callback(CallCallback callback);
void set_location_callback(LocationCallback callback);
/**
* Show the screen
*/
void show();
/**
* Hide the screen
*/
void hide();
/**
* Get the root LVGL object
* @return Root object
*/
lv_obj_t* get_object();
private:
lv_obj_t* _screen;
lv_obj_t* _header;
lv_obj_t* _message_list;
lv_obj_t* _input_area;
lv_obj_t* _text_area;
lv_obj_t* _btn_send;
lv_obj_t* _btn_back;
lv_obj_t* _btn_call;
lv_obj_t* _btn_location;
RNS::Bytes _peer_hash;
::LXMF::MessageStore* _message_store;
std::deque<MessageItem> _messages;
// Composer text captured when a send was accepted into the main-loop
// mailbox. apply_outbound_result() only clears the composer when a
// non-empty marker matches the current composer text exactly, so input
// typed into the composer while persistence/admission was in flight is
// never erased by a later commit. Empty means "no pending submission",
// which clears nothing (safe for retry/rejected sends, where the text
// is retained deliberately). The marker is assigned by
// UIManager::on_send_message_from_chat() in the same LVGL lock section
// as the mailbox publish, so the main loop can never observe the
// mailbox entry before the marker is set.
std::string _pending_submitted_text;
// Map message hash to bubble row for targeted updates
std::map<RNS::Bytes, lv_obj_t*> _message_rows;
// Conversation-prepare state (main-loop I/O + LVGL commit). Both fields are
// only read/written while holding the LVGL lock (load_conversation, the
// prepare guard, and the commit are all locked sections), so they need no
// atomics. _prepare_generation disambiguates a same-peer re-open that
// happens while a prepare's I/O is in flight.
RNS::Bytes _prepared_peer_hash;
uint32_t _prepare_generation = 0;
// Message count at the moment the current rows were committed by
// prepare_conversation(). A same-peer re-open compares the store's live
// count against this: if it grew (a message for this peer landed while
// the chat was hidden), the early-return is not taken and prepare is
// re-armed. Zero means "nothing committed yet".
size_t _prepared_message_count = 0;
BackCallback _back_callback;
SendMessageCallback _send_message_callback;
CallCallback _call_callback;
LocationCallback _location_callback;
// UI construction
void create_header();
void create_message_list();
void create_input_area();
void create_message_bubble(const MessageItem& item);
// Event handlers
static void on_back_clicked(lv_event_t* event);
static void on_call_clicked(lv_event_t* event);
static void on_location_clicked(lv_event_t* event);
static void on_send_clicked(lv_event_t* event);
static void on_message_long_pressed(lv_event_t* event);
static void on_copy_dialog_action(lv_event_t* event);
void show_full_message(const String& content); // detail view for a long message
static void on_full_message_copy(lv_event_t* event);
static void on_full_message_close(lv_event_t* event);
static void on_textarea_long_pressed(lv_event_t* event);
static void on_paste_dialog_action(lv_event_t* event);
// Copy/paste state
String _pending_copy_text;
// Pagination state for infinite scroll
std::vector<RNS::Bytes> _all_message_hashes; // All message hashes in conversation
size_t _display_start_idx; // Index into _all_message_hashes where display starts
// refresh() renders only INITIAL_RENDER messages synchronously (fast open);
// tick_background_fill() then streams the rest of the first page in,
// BG_FILL_BATCH at a time on the main loop. This keeps the per-step
// under-LVGL-lock work tiny — a large conversation no longer freezes the UI
// or trips LVGLLock's 5s timeout (which previously asserted and crashed).
static constexpr size_t INITIAL_RENDER = 3; // newest messages shown on open
static constexpr size_t MESSAGES_PER_PAGE = 10; // full first page (filled in background)
static constexpr size_t BG_FILL_BATCH = 2; // older messages streamed per tick
static constexpr size_t MAX_DISPLAYED_MESSAGES = 50; // Cap to prevent memory exhaustion
// Cap the text rendered per bubble. LVGL lays out (and re-draws on scroll) a
// wrapped label in O(length); a multi-KB message (e.g. a large bz2-delivered
// payload) becomes a 50+ line bubble that crawls when scrolled past. The full
// content stays stored; only the rendered text is truncated.
static constexpr size_t MAX_DISPLAY_CHARS = 600;
bool _loading_more; // Prevent concurrent loads
// Streaming state. _bg_fill_active is atomic because on_scroll() (LVGL task)
// and refresh() may set it while tick_background_fill() (main loop) reads it;
// writers always set _bg_fill_target BEFORE _bg_fill_active so the target is
// visible once active is observed true.
std::atomic<bool> _bg_fill_active{false}; // streaming the rest of the page in
size_t _bg_fill_target = 0; // _display_start_idx to fill down to
// Opening a chat prepends the rest of its first page asynchronously. Keep
// the viewport pinned to the newest message during only that initial fill;
// user-triggered pagination at the top must preserve the user's position.
std::atomic<bool> _keep_bottom_during_background_fill{false};
// Long-press full-message view, deferred to the main loop. The LVGL
// event handler only records the hash; tick_pending_full_message()
// does the (disk-bound) load_message_content() off the LVGL task and
// builds the modal. Same set-before-arm ordering as the background
// fill above.
std::atomic<bool> _pending_full_message{false};
RNS::Bytes _pending_full_message_hash;
// Load more messages (infinite scroll + background fill)
void load_more_messages(size_t batch = MESSAGES_PER_PAGE);
void scroll_to_bottom();
static void on_scroll(lv_event_t* event);
// Guards background-fill batches against a peer change or a re-arm
// (prepare_conversation) that lands while a batch's metadata I/O is in
// flight; only read/written under the LVGL lock.
uint32_t _fill_generation = 0;
// Utility
static void format_timestamp(double timestamp, char* buf, size_t buf_size);
static const char* get_delivery_indicator(bool outgoing, bool delivered, bool failed);
static String parse_display_name(const RNS::Bytes& app_data);
static void build_status_text(char* buf, size_t buf_size, const char* timestamp,
bool outgoing, bool delivered, bool failed);
};
} // namespace LXMF
} // namespace UI
#endif // ARDUINO
#endif // UI_LXMF_CHATSCREEN_H