diff --git a/src/MessageStore.cpp b/src/MessageStore.cpp index 1cb5ca855..e3de1c038 100644 --- a/src/MessageStore.cpp +++ b/src/MessageStore.cpp @@ -4,6 +4,10 @@ #include "SPILock.h" #include "SafeFile.h" #include "configuration.h" // for millis() +#include "graphics/draw/MessageRenderer.h" + +using graphics::MessageRenderer::setThreadMode; +using graphics::MessageRenderer::ThreadMode; MessageStore::MessageStore(const std::string &label) { @@ -50,6 +54,13 @@ void MessageStore::addFromPacket(const meshtastic_MeshPacket &packet) } addLiveMessage(sm); + + // === Auto-switch thread view on new message === + if (sm.type == MessageType::BROADCAST) { + setThreadMode(ThreadMode::CHANNEL, sm.channelIndex); + } else if (sm.type == MessageType::DM_TO_US) { + setThreadMode(ThreadMode::DIRECT, -1, sm.sender); + } } // === Outgoing/manual message === diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index c300cd227..8e5dce838 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1446,7 +1446,7 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) sm.channelIndex = packet->channel; sm.text = std::string(reinterpret_cast(packet->decoded.payload.bytes)); - // ✅ Distinguish between broadcast vs DM to us + // Distinguish between broadcast vs DM to us if (packet->decoded.dest == NODENUM_BROADCAST) { sm.dest = NODENUM_BROADCAST; sm.type = MessageType::BROADCAST; @@ -1457,6 +1457,13 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) messageStore.addLiveMessage(sm); // RAM only (flash updated at shutdown) + // 🔹 Auto-switch thread view + if (sm.type == MessageType::BROADCAST) { + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::CHANNEL, sm.channelIndex); + } else if (sm.type == MessageType::DM_TO_US) { + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::DIRECT, -1, sm.sender); + } + // 🔹 Reset scroll so newest message starts from the top graphics::MessageRenderer::resetScrollState(); } else { @@ -1520,7 +1527,7 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) sm.channelIndex = packet->channel; sm.text = std::string(reinterpret_cast(packet->decoded.payload.bytes)); - // ✅ Distinguish between broadcast vs DM to us + // 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; @@ -1531,7 +1538,14 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) messageStore.addLiveMessage(sm); // RAM only (flash updated at shutdown) - // 🔹 Reset scroll so newest message starts from the top + // Auto-switch thread view + if (sm.type == MessageType::BROADCAST) { + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::CHANNEL, sm.channelIndex); + } else if (sm.type == MessageType::DM_TO_US) { + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::DIRECT, -1, sm.sender); + } + + // 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 b477165df..d9699ec53 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -10,6 +10,7 @@ #include "buzz.h" #include "graphics/Screen.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/draw/MessageRenderer.h" #include "graphics/draw/UIRenderer.h" #include "input/RotaryEncoderInterruptImpl1.h" #include "input/UpDownInterruptImpl1.h" @@ -349,14 +350,30 @@ void menuHandler::clockMenu() void menuHandler::messageResponseMenu() { - enum optionsNumbers { Back = 0, DismissAll = 1, DismissOldest = 2, Preset = 3, Freetext = 4, Aloud = 5, enumEnd = 6 }; + enum optionsNumbers { Back = 0, ViewMode, DismissAll, DismissOldest, Preset, Freetext, Aloud, enumEnd }; + + static const char *optionsArray[enumEnd]; + static int optionsEnumArray[enumEnd]; + int options = 0; + + optionsArray[options] = "Back"; + optionsEnumArray[options++] = Back; + + optionsArray[options] = "View Mode"; + optionsEnumArray[options++] = ViewMode; + + optionsArray[options] = "Dismiss All"; + optionsEnumArray[options++] = DismissAll; + + optionsArray[options] = "Dismiss Oldest"; + optionsEnumArray[options++] = DismissOldest; + #if defined(M5STACK_UNITC6L) - static const char *optionsArray[enumEnd] = {"Back", "Dismiss All", "Dismiss Oldest", "Reply Preset"}; + optionsArray[options] = "Reply Preset"; #else - static const char *optionsArray[enumEnd] = {"Back", "Dismiss All", "Dismiss Oldest", "Reply via Preset"}; + optionsArray[options] = "Reply via Preset"; #endif - static int optionsEnumArray[enumEnd] = {Back, DismissAll, DismissOldest, Preset}; - int options = 4; + optionsEnumArray[options++] = Preset; if (kb_found) { optionsArray[options] = "Reply via Freetext"; @@ -367,6 +384,7 @@ void menuHandler::messageResponseMenu() optionsArray[options] = "Read Aloud"; optionsEnumArray[options++] = Aloud; #endif + BannerOverlayOptions bannerOptions; #if defined(M5STACK_UNITC6L) bannerOptions.message = "Message"; @@ -377,11 +395,14 @@ void menuHandler::messageResponseMenu() bannerOptions.optionsEnumPtr = optionsEnumArray; bannerOptions.optionsCount = options; bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == DismissAll) { - // Remove all messages + LOG_DEBUG("messageResponseMenu: selected %d", selected); + if (selected == ViewMode) { + LOG_DEBUG("Switching to message_viewmode_menu"); + menuHandler::menuQueue = menuHandler::message_viewmode_menu; + screen->runNow(); + } else if (selected == DismissAll) { 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) { @@ -395,19 +416,137 @@ void menuHandler::messageResponseMenu() } else { cannedMessageModule->LaunchFreetextWithDestination(devicestate.rx_text_message.from); } - } #ifdef HAS_I2S - else if (selected == Aloud) { + } else if (selected == Aloud) { const meshtastic_MeshPacket &mp = devicestate.rx_text_message; const char *msg = reinterpret_cast(mp.decoded.payload.bytes); - audioThread->readAloud(msg); - } #endif + } }; screen->showOverlayBanner(bannerOptions); } +void menuHandler::messageViewModeMenu() +{ + // Collect menu entries + static std::vector labels; + static std::vector ids; + + labels.clear(); + ids.clear(); + + // Back + labels.push_back("Back"); + ids.push_back(-1); + + // View All + labels.push_back("View All"); + ids.push_back(-2); + + // --- Add channels with live messages --- + for (int ch = 0; ch < 8; ++ch) { + auto msgs = messageStore.getChannelMessages(ch); + if (!msgs.empty()) { + char buf[20]; + snprintf(buf, sizeof(buf), "Channel %d", ch); + labels.push_back(buf); + ids.push_back(100 + ch); + } + } + + // --- Add channels from registry --- + for (int ch : graphics::MessageRenderer::getSeenChannels()) { + if (std::find(ids.begin(), ids.end(), 100 + ch) == ids.end()) { + char buf[20]; + snprintf(buf, sizeof(buf), "Channel %d", ch); + labels.push_back(buf); + ids.push_back(100 + ch); + } + } + + // --- Add DMs from live store --- + auto dms = messageStore.getDirectMessages(); + std::vector uniqueSenders; + for (auto &m : dms) { + if (std::find(uniqueSenders.begin(), uniqueSenders.end(), m.sender) == uniqueSenders.end()) { + uniqueSenders.push_back(m.sender); + } + } + + // --- Add DMs from registry --- + for (uint32_t peer : graphics::MessageRenderer::getSeenPeers()) { + if (std::find(uniqueSenders.begin(), uniqueSenders.end(), peer) == uniqueSenders.end()) { + uniqueSenders.push_back(peer); + } + } + + std::sort(uniqueSenders.begin(), uniqueSenders.end()); + + for (auto sender : uniqueSenders) { + auto node = nodeDB->getMeshNode(sender); + std::string name; + if (node && node->has_user) { + name = sanitizeString(node->user.long_name).substr(0, 15); + } else { + char buf[20]; + snprintf(buf, sizeof(buf), "Node %08X", sender); + name = buf; + } + labels.push_back("DM: " + name); + ids.push_back(sender); + } + + // --- Determine active ID --- + int activeId = -2; + auto mode = graphics::MessageRenderer::getThreadMode(); + if (mode == graphics::MessageRenderer::ThreadMode::ALL) { + activeId = -2; + } else if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { + activeId = 100 + graphics::MessageRenderer::getThreadChannel(); + } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { + activeId = (int)graphics::MessageRenderer::getThreadPeer(); + } + + // Prepare arrays for banner + static std::vector options; + static std::vector optionIds; + options.clear(); + optionIds.clear(); + + int initialIndex = 0; + for (size_t i = 0; i < labels.size(); i++) { + options.push_back(labels[i].c_str()); + optionIds.push_back(ids[i]); + if (ids[i] == activeId) { + initialIndex = i; + } + } + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Select View Mode"; + bannerOptions.optionsArrayPtr = options.data(); + bannerOptions.optionsEnumPtr = optionIds.data(); + bannerOptions.optionsCount = options.size(); + bannerOptions.InitialSelected = initialIndex; + + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == -1) { + menuHandler::menuQueue = menuHandler::message_response_menu; + screen->runNow(); + } else if (selected == -2) { + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::ALL); + } else if (selected >= 100) { + int ch = selected - 100; + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::CHANNEL, ch); + } else { + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::DIRECT, -1, selected); + } + }; + + screen->showOverlayBanner(bannerOptions); +} + void menuHandler::homeBaseMenu() { enum optionsNumbers { Back, Backlight, Position, Preset, Freetext, Sleep, enumEnd }; @@ -1579,6 +1718,12 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case throttle_message: screen->showSimpleBanner("Too Many Attempts\nTry again in 60 seconds.", 5000); break; + case message_response_menu: + messageResponseMenu(); + break; + case message_viewmode_menu: + messageViewModeMenu(); + break; } menuQueue = menu_none; } diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 56477258f..81c0f7b77 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -42,7 +42,9 @@ class menuHandler key_verification_final_prompt, trace_route_menu, throttle_message, - FrameToggles + FrameToggles, + message_response_menu, + message_viewmode_menu // <-- View Mode menu entry }; static screenMenus menuQueue; @@ -57,6 +59,7 @@ class menuHandler static void TwelveHourPicker(); static void ClockFacePicker(); static void messageResponseMenu(); + static void messageViewModeMenu(); // <-- prototype already here static void homeBaseMenu(); static void textMessageBaseMenu(); static void systemBaseMenu(); @@ -94,4 +97,4 @@ class menuHandler }; } // namespace graphics -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 42394a414..43ab72ce0 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -94,13 +94,13 @@ void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string } } - // === Step 2: Baseline alignment === + // Step 2: Baseline alignment int lineHeight = std::max(fontHeight, maxIconHeight); int baselineOffset = (lineHeight - fontHeight) / 2; int fontY = y + baselineOffset; int fontMidline = fontY + fontHeight / 2; - // === Step 3: Render line in segments === + // Step 3: Render line in segments size_t i = 0; bool inBold = false; @@ -172,7 +172,7 @@ void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string } } -// === Scroll state (file scope so we can reset on new message) === +// Scroll state (file scope so we can reset on new message) float scrollY = 0.0f; uint32_t lastTime = 0; uint32_t scrollStartDelay = 0; @@ -181,7 +181,7 @@ bool waitingToReset = false; bool scrollStarted = false; static bool didReset = false; // <-- add here -// === Reset scroll state when new messages arrive === +// Reset scroll state when new messages arrive void resetScrollState() { scrollY = 0.0f; @@ -192,6 +192,60 @@ void resetScrollState() didReset = false; // <-- now valid } +// --- Current thread state --- +static ThreadMode currentMode = ThreadMode::ALL; +static int currentChannel = -1; +static uint32_t currentPeer = 0; + +// --- Registry of seen threads for manual toggle --- +static std::vector seenChannels; +static std::vector seenPeers; + +// Setter so other code can switch threads +void setThreadMode(ThreadMode mode, int channel /* = -1 */, uint32_t peer /* = 0 */) +{ + currentMode = mode; + currentChannel = channel; + currentPeer = peer; + didReset = false; // force reset when mode changes + + // Track channels we’ve seen + if (mode == ThreadMode::CHANNEL && channel >= 0) { + if (std::find(seenChannels.begin(), seenChannels.end(), channel) == seenChannels.end()) + seenChannels.push_back(channel); + } + + // Track DMs we’ve seen + if (mode == ThreadMode::DIRECT && peer != 0) { + if (std::find(seenPeers.begin(), seenPeers.end(), peer) == seenPeers.end()) + seenPeers.push_back(peer); + } +} + +ThreadMode getThreadMode() +{ + return currentMode; +} + +int getThreadChannel() +{ + return currentChannel; +} + +uint32_t getThreadPeer() +{ + return currentPeer; +} + +// === Accessors for menuHandler === +const std::vector &getSeenChannels() +{ + return seenChannels; +} +const std::vector &getSeenPeers() +{ + return seenPeers; +} void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { @@ -203,8 +257,26 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 // Clear the unread message indicator when viewing the message hasUnreadMessage = false; - // === Use live RAM buffer directly (boot handles flash load) === - const auto &msgs = messageStore.getMessages(); + // Filter messages based on thread mode + std::deque filtered; + for (const auto &m : messageStore.getMessages()) { + bool include = false; + switch (currentMode) { + case ThreadMode::ALL: + include = true; + break; + case ThreadMode::CHANNEL: + if (m.type == MessageType::BROADCAST && (int)m.channelIndex == currentChannel) + include = true; + break; + case ThreadMode::DIRECT: + if (m.type == MessageType::DM_TO_US && m.sender == currentPeer) + include = true; + break; + } + if (include) + filtered.push_back(m); + } display->clear(); display->setTextAlignment(TEXT_ALIGN_LEFT); @@ -222,10 +294,30 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 const int textWidth = SCREEN_WIDTH; #endif - // === Set Title + // Title string depending on mode + static char titleBuf[32]; const char *titleStr = "Messages"; + switch (currentMode) { + case ThreadMode::ALL: + titleStr = "Messages"; + break; + case ThreadMode::CHANNEL: + snprintf(titleBuf, sizeof(titleBuf), "Ch%d", currentChannel); + titleStr = titleBuf; + break; + case ThreadMode::DIRECT: { + meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentPeer); + if (node && node->has_user) { + snprintf(titleBuf, sizeof(titleBuf), "DM: %s", node->user.short_name); + } else { + snprintf(titleBuf, sizeof(titleBuf), "DM: %08x", currentPeer); + } + titleStr = titleBuf; + break; + } + } - if (msgs.empty()) { + if (filtered.empty()) { graphics::drawCommonHeader(display, x, y, titleStr); didReset = false; const char *messageString = "No messages"; @@ -238,12 +330,12 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 return; } - // === Build lines for all messages (newest first) === + // Build lines for filtered 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) { + for (auto it = filtered.rbegin(); it != filtered.rend(); ++it) { const auto &m = *it; // --- Build header line for this message --- @@ -268,21 +360,20 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 sender = "Me"; } - // === 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)"); + // Channel / destination labeling + char chanType[32] = ""; + if (currentMode == ThreadMode::ALL) { + if (m.dest == NODENUM_BROADCAST) { + snprintf(chanType, sizeof(chanType), "(Ch%d)", m.channelIndex); + } else { + snprintf(chanType, sizeof(chanType), "(DM)"); + } } + // else: leave empty for thread views - // === Calculate how long ago === + // 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]; @@ -298,11 +389,12 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 snprintf(timeBuf, sizeof(timeBuf), "%ud ago", seconds / 86400); } + // Final header line 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); + snprintf(headerStr, sizeof(headerStr), "%s @%s %s", timeBuf, sender, chanType); } // Push header line @@ -319,11 +411,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 } } - // === Cache lines and heights === + // Cache lines and heights cachedLines = allLines; cachedHeights = calculateLineHeights(cachedLines, emotes); - // === Scrolling logic (unchanged) === + // Scrolling logic (unchanged) uint32_t now = millis(); int totalHeight = 0; for (size_t i = 0; i < cachedHeights.size(); ++i) @@ -363,7 +455,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 int scrollOffset = static_cast(scrollY); int yOffset = -scrollOffset + getTextPositions(display)[1]; - // === Render visible lines === + // Render visible lines for (size_t i = 0; i < cachedLines.size(); ++i) { int lineY = yOffset; for (size_t j = 0; j < i; ++j) @@ -393,6 +485,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 } } } + // Draw screen title header last graphics::drawCommonHeader(display, x, y, titleStr); } diff --git a/src/graphics/draw/MessageRenderer.h b/src/graphics/draw/MessageRenderer.h index 8f5e369a2..63e6bb317 100644 --- a/src/graphics/draw/MessageRenderer.h +++ b/src/graphics/draw/MessageRenderer.h @@ -10,6 +10,25 @@ namespace graphics namespace MessageRenderer { +// === Thread filter modes === +enum class ThreadMode { ALL, CHANNEL, DIRECT }; + +// Setter for switching thread mode +void setThreadMode(ThreadMode mode, int channel = -1, uint32_t peer = 0); + +// Getter for current mode +ThreadMode getThreadMode(); + +// Getter for current channel (valid if mode == CHANNEL) +int getThreadChannel(); + +// Getter for current peer (valid if mode == DIRECT) +uint32_t getThreadPeer(); + +// --- Registry accessors for menuHandler --- +const std::vector &getSeenChannels(); +const std::vector &getSeenPeers(); + // Text and emote rendering void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount); diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 668ab98c2..e9d703132 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -14,6 +14,7 @@ #include "detect/ScanI2C.h" #include "graphics/Screen.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/draw/MessageRenderer.h" #include "graphics/draw/NotificationRenderer.h" #include "graphics/emotes.h" #include "graphics/images.h" @@ -953,7 +954,7 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha p->to = dest; p->channel = channel; p->want_ack = true; - p->decoded.dest = dest; // <-- Mirror picker: NODENUM_BROADCAST or node->num + p->decoded.dest = dest; // Mirror picker: NODENUM_BROADCAST or node->num this->lastSentNode = dest; this->incoming = dest; @@ -977,10 +978,25 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha sm.sender = nodeDB->getNodeNum(); sm.channelIndex = channel; sm.text = std::string(message); - sm.dest = dest; // ✅ Will be NODENUM_BROADCAST or node->num + + // Classify broadcast vs DM + if (dest == NODENUM_BROADCAST) { + sm.dest = NODENUM_BROADCAST; + sm.type = MessageType::BROADCAST; + } else { + sm.dest = dest; + sm.type = MessageType::DM_TO_US; + } messageStore.addLiveMessage(sm); + // Auto-switch thread view on outgoing message + if (sm.type == MessageType::BROADCAST) { + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::CHANNEL, sm.channelIndex); + } else { + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::DIRECT, -1, sm.dest); + } + playComboTune(); }