First try at multimessage storage and display

This commit is contained in:
HarukiToreda 2025-09-21 17:40:26 -04:00
parent 040b3b8c7f
commit cf9bc7ac00
10 changed files with 565 additions and 222 deletions

214
src/MessageStore.cpp Normal file
View File

@ -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<const char *>(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<StoredMessage> MessageStore::getChannelMessages(uint8_t channel) const
{
std::deque<StoredMessage> result;
for (const auto &m : liveMessages) {
if (m.type == MessageType::BROADCAST && m.channelIndex == channel) {
result.push_back(m);
}
}
return result;
}
std::deque<StoredMessage> MessageStore::getDirectMessages() const
{
std::deque<StoredMessage> result;
for (const auto &m : liveMessages) {
if (m.type == MessageType::DM_TO_US) {
result.push_back(m);
}
}
return result;
}
// === Global definition ===
MessageStore messageStore("default");

75
src/MessageStore.h Normal file
View File

@ -0,0 +1,75 @@
#pragma once
#include "mesh/generated/meshtastic/mesh.pb.h" // for meshtastic_MeshPacket
#include <cstdint>
#include <deque>
#include <string>
// Max number of messages well 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<StoredMessage> &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<StoredMessage> &getMessages() const { return liveMessages; }
// Optional: direct access to persisted copy (mainly for debugging/inspection)
const std::deque<StoredMessage> &getPersistedMessages() const { return messages; }
// === Helper filters for future use ===
std::deque<StoredMessage> getChannelMessages(uint8_t channel) const;
std::deque<StoredMessage> getDirectMessages() const;
std::deque<StoredMessage> getConversationWith(uint32_t peer) const;
private:
// RAM buffer (always current, main source for UI)
std::deque<StoredMessage> liveMessages;
// Persisted storage (only updated on shutdown, loaded at boot)
std::deque<StoredMessage> messages;
std::string filename;
};
// === Global instance (defined in MessageStore.cpp) ===
extern MessageStore messageStore;

View File

@ -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

View File

@ -46,6 +46,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#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 <http://www.gnu.org/licenses/>.
#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<const char *>(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<const char *>(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<const char *>(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();
}
}

View File

@ -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;

View File

@ -26,6 +26,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#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<const char *>(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<std::string> allLines;
std::vector<bool> isMine; // track alignment
std::vector<bool> 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, &timestampHours, &timestampMinutes, &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<std::string> 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<int>(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<std::string> generateLines(OLEDDisplay *display, const char *headerStr, const char *messageBuf, int textWidth)
{
std::vector<std::string> 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<std::string> 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<int> calculateLineHeights(const std::vector<std::string> &lines, con
{
std::vector<int> 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<int> calculateLineHeights(const std::vector<std::string> &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, dont compress
lineHeight += 4; // add breathing room
}
rowHeights.push_back(lineHeight);

View File

@ -26,5 +26,8 @@ std::vector<int> calculateLineHeights(const std::vector<std::string> &lines, con
void renderMessageContent(OLEDDisplay *display, const std::vector<std::string> &lines, const std::vector<int> &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

View File

@ -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) {

View File

@ -156,7 +156,7 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
String temporaryMessage;
// === Message Storage ===
char messageStore[CANNED_MESSAGE_MODULE_MESSAGES_SIZE + 1];
char messageBuffer[CANNED_MESSAGE_MODULE_MESSAGES_SIZE + 1];
char *messages[CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT];
int messagesCount = 0;
int currentMessageIndex = -1;

View File

@ -6,6 +6,7 @@
#endif
#include "GPS.h"
#include "MeshService.h"
#include "MessageStore.h"
#include "Module.h"
#include "NodeDB.h"
#include "main.h"
@ -77,6 +78,7 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event)
case INPUT_BROKER_MSG_REBOOT:
IF_SCREEN(screen->showSimpleBanner("Rebooting...", 0));
nodeDB->saveToDisk();
messageStore.saveToFlash();
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
// runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
return true;