diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 94000233..cc0cb45e 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,11 +8,11 @@ #define FIRMWARE_VER_CODE 8 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "25 Feb 2026" +#define FIRMWARE_BUILD_DATE "27 Feb 2026" #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "Meck v0.9.4" +#define FIRMWARE_VERSION "Meck v0.9.5" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 74db42e9..7c8fe830 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1837,9 +1837,23 @@ void handleKeyboardInput() { composeDMContactIdx = -1; composeChannelIdx = ui_task.getChannelScreenViewIdx(); composeMode = true; - composeBuffer[0] = '\0'; - composePos = 0; - Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx); + + // If reply select mode is active, pre-fill @SenderName + char replySender[32]; + if (chScr2 && chScr2->isReplySelectMode() + && chScr2->getReplySelectSender(replySender, sizeof(replySender))) { + int prefixLen = snprintf(composeBuffer, sizeof(composeBuffer), + "@%s ", replySender); + composePos = prefixLen; + chScr2->exitReplySelect(); + Serial.printf("Reply compose to @%s, channel %d\n", + replySender, composeChannelIdx); + } else { + composeBuffer[0] = '\0'; + composePos = 0; + if (chScr2) chScr2->exitReplySelect(); // Clean up if somehow active + Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx); + } drawComposeScreen(); lastComposeRefresh = millis(); } else { @@ -1864,8 +1878,10 @@ void handleKeyboardInput() { break; case 'r': - // Import/merge contacts from SD backup (contacts screen only) - if (ui_task.isOnContactsScreen()) { + // Reply select mode (channel screen) or import contacts (contacts screen) + if (ui_task.isOnChannelScreen()) { + ui_task.injectKey('r'); + } else if (ui_task.isOnContactsScreen()) { Serial.println("Contacts: Importing from SD..."); int added = importContactsFromSD(); if (added > 0) { @@ -1886,9 +1902,13 @@ void handleKeyboardInput() { case 'q': case '\b': - // If channel screen path overlay is showing, dismiss it instead of going home + // If channel screen reply select or path overlay is showing, dismiss it if (ui_task.isOnChannelScreen()) { ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen(); + if (chScr && chScr->isReplySelectMode()) { + ui_task.injectKey('q'); + break; + } if (chScr && chScr->isShowingPathOverlay()) { ui_task.injectKey('q'); break; diff --git a/examples/companion_radio/ui-new/ChannelScreen.h b/examples/companion_radio/ui-new/ChannelScreen.h index 76311392..d9dddcfe 100644 --- a/examples/companion_radio/ui-new/ChannelScreen.h +++ b/examples/companion_radio/ui-new/ChannelScreen.h @@ -77,6 +77,11 @@ private: int _pathScrollPos; // Scroll offset within path overlay hop list int _pathHopsVisible; // Hops that fit on screen (set during render) + // Reply select mode — press R to pick a message and reply with @mention + bool _replySelectMode; // True when user is picking a message to reply to + int _replySelectPos; // Index into chronological channelMsgs[] (0=oldest) + int _replyChannelMsgCount; // Cached count from last render (for input bounds) + // Per-channel unread message counts (standalone mode) // Index 0..MAX_GROUP_CHANNELS-1 for channel messages // Index MAX_GROUP_CHANNELS for DMs (channel_idx == 0xFF) @@ -85,7 +90,8 @@ private: public: ChannelScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0), - _msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false), _pathScrollPos(0), _pathHopsVisible(20) { + _msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false), _pathScrollPos(0), _pathHopsVisible(20), + _replySelectMode(false), _replySelectPos(-1), _replyChannelMsgCount(0) { // Initialize all messages as invalid for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) { _messages[i].valid = false; @@ -128,6 +134,8 @@ public: _scrollPos = 0; _showPathOverlay = false; // Dismiss overlay on new message _pathScrollPos = 0; + _replySelectMode = false; // Dismiss reply select on new message + _replySelectPos = -1; // Track unread count for this channel (only for received messages, not sent) // path_len == 0 means locally sent @@ -166,6 +174,70 @@ public: bool isShowingPathOverlay() const { return _showPathOverlay; } void dismissPathOverlay() { _showPathOverlay = false; _pathScrollPos = 0; } + // --- Reply select mode (R key → pick a message → Enter to @mention reply) --- + bool isReplySelectMode() const { return _replySelectMode; } + void exitReplySelect() { _replySelectMode = false; _replySelectPos = -1; } + + // Extract sender name from a "Sender: message" formatted text. + // Returns true if a sender was found, fills senderBuf (null-terminated). + static bool extractSenderName(const char* msgText, char* senderBuf, int bufLen) { + const char* colon = strstr(msgText, ": "); + if (!colon || colon == msgText) return false; + int nameLen = colon - msgText; + if (nameLen >= bufLen) nameLen = bufLen - 1; + memcpy(senderBuf, msgText, nameLen); + senderBuf[nameLen] = '\0'; + return true; + } + + // Get the sender name of the currently selected message in reply select mode. + // Returns true and fills senderBuf if valid selection exists. + bool getReplySelectSender(char* senderBuf, int bufLen) { + if (!_replySelectMode || _replySelectPos < 0) return false; + + // Rebuild the channel message list (same logic as render) + static int rsMsgs[CHANNEL_MSG_HISTORY_SIZE]; + int count = 0; + for (int i = 0; i < _msgCount && count < CHANNEL_MSG_HISTORY_SIZE; i++) { + int idx = _newestIdx - i; + while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE; + idx = idx % CHANNEL_MSG_HISTORY_SIZE; + if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) { + rsMsgs[count++] = idx; + } + } + // Reverse to chronological (oldest first) + for (int l = 0, r = count - 1; l < r; l++, r--) { + int t = rsMsgs[l]; rsMsgs[l] = rsMsgs[r]; rsMsgs[r] = t; + } + + if (_replySelectPos >= count) return false; + int idx = rsMsgs[_replySelectPos]; + return extractSenderName(_messages[idx].text, senderBuf, bufLen); + } + + // Get the ChannelMessage pointer for the currently selected reply message. + ChannelMessage* getReplySelectMsg() { + if (!_replySelectMode || _replySelectPos < 0) return nullptr; + + static int rsMsgs[CHANNEL_MSG_HISTORY_SIZE]; + int count = 0; + for (int i = 0; i < _msgCount && count < CHANNEL_MSG_HISTORY_SIZE; i++) { + int idx = _newestIdx - i; + while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE; + idx = idx % CHANNEL_MSG_HISTORY_SIZE; + if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) { + rsMsgs[count++] = idx; + } + } + for (int l = 0, r = count - 1; l < r; l++, r--) { + int t = rsMsgs[l]; rsMsgs[l] = rsMsgs[r]; rsMsgs[r] = t; + } + + if (_replySelectPos >= count) return nullptr; + return &_messages[rsMsgs[_replySelectPos]]; + } + // --- Unread message tracking (standalone mode) --- // Mark all messages for a channel as read @@ -595,6 +667,9 @@ public: int tmp = channelMsgs[l]; channelMsgs[l] = channelMsgs[r]; channelMsgs[r] = tmp; } + // Cache for reply select input bounds + _replyChannelMsgCount = numChannelMsgs; + // Clamp scroll position to valid range int maxScroll = numChannelMsgs > _msgsPerPage ? numChannelMsgs - _msgsPerPage : 0; if (_scrollPos > maxScroll) _scrollPos = maxScroll; @@ -612,31 +687,61 @@ public: int idx = channelMsgs[i]; ChannelMessage* msg = &_messages[idx]; + // Reply select: is this the currently selected message? + bool isSelected = (_replySelectMode && i == _replySelectPos); + + // Highlight: single fillRect for the entire message area, then + // draw DARK text on top (same pattern as web reader bookmarks). + // Because message height depends on word-wrap, we fill a generous + // area up-front and erase the excess after rendering. + int yStart = y; + int contentW = display.width(); + int maxLinesPerMsg = 8; + if (isSelected) { + int maxFillH = (maxLinesPerMsg + 1) * lineHeight + 2; + int availH = maxY - y; + if (maxFillH > availH) maxFillH = availH; + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y + 5, contentW, maxFillH); + } + // Time indicator with hop count - inline on same line as message start display.setCursor(0, y); - display.setColor(DisplayDriver::YELLOW); + display.setColor(isSelected ? DisplayDriver::DARK : DisplayDriver::YELLOW); uint32_t age = _rtc->getCurrentTime() - msg->timestamp; - if (age < 60) { - sprintf(tmp, "(%d) %ds ", msg->path_len == 0xFF ? 0 : msg->path_len, age); - } else if (age < 3600) { - sprintf(tmp, "(%d) %dm ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 60); - } else if (age < 86400) { - sprintf(tmp, "(%d) %dh ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 3600); + if (isSelected) { + // Show > marker for selected message, replacing the hop count + if (age < 60) { + sprintf(tmp, ">%ds ", age); + } else if (age < 3600) { + sprintf(tmp, ">%dm ", age / 60); + } else if (age < 86400) { + sprintf(tmp, ">%dh ", age / 3600); + } else { + sprintf(tmp, ">%dd ", age / 86400); + } } else { - sprintf(tmp, "(%d) %dd ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 86400); + if (age < 60) { + sprintf(tmp, "(%d) %ds ", msg->path_len == 0xFF ? 0 : msg->path_len, age); + } else if (age < 3600) { + sprintf(tmp, "(%d) %dm ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 60); + } else if (age < 86400) { + sprintf(tmp, "(%d) %dh ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 3600); + } else { + sprintf(tmp, "(%d) %dd ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 86400); + } } display.print(tmp); // DO NOT advance y - message text continues on the same line // Message text with character wrapping and inline emoji support // (continues after timestamp on first line) - display.setColor(DisplayDriver::LIGHT); + display.setColor(isSelected ? DisplayDriver::DARK : DisplayDriver::LIGHT); int textLen = strlen(msg->text); int pos = 0; int linesForThisMsg = 0; - int maxLinesPerMsg = 8; char charStr[2] = {0, 0}; // Track position in pixels for emoji placement @@ -739,6 +844,21 @@ public: } y += 2; // Small gap between messages + + // Erase excess highlight below the actual message. + // The upfront fillRect covered a max area; restore the unused + // portion back to background so subsequent messages render cleanly. + if (isSelected) { + int usedH = y - yStart; + int maxFillH = (maxLinesPerMsg + 1) * lineHeight + 2; + int availH = maxY - yStart; + if (maxFillH > availH) maxFillH = availH; + if (usedH < maxFillH) { + display.setColor(DisplayDriver::DARK); + display.fillRect(0, y + 5, contentW, maxFillH - usedH); + } + } + msgsDrawn++; if (y + lineHeight > maxY) screenFull = true; } @@ -791,12 +911,17 @@ public: display.setColor(DisplayDriver::YELLOW); // Left side: abbreviated controls - display.print("Q:Bck A/D:Ch V:Pth"); - - // Right side: Ent:New - const char* rightText = "Ent:New"; - display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY); - display.print(rightText); + if (_replySelectMode) { + display.print("W/S:Sel V:Pth Q:X"); + const char* rightText = "Ent:Reply"; + display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY); + display.print(rightText); + } else { + display.print("Q:Bck A/D:Ch R:Rply"); + const char* rightText = "Ent:New"; + display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY); + display.print(rightText); + } #if AUTO_OFF_MILLIS == 0 // e-ink return 5000; @@ -836,8 +961,71 @@ public: } return true; // Consume all other keys while overlay is up } - + + // --- Reply select mode --- + if (_replySelectMode) { + // Q - exit reply select + if (c == 'q' || c == 'Q' || c == '\b') { + _replySelectMode = false; + _replySelectPos = -1; + return true; + } + // W - select older message (lower index in chronological order) + if (c == 'w' || c == 'W' || c == 0xF2) { + if (_replySelectPos > 0) { + _replySelectPos--; + // Auto-scroll to keep selection visible + int startIdx = _replyChannelMsgCount - _msgsPerPage - _scrollPos; + if (startIdx < 0) startIdx = 0; + if (_replySelectPos < startIdx) { + _scrollPos++; + } + } + return true; + } + // S - select newer message (higher index in chronological order) + if (c == 's' || c == 'S' || c == 0xF1) { + if (_replySelectPos < _replyChannelMsgCount - 1) { + _replySelectPos++; + // Auto-scroll to keep selection visible + int endIdx = _replyChannelMsgCount - _scrollPos; + if (_replySelectPos >= endIdx) { + if (_scrollPos > 0) _scrollPos--; + } + } + return true; + } + // V - view path for the SELECTED message (not just newest received) + if (c == 'v' || c == 'V') { + // Path overlay will use getNewestReceivedMsg() — for v1 this is fine. + // The user can see the selected message's hop count in the > marker. + ChannelMessage* selMsg = getReplySelectMsg(); + if (selMsg && selMsg->path_len != 0) { + _showPathOverlay = true; + _pathScrollPos = 0; + } + return true; + } + // Enter - let main.cpp handle (enters compose with @mention) + if (c == '\r' || c == 13) { + return false; + } + return true; // Consume all other keys in reply select + } + int channelMsgCount = getMessageCountForChannel(); + + // R - enter reply select mode + if (c == 'r' || c == 'R') { + if (channelMsgCount > 0) { + _replySelectMode = true; + // Start with newest message selected + _replySelectPos = _replyChannelMsgCount > 0 + ? _replyChannelMsgCount - 1 : channelMsgCount - 1; + return true; + } + return false; + } // V - show path detail for last received message if (c == 'v' || c == 'V') { @@ -867,6 +1055,8 @@ public: // A - previous channel if (c == 'a' || c == 'A') { + _replySelectMode = false; + _replySelectPos = -1; if (_viewChannelIdx > 0) { _viewChannelIdx--; } else { @@ -886,6 +1076,8 @@ public: // D - next channel if (c == 'd' || c == 'D') { + _replySelectMode = false; + _replySelectPos = -1; ChannelDetails ch; uint8_t nextIdx = _viewChannelIdx + 1; if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') { diff --git a/variants/lilygo_tdeck_pro/platformio.ini b/variants/lilygo_tdeck_pro/platformio.ini index 38ca79dd..0f81e1f0 100644 --- a/variants/lilygo_tdeck_pro/platformio.ini +++ b/variants/lilygo_tdeck_pro/platformio.ini @@ -157,7 +157,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D HAS_4G_MODEM=1 -D MECK_WEB_READER=1 - -D FIRMWARE_VERSION='"Meck v0.9.4.4G"' + -D FIRMWARE_VERSION='"Meck v0.9.5.4G"' build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} + + @@ -183,7 +183,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D HAS_4G_MODEM=1 -D MECK_WEB_READER=1 - -D FIRMWARE_VERSION='"Meck v0.9.4.4G.SA"' + -D FIRMWARE_VERSION='"Meck v0.9.5.4G.SA"' build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} + +