Files
MeshCore-Solo/examples/companion_radio/ui-new/QuickMsgScreen.h

1222 lines
45 KiB
C
Raw Normal View History

#pragma once
// Custom screen — not part of upstream UITask.cpp
// Included by UITask.cpp after SettingsScreen.h is defined.
class QuickMsgScreen : public UIScreen {
UITask* _task;
enum Phase { MODE_SELECT, CONTACT_PICK, DM_HIST, MSG_PICK, CHANNEL_PICK, CHANNEL_HIST, KEYBOARD };
Phase _phase;
// MODE_SELECT
int _mode_sel; // 0=Direct, 1=Channel, 2=Room Servers
// CONTACT_PICK
int _contact_sel, _contact_scroll;
int _num_contacts;
uint16_t _sorted[MAX_CONTACTS];
ContactInfo _sel_contact;
bool _room_mode; // true = picking a room server, false = picking a DM contact
// CHANNEL_PICK
int _channel_sel, _channel_scroll;
int _num_channels;
uint8_t _channel_indices[MAX_GROUP_CHANNELS];
uint8_t _ch_unread[MAX_GROUP_CHANNELS];
int _sel_channel_idx;
bool _sending_to_channel;
// MSG_PICK (shared)
int _msg_sel, _msg_scroll;
int _active_msgs[QUICK_MSGS_MAX];
int _active_msg_count;
// CHANNEL_HIST
int _hist_sel, _hist_scroll;
FullscreenMsgView _fs;
int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST
int _viewing_max_seen; // highest _hist_sel reached in current session
static const int CH_HIST_MAX = 32;
// KEYBOARD
KeyboardWidget _kb;
// 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]; uint32_t timestamp; };
ChHistEntry _hist[CH_HIST_MAX];
int _hist_head, _hist_count;
// DM_HIST
struct DmHistEntry { uint8_t prefix[4]; uint8_t outgoing; char text[80]; uint32_t timestamp; };
static const int DM_HIST_MAX = 64;
DmHistEntry _dm_hist[DM_HIST_MAX];
int _dm_hist_head, _dm_hist_count;
int _dm_hist_sel, _dm_hist_scroll;
FullscreenMsgView _dm_fs;
int _visible = 4; // updated in render(); used by handleInput() for scroll clamping
int _hist_visible = 2; // updated in render(); for history list scroll clamping
void expandMsg(const char* tmpl, char* out, int out_len) const {
double lat = 0, lon = 0;
bool gps_valid = false;
#if ENV_INCLUDE_GPS == 1
LocationProvider* loc = sensors.getLocationProvider();
if (loc && loc->isValid()) {
lat = loc->getLatitude() / 1000000.0;
lon = loc->getLongitude() / 1000000.0;
gps_valid = true;
}
#endif
NodePrefs* np = _task->getNodePrefs();
float batt = (float)board.getBattMilliVolts() / 1000.0f;
::expandMsg(tmpl, out, out_len, lat, lon, gps_valid,
rtc_clock.getCurrentTime(),
np ? np->tz_offset_hours : 0,
&sensors, batt);
}
// Strip "@[nick] " reply prefix from a message body for compact list display.
static const char* skipReplyPrefix(const char* text) {
if (text[0] == '@' && text[1] == '[') {
const char* close = strchr(text + 2, ']');
if (close && close[1] == ' ' && close[2]) text = close + 2;
}
while (*text == '\n' || *text == '\r' || *text == ' ') text++;
return text;
}
// 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 > 31) slen = 31;
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;
NodePrefs* p = _task->getNodePrefs();
if (p) {
for (int i = 0; i < QUICK_MSGS_MAX; i++) {
if (p->custom_msgs[i][0] != '\0')
_active_msgs[_active_msg_count++] = i;
}
}
}
static void fmtMsgAge(char* buf, int n, uint32_t timestamp, uint32_t now) {
if (timestamp == 0 || now < timestamp) { buf[0] = '\0'; return; }
uint32_t age = now - timestamp;
if (age < 60) snprintf(buf, n, "%us", age);
else if (age < 3600) snprintf(buf, n, "%um", age / 60);
else if (age < 86400) snprintf(buf, n, "%uh", age / 3600);
else snprintf(buf, n, ">1d");
}
// count history entries for a specific channel
int histCountForChannel(int ch_idx) const {
int n = 0;
for (int i = 0; i < _hist_count; i++) {
if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++;
}
return n;
}
// get ring-buffer position of j-th history entry for channel (newest first)
int histEntryForChannel(int ch_idx, int j) const {
int n = 0;
for (int i = _hist_count - 1; i >= 0; i--) {
int pos = (_hist_head + i) % CH_HIST_MAX;
if (_hist[pos].ch_idx == (uint8_t)ch_idx) {
if (n == j) return pos;
n++;
}
}
return -1;
}
void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) {
int pos;
if (_dm_hist_count < DM_HIST_MAX) {
pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX;
_dm_hist_count++;
} else {
pos = _dm_hist_head;
_dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX;
}
memcpy(_dm_hist[pos].prefix, pub_key, 4);
_dm_hist[pos].outgoing = outgoing ? 1 : 0;
_dm_hist[pos].timestamp = rtc_clock.getCurrentTime();
strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1);
_dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0';
}
int dmHistCountForContact(const uint8_t* prefix) const {
int n = 0;
for (int i = 0; i < _dm_hist_count; i++)
if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++;
return n;
}
int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest
int n = 0;
for (int i = _dm_hist_count - 1; i >= 0; i--) {
int pos = (_dm_hist_head + i) % DM_HIST_MAX;
if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) {
if (n == j) return pos;
n++;
}
}
return -1;
}
void afterSend(bool ok, const char* msg) {
_reply_mode = false;
if (ok && _sending_to_channel) {
_hist_sel = 0;
_hist_scroll = 0;
_phase = CHANNEL_HIST; // set before addChannelMsg so viewing=true, no unread bump
char entry[sizeof(ChHistEntry::text)];
snprintf(entry, sizeof(entry), "Me: %s", msg);
addChannelMsg(_sel_channel_idx, entry);
// After inserting sent msg at index 0, the unread index range is stale.
// User is active in this channel — treat as fully read.
_ch_unread[_sel_channel_idx] = 0;
_unread_at_entry = 0;
_viewing_max_seen = 0;
_task->showAlert("Sent!", 600);
} else if (ok) {
storeDMMsg(_sel_contact.id.pub_key, true, msg);
_dm_hist_sel = 0;
_dm_hist_scroll = 0;
_phase = DM_HIST;
_task->showAlert("Sent!", 600);
} else {
_task->showAlert("Send failed", 1500);
_task->gotoHomeScreen();
}
}
bool sendText(const char* msg) {
if (_sending_to_channel) {
ChannelDetails ch;
if (!the_mesh.getChannel(_sel_channel_idx, ch)) return false;
return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel,
the_mesh.getNodeName(), msg, strlen(msg));
} else {
uint32_t expected_ack = 0, est_timeout = 0;
return the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0,
msg, expected_ack, est_timeout) > 0;
}
}
void buildContactList() {
NodePrefs* p = _task->getNodePrefs();
ContactInfo c;
int total = the_mesh.getNumContacts();
_num_contacts = 0;
if (_room_mode) {
bool fav_only = (p && p->room_fav_only);
for (int i = 0; i < total; i++) {
if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_ROOM) continue;
if (fav_only && !(c.flags & 0x01)) continue;
_sorted[_num_contacts++] = i;
}
} else {
bool show_all = (p && p->dm_show_all);
// Build _sorted and counts[] in one pass — avoids a second getContactByIdx loop.
int counts[MAX_CONTACTS];
for (int i = 0; i < total; i++) {
if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue;
if (!show_all && !(c.flags & 0x01)) continue;
counts[_num_contacts] = dmHistCountForContact(c.id.pub_key);
_sorted[_num_contacts++] = i;
}
// Sort by message count descending; contacts with no messages keep original order.
for (int i = 1; i < _num_contacts; i++) {
if (counts[i] == 0) continue;
uint16_t key = _sorted[i]; int kc = counts[i];
int j = i;
while (j > 0 && counts[j-1] < kc) {
_sorted[j] = _sorted[j-1]; counts[j] = counts[j-1]; j--;
}
_sorted[j] = key; counts[j] = kc;
}
}
}
void buildChannelList() {
_num_channels = 0;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
_channel_indices[_num_channels++] = (uint8_t)i;
}
}
}
// Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on
uint8_t chNotifState(uint8_t ch_idx) const {
NodePrefs* p = _task->getNodePrefs();
if (!p) return 0;
uint64_t mask = 1ULL << ch_idx;
if (!(p->ch_notif_override & mask)) return 0;
return (p->ch_notif_muted & mask) ? 1 : 2;
}
void setChNotifState(uint8_t ch_idx, uint8_t state) {
NodePrefs* p = _task->getNodePrefs();
if (!p) return;
uint64_t mask = 1ULL << ch_idx;
if (state == 0) {
p->ch_notif_override &= ~mask;
p->ch_notif_muted &= ~mask;
} else if (state == 1) {
p->ch_notif_override |= mask;
p->ch_notif_muted |= mask;
} else {
p->ch_notif_override |= mask;
p->ch_notif_muted &= ~mask;
}
}
uint8_t dmNotifState(const uint8_t* pub_key) const {
NodePrefs* p = _task->getNodePrefs();
if (!p) return 0;
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++)
if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0)
return p->dm_notif[i].state;
return 0;
}
void setDmNotifState(const uint8_t* pub_key, uint8_t state) {
NodePrefs* p = _task->getNodePrefs();
if (!p) return;
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) {
if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) {
if (state == 0) { memset(&p->dm_notif[i], 0, sizeof(p->dm_notif[i])); }
else p->dm_notif[i].state = state;
return;
}
}
if (state == 0) return;
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) {
if (p->dm_notif[i].state == 0) {
memcpy(p->dm_notif[i].prefix, pub_key, 4);
p->dm_notif[i].state = state;
return;
}
}
// table full — overwrite slot 0
memcpy(p->dm_notif[0].prefix, pub_key, 4);
p->dm_notif[0].state = state;
}
uint8_t chNotifMelody(uint8_t ch_idx) const {
NodePrefs* p = _task->getNodePrefs();
if (!p) return 0;
uint64_t mask = 1ULL << ch_idx;
if (!(p->ch_notif_melody_set & mask)) return 0;
return (p->ch_notif_melody_2 & mask) ? 2 : 1;
}
void setChNotifMelody(uint8_t ch_idx, uint8_t slot) {
NodePrefs* p = _task->getNodePrefs();
if (!p) return;
uint64_t mask = 1ULL << ch_idx;
if (slot == 0) {
p->ch_notif_melody_set &= ~mask;
p->ch_notif_melody_2 &= ~mask;
} else if (slot == 1) {
p->ch_notif_melody_set |= mask;
p->ch_notif_melody_2 &= ~mask;
} else {
p->ch_notif_melody_set |= mask;
p->ch_notif_melody_2 |= mask;
}
}
uint8_t dmMelodySlot(const uint8_t* pub_key) const {
NodePrefs* p = _task->getNodePrefs();
if (!p) return 0;
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++)
if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0)
return p->dm_melody[i].slot;
return 0;
}
void setDmMelody(const uint8_t* pub_key, uint8_t slot) {
NodePrefs* p = _task->getNodePrefs();
if (!p) return;
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) {
if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) {
if (slot == 0) memset(&p->dm_melody[i], 0, sizeof(p->dm_melody[i]));
else p->dm_melody[i].slot = slot;
return;
}
}
if (slot == 0) return;
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) {
if (p->dm_melody[i].slot == 0) {
memcpy(p->dm_melody[i].prefix, pub_key, 4);
p->dm_melody[i].slot = slot;
return;
}
}
memcpy(p->dm_melody[0].prefix, pub_key, 4);
p->dm_melody[0].slot = slot;
}
public:
QuickMsgScreen(UITask* task)
: _task(task), _phase(MODE_SELECT), _mode_sel(0),
_contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false),
_channel_sel(0), _channel_scroll(0), _num_channels(0),
_sel_channel_idx(0), _sending_to_channel(false),
_msg_sel(0), _msg_scroll(0), _active_msg_count(0),
_hist_sel(0), _hist_scroll(0),
_unread_at_entry(0), _viewing_max_seen(0),
_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), _reply_mode(false) {
memset(_ch_unread, 0, sizeof(_ch_unread));
}
void addChannelMsg(uint8_t ch_idx, const char* text) {
int pos;
if (_hist_count < CH_HIST_MAX) {
pos = (_hist_head + _hist_count) % CH_HIST_MAX;
_hist_count++;
} else {
pos = _hist_head;
_hist_head = (_hist_head + 1) % CH_HIST_MAX;
}
_hist[pos].ch_idx = ch_idx;
_hist[pos].timestamp = rtc_clock.getCurrentTime();
strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1);
_hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0';
if (ch_idx < MAX_GROUP_CHANNELS) {
bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx);
if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++;
}
}
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) {
storeDMMsg(pub_key, outgoing, text);
}
int getDMUnreadTotal() const {
return _task->getDMUnreadTotal();
}
int getTotalChannelUnread() const {
int total = 0;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i];
return total;
}
void clearAllChannelUnread() {
memset(_ch_unread, 0, sizeof(_ch_unread));
}
void updateChannelUnread() {
if (_hist_sel < 0 || _sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return;
if (_hist_sel > _viewing_max_seen) _viewing_max_seen = _hist_sel;
// histEntryForChannel is newest-first: index 0 = newest (unread), higher = older.
// Each step down from 0 sees one more message; seen count = max_seen + 1.
int remaining = _unread_at_entry - (_viewing_max_seen + 1);
_ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0);
}
void reset() {
_phase = MODE_SELECT;
_mode_sel = 0;
_contact_sel = _contact_scroll = 0;
_msg_sel = _msg_scroll = 0;
_channel_sel = _channel_scroll = 0;
_sending_to_channel = false;
_room_mode = false;
buildContactList();
buildChannelList();
_ctx_menu.active = false;
_ctx_dirty = false;
_unread_at_entry = 0;
_viewing_max_seen = 0;
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
int lh = display.getLineHeight();
int item_h = display.lineStep();
int start_y = display.listStart();
int cw = display.getCharWidth();
_visible = display.listVisible(item_h);
if (_phase == MODE_SELECT) {
display.drawTextCentered(display.width()/2, 0, "MESSAGE");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
const char* opts[] = { "Direct message", "Channels", "Room Servers" };
int badges[3] = {
getDMUnreadTotal(),
_task->getChannelUnreadCount(),
_task->getRoomUnreadCount()
};
for (int i = 0; i < 3; i++) {
int y = start_y + i * item_h;
bool sel = (i == _mode_sel);
display.drawSelectionRow(0, y - 1, display.width(), item_h - 1, sel);
display.setCursor(0, y);
display.print(sel ? ">" : " ");
display.setCursor(cw + 2, y);
display.print(opts[i]);
if (badges[i] > 0) {
char badge[5];
snprintf(badge, sizeof(badge), "%d", badges[i]);
int bw = display.getTextWidth(badge) + 2;
display.setCursor(display.width() - bw, y);
display.print(badge);
}
}
display.setColor(DisplayDriver::LIGHT);
} else if (_phase == CONTACT_PICK) {
display.drawTextCentered(display.width()/2, 0, _room_mode ? "SELECT ROOM" : "SELECT CONTACT");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
if (_num_contacts == 0) {
display.drawTextCentered(display.width()/2, display.height()/2, _room_mode ? "No room servers" : "No favourites");
return 5000;
}
for (int i = 0; i < _visible && (_contact_scroll+i) < _num_contacts; i++) {
int list_idx = _contact_scroll + i;
int mesh_idx = _sorted[list_idx];
bool sel = (list_idx == _contact_sel);
int y = start_y + i * item_h;
display.drawSelectionRow(0, y - 1, display.width(), item_h - 1, sel);
ContactInfo c;
if (the_mesh.getContactByIdx(mesh_idx, c)) {
display.setCursor(0, y);
display.print(sel ? ">" : " ");
char filtered[sizeof(c.name)];
display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered));
uint8_t dm_unread = _task->getDMUnread(c.id.pub_key);
char badge[5] = "";
int bw = 0;
if (dm_unread > 0) {
snprintf(badge, sizeof(badge), "%d", (int)dm_unread);
bw = display.getTextWidth(badge) + 2;
}
display.drawTextEllipsized(cw + 2, y, display.width() - cw - 2 - bw - 1, filtered);
if (dm_unread > 0) {
display.setCursor(display.width() - bw + 1, y);
display.print(badge);
}
}
}
display.setColor(DisplayDriver::LIGHT);
if (_contact_scroll > 0) { display.setCursor(display.width() - cw, start_y); display.print("^"); }
if (_contact_scroll + _visible < _num_contacts) { display.setCursor(display.width() - cw, start_y + (_visible-1)*item_h); display.print("v"); }
// Context menu overlay
if (_ctx_menu.active) _ctx_menu.render(display);
} else if (_phase == CHANNEL_PICK) {
display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
if (_num_channels == 0) {
display.drawTextCentered(display.width()/2, display.height()/2, "No channels");
return 5000;
}
for (int i = 0; i < _visible && (_channel_scroll+i) < _num_channels; i++) {
int list_idx = _channel_scroll + i;
bool sel = (list_idx == _channel_sel);
int y = start_y + i * item_h;
display.drawSelectionRow(0, y - 1, display.width(), item_h - 1, sel);
display.setCursor(0, y);
display.print(sel ? ">" : " ");
ChannelDetails ch;
if (the_mesh.getChannel(_channel_indices[list_idx], ch)) {
uint8_t unread = _ch_unread[_channel_indices[list_idx]];
char badge[5] = "";
int bw = 0;
if (unread > 0) {
snprintf(badge, sizeof(badge), "%d", (int)unread);
bw = display.getTextWidth(badge) + 2;
}
display.drawTextEllipsized(cw + 2, y, display.width() - cw - 4 - bw, ch.name);
if (unread > 0) {
display.setCursor(display.width() - bw, y);
display.print(badge);
}
}
}
display.setColor(DisplayDriver::LIGHT);
if (_channel_scroll > 0) { display.setCursor(display.width() - cw, start_y); display.print("^"); }
if (_channel_scroll + _visible < _num_channels) { display.setCursor(display.width() - cw, start_y + (_visible-1)*item_h); display.print("v"); }
// Context menu overlay
if (_ctx_menu.active) _ctx_menu.render(display);
} else if (_phase == DM_HIST) {
display.setTextSize(1);
char filtered_name[sizeof(_sel_contact.name)];
display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name));
if (_dm_fs.active && _dm_hist_sel >= 0) {
int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
if (ring_pos >= 0) {
const DmHistEntry& e = _dm_hist[ring_pos];
const char* sender = e.outgoing ? "Me" : filtered_name;
int dm_count = dmHistCountForContact(_sel_contact.id.pub_key);
int ret = _dm_fs.render(display, sender, e.text,
_dm_hist_sel < dm_count - 1,
_dm_hist_sel > 0);
if (_ctx_menu.active) _ctx_menu.render(display);
return ret;
}
return 500;
}
int hist_box_h = 2 * lh + 1;
int hist_item_h = hist_box_h + 1;
int hist_start_y = display.headerH();
int cby = display.height() - lh - 2;
_hist_visible = (cby - 1 - hist_start_y) / hist_item_h;
if (_hist_visible < 1) _hist_visible = 1;
char title[24];
display.setColor(DisplayDriver::LIGHT);
snprintf(title, sizeof(title), "%.23s", filtered_name);
display.drawTextCentered(display.width()/2, 0, title);
display.fillRect(0, lh + 1, display.width(), display.sepH());
int dm_count = dmHistCountForContact(_sel_contact.id.pub_key);
uint32_t now_ts = rtc_clock.getCurrentTime();
for (int i = 0; i < _hist_visible && (_dm_hist_scroll + i) < dm_count; i++) {
int item = _dm_hist_scroll + i;
bool sel = (item == _dm_hist_sel);
int y = hist_start_y + i * hist_item_h;
int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, item);
if (ring_pos < 0) continue;
const DmHistEntry& e = _dm_hist[ring_pos];
const char* sender = e.outgoing ? "Me" : filtered_name;
char age[6]; fmtMsgAge(age, sizeof(age), e.timestamp, now_ts);
int age_w = age[0] ? display.getTextWidth(age) + 3 : 0;
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y, display.width(), hist_box_h);
display.setColor(DisplayDriver::DARK);
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender);
if (age[0]) { display.setCursor(display.width() - age_w, y + 1); display.print(age); }
display.drawTextEllipsized(3, y + lh + 1, display.width() - cw - 2, skipReplyPrefix(e.text));
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, y, display.width(), hist_box_h);
display.fillRect(1, y + 1, display.width() - 2, lh);
display.setColor(DisplayDriver::DARK);
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender);
if (age[0]) { display.setCursor(display.width() - age_w, y + 1); display.print(age); }
display.setColor(DisplayDriver::LIGHT);
display.drawTextEllipsized(3, y + lh + 1, display.width() - cw - 2, skipReplyPrefix(e.text));
}
}
if (dm_count == 0) {
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet");
}
display.setColor(DisplayDriver::LIGHT);
if (_dm_hist_scroll > 0) {
display.setCursor(display.width() - cw, hist_start_y + 1);
display.print("^");
}
if (_dm_hist_scroll + _hist_visible < dm_count) {
display.setCursor(display.width() - cw, hist_start_y + (_hist_visible - 1) * hist_item_h + hist_box_h - lh);
display.print("v");
}
bool compose_sel = (_dm_hist_sel == -1);
const char* ctxt = "[+ send]";
int ctw = display.getTextWidth(ctxt);
int cbx = 1;
if (compose_sel) {
display.fillRect(cbx, cby - 1, ctw + 4, lh + 1);
display.setColor(DisplayDriver::DARK);
}
display.setCursor(cbx + 2, cby);
display.print(ctxt);
display.setColor(DisplayDriver::LIGHT);
if (_ctx_menu.active) _ctx_menu.render(display);
return dm_count > 0 ? 500 : 2000;
} else if (_phase == CHANNEL_HIST) {
if (_fs.active && _hist_sel >= 0) {
int fs_hist_count = histCountForChannel(_sel_channel_idx);
int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel);
if (ring_pos >= 0) {
const char* ftext = _hist[ring_pos].text;
const char* fsep = strstr(ftext, ": ");
char fsender[22], fmsg[140];
if (fsep) {
int nl = fsep - ftext; if (nl > 21) nl = 21;
strncpy(fsender, ftext, nl); fsender[nl] = '\0';
strncpy(fmsg, fsep + 2, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0';
} else {
strncpy(fsender, "?", sizeof(fsender));
strncpy(fmsg, ftext, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0';
}
int ret = _fs.render(display, fsender, fmsg,
_hist_sel < fs_hist_count - 1,
_hist_sel > 0);
if (_ctx_menu.active) _ctx_menu.render(display);
return ret;
}
return 2000;
}
int hist_box_h = 2 * lh + 1;
int hist_item_h = hist_box_h + 1;
int hist_start_y = display.headerH();
int cby = display.height() - lh - 2;
_hist_visible = (cby - 1 - hist_start_y) / hist_item_h;
if (_hist_visible < 1) _hist_visible = 1;
ChannelDetails ch;
the_mesh.getChannel(_sel_channel_idx, ch);
char title[24];
snprintf(title, sizeof(title), "%.23s", ch.name);
display.drawTextCentered(display.width()/2, 0, title);
display.fillRect(0, lh + 1, display.width(), display.sepH());
int ch_hist_count = histCountForChannel(_sel_channel_idx);
uint32_t now_ts = rtc_clock.getCurrentTime();
for (int i = 0; i < _hist_visible && (_hist_scroll + i) < ch_hist_count; i++) {
int item = _hist_scroll + i;
bool sel = (item == _hist_sel);
int y = hist_start_y + i * hist_item_h;
int ring_pos = histEntryForChannel(_sel_channel_idx, item);
if (ring_pos < 0) continue;
const char* text = _hist[ring_pos].text;
const char* sep = strstr(text, ": ");
char sender[22], msg_part[79];
if (sep) {
int nl = sep - text; if (nl > 21) nl = 21;
strncpy(sender, text, nl); sender[nl] = '\0';
strncpy(msg_part, sep + 2, sizeof(msg_part) - 1);
msg_part[sizeof(msg_part) - 1] = '\0';
} else {
strncpy(sender, "?", sizeof(sender));
strncpy(msg_part, text, sizeof(msg_part) - 1);
msg_part[sizeof(msg_part) - 1] = '\0';
}
char age[6]; fmtMsgAge(age, sizeof(age), _hist[ring_pos].timestamp, now_ts);
int age_w = age[0] ? display.getTextWidth(age) + 3 : 0;
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y, display.width(), hist_box_h);
display.setColor(DisplayDriver::DARK);
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender);
if (age[0]) { display.setCursor(display.width() - age_w, y + 1); display.print(age); }
display.drawTextEllipsized(3, y + lh + 1, display.width() - cw - 2, skipReplyPrefix(msg_part));
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, y, display.width(), hist_box_h);
display.fillRect(1, y + 1, display.width() - 2, lh);
display.setColor(DisplayDriver::DARK);
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender);
if (age[0]) { display.setCursor(display.width() - age_w, y + 1); display.print(age); }
display.setColor(DisplayDriver::LIGHT);
display.drawTextEllipsized(3, y + lh + 1, display.width() - cw - 2, skipReplyPrefix(msg_part));
}
}
if (ch_hist_count == 0) {
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet");
}
// scroll hints
display.setColor(DisplayDriver::LIGHT);
if (_hist_scroll > 0) {
display.setCursor(display.width() - cw, hist_start_y + 1);
display.print("^");
}
if (_hist_scroll + _hist_visible < ch_hist_count) {
display.setCursor(display.width() - cw, hist_start_y + (_hist_visible - 1) * hist_item_h + hist_box_h - lh);
display.print("v");
}
// small compose button (bottom-left, always bordered, inverted when selected)
bool compose_sel = (_hist_sel == -1);
const char* ctxt = "[+ send]";
int ctw = display.getTextWidth(ctxt);
int cbx = 1;
if (compose_sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(cbx - 1, cby - 1, ctw + 4, lh + 2);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(cbx - 1, cby - 1, ctw + 4, lh + 2);
}
display.setCursor(cbx + 1, cby);
display.print(ctxt);
display.setColor(DisplayDriver::LIGHT);
if (_ctx_menu.active) _ctx_menu.render(display);
} else if (_phase == KEYBOARD) {
return _kb.render(display);
} else { // MSG_PICK
char title[24];
if (_reply_mode) {
int rlen = (int)strlen(_reply_prefix) - 4; // exclude "@[" and "] "
if (rlen < 0) rlen = 0;
if (rlen > 20) rlen = 20;
char nick_raw[32], nick_trans[32];
snprintf(nick_raw, sizeof(nick_raw), "%.*s", rlen, _reply_prefix + 2);
display.translateUTF8ToBlocks(nick_trans, nick_raw, sizeof(nick_trans));
snprintf(title, sizeof(title), "RE:%s", nick_trans);
} else if (_sending_to_channel) {
ChannelDetails ch;
the_mesh.getChannel(_sel_channel_idx, ch);
snprintf(title, sizeof(title), "%.23s", ch.name);
} else {
snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name);
}
display.drawTextCentered(display.width()/2, 0, title);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
int total_msg_items = 1 + _active_msg_count;
for (int i = 0; i < _visible && (_msg_scroll+i) < total_msg_items; i++) {
int idx = _msg_scroll + i;
bool sel = (idx == _msg_sel);
int y = start_y + i * item_h;
display.drawSelectionRow(0, y - 1, display.width(), item_h - 1, sel);
display.setCursor(0, y);
display.print(sel ? ">" : " ");
if (idx == 0) {
display.setCursor(cw + 2, y);
display.print("Custom message...");
} else {
NodePrefs* p = _task->getNodePrefs();
int slot = _active_msgs[idx - 1];
const char* tmpl = p ? p->custom_msgs[slot] : "";
display.drawTextEllipsized(cw + 2, y, display.width() - cw - 4, tmpl);
}
}
display.setColor(DisplayDriver::LIGHT);
if (_msg_scroll > 0) { display.setCursor(display.width() - cw, start_y); display.print("^"); }
if (_msg_scroll + _visible < total_msg_items) { display.setCursor(display.width() - cw, start_y + (_visible-1)*item_h); display.print("v"); }
}
return 2000;
}
bool handleInput(char c) override {
if (_phase == MODE_SELECT) {
if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; }
if (c == KEY_UP && _mode_sel > 0) { _mode_sel--; return true; }
if (c == KEY_DOWN && _mode_sel < 2) { _mode_sel++; return true; }
if (c == KEY_ENTER) {
if (_mode_sel == 1) {
_phase = CHANNEL_PICK;
} else {
_room_mode = (_mode_sel == 2);
if (_room_mode) _task->clearRoomUnread();
buildContactList();
_contact_sel = _contact_scroll = 0;
_phase = CONTACT_PICK;
}
return true;
}
} else if (_phase == CONTACT_PICK) {
// Context menu consumes all input while open
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED && _num_contacts > 0) {
ContactInfo ci;
if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) {
if (_ctx_menu.selectedIndex() == 0) {
_task->clearDMUnread(ci.id.pub_key);
} else if (_ctx_menu.selectedIndex() == 1) {
uint8_t nstate = dmNotifState(ci.id.pub_key);
setDmNotifState(ci.id.pub_key, (nstate + 1) % 3);
_ctx_dirty = true;
} else {
uint8_t slot = dmMelodySlot(ci.id.pub_key);
setDmMelody(ci.id.pub_key, (slot + 1) % 3);
_ctx_dirty = true;
}
}
}
if (res != PopupMenu::NONE && _ctx_dirty) {
the_mesh.savePrefs(); _ctx_dirty = false;
}
return true;
}
if (c == KEY_CANCEL) { _room_mode = false; _phase = MODE_SELECT; return true; }
if (c == KEY_UP && _contact_sel > 0) {
_contact_sel--;
if (_contact_sel < _contact_scroll) _contact_scroll = _contact_sel;
return true;
}
if (c == KEY_DOWN && _contact_sel < _num_contacts - 1) {
_contact_sel++;
if (_contact_sel >= _contact_scroll + _visible) _contact_scroll = _contact_sel - _visible + 1;
return true;
}
if (c == KEY_ENTER && _num_contacts > 0) {
if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) {
_task->clearDMUnread(_sel_contact.id.pub_key);
_dm_hist_sel = -1;
_dm_hist_scroll = 0;
_dm_fs.active = false;
_phase = DM_HIST;
}
return true;
}
if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) {
static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" };
ContactInfo ci;
the_mesh.getContactByIdx(_sorted[_contact_sel], ci);
snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s",
NOTIF_LABELS[dmNotifState(ci.id.pub_key)]);
{ static const char* ML[] = { "global", "M1", "M2" };
snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s",
ML[dmMelodySlot(ci.id.pub_key)]); }
_ctx_menu.begin("Contact options", 3);
_ctx_menu.addItem("Mark as read");
_ctx_menu.addItem(_ctx_notif_item);
_ctx_menu.addItem(_ctx_melody_item);
_ctx_dirty = false;
return true;
}
} else if (_phase == CHANNEL_PICK) {
// Context menu consumes all input while open
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED && _num_channels > 0) {
uint8_t ch_idx = _channel_indices[_channel_sel];
if (_ctx_menu.selectedIndex() == 0) {
_ch_unread[ch_idx] = 0;
} else if (_ctx_menu.selectedIndex() == 1) {
uint8_t nstate = chNotifState(ch_idx);
setChNotifState(ch_idx, (nstate + 1) % 3);
_ctx_dirty = true;
} else {
uint8_t slot = chNotifMelody(ch_idx);
setChNotifMelody(ch_idx, (slot + 1) % 3);
_ctx_dirty = true;
}
}
if (res != PopupMenu::NONE && _ctx_dirty) {
the_mesh.savePrefs(); _ctx_dirty = false;
}
return true;
}
if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; }
if (c == KEY_UP && _channel_sel > 0) {
_channel_sel--;
if (_channel_sel < _channel_scroll) _channel_scroll = _channel_sel;
return true;
}
if (c == KEY_DOWN && _channel_sel < _num_channels - 1) {
_channel_sel++;
if (_channel_sel >= _channel_scroll + _visible) _channel_scroll = _channel_sel - _visible + 1;
return true;
}
if (c == KEY_ENTER && _num_channels > 0) {
_sel_channel_idx = _channel_indices[_channel_sel];
int hc = histCountForChannel(_sel_channel_idx);
_unread_at_entry = (int)_ch_unread[_sel_channel_idx];
_hist_scroll = 0;
_hist_sel = hc > 0 ? 0 : -1;
_viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0;
_phase = CHANNEL_HIST;
updateChannelUnread();
return true;
}
if (c == KEY_CONTEXT_MENU && _num_channels > 0) {
uint8_t ch_idx = _channel_indices[_channel_sel];
static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" };
snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s",
NOTIF_LABELS[chNotifState(ch_idx)]);
{ static const char* ML[] = { "global", "M1", "M2" };
snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s",
ML[chNotifMelody(ch_idx)]); }
_ctx_menu.begin("Channel options", 3);
_ctx_menu.addItem("Mark all read");
_ctx_menu.addItem(_ctx_notif_item);
_ctx_menu.addItem(_ctx_melody_item);
_ctx_dirty = false;
return true;
}
} 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; }
} else if (res == FullscreenMsgView::NEXT) {
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) {
char _tname[32]; DisplayDriver::translateUTF8Static(_tname, _sel_contact.name, sizeof(_tname));
snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _tname);
_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;
}
if (c == KEY_CANCEL) { _phase = CONTACT_PICK; return true; }
if (c == KEY_UP) {
if (_dm_hist_sel > 0) {
_dm_hist_sel--;
if (_dm_hist_sel < _dm_hist_scroll) _dm_hist_scroll = _dm_hist_sel;
} else if (_dm_hist_sel == 0) {
_dm_hist_sel = -1;
}
return true;
}
if (c == KEY_DOWN) {
if (_dm_hist_sel == -1 && dm_count > 0) { _dm_hist_sel = 0; _dm_hist_scroll = 0; }
else if (_dm_hist_sel >= 0 && _dm_hist_sel < dm_count - 1) {
_dm_hist_sel++;
if (_dm_hist_sel >= _dm_hist_scroll + _hist_visible)
_dm_hist_scroll = _dm_hist_sel - _hist_visible + 1;
}
return true;
}
if (c == KEY_ENTER) {
if (_dm_hist_sel >= 0) {
_dm_fs.begin();
} else {
_sending_to_channel = false;
setupMsgPick();
_phase = MSG_PICK;
}
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) {
char _tname[32]; DisplayDriver::translateUTF8Static(_tname, _sel_contact.name, sizeof(_tname));
snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _tname);
_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(); }
} else if (res == FullscreenMsgView::NEXT) {
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;
}
if (c == KEY_CANCEL) { _phase = CHANNEL_PICK; return true; }
if (c == KEY_UP) {
if (_hist_sel > 0) { _hist_sel--; if (_hist_sel < _hist_scroll) _hist_scroll = _hist_sel; }
else if (_hist_sel == 0) _hist_sel = -1;
updateChannelUnread();
return true;
}
if (c == KEY_DOWN) {
if (_hist_sel == -1 && ch_hist_count > 0) { _hist_sel = 0; _hist_scroll = 0; }
else if (_hist_sel >= 0 && _hist_sel < ch_hist_count - 1) {
_hist_sel++;
if (_hist_sel >= _hist_scroll + _hist_visible) _hist_scroll = _hist_sel - _hist_visible + 1;
}
updateChannelUnread();
return true;
}
if (c == KEY_ENTER) {
if (_hist_sel >= 0) {
_fs.begin();
updateChannelUnread();
} else {
_sending_to_channel = true;
setupMsgPick();
_phase = MSG_PICK;
}
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) {
int prefix_len = _reply_mode ? (int)strlen(_reply_prefix) : 0;
if (_kb.len > prefix_len) {
// Expand only the body — prefix "@[nick] " is preserved verbatim, so a nick
// that happens to contain a placeholder token isn't substituted.
char expanded[KB_MAX_LEN + 1];
if (prefix_len > 0) {
memcpy(expanded, _kb.buf, prefix_len);
expandMsg(_kb.buf + prefix_len, expanded + prefix_len, sizeof(expanded) - prefix_len);
} else {
expandMsg(_kb.buf, expanded, sizeof(expanded));
}
bool ok = sendText(expanded);
afterSend(ok, expanded);
}
}
return true;
} 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;
}
if (c == KEY_UP && _msg_sel > 0) {
_msg_sel--;
if (_msg_sel < _msg_scroll) _msg_scroll = _msg_sel;
return true;
}
if (c == KEY_DOWN && _msg_sel < total_msg_items - 1) {
_msg_sel++;
if (_msg_sel >= _msg_scroll + _visible) _msg_scroll = _msg_sel - _visible + 1;
return true;
}
if (c == KEY_ENTER) {
if (_msg_sel == 0) {
_kb.begin(_reply_mode ? _reply_prefix : "");
kbAddSensorPlaceholders(_kb, &sensors);
_phase = KEYBOARD;
return true;
}
NodePrefs* p = _task->getNodePrefs();
int slot = _active_msgs[_msg_sel - 1];
const char* tmpl = p ? p->custom_msgs[slot] : "OK";
char msg[140];
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;
}
}
return false;
}
};