diff --git a/src/config/UserConfig.cpp b/src/config/UserConfig.cpp index 4bf9d04..9ee01f7 100644 --- a/src/config/UserConfig.cpp +++ b/src/config/UserConfig.cpp @@ -50,6 +50,7 @@ bool UserConfig::parseJson(const String& json) { _settings.denseFontMode = doc["dense_font"] | false; _settings.trackballSpeed = doc["trackball_speed"] | 3; _settings.touchSensitivity = doc["touch_sens"] | 3; + _settings.transportEnabled = doc["transport"] | false; _settings.bleEnabled = doc["ble_enabled"] | true; _settings.audioEnabled = doc["audio_on"] | true; @@ -91,6 +92,7 @@ String UserConfig::serializeToJson() const { doc["dense_font"] = _settings.denseFontMode; doc["trackball_speed"] = _settings.trackballSpeed; doc["touch_sens"] = _settings.touchSensitivity; + doc["transport"] = _settings.transportEnabled; doc["ble_enabled"] = _settings.bleEnabled; doc["audio_on"] = _settings.audioEnabled; diff --git a/src/config/UserConfig.h b/src/config/UserConfig.h index 2506464..66bf5d1 100644 --- a/src/config/UserConfig.h +++ b/src/config/UserConfig.h @@ -47,6 +47,9 @@ struct UserSettings { // Touch uint8_t touchSensitivity = 3; // 1-5 + // Network + bool transportEnabled = false; // Transport node (rebroadcasts packets) + // BLE bool bleEnabled = true; diff --git a/src/main.cpp b/src/main.cpp index a0e2052..3332b94 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -33,6 +33,7 @@ #include "reticulum/ReticulumManager.h" #include "reticulum/AnnounceManager.h" #include "reticulum/LXMFManager.h" +#include "reticulum/IdentityManager.h" #include "transport/LoRaInterface.h" #include "transport/WiFiInterface.h" #include "transport/TCPClientInterface.h" @@ -40,6 +41,7 @@ #include "transport/BLESideband.h" #include "config/UserConfig.h" #include "audio/AudioNotify.h" +#include #include #include #include @@ -81,6 +83,7 @@ BLESideband bleSideband; UserConfig userConfig; Power powerMgr; AudioNotify audio; +IdentityManager identityMgr; // --- Screens --- BootScreen bootScreen; @@ -401,6 +404,20 @@ void setup() { bootScreen.setProgress(0.65f, "Starting Reticulum..."); bootRender(); rns.setSDStore(&sdStore); + // Transport mode is loaded later from config; default is endpoint (no rebroadcast) + // We load a quick peek at the config to get the transport setting before RNS init + { + String json = sdStore.isReady() ? sdStore.readString(SD_PATH_USER_CONFIG) : ""; + if (json.isEmpty()) json = flash.readString(PATH_USER_CONFIG); + if (!json.isEmpty()) { + JsonDocument peek; + if (!deserializeJson(peek, json)) { + bool transport = peek["transport"] | false; + rns.setTransportEnabled(transport); + Serial.printf("[BOOT] Transport mode: %s\n", transport ? "ON" : "OFF"); + } + } + } if (rns.begin(&radio, &flash)) { Serial.printf("[BOOT] Identity: %s\n", rns.identityHash().c_str()); bootScreen.setProgress(0.72f, "Reticulum active"); @@ -410,6 +427,9 @@ void setup() { } bootRender(); + // Step 15.5: Identity manager + identityMgr.begin(&flash, &sdStore); + // Step 16: Message store bootScreen.setProgress(0.72f, "Starting messaging..."); bootRender(); @@ -583,6 +603,7 @@ void setup() { settingsScreen.setWiFi(wifiImpl); settingsScreen.setTCPClients(&tcpClients); settingsScreen.setRNS(&rns); + settingsScreen.setIdentityManager(&identityMgr); settingsScreen.setUIManager(&ui); settingsScreen.setIdentityHash(rns.identityHash()); settingsScreen.setSaveCallback([]() -> bool { diff --git a/src/reticulum/IdentityManager.cpp b/src/reticulum/IdentityManager.cpp new file mode 100644 index 0000000..3e02a5c --- /dev/null +++ b/src/reticulum/IdentityManager.cpp @@ -0,0 +1,178 @@ +#include "IdentityManager.h" +#include +#include +#include + +bool IdentityManager::begin(FlashStore* flash, SDStore* sd) { + _flash = flash; + _sd = sd; + _flash->ensureDir(ID_DIR); + loadSlotMeta(); + + // If no slots exist, import the current active identity as slot 0 + if (_slots.empty()) { + if (_flash->exists("/identity/identity.key")) { + IdentitySlot slot; + slot.keyPath = "/identity/identity.key"; + slot.active = true; + + // Load the key to get its hash + RNS::Bytes keyData; + if (RNS::Utilities::OS::read_file(slot.keyPath.c_str(), keyData) > 0) { + RNS::Identity id(false); + if (id.load_private_key(keyData)) { + slot.hash = id.hexhash(); + } + } + _slots.push_back(slot); + _activeIdx = 0; + saveSlotMeta(); + } + } else { + // Find active slot + for (int i = 0; i < (int)_slots.size(); i++) { + if (_slots[i].active) { _activeIdx = i; break; } + } + } + + Serial.printf("[IDMGR] %d identities, active=%d\n", (int)_slots.size(), _activeIdx); + return true; +} + +String IdentityManager::slotKeyPath(int slotNum) const { + char path[48]; + snprintf(path, sizeof(path), "/identity/id_%d.key", slotNum); + return String(path); +} + +int IdentityManager::createIdentity(const String& displayName) { + if ((int)_slots.size() >= MAX_IDENTITIES) return -1; + + // Generate new identity + RNS::Identity newId; + RNS::Bytes privKey = newId.get_private_key(); + if (privKey.size() == 0) return -1; + + int slotNum = (int)_slots.size(); + String keyPath = slotKeyPath(slotNum); + + // Save key file + _flash->writeAtomic(keyPath.c_str(), privKey.data(), privKey.size()); + + IdentitySlot slot; + slot.hash = newId.hexhash(); + slot.displayName = displayName; + slot.keyPath = keyPath; + slot.active = false; + _slots.push_back(slot); + + saveSlotMeta(); + Serial.printf("[IDMGR] Created identity %d: %s\n", slotNum, slot.hash.substr(0, 16).c_str()); + return slotNum; +} + +bool IdentityManager::deleteIdentity(int index) { + if (index < 0 || index >= (int)_slots.size()) return false; + if ((int)_slots.size() <= 1) return false; // Can't delete last identity + if (_slots[index].active) return false; // Can't delete active identity + + // Remove key file + _flash->remove(_slots[index].keyPath.c_str()); + + _slots.erase(_slots.begin() + index); + + // Adjust active index + if (_activeIdx > index) _activeIdx--; + + saveSlotMeta(); + Serial.printf("[IDMGR] Deleted identity %d\n", index); + return true; +} + +bool IdentityManager::switchTo(int index, RNS::Identity& outIdentity) { + if (index < 0 || index >= (int)_slots.size()) return false; + if (index == _activeIdx) return true; + + // Load the key + RNS::Bytes keyData; + if (RNS::Utilities::OS::read_file(_slots[index].keyPath.c_str(), keyData) <= 0) { + Serial.printf("[IDMGR] Failed to read key for slot %d\n", index); + return false; + } + + RNS::Identity id(false); + if (!id.load_private_key(keyData)) { + Serial.printf("[IDMGR] Failed to load key for slot %d\n", index); + return false; + } + + // Mark old active as inactive + if (_activeIdx >= 0 && _activeIdx < (int)_slots.size()) { + _slots[_activeIdx].active = false; + } + + _slots[index].active = true; + _activeIdx = index; + + // Copy the active key to the standard identity path for ReticulumManager + _flash->writeAtomic("/identity/identity.key", keyData.data(), keyData.size()); + if (_sd && _sd->isReady()) { + _sd->ensureDir("/ratputer/identity"); + _sd->writeAtomic("/ratputer/identity/identity.key", keyData.data(), keyData.size()); + } + + outIdentity = id; + saveSlotMeta(); + Serial.printf("[IDMGR] Switched to identity %d: %s\n", index, _slots[index].hash.substr(0, 16).c_str()); + return true; +} + +void IdentityManager::setDisplayName(int index, const String& name) { + if (index < 0 || index >= (int)_slots.size()) return; + _slots[index].displayName = name; + saveSlotMeta(); +} + +void IdentityManager::refresh() { + loadSlotMeta(); +} + +void IdentityManager::loadSlotMeta() { + _slots.clear(); + String json = _flash->readString(META_PATH); + if (json.isEmpty()) return; + + JsonDocument doc; + if (deserializeJson(doc, json)) return; + + JsonArray arr = doc["slots"]; + if (!arr) return; + + for (JsonObject obj : arr) { + IdentitySlot slot; + slot.hash = obj["hash"] | ""; + slot.displayName = obj["name"] | ""; + slot.keyPath = obj["path"] | ""; + slot.active = obj["active"] | false; + if (!slot.keyPath.isEmpty()) { + _slots.push_back(slot); + } + } +} + +void IdentityManager::saveSlotMeta() { + JsonDocument doc; + JsonArray arr = doc["slots"].to(); + + for (auto& slot : _slots) { + JsonObject obj = arr.add(); + obj["hash"] = slot.hash; + obj["name"] = slot.displayName; + obj["path"] = slot.keyPath; + obj["active"] = slot.active; + } + + String json; + serializeJson(doc, json); + _flash->writeString(META_PATH, json); +} diff --git a/src/reticulum/IdentityManager.h b/src/reticulum/IdentityManager.h new file mode 100644 index 0000000..0afaaec --- /dev/null +++ b/src/reticulum/IdentityManager.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include +#include +#include "storage/FlashStore.h" +#include "storage/SDStore.h" + +struct IdentitySlot { + std::string hash; // hex hash (first 32 chars) + String displayName; // per-identity display name + String keyPath; // storage path to .key file + bool active; // currently loaded identity +}; + +class IdentityManager { +public: + bool begin(FlashStore* flash, SDStore* sd = nullptr); + + // List all stored identities + const std::vector& identities() const { return _slots; } + int count() const { return (int)_slots.size(); } + + // Current active identity index (-1 if none) + int activeIndex() const { return _activeIdx; } + + // Create a new identity, returns index + int createIdentity(const String& displayName = ""); + + // Delete identity at index (cannot delete last one) + bool deleteIdentity(int index); + + // Switch to identity at index — returns the loaded Identity + // Caller must reinitialize Destination after switching + bool switchTo(int index, RNS::Identity& outIdentity); + + // Save display name for identity at index + void setDisplayName(int index, const String& name); + + // Refresh slot list from storage + void refresh(); + +private: + void loadSlotMeta(); + void saveSlotMeta(); + String slotKeyPath(int slotNum) const; + + FlashStore* _flash = nullptr; + SDStore* _sd = nullptr; + std::vector _slots; + int _activeIdx = -1; + + static constexpr int MAX_IDENTITIES = 8; + static constexpr const char* META_PATH = "/identity/slots.json"; + static constexpr const char* ID_DIR = "/identity"; +}; diff --git a/src/reticulum/ReticulumManager.cpp b/src/reticulum/ReticulumManager.cpp index b30be88..a8589fc 100644 --- a/src/reticulum/ReticulumManager.cpp +++ b/src/reticulum/ReticulumManager.cpp @@ -68,13 +68,19 @@ bool ReticulumManager::begin(SX1262* radio, FlashStore* flash) { } _reticulum = RNS::Reticulum(); - RNS::Reticulum::transport_enabled(true); + RNS::Reticulum::transport_enabled(_transportEnabled); RNS::Reticulum::probe_destination_enabled(true); - // PSRAM allows larger tables than Ratputer - RNS::Transport::path_table_maxsize(64); - RNS::Transport::announce_table_maxsize(64); + if (_transportEnabled) { + // Transport node: larger tables for routing + RNS::Transport::path_table_maxsize(64); + RNS::Transport::announce_table_maxsize(64); + } else { + // Endpoint: smaller tables, no rebroadcasting + RNS::Transport::path_table_maxsize(32); + RNS::Transport::announce_table_maxsize(32); + } _reticulum.start(); - Serial.println("[RNS] Reticulum started (Transport Node)"); + Serial.printf("[RNS] Reticulum started (%s)\n", _transportEnabled ? "Transport Node" : "Endpoint"); if (!loadOrCreateIdentity()) { Serial.println("[RNS] ERROR: Identity creation failed!"); @@ -92,7 +98,7 @@ bool ReticulumManager::begin(SX1262* radio, FlashStore* flash) { _destination.accepts_links(true); _transportActive = true; - Serial.println("[RNS] Transport node active"); + Serial.printf("[RNS] %s active\n", _transportEnabled ? "Transport node" : "Endpoint"); return true; } diff --git a/src/reticulum/ReticulumManager.h b/src/reticulum/ReticulumManager.h index 23d79b5..5e2d094 100644 --- a/src/reticulum/ReticulumManager.h +++ b/src/reticulum/ReticulumManager.h @@ -39,6 +39,7 @@ public: bool begin(SX1262* radio, FlashStore* flash); void setSDStore(SDStore* sd) { _sd = sd; } + void setTransportEnabled(bool enabled) { _transportEnabled = enabled; } void loop(); void persistData(); @@ -66,6 +67,7 @@ private: LoRaInterface* _loraImpl = nullptr; FlashStore* _flash = nullptr; SDStore* _sd = nullptr; + bool _transportEnabled = false; // Default: endpoint mode (no rebroadcast) bool _transportActive = false; unsigned long _lastPersist = 0; unsigned long _lastAnnounceTime = 0; diff --git a/src/transport/LoRaInterface.cpp b/src/transport/LoRaInterface.cpp index 1ca105d..39c9cdf 100644 --- a/src/transport/LoRaInterface.cpp +++ b/src/transport/LoRaInterface.cpp @@ -73,16 +73,15 @@ void LoRaInterface::send_outgoing(const RNS::Bytes& data) { void LoRaInterface::loop() { if (!_online || !_radio) return; - // Periodic RX debug: dump RSSI + IRQ flags + chip status every 5 seconds + // Periodic RX debug: dump RSSI + chip status every 30 seconds static unsigned long lastRxDebug = 0; - if (millis() - lastRxDebug > 5000) { + if (millis() - lastRxDebug > 30000) { lastRxDebug = millis(); int rssi = _radio->currentRssi(); - uint16_t irq = _radio->getIrqFlags(); uint8_t status = _radio->getStatus(); uint8_t chipMode = (status >> 4) & 0x07; - Serial.printf("[LORA_IF] RX monitor: RSSI=%d dBm, IRQ=0x%04X, status=0x%02X(mode=%d), devErr=0x%04X\n", - rssi, irq, status, chipMode, _radio->getDeviceErrors()); + Serial.printf("[LORA_IF] RX: RSSI=%d dBm, status=0x%02X(mode=%d)\n", + rssi, status, chipMode); } int packetSize = _radio->parsePacket(); diff --git a/src/transport/TCPClientInterface.cpp b/src/transport/TCPClientInterface.cpp index f333d5a..778481b 100644 --- a/src/transport/TCPClientInterface.cpp +++ b/src/transport/TCPClientInterface.cpp @@ -50,14 +50,18 @@ void TCPClientInterface::loop() { return; } - // Read incoming frames - unsigned long rxStart = millis(); - int len = readFrame(_rxBuffer, sizeof(_rxBuffer)); - if (len > 0) { - RNS::Bytes data(_rxBuffer, len); - Serial.printf("[TCP] RX %d bytes from %s:%d (%lums)\n", - len, _host.c_str(), _port, millis() - rxStart); - InterfaceImpl::handle_incoming(data); + // Drain multiple incoming frames per loop (up to 10) + for (int i = 0; i < 10 && _client.available(); i++) { + unsigned long rxStart = millis(); + int len = readFrame(_rxBuffer, sizeof(_rxBuffer)); + if (len > 0) { + RNS::Bytes data(_rxBuffer, len); + Serial.printf("[TCP] RX %d bytes from %s:%d (%lums)\n", + len, _host.c_str(), _port, millis() - rxStart); + InterfaceImpl::handle_incoming(data); + } else { + break; // Incomplete frame, wait for more data + } } } diff --git a/src/ui/screens/MessageView.cpp b/src/ui/screens/MessageView.cpp index d72e894..fd105f6 100644 --- a/src/ui/screens/MessageView.cpp +++ b/src/ui/screens/MessageView.cpp @@ -4,19 +4,22 @@ #include "reticulum/LXMFManager.h" #include "reticulum/AnnounceManager.h" #include +#include void MessageView::onEnter() { if (_lxmf) _lxmf->markRead(_peerHex); _lastMsgCount = -1; - _scrollOffset = 0; + _scrollPixels = 0; _lastRefreshMs = 0; refreshMessages(); + rebuildLayout(); markDirty(); } void MessageView::onExit() { _inputText.clear(); _cachedMsgs.clear(); + _layout.clear(); } void MessageView::refreshMessages() { @@ -33,15 +36,79 @@ void MessageView::update() { refreshMessages(); if ((int)_cachedMsgs.size() != oldCount) { _lastMsgCount = (int)_cachedMsgs.size(); + rebuildLayout(); + _scrollPixels = 0; // snap to bottom on new message markDirty(); } } } +// ============================================================================= +// Word wrap + layout +// ============================================================================= +std::vector MessageView::wordWrap(const std::string& text, int maxChars) { + std::vector lines; + if (text.empty()) { lines.push_back(""); return lines; } + + size_t pos = 0; + while (pos < text.size()) { + size_t remaining = text.size() - pos; + if ((int)remaining <= maxChars) { + lines.push_back(text.substr(pos)); + break; + } + // Find last space within maxChars + int breakAt = maxChars; + for (int i = maxChars; i > maxChars / 2; i--) { + if (text[pos + i] == ' ') { breakAt = i; break; } + } + lines.push_back(text.substr(pos, breakAt)); + pos += breakAt; + // Skip the space we broke at + if (pos < text.size() && text[pos] == ' ') pos++; + } + return lines; +} + +std::string MessageView::formatTime(double timestamp) { + if (timestamp < 1000000) return ""; + time_t t = (time_t)timestamp; + struct tm* tm = localtime(&t); + if (!tm) return ""; + char buf[8]; + snprintf(buf, sizeof(buf), "%02d:%02d", tm->tm_hour, tm->tm_min); + return buf; +} + +void MessageView::rebuildLayout() { + _layout.clear(); + _totalContentH = 0; + + for (int i = 0; i < (int)_cachedMsgs.size(); i++) { + const auto& msg = _cachedMsgs[i]; + MsgLayout ml; + ml.msgIdx = i; + ml.incoming = msg.incoming; + ml.lines = wordWrap(msg.content, MAX_BUBBLE_CHARS); + ml.timeStr = formatTime(msg.timestamp); + + int textH = (int)ml.lines.size() * LINE_H; + ml.totalHeight = textH + BUBBLE_PAD * 2 + BUBBLE_GAP; + // Add time line height if we have a timestamp + if (!ml.timeStr.empty()) ml.totalHeight += 8; + + _totalContentH += ml.totalHeight; + _layout.push_back(ml); + } +} + +// ============================================================================= +// Drawing +// ============================================================================= void MessageView::draw(LGFX_TDeck& gfx) { gfx.setTextSize(1); - // Header — show node name if known + // Header — peer name gfx.setTextColor(Theme::ACCENT, Theme::BG); gfx.setCursor(4, Theme::CONTENT_Y + 2); std::string headerName; @@ -54,100 +121,150 @@ void MessageView::draw(LGFX_TDeck& gfx) { snprintf(header, sizeof(header), "< %s", headerName.c_str()); gfx.print(header); - // Divider under header int headerBottom = Theme::CONTENT_Y + 12; gfx.drawFastHLine(0, headerBottom, Theme::SCREEN_W, Theme::BORDER); - // Input area (bottom of content) + // Input area int inputY = Theme::SCREEN_H - Theme::TAB_BAR_H - 16; gfx.drawFastHLine(0, inputY - 2, Theme::SCREEN_W, Theme::BORDER); gfx.fillRect(0, inputY, Theme::SCREEN_W, 16, Theme::BG); - // Input text - gfx.setTextColor(Theme::PRIMARY, Theme::BG); gfx.setCursor(4, inputY + 4); if (_inputText.empty()) { gfx.setTextColor(Theme::MUTED, Theme::BG); gfx.print("Type message..."); } else { - // Show last ~48 chars if too long - if (_inputText.length() > 48) { - gfx.print(_inputText.substr(_inputText.length() - 48).c_str()); + gfx.setTextColor(Theme::PRIMARY, Theme::BG); + int maxShow = (Theme::SCREEN_W - 44) / CHAR_W; + if ((int)_inputText.length() > maxShow) { + gfx.print(_inputText.substr(_inputText.length() - maxShow).c_str()); } else { gfx.print(_inputText.c_str()); } } // Cursor blink - int cursorX = 4 + (int)std::min(_inputText.length(), (size_t)48) * 6; + int maxShow = (Theme::SCREEN_W - 44) / CHAR_W; + int cursorX = 4 + (int)std::min(_inputText.length(), (size_t)maxShow) * CHAR_W; if ((millis() / 500) % 2 == 0) { gfx.fillRect(cursorX, inputY + 2, 2, 10, Theme::ACCENT); } - // [Send] label + // Send button gfx.setTextColor(Theme::PRIMARY, Theme::SELECTION_BG); gfx.fillRect(Theme::SCREEN_W - 32, inputY, 30, 14, Theme::SELECTION_BG); gfx.setCursor(Theme::SCREEN_W - 30, inputY + 3); gfx.print("Send"); - // Message area - if (!_lxmf) return; - const auto& msgs = _cachedMsgs; - int msgAreaTop = headerBottom + 2; - int msgAreaBottom = inputY - 4; - int lineH = 12; - int maxLines = (msgAreaBottom - msgAreaTop) / lineH; + // Message area with bubbles + int msgTop = headerBottom + 2; + int msgBottom = inputY - 3; + int msgAreaH = msgBottom - msgTop; - // Draw messages from bottom up - int startIdx = (int)msgs.size() - maxLines - _scrollOffset; - if (startIdx < 0) startIdx = 0; - int endIdx = startIdx + maxLines; - if (endIdx > (int)msgs.size()) endIdx = (int)msgs.size(); + if (_layout.empty()) return; - int y = msgAreaTop; - for (int i = startIdx; i < endIdx; i++) { - const auto& msg = msgs[i]; - if (msg.incoming) { - gfx.setTextColor(Theme::ACCENT, Theme::BG); - gfx.setCursor(4, y); + // Calculate visible region — draw from bottom up + // _scrollPixels == 0 means showing the latest messages at bottom + int contentBottom = _totalContentH; + int viewBottom = contentBottom - _scrollPixels; + int viewTop = viewBottom - msgAreaH; + + int y = msgTop; // screen Y cursor + int contentY = 0; // content space Y cursor + + for (int li = 0; li < (int)_layout.size(); li++) { + const auto& ml = _layout[li]; + int blockTop = contentY; + int blockBottom = contentY + ml.totalHeight; + + // Skip blocks entirely above viewport + if (blockBottom <= viewTop) { + contentY = blockBottom; + continue; + } + // Stop if we've passed the viewport + if (blockTop >= viewBottom) break; + + // Map content Y to screen Y + int screenY = msgTop + (blockTop - viewTop); + + const auto& msg = _cachedMsgs[ml.msgIdx]; + int textH = (int)ml.lines.size() * LINE_H; + int bubbleH = textH + BUBBLE_PAD * 2; + + // Calculate bubble width from longest line + int maxLineLen = 0; + for (auto& line : ml.lines) { + if ((int)line.length() > maxLineLen) maxLineLen = line.length(); + } + int bubbleW = maxLineLen * CHAR_W + BUBBLE_PAD * 2 + 2; + if (bubbleW < 30) bubbleW = 30; + + int bubbleX; + uint32_t bubbleBg, textColor; + + if (ml.incoming) { + bubbleX = 4; + bubbleBg = Theme::MSG_IN_BG; + textColor = Theme::ACCENT; } else { - // Status indicator: * = sent, ! = failed, ~ = queued/sending - const char* indicator = "~"; - if (msg.status == LXMFStatus::SENT || msg.status == LXMFStatus::DELIVERED) indicator = "*"; - else if (msg.status == LXMFStatus::FAILED) indicator = "!"; - - gfx.setTextColor(Theme::PRIMARY, Theme::BG); - // Right-align outgoing + status - int contentLen = std::min((int)msg.content.length(), 38); - int tw = (contentLen + 2) * 6; // +2 for space + indicator - gfx.setCursor(Theme::SCREEN_W - tw - 4, y); + bubbleX = Theme::SCREEN_W - bubbleW - 4; + bubbleBg = Theme::MSG_OUT_BG; + textColor = Theme::PRIMARY; } - // Truncate long messages to one line for now - if (msg.content.length() > 48) { - gfx.print(msg.content.substr(0, 45).c_str()); - gfx.print("..."); - } else { - gfx.print(msg.content.c_str()); - } + // Clip to message area + if (screenY >= msgTop && screenY + bubbleH <= msgBottom) { + // Draw bubble background + gfx.fillRoundRect(bubbleX, screenY, bubbleW, bubbleH, 3, bubbleBg); - // Show status indicator for outgoing messages - if (!msg.incoming) { - const char* ind = "~"; - uint32_t indColor = Theme::MUTED; - if (msg.status == LXMFStatus::SENT || msg.status == LXMFStatus::DELIVERED) { - ind = "*"; indColor = Theme::ACCENT; - } else if (msg.status == LXMFStatus::FAILED) { - ind = "!"; indColor = TFT_RED; + // Draw text lines + gfx.setTextColor(textColor, bubbleBg); + for (int j = 0; j < (int)ml.lines.size(); j++) { + gfx.setCursor(bubbleX + BUBBLE_PAD + 1, screenY + BUBBLE_PAD + j * LINE_H); + gfx.print(ml.lines[j].c_str()); + } + + // Status indicator for outgoing + if (!ml.incoming) { + const char* ind = "~"; + uint32_t indColor = Theme::MUTED; + if (msg.status == LXMFStatus::SENT || msg.status == LXMFStatus::DELIVERED) { + ind = "*"; indColor = Theme::ACCENT; + } else if (msg.status == LXMFStatus::FAILED) { + ind = "!"; indColor = Theme::ERROR_CLR; + } + gfx.setTextColor(indColor, bubbleBg); + gfx.setCursor(bubbleX + bubbleW - CHAR_W - BUBBLE_PAD, screenY + bubbleH - LINE_H - BUBBLE_PAD + 1); + gfx.print(ind); + } + + // Timestamp below bubble + if (!ml.timeStr.empty()) { + int timeX = ml.incoming ? bubbleX + 2 : bubbleX + bubbleW - (int)ml.timeStr.length() * CHAR_W - 2; + gfx.setTextColor(Theme::MUTED, Theme::BG); + gfx.setCursor(timeX, screenY + bubbleH + 1); + gfx.print(ml.timeStr.c_str()); } - gfx.setTextColor(indColor, Theme::BG); - gfx.print(" "); - gfx.print(ind); } - y += lineH; + + contentY = blockBottom; + } + + // Scroll indicator + if (_totalContentH > msgAreaH) { + int thumbH = std::max(8, msgAreaH * msgAreaH / _totalContentH); + int maxScroll = _totalContentH - msgAreaH; + int thumbY = msgTop + (msgAreaH - thumbH) * (_totalContentH - _scrollPixels - msgAreaH) / std::max(1, maxScroll); + thumbY = std::max(msgTop, std::min(thumbY, msgBottom - thumbH)); + gfx.fillRect(Theme::SCREEN_W - 2, msgTop, 2, msgAreaH, Theme::BORDER); + gfx.fillRect(Theme::SCREEN_W - 2, thumbY, 2, thumbH, Theme::SECONDARY); } } +// ============================================================================= +// Input handling +// ============================================================================= void MessageView::sendCurrentMessage() { if (!_lxmf || _peerHex.empty() || _inputText.empty()) return; @@ -156,17 +273,19 @@ void MessageView::sendCurrentMessage() { _lxmf->sendMessage(destHash, _inputText.c_str()); _inputText.clear(); + // Immediately refresh to show sent message + refreshMessages(); + rebuildLayout(); + _scrollPixels = 0; markDirty(); } bool MessageView::handleKey(const KeyEvent& event) { - // Escape goes back if (event.character == 0x1B) { if (_onBack) _onBack(); return true; } - // Backspace: delete text, or go back if input is empty if (event.del || event.character == 0x08) { if (!_inputText.empty()) { _inputText.pop_back(); @@ -177,29 +296,29 @@ bool MessageView::handleKey(const KeyEvent& event) { return true; } - // Enter sends message if (event.enter || event.character == '\n' || event.character == '\r') { sendCurrentMessage(); return true; } - // Arrow keys scroll messages (Alt+I/M) + // Scroll if (event.up) { - if (_scrollOffset < _lastMsgCount - 5) { - _scrollOffset++; + int maxScroll = std::max(0, _totalContentH - 100); + if (_scrollPixels < maxScroll) { + _scrollPixels += 20; markDirty(); } return true; } if (event.down) { - if (_scrollOffset > 0) { - _scrollOffset--; + if (_scrollPixels > 0) { + _scrollPixels -= 20; + if (_scrollPixels < 0) _scrollPixels = 0; markDirty(); } return true; } - // Printable characters → text input if (event.character >= 0x20 && event.character < 0x7F) { _inputText += (char)event.character; markDirty(); diff --git a/src/ui/screens/MessageView.h b/src/ui/screens/MessageView.h index de612fa..4e19991 100644 --- a/src/ui/screens/MessageView.h +++ b/src/ui/screens/MessageView.h @@ -9,6 +9,15 @@ class LXMFManager; class AnnounceManager; +// Pre-computed layout for a single message (may span multiple lines) +struct MsgLayout { + int msgIdx; // index into _cachedMsgs + std::vector lines; // word-wrapped lines + std::string timeStr; // "12:34" or "HH:MM" + bool incoming; + int totalHeight; // pixel height for this message block +}; + class MessageView : public Screen { public: using BackCallback = std::function; @@ -29,6 +38,10 @@ public: private: void sendCurrentMessage(); void refreshMessages(); + void rebuildLayout(); + + static std::vector wordWrap(const std::string& text, int maxChars); + static std::string formatTime(double timestamp); LXMFManager* _lxmf = nullptr; AnnounceManager* _am = nullptr; @@ -36,8 +49,15 @@ private: std::string _peerHex; std::string _inputText; int _lastMsgCount = -1; - int _scrollOffset = 0; // scroll from bottom + int _scrollPixels = 0; // pixel scroll from bottom + int _totalContentH = 0; // total height of all messages std::vector _cachedMsgs; + std::vector _layout; unsigned long _lastRefreshMs = 0; static constexpr unsigned long REFRESH_INTERVAL_MS = 500; + static constexpr int CHAR_W = 6; + static constexpr int LINE_H = 10; + static constexpr int BUBBLE_PAD = 3; + static constexpr int BUBBLE_GAP = 4; + static constexpr int MAX_BUBBLE_CHARS = 38; }; diff --git a/src/ui/screens/SettingsScreen.cpp b/src/ui/screens/SettingsScreen.cpp index 4547a73..a824aec 100644 --- a/src/ui/screens/SettingsScreen.cpp +++ b/src/ui/screens/SettingsScreen.cpp @@ -10,6 +10,7 @@ #include "hal/Power.h" #include "transport/WiFiInterface.h" #include "reticulum/ReticulumManager.h" +#include "reticulum/IdentityManager.h" #include #include #include @@ -104,6 +105,67 @@ void SettingsScreen::buildItems() { _items.push_back(nameItem); idx++; } + // Identity switcher (if IdentityManager available) + if (_idMgr && _idMgr->count() > 0) { + SettingItem idSwitch; + idSwitch.label = "Active Identity"; + idSwitch.type = SettingType::ENUM_CHOICE; + idSwitch.getter = [this]() { return _idMgr->activeIndex(); }; + idSwitch.setter = [this](int v) { + // Identity switch requires reboot + if (v == _idMgr->activeIndex()) return; + RNS::Identity newId; + if (_idMgr->switchTo(v, newId)) { + if (_ui) _ui->statusBar().showToast("Identity switched! Rebooting...", 2000); + applyAndSave(); + delay(1000); + ESP.restart(); + } else { + if (_ui) _ui->statusBar().showToast("Switch failed!", 1500); + } + }; + idSwitch.formatter = nullptr; + idSwitch.minVal = 0; + idSwitch.maxVal = _idMgr->count() - 1; + idSwitch.step = 1; + // Build labels from identity slots + for (int i = 0; i < _idMgr->count(); i++) { + auto& slot = _idMgr->identities()[i]; + static char labelBufs[8][32]; + if (!slot.displayName.isEmpty()) { + snprintf(labelBufs[i], sizeof(labelBufs[i]), "%s", slot.displayName.c_str()); + } else { + snprintf(labelBufs[i], sizeof(labelBufs[i]), "%.12s", slot.hash.c_str()); + } + idSwitch.enumLabels.push_back(labelBufs[i]); + } + _items.push_back(idSwitch); + idx++; + } + { + SettingItem newId; + newId.label = "New Identity"; + newId.type = SettingType::ACTION; + newId.formatter = [](int) { return String("[Enter]"); }; + newId.action = [this, &s]() { + if (!_idMgr) { + if (_ui) _ui->statusBar().showToast("Not available", 1200); + return; + } + if (_idMgr->count() >= 8) { + if (_ui) _ui->statusBar().showToast("Max 8 identities!", 1200); + return; + } + int idx = _idMgr->createIdentity(s.displayName); + if (idx >= 0) { + if (_ui) _ui->statusBar().showToast("Identity created!", 1200); + buildItems(); // Rebuild to show new identity in switcher + markDirty(); + } + }; + _items.push_back(newId); + idx++; + } _categories.push_back({"Device", devStart, idx - devStart, [&s]() { return s.displayName.isEmpty() ? String("(unnamed)") : s.displayName; }}); @@ -308,6 +370,11 @@ void SettingsScreen::buildItems() { [](int v) { return String(v); }, 1, 65535, 1}); idx++; + _items.push_back({"Transport Node", SettingType::TOGGLE, + [&s]() { return s.transportEnabled ? 1 : 0; }, + [&s](int v) { s.transportEnabled = (v != 0); }, + [](int v) { return v ? String("ON") : String("OFF"); }}); + idx++; _items.push_back({"BLE", SettingType::TOGGLE, [&s]() { return s.bleEnabled ? 1 : 0; }, [&s](int v) { s.bleEnabled = (v != 0); }, diff --git a/src/ui/screens/SettingsScreen.h b/src/ui/screens/SettingsScreen.h index 2d220e6..a89c558 100644 --- a/src/ui/screens/SettingsScreen.h +++ b/src/ui/screens/SettingsScreen.h @@ -15,6 +15,7 @@ class Power; class WiFiInterface; class TCPClientInterface; class ReticulumManager; +class IdentityManager; enum class SettingType : uint8_t { READONLY, @@ -69,6 +70,7 @@ public: void setWiFi(WiFiInterface* wifi) { _wifi = wifi; } void setTCPClients(std::vector* tcp) { _tcp = tcp; } void setRNS(ReticulumManager* rns) { _rns = rns; } + void setIdentityManager(IdentityManager* idm) { _idMgr = idm; } void setUIManager(UIManager* ui) { _ui = ui; } void setIdentityHash(const String& hash) { _identityHash = hash; } void setSaveCallback(std::function cb) { _saveCallback = cb; } @@ -104,6 +106,7 @@ private: WiFiInterface* _wifi = nullptr; std::vector* _tcp = nullptr; ReticulumManager* _rns = nullptr; + IdentityManager* _idMgr = nullptr; UIManager* _ui = nullptr; String _identityHash;