#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 #include #include #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(*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; }