mirror of
https://github.com/ratspeak/ratdeck.git
synced 2026-05-15 11:55:13 +00:00
edbb67c0c8
The timestamp label was positioned via lv_obj_align_to below the message box, but the bubble container's LV_SIZE_CONTENT height was driven entirely by the box, so the time rendered below the bubble's allocated row in the flex scroll — visibly hanging into the gap. Add pad_bottom=14 to the bubble so its outer height includes the timestamp row, and anchor the box at TOP_LEFT/TOP_RIGHT (was LEFT_MID/RIGHT_MID) so the layout stacks cleanly: box on top, timestamp tucked under it inside the bubble's bbox.
398 lines
15 KiB
C++
398 lines
15 KiB
C++
#include "LvMessageView.h"
|
|
#include "ui/Theme.h"
|
|
#include "ui/LvTheme.h"
|
|
#include "ui/LvTabBar.h"
|
|
#include "reticulum/LXMFManager.h"
|
|
#include "reticulum/AnnounceManager.h"
|
|
#include <Arduino.h>
|
|
#include <time.h>
|
|
#include "fonts/fonts.h"
|
|
|
|
std::string LvMessageView::getPeerName() {
|
|
if (_am) {
|
|
std::string name = _am->lookupName(_peerHex);
|
|
if (!name.empty()) return name;
|
|
}
|
|
return _peerHex.substr(0, 12);
|
|
}
|
|
|
|
void LvMessageView::createUI(lv_obj_t* parent) {
|
|
_screen = parent;
|
|
lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0);
|
|
lv_obj_set_style_pad_all(parent, 0, 0);
|
|
|
|
// Use flex column layout: header, messages (grows), input
|
|
lv_obj_set_layout(parent, LV_LAYOUT_FLEX);
|
|
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
|
lv_obj_set_style_pad_row(parent, 0, 0);
|
|
|
|
const lv_font_t* font = &lv_font_ratdeck_12;
|
|
int headerH = 22;
|
|
int inputH = 28;
|
|
|
|
// Header bar (top)
|
|
_header = lv_obj_create(parent);
|
|
lv_obj_set_size(_header, lv_pct(100), headerH);
|
|
lv_obj_set_style_bg_color(_header, lv_color_hex(Theme::BG), 0);
|
|
lv_obj_set_style_bg_opa(_header, LV_OPA_COVER, 0);
|
|
lv_obj_set_style_border_color(_header, lv_color_hex(Theme::BORDER), 0);
|
|
lv_obj_set_style_border_width(_header, 1, 0);
|
|
lv_obj_set_style_border_side(_header, LV_BORDER_SIDE_BOTTOM, 0);
|
|
lv_obj_set_style_pad_all(_header, 0, 0);
|
|
lv_obj_set_style_radius(_header, 0, 0);
|
|
lv_obj_clear_flag(_header, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
_lblHeader = lv_label_create(_header);
|
|
lv_obj_set_style_text_font(_lblHeader, &lv_font_ratdeck_14, 0);
|
|
lv_obj_set_style_text_color(_lblHeader, lv_color_hex(Theme::ACCENT), 0);
|
|
lv_obj_align(_lblHeader, LV_ALIGN_LEFT_MID, 4, 0);
|
|
|
|
// Make header tappable for back navigation
|
|
lv_obj_add_flag(_header, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_add_event_cb(_header, [](lv_event_t* e) {
|
|
auto* self = (LvMessageView*)lv_event_get_user_data(e);
|
|
if (self->_onBack) self->_onBack();
|
|
}, LV_EVENT_CLICKED, this);
|
|
|
|
// Message scroll area (middle, grows to fill)
|
|
_msgScroll = lv_obj_create(parent);
|
|
lv_obj_set_width(_msgScroll, lv_pct(100));
|
|
lv_obj_set_flex_grow(_msgScroll, 1);
|
|
lv_obj_set_style_bg_color(_msgScroll, lv_color_hex(Theme::BG), 0);
|
|
lv_obj_set_style_bg_opa(_msgScroll, LV_OPA_COVER, 0);
|
|
lv_obj_set_style_border_width(_msgScroll, 0, 0);
|
|
lv_obj_set_style_pad_all(_msgScroll, 4, 0);
|
|
lv_obj_set_style_pad_row(_msgScroll, 6, 0);
|
|
lv_obj_set_style_radius(_msgScroll, 0, 0);
|
|
lv_obj_set_layout(_msgScroll, LV_LAYOUT_FLEX);
|
|
lv_obj_set_flex_flow(_msgScroll, LV_FLEX_FLOW_COLUMN);
|
|
lv_obj_add_style(_msgScroll, LvTheme::styleScrollbar(), LV_PART_SCROLLBAR);
|
|
|
|
// Input row (bottom, just above tab bar)
|
|
_inputRow = lv_obj_create(parent);
|
|
lv_obj_set_size(_inputRow, lv_pct(100), inputH);
|
|
lv_obj_set_style_bg_color(_inputRow, lv_color_hex(Theme::BG), 0);
|
|
lv_obj_set_style_bg_opa(_inputRow, LV_OPA_COVER, 0);
|
|
lv_obj_set_style_border_color(_inputRow, lv_color_hex(Theme::BORDER), 0);
|
|
lv_obj_set_style_border_width(_inputRow, 1, 0);
|
|
lv_obj_set_style_border_side(_inputRow, LV_BORDER_SIDE_TOP, 0);
|
|
lv_obj_set_style_pad_all(_inputRow, 3, 0);
|
|
lv_obj_set_style_radius(_inputRow, 0, 0);
|
|
lv_obj_clear_flag(_inputRow, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
_textarea = lv_textarea_create(_inputRow);
|
|
lv_obj_set_size(_textarea, Theme::CONTENT_W - 50, 22);
|
|
lv_obj_align(_textarea, LV_ALIGN_LEFT_MID, 0, 0);
|
|
lv_textarea_set_one_line(_textarea, true);
|
|
lv_textarea_set_placeholder_text(_textarea, "Type message...");
|
|
lv_obj_add_style(_textarea, LvTheme::styleTextarea(), 0);
|
|
lv_obj_set_style_border_width(_textarea, 0, 0);
|
|
lv_obj_set_style_text_font(_textarea, font, 0);
|
|
lv_obj_set_style_pad_all(_textarea, 2, 0);
|
|
|
|
_btnSend = lv_btn_create(_inputRow);
|
|
lv_obj_set_size(_btnSend, 40, 22);
|
|
lv_obj_align(_btnSend, LV_ALIGN_RIGHT_MID, 0, 0);
|
|
lv_obj_add_style(_btnSend, LvTheme::styleBtn(), 0);
|
|
lv_obj_set_style_pad_all(_btnSend, 0, 0);
|
|
lv_obj_t* sendLbl = lv_label_create(_btnSend);
|
|
lv_obj_set_style_text_font(sendLbl, &lv_font_ratdeck_10, 0);
|
|
lv_label_set_text(sendLbl, "Send");
|
|
lv_obj_center(sendLbl);
|
|
lv_obj_add_event_cb(_btnSend, [](lv_event_t* e) {
|
|
auto* self = (LvMessageView*)lv_event_get_user_data(e);
|
|
self->sendCurrentMessage();
|
|
}, LV_EVENT_CLICKED, this);
|
|
}
|
|
|
|
void LvMessageView::destroyUI() {
|
|
_header = nullptr;
|
|
_lblHeader = nullptr;
|
|
_msgScroll = nullptr;
|
|
_inputRow = nullptr;
|
|
_textarea = nullptr;
|
|
_btnSend = nullptr;
|
|
LvScreen::destroyUI();
|
|
}
|
|
|
|
void LvMessageView::onEnter() {
|
|
if (_lxmf) {
|
|
_lxmf->markRead(_peerHex);
|
|
// Update unread badge on Messages tab
|
|
if (_ui) _ui->lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, _lxmf->unreadCount());
|
|
// Register status callback — partial update without full rebuild
|
|
std::string peer = _peerHex;
|
|
_lxmf->setStatusCallback([this, peer](const std::string& peerHex, double, LXMFStatus newStatus) {
|
|
if (peerHex != peer) return;
|
|
for (int i = _cachedMsgs.size() - 1; i >= 0; i--) {
|
|
if (!_cachedMsgs[i].incoming && _cachedMsgs[i].status == LXMFStatus::QUEUED) {
|
|
_cachedMsgs[i].status = newStatus;
|
|
updateMessageStatus(i, newStatus);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
_lastMsgCount = -1;
|
|
_lastRefreshMs = 0;
|
|
_inputText.clear();
|
|
|
|
if (_lblHeader) {
|
|
char header[48];
|
|
snprintf(header, sizeof(header), "< %s", getPeerName().c_str());
|
|
lv_label_set_text(_lblHeader, header);
|
|
}
|
|
if (_textarea) {
|
|
lv_textarea_set_text(_textarea, "");
|
|
}
|
|
_cachedMsgs.clear(); // Force fresh load
|
|
rebuildMessages();
|
|
}
|
|
|
|
void LvMessageView::onExit() {
|
|
if (_lxmf) _lxmf->setStatusCallback(nullptr);
|
|
_inputText.clear();
|
|
_cachedMsgs.clear();
|
|
}
|
|
|
|
void LvMessageView::refreshUI() {
|
|
if (!_lxmf) return;
|
|
unsigned long now = millis();
|
|
if (now - _lastRefreshMs < REFRESH_INTERVAL_MS) return;
|
|
_lastRefreshMs = now;
|
|
|
|
// Only reload from disk when message count changes (new messages arrive)
|
|
auto* summary = _lxmf->getConversationSummary(_peerHex);
|
|
if (summary && summary->totalCount == (int)_cachedMsgs.size()) return;
|
|
|
|
auto newMsgs = _lxmf->getMessages(_peerHex);
|
|
if (newMsgs.size() != _cachedMsgs.size()) {
|
|
if (newMsgs.size() > _cachedMsgs.size()) {
|
|
// Incremental append — only create widgets for new messages
|
|
size_t oldCount = _cachedMsgs.size();
|
|
_cachedMsgs = std::move(newMsgs);
|
|
_lastMsgCount = (int)_cachedMsgs.size();
|
|
_lastRefreshMs = millis();
|
|
for (size_t i = oldCount; i < _cachedMsgs.size(); i++) {
|
|
appendMessage(_cachedMsgs[i]);
|
|
}
|
|
lv_obj_scroll_to_y(_msgScroll, LV_COORD_MAX, LV_ANIM_OFF);
|
|
} else {
|
|
// Count decreased (deletion?) — full rebuild
|
|
_cachedMsgs = std::move(newMsgs);
|
|
_lastMsgCount = (int)_cachedMsgs.size();
|
|
rebuildMessages();
|
|
}
|
|
// Mark as read since user is actively viewing this conversation
|
|
_lxmf->markRead(_peerHex);
|
|
if (_ui) _ui->lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, _lxmf->unreadCount());
|
|
}
|
|
}
|
|
|
|
void LvMessageView::appendMessage(const LXMFMessage& msg) {
|
|
if (!_msgScroll) return;
|
|
|
|
const lv_font_t* font = &lv_font_ratdeck_12;
|
|
int maxBubbleW = Theme::CONTENT_W * 3 / 4;
|
|
|
|
// Bubble container. Reserve pad_bottom for the timestamp row so the
|
|
// bubble's allocated height in the flex scroll includes it — without
|
|
// this, the time renders into the row gap and looks like it's hanging
|
|
// outside the bubble.
|
|
lv_obj_t* bubble = lv_obj_create(_msgScroll);
|
|
lv_obj_set_width(bubble, Theme::CONTENT_W - 12);
|
|
lv_obj_set_style_pad_all(bubble, 0, 0);
|
|
lv_obj_set_style_pad_bottom(bubble, 14, 0);
|
|
lv_obj_set_style_bg_opa(bubble, LV_OPA_TRANSP, 0);
|
|
lv_obj_set_style_border_width(bubble, 0, 0);
|
|
lv_obj_clear_flag(bubble, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_set_height(bubble, LV_SIZE_CONTENT);
|
|
|
|
// Message text in a rounded box
|
|
lv_obj_t* box = lv_obj_create(bubble);
|
|
lv_obj_set_style_radius(box, 4, 0);
|
|
lv_obj_set_style_pad_all(box, 5, 0);
|
|
lv_obj_set_style_border_width(box, 0, 0);
|
|
lv_obj_set_width(box, LV_SIZE_CONTENT);
|
|
lv_obj_set_height(box, LV_SIZE_CONTENT);
|
|
lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
if (msg.incoming) {
|
|
lv_obj_set_style_bg_color(box, lv_color_hex(Theme::MSG_IN_BG), 0);
|
|
lv_obj_align(box, LV_ALIGN_TOP_LEFT, 0, 0);
|
|
} else {
|
|
lv_obj_set_style_bg_color(box, lv_color_hex(Theme::MSG_OUT_BG), 0);
|
|
lv_obj_align(box, LV_ALIGN_TOP_RIGHT, 0, 0);
|
|
}
|
|
lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0);
|
|
|
|
// Message text color — incoming is plain text, outgoing reflects delivery status
|
|
uint32_t textColor = Theme::TEXT_PRIMARY; // incoming default
|
|
if (!msg.incoming) {
|
|
switch (msg.status) {
|
|
case LXMFStatus::QUEUED:
|
|
case LXMFStatus::SENDING:
|
|
textColor = Theme::TEXT_SECONDARY; break;
|
|
case LXMFStatus::SENT:
|
|
case LXMFStatus::DELIVERED:
|
|
textColor = Theme::TEXT_PRIMARY; break;
|
|
case LXMFStatus::FAILED:
|
|
textColor = Theme::ERROR_CLR; break;
|
|
default:
|
|
textColor = Theme::TEXT_PRIMARY; break;
|
|
}
|
|
}
|
|
lv_obj_t* lbl = lv_label_create(box);
|
|
lv_obj_set_style_text_font(lbl, font, 0);
|
|
lv_obj_set_style_text_color(lbl, lv_color_hex(textColor), 0);
|
|
lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP);
|
|
lv_obj_set_width(lbl, maxBubbleW - 14);
|
|
lv_label_set_text(lbl, msg.content.c_str());
|
|
|
|
// Status indicator for outgoing (tracked for partial updates)
|
|
if (!msg.incoming) {
|
|
const char* ind = "~";
|
|
uint32_t indColor = Theme::TEXT_MUTED;
|
|
if (msg.status == LXMFStatus::SENT || msg.status == LXMFStatus::DELIVERED) {
|
|
ind = "*"; indColor = Theme::SUCCESS;
|
|
} else if (msg.status == LXMFStatus::FAILED) {
|
|
ind = "!"; indColor = Theme::ERROR_CLR;
|
|
}
|
|
lv_obj_t* statusLbl = lv_label_create(box);
|
|
lv_obj_set_style_text_font(statusLbl, &lv_font_ratdeck_10, 0);
|
|
lv_obj_set_style_text_color(statusLbl, lv_color_hex(indColor), 0);
|
|
lv_label_set_text(statusLbl, ind);
|
|
lv_obj_align(statusLbl, LV_ALIGN_BOTTOM_RIGHT, 0, 0);
|
|
_statusLabels.push_back(statusLbl);
|
|
_textLabels.push_back(lbl);
|
|
} else {
|
|
_statusLabels.push_back(nullptr);
|
|
_textLabels.push_back(nullptr);
|
|
}
|
|
|
|
// Timestamp below bubble
|
|
if (msg.timestamp > 1700000000) {
|
|
time_t t = (time_t)msg.timestamp;
|
|
struct tm* tm = localtime(&t);
|
|
if (tm) {
|
|
char timeBuf[8];
|
|
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", tm->tm_hour, tm->tm_min);
|
|
lv_obj_t* timeLbl = lv_label_create(bubble);
|
|
lv_obj_set_style_text_font(timeLbl, &lv_font_ratdeck_10, 0);
|
|
lv_obj_set_style_text_color(timeLbl, lv_color_hex(Theme::TEXT_MUTED), 0);
|
|
lv_label_set_text(timeLbl, timeBuf);
|
|
if (msg.incoming) {
|
|
lv_obj_align_to(timeLbl, box, LV_ALIGN_OUT_BOTTOM_LEFT, 2, 1);
|
|
} else {
|
|
lv_obj_align_to(timeLbl, box, LV_ALIGN_OUT_BOTTOM_RIGHT, -2, 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void LvMessageView::rebuildMessages() {
|
|
if (!_lxmf || !_msgScroll) return;
|
|
|
|
// Only load from disk if _cachedMsgs is empty (first call or after send)
|
|
if (_cachedMsgs.empty()) {
|
|
_cachedMsgs = _lxmf->getMessages(_peerHex);
|
|
}
|
|
_lastMsgCount = (int)_cachedMsgs.size();
|
|
_lastRefreshMs = millis();
|
|
lv_obj_clean(_msgScroll);
|
|
_statusLabels.clear();
|
|
_textLabels.clear();
|
|
|
|
for (const auto& msg : _cachedMsgs) {
|
|
appendMessage(msg);
|
|
}
|
|
|
|
// Auto-scroll to bottom
|
|
lv_obj_scroll_to_y(_msgScroll, LV_COORD_MAX, LV_ANIM_OFF);
|
|
}
|
|
|
|
void LvMessageView::updateMessageStatus(int msgIdx, LXMFStatus status) {
|
|
if (msgIdx < 0 || msgIdx >= (int)_statusLabels.size()) return;
|
|
lv_obj_t* statusLbl = _statusLabels[msgIdx];
|
|
lv_obj_t* textLbl = _textLabels[msgIdx];
|
|
if (!statusLbl) return; // Incoming message, no status label
|
|
|
|
// Update status indicator
|
|
const char* ind = "~";
|
|
uint32_t indColor = Theme::TEXT_MUTED;
|
|
if (status == LXMFStatus::SENT || status == LXMFStatus::DELIVERED) {
|
|
ind = "*"; indColor = Theme::SUCCESS;
|
|
} else if (status == LXMFStatus::FAILED) {
|
|
ind = "!"; indColor = Theme::ERROR_CLR;
|
|
}
|
|
lv_obj_set_style_text_color(statusLbl, lv_color_hex(indColor), 0);
|
|
lv_label_set_text(statusLbl, ind);
|
|
|
|
// Update text color to match status
|
|
if (textLbl) {
|
|
uint32_t textColor = Theme::TEXT_PRIMARY;
|
|
if (status == LXMFStatus::QUEUED || status == LXMFStatus::SENDING) {
|
|
textColor = Theme::TEXT_SECONDARY;
|
|
} else if (status == LXMFStatus::FAILED) {
|
|
textColor = Theme::ERROR_CLR;
|
|
}
|
|
lv_obj_set_style_text_color(textLbl, lv_color_hex(textColor), 0);
|
|
}
|
|
}
|
|
|
|
void LvMessageView::sendCurrentMessage() {
|
|
if (!_lxmf || _peerHex.empty() || _inputText.empty()) return;
|
|
|
|
RNS::Bytes destHash;
|
|
destHash.assignHex(_peerHex.c_str());
|
|
_lxmf->sendMessage(destHash, _inputText.c_str());
|
|
|
|
_inputText.clear();
|
|
if (_textarea) lv_textarea_set_text(_textarea, "");
|
|
_cachedMsgs.clear(); // Force fresh load in rebuildMessages
|
|
rebuildMessages();
|
|
}
|
|
|
|
bool LvMessageView::handleKey(const KeyEvent& event) {
|
|
if (event.character == 0x1B) {
|
|
if (_onBack) _onBack();
|
|
return true;
|
|
}
|
|
|
|
if (event.del || event.character == 0x08) {
|
|
if (!_inputText.empty()) {
|
|
_inputText.pop_back();
|
|
if (_textarea) lv_textarea_set_text(_textarea, _inputText.c_str());
|
|
} else {
|
|
if (_onBack) _onBack();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (event.enter || event.character == '\n' || event.character == '\r') {
|
|
sendCurrentMessage();
|
|
return true;
|
|
}
|
|
|
|
// Scroll
|
|
if (event.up) {
|
|
if (_msgScroll) lv_obj_scroll_to_y(_msgScroll,
|
|
lv_obj_get_scroll_y(_msgScroll) - 30, LV_ANIM_OFF);
|
|
return true;
|
|
}
|
|
if (event.down) {
|
|
if (_msgScroll) lv_obj_scroll_to_y(_msgScroll,
|
|
lv_obj_get_scroll_y(_msgScroll) + 30, LV_ANIM_OFF);
|
|
return true;
|
|
}
|
|
|
|
if (event.character >= 0x20 && event.character < 0x7F) {
|
|
_inputText += (char)event.character;
|
|
if (_textarea) lv_textarea_set_text(_textarea, _inputText.c_str());
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|