From 1e2292415919a772d81a7299d66b79e52e34eadb Mon Sep 17 00:00:00 2001 From: DeFiDude <59237470+DeFiDude@users.noreply.github.com> Date: Sun, 8 Mar 2026 17:53:43 -0600 Subject: [PATCH] v1.5.4: Hop-based eviction, first-boot data cleanup, message performance - Evict highest-hop unsaved nodes first when peer list is full (nearby peers survive) - Add first-boot SD data cleanup screen before name input - Conversation summaries cache for faster message list rendering - Optimize message view refresh with summary-based change detection - Streamline unread tracking through MessageStore instead of LXMFManager --- src/config/Config.h | 4 +- src/main.cpp | 30 ++++- src/reticulum/AnnounceManager.cpp | 20 ++- src/reticulum/AnnounceManager.h | 1 + src/reticulum/LXMFManager.cpp | 27 ++--- src/reticulum/LXMFManager.h | 5 +- src/storage/MessageStore.cpp | 174 ++++++++++++++++++++++++--- src/storage/MessageStore.h | 13 ++ src/storage/SDStore.cpp | 15 +++ src/storage/SDStore.h | 1 + src/ui/screens/LvDataCleanScreen.cpp | 115 ++++++++++++++++++ src/ui/screens/LvDataCleanScreen.h | 26 ++++ src/ui/screens/LvMessageView.cpp | 45 ++++--- src/ui/screens/LvMessageView.h | 1 + src/ui/screens/LvMessagesScreen.cpp | 14 +-- 15 files changed, 418 insertions(+), 73 deletions(-) create mode 100644 src/ui/screens/LvDataCleanScreen.cpp create mode 100644 src/ui/screens/LvDataCleanScreen.h diff --git a/src/config/Config.h b/src/config/Config.h index af9ef33..3a57e0f 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -6,8 +6,8 @@ #define RATDECK_VERSION_MAJOR 1 #define RATDECK_VERSION_MINOR 5 -#define RATDECK_VERSION_PATCH 3 -#define RATDECK_VERSION_STRING "1.5.3" +#define RATDECK_VERSION_PATCH 4 +#define RATDECK_VERSION_STRING "1.5.4" // --- Feature Flags --- #define HAS_DISPLAY true diff --git a/src/main.cpp b/src/main.cpp index 7f4e266..946bc53 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -31,6 +31,7 @@ #include "ui/screens/LvHelpOverlay.h" // Map screen removed #include "ui/screens/LvNameInputScreen.h" +#include "ui/screens/LvDataCleanScreen.h" #include "storage/FlashStore.h" #include "storage/SDStore.h" #include "storage/MessageStore.h" @@ -100,6 +101,7 @@ LvSettingsScreen lvSettingsScreen; LvHelpOverlay lvHelpOverlay; // LvMapScreen removed LvNameInputScreen lvNameInputScreen; +LvDataCleanScreen lvDataCleanScreen; // Tab-screen mapping (4 tabs) — LVGL versions LvScreen* lvTabScreens[LvTabBar::TAB_COUNT] = {}; @@ -749,6 +751,23 @@ void setup() { if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]); }); + // Data clean screen (first boot only — when SD has old data) + lvDataCleanScreen.setDoneCallback([](bool wipe) { + if (wipe) { + Serial.println("[BOOT] User chose to wipe old data"); + lvDataCleanScreen.showStatus("Clearing old data..."); + sdStore.wipeRatputer(); + if (announceManager) announceManager->clearAll(); + Serial.println("[BOOT] Old data cleared"); + lvDataCleanScreen.showStatus("Done! Rebooting..."); + delay(1500); + ESP.restart(); + } else { + Serial.println("[BOOT] User chose to keep old data"); + ui.setLvScreen(&lvNameInputScreen); + } + }); + // Name input screen (first boot only — when no display name is set) lvNameInputScreen.setDoneCallback([](const String& name) { String finalName = name; @@ -780,9 +799,14 @@ void setup() { }); if (userConfig.settings().displayName.isEmpty()) { - // Show name input screen (boot mode keeps status/tab bars hidden) - ui.setLvScreen(&lvNameInputScreen); - Serial.println("[BOOT] Showing name input screen"); + // First boot — check if SD has old data that should be cleaned + if (sdStore.isReady() && sdStore.hasExistingData()) { + ui.setLvScreen(&lvDataCleanScreen); + Serial.println("[BOOT] Old SD data found, showing data clean screen"); + } else { + ui.setLvScreen(&lvNameInputScreen); + Serial.println("[BOOT] Showing name input screen"); + } } else { // Name already set — go straight to home ui.setBootMode(false); diff --git a/src/reticulum/AnnounceManager.cpp b/src/reticulum/AnnounceManager.cpp index 15e44d2..7393df8 100644 --- a/src/reticulum/AnnounceManager.cpp +++ b/src/reticulum/AnnounceManager.cpp @@ -155,15 +155,19 @@ void AnnounceManager::received_announce( if ((int)_nodes.size() >= MAX_NODES) { evictStale(); if ((int)_nodes.size() >= MAX_NODES) { + uint8_t maxHops = 0; unsigned long oldest = ULONG_MAX; - int oldestIdx = -1; + int evictIdx = -1; for (int i = 0; i < (int)_nodes.size(); i++) { - if (!_nodes[i].saved && _nodes[i].lastSeen < oldest) { + if (_nodes[i].saved) continue; + if (_nodes[i].hops > maxHops || + (_nodes[i].hops == maxHops && _nodes[i].lastSeen < oldest)) { + maxHops = _nodes[i].hops; oldest = _nodes[i].lastSeen; - oldestIdx = i; + evictIdx = i; } } - if (oldestIdx >= 0) _nodes.erase(_nodes.begin() + oldestIdx); + if (evictIdx >= 0) _nodes.erase(_nodes.begin() + evictIdx); } } if ((int)_nodes.size() >= MAX_NODES) return; @@ -251,6 +255,14 @@ void AnnounceManager::clearTransientNodes() { } } +void AnnounceManager::clearAll() { + _nodes.clear(); + _nameCache.clear(); + _contactsDirty = false; + _nameCacheDirty = false; + Serial.println("[ANNOUNCE] Cleared all nodes and name cache"); +} + void AnnounceManager::saveContact(const DiscoveredNode& node) { std::string hexHash = node.hash.toHex(); JsonDocument doc; diff --git a/src/reticulum/AnnounceManager.h b/src/reticulum/AnnounceManager.h index bc5db9f..aff77b8 100644 --- a/src/reticulum/AnnounceManager.h +++ b/src/reticulum/AnnounceManager.h @@ -50,6 +50,7 @@ public: void addManualContact(const std::string& hexHash, const std::string& name); void evictStale(unsigned long maxAgeMs = 3600000); void clearTransientNodes(); + void clearAll(); private: void saveContact(const DiscoveredNode& node); diff --git a/src/reticulum/LXMFManager.cpp b/src/reticulum/LXMFManager.cpp index a30aa9f..fbdfa6a 100644 --- a/src/reticulum/LXMFManager.cpp +++ b/src/reticulum/LXMFManager.cpp @@ -146,8 +146,6 @@ void LXMFManager::processIncoming(const uint8_t* data, size_t len, const RNS::By Serial.printf("[LXMF] Message from %s (%d bytes) content_len=%d\n", msg.sourceHash.toHex().substr(0, 8).c_str(), (int)len, (int)msg.content.size()); if (_store) { _store->saveMessage(msg); } - std::string peerHex = msg.sourceHash.toHex(); - _unread[peerHex]++; if (_onMessage) { _onMessage(msg); } } @@ -163,28 +161,17 @@ std::vector LXMFManager::getMessages(const std::string& peerHex) co } int LXMFManager::unreadCount(const std::string& peerHex) const { - if (!_unreadComputed) { const_cast(this)->computeUnreadFromDisk(); } - if (peerHex.empty()) { - int total = 0; - for (auto& kv : _unread) total += kv.second; - return total; - } - auto it = _unread.find(peerHex); - return (it != _unread.end()) ? it->second : 0; + if (!_store) return 0; + if (peerHex.empty()) return _store->totalUnreadCount(); + const ConversationSummary* s = _store->getSummary(peerHex); + return s ? s->unreadCount : 0; } -void LXMFManager::computeUnreadFromDisk() { - _unreadComputed = true; - if (!_store) return; - for (auto& conv : _store->conversations()) { - auto msgs = _store->loadConversation(conv); - int count = 0; - for (auto& m : msgs) { if (m.incoming && !m.read) count++; } - if (count > 0) _unread[conv] = count; - } +const ConversationSummary* LXMFManager::getConversationSummary(const std::string& peerHex) const { + if (!_store) return nullptr; + return _store->getSummary(peerHex); } void LXMFManager::markRead(const std::string& peerHex) { - _unread[peerHex] = 0; if (_store) { _store->markConversationRead(peerHex); } } diff --git a/src/reticulum/LXMFManager.h b/src/reticulum/LXMFManager.h index d4628e4..82057df 100644 --- a/src/reticulum/LXMFManager.h +++ b/src/reticulum/LXMFManager.h @@ -27,6 +27,7 @@ public: std::vector getMessages(const std::string& peerHex) const; int unreadCount(const std::string& peerHex = "") const; void markRead(const std::string& peerHex); + const ConversationSummary* getConversationSummary(const std::string& peerHex) const; private: bool sendDirect(LXMFMessage& msg); @@ -40,10 +41,6 @@ private: StatusCallback _statusCb; std::deque _outQueue; - void computeUnreadFromDisk(); - mutable bool _unreadComputed = false; - mutable std::map _unread; - // Deduplication: recently seen message IDs std::set _seenMessageIds; static constexpr int MAX_SEEN_IDS = 100; diff --git a/src/storage/MessageStore.cpp b/src/storage/MessageStore.cpp index 2e2f524..1aa1331 100644 --- a/src/storage/MessageStore.cpp +++ b/src/storage/MessageStore.cpp @@ -24,6 +24,7 @@ bool MessageStore::begin(FlashStore* flash, SDStore* sd) { migrateTruncatedDirs(); initReceiveCounter(); refreshConversations(); + buildSummaries(); Serial.printf("[MSGSTORE] %d conversations found, receive counter=%lu\n", (int)_conversations.size(), (unsigned long)_nextReceiveCounter); return true; @@ -305,6 +306,19 @@ bool MessageStore::saveMessage(const LXMFMessage& msg) { if (sdOk) enforceSDLimit(peerHex); if (flashOk) enforceFlashLimit(peerHex); + // Update summary cache + { + auto& s = _summaries[peerHex]; + s.lastTimestamp = msg.timestamp; + s.lastIncoming = msg.incoming; + std::string prefix = msg.incoming ? "Them: " : "You: "; + std::string content = msg.content; + if (content.size() > 15) content = content.substr(0, 15) + "..."; + s.lastPreview = prefix + content; + s.totalCount++; + if (msg.incoming && !msg.read) s.unreadCount++; + } + return sdOk || flashOk; } @@ -433,35 +447,54 @@ bool MessageStore::deleteConversation(const std::string& peerHex) { _conversations.erase( std::remove(_conversations.begin(), _conversations.end(), peerHex), _conversations.end()); + _summaries.erase(peerHex); return true; } void MessageStore::markConversationRead(const std::string& peerHex) { auto markInDir = [&](auto openFn, auto writeFn, const String& dir) { + // Collect only incoming (_i.json) filenames + std::vector incomingFiles; File d = openFn(dir.c_str()); if (!d || !d.isDirectory()) return; File entry = d.openNextFile(); while (entry) { if (!entry.isDirectory() && isJsonFile(entry.name())) { - size_t size = entry.size(); - if (size > 0 && size < 4096) { - String json = entry.readString(); - JsonDocument doc; - if (!deserializeJson(doc, json)) { - bool incoming = doc["incoming"] | false; - bool isRead = doc["read"] | false; - if (incoming && !isRead) { - doc["read"] = true; - String updated; - serializeJson(doc, updated); - String path = dir + "/" + entry.name(); - writeFn(path.c_str(), updated); - } - } + String name = entry.name(); + int len = name.length(); + // Check for _i.json suffix (incoming) + if (len >= 7 && name[len - 6] == 'i') { + incomingFiles.push_back(name); } } entry = d.openNextFile(); } + + // Sort descending (newest first) to stop early at first already-read + std::sort(incomingFiles.begin(), incomingFiles.end(), + [](const String& a, const String& b) { return a > b; }); + + for (const auto& fname : incomingFiles) { + String path = dir + "/" + fname; + // Read file via the appropriate storage + String json; + File f = openFn(path.c_str()); + if (f && !f.isDirectory()) { + size_t size = f.size(); + if (size > 0 && size < 4096) json = f.readString(); + f.close(); + } + if (json.length() == 0) continue; + + JsonDocument doc; + if (deserializeJson(doc, json)) continue; + bool isRead = doc["read"] | false; + if (isRead) break; // all older must be read too + doc["read"] = true; + String updated; + serializeJson(doc, updated); + writeFn(path.c_str(), updated); + } }; if (_sd && _sd->isReady()) { @@ -477,6 +510,117 @@ void MessageStore::markConversationRead(const std::string& peerHex) { [&](const char* p, const String& d) { _flash->writeString(p, d); return true; }, dir); } + + _summaries[peerHex].unreadCount = 0; +} + +void MessageStore::buildSummaries() { + _summaries.clear(); + + for (const auto& peerHex : _conversations) { + ConversationSummary summary; + + // Collect filenames from the conversation directory + std::vector files; + auto collectFiles = [&](File& d) { + File entry = d.openNextFile(); + while (entry) { + if (!entry.isDirectory() && isJsonFile(entry.name())) { + files.push_back(entry.name()); + } + entry = d.openNextFile(); + } + }; + + bool loadedFromSD = false; + if (_sd && _sd->isReady()) { + String sdDir = sdConversationDir(peerHex); + File d = _sd->openDir(sdDir.c_str()); + if (d && d.isDirectory()) { + collectFiles(d); + loadedFromSD = true; + } + } + if (!loadedFromSD && _flash) { + String dir = conversationDir(peerHex); + File d = LittleFS.open(dir); + if (d && d.isDirectory()) { + collectFiles(d); + } + } + + summary.totalCount = (int)files.size(); + + if (files.empty()) { + _summaries[peerHex] = summary; + continue; + } + + // Sort filenames — highest counter = most recent (last element) + std::sort(files.begin(), files.end()); + + // Read the last file for preview/timestamp + String lastFilename = files.back(); + String basePath = loadedFromSD ? sdConversationDir(peerHex) : conversationDir(peerHex); + String lastPath = basePath + "/" + lastFilename; + + auto readJsonFile = [&](const String& path) -> String { + if (loadedFromSD && _sd && _sd->isReady()) { + return _sd->readString(path.c_str()); + } else if (_flash) { + return _flash->readString(path.c_str()); + } + return String(""); + }; + + String json = readJsonFile(lastPath); + if (json.length() > 0) { + JsonDocument doc; + if (!deserializeJson(doc, json)) { + summary.lastTimestamp = doc["ts"] | 0.0; + std::string content = doc["content"] | ""; + summary.lastIncoming = doc["incoming"] | false; + std::string prefix = summary.lastIncoming ? "Them: " : "You: "; + if (content.size() > 15) content = content.substr(0, 15) + "..."; + summary.lastPreview = prefix + content; + } + } + + // Count unreads: scan _i.json files (incoming), newest first, stop at first already-read + int unread = 0; + for (int i = (int)files.size() - 1; i >= 0; i--) { + const String& fname = files[i]; + // Only check incoming files (suffix _i.json) + int flen = fname.length(); + if (flen < 7 || fname[flen - 6] != 'i') continue; // not _i.json + + String fpath = basePath + "/" + fname; + String fjson = readJsonFile(fpath); + if (fjson.length() > 0) { + JsonDocument fdoc; + if (!deserializeJson(fdoc, fjson)) { + bool isRead = fdoc["read"] | false; + if (isRead) break; // all older messages must be read too + unread++; + } + } + } + summary.unreadCount = unread; + _summaries[peerHex] = summary; + } + + Serial.printf("[MSGSTORE] Built summaries for %d conversations\n", (int)_summaries.size()); +} + +const ConversationSummary* MessageStore::getSummary(const std::string& peerHex) const { + auto it = _summaries.find(peerHex); + return (it != _summaries.end()) ? &it->second : nullptr; +} + +int MessageStore::totalUnreadCount() const { + int total = 0; + for (const auto& kv : _summaries) total += kv.second.unreadCount; + return total; } String MessageStore::conversationDir(const std::string& peerHex) const { diff --git a/src/storage/MessageStore.h b/src/storage/MessageStore.h index 3c686f8..0172ecd 100644 --- a/src/storage/MessageStore.h +++ b/src/storage/MessageStore.h @@ -8,6 +8,14 @@ #include #include +struct ConversationSummary { + double lastTimestamp = 0; + std::string lastPreview; // first ~20 chars of last message + bool lastIncoming = false; + int unreadCount = 0; + int totalCount = 0; +}; + class MessageStore { public: bool begin(FlashStore* flash, SDStore* sd = nullptr); @@ -20,6 +28,9 @@ public: bool deleteConversation(const std::string& peerHex); void markConversationRead(const std::string& peerHex); + const ConversationSummary* getSummary(const std::string& peerHex) const; + int totalUnreadCount() const; + private: String conversationDir(const std::string& peerHex) const; String sdConversationDir(const std::string& peerHex) const; @@ -28,9 +39,11 @@ private: void migrateFlashToSD(); void migrateTruncatedDirs(); void initReceiveCounter(); + void buildSummaries(); FlashStore* _flash = nullptr; SDStore* _sd = nullptr; std::vector _conversations; + std::map _summaries; uint32_t _nextReceiveCounter = 0; }; diff --git a/src/storage/SDStore.cpp b/src/storage/SDStore.cpp index 301fd24..b815133 100644 --- a/src/storage/SDStore.cpp +++ b/src/storage/SDStore.cpp @@ -188,6 +188,21 @@ void SDStore::wipeDir(const char* path) { dir.close(); } +bool SDStore::hasExistingData() { + if (!_ready) return false; + if (SD.exists("/ratputer/config.json")) return true; + if (SD.exists("/ratputer/identity/identity")) return true; + File dir = SD.open("/ratputer/messages"); + if (dir && dir.isDirectory()) { + File entry = dir.openNextFile(); + bool found = (bool)entry; + if (entry) entry.close(); + dir.close(); + if (found) return true; + } + return false; +} + bool SDStore::formatForRatputer() { if (!_ready) return false; Serial.println("[SD] Creating Ratputer directory structure..."); diff --git a/src/storage/SDStore.h b/src/storage/SDStore.h index add4d3f..f744fe5 100644 --- a/src/storage/SDStore.h +++ b/src/storage/SDStore.h @@ -27,6 +27,7 @@ public: bool formatForRatputer(); bool wipeRatputer(); + bool hasExistingData(); private: void wipeDir(const char* path); diff --git a/src/ui/screens/LvDataCleanScreen.cpp b/src/ui/screens/LvDataCleanScreen.cpp new file mode 100644 index 0000000..16d6765 --- /dev/null +++ b/src/ui/screens/LvDataCleanScreen.cpp @@ -0,0 +1,115 @@ +#include "LvDataCleanScreen.h" +#include "ui/Theme.h" +#include "ui/LvTheme.h" +#include "config/Config.h" + +void LvDataCleanScreen::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); + + // Title: "RATSPEAK" + lv_obj_t* title = lv_label_create(parent); + lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(title, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(title, "RATSPEAK"); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 20); + + // Subtitle + lv_obj_t* sub = lv_label_create(parent); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(sub, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(sub, "ratspeak.org"); + lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 42); + + // Message + lv_obj_t* msg = lv_label_create(parent); + lv_obj_set_style_text_font(msg, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(msg, lv_color_hex(Theme::SECONDARY), 0); + lv_label_set_text(msg, "Old data found on SD card."); + lv_obj_align(msg, LV_ALIGN_TOP_MID, 0, 75); + + // Prompt + lv_obj_t* prompt = lv_label_create(parent); + lv_obj_set_style_text_font(prompt, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(prompt, lv_color_hex(Theme::SECONDARY), 0); + lv_label_set_text(prompt, "Remove old data and start fresh?"); + lv_obj_align(prompt, LV_ALIGN_TOP_MID, 0, 95); + + // Yes label + _yesLabel = lv_label_create(parent); + lv_obj_set_style_text_font(_yesLabel, &lv_font_montserrat_14, 0); + lv_label_set_text(_yesLabel, "[ Yes ]"); + lv_obj_align(_yesLabel, LV_ALIGN_TOP_MID, -50, 135); + + // No label + _noLabel = lv_label_create(parent); + lv_obj_set_style_text_font(_noLabel, &lv_font_montserrat_14, 0); + lv_label_set_text(_noLabel, "[ No ]"); + lv_obj_align(_noLabel, LV_ALIGN_TOP_MID, 50, 135); + + updateSelection(); + + // Hint + _hintLabel = lv_label_create(parent); + lv_obj_set_style_text_font(_hintLabel, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(_hintLabel, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(_hintLabel, "[] Select [Enter] OK"); + lv_obj_align(_hintLabel, LV_ALIGN_TOP_MID, 0, 165); + + // Status label (hidden until showStatus is called) + _statusLabel = lv_label_create(parent); + lv_obj_set_style_text_font(_statusLabel, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(_statusLabel, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(_statusLabel, ""); + lv_obj_align(_statusLabel, LV_ALIGN_TOP_MID, 0, 140); + lv_obj_add_flag(_statusLabel, LV_OBJ_FLAG_HIDDEN); + + // Version + lv_obj_t* ver = lv_label_create(parent); + lv_obj_set_style_text_font(ver, &lv_font_montserrat_10, 0); + lv_obj_set_style_text_color(ver, lv_color_hex(Theme::MUTED), 0); + char verBuf[32]; + snprintf(verBuf, sizeof(verBuf), "v%s", RATDECK_VERSION_STRING); + lv_label_set_text(ver, verBuf); + lv_obj_align(ver, LV_ALIGN_BOTTOM_MID, 0, -10); +} + +void LvDataCleanScreen::updateSelection() { + if (_selectedYes) { + lv_obj_set_style_text_color(_yesLabel, lv_color_hex(Theme::ACCENT), 0); + lv_obj_set_style_text_color(_noLabel, lv_color_hex(Theme::MUTED), 0); + } else { + lv_obj_set_style_text_color(_yesLabel, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(_noLabel, lv_color_hex(Theme::ACCENT), 0); + } +} + +void LvDataCleanScreen::showStatus(const char* msg) { + if (_yesLabel) lv_obj_add_flag(_yesLabel, LV_OBJ_FLAG_HIDDEN); + if (_noLabel) lv_obj_add_flag(_noLabel, LV_OBJ_FLAG_HIDDEN); + if (_hintLabel) lv_obj_add_flag(_hintLabel, LV_OBJ_FLAG_HIDDEN); + if (_statusLabel) { + lv_label_set_text(_statusLabel, msg); + lv_obj_clear_flag(_statusLabel, LV_OBJ_FLAG_HIDDEN); + } + lv_timer_handler(); +} + +bool LvDataCleanScreen::handleKey(const KeyEvent& event) { + if (event.left) { + _selectedYes = true; + updateSelection(); + return true; + } + if (event.right) { + _selectedYes = false; + updateSelection(); + return true; + } + if (event.enter || event.character == '\n' || event.character == '\r') { + if (_doneCb) _doneCb(_selectedYes); + return true; + } + return true; // Consume all keys +} diff --git a/src/ui/screens/LvDataCleanScreen.h b/src/ui/screens/LvDataCleanScreen.h new file mode 100644 index 0000000..2b035eb --- /dev/null +++ b/src/ui/screens/LvDataCleanScreen.h @@ -0,0 +1,26 @@ +#pragma once + +#include "ui/UIManager.h" +#include + +class LvDataCleanScreen : public LvScreen { +public: + void createUI(lv_obj_t* parent) override; + bool handleKey(const KeyEvent& event) override; + const char* title() const override { return "Setup"; } + + // Callback: true = user chose Yes (wipe), false = user chose No (skip) + void setDoneCallback(std::function cb) { _doneCb = cb; } + + // Replace buttons with a status message (for showing progress) + void showStatus(const char* msg); + +private: + lv_obj_t* _yesLabel = nullptr; + lv_obj_t* _noLabel = nullptr; + lv_obj_t* _hintLabel = nullptr; + lv_obj_t* _statusLabel = nullptr; + bool _selectedYes = true; + std::function _doneCb; + void updateSelection(); +}; diff --git a/src/ui/screens/LvMessageView.cpp b/src/ui/screens/LvMessageView.cpp index 76f9d45..52c665b 100644 --- a/src/ui/screens/LvMessageView.cpp +++ b/src/ui/screens/LvMessageView.cpp @@ -114,11 +114,12 @@ void LvMessageView::onEnter() { // Register status callback for live updates std::string peer = _peerHex; _lxmf->setStatusCallback([this, peer](const std::string& peerHex, double, LXMFStatus) { - if (peerHex == peer) _lastRefreshMs = 0; // Force rebuild on next refreshUI + if (peerHex == peer) _statusDirty = true; }); } _lastMsgCount = -1; _lastRefreshMs = 0; + _statusDirty = false; _inputText.clear(); if (_lblHeader) { @@ -129,6 +130,7 @@ void LvMessageView::onEnter() { if (_textarea) { lv_textarea_set_text(_textarea, ""); } + _cachedMsgs.clear(); // Force fresh load rebuildMessages(); } @@ -141,28 +143,38 @@ void LvMessageView::onExit() { void LvMessageView::refreshUI() { if (!_lxmf) return; unsigned long now = millis(); - if (now - _lastRefreshMs >= REFRESH_INTERVAL_MS) { - auto newMsgs = _lxmf->getMessages(_peerHex); - _lastRefreshMs = now; - // Rebuild if count changed or any status changed - bool changed = (newMsgs.size() != _cachedMsgs.size()); - if (!changed) { - for (size_t i = 0; i < newMsgs.size(); i++) { - if (newMsgs[i].status != _cachedMsgs[i].status) { changed = true; break; } - } - } - if (changed) { - _cachedMsgs = newMsgs; - _lastMsgCount = (int)_cachedMsgs.size(); - rebuildMessages(); + if (now - _lastRefreshMs < REFRESH_INTERVAL_MS) return; + _lastRefreshMs = now; + + // Quick check: if summary count unchanged and no status callback fired, skip disk load + auto* summary = _lxmf->getConversationSummary(_peerHex); + if (summary && !_statusDirty && summary->totalCount == (int)_cachedMsgs.size()) return; + + _statusDirty = false; + auto newMsgs = _lxmf->getMessages(_peerHex); + + // Rebuild if count changed or any status changed + bool changed = (newMsgs.size() != _cachedMsgs.size()); + if (!changed) { + for (size_t i = 0; i < newMsgs.size(); i++) { + if (newMsgs[i].status != _cachedMsgs[i].status) { changed = true; break; } } } + if (changed) { + _cachedMsgs = std::move(newMsgs); + _lastMsgCount = (int)_cachedMsgs.size(); + rebuildMessages(); + } } void LvMessageView::rebuildMessages() { if (!_lxmf || !_msgScroll) return; - _cachedMsgs = _lxmf->getMessages(_peerHex); + // 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); @@ -269,6 +281,7 @@ void LvMessageView::sendCurrentMessage() { _inputText.clear(); if (_textarea) lv_textarea_set_text(_textarea, ""); + _cachedMsgs.clear(); // Force fresh load in rebuildMessages rebuildMessages(); } diff --git a/src/ui/screens/LvMessageView.h b/src/ui/screens/LvMessageView.h index e8c256d..33a8f9f 100644 --- a/src/ui/screens/LvMessageView.h +++ b/src/ui/screens/LvMessageView.h @@ -42,6 +42,7 @@ private: int _lastMsgCount = -1; unsigned long _lastRefreshMs = 0; std::vector _cachedMsgs; + bool _statusDirty = false; // LVGL widgets lv_obj_t* _header = nullptr; diff --git a/src/ui/screens/LvMessagesScreen.cpp b/src/ui/screens/LvMessagesScreen.cpp index ab387fc..f09216c 100644 --- a/src/ui/screens/LvMessagesScreen.cpp +++ b/src/ui/screens/LvMessagesScreen.cpp @@ -96,15 +96,11 @@ void LvMessagesScreen::rebuildList() { for (int i = 0; i < count; i++) { ConvInfo ci; ci.peerHex = convs[i]; - ci.hasUnread = _lxmf->unreadCount(ci.peerHex) > 0; - auto msgs = _lxmf->getMessages(ci.peerHex); - if (!msgs.empty()) { - const auto& last = msgs.back(); - ci.lastTs = last.timestamp; - std::string prefix = last.incoming ? "Them: " : "You: "; - std::string content = last.content; - if (content.size() > 15) content = content.substr(0, 15) + "..."; - ci.preview = prefix + content; + auto* s = _lxmf->getConversationSummary(ci.peerHex); + if (s) { + ci.lastTs = s->lastTimestamp; + ci.preview = s->lastPreview; + ci.hasUnread = s->unreadCount > 0; } sorted.push_back(ci); }