roomserver additions stage 1; dms ui functionality improvements; removed t-deck plus device variant

This commit is contained in:
pelgraine
2026-03-21 21:27:20 +11:00
parent e8a8be521a
commit c2840a43aa
12 changed files with 906 additions and 367 deletions
+7
View File
@@ -1588,6 +1588,13 @@ void MyMesh::handleCmdFrame(size_t len) {
uint8_t ch_idx = is_v3_ch ? out_frame[4] : out_frame[1];
_ui->markChannelReadFromBLE(ch_idx);
}
// Mark DM slot read when companion app syncs a contact (DM/room) message
bool is_v3_dm = (out_frame[0] == RESP_CODE_CONTACT_MSG_RECV_V3);
bool is_old_dm = (out_frame[0] == RESP_CODE_CONTACT_MSG_RECV);
if (is_v3_dm || is_old_dm) {
_ui->markChannelReadFromBLE(0xFF);
}
}
#endif
} else {
+184 -24
View File
@@ -954,10 +954,38 @@ static void lastHeardToggleContact() {
}
#endif
// Channel screen: long press → compose to current channel
// Channel screen: long press → compose to current channel (or DM actions on DM tab)
if (ui_task.isOnChannelScreen()) {
#if defined(LilyGo_T5S3_EPaper_Pro)
uint8_t chIdx = ui_task.getChannelScreenViewIdx();
if (chIdx == 0xFF) {
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
if (chScr->isDMInboxMode()) {
// Inbox mode: long press = open selected conversation (same as Enter)
return '\r';
}
// Conversation mode: long press = compose reply
#if defined(LilyGo_T5S3_EPaper_Pro)
const char* dmName = chScr->getDMFilterName();
if (dmName && dmName[0]) {
uint32_t numC = the_mesh.getNumContacts();
ContactInfo ci;
for (uint32_t j = 0; j < numC; j++) {
if (the_mesh.getContactByIdx(j, ci) && strcmp(ci.name, dmName) == 0) {
char label[40];
snprintf(label, sizeof(label), "DM: %s", dmName);
ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, j);
ui_task.clearDMUnread(j);
return 0;
}
}
}
ui_task.showAlert("Contact not found", 1000);
return 0;
#else
return KEY_ENTER;
#endif
}
#if defined(LilyGo_T5S3_EPaper_Pro)
ChannelDetails ch;
if (the_mesh.getChannel(chIdx, ch)) {
char label[40];
@@ -978,6 +1006,13 @@ static void lastHeardToggleContact() {
uint8_t ctype = cs->getSelectedContactType();
#if defined(LilyGo_T5S3_EPaper_Pro)
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
if (ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
return 0;
}
char dname[32];
cs->getSelectedContactName(dname, sizeof(dname));
char label[40];
@@ -987,6 +1022,16 @@ static void lastHeardToggleContact() {
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
ui_task.gotoRepeaterAdmin(idx);
return 0;
} else if (idx >= 0 && ctype == ADV_TYPE_ROOM) {
// Room server: open login (after login, auto-redirects to conversation)
ui_task.gotoRepeaterAdmin(idx);
return 0;
} else if (idx >= 0 && ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
return 0;
}
#else
// T-Deck Pro: repeater admin works directly, DM via keyboard compose
@@ -2110,13 +2155,37 @@ void loop() {
} else if (ckb == '\r') {
// Enter key — screen-specific compose or select
if (ui_task.isOnChannelScreen()) {
// Open VKB for channel message compose
uint8_t chIdx = ui_task.getChannelScreenViewIdx();
ChannelDetails ch;
if (the_mesh.getChannel(chIdx, ch)) {
char label[40];
snprintf(label, sizeof(label), "To: %s", ch.name);
ui_task.showVirtualKeyboard(VKB_CHANNEL_MSG, label, "", 137, chIdx);
if (chIdx == 0xFF) {
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
if (chScr->isDMInboxMode()) {
// Inbox mode: inject Enter to open conversation
ui_task.injectKey('\r');
} else {
// Conversation mode: open VKB DM compose
const char* dmName = chScr->getDMFilterName();
if (dmName && dmName[0]) {
uint32_t numC = the_mesh.getNumContacts();
ContactInfo ci;
for (uint32_t j = 0; j < numC; j++) {
if (the_mesh.getContactByIdx(j, ci) && strcmp(ci.name, dmName) == 0) {
char label[40];
snprintf(label, sizeof(label), "DM: %s", dmName);
ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, j);
ui_task.clearDMUnread(j);
break;
}
}
}
}
} else {
// Open VKB for channel message compose
ChannelDetails ch;
if (the_mesh.getChannel(chIdx, ch)) {
char label[40];
snprintf(label, sizeof(label), "To: %s", ch.name);
ui_task.showVirtualKeyboard(VKB_CHANNEL_MSG, label, "", 137, chIdx);
}
}
} else if (ui_task.isOnContactsScreen()) {
// DM compose for chat contacts, admin for repeaters
@@ -2125,13 +2194,28 @@ void loop() {
int idx = cs->getSelectedContactIdx();
uint8_t ctype = cs->getSelectedContactType();
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
char dname[32];
cs->getSelectedContactName(dname, sizeof(dname));
char label[40];
snprintf(label, sizeof(label), "DM: %s", dname);
ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, idx);
if (ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
} else {
char dname[32];
cs->getSelectedContactName(dname, sizeof(dname));
char label[40];
snprintf(label, sizeof(label), "DM: %s", dname);
ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, idx);
}
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
ui_task.gotoRepeaterAdmin(idx);
} else if (idx >= 0 && ctype == ADV_TYPE_ROOM) {
// Room server: open login (auto-redirects to conversation)
ui_task.gotoRepeaterAdmin(idx);
} else if (idx >= 0 && ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
}
}
} else if (ui_task.isOnRepeaterAdmin()) {
@@ -2966,24 +3050,46 @@ void handleKeyboardInput() {
int idx = cs->getSelectedContactIdx();
uint8_t ctype = cs->getSelectedContactType();
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
composeDM = true;
composeDMContactIdx = idx;
cs->getSelectedContactName(composeDMName, sizeof(composeDMName));
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx);
drawComposeScreen();
lastComposeRefresh = millis();
// If unread DMs exist, go to conversation view to read first
if (ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
Serial.printf("Unread DMs from %s — opening conversation\n", cname);
} else {
composeDM = true;
composeDMContactIdx = idx;
cs->getSelectedContactName(composeDMName, sizeof(composeDMName));
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx);
drawComposeScreen();
lastComposeRefresh = millis();
}
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
// Open repeater admin screen
char rname[32];
cs->getSelectedContactName(rname, sizeof(rname));
Serial.printf("Opening repeater admin for %s (idx %d)\n", rname, idx);
ui_task.gotoRepeaterAdmin(idx);
} else if (idx >= 0 && ctype == ADV_TYPE_ROOM) {
// Room server: open login screen (after login, auto-redirects to conversation)
char rname[32];
cs->getSelectedContactName(rname, sizeof(rname));
Serial.printf("Room %s — opening login\n", rname);
ui_task.gotoRepeaterAdmin(idx);
} else if (idx >= 0) {
// Non-chat, non-repeater contact (room, sensor, etc.) - future use
Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx);
// Other contacts with unreads
if (ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
} else {
Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx);
}
}
} else if (ui_task.isOnChannelScreen()) {
// If path overlay is showing, Enter copies path text to compose buffer
@@ -3010,6 +3116,42 @@ void handleKeyboardInput() {
lastComposeRefresh = millis();
break;
}
// DM inbox mode: pass Enter to ChannelScreen to open the selected conversation
if (chScr2 && chScr2->isDMInboxMode()) {
ui_task.injectKey('\r');
break;
}
// DM conversation mode: Enter opens DM compose to the contact being viewed
// (DM inbox mode Enter is handled by ChannelScreen::handleInput internally)
if (chScr2 && chScr2->isDMConversation()) {
const char* dmName = chScr2->getDMFilterName();
if (dmName && dmName[0]) {
uint32_t numC = the_mesh.getNumContacts();
ContactInfo ci;
for (uint32_t j = 0; j < numC; j++) {
if (the_mesh.getContactByIdx(j, ci) && strcmp(ci.name, dmName) == 0) {
composeDM = true;
composeDMContactIdx = (int)j;
strncpy(composeDMName, dmName, sizeof(composeDMName) - 1);
composeDMName[sizeof(composeDMName) - 1] = '\0';
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
ui_task.clearDMUnread(j);
Serial.printf("DM conversation compose to %s (idx %d)\n", dmName, j);
drawComposeScreen();
lastComposeRefresh = millis();
break;
}
}
} else {
ui_task.showAlert("No contact selected", 1000);
}
break;
}
composeDM = false;
composeDMContactIdx = -1;
composeChannelIdx = ui_task.getChannelScreenViewIdx();
@@ -3124,6 +3266,24 @@ void handleKeyboardInput() {
}
break;
case 'l':
// L = Login/Admin — from DM conversation, open repeater admin with auto-login
if (ui_task.isOnChannelScreen()) {
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
Serial.printf("[L key] onChannelScreen=1, isDMConv=%d, perms=%d, cidx=%d\n",
chScr ? chScr->isDMConversation() : -1,
chScr ? chScr->getDMContactPerms() : -1,
chScr ? chScr->getDMContactIdx() : -1);
if (chScr && chScr->isDMConversation() && chScr->getDMContactPerms() > 0) {
int cidx = chScr->getDMContactIdx();
if (cidx >= 0) {
ui_task.gotoRepeaterAdminDirect(cidx);
Serial.printf("DM conversation: auto-login admin for idx %d\n", cidx);
}
}
}
break;
case 'q':
case '\b':
// If channel screen reply select or path overlay is showing, dismiss it
+533 -34
View File
@@ -84,6 +84,34 @@ private:
int _replySelectPos; // Index into chronological channelMsgs[] (0=oldest)
int _replyChannelMsgCount; // Cached count from last render (for input bounds)
// DM tab (channel_idx == 0xFF) two-level view:
// Inbox mode: list of contacts you have DMs from
// Conversation mode: messages filtered to one contact
bool _dmInboxMode; // true = showing inbox list, false = conversation
int _dmInboxScroll; // Scroll position in inbox list
char _dmFilterName[32]; // Selected contact name for conversation view
int _dmContactIdx; // Contact index for conversation (-1 if unknown)
uint8_t _dmContactPerms; // Last login permissions for this contact (0=none/guest)
const uint8_t* _dmUnreadPtr; // Pointer to per-contact DM unread array (from UITask)
// Helper: does a message belong to the current view?
bool msgMatchesView(const ChannelMessage& msg) const {
if (!msg.valid) return false;
if (_viewChannelIdx != 0xFF) {
return msg.channel_idx == _viewChannelIdx;
}
// DM tab in conversation mode: filter by sender name
if (!_dmInboxMode && _dmFilterName[0] != '\0') {
if (msg.channel_idx != 0xFF) return false;
int nameLen = strlen(_dmFilterName);
if (strncmp(msg.text, _dmFilterName, nameLen) != 0) return false;
if (msg.text[nameLen] != ':') return false;
return true;
}
// Inbox mode or no filter — match all DMs
return msg.channel_idx == 0xFF;
}
// 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)
@@ -93,7 +121,9 @@ 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),
_replySelectMode(false), _replySelectPos(-1), _replyChannelMsgCount(0) {
_replySelectMode(false), _replySelectPos(-1), _replyChannelMsgCount(0),
_dmInboxMode(true), _dmInboxScroll(0), _dmContactIdx(-1), _dmContactPerms(0), _dmUnreadPtr(nullptr) {
_dmFilterName[0] = '\0';
// Initialize all messages as invalid
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
_messages[i].valid = false;
@@ -158,7 +188,7 @@ public:
int getMessageCountForChannel() const {
int count = 0;
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
if (_messages[i].valid && _messages[i].channel_idx == _viewChannelIdx) {
if (msgMatchesView(_messages[i])) {
count++;
}
}
@@ -173,11 +203,49 @@ public:
_scrollPos = 0;
_showPathOverlay = false;
_pathScrollPos = 0;
// Reset DM inbox state when entering DM tab
if (idx == 0xFF) {
_dmInboxMode = true;
_dmInboxScroll = 0;
_dmFilterName[0] = '\0';
_dmContactIdx = -1;
_dmContactPerms = 0;
}
markChannelRead(idx);
}
bool isDMTab() const { return _viewChannelIdx == 0xFF; }
bool isDMInboxMode() const { return _viewChannelIdx == 0xFF && _dmInboxMode; }
bool isDMConversation() const { return _viewChannelIdx == 0xFF && !_dmInboxMode; }
const char* getDMFilterName() const { return _dmFilterName; }
// Open a specific contact's DM conversation directly (skipping inbox)
void openConversation(const char* contactName, int contactIdx = -1, uint8_t perms = 0) {
strncpy(_dmFilterName, contactName, sizeof(_dmFilterName) - 1);
_dmFilterName[sizeof(_dmFilterName) - 1] = '\0';
_dmInboxMode = false;
_dmContactIdx = contactIdx;
_dmContactPerms = perms;
_scrollPos = 0;
Serial.printf("[ChannelScreen] openConversation: name=%s, idx=%d, perms=%d\n",
contactName, contactIdx, perms);
}
int getDMContactIdx() const { return _dmContactIdx; }
uint8_t getDMContactPerms() const { return _dmContactPerms; }
void setDMContactPerms(uint8_t p) { _dmContactPerms = p; }
bool isShowingPathOverlay() const { return _showPathOverlay; }
void dismissPathOverlay() { _showPathOverlay = false; _pathScrollPos = 0; }
// Set pointer to per-contact DM unread array (called by UITask after allocation)
void setDMUnreadPtr(const uint8_t* ptr) { _dmUnreadPtr = ptr; }
// Subtract a specific amount from the DM unread slot (used by per-contact clearing)
void subtractDMUnread(int count) {
int slot = MAX_GROUP_CHANNELS; // DM slot
_unread[slot] -= count;
if (_unread[slot] < 0) _unread[slot] = 0;
}
// --- Reply select mode (R key → pick a message → Enter to @mention reply) ---
bool isReplySelectMode() const { return _replySelectMode; }
void exitReplySelect() { _replySelectMode = false; _replySelectPos = -1; }
@@ -206,7 +274,7 @@ public:
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) {
if (_messages[idx].valid && msgMatchesView(_messages[idx])) {
rsMsgs[count++] = idx;
}
}
@@ -230,7 +298,7 @@ public:
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) {
if (_messages[idx].valid && msgMatchesView(_messages[idx])) {
rsMsgs[count++] = idx;
}
}
@@ -277,7 +345,7 @@ public:
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
if (msgMatchesView(_messages[idx])
&& _messages[idx].path_len != 0) {
return &_messages[idx];
}
@@ -449,7 +517,15 @@ public:
// Get channel name
ChannelDetails channel;
if (the_mesh.getChannel(_viewChannelIdx, channel)) {
if (_viewChannelIdx == 0xFF) {
if (_dmInboxMode) {
display.print("Direct Messages");
} else {
char hdr[40];
snprintf(hdr, sizeof(hdr), "DM: %s", _dmFilterName);
display.print(hdr);
}
} else if (the_mesh.getChannel(_viewChannelIdx, channel)) {
display.print(channel.name);
} else {
sprintf(tmp, "Channel %d", _viewChannelIdx);
@@ -464,6 +540,180 @@ public:
// Divider line
display.drawRect(0, 11, display.width(), 1);
// === DM Inbox mode: show list of contacts with DMs ===
if (_viewChannelIdx == 0xFF && _dmInboxMode) {
#define DM_INBOX_MAX 20
struct DMInboxEntry {
char name[32];
int msgCount;
int unreadCount;
uint32_t newestTs;
};
DMInboxEntry inbox[DM_INBOX_MAX];
int inboxCount = 0;
// Scan all DMs and build unique sender list
for (int i = 0; i < _msgCount && i < 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 != 0xFF) continue;
char sender[32];
if (!extractSenderName(_messages[idx].text, sender, sizeof(sender))) continue;
// Find existing entry
int found = -1;
for (int j = 0; j < inboxCount; j++) {
if (strcmp(inbox[j].name, sender) == 0) { found = j; break; }
}
if (found < 0 && inboxCount < DM_INBOX_MAX) {
found = inboxCount++;
strncpy(inbox[found].name, sender, 31);
inbox[found].name[31] = '\0';
inbox[found].msgCount = 0;
inbox[found].unreadCount = 0;
inbox[found].newestTs = 0;
}
if (found >= 0) {
inbox[found].msgCount++;
if (_messages[idx].timestamp > inbox[found].newestTs)
inbox[found].newestTs = _messages[idx].timestamp;
}
}
// Look up unread counts from per-contact array
if (_dmUnreadPtr) {
for (int e = 0; e < inboxCount; e++) {
uint32_t numC = the_mesh.getNumContacts();
ContactInfo ci;
for (uint32_t c = 0; c < numC; c++) {
if (the_mesh.getContactByIdx(c, ci) && strcmp(ci.name, inbox[e].name) == 0) {
inbox[e].unreadCount = _dmUnreadPtr[c];
break;
}
}
}
}
// Sort by newest timestamp descending (insertion sort)
for (int i = 1; i < inboxCount; i++) {
DMInboxEntry tmp2 = inbox[i];
int j = i - 1;
while (j >= 0 && inbox[j].newestTs < tmp2.newestTs) {
inbox[j + 1] = inbox[j];
j--;
}
inbox[j + 1] = tmp2;
}
// Render inbox list
display.setTextSize(0);
int lineH = 9;
int headerH = 14;
int footerH = 14;
int maxY = display.height() - footerH;
int y = headerH;
int maxVisible = (maxY - headerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
// Clamp scroll
if (_dmInboxScroll >= inboxCount) _dmInboxScroll = inboxCount > 0 ? inboxCount - 1 : 0;
if (inboxCount == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, y);
display.print("No direct messages");
display.setCursor(0, y + lineH);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("DMs from contacts appear here");
#else
display.print("A/D: Switch channel");
#endif
} else {
int startIdx = max(0, min(_dmInboxScroll - maxVisible / 2,
inboxCount - maxVisible));
int endIdx = min(inboxCount, startIdx + maxVisible);
for (int i = startIdx; i < endIdx && y + lineH <= maxY; i++) {
bool selected = (i == _dmInboxScroll);
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineH);
#else
display.fillRect(0, y + 5, display.width(), lineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
// Prefix: > for selected, unread indicator
char prefix[6];
if (inbox[i].unreadCount > 0) {
snprintf(prefix, sizeof(prefix), "%s*%d", selected ? ">" : " ", inbox[i].unreadCount);
} else {
snprintf(prefix, sizeof(prefix), "%s ", selected ? ">" : " ");
}
display.print(prefix);
// Name (truncated)
char filteredName[32];
display.translateUTF8ToBlocks(filteredName, inbox[i].name, sizeof(filteredName));
// Right side: message count + age
char ageStr[8];
uint32_t age = _rtc->getCurrentTime() - inbox[i].newestTs;
if (age < 60) snprintf(ageStr, sizeof(ageStr), "%ds", age);
else if (age < 3600) snprintf(ageStr, sizeof(ageStr), "%dm", age / 60);
else if (age < 86400) snprintf(ageStr, sizeof(ageStr), "%dh", age / 3600);
else snprintf(ageStr, sizeof(ageStr), "%dd", age / 86400);
char rightStr[16];
snprintf(rightStr, sizeof(rightStr), "(%d) %s", inbox[i].msgCount, ageStr);
int rightW = display.getTextWidth(rightStr) + 2;
int nameX = display.getTextWidth(prefix) + 2;
int nameMaxW = display.width() - nameX - rightW - 2;
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
display.setCursor(display.width() - rightW, y);
display.print(rightStr);
y += lineH;
}
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Swipe:Nav");
const char* rtInbox = "Hold:Open";
display.setCursor(display.width() - display.getTextWidth(rtInbox) - 2, footerY);
display.print(rtInbox);
#else
display.setCursor(0, footerY);
display.print("Q:Bck A/D:Ch");
const char* rtInbox = "Ent:Open";
display.setCursor(display.width() - display.getTextWidth(rtInbox) - 2, footerY);
display.print(rtInbox);
#endif
#ifdef USE_EINK
return 5000;
#else
return 1000;
#endif
}
// --- Path detail overlay ---
if (_showPathOverlay) {
@@ -667,18 +917,154 @@ public:
display.setTextSize(0); // Tiny font for body text
display.setCursor(0, 20);
display.setColor(DisplayDriver::LIGHT);
display.print("No messages yet");
display.setCursor(0, 30);
if (_viewChannelIdx == 0xFF) {
char noMsg[48];
snprintf(noMsg, sizeof(noMsg), "No messages from %s", _dmFilterName);
display.print(noMsg);
display.setCursor(0, 30);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe: Switch channel");
display.setCursor(0, 40);
display.print("Long press: Compose");
display.print("Hold: Compose reply");
#else
display.print("A/D: Switch channel");
display.setCursor(0, 40);
display.print("C: Compose message");
display.print("Q: Back to inbox");
display.setCursor(0, 40);
display.print("Ent: Compose reply");
#endif
} else {
display.print("No messages yet");
display.setCursor(0, 30);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe: Switch channel");
display.setCursor(0, 40);
display.print("Long press: Compose");
#else
display.print("A/D: Switch channel");
display.setCursor(0, 40);
display.print("C: Compose message");
#endif
}
display.setTextSize(1); // Restore for footer
} else if (_viewChannelIdx == 0xFF && _dmInboxMode) {
// =================================================================
// DM Inbox: list of contacts/rooms you have DM history with
// =================================================================
display.setTextSize(0);
int lineHeight = 9;
int headerHeight = 14;
int footerHeight = 14;
int maxY = display.height() - footerHeight;
int y = headerHeight;
// Scan all DM messages and collect unique senders
#define DM_INBOX_MAX 16
struct InboxEntry {
char name[24];
int count;
uint32_t newest_ts;
};
static InboxEntry inbox[DM_INBOX_MAX];
int inboxCount = 0;
for (int i = 0; i < _msgCount; 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 != 0xFF) continue;
char sender[24];
if (!extractSenderName(_messages[idx].text, sender, sizeof(sender))) continue;
// Find or add sender in inbox
bool found = false;
for (int j = 0; j < inboxCount; j++) {
if (strcmp(inbox[j].name, sender) == 0) {
inbox[j].count++;
if (_messages[idx].timestamp > inbox[j].newest_ts)
inbox[j].newest_ts = _messages[idx].timestamp;
found = true;
break;
}
}
if (!found && inboxCount < DM_INBOX_MAX) {
strncpy(inbox[inboxCount].name, sender, 23);
inbox[inboxCount].name[23] = '\0';
inbox[inboxCount].count = 1;
inbox[inboxCount].newest_ts = _messages[idx].timestamp;
inboxCount++;
}
}
// Sort by newest timestamp descending (most recent first)
for (int i = 1; i < inboxCount; i++) {
InboxEntry tmp2 = inbox[i];
int j = i - 1;
while (j >= 0 && inbox[j].newest_ts < tmp2.newest_ts) {
inbox[j + 1] = inbox[j];
j--;
}
inbox[j + 1] = tmp2;
}
if (inboxCount == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, y);
display.print("No conversations");
} else {
// Clamp scroll
if (_dmInboxScroll >= inboxCount) _dmInboxScroll = inboxCount - 1;
if (_dmInboxScroll < 0) _dmInboxScroll = 0;
int maxVisible = (maxY - headerHeight) / lineHeight;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_dmInboxScroll - maxVisible / 2,
inboxCount - maxVisible));
int endIdx = min(inboxCount, startIdx + maxVisible);
uint32_t now = _rtc->getCurrentTime();
for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) {
bool selected = (i == _dmInboxScroll);
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + 5, display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
display.print(selected ? ">" : " ");
// Name (ellipsized)
char filteredName[24];
display.translateUTF8ToBlocks(filteredName, inbox[i].name, sizeof(filteredName));
// Right side: message count + age
char ageStr[8];
uint32_t age = now - inbox[i].newest_ts;
if (age < 60) snprintf(ageStr, sizeof(ageStr), "%ds", age);
else if (age < 3600) snprintf(ageStr, sizeof(ageStr), "%dm", age / 60);
else if (age < 86400) snprintf(ageStr, sizeof(ageStr), "%dh", age / 3600);
else snprintf(ageStr, sizeof(ageStr), "%dd", age / 86400);
char rightStr[16];
snprintf(rightStr, sizeof(rightStr), "[%d] %s", inbox[i].count, ageStr);
int rightW = display.getTextWidth(rightStr) + 2;
int nameX = display.getTextWidth(">") + 2;
int nameMaxW = display.width() - nameX - rightW - 2;
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
display.setCursor(display.width() - rightW, y);
display.print(rightStr);
y += lineHeight;
}
}
display.setTextSize(1);
} else {
display.setTextSize(0); // Tiny font for message body
int lineHeight = 9; // 8px font + 1px spacing
@@ -701,7 +1087,7 @@ public:
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
if (msgMatchesView(_messages[idx])) {
channelMsgs[numChannelMsgs++] = idx;
}
}
@@ -968,13 +1354,20 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Swipe:Ch/Scroll");
const char* midCh = "Tap:Path";
display.setCursor((display.width() - display.getTextWidth(midCh)) / 2, footerY);
display.print(midCh);
const char* rtCh = "Hold:Compose";
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
display.print(rtCh);
if (_viewChannelIdx == 0xFF) {
display.print("Swipe:Scroll");
const char* rtCh = "Hold:Reply";
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
display.print(rtCh);
} else {
display.print("Swipe:Ch/Scroll");
const char* midCh = "Tap:Path";
display.setCursor((display.width() - display.getTextWidth(midCh)) / 2, footerY);
display.print(midCh);
const char* rtCh = "Hold:Compose";
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
display.print(rtCh);
}
#else
// Left side: abbreviated controls
if (_replySelectMode) {
@@ -982,6 +1375,17 @@ public:
const char* rightText = "Ent:Reply";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
} else if (_viewChannelIdx == 0xFF) {
Serial.printf("[DM Footer] perms=%d, contactIdx=%d, inboxMode=%d\n",
_dmContactPerms, _dmContactIdx, _dmInboxMode);
if (_dmContactPerms > 0) {
display.print("Q:Exit L:Admin");
} else {
display.print("Q:Exit");
}
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";
@@ -1080,10 +1484,90 @@ public:
return true; // Consume all other keys in reply select
}
// --- DM Inbox mode (two-level DM view) ---
if (_viewChannelIdx == 0xFF && _dmInboxMode) {
// W - scroll up in inbox
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_dmInboxScroll > 0) { _dmInboxScroll--; return true; }
return false;
}
// S - scroll down in inbox
if (c == 's' || c == 'S' || c == 0xF1) {
_dmInboxScroll++; // Clamped during render
return true;
}
// Enter - open conversation for selected sender
if (c == '\r' || c == 13) {
// Rebuild inbox to find the selected sender name
int cur = 0;
for (int i = 0; i < _msgCount; 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 != 0xFF) continue;
char sender[24];
if (!extractSenderName(_messages[idx].text, sender, sizeof(sender))) continue;
// Check if we've seen this sender already
bool dup = false;
for (int k = 0; k < i; k++) {
int ki = _newestIdx - k;
while (ki < 0) ki += CHANNEL_MSG_HISTORY_SIZE;
ki = ki % CHANNEL_MSG_HISTORY_SIZE;
if (!_messages[ki].valid || _messages[ki].channel_idx != 0xFF) continue;
char prev[24];
if (extractSenderName(_messages[ki].text, prev, sizeof(prev))
&& strcmp(prev, sender) == 0) { dup = true; break; }
}
if (dup) continue;
if (cur == _dmInboxScroll) {
strncpy(_dmFilterName, sender, sizeof(_dmFilterName) - 1);
_dmFilterName[sizeof(_dmFilterName) - 1] = '\0';
_dmInboxMode = false;
_scrollPos = 0;
// Look up contact index
_dmContactIdx = -1;
_dmContactPerms = 0;
uint32_t numC = the_mesh.getNumContacts();
ContactInfo ci;
for (uint32_t c = 0; c < numC; c++) {
if (the_mesh.getContactByIdx(c, ci) && strcmp(ci.name, sender) == 0) {
_dmContactIdx = (int)c;
break;
}
}
return true;
}
cur++;
}
return true;
}
// Q - let main.cpp handle (back to home)
if (c == 'q' || c == 'Q' || c == '\b') {
return false;
}
// A/D pass through to channel switching below
if (c == 'a' || c == 'A' || c == 'd' || c == 'D') {
// Fall through to channel switching
} else {
return true; // Consume other keys
}
}
// --- DM Conversation mode: Q goes back to inbox ---
if (_viewChannelIdx == 0xFF && !_dmInboxMode) {
if (c == 'q' || c == 'Q' || c == '\b') {
_dmInboxMode = true;
_dmFilterName[0] = '\0';
_scrollPos = 0;
return true;
}
}
int channelMsgCount = getMessageCountForChannel();
// R - enter reply select mode
// R - enter reply select mode (group channels only — DM tab uses Enter to reply)
if (c == 'r' || c == 'R') {
if (_viewChannelIdx == 0xFF) return false; // Not applicable on DM tab
if (channelMsgCount > 0) {
_replySelectMode = true;
// Start with newest message selected
@@ -1120,14 +1604,12 @@ public:
}
}
// A - previous channel
// A - previous channel (includes DM tab at 0xFF)
if (c == 'a' || c == 'A') {
_replySelectMode = false;
_replySelectPos = -1;
if (_viewChannelIdx > 0) {
_viewChannelIdx--;
} else {
// Wrap to last valid channel
if (_viewChannelIdx == 0xFF) {
// DM tab → go to last valid group channel
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
@@ -1135,22 +1617,39 @@ public:
break;
}
}
} else if (_viewChannelIdx > 0) {
_viewChannelIdx--;
} else {
// Channel 0 → wrap to DM tab
_viewChannelIdx = 0xFF;
_dmInboxMode = true;
_dmInboxScroll = 0;
_dmFilterName[0] = '\0';
}
_scrollPos = 0;
markChannelRead(_viewChannelIdx);
return true;
}
// D - next channel
// D - next channel (includes DM tab at 0xFF)
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') {
_viewChannelIdx = nextIdx;
} else {
if (_viewChannelIdx == 0xFF) {
// DM tab → wrap to channel 0
_viewChannelIdx = 0;
} else {
ChannelDetails ch;
uint8_t nextIdx = _viewChannelIdx + 1;
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
_viewChannelIdx = nextIdx;
} else {
// Past last channel → go to DM tab
_viewChannelIdx = 0xFF;
_dmInboxMode = true;
_dmInboxScroll = 0;
_dmFilterName[0] = '\0';
}
}
_scrollPos = 0;
markChannelRead(_viewChannelIdx);
@@ -40,6 +40,9 @@ private:
// How many rows fit on screen (computed during render)
int _rowsPerPage;
// Pointer to per-contact DM unread array (owned by UITask, set via setter)
const uint8_t* _dmUnread = nullptr;
// --- helpers ---
static const char* filterLabel(FilterMode f) {
@@ -145,6 +148,9 @@ public:
void invalidateCache() { _cacheValid = false; }
// Set pointer to per-contact DM unread array (called by UITask after allocation)
void setDMUnreadPtr(const uint8_t* ptr) { _dmUnread = ptr; }
void resetScroll() {
_scrollPos = 0;
_cacheValid = false;
@@ -303,9 +309,14 @@ public:
char ageStr[6];
formatAge(ageStr, sizeof(ageStr), now, contact.last_advert_timestamp);
// Build right-side string: "hops age"
char rightStr[14];
snprintf(rightStr, sizeof(rightStr), "%sh %s", hopStr, ageStr);
// Build right-side string: "*N hops age" if unread, else "hops age"
int dmCount = (_dmUnread && _filteredIdx[i] < MAX_CONTACTS) ? _dmUnread[_filteredIdx[i]] : 0;
char rightStr[20];
if (dmCount > 0) {
snprintf(rightStr, sizeof(rightStr), "*%d %sh %s", dmCount, hopStr, ageStr);
} else {
snprintf(rightStr, sizeof(rightStr), "%sh %s", hopStr, ageStr);
}
int rightWidth = display.getTextWidth(rightStr) + 2;
// Name region: after prefix + small gap, before right info
@@ -561,7 +561,9 @@ public:
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
snprintf(tmp, sizeof(tmp), "Admin: %.16s", _repeaterName);
const char* hdrPrefix = (_state == STATE_PASSWORD_ENTRY || _state == STATE_LOGGING_IN)
? "Login" : "Admin";
snprintf(tmp, sizeof(tmp), "%s: %.16s", hdrPrefix, _repeaterName);
display.print(tmp);
if (_state >= STATE_CATEGORY_MENU && _state <= STATE_RESPONSE_VIEW) {
+134 -1
View File
@@ -1138,6 +1138,17 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
_node_prefs = node_prefs;
// Initialize message dedup ring buffer
memset(_dedup, 0, sizeof(_dedup));
_dedupIdx = 0;
// Allocate per-contact DM unread tracking (PSRAM if available)
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
_dmUnread = (uint8_t*)ps_calloc(MAX_CONTACTS, sizeof(uint8_t));
#else
_dmUnread = new uint8_t[MAX_CONTACTS]();
#endif
#if ENV_INCLUDE_GPS == 1
// Apply GPS preferences from stored prefs
if (_sensors != NULL && _node_prefs != NULL) {
@@ -1184,7 +1195,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
msg_preview = new MsgPreviewScreen(this, &rtc_clock);
channel_screen = new ChannelScreen(this, &rtc_clock);
((ChannelScreen*)channel_screen)->setDMUnreadPtr(_dmUnread);
contacts_screen = new ContactsScreen(this, &rtc_clock);
((ContactsScreen*)contacts_screen)->setDMUnreadPtr(_dmUnread);
text_reader = new TextReaderScreen(this);
notes_screen = new NotesScreen(this);
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
@@ -1266,6 +1279,24 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
const uint8_t* path, int8_t snr) {
_msgcount = msgcount;
// --- Dedup: suppress retry spam (same sender + text within 60s) ---
uint32_t nameH = simpleHash(from_name);
uint32_t textH = simpleHash(text);
unsigned long now = millis();
for (int i = 0; i < MSG_DEDUP_SIZE; i++) {
if (_dedup[i].name_hash == nameH && _dedup[i].text_hash == textH &&
(now - _dedup[i].millis) < MSG_DEDUP_WINDOW_MS) {
// Duplicate — suppress UI notification but still queued for BLE sync
Serial.printf("[Dedup] Suppressed duplicate from %s\n", from_name);
return;
}
}
// Record this message in the dedup ring
_dedup[_dedupIdx].name_hash = nameH;
_dedup[_dedupIdx].text_hash = textH;
_dedup[_dedupIdx].millis = now;
_dedupIdx = (_dedupIdx + 1) % MSG_DEDUP_SIZE;
// Add to preview screen (for notifications on non-keyboard devices)
((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text);
@@ -1282,7 +1313,16 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
}
// Add to channel history screen with channel index, path data, and SNR
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr);
// For DMs (channel_idx == 0xFF), prefix text with sender name so the
// DM inbox can extract it later. Channel messages already arrive from
// MeshCore in "Sender: message" format.
if (channel_idx == 0xFF) {
char dmFormatted[CHANNEL_MSG_TEXT_LEN];
snprintf(dmFormatted, sizeof(dmFormatted), "%s: %s", from_name, text);
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, dmFormatted, path, snr);
} else {
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr);
}
// If user is currently viewing this channel, mark it as read immediately
// (they can see the message arrive in real-time)
@@ -1290,6 +1330,18 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
((ChannelScreen *) channel_screen)->getViewChannelIdx() == channel_idx) {
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
}
// Per-contact DM unread tracking: find contact index by name
if (channel_idx == 0xFF && _dmUnread) {
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo contact;
for (uint32_t ci = 0; ci < numContacts; ci++) {
if (the_mesh.getContactByIdx(ci, contact) && strcmp(contact.name, from_name) == 0) {
if (_dmUnread[ci] < 255) _dmUnread[ci]++;
break;
}
}
}
#if defined(LilyGo_TDeck_Pro) || defined(LilyGo_T5S3_EPaper_Pro)
// Don't interrupt user with popup - just show brief notification
@@ -2180,6 +2232,30 @@ void UITask::gotoChannelScreen() {
_next_refresh = 100;
}
void UITask::gotoDMTab() {
((ChannelScreen *) channel_screen)->setViewChannelIdx(0xFF); // switches + marks read
((ChannelScreen *) channel_screen)->resetScroll();
setCurrScreen(channel_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoDMConversation(const char* contactName, int contactIdx, uint8_t perms) {
ChannelScreen* cs = (ChannelScreen*)channel_screen;
cs->setViewChannelIdx(0xFF); // enters inbox mode + marks read
cs->openConversation(contactName, contactIdx, perms); // switches to conversation mode
cs->resetScroll();
setCurrScreen(channel_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoContactsScreen() {
((ContactsScreen *) contacts_screen)->resetScroll();
setCurrScreen(contacts_screen);
@@ -2288,10 +2364,34 @@ void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, cons
void UITask::markChannelReadFromBLE(uint8_t channel_idx) {
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
// If clearing DMs, also zero all per-contact DM counts
if (channel_idx == 0xFF && _dmUnread) {
memset(_dmUnread, 0, MAX_CONTACTS * sizeof(uint8_t));
}
// Trigger a refresh so the home screen unread count updates in real-time
_next_refresh = millis() + 200;
}
bool UITask::hasDMUnread(int contactIdx) const {
if (!_dmUnread || contactIdx < 0 || contactIdx >= MAX_CONTACTS) return false;
return _dmUnread[contactIdx] > 0;
}
int UITask::getDMUnreadCount(int contactIdx) const {
if (!_dmUnread || contactIdx < 0 || contactIdx >= MAX_CONTACTS) return 0;
return _dmUnread[contactIdx];
}
void UITask::clearDMUnread(int contactIdx) {
if (!_dmUnread || contactIdx < 0 || contactIdx >= MAX_CONTACTS) return;
int count = _dmUnread[contactIdx];
if (count > 0) {
_dmUnread[contactIdx] = 0;
((ChannelScreen *) channel_screen)->subtractDMUnread(count);
_next_refresh = millis() + 200;
}
}
void UITask::gotoRepeaterAdmin(int contactIdx) {
// Lazy-initialize on first use (same pattern as audiobook player)
if (repeater_admin == nullptr) {
@@ -2317,6 +2417,17 @@ void UITask::gotoRepeaterAdmin(int contactIdx) {
_next_refresh = 100;
}
void UITask::gotoRepeaterAdminDirect(int contactIdx) {
// Open admin and auto-submit cached password (skips password screen)
_skipRoomRedirect = true; // Don't redirect back to conversation after login
gotoRepeaterAdmin(contactIdx);
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)repeater_admin;
if (admin && admin->getState() == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) {
// If password was pre-filled from cache, simulate Enter to submit login
admin->handleInput('\r');
}
}
void UITask::gotoDiscoveryScreen() {
((DiscoveryScreen*)discovery_screen)->resetScroll();
setCurrScreen(discovery_screen);
@@ -2381,6 +2492,28 @@ void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t serv
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
_next_refresh = 100; // trigger re-render
if (success) {
int cidx = ((RepeaterAdminScreen*)repeater_admin)->getContactIdx();
if (cidx >= 0) {
clearDMUnread(cidx);
// Room server login: redirect to conversation view with stored permissions.
// Admin users see L:Admin footer to access the admin panel.
// Skip redirect if user explicitly pressed L to get to admin.
if (!_skipRoomRedirect) {
ContactInfo contact;
if (the_mesh.getContactByIdx(cidx, contact) && contact.type == ADV_TYPE_ROOM) {
uint8_t maskedPerms = permissions & 0x03;
Serial.printf("[Admin] Room login (raw=%d, masked=%d) — opening conversation for %s\n",
permissions, maskedPerms, contact.name);
gotoDMConversation(contact.name, cidx, maskedPerms);
return;
}
}
_skipRoomRedirect = false;
}
}
}
}
+31
View File
@@ -114,6 +114,26 @@ class UITask : public AbstractUITask {
unsigned long _lastLockRefresh = 0; // Periodic lock screen clock update
#endif
// --- Message dedup ring buffer (suppress retry spam at UI level) ---
#define MSG_DEDUP_SIZE 8
#define MSG_DEDUP_WINDOW_MS 60000 // 60 seconds
struct MsgDedup {
uint32_t name_hash;
uint32_t text_hash;
unsigned long millis;
};
MsgDedup _dedup[MSG_DEDUP_SIZE];
int _dedupIdx = 0;
// --- Per-contact DM unread tracking ---
uint8_t* _dmUnread = nullptr; // PSRAM-allocated, MAX_CONTACTS entries
static uint32_t simpleHash(const char* s) {
uint32_t h = 5381;
while (*s) { h = ((h << 5) + h) ^ (uint8_t)*s++; }
return h;
}
void userLedHandler();
// Button action handlers
@@ -141,6 +161,8 @@ public:
void gotoHomeScreen();
void gotoChannelScreen(); // Navigate to channel message screen
void gotoDMTab(); // Navigate directly to DM tab on channel screen
void gotoDMConversation(const char* contactName, int contactIdx = -1, uint8_t perms = 0);
void gotoContactsScreen(); // Navigate to contacts list
void gotoTextReader(); // *** NEW: Navigate to text reader ***
void gotoNotesScreen(); // Navigate to notes editor
@@ -148,6 +170,7 @@ public:
void gotoOnboarding(); // Navigate to settings in onboarding mode
void gotoAudiobookPlayer(); // Navigate to audiobook player
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
void gotoRepeaterAdminDirect(int contactIdx); // Auto-login admin (L key from conversation)
void gotoDiscoveryScreen(); // Navigate to node discovery scan
void gotoLastHeardScreen(); // Navigate to last heard passive list
#if HAS_GPS
@@ -171,6 +194,14 @@ public:
}
int getMsgCount() const { return _msgcount; }
int getUnreadMsgCount() const; // Per-channel unread tracking (standalone)
// Per-contact DM unread tracking
bool hasDMUnread(int contactIdx) const;
int getDMUnreadCount(int contactIdx) const;
void clearDMUnread(int contactIdx);
// Flag: suppress room→conversation redirect on next login (L key admin access)
bool _skipRoomRedirect = false;
bool hasDisplay() const { return _display != NULL; }
bool isButtonPressed() const;
bool isOnChannelScreen() const { return curr == channel_screen; }
-35
View File
@@ -1,35 +0,0 @@
#include <Arduino.h>
#include "TDeckBoard.h"
uint32_t deviceOnline = 0x00;
void TDeckBoard::begin() {
ESP32Board::begin();
// Enable peripheral power
pinMode(PIN_PERF_POWERON, OUTPUT);
digitalWrite(PIN_PERF_POWERON, HIGH);
// Configure user button
pinMode(PIN_USER_BTN, INPUT);
// Configure LoRa Pins
pinMode(P_LORA_MISO, INPUT_PULLUP);
// pinMode(P_LORA_DIO_1, INPUT_PULLUP);
#ifdef P_LORA_TX_LED
digitalWrite(P_LORA_TX_LED, HIGH); // inverted pin for SX1276 - HIGH for off
#endif
esp_reset_reason_t reason = esp_reset_reason();
if (reason == ESP_RST_DEEPSLEEP) {
long wakeup_source = esp_sleep_get_ext1_wakeup_status();
if (wakeup_source & (1 << P_LORA_DIO_1)) {
startup_reason = BD_STARTUP_RX_PACKET; // received a LoRa packet (while in deep sleep)
}
rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS);
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
}
}
-68
View File
@@ -1,68 +0,0 @@
#pragma once
#include <Wire.h>
#include <Arduino.h>
#include "helpers/ESP32Board.h"
#include <driver/rtc_io.h>
#define PIN_VBAT_READ 4
#define BATTERY_SAMPLES 8
#define ADC_MULTIPLIER (2.0f * 3.3f * 1000)
class TDeckBoard : public ESP32Board {
public:
void begin();
#ifdef P_LORA_TX_LED
void onBeforeTransmit() override{
digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on - invert pin for SX1276
}
void onAfterTransmit() override{
digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED off - invert pin for SX1276
}
#endif
void enterDeepSleep(uint32_t secs, int pin_wake_btn) {
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
// Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep
rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY);
rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1);
rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS);
if (pin_wake_btn < 0) {
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet
} else {
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn
}
if (secs > 0) {
esp_sleep_enable_timer_wakeup(secs * 1000000);
}
// Finally set ESP32 into sleep
esp_deep_sleep_start(); // CPU halts here and never returns!
}
uint16_t getBattMilliVolts() {
#if defined(PIN_VBAT_READ) && defined(ADC_MULTIPLIER)
analogReadResolution(12);
uint32_t raw = 0;
for (int i = 0; i < BATTERY_SAMPLES; i++) {
raw += analogRead(PIN_VBAT_READ);
}
raw = raw / BATTERY_SAMPLES;
return (ADC_MULTIPLIER * raw) / 4096;
#else
return 0;
#endif
}
const char* getManufacturerName() const{
return "LilyGo T-Deck";
}
};
-115
View File
@@ -1,115 +0,0 @@
[LilyGo_TDeck]
extends = esp32_base
board = t-deck
build_flags =
${esp32_base.build_flags}
${sensor_base.build_flags}
-I variants/lilygo_tdeck
-D LILYGO_TDECK
-D BOARD_HAS_PSRAM=1
-D CORE_DEBUG_LEVEL=1
-D ARDUINO_USB_CDC_ON_BOOT=1
-D PIN_USER_BTN=0 ; Trackball button
-D PIN_PERF_POWERON=10 ; Peripheral power pin
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D LORA_TX_POWER=22
-D SX126X_DIO2_AS_RF_SWITCH=false
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
-D SX126X_DIO3_TCXO_VOLTAGE=1.8f
-D P_LORA_DIO_1=45 ; LORA IRQ pin
-D ENV_INCLUDE_GPS=1
-D ENV_INCLUDE_AHTX0=0
-D ENV_INCLUDE_BME280=0
-D ENV_INCLUDE_BMP280=0
-D ENV_INCLUDE_SHTC3=0
-D ENV_INCLUDE_SHT4X=0
-D ENV_INCLUDE_LPS22HB=0
-D ENV_INCLUDE_INA3221=0
-D ENV_INCLUDE_INA219=0
-D ENV_INCLUDE_INA226=0
-D ENV_INCLUDE_INA260=0
-D ENV_INCLUDE_MLX90614=0
-D ENV_INCLUDE_VL53L0X=0
-D ENV_INCLUDE_BME680=0
-D ENV_INCLUDE_BMP085=0
-D P_LORA_NSS=9 ; LORA SS pin
-D P_LORA_RESET=17 ; LORA RST pin
-D P_LORA_BUSY=13 ; LORA Busy pin
-D P_LORA_SCLK=40 ; LORA SCLK pin
-D P_LORA_MISO=38 ; LORA MISO pin
-D P_LORA_MOSI=41 ; LORA MOSI pin
-D DISPLAY_CLASS=ST7789LCDDisplay
-D DISPLAY_SCALE_X=2.5
-D DISPLAY_SCALE_Y=3.75
-D PIN_TFT_RST=-1
-D PIN_TFT_VDD_CTL=-1
-D PIN_TFT_LEDA_CTL=42
-D PIN_TFT_CS=12
-D PIN_TFT_DC=11
-D PIN_TFT_SCL=40
-D PIN_TFT_SDA=41
-D PIN_GPS_RX=43
-D PIN_GPS_TX=44
-D GPS_BAUD_RATE=38400
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/lilygo_tdeck>
+<helpers/sensors/*.cpp>
lib_deps =
${esp32_base.lib_deps}
${sensor_base.lib_deps}
adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0
[env:LilyGo_TDeck_companion_radio_usb]
extends = LilyGo_TDeck
build_flags =
${LilyGo_TDeck.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D OFFLINE_QUEUE_SIZE=256
build_src_filter = ${LilyGo_TDeck.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/ST7789LCDDisplay.cpp>
lib_deps =
${LilyGo_TDeck.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:LilyGo_TDeck_companion_radio_ble]
extends = LilyGo_TDeck
build_flags =
${LilyGo_TDeck.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
build_src_filter = ${LilyGo_TDeck.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/ST7789LCDDisplay.cpp>
lib_deps =
${LilyGo_TDeck.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:LilyGo_TDeck_repeater]
extends = LilyGo_TDeck
build_flags =
${LilyGo_TDeck.build_flags}
-D ADVERT_NAME='"TDeck Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
build_src_filter = ${LilyGo_TDeck.build_src_filter}
+<../examples/simple_repeater>
+<helpers/ui/ST7789LCDDisplay.cpp>
lib_deps =
${LilyGo_TDeck.lib_deps}
${esp32_ota.lib_deps}
-55
View File
@@ -1,55 +0,0 @@
#include <Arduino.h>
#include "target.h"
TDeckBoard board;
#if defined(P_LORA_SCLK)
static SPIClass spi;
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi);
#else
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY);
#endif
WRAPPER_CLASS radio_driver(radio, board);
ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
MicroNMEALocationProvider gps(Serial1, &rtc_clock);
EnvironmentSensorManager sensors(gps);
#ifdef DISPLAY_CLASS
DISPLAY_CLASS display;
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
#endif
bool radio_init() {
fallback_clock.begin();
rtc_clock.begin(Wire);
Wire.begin(18, 8);
#if defined(P_LORA_SCLK)
return radio.std_init(&spi);
#else
return radio.std_init();
#endif
}
uint32_t radio_get_rng_seed() {
return radio.random(0x7FFFFFFF);
}
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
radio.setFrequency(freq);
radio.setSpreadingFactor(sf);
radio.setBandwidth(bw);
radio.setCodingRate(cr);
}
void radio_set_tx_power(uint8_t dbm) {
radio.setOutputPower(dbm);
}
mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
-31
View File
@@ -1,31 +0,0 @@
#pragma once
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <TDeckBoard.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include <helpers/SensorManager.h>
#ifdef DISPLAY_CLASS
#include <helpers/ui/ST7789LCDDisplay.h>
#include <helpers/ui/MomentaryButton.h>
#endif
#include "helpers/sensors/EnvironmentSensorManager.h"
#include "helpers/sensors/MicroNMEALocationProvider.h"
extern TDeckBoard board;
extern WRAPPER_CLASS radio_driver;
extern AutoDiscoverRTCClock rtc_clock;
extern EnvironmentSensorManager sensors;
#ifdef DISPLAY_CLASS
extern DISPLAY_CLASS display;
extern MomentaryButton user_btn;
#endif
bool radio_init();
uint32_t radio_get_rng_seed();
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
void radio_set_tx_power(uint8_t dbm);
mesh::LocalIdentity radio_new_identity();