feat: add reply action to channel and DM message history

Long-press Enter (KEY_CONTEXT_MENU) on a selected message shows an
'Options / Reply' popup in both list and fullscreen views. Confirming
opens MSG_PICK with 'RE: nick' title — the reply can be sent via
keyboard or quick message templates, both automatically prefixed with
'@nick '.

- Channel: reply prefix extracted from 'sender: message' format
- DM: reply available only on incoming messages, uses contact name
- FullscreenMsgView: REPLY added to Result enum, KEY_CONTEXT_MENU handled
- MSG_PICK: _reply_mode flag routes prefix through keyboard and templates
- Guard: prevent sending empty reply (prefix-only keyboard input ignored)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-18 15:12:11 +02:00
parent 8f566732ed
commit 6f7de3ed20
2 changed files with 112 additions and 11 deletions

View File

@@ -14,7 +14,7 @@ struct FullscreenMsgView {
FullscreenMsgView() : scroll(0), active(false) {}
enum Result { NONE, PREV, NEXT, CLOSE };
enum Result { NONE, PREV, NEXT, CLOSE, REPLY };
void begin() { scroll = 0; active = true; }
@@ -109,10 +109,11 @@ struct FullscreenMsgView {
}
Result handleInput(char c) {
if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; }
if (c == KEY_DOWN) { scroll++; return NONE; }
if (c == KEY_LEFT) return NEXT;
if (c == KEY_RIGHT) return PREV;
if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; }
if (c == KEY_DOWN) { scroll++; return NONE; }
if (c == KEY_LEFT) return NEXT;
if (c == KEY_RIGHT) return PREV;
if (c == KEY_CONTEXT_MENU) return REPLY;
if (c == KEY_ENTER || c == KEY_CANCEL) return CLOSE;
return NONE;
}

View File

@@ -41,11 +41,13 @@ class QuickMsgScreen : public UIScreen {
// KEYBOARD
KeyboardWidget _kb;
// Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK)
// Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK / histories)
PopupMenu _ctx_menu;
bool _ctx_dirty;
char _ctx_notif_item[22];
char _ctx_melody_item[20];
char _reply_prefix[36]; // "@nick " built when reply is triggered
bool _reply_mode; // true while composing a reply (prefix is prepended)
struct ChHistEntry { uint8_t ch_idx; char text[140]; };
ChHistEntry _hist[CH_HIST_MAX];
@@ -86,6 +88,25 @@ class QuickMsgScreen : public UIScreen {
&sensors, batt);
}
// Build "@nick " prefix from a channel message text ("nick: body") into _reply_prefix.
// Returns false if sender is "Me" (own message — no reply prefix needed).
bool buildChannelReplyPrefix(const char* text) {
const char* sep = strstr(text, ": ");
if (!sep) return false;
int slen = (int)(sep - text);
if (slen == 2 && strncmp(text, "Me", 2) == 0) return false;
if (slen > 32) slen = 32;
snprintf(_reply_prefix, sizeof(_reply_prefix), "@%.*s ", slen, text);
return true;
}
void startReply(bool to_channel) {
_sending_to_channel = to_channel;
_reply_mode = true;
setupMsgPick();
_phase = MSG_PICK;
}
void setupMsgPick() {
_msg_sel = _msg_scroll = 0;
_active_msg_count = 0;
@@ -167,6 +188,7 @@ class QuickMsgScreen : public UIScreen {
}
void afterSend(bool ok, const char* msg) {
_reply_mode = false;
if (ok && _sending_to_channel) {
_hist_sel = 0;
_hist_scroll = 0;
@@ -361,7 +383,7 @@ public:
_hist_head(0), _hist_count(0),
_dm_hist_head(0), _dm_hist_count(0),
_dm_hist_sel(-1), _dm_hist_scroll(0),
_ctx_dirty(false) {
_ctx_dirty(false), _reply_mode(false) {
memset(_ch_unread, 0, sizeof(_ch_unread));
}
@@ -751,7 +773,9 @@ public:
} else { // MSG_PICK
char title[24];
if (_sending_to_channel) {
if (_reply_mode) {
snprintf(title, sizeof(title), "RE:%.20s", _reply_prefix + 1); // skip '@'
} else if (_sending_to_channel) {
ChannelDetails ch;
the_mesh.getChannel(_sel_channel_idx, ch);
snprintf(title, sizeof(title), "%.23s", ch.name);
@@ -938,6 +962,16 @@ public:
} else if (_phase == DM_HIST) {
int dm_count = dmHistCountForContact(_sel_contact.id.pub_key);
if (_dm_fs.active) {
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
_dm_fs.active = false;
startReply(false);
} else if (res != PopupMenu::NONE) {
_ctx_menu.active = false;
}
return true;
}
auto res = _dm_fs.handleInput(c);
if (res == FullscreenMsgView::PREV) {
if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_fs.scroll = 0; }
@@ -945,6 +979,22 @@ public:
if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_fs.scroll = 0; }
} else if (res == FullscreenMsgView::CLOSE) {
_dm_fs.active = false;
} else if (res == FullscreenMsgView::REPLY) {
int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
if (ring_pos >= 0 && !_dm_hist[ring_pos].outgoing) {
snprintf(_reply_prefix, sizeof(_reply_prefix), "@%.32s ", _sel_contact.name);
_ctx_menu.begin("Options", 1);
_ctx_menu.addItem("Reply");
}
}
return true;
}
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
startReply(false);
} else if (res != PopupMenu::NONE) {
_ctx_menu.active = false;
}
return true;
}
@@ -977,10 +1027,29 @@ public:
}
return true;
}
if (c == KEY_CONTEXT_MENU && _dm_hist_sel >= 0) {
int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
if (ring_pos >= 0 && !_dm_hist[ring_pos].outgoing) {
snprintf(_reply_prefix, sizeof(_reply_prefix), "@%.32s ", _sel_contact.name);
_ctx_menu.begin("Options", 1);
_ctx_menu.addItem("Reply");
}
return true;
}
} else if (_phase == CHANNEL_HIST) {
int ch_hist_count = histCountForChannel(_sel_channel_idx);
if (_fs.active) {
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
_fs.active = false;
startReply(true);
} else if (res != PopupMenu::NONE) {
_ctx_menu.active = false;
}
return true;
}
auto res = _fs.handleInput(c);
if (res == FullscreenMsgView::PREV) {
if (_hist_sel < ch_hist_count - 1) { _hist_sel++; _fs.scroll = 0; updateChannelUnread(); }
@@ -988,6 +1057,21 @@ public:
if (_hist_sel > 0) { _hist_sel--; _fs.scroll = 0; updateChannelUnread(); }
} else if (res == FullscreenMsgView::CLOSE) {
_fs.active = false;
} else if (res == FullscreenMsgView::REPLY) {
int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel);
if (ring_pos >= 0 && buildChannelReplyPrefix(_hist[ring_pos].text)) {
_ctx_menu.begin("Options", 1);
_ctx_menu.addItem("Reply");
}
}
return true;
}
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
startReply(true);
} else if (res != PopupMenu::NONE) {
_ctx_menu.active = false;
}
return true;
}
@@ -1018,13 +1102,22 @@ public:
}
return true;
}
if (c == KEY_CONTEXT_MENU && _hist_sel >= 0) {
int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel);
if (ring_pos >= 0 && buildChannelReplyPrefix(_hist[ring_pos].text)) {
_ctx_menu.begin("Options", 1);
_ctx_menu.addItem("Reply");
}
return true;
}
} else if (_phase == KEYBOARD) {
auto res = _kb.handleInput(c);
if (res == KeyboardWidget::CANCELLED) {
_phase = MSG_PICK;
} else if (res == KeyboardWidget::DONE) {
if (_kb.len > 0) {
int min_len = _reply_mode ? (int)strlen(_reply_prefix) : 0;
if (_kb.len > min_len) {
char expanded[KB_MAX_LEN + 1];
expandMsg(_kb.buf, expanded, sizeof(expanded));
bool ok = sendText(expanded);
@@ -1036,6 +1129,7 @@ public:
} else { // MSG_PICK
int total_msg_items = 1 + _active_msg_count;
if (c == KEY_CANCEL) {
_reply_mode = false;
_phase = _sending_to_channel ? CHANNEL_HIST : DM_HIST;
return true;
}
@@ -1051,7 +1145,7 @@ public:
}
if (c == KEY_ENTER) {
if (_msg_sel == 0) {
_kb.begin();
_kb.begin(_reply_mode ? _reply_prefix : "");
kbAddSensorPlaceholders(_kb, &sensors);
_phase = KEYBOARD;
return true;
@@ -1060,7 +1154,13 @@ public:
int slot = _active_msgs[_msg_sel - 1];
const char* tmpl = p ? p->custom_msgs[slot] : "OK";
char msg[140];
expandMsg(tmpl, msg, sizeof(msg));
if (_reply_mode) {
char body[140];
expandMsg(tmpl, body, sizeof(body));
snprintf(msg, sizeof(msg), "%s%s", _reply_prefix, body);
} else {
expandMsg(tmpl, msg, sizeof(msg));
}
bool ok = sendText(msg);
afterSend(ok, msg);
return true;