diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 7486b0ed..cf4ed245 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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 { diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index f749e4de..3459c5cb 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -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 diff --git a/examples/companion_radio/ui-new/ChannelScreen.h b/examples/companion_radio/ui-new/ChannelScreen.h index b8d84f1e..3d744242 100644 --- a/examples/companion_radio/ui-new/ChannelScreen.h +++ b/examples/companion_radio/ui-new/ChannelScreen.h @@ -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); diff --git a/examples/companion_radio/ui-new/Contactsscreen.h b/examples/companion_radio/ui-new/Contactsscreen.h index 7e00ad68..6b791470 100644 --- a/examples/companion_radio/ui-new/Contactsscreen.h +++ b/examples/companion_radio/ui-new/Contactsscreen.h @@ -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 diff --git a/examples/companion_radio/ui-new/Repeateradminscreen.h b/examples/companion_radio/ui-new/Repeateradminscreen.h index ea986b6a..dcf43193 100644 --- a/examples/companion_radio/ui-new/Repeateradminscreen.h +++ b/examples/companion_radio/ui-new/Repeateradminscreen.h @@ -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) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 5536bb19..59a99a9e 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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; + } + } } } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 227b06fd..25aed75d 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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; } diff --git a/variants/lilygo_tdeck/TDeckBoard.cpp b/variants/lilygo_tdeck/TDeckBoard.cpp deleted file mode 100644 index ad1e435b..00000000 --- a/variants/lilygo_tdeck/TDeckBoard.cpp +++ /dev/null @@ -1,35 +0,0 @@ -#include -#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); - } -} \ No newline at end of file diff --git a/variants/lilygo_tdeck/TDeckBoard.h b/variants/lilygo_tdeck/TDeckBoard.h deleted file mode 100644 index 7ed007af..00000000 --- a/variants/lilygo_tdeck/TDeckBoard.h +++ /dev/null @@ -1,68 +0,0 @@ -#pragma once - -#include -#include -#include "helpers/ESP32Board.h" -#include - -#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"; - } -}; \ No newline at end of file diff --git a/variants/lilygo_tdeck/platformio.ini b/variants/lilygo_tdeck/platformio.ini deleted file mode 100644 index 807663f8..00000000 --- a/variants/lilygo_tdeck/platformio.ini +++ /dev/null @@ -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> - + -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} - + - + - +<../examples/companion_radio/*.cpp> - +<../examples/companion_radio/ui-new/*.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} - + - + - +<../examples/companion_radio/*.cpp> - +<../examples/companion_radio/ui-new/*.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> - + -lib_deps = - ${LilyGo_TDeck.lib_deps} - ${esp32_ota.lib_deps} \ No newline at end of file diff --git a/variants/lilygo_tdeck/target.cpp b/variants/lilygo_tdeck/target.cpp deleted file mode 100644 index 50ffa735..00000000 --- a/variants/lilygo_tdeck/target.cpp +++ /dev/null @@ -1,55 +0,0 @@ -#include -#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 -} \ No newline at end of file diff --git a/variants/lilygo_tdeck/target.h b/variants/lilygo_tdeck/target.h deleted file mode 100644 index 4640925f..00000000 --- a/variants/lilygo_tdeck/target.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#define RADIOLIB_STATIC_ONLY 1 -#include -#include -#include -#include -#include -#include -#ifdef DISPLAY_CLASS - #include - #include -#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(); \ No newline at end of file