press R in message channel screen view to select a message to reply directly to; update firmware version and date

This commit is contained in:
pelgraine
2026-02-27 08:32:20 +11:00
parent 6d8a01b593
commit 6cad4f8610
4 changed files with 240 additions and 28 deletions
+2 -2
View File
@@ -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)
+26 -6
View File
@@ -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;
+210 -18
View File
@@ -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') {