mirror of
https://github.com/ratspeak/ratdeck.git
synced 2026-07-11 16:48:50 +00:00
594 lines
22 KiB
C++
594 lines
22 KiB
C++
#include "LvMessagesScreen.h"
|
|
#include "ui/Theme.h"
|
|
#include "ui/LvTheme.h"
|
|
#include "ui/LvInput.h"
|
|
#include "ui/UIManager.h"
|
|
#include "ui/LxmFaceAvatar.h"
|
|
#include "reticulum/LXMFManager.h"
|
|
#include "reticulum/AnnounceManager.h"
|
|
#include "storage/MessageStore.h"
|
|
#include <Arduino.h>
|
|
#include <time.h>
|
|
#include <algorithm>
|
|
#include "fonts/fonts.h"
|
|
|
|
namespace {
|
|
|
|
constexpr int kRowH = 58;
|
|
constexpr int kRailW = 5;
|
|
constexpr int kChatAvatar = 32;
|
|
constexpr int kChatTextX = 54;
|
|
bool isPendingStatus(LXMFStatus status) {
|
|
return status == LXMFStatus::QUEUED || status == LXMFStatus::SENDING;
|
|
}
|
|
|
|
uint32_t statusColor(LXMFStatus status) {
|
|
switch (status) {
|
|
case LXMFStatus::DELIVERED: return Theme::SUCCESS;
|
|
case LXMFStatus::SENT: return Theme::TEXT_MUTED;
|
|
case LXMFStatus::FAILED: return Theme::ERROR_CLR;
|
|
case LXMFStatus::QUEUED:
|
|
case LXMFStatus::SENDING: return Theme::WARNING_CLR;
|
|
default: return Theme::TEXT_MUTED;
|
|
}
|
|
}
|
|
|
|
const char* statusLabel(LXMFStatus status) {
|
|
switch (status) {
|
|
case LXMFStatus::DELIVERED: return "SENT";
|
|
case LXMFStatus::SENT: return "SENT";
|
|
case LXMFStatus::FAILED: return "FAILED";
|
|
case LXMFStatus::SENDING: return "SENDING";
|
|
case LXMFStatus::QUEUED: return "QUEUED";
|
|
default: return "";
|
|
}
|
|
}
|
|
|
|
std::string shortText(const std::string& text, size_t maxLen) {
|
|
if (text.size() <= maxLen) return text;
|
|
if (maxLen <= 3) return text.substr(0, maxLen);
|
|
return text.substr(0, maxLen - 3) + "...";
|
|
}
|
|
|
|
std::string chatPreviewText(const std::string& text, size_t maxLen) {
|
|
if (text.empty()) return text;
|
|
|
|
std::string firstLine;
|
|
firstLine.reserve(std::min(text.size(), maxLen));
|
|
bool truncated = false;
|
|
for (char c : text) {
|
|
if (c == '\r' || c == '\n') {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
firstLine += (c == '\t') ? ' ' : c;
|
|
}
|
|
|
|
while (!firstLine.empty() && firstLine.back() == ' ') {
|
|
firstLine.pop_back();
|
|
}
|
|
|
|
if (firstLine.size() > maxLen) {
|
|
firstLine = shortText(firstLine, maxLen);
|
|
} else if (truncated && firstLine.size() + 3 <= maxLen) {
|
|
firstLine += "...";
|
|
} else if (truncated && firstLine.size() > 3) {
|
|
firstLine = firstLine.substr(0, maxLen - 3) + "...";
|
|
}
|
|
|
|
return firstLine;
|
|
}
|
|
|
|
std::string displayNameForPeer(AnnounceManager* am, const std::string& peerHex) {
|
|
if (am) {
|
|
std::string peerName = am->lookupName(peerHex);
|
|
if (!peerName.empty()) return shortText(peerName, 18);
|
|
}
|
|
return shortText(peerHex, 12);
|
|
}
|
|
|
|
bool formatClock(double ts, char* out, size_t outLen) {
|
|
if (!out || outLen == 0 || ts <= 1700000000) return false;
|
|
time_t t = (time_t)ts;
|
|
struct tm* tm = localtime(&t);
|
|
if (!tm) return false;
|
|
snprintf(out, outLen, "%02d:%02d", tm->tm_hour, tm->tm_min);
|
|
return true;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void LvMessagesScreen::createUI(lv_obj_t* parent) {
|
|
_screen = parent;
|
|
lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0);
|
|
lv_obj_set_style_pad_all(parent, 0, 0);
|
|
|
|
_lblEmpty = lv_obj_create(parent);
|
|
lv_obj_set_size(_lblEmpty, 272, 104);
|
|
lv_obj_set_style_bg_color(_lblEmpty, lv_color_hex(Theme::BG_ELEVATED), 0);
|
|
lv_obj_set_style_bg_opa(_lblEmpty, LV_OPA_80, 0);
|
|
lv_obj_set_style_border_color(_lblEmpty, lv_color_hex(Theme::BORDER), 0);
|
|
lv_obj_set_style_border_width(_lblEmpty, 1, 0);
|
|
lv_obj_set_style_radius(_lblEmpty, 6, 0);
|
|
lv_obj_set_style_pad_all(_lblEmpty, 0, 0);
|
|
lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_center(_lblEmpty);
|
|
|
|
lv_obj_t* emptyIcon = lv_label_create(_lblEmpty);
|
|
lv_obj_set_style_text_font(emptyIcon, &lv_font_montserrat_12, 0);
|
|
lv_obj_set_style_text_color(emptyIcon, lv_color_hex(Theme::PRIMARY), 0);
|
|
lv_label_set_text(emptyIcon, LV_SYMBOL_ENVELOPE);
|
|
lv_obj_align(emptyIcon, LV_ALIGN_TOP_MID, 0, 15);
|
|
|
|
lv_obj_t* emptyTitle = lv_label_create(_lblEmpty);
|
|
lv_obj_set_style_text_font(emptyTitle, &lv_font_rsdeck_14, 0);
|
|
lv_obj_set_style_text_color(emptyTitle, lv_color_hex(Theme::TEXT_PRIMARY), 0);
|
|
lv_label_set_text(emptyTitle, "No conversations");
|
|
lv_obj_align(emptyTitle, LV_ALIGN_TOP_MID, 0, 34);
|
|
|
|
lv_obj_t* emptySub = lv_label_create(_lblEmpty);
|
|
lv_obj_set_style_text_font(emptySub, &lv_font_rsdeck_12, 0);
|
|
lv_obj_set_style_text_color(emptySub, lv_color_hex(Theme::TEXT_SECONDARY), 0);
|
|
lv_label_set_text(emptySub, "LXMF inbox is clear");
|
|
lv_obj_align(emptySub, LV_ALIGN_TOP_MID, 0, 56);
|
|
|
|
lv_obj_t* emptyLine = lv_obj_create(_lblEmpty);
|
|
lv_obj_set_size(emptyLine, 160, 1);
|
|
lv_obj_set_style_bg_color(emptyLine, lv_color_hex(Theme::DIVIDER), 0);
|
|
lv_obj_set_style_bg_opa(emptyLine, LV_OPA_COVER, 0);
|
|
lv_obj_set_style_border_width(emptyLine, 0, 0);
|
|
lv_obj_align(emptyLine, LV_ALIGN_BOTTOM_MID, 0, -18);
|
|
|
|
_list = lv_obj_create(parent);
|
|
lv_obj_set_size(_list, lv_pct(100), lv_pct(100));
|
|
lv_obj_add_style(_list, LvTheme::styleList(), 0);
|
|
lv_obj_add_style(_list, LvTheme::styleScrollbar(), LV_PART_SCROLLBAR);
|
|
lv_obj_set_layout(_list, LV_LAYOUT_FLEX);
|
|
lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN);
|
|
|
|
_lastConvCount = -1;
|
|
}
|
|
|
|
void LvMessagesScreen::onEnter() {
|
|
_lastConvCount = -1;
|
|
_focusActive = false;
|
|
hideActionMenu();
|
|
rebuildList();
|
|
}
|
|
|
|
void LvMessagesScreen::onExit() {
|
|
hideActionMenu();
|
|
}
|
|
|
|
void LvMessagesScreen::refreshUI() {
|
|
if (!_lxmf) return;
|
|
if (_lpState != LP_NONE) return;
|
|
int count = (int)_lxmf->conversations().size();
|
|
int unread = _lxmf->unreadCount();
|
|
int queued = _lxmf->queuedCount();
|
|
uint32_t revision = _lxmf->storeRevision();
|
|
if (count != _lastConvCount || unread != _lastUnreadTotal ||
|
|
queued != _lastQueuedCount || revision != _lastStoreRevision) {
|
|
rebuildList();
|
|
}
|
|
}
|
|
|
|
void LvMessagesScreen::rebuildList() {
|
|
if (!_lxmf || !_list) return;
|
|
unsigned long startMs = millis();
|
|
|
|
const auto& convs = _lxmf->conversations();
|
|
int count = (int)convs.size();
|
|
_lastConvCount = count;
|
|
_lastUnreadTotal = _lxmf->unreadCount();
|
|
_lastQueuedCount = _lxmf->queuedCount();
|
|
_lastStoreRevision = _lxmf->storeRevision();
|
|
_sortedPeers.clear();
|
|
_sortedConvs.clear();
|
|
|
|
lv_obj_clean(_list);
|
|
_avatarBuffers.clear();
|
|
|
|
if (count == 0) {
|
|
lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN);
|
|
return;
|
|
}
|
|
|
|
lv_obj_add_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_clear_flag(_list, LV_OBJ_FLAG_HIDDEN);
|
|
|
|
// Build sorted conversation info
|
|
_sortedConvs.reserve(count);
|
|
_avatarBuffers.reserve(count);
|
|
for (int i = 0; i < count; i++) {
|
|
ConvInfo ci;
|
|
ci.peerHex = convs[i];
|
|
auto* s = _lxmf->getConversationSummary(ci.peerHex);
|
|
if (s) {
|
|
ci.lastTs = s->lastTimestamp;
|
|
ci.preview = chatPreviewText(s->lastPreview, 56);
|
|
ci.lastIncoming = s->lastIncoming;
|
|
ci.unreadCount = s->unreadCount;
|
|
ci.totalCount = s->totalCount;
|
|
ci.hasUnread = ci.unreadCount > 0;
|
|
ci.hasOutgoing = s->hasOutgoing;
|
|
ci.hasPending = s->hasPending;
|
|
ci.hasFailed = s->hasFailed;
|
|
ci.lastOutgoingStatus = s->lastOutgoingStatus;
|
|
}
|
|
ci.displayName = displayNameForPeer(_am, ci.peerHex);
|
|
|
|
if (_am) {
|
|
const DiscoveredNode* node = _am->findNodeByHex(ci.peerHex);
|
|
if (node) {
|
|
ci.knownNode = true;
|
|
ci.savedNode = node->saved;
|
|
ci.rssi = node->rssi;
|
|
ci.snr = node->snr;
|
|
ci.hops = node->hops;
|
|
ci.lastSeen = node->lastSeen;
|
|
}
|
|
}
|
|
|
|
_sortedConvs.push_back(ci);
|
|
}
|
|
|
|
std::sort(_sortedConvs.begin(), _sortedConvs.end(), [](const ConvInfo& a, const ConvInfo& b) {
|
|
return a.lastTs > b.lastTs;
|
|
});
|
|
|
|
for (auto& ci : _sortedConvs) _sortedPeers.push_back(ci.peerHex);
|
|
|
|
// Build list items with focus group support
|
|
const lv_font_t* nameFont = &lv_font_rsdeck_14;
|
|
const lv_font_t* smallFont = &lv_font_rsdeck_12;
|
|
|
|
for (int i = 0; i < count; i++) {
|
|
const auto& ci = _sortedConvs[i];
|
|
bool latestOutgoingFailed = ci.hasOutgoing && ci.lastOutgoingStatus == LXMFStatus::FAILED;
|
|
|
|
lv_obj_t* row = lv_obj_create(_list);
|
|
lv_obj_set_size(row, Theme::CONTENT_W, kRowH);
|
|
lv_obj_add_style(row, LvTheme::styleListBtn(), 0);
|
|
lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED);
|
|
lv_obj_set_style_pad_all(row, 0, 0);
|
|
lv_obj_set_style_bg_color(row, lv_color_hex(ci.hasUnread ? Theme::PRIMARY_SUBTLE : Theme::BG), 0);
|
|
lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0);
|
|
lv_obj_set_style_border_width(row, 1, 0);
|
|
lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0);
|
|
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_set_user_data(row, (void*)(intptr_t)i);
|
|
|
|
lv_obj_add_event_cb(row, [](lv_event_t* e) {
|
|
auto* self = (LvMessagesScreen*)lv_event_get_user_data(e);
|
|
if (self->_lpState != LP_NONE) return;
|
|
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
|
|
if (idx < (int)self->_sortedPeers.size() && self->_onOpen) {
|
|
self->_onOpen(self->_sortedPeers[idx]);
|
|
}
|
|
}, LV_EVENT_CLICKED, this);
|
|
lv_obj_add_event_cb(row, [](lv_event_t* e) {
|
|
auto* self = (LvMessagesScreen*)lv_event_get_user_data(e);
|
|
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
|
|
self->showActionMenu(idx);
|
|
}, LV_EVENT_LONG_PRESSED, this);
|
|
|
|
lv_group_add_obj(LvInput::group(), row);
|
|
lv_obj_add_event_cb(row, [](lv_event_t* e) {
|
|
lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON);
|
|
}, LV_EVENT_FOCUSED, nullptr);
|
|
|
|
uint32_t railColor = Theme::BORDER;
|
|
if (latestOutgoingFailed) railColor = Theme::ERROR_CLR;
|
|
else if (ci.hasPending) railColor = Theme::WARNING_CLR;
|
|
else if (ci.hasUnread) railColor = Theme::PRIMARY;
|
|
else if (ci.savedNode) railColor = Theme::TEXT_SECONDARY;
|
|
else if (ci.knownNode) railColor = Theme::TEXT_MUTED;
|
|
|
|
lv_obj_t* rail = lv_obj_create(row);
|
|
lv_obj_set_size(rail, kRailW, kRowH);
|
|
lv_obj_set_pos(rail, 0, 0);
|
|
lv_obj_set_style_bg_color(rail, lv_color_hex(railColor), 0);
|
|
lv_obj_set_style_bg_opa(rail, LV_OPA_COVER, 0);
|
|
lv_obj_set_style_border_width(rail, 0, 0);
|
|
lv_obj_set_style_radius(rail, 0, 0);
|
|
lv_obj_set_style_pad_all(rail, 0, 0);
|
|
lv_obj_clear_flag(rail, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
_avatarBuffers.emplace_back(LxmFaceAvatar::bufferSize(kChatAvatar));
|
|
auto avatar = LxmFaceAvatar::create(row, 13, 13, kChatAvatar,
|
|
_avatarBuffers.back().data(),
|
|
Theme::PRIMARY_SUBTLE,
|
|
ci.hasUnread ? Theme::PRIMARY : Theme::BORDER);
|
|
LxmFaceAvatar::render(avatar.canvas, String(ci.peerHex.c_str()));
|
|
|
|
int leftPad = kChatTextX;
|
|
|
|
// Name (top-left, first line)
|
|
lv_obj_t* nameLbl = lv_label_create(row);
|
|
lv_obj_set_style_text_font(nameLbl, nameFont, 0);
|
|
lv_obj_set_style_text_color(nameLbl, lv_color_hex(ci.hasUnread ? Theme::ACCENT : Theme::TEXT_PRIMARY), 0);
|
|
lv_label_set_long_mode(nameLbl, LV_LABEL_LONG_DOT);
|
|
lv_obj_set_width(nameLbl, Theme::CONTENT_W - leftPad - 70);
|
|
lv_label_set_text(nameLbl, ci.displayName.c_str());
|
|
lv_obj_set_pos(nameLbl, leftPad, 6);
|
|
|
|
// Time (top-right)
|
|
char timeBuf[8];
|
|
if (formatClock(ci.lastTs, timeBuf, sizeof(timeBuf))) {
|
|
lv_obj_t* timeLbl = lv_label_create(row);
|
|
lv_obj_set_style_text_font(timeLbl, &lv_font_rsdeck_10, 0);
|
|
lv_obj_set_style_text_color(timeLbl, lv_color_hex(ci.hasUnread ? Theme::PRIMARY : Theme::TEXT_MUTED), 0);
|
|
lv_label_set_text(timeLbl, timeBuf);
|
|
lv_obj_align(timeLbl, LV_ALIGN_TOP_RIGHT, -8, 7);
|
|
}
|
|
|
|
// Preview (second line, below name)
|
|
lv_obj_t* prevLbl = lv_label_create(row);
|
|
lv_obj_set_style_text_font(prevLbl, smallFont, 0);
|
|
uint32_t previewColor = Theme::TEXT_SECONDARY;
|
|
if (latestOutgoingFailed) previewColor = Theme::ERROR_CLR;
|
|
else if (ci.hasPending) previewColor = Theme::WARNING_CLR;
|
|
else if (ci.hasUnread) previewColor = Theme::TEXT_PRIMARY;
|
|
lv_obj_set_style_text_color(prevLbl, lv_color_hex(previewColor), 0);
|
|
lv_label_set_long_mode(prevLbl, LV_LABEL_LONG_DOT);
|
|
lv_obj_set_width(prevLbl, Theme::CONTENT_W - leftPad - 76);
|
|
lv_label_set_text(prevLbl, ci.preview.empty() ? "No preview" : ci.preview.c_str());
|
|
lv_obj_set_pos(prevLbl, leftPad, 28);
|
|
|
|
char chipBuf[16] = {0};
|
|
uint32_t chipColor = Theme::TEXT_MUTED;
|
|
if (latestOutgoingFailed) {
|
|
snprintf(chipBuf, sizeof(chipBuf), "FAILED");
|
|
chipColor = Theme::ERROR_CLR;
|
|
} else if (ci.hasPending) {
|
|
const char* pending = statusLabel(ci.lastOutgoingStatus);
|
|
snprintf(chipBuf, sizeof(chipBuf), "%s", pending[0] ? pending : "QUEUED");
|
|
chipColor = Theme::WARNING_CLR;
|
|
} else if (ci.hasUnread) {
|
|
if (ci.unreadCount > 9) snprintf(chipBuf, sizeof(chipBuf), "NEW 9+");
|
|
else snprintf(chipBuf, sizeof(chipBuf), "NEW %d", ci.unreadCount);
|
|
chipColor = Theme::PRIMARY;
|
|
} else if (!ci.lastIncoming && ci.hasOutgoing &&
|
|
(ci.lastOutgoingStatus == LXMFStatus::SENT || ci.lastOutgoingStatus == LXMFStatus::DELIVERED)) {
|
|
snprintf(chipBuf, sizeof(chipBuf), "%s", statusLabel(ci.lastOutgoingStatus));
|
|
chipColor = statusColor(ci.lastOutgoingStatus);
|
|
}
|
|
|
|
if (chipBuf[0] != '\0') {
|
|
lv_obj_t* chip = lv_obj_create(row);
|
|
lv_obj_set_size(chip, 58, 15);
|
|
lv_obj_align(chip, LV_ALIGN_BOTTOM_RIGHT, -7, -8);
|
|
lv_obj_set_style_bg_color(chip, lv_color_hex(Theme::BG), 0);
|
|
lv_obj_set_style_bg_opa(chip, LV_OPA_80, 0);
|
|
lv_obj_set_style_border_color(chip, lv_color_hex(chipColor), 0);
|
|
lv_obj_set_style_border_width(chip, 1, 0);
|
|
lv_obj_set_style_radius(chip, 4, 0);
|
|
lv_obj_set_style_pad_all(chip, 0, 0);
|
|
lv_obj_clear_flag(chip, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
lv_obj_t* chipLbl = lv_label_create(chip);
|
|
lv_obj_set_style_text_font(chipLbl, &lv_font_rsdeck_10, 0);
|
|
lv_obj_set_style_text_color(chipLbl, lv_color_hex(chipColor), 0);
|
|
lv_label_set_text(chipLbl, chipBuf);
|
|
lv_obj_center(chipLbl);
|
|
}
|
|
}
|
|
|
|
// Clear auto-focus if user hasn't started navigating yet
|
|
if (!_focusActive) {
|
|
lv_obj_t* focused = lv_group_get_focused(LvInput::group());
|
|
if (focused) lv_obj_clear_state(focused, LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY);
|
|
}
|
|
|
|
unsigned long elapsed = millis() - startMs;
|
|
if (elapsed > 25) {
|
|
Serial.printf("[PERF] Chats rebuild: %d convs in %lums\n",
|
|
count, (unsigned long)elapsed);
|
|
}
|
|
}
|
|
|
|
int LvMessagesScreen::getFocusedPeerIdx() const {
|
|
lv_obj_t* focused = lv_group_get_focused(LvInput::group());
|
|
if (!focused) return -1;
|
|
return (int)(intptr_t)lv_obj_get_user_data(focused);
|
|
}
|
|
|
|
void LvMessagesScreen::hideActionMenu() {
|
|
if (_actionOverlay) {
|
|
lv_obj_del_async(_actionOverlay);
|
|
_actionOverlay = nullptr;
|
|
}
|
|
for (int i = 0; i < 3; i++) {
|
|
_actionRows[i] = nullptr;
|
|
_actionLabels[i] = nullptr;
|
|
}
|
|
_lpState = LP_NONE;
|
|
_lpPeerIdx = -1;
|
|
_menuIdx = 0;
|
|
_menuCount = 0;
|
|
}
|
|
|
|
void LvMessagesScreen::rebuildActionOverlay(const char* title, const char* const* labels, int count) {
|
|
if (_actionOverlay) {
|
|
lv_obj_del_async(_actionOverlay);
|
|
_actionOverlay = nullptr;
|
|
}
|
|
for (int i = 0; i < 3; i++) {
|
|
_actionRows[i] = nullptr;
|
|
_actionLabels[i] = nullptr;
|
|
}
|
|
|
|
_menuCount = count;
|
|
_actionOverlay = lv_obj_create(lv_layer_top());
|
|
lv_obj_set_size(_actionOverlay, 244, 118);
|
|
lv_obj_center(_actionOverlay);
|
|
lv_obj_add_style(_actionOverlay, LvTheme::styleModal(), 0);
|
|
lv_obj_set_style_pad_all(_actionOverlay, 8, 0);
|
|
lv_obj_clear_flag(_actionOverlay, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
lv_obj_t* titleLbl = lv_label_create(_actionOverlay);
|
|
lv_obj_set_style_text_font(titleLbl, &lv_font_rsdeck_12, 0);
|
|
lv_obj_set_style_text_color(titleLbl, lv_color_hex(Theme::ACCENT), 0);
|
|
lv_label_set_long_mode(titleLbl, LV_LABEL_LONG_DOT);
|
|
lv_obj_set_width(titleLbl, 220);
|
|
lv_label_set_text(titleLbl, title);
|
|
lv_obj_align(titleLbl, LV_ALIGN_TOP_MID, 0, 0);
|
|
|
|
for (int i = 0; i < count && i < 3; i++) {
|
|
lv_obj_t* row = lv_obj_create(_actionOverlay);
|
|
lv_obj_set_size(row, 220, 24);
|
|
lv_obj_set_pos(row, 4, 24 + i * 28);
|
|
lv_obj_add_style(row, LvTheme::styleListBtn(), 0);
|
|
lv_obj_set_style_pad_all(row, 0, 0);
|
|
lv_obj_set_style_bg_color(row, lv_color_hex(i == _menuIdx ? Theme::BG_HOVER : Theme::BG), 0);
|
|
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_set_user_data(row, (void*)(intptr_t)i);
|
|
lv_obj_add_event_cb(row, [](lv_event_t* e) {
|
|
auto* self = (LvMessagesScreen*)lv_event_get_user_data(e);
|
|
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
|
|
if (!self) return;
|
|
self->_menuIdx = idx;
|
|
KeyEvent enter = {};
|
|
enter.enter = true;
|
|
self->handleKey(enter);
|
|
}, LV_EVENT_CLICKED, this);
|
|
|
|
lv_obj_t* lbl = lv_label_create(row);
|
|
lv_obj_set_style_text_font(lbl, &lv_font_rsdeck_12, 0);
|
|
lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::TEXT_PRIMARY), 0);
|
|
lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT);
|
|
lv_obj_set_width(lbl, 200);
|
|
lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0);
|
|
lv_label_set_text(lbl, labels[i]);
|
|
lv_obj_center(lbl);
|
|
_actionRows[i] = row;
|
|
_actionLabels[i] = lbl;
|
|
}
|
|
}
|
|
|
|
void LvMessagesScreen::showActionMenu(int peerIdx) {
|
|
if (!_lxmf || peerIdx < 0 || peerIdx >= (int)_sortedPeers.size()) return;
|
|
_focusActive = true;
|
|
_lpPeerIdx = peerIdx;
|
|
_lpState = LP_MENU;
|
|
_menuIdx = 0;
|
|
static const char* labels[] = {"Add Friend", "Delete Chat", "Cancel"};
|
|
rebuildActionOverlay("CHAT ACTION", labels, 3);
|
|
}
|
|
|
|
void LvMessagesScreen::showDeleteConfirm() {
|
|
_lpState = LP_CONFIRM_DELETE;
|
|
_menuIdx = 0;
|
|
static const char* labels[] = {"Delete Chat", "Cancel"};
|
|
rebuildActionOverlay("DELETE CHAT?", labels, 2);
|
|
}
|
|
|
|
void LvMessagesScreen::addFocusedPeerToContacts() {
|
|
if (!_am || _lpPeerIdx < 0 || _lpPeerIdx >= (int)_sortedPeers.size()) return;
|
|
const auto& peerHex = _sortedPeers[_lpPeerIdx];
|
|
const DiscoveredNode* existing = _am->findNodeByHex(peerHex);
|
|
if (existing && !existing->saved) {
|
|
auto& node = const_cast<DiscoveredNode&>(*existing);
|
|
node.saved = true;
|
|
_am->saveContacts();
|
|
if (_ui) _ui->lvStatusBar().showToast("Added to friends", 1200);
|
|
} else if (!existing) {
|
|
_am->addManualContact(peerHex, "");
|
|
if (_ui) _ui->lvStatusBar().showToast("Added to friends", 1200);
|
|
} else if (_ui) {
|
|
_ui->lvStatusBar().showToast("Already a friend", 1200);
|
|
}
|
|
}
|
|
|
|
void LvMessagesScreen::deleteFocusedConversation() {
|
|
if (!_lxmf || _lpPeerIdx < 0 || _lpPeerIdx >= (int)_sortedPeers.size()) return;
|
|
const auto& peerHex = _sortedPeers[_lpPeerIdx];
|
|
_lxmf->markRead(peerHex);
|
|
_lxmf->deleteConversation(peerHex);
|
|
if (_ui) {
|
|
_ui->lvStatusBar().showToast("Chat deleted", 1200);
|
|
_ui->lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, _lxmf->unreadCount());
|
|
}
|
|
_lastConvCount = -1;
|
|
rebuildList();
|
|
}
|
|
|
|
bool LvMessagesScreen::handleLongPress() {
|
|
if (!_lxmf) return false;
|
|
// Only consume long-press once the user has actively navigated into
|
|
// the list. Otherwise let main.cpp blank the screen.
|
|
if (!_focusActive) return false;
|
|
int idx = getFocusedPeerIdx();
|
|
if (idx < 0 || idx >= (int)_sortedPeers.size()) return false;
|
|
showActionMenu(idx);
|
|
return true;
|
|
}
|
|
|
|
bool LvMessagesScreen::handleKey(const KeyEvent& event) {
|
|
if (!_lxmf) return false;
|
|
|
|
if (!_focusActive && (event.up || event.down || event.enter)) {
|
|
_focusActive = true;
|
|
lv_obj_t* focused = lv_group_get_focused(LvInput::group());
|
|
if (focused) lv_obj_add_state(focused, LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY);
|
|
return true;
|
|
}
|
|
|
|
// Long-press menu mode
|
|
if (_lpState == LP_MENU) {
|
|
if (event.up || event.down) {
|
|
_menuIdx = (_menuIdx + (event.down ? 1 : -1) + _menuCount) % _menuCount;
|
|
static const char* labels[] = {"Add Friend", "Delete Chat", "Cancel"};
|
|
rebuildActionOverlay("CHAT ACTION", labels, 3);
|
|
return true;
|
|
}
|
|
if (event.enter || event.character == '\n' || event.character == '\r') {
|
|
if (_menuIdx == 0) {
|
|
addFocusedPeerToContacts();
|
|
} else if (_menuIdx == 1) {
|
|
showDeleteConfirm();
|
|
return true;
|
|
} else {
|
|
if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800);
|
|
}
|
|
hideActionMenu();
|
|
return true;
|
|
}
|
|
if (event.del || event.character == 8 || event.character == 0x1B) {
|
|
hideActionMenu();
|
|
if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800);
|
|
return true;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Confirm delete mode
|
|
if (_lpState == LP_CONFIRM_DELETE) {
|
|
if (event.up || event.down) {
|
|
_menuIdx = (_menuIdx + 1) % _menuCount;
|
|
static const char* labels[] = {"Delete Chat", "Cancel"};
|
|
rebuildActionOverlay("DELETE CHAT?", labels, 2);
|
|
return true;
|
|
}
|
|
if (event.enter || event.character == '\n' || event.character == '\r') {
|
|
if (_menuIdx == 0) {
|
|
deleteFocusedConversation();
|
|
} else if (_ui) {
|
|
_ui->lvStatusBar().showToast("Cancelled", 800);
|
|
}
|
|
hideActionMenu();
|
|
return true;
|
|
}
|
|
hideActionMenu();
|
|
if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800);
|
|
return true;
|
|
}
|
|
|
|
// Let LVGL focus group handle up/down/enter navigation
|
|
return false;
|
|
}
|