mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-07-10 00:01:52 +00:00
e1760291f5
on_full_message_close() runs from the Close button's own callback, and the button is a descendant of the modal — so lv_obj_del(modal) freed the button mid-dispatch (use-after-free). lv_obj_del_async defers the delete until the event completes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
843 lines
31 KiB
C++
843 lines
31 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),
|
|
_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, 195);
|
|
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);
|
|
}
|
|
|
|
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();
|
|
_peer_hash = peer_hash;
|
|
_message_store = &store;
|
|
|
|
{
|
|
char log_buf[64];
|
|
snprintf(log_buf, sizeof(log_buf), "Loading conversation with peer %.8s...",
|
|
peer_hash.toHex().c_str());
|
|
INFO(log_buf);
|
|
}
|
|
|
|
// Three-tier display name resolution (mirrors ConversationListScreen):
|
|
// 1. Live announce cache (Identity::recall_app_data)
|
|
// 2. MessageStore-persisted name (survives reboots)
|
|
// 3. Truncated hash (last resort)
|
|
// 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());
|
|
}
|
|
}
|
|
if (peer_name.length() == 0) {
|
|
char hash_buf[20];
|
|
snprintf(hash_buf, sizeof(hash_buf), "%.12s...", peer_hash.toHex().c_str());
|
|
peer_name = hash_buf;
|
|
}
|
|
|
|
// Update header with peer info
|
|
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());
|
|
|
|
refresh();
|
|
}
|
|
|
|
void ChatScreen::refresh() {
|
|
LVGL_LOCK();
|
|
if (!_message_store) {
|
|
return;
|
|
}
|
|
|
|
INFO("Refreshing chat messages");
|
|
|
|
// Clear existing messages and row tracking
|
|
lv_obj_clean(_message_list);
|
|
_messages.clear();
|
|
_message_rows.clear();
|
|
|
|
// Reserve capacity for message hashes to reduce fragmentation
|
|
_all_message_hashes.reserve(200);
|
|
|
|
// Load all message hashes from store (sorted by timestamp)
|
|
_all_message_hashes = _message_store->get_messages_for_conversation(_peer_hash);
|
|
|
|
// Render only the few NEWEST messages synchronously (under the LVGL lock) so
|
|
// the conversation opens fast. The rest of the page is streamed in by
|
|
// tick_background_fill() a couple per main-loop tick, so the UI never freezes.
|
|
// (This runs on the main loop, not a task: the MessageStore shares one
|
|
// _json_doc between save + load and isn't safe for concurrent access.)
|
|
if (_all_message_hashes.size() > INITIAL_RENDER) {
|
|
_display_start_idx = _all_message_hashes.size() - INITIAL_RENDER;
|
|
} else {
|
|
_display_start_idx = 0;
|
|
}
|
|
|
|
{
|
|
char log_buf[80];
|
|
snprintf(log_buf, sizeof(log_buf), " Found %zu messages, displaying last %zu",
|
|
_all_message_hashes.size(), _all_message_hashes.size() - _display_start_idx);
|
|
INFO(log_buf);
|
|
}
|
|
|
|
for (size_t i = _display_start_idx; i < _all_message_hashes.size(); i++) {
|
|
const auto& msg_hash = _all_message_hashes[i];
|
|
|
|
// Use fast metadata loader (no msgpack unpacking)
|
|
::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));
|
|
|
|
_messages.push_back(item);
|
|
create_message_bubble(item);
|
|
}
|
|
|
|
// 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;
|
|
_bg_fill_active.store(_display_start_idx > _bg_fill_target);
|
|
|
|
// Scroll to bottom
|
|
lv_obj_scroll_to_y(_message_list, LV_COORD_MAX, LV_ANIM_OFF);
|
|
}
|
|
|
|
// 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);
|
|
return;
|
|
}
|
|
size_t remaining = _display_start_idx - _bg_fill_target;
|
|
load_more_messages(remaining < BG_FILL_BATCH ? remaining : BG_FILL_BATCH);
|
|
if (_display_start_idx <= _bg_fill_target) {
|
|
_bg_fill_active.store(false);
|
|
}
|
|
}
|
|
|
|
void ChatScreen::load_more_messages(size_t batch) {
|
|
LVGL_LOCK();
|
|
if (_loading_more || _display_start_idx == 0 || !_message_store) {
|
|
return; // Already at the beginning or already loading
|
|
}
|
|
|
|
_loading_more = true;
|
|
INFO("Loading more messages...");
|
|
|
|
// Calculate how many more to load
|
|
size_t load_count = batch;
|
|
if (_display_start_idx < load_count) {
|
|
load_count = _display_start_idx;
|
|
}
|
|
size_t new_start_idx = _display_start_idx - load_count;
|
|
|
|
{
|
|
char log_buf[64];
|
|
snprintf(log_buf, sizeof(log_buf), " Loading messages %zu to %zu",
|
|
new_start_idx, _display_start_idx - 1);
|
|
INFO(log_buf);
|
|
}
|
|
|
|
// Load and prepend messages directly (no temporary vector allocation)
|
|
// Process in reverse order so push_front maintains correct sequence
|
|
size_t items_added = 0;
|
|
for (size_t i = _display_start_idx; i > new_start_idx; ) {
|
|
--i; // Decrement first since we're iterating backwards
|
|
const auto& msg_hash = _all_message_hashes[i];
|
|
|
|
::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));
|
|
|
|
// 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);
|
|
items_added++;
|
|
}
|
|
_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::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->_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::show() {
|
|
LVGL_LOCK();
|
|
lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_move_foreground(_screen); // Bring to front for touch events
|
|
|
|
// 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_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_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_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) {
|
|
screen->_send_message_callback(message);
|
|
|
|
// Clear text area and keep focus for next message
|
|
lv_textarea_set_text(screen->_text_area, "");
|
|
lv_group_focus_obj(screen->_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 truncated copy of long messages, so recover the FULL
|
|
// content for this row (reverse-lookup row -> hash -> stored item) for both
|
|
// the detail view and Copy.
|
|
String full;
|
|
for (const auto& kv : screen->_message_rows) {
|
|
if (kv.second == row) {
|
|
for (const auto& m : screen->_messages) {
|
|
if (m.message_hash == kv.first) {
|
|
full = m.content;
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (full.length() == 0) { // fallback to the (possibly truncated) label text
|
|
lv_obj_t* label = lv_obj_get_child(bubble, 0);
|
|
if (label) {
|
|
const char* t = lv_label_get_text(label);
|
|
if (t) full = t;
|
|
}
|
|
}
|
|
if (full.length() == 0) {
|
|
return;
|
|
}
|
|
|
|
screen->_pending_copy_text = full;
|
|
screen->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
|