diff --git a/src/MessageStore.cpp b/src/MessageStore.cpp new file mode 100644 index 000000000..3c0eb2691 --- /dev/null +++ b/src/MessageStore.cpp @@ -0,0 +1,214 @@ +#include "MessageStore.h" +#include "FSCommon.h" +#include "NodeDB.h" // for nodeDB->getNodeNum() +#include "SPILock.h" +#include "SafeFile.h" +#include "configuration.h" // for millis() + +MessageStore::MessageStore(const std::string &label) +{ + filename = "/Messages_" + label + ".msgs"; +} + +// === Live message handling (RAM only) === +void MessageStore::addLiveMessage(const StoredMessage &msg) +{ + if (liveMessages.size() >= MAX_MESSAGES_SAVED) { + liveMessages.pop_front(); // keep only most recent N + } + liveMessages.push_back(msg); +} + +// === Persistence queue (used only on shutdown/reboot) === +void MessageStore::addMessage(const StoredMessage &msg) +{ + if (messages.size() >= MAX_MESSAGES_SAVED) { + messages.pop_front(); + } + messages.push_back(msg); +} + +void MessageStore::addFromPacket(const meshtastic_MeshPacket &packet) +{ + StoredMessage sm; + + sm.timestamp = packet.rx_time ? packet.rx_time : (millis() / 1000); + sm.sender = packet.from; + sm.channelIndex = packet.channel; + sm.text = std::string(reinterpret_cast(packet.decoded.payload.bytes)); + + // Classification logic + if (packet.to == NODENUM_BROADCAST || packet.decoded.dest == NODENUM_BROADCAST) { + sm.dest = NODENUM_BROADCAST; + sm.type = MessageType::BROADCAST; + } else if (packet.to == nodeDB->getNodeNum()) { + sm.dest = nodeDB->getNodeNum(); // DM to us + sm.type = MessageType::DM_TO_US; + } else { + sm.dest = NODENUM_BROADCAST; // fallback + sm.type = MessageType::BROADCAST; + } + + addLiveMessage(sm); +} + +// === Outgoing/manual message === +void MessageStore::addFromString(uint32_t sender, uint8_t channelIndex, const std::string &text) +{ + StoredMessage sm; + sm.timestamp = millis() / 1000; + sm.sender = sender; + sm.channelIndex = channelIndex; + sm.text = text; + + // Default manual adds to broadcast + sm.dest = NODENUM_BROADCAST; + sm.type = MessageType::BROADCAST; + + addLiveMessage(sm); +} + +// === Save RAM queue to flash (called on shutdown) === +void MessageStore::saveToFlash() +{ +#ifdef FSCom + // Copy live RAM buffer into persistence queue + messages = liveMessages; + + spiLock->lock(); + FSCom.mkdir("/"); // ensure root exists + spiLock->unlock(); + + SafeFile f(filename.c_str(), false); + + spiLock->lock(); + uint8_t count = messages.size(); + f.write(&count, 1); + + for (uint8_t i = 0; i < messages.size() && i < MAX_MESSAGES_SAVED; i++) { + const StoredMessage &m = messages.at(i); + f.write((uint8_t *)&m.timestamp, sizeof(m.timestamp)); + f.write((uint8_t *)&m.sender, sizeof(m.sender)); + f.write((uint8_t *)&m.channelIndex, sizeof(m.channelIndex)); + f.write((uint8_t *)&m.dest, sizeof(m.dest)); + f.write((uint8_t *)m.text.c_str(), std::min(MAX_MESSAGE_SIZE, (uint32_t)m.text.size())); + f.write('\0'); // null terminator + } + spiLock->unlock(); + + f.close(); +#else + // Filesystem not available, skip persistence +#endif +} + +// === Load persisted messages into RAM (called at boot) === +void MessageStore::loadFromFlash() +{ + messages.clear(); + liveMessages.clear(); +#ifdef FSCom + concurrency::LockGuard guard(spiLock); + + if (!FSCom.exists(filename.c_str())) + return; + auto f = FSCom.open(filename.c_str(), FILE_O_READ); + if (!f) + return; + + uint8_t count = 0; + f.readBytes((char *)&count, 1); + + for (uint8_t i = 0; i < count && i < MAX_MESSAGES_SAVED; i++) { + StoredMessage m; + f.readBytes((char *)&m.timestamp, sizeof(m.timestamp)); + f.readBytes((char *)&m.sender, sizeof(m.sender)); + f.readBytes((char *)&m.channelIndex, sizeof(m.channelIndex)); + f.readBytes((char *)&m.dest, sizeof(m.dest)); + + char c; + while (m.text.size() < MAX_MESSAGE_SIZE) { + if (f.readBytes(&c, 1) <= 0) + break; + if (c == '\0') + break; + m.text.push_back(c); + } + + // Recompute type from dest + if (m.dest == NODENUM_BROADCAST) { + m.type = MessageType::BROADCAST; + } else { + m.type = MessageType::DM_TO_US; + } + + messages.push_back(m); + liveMessages.push_back(m); // restore into RAM buffer + } + f.close(); +#endif +} + +// === Clear all messages (RAM + persisted queue) === +void MessageStore::clearAllMessages() +{ + liveMessages.clear(); + messages.clear(); + +#ifdef FSCom + SafeFile f(filename.c_str(), false); + uint8_t count = 0; + f.write(&count, 1); // write "0 messages" + f.close(); +#endif +} + +// === Dismiss oldest message (RAM + persisted queue) === +void MessageStore::dismissOldestMessage() +{ + if (!liveMessages.empty()) { + liveMessages.pop_front(); + } + if (!messages.empty()) { + messages.pop_front(); + } + saveToFlash(); +} + +// === Dismiss newest message (RAM + persisted queue) === +void MessageStore::dismissNewestMessage() +{ + if (!liveMessages.empty()) { + liveMessages.pop_back(); + } + if (!messages.empty()) { + messages.pop_back(); + } + saveToFlash(); +} + +// === Helper filters for future use === +std::deque MessageStore::getChannelMessages(uint8_t channel) const +{ + std::deque result; + for (const auto &m : liveMessages) { + if (m.type == MessageType::BROADCAST && m.channelIndex == channel) { + result.push_back(m); + } + } + return result; +} + +std::deque MessageStore::getDirectMessages() const +{ + std::deque result; + for (const auto &m : liveMessages) { + if (m.type == MessageType::DM_TO_US) { + result.push_back(m); + } + } + return result; +} + +// === Global definition === +MessageStore messageStore("default"); \ No newline at end of file diff --git a/src/MessageStore.h b/src/MessageStore.h new file mode 100644 index 000000000..0cfb5f980 --- /dev/null +++ b/src/MessageStore.h @@ -0,0 +1,75 @@ +#pragma once +#include "mesh/generated/meshtastic/mesh.pb.h" // for meshtastic_MeshPacket +#include +#include +#include + +// Max number of messages we’ll keep in history +constexpr size_t MAX_MESSAGES_SAVED = 20; +constexpr size_t MAX_MESSAGE_SIZE = 220; // safe bound for text payload + +// Explicit message classification +enum class MessageType : uint8_t { BROADCAST = 0, DM_TO_US = 1 }; + +struct StoredMessage { + uint32_t timestamp; // When message was created (secs since boot/RTC) + uint32_t sender; // NodeNum of sender + uint8_t channelIndex; // Channel index used + std::string text; // UTF-8 text payload + + // Destination node. + // 0xffffffff (NODENUM_BROADCAST) means broadcast, + // otherwise this is the NodeNum of the DM recipient. + uint32_t dest; + + // Explicit classification (derived from dest when loading old messages) + MessageType type; +}; + +class MessageStore +{ + public: + explicit MessageStore(const std::string &label); + + // === Live RAM methods (always current, used by UI and runtime) === + void addLiveMessage(const StoredMessage &msg); + const std::deque &getLiveMessages() const { return liveMessages; } + + // === Persistence methods (used only on boot/shutdown) === + void addMessage(const StoredMessage &msg); // Add to persistence queue + void addFromPacket(const meshtastic_MeshPacket &mp); // Incoming → RAM only + void addFromString(uint32_t sender, uint8_t channelIndex, + const std::string &text); // Outgoing/manual → RAM only + void saveToFlash(); // Persist RAM → flash + void loadFromFlash(); // Restore flash → RAM + + // === Clear all messages (RAM + persisted queue) === + void clearAllMessages(); + + // === Dismiss helpers === + void dismissOldestMessage(); // Drop front() from history + void dismissNewestMessage(); // Drop back() from history (useful for current screen) + + // === Unified accessor (for UI code, defaults to RAM buffer) === + const std::deque &getMessages() const { return liveMessages; } + + // Optional: direct access to persisted copy (mainly for debugging/inspection) + const std::deque &getPersistedMessages() const { return messages; } + + // === Helper filters for future use === + std::deque getChannelMessages(uint8_t channel) const; + std::deque getDirectMessages() const; + std::deque getConversationWith(uint32_t peer) const; + + private: + // RAM buffer (always current, main source for UI) + std::deque liveMessages; + + // Persisted storage (only updated on shutdown, loaded at boot) + std::deque messages; + + std::string filename; +}; + +// === Global instance (defined in MessageStore.cpp) === +extern MessageStore messageStore; diff --git a/src/Power.cpp b/src/Power.cpp index 7de82b8d6..1cd38aaa7 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -11,6 +11,7 @@ * For more information, see: https://meshtastic.org/ */ #include "power.h" +#include "MessageStore.h" #include "NodeDB.h" #include "PowerFSM.h" #include "Throttle.h" @@ -757,6 +758,8 @@ void Power::shutdown() playShutdownMelody(); #endif nodeDB->saveToDisk(); + // === Save live messages before powering off === + messageStore.saveToFlash(); #if defined(ARCH_NRF52) || defined(ARCH_ESP32) || defined(ARCH_RP2040) #ifdef PIN_LED1 diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 204fbd451..35ab4b34f 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -46,6 +46,7 @@ along with this program. If not, see . #endif #include "FSCommon.h" #include "MeshService.h" +#include "MessageStore.h" #include "RadioLibInterface.h" #include "error.h" #include "gps/GeoCoord.h" @@ -64,6 +65,7 @@ along with this program. If not, see . #include "modules/WaypointModule.h" #include "sleep.h" #include "target_specific.h" +extern MessageStore messageStore; using graphics::Emote; using graphics::emotes; @@ -651,6 +653,10 @@ void Screen::setup() if (inputBroker) inputObserver.observe(inputBroker); + // === Load persisted messages into RAM === + messageStore.loadFromFlash(); + LOG_INFO("MessageStore loaded from flash"); + // === Notify modules that support UI events === MeshModule::observeUIEvents(&uiFrameEventObserver); } @@ -1414,21 +1420,41 @@ int Screen::handleStatusUpdate(const meshtastic::Status *arg) return 0; } - // Handles when message is received; will jump to text message frame. int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) { if (showingNormalScreen) { if (packet->from == 0) { - // Outgoing message (likely sent from phone) + // === Outgoing message (likely sent from phone) === devicestate.has_rx_text_message = false; memset(&devicestate.rx_text_message, 0, sizeof(devicestate.rx_text_message)); hiddenFrames.textMessage = true; hasUnreadMessage = false; // Clear unread state when user replies setFrames(FOCUS_PRESERVE); // Stay on same frame, silently update frame list + + // === Save our own outgoing message to live RAM === + StoredMessage sm; + sm.timestamp = millis() / 1000; + sm.sender = nodeDB->getNodeNum(); // us + sm.channelIndex = packet->channel; + sm.text = std::string(reinterpret_cast(packet->decoded.payload.bytes)); + + // ✅ Distinguish between broadcast vs DM to us + if (packet->decoded.dest == NODENUM_BROADCAST) { + sm.dest = NODENUM_BROADCAST; + sm.type = MessageType::BROADCAST; + } else { + sm.dest = nodeDB->getNodeNum(); + sm.type = MessageType::DM_TO_US; + } + + messageStore.addLiveMessage(sm); // RAM only (flash updated at shutdown) + + // 🔹 Reset scroll so newest message starts from the top + graphics::MessageRenderer::resetScrollState(); } else { - // Incoming message + // === Incoming message === devicestate.has_rx_text_message = true; // Needed to include the message frame hasUnreadMessage = true; // Enables mail icon in the header setFrames(FOCUS_PRESERVE); // Refresh frame list without switching view @@ -1438,12 +1464,12 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) setOn(true); // Wake up the screen first forceDisplay(); // Forces screen redraw } + // === Prepare banner content === const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet->from); const char *longName = (node && node->has_user) ? node->user.long_name : nullptr; const char *msgRaw = reinterpret_cast(packet->decoded.payload.bytes); - char banner[256]; // Check for bell character in message to determine alert type @@ -1468,11 +1494,11 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) #else snprintf(banner, sizeof(banner), "New Message from\n%s", longName); #endif - } else { strcpy(banner, "New Message"); } } + #if defined(M5STACK_UNITC6L) screen->setOn(true); screen->showSimpleBanner(banner, 1500); @@ -1480,6 +1506,27 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) #else screen->showSimpleBanner(banner, 3000); #endif + + // === Save this incoming message to live RAM === + StoredMessage sm; + sm.timestamp = packet->rx_time ? packet->rx_time : (millis() / 1000); + sm.sender = packet->from; + sm.channelIndex = packet->channel; + sm.text = std::string(reinterpret_cast(packet->decoded.payload.bytes)); + + // ✅ Distinguish between broadcast vs DM to us + if (packet->to == NODENUM_BROADCAST || packet->decoded.dest == NODENUM_BROADCAST) { + sm.dest = NODENUM_BROADCAST; + sm.type = MessageType::BROADCAST; + } else { + sm.dest = nodeDB->getNodeNum(); // DM to us + sm.type = MessageType::DM_TO_US; + } + + messageStore.addLiveMessage(sm); // RAM only (flash updated at shutdown) + + // 🔹 Reset scroll so newest message starts from the top + graphics::MessageRenderer::resetScrollState(); } } diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 9be0e0b02..b477165df 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -5,6 +5,7 @@ #include "MenuHandler.h" #include "MeshRadio.h" #include "MeshService.h" +#include "MessageStore.h" #include "NodeDB.h" #include "buzz.h" #include "graphics/Screen.h" @@ -348,14 +349,14 @@ void menuHandler::clockMenu() void menuHandler::messageResponseMenu() { - enum optionsNumbers { Back = 0, Dismiss = 1, Preset = 2, Freetext = 3, Aloud = 4, enumEnd = 5 }; + enum optionsNumbers { Back = 0, DismissAll = 1, DismissOldest = 2, Preset = 3, Freetext = 4, Aloud = 5, enumEnd = 6 }; #if defined(M5STACK_UNITC6L) - static const char *optionsArray[enumEnd] = {"Back", "Dismiss", "Reply Preset"}; + static const char *optionsArray[enumEnd] = {"Back", "Dismiss All", "Dismiss Oldest", "Reply Preset"}; #else - static const char *optionsArray[enumEnd] = {"Back", "Dismiss", "Reply via Preset"}; + static const char *optionsArray[enumEnd] = {"Back", "Dismiss All", "Dismiss Oldest", "Reply via Preset"}; #endif - static int optionsEnumArray[enumEnd] = {Back, Dismiss, Preset}; - int options = 3; + static int optionsEnumArray[enumEnd] = {Back, DismissAll, DismissOldest, Preset}; + int options = 4; if (kb_found) { optionsArray[options] = "Reply via Freetext"; @@ -376,8 +377,12 @@ void menuHandler::messageResponseMenu() bannerOptions.optionsEnumPtr = optionsEnumArray; bannerOptions.optionsCount = options; bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Dismiss) { - screen->hideCurrentFrame(); + if (selected == DismissAll) { + // Remove all messages + messageStore.clearAllMessages(); + } else if (selected == DismissOldest) { + // Remove only the oldest message + messageStore.dismissOldestMessage(); } else if (selected == Preset) { if (devicestate.rx_text_message.to == NODENUM_BROADCAST) { cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, devicestate.rx_text_message.channel); @@ -1043,6 +1048,7 @@ void menuHandler::rebootMenu() if (selected == 1) { IF_SCREEN(screen->showSimpleBanner("Rebooting...", 0)); nodeDB->saveToDisk(); + messageStore.saveToFlash(); rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; } else { menuQueue = power_menu; diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 6971826de..42394a414 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -26,6 +26,7 @@ along with this program. If not, see . #include "MessageRenderer.h" // Core includes +#include "MessageStore.h" #include "NodeDB.h" #include "configuration.h" #include "gps/RTC.h" @@ -148,7 +149,8 @@ void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string // Render the emote (if found) if (matchedEmote && i == nextEmotePos) { - int iconY = fontMidline - matchedEmote->height / 2 - 1; + // Center vertically — padding handled in calculateLineHeights + int iconY = fontMidline - matchedEmote->height / 2; display->drawXbm(cursorX, iconY, matchedEmote->width, matchedEmote->height, matchedEmote->bitmap); cursorX += matchedEmote->width + 1; i += emojiLen; @@ -170,13 +172,39 @@ void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string } } +// === Scroll state (file scope so we can reset on new message) === +float scrollY = 0.0f; +uint32_t lastTime = 0; +uint32_t scrollStartDelay = 0; +uint32_t pauseStart = 0; +bool waitingToReset = false; +bool scrollStarted = false; +static bool didReset = false; // <-- add here + +// === Reset scroll state when new messages arrive === +void resetScrollState() +{ + scrollY = 0.0f; + scrollStarted = false; + waitingToReset = false; + scrollStartDelay = millis(); + lastTime = millis(); + + didReset = false; // <-- now valid +} + void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { + if (!didReset) { + resetScrollState(); + didReset = true; + } + // Clear the unread message indicator when viewing the message hasUnreadMessage = false; - const meshtastic_MeshPacket &mp = devicestate.rx_text_message; - const char *msg = reinterpret_cast(mp.decoded.payload.bytes); + // === Use live RAM buffer directly (boot handles flash load) === + const auto &msgs = messageStore.getMessages(); display->clear(); display->setTextAlignment(TEXT_ALIGN_LEFT); @@ -192,20 +220,14 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 const int scrollBottom = SCREEN_HEIGHT - navHeight; const int usableHeight = scrollBottom; const int textWidth = SCREEN_WIDTH; - #endif - bool isInverted = (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED); - bool isBold = config.display.heading_bold; // === Set Title const char *titleStr = "Messages"; - // Check if we have more than an empty message to show - char messageBuf[237]; - snprintf(messageBuf, sizeof(messageBuf), "%s", msg); - if (strlen(messageBuf) == 0) { - // === Header === + if (msgs.empty()) { graphics::drawCommonHeader(display, x, y, titleStr); + didReset = false; const char *messageString = "No messages"; int center_text = (SCREEN_WIDTH / 2) - (display->getStringWidth(messageString) / 2); #if defined(M5STACK_UNITC6L) @@ -216,176 +238,103 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 return; } - // === Header Construction === - meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp)); - char headerStr[80]; - const char *sender = "???"; + // === Build lines for all messages (newest first) === + std::vector allLines; + std::vector isMine; // track alignment + std::vector isHeader; // track header lines + + for (auto it = msgs.rbegin(); it != msgs.rend(); ++it) { + const auto &m = *it; + + // --- Build header line for this message --- + meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(m.sender); + const char *sender = "???"; #if defined(M5STACK_UNITC6L) - if (node && node->has_user) - sender = node->user.short_name; -#else - if (node && node->has_user) { - if (SCREEN_WIDTH >= 200 && strlen(node->user.long_name) > 0) { - sender = node->user.long_name; - } else { + if (node && node->has_user) sender = node->user.short_name; - } - } -#endif - uint32_t seconds = sinceReceived(&mp), minutes = seconds / 60, hours = minutes / 60, days = hours / 24; - uint8_t timestampHours, timestampMinutes; - int32_t daysAgo; - bool useTimestamp = deltaToTimestamp(seconds, ×tampHours, ×tampMinutes, &daysAgo); - - if (useTimestamp && minutes >= 15 && daysAgo == 0) { - std::string prefix = (daysAgo == 1 && SCREEN_WIDTH >= 200) ? "Yesterday" : "At"; - if (config.display.use_12h_clock) { - bool isPM = timestampHours >= 12; - timestampHours = timestampHours % 12; - if (timestampHours == 0) - timestampHours = 12; - snprintf(headerStr, sizeof(headerStr), "%s %d:%02d%s from %s", prefix.c_str(), timestampHours, timestampMinutes, - isPM ? "p" : "a", sender); - } else { - snprintf(headerStr, sizeof(headerStr), "%s %d:%02d from %s", prefix.c_str(), timestampHours, timestampMinutes, - sender); - } - } else { -#if defined(M5STACK_UNITC6L) - snprintf(headerStr, sizeof(headerStr), "%s from %s", UIRenderer::drawTimeDelta(days, hours, minutes, seconds).c_str(), - sender); #else - snprintf(headerStr, sizeof(headerStr), "%s ago from %s", UIRenderer::drawTimeDelta(days, hours, minutes, seconds).c_str(), - sender); -#endif - } -#if defined(M5STACK_UNITC6L) - graphics::drawCommonHeader(display, x, y, titleStr); - int headerY = getTextPositions(display)[1]; - display->drawString(x, headerY, headerStr); - for (int separatorX = 0; separatorX < SCREEN_WIDTH; separatorX += 2) { - display->setPixel(separatorX, fixedTopHeight - 1); - } - cachedLines.clear(); - std::string fullMsg(messageBuf); - std::string currentLine; - for (size_t i = 0; i < fullMsg.size();) { - unsigned char c = fullMsg[i]; - size_t charLen = 1; - if ((c & 0xE0) == 0xC0) - charLen = 2; - else if ((c & 0xF0) == 0xE0) - charLen = 3; - else if ((c & 0xF8) == 0xF0) - charLen = 4; - std::string nextChar = fullMsg.substr(i, charLen); - std::string testLine = currentLine + nextChar; - if (display->getStringWidth(testLine.c_str()) > windowWidth) { - cachedLines.push_back(currentLine); - currentLine = nextChar; - } else { - currentLine = testLine; - } - - i += charLen; - } - if (!currentLine.empty()) - cachedLines.push_back(currentLine); - cachedHeights = calculateLineHeights(cachedLines, emotes); - int yOffset = windowY; - int linesDrawn = 0; - for (size_t i = 0; i < cachedLines.size(); ++i) { - if (linesDrawn >= 2) - break; - int lineHeight = cachedHeights[i]; - if (yOffset + lineHeight > windowY + windowHeight) - break; - drawStringWithEmotes(display, windowX, yOffset, cachedLines[i], emotes, numEmotes); - yOffset += lineHeight; - linesDrawn++; - } - screen->forceDisplay(); -#else - uint32_t now = millis(); -#ifndef EXCLUDE_EMOJI - // === Bounce animation setup === - static uint32_t lastBounceTime = 0; - static int bounceY = 0; - const int bounceRange = 2; // Max pixels to bounce up/down - const int bounceInterval = 10; // How quickly to change bounce direction (ms) - - if (now - lastBounceTime >= bounceInterval) { - lastBounceTime = now; - bounceY = (bounceY + 1) % (bounceRange * 2); - } - for (int i = 0; i < numEmotes; ++i) { - const Emote &e = emotes[i]; - if (strcmp(msg, e.label) == 0) { - int headerY = getTextPositions(display)[1]; // same as scrolling header line - display->drawString(x + 3, headerY, headerStr); - if (isInverted && isBold) - display->drawString(x + 4, headerY, headerStr); - - // Draw separator (same as scroll version) - for (int separatorX = 1; separatorX <= (display->getStringWidth(headerStr) + 2); separatorX += 2) { - display->setPixel(separatorX, headerY + ((isHighResolution) ? 19 : 13)); + if (node && node->has_user) { + if (SCREEN_WIDTH >= 200 && strlen(node->user.long_name) > 0) { + sender = node->user.long_name; + } else { + sender = node->user.short_name; } + } +#endif - // Center the emote below the header line + separator + nav - int remainingHeight = SCREEN_HEIGHT - (headerY + FONT_HEIGHT_SMALL) - navHeight; - int emoteY = headerY + 6 + FONT_HEIGHT_SMALL + (remainingHeight - e.height) / 2 + bounceY - bounceRange; - display->drawXbm((SCREEN_WIDTH - e.width) / 2, emoteY, e.width, e.height, e.bitmap); + // If this is *our own* message, override sender to "Me" + bool mine = (m.sender == nodeDB->getNodeNum()); + if (mine) { + sender = "Me"; + } - // Draw header at the end to sort out overlapping elements - graphics::drawCommonHeader(display, x, y, titleStr); - return; + // === Channel / destination labeling === + char chanType[32]; + if (m.dest == NODENUM_BROADCAST) { + // Broadcast to a channel + snprintf(chanType, sizeof(chanType), "(Ch%d)", m.channelIndex + 1); + } else { + // Direct message (always to us if it shows up) + snprintf(chanType, sizeof(chanType), "(DM)"); + } + + // === Calculate how long ago === + uint32_t nowSecs = millis() / 1000; + uint32_t seconds = (nowSecs > m.timestamp) ? (nowSecs - m.timestamp) : 0; + + // Fallback if timestamp looks bogus (0 or way too large) + bool invalidTime = (m.timestamp == 0 || seconds > 315360000); // >10 years + + char timeBuf[16]; + if (invalidTime) { + snprintf(timeBuf, sizeof(timeBuf), "???"); + } else if (seconds < 60) { + snprintf(timeBuf, sizeof(timeBuf), "%us ago", seconds); + } else if (seconds < 3600) { + snprintf(timeBuf, sizeof(timeBuf), "%um ago", seconds / 60); + } else if (seconds < 86400) { + snprintf(timeBuf, sizeof(timeBuf), "%uh ago", seconds / 3600); + } else { + snprintf(timeBuf, sizeof(timeBuf), "%ud ago", seconds / 86400); + } + + char headerStr[96]; + if (mine) { + snprintf(headerStr, sizeof(headerStr), "me %s %s", timeBuf, chanType); + } else { + snprintf(headerStr, sizeof(headerStr), "%s from %s %s", timeBuf, sender, chanType); + } + + // Push header line + allLines.push_back(std::string(headerStr)); + isMine.push_back(mine); + isHeader.push_back(true); + + // --- Split message text into wrapped lines --- + std::vector wrapped = generateLines(display, "", m.text.c_str(), textWidth); + for (auto &ln : wrapped) { + allLines.push_back(ln); + isMine.push_back(mine); + isHeader.push_back(false); } } -#endif - // === Generate the cache key === - size_t currentKey = (size_t)mp.from; - currentKey ^= ((size_t)mp.to << 8); - currentKey ^= ((size_t)mp.rx_time << 16); - currentKey ^= ((size_t)mp.id << 24); - if (cachedKey != currentKey) { - LOG_INFO("Onscreen message scroll cache key needs updating: cachedKey=0x%0x, currentKey=0x%x", cachedKey, currentKey); + // === Cache lines and heights === + cachedLines = allLines; + cachedHeights = calculateLineHeights(cachedLines, emotes); - // Cache miss - regenerate lines and heights - cachedLines = generateLines(display, headerStr, messageBuf, textWidth); - cachedHeights = calculateLineHeights(cachedLines, emotes); - cachedKey = currentKey; - } else { - // Cache hit but update the header line with current time information - cachedLines[0] = std::string(headerStr); - // The header always has a fixed height since it doesn't contain emotes - // As per calculateLineHeights logic for lines without emotes: - cachedHeights[0] = FONT_HEIGHT_SMALL - 2; - if (cachedHeights[0] < 8) - cachedHeights[0] = 8; // minimum safety - } - - // === Scrolling logic === + // === Scrolling logic (unchanged) === + uint32_t now = millis(); int totalHeight = 0; - for (size_t i = 1; i < cachedHeights.size(); ++i) { + for (size_t i = 0; i < cachedHeights.size(); ++i) totalHeight += cachedHeights[i]; - } - int usableScrollHeight = usableHeight - cachedHeights[0]; // remove header height + int usableScrollHeight = usableHeight; int scrollStop = std::max(0, totalHeight - usableScrollHeight + cachedHeights.back()); - static float scrollY = 0.0f; - static uint32_t lastTime = 0, scrollStartDelay = 0, pauseStart = 0; - static bool waitingToReset = false, scrollStarted = false; - - // === Smooth scrolling adjustment === - // You can tweak this divisor to change how smooth it scrolls. - // Lower = smoother, but can feel slow. float delta = (now - lastTime) / 400.0f; lastTime = now; + const float scrollSpeed = 2.0f; - const float scrollSpeed = 2.0f; // pixels per second - - // Delay scrolling start by 2 seconds if (scrollStartDelay == 0) scrollStartDelay = now; if (!scrollStarted && now - scrollStartDelay > 2000) @@ -413,22 +362,49 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 int scrollOffset = static_cast(scrollY); int yOffset = -scrollOffset + getTextPositions(display)[1]; - for (int separatorX = 1; separatorX <= (display->getStringWidth(headerStr) + 2); separatorX += 2) { - display->setPixel(separatorX, yOffset + ((isHighResolution) ? 19 : 13)); - } // === Render visible lines === - renderMessageContent(display, cachedLines, cachedHeights, x, yOffset, scrollBottom, emotes, numEmotes, isInverted, isBold); + for (size_t i = 0; i < cachedLines.size(); ++i) { + int lineY = yOffset; + for (size_t j = 0; j < i; ++j) + lineY += cachedHeights[j]; - // Draw header at the end to sort out overlapping elements + if (lineY > -cachedHeights[i] && lineY < scrollBottom) { + if (isHeader[i]) { + // Render header + int w = display->getStringWidth(cachedLines[i].c_str()); + int headerX = isMine[i] ? (SCREEN_WIDTH - w - 2) : x; + display->drawString(headerX, lineY, cachedLines[i].c_str()); + + // Draw underline just under header text + int underlineY = lineY + FONT_HEIGHT_SMALL; + for (int px = 0; px < w; ++px) { + display->setPixel(headerX + px, underlineY); + } + } else { + // Render message line + if (isMine[i]) { + int w = display->getStringWidth(cachedLines[i].c_str()); + int rightX = SCREEN_WIDTH - w - 2; + drawStringWithEmotes(display, rightX, lineY, cachedLines[i], emotes, numEmotes); + } else { + drawStringWithEmotes(display, x, lineY, cachedLines[i], emotes, numEmotes); + } + } + } + } + // Draw screen title header last graphics::drawCommonHeader(display, x, y, titleStr); -#endif } std::vector generateLines(OLEDDisplay *display, const char *headerStr, const char *messageBuf, int textWidth) { std::vector lines; - lines.push_back(std::string(headerStr)); // Header line is always first + + // Only push headerStr if it's not empty (prevents extra blank line after headers) + if (headerStr && headerStr[0] != '\0') { + lines.push_back(std::string(headerStr)); + } std::string line, word; for (int i = 0; messageBuf[i]; ++i) { @@ -451,10 +427,6 @@ std::vector generateLines(OLEDDisplay *display, const char *headerS } else { word += ch; std::string test = line + word; -// Keep these lines for diagnostics -// LOG_INFO("Char: '%c' (0x%02X)", ch, (unsigned char)ch); -// LOG_INFO("Current String: %s", test.c_str()); -// Note: there are boolean comparison uint16 (getStringWidth) with int (textWidth), hope textWidth is always positive :) #if defined(OLED_UA) || defined(OLED_RU) uint16_t strWidth = display->getStringWidth(test.c_str(), test.length(), true); #else @@ -481,10 +453,13 @@ std::vector calculateLineHeights(const std::vector &lines, con { std::vector rowHeights; - for (const auto &_line : lines) { + for (size_t idx = 0; idx < lines.size(); ++idx) { + const auto &_line = lines[idx]; int lineHeight = FONT_HEIGHT_SMALL; bool hasEmote = false; + bool isHeader = false; + // Detect emotes in this line for (int i = 0; i < numEmotes; ++i) { const Emote &e = emotes[i]; if (_line.find(e.label) != std::string::npos) { @@ -493,11 +468,34 @@ std::vector calculateLineHeights(const std::vector &lines, con } } - // Apply tighter spacing if no emotes on this line - if (!hasEmote) { - lineHeight -= 2; // reduce by 2px for tighter spacing + // Detect header lines (start of a message, or time stamps like "5m ago") + if (idx == 0 || _line.find("ago") != std::string::npos || _line.rfind("me ", 0) == 0) { + isHeader = true; + } + + // Look ahead to see if next line is a header → this is the last line of a message + bool beforeHeader = + (idx + 1 < lines.size() && (lines[idx + 1].find("ago") != std::string::npos || lines[idx + 1].rfind("me ", 0) == 0)); + + if (isHeader) { + // Headers always keep full line height + lineHeight = FONT_HEIGHT_SMALL; + } else if (beforeHeader) { + if (hasEmote) { + // Last line has emote → preserve its height + padding + lineHeight = std::max(lineHeight, FONT_HEIGHT_SMALL) + 4; + } else { + // Plain last line → full spacing only + lineHeight = FONT_HEIGHT_SMALL; + } + } else if (!hasEmote) { + // Plain body line, tighter spacing + lineHeight -= 4; if (lineHeight < 8) - lineHeight = 8; // minimum safety + lineHeight = 8; // safe minimum + } else { + // Line has emotes, don’t compress + lineHeight += 4; // add breathing room } rowHeights.push_back(lineHeight); diff --git a/src/graphics/draw/MessageRenderer.h b/src/graphics/draw/MessageRenderer.h index c15a699f7..8f5e369a2 100644 --- a/src/graphics/draw/MessageRenderer.h +++ b/src/graphics/draw/MessageRenderer.h @@ -26,5 +26,8 @@ std::vector calculateLineHeights(const std::vector &lines, con void renderMessageContent(OLEDDisplay *display, const std::vector &lines, const std::vector &rowHeights, int x, int yOffset, int scrollBottom, const Emote *emotes, int numEmotes, bool isInverted, bool isBold); +// Reset scroll state when new messages arrive +void resetScrollState(); + } // namespace MessageRenderer } // namespace graphics diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index e9f52bb7d..668ab98c2 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -7,6 +7,7 @@ #include "Channels.h" #include "FSCommon.h" #include "MeshService.h" +#include "MessageStore.h" #include "NodeDB.h" #include "SPILock.h" #include "buzz.h" @@ -20,6 +21,7 @@ #include "mesh/generated/meshtastic/cannedmessages.pb.h" #include "modules/AdminModule.h" #include "modules/ExternalNotificationModule.h" // for buzzer control +extern MessageStore messageStore; #if HAS_TRACKBALL #include "input/TrackballInterruptImpl1.h" #endif @@ -149,7 +151,7 @@ int CannedMessageModule::splitConfiguredMessages() String canned_messages = cannedMessageModuleConfig.messages; // Copy all message parts into the buffer - strncpy(this->messageStore, canned_messages.c_str(), sizeof(this->messageStore)); + strncpy(this->messageBuffer, canned_messages.c_str(), sizeof(this->messageBuffer)); // Temporary array to allow for insertion const char *tempMessages[CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT + 3] = {0}; @@ -166,16 +168,16 @@ int CannedMessageModule::splitConfiguredMessages() #endif // First message always starts at buffer start - tempMessages[tempCount++] = this->messageStore; - int upTo = strlen(this->messageStore) - 1; + tempMessages[tempCount++] = this->messageBuffer; + int upTo = strlen(this->messageBuffer) - 1; // Walk buffer, splitting on '|' while (i < upTo) { - if (this->messageStore[i] == '|') { - this->messageStore[i] = '\0'; // End previous message + if (this->messageBuffer[i] == '|') { + this->messageBuffer[i] = '\0'; // End previous message if (tempCount >= CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT) break; - tempMessages[tempCount++] = (this->messageStore + i + 1); + tempMessages[tempCount++] = (this->messageBuffer + i + 1); } i += 1; } @@ -946,49 +948,42 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha lastDest = dest; lastChannel = channel; lastDestSet = true; - // === Prepare packet === + meshtastic_MeshPacket *p = allocDataPacket(); p->to = dest; p->channel = channel; p->want_ack = true; + p->decoded.dest = dest; // <-- Mirror picker: NODENUM_BROADCAST or node->num - // Save destination for ACK/NACK UI fallback this->lastSentNode = dest; this->incoming = dest; - // Copy message payload + // Copy payload p->decoded.payload.size = strlen(message); memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size); - // Optionally add bell character if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) { - p->decoded.payload.bytes[p->decoded.payload.size++] = 7; // Bell - p->decoded.payload.bytes[p->decoded.payload.size] = '\0'; // Null-terminate + p->decoded.payload.bytes[p->decoded.payload.size++] = 7; + p->decoded.payload.bytes[p->decoded.payload.size] = '\0'; } - // Mark as waiting for ACK to trigger ACK/NACK screen this->waitingForAck = true; - // Log outgoing message - LOG_INFO("Send message id=%u, dest=%x, msg=%.*s", p->id, p->to, p->decoded.payload.size, p->decoded.payload.bytes); - - if (p->to != 0xffffffff) { - LOG_INFO("Proactively adding %x as favorite node", p->to); - nodeDB->set_favorite(true, p->to); - screen->setFrames(graphics::Screen::FOCUS_PRESERVE); - } - - // Send to mesh and phone (even if no phone connected, to track ACKs) service->sendToMesh(p, RX_SRC_LOCAL, true); - // === Simulate local message to clear unread UI === - if (screen) { - meshtastic_MeshPacket simulatedPacket = {}; - simulatedPacket.from = 0; // Local device - screen->handleTextMessage(&simulatedPacket); - } + // Save outgoing message + StoredMessage sm; + sm.timestamp = millis() / 1000; + sm.sender = nodeDB->getNodeNum(); + sm.channelIndex = channel; + sm.text = std::string(message); + sm.dest = dest; // ✅ Will be NODENUM_BROADCAST or node->num + + messageStore.addLiveMessage(sm); + playComboTune(); } + int32_t CannedMessageModule::runOnce() { if (this->runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION && needsUpdate) { diff --git a/src/modules/CannedMessageModule.h b/src/modules/CannedMessageModule.h index 5b0481ac7..890dda4b2 100644 --- a/src/modules/CannedMessageModule.h +++ b/src/modules/CannedMessageModule.h @@ -156,7 +156,7 @@ class CannedMessageModule : public SinglePortModule, public ObservableshowSimpleBanner("Rebooting...", 0)); nodeDB->saveToDisk(); + messageStore.saveToFlash(); rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; // runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; return true;