From 6d82da877eb351d31e4ef4325cd8d85f48e0a5da Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:27:32 +0200 Subject: [PATCH] feat(ui-core,ui-lvgl): room sessions and contact helpers in the Core, L2 rooms, conversation options, message actions Co-Authored-By: Claude Opus 5.5 --- docs/development/l2-hardware-checklist.md | 14 + docs/development/ui-core.md | 4 +- .../companion_radio/ui-core/ContactControl.h | 87 +++++ .../companion_radio/ui-core/RoomSessions.h | 124 +++++++ examples/companion_radio/ui-core/UiCore.h | 11 +- .../ui-lvgl/ConversationScreen.h | 314 ++++++++++++++++++ examples/companion_radio/ui-lvgl/UITask.cpp | 140 ++++++-- examples/companion_radio/ui-lvgl/UITask.h | 11 + .../companion_radio/ui-new/MessagesScreen.h | 150 ++------- examples/simple_room_server/main.cpp | 8 + variants/sim/web/lvgl.html | 17 +- 11 files changed, 728 insertions(+), 152 deletions(-) create mode 100644 examples/companion_radio/ui-core/ContactControl.h create mode 100644 examples/companion_radio/ui-core/RoomSessions.h create mode 100644 examples/companion_radio/ui-lvgl/ConversationScreen.h diff --git a/docs/development/l2-hardware-checklist.md b/docs/development/l2-hardware-checklist.md index 11f01ae9..91236ef6 100644 --- a/docs/development/l2-hardware-checklist.md +++ b/docs/development/l2-hardware-checklist.md @@ -66,6 +66,20 @@ Confirmed: green dot inside the ring, waypoints much better, download works, set - [ ] Room server login from the phone app / L1 still works while this exists (only admin logins go to the new session) - [ ] L1 (ui-new): Tools > Admin works as before (login, saved password, typed values, confirm reboot); it now also skips the login for the node you just used +## New: Rooms, conversation options, message actions (2026-09-25) +- [ ] Messages: sections CHANNELS / DIRECT / ROOMS; "All" / "★ Fav" pill on CHANNELS and ROOMS shows only favourites (and survives a reboot) +- [ ] ROOMS lists room servers ("Tap to log in" / last post); "ROOMS - N new" when posts arrived +- [ ] Tap a room never logged into: password popup (empty is fine for public rooms) → "Logging in..." → "Logged in" → thread opens +- [ ] Wrong password: "Login failed - wrong password?" and the popup comes back; no answer: "No answer from the room" +- [ ] Reboot, tap the same room: logs in with the saved password without asking, then opens +- [ ] Room thread: other people's posts show their name above the text; your post gets "delivered" +- [ ] Room ⚙ / hold the row: "Logged in" status, Fav, Login (new password), Logout (forgets the password; next tap asks again) +- [ ] DIRECT: hold a conversation (or ⚙ in the thread): Alerts Default / Muted / Always, Fav (star in the list), Read; muted shows the mute icon +- [ ] New message: CONTACTS "All" / "★ Fav" pill +- [ ] Hold someone's message bubble: path ("Path (2 hops): A > B" or "Heard directly"), Reply puts "@[name] " in the field with the keyboard up, Set target (message with a position) sets the navigation target +- [ ] Hold your own channel post: "Relayed by: ..." once a repeater echoed it +- [ ] L1 (ui-new): Room Servers login / logout / saved password work as before (now in the shared RoomSessions) + ## New: Bot (2026-09-25) - [ ] Home page 2: Clock, Bot - [ ] Bot: tabs Channel / Room / Direct / Other; header shows "N sent" once it has replied diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index 2dca4432..4698a0d3 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -32,7 +32,7 @@ interleaved. Classified: - `Trail.h` / `TrailStore`, `ScopeList.h`. Waypoints ✅ `ui-core/WaypointModel.h` (store + `/waypoints` persistence + "delete clears the Locator target"; ui-new's `UITask::waypoints()` forwards to it). **Engines — logic living inside `UITask.cpp` / `UITask.h`** -- Unread tracking — DM unread table ✅ (`ui-core/DmUnreadTable.h`), room unread (still in `UITask`). +- Unread tracking — DM unread table ✅ (`ui-core/DmUnreadTable.h`), room unread (one counter in `UiCore`). Room logins ✅ `ui-core/RoomSessions.h`. - Notifications ✅ split: the Core decides *what* happened (`MessageArrived` with kind / DM sender / channel slot, `AdvertHeard`); the frontend decides *how* to show it (`showAlert`, `SoundNotifier`, vibration, LED, wake-on-message) — those are platform services. - Live share ✅ `ui-core/LiveShareEngine.h` — session timer, movement/heartbeat gate, send + scope guard, peers' `LiveTrackStore` + expiry. - Locator ✅ `ui-core/LocatorEngine.h` — active target, geofence state machine, proximity beeper. @@ -110,7 +110,7 @@ call the same ones, so e.g. "marking a channel read" has one implementation. ### 5. Declarative settings schema -`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, battery shutdown, GPS power saving, time zone) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h`. Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it. The bot's settings are a table too, `ui-core/BotConfig.h` (`botcfg::`: tabs, rows, the NodePrefs flag / text / hour behind each, reply placeholders); ui-new's Tools > Bot and ui-lvgl's Bot screen render it, the bot itself runs in MyMesh. +`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, battery shutdown, GPS power saving, time zone) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h`. Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it. The bot's settings are a table too, `ui-core/BotConfig.h` (`botcfg::`: tabs, rows, the NodePrefs flag / text / hour behind each, reply placeholders); ui-new's Tools > Bot and ui-lvgl's Bot screen render it, the bot itself runs in MyMesh. Room servers: `ui-core/RoomSessions.h` (`UiCore::rooms`) keeps who is logged in this session, the one login in flight (with a timeout) and the saved passwords (saved on success, forgotten on failure); `open(contact)` says whether to go straight in, wait for a background login or ask for a password, and outcomes come out through `take()`. Per-contact helpers are `ui-core/ContactControl.h` (`contactctl::`: DM notification / melody overrides, favourite flag, the `@[name] ` reply prefix, splitting a room post into author and text, naming a message's path hops). ui-new's Messages and ui-lvgl's Chats / thread use both. Replaces the per-item code in `SettingsScreen`: diff --git a/examples/companion_radio/ui-core/ContactControl.h b/examples/companion_radio/ui-core/ContactControl.h new file mode 100644 index 00000000..f05bfd26 --- /dev/null +++ b/examples/companion_radio/ui-core/ContactControl.h @@ -0,0 +1,87 @@ +#pragma once +// Conversation-level helpers shared by the messages screens (ui-new's +// Messages, ui-lvgl's Chats / thread): per-contact notification and melody +// overrides, the favourite flag, the reply prefix, splitting a room post into +// author and text, and naming the hops of a message's path. + +namespace contactctl { + +// Per-contact overrides live in small {prefix[4], value} tables in NodePrefs: +// value 0 = no override = free slot. Full table: slot 0 is overwritten. +enum : uint8_t { NOTIF_DEFAULT = 0, NOTIF_MUTED = 1, NOTIF_ALWAYS = 2 }; + +template +inline uint8_t tableGet(const Entry* tbl, int n, const uint8_t* pub_key, uint8_t Entry::* val) { + for (int i = 0; i < n; i++) + if (tbl[i].*val && memcmp(tbl[i].prefix, pub_key, 4) == 0) return tbl[i].*val; + return 0; +} +template +inline void tableSet(Entry* tbl, int n, const uint8_t* pub_key, uint8_t Entry::* val, uint8_t v) { + for (int i = 0; i < n; i++) + if (tbl[i].*val && memcmp(tbl[i].prefix, pub_key, 4) == 0) { + if (v == 0) memset(&tbl[i], 0, sizeof(tbl[i])); else tbl[i].*val = v; + return; + } + if (v == 0) return; + for (int i = 0; i < n; i++) + if (tbl[i].*val == 0) { memcpy(tbl[i].prefix, pub_key, 4); tbl[i].*val = v; return; } + memcpy(tbl[0].prefix, pub_key, 4); tbl[0].*val = v; +} + +inline uint8_t notif(const NodePrefs* p, const uint8_t* pub_key) { + return p ? tableGet(p->dm_notif, NodePrefs::DM_NOTIF_TABLE_MAX, pub_key, &NodePrefs::DmNotifEntry::state) : 0; +} +inline void setNotif(NodePrefs* p, const uint8_t* pub_key, uint8_t state) { + if (p) tableSet(p->dm_notif, NodePrefs::DM_NOTIF_TABLE_MAX, pub_key, &NodePrefs::DmNotifEntry::state, state); +} +// Melody slot: 0 = default, 1 / 2 = the user melodies. +inline uint8_t melody(const NodePrefs* p, const uint8_t* pub_key) { + return p ? tableGet(p->dm_melody, NodePrefs::DM_MELODY_TABLE_MAX, pub_key, &NodePrefs::DmMelodyEntry::slot) : 0; +} +inline void setMelody(NodePrefs* p, const uint8_t* pub_key, uint8_t slot) { + if (p) tableSet(p->dm_melody, NodePrefs::DM_MELODY_TABLE_MAX, pub_key, &NodePrefs::DmMelodyEntry::slot, slot); +} + +// ContactInfo::flags bit 0, the same star the companion app sets. +inline bool favourite(const ContactInfo& ci) { return (ci.flags & 0x01) != 0; } +inline bool setFavourite(const uint8_t* pub_key, bool on) { return the_mesh.setContactFavourite(pub_key, on); } + +// "@[nick] " -- how a reply names who it answers (raw UTF-8, over the air as is). +inline void replyPrefix(const char* nick, char* out, size_t n) { snprintf(out, n, "@[%.31s] ", nick); } + +// A room post is filed "Author: text" (UiCore::onMessageRecvEx). Author into +// `author` ("" when there is none), returns the text after it. +inline const char* splitRoomPost(const char* text, char* author, size_t n) { + const char* sep = strstr(text, ": "); + if (!sep || (size_t)(sep - text) >= n) { if (n) author[0] = '\0'; return text; } + memcpy(author, text, sep - text); + author[sep - text] = '\0'; + return sep + 2; +} + +// Hops recorded with a history entry: path_len packs hash size (top 2 bits, +// minus 1) and hop count (low 6 bits). +inline uint8_t hopCount(uint8_t path_len_packed) { + uint8_t hash_size = (path_len_packed >> 6) + 1; + uint8_t n = path_len_packed & 63; + uint8_t max_hops = MAX_HIST_PATH_BYTES / hash_size; // what capturePath() stores at most + return n > max_hops ? max_hops : n; +} + +// Hop i's name: the contact whose key starts with the hash, else "?AABB". +inline void hopName(uint8_t path_len_packed, const uint8_t* path, int i, char* out, size_t n) { + uint8_t hash_size = (path_len_packed >> 6) + 1; + const uint8_t* hash = &path[i * hash_size]; + for (int idx = 0; ; idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (c.id.isHashMatch(hash, hash_size)) { snprintf(out, n, "%s", c.name); return; } + } + char hex[9] = {0}; + uint8_t b = hash_size > 4 ? 4 : hash_size; + for (uint8_t k = 0; k < b; k++) snprintf(hex + k * 2, 3, "%02X", hash[k]); + snprintf(out, n, "?%s", hex); +} + +} // namespace contactctl diff --git a/examples/companion_radio/ui-core/RoomSessions.h b/examples/companion_radio/ui-core/RoomSessions.h new file mode 100644 index 00000000..e8870433 --- /dev/null +++ b/examples/companion_radio/ui-core/RoomSessions.h @@ -0,0 +1,124 @@ +#pragma once +// Room server sessions for the on-device messages screens (ui-new's +// Messages > Room Servers, ui-lvgl's Messages > Rooms). Posting to a room needs +// a login handshake first, even with a blank password; this keeps which rooms +// were logged in to this power-on, the one login in flight, and the password +// memory (MyMesh::saveRoomPassword): a room logged in to on an earlier boot +// logs in again silently with the saved password, a failed login forgets it. +// +// RAM-only memo: the server's ACL is the real permission store and survives +// reboot on its own, but the device can't query it, so this only saves asking +// again for a password entered this session. + +class RoomSessions { +public: + enum Open : uint8_t { OPEN_NOW, LOGGING_IN, NEED_PASSWORD, SEND_FAILED }; + enum Outcome : uint8_t { NONE, LOGGED_IN, LOGIN_FAILED, LOGIN_TIMEOUT }; + + bool isLoggedIn(const uint8_t* pub_key) const { + for (int i = 0; i < _count; i++) + if (memcmp(_prefix[(_head + i) % TABLE_SIZE], pub_key, 4) == 0) return true; + return false; + } + + // Opening a room: straight in when logged in this session, a background + // login with the saved password, else the frontend asks for one. + Open open(const ContactInfo& ci) { + if (isLoggedIn(ci.id.pub_key)) return OPEN_NOW; + char saved[sizeof(_pw)]; + if (the_mesh.getRoomPassword(ci.id.pub_key, saved, sizeof(saved))) + return login(ci, saved) ? LOGGING_IN : SEND_FAILED; + return NEED_PASSWORD; + } + + bool login(const ContactInfo& ci, const char* password) { + snprintf(_pw, sizeof(_pw), "%s", password); + uint32_t est = 0; + if (!the_mesh.sendRoomLogin(ci, password, est)) { _pending = false; return false; } + memcpy(_pending_key, ci.id.pub_key, 4); + _pending = true; + _deadline = millis() + est + 4000; // same margin as a DM's ACK wait + return true; + } + + bool loggingIn() const { return _pending; } + bool loggingIn(const uint8_t* pub_key) const { return _pending && memcmp(_pending_key, pub_key, 4) == 0; } + const uint8_t* pendingKey() const { return _pending_key; } + + // Local only: the room ACL has no session to tear down (MyMesh::logoutRoom()). + void logout(const uint8_t* pub_key) { + the_mesh.logoutRoom(pub_key); + forget(pub_key); + if (loggingIn(pub_key)) cancel(); + } + + void cancel() { + if (_pending) the_mesh.cancelUiPendingLogin(_pending_key); + _pending = false; + } + + // From UiCore::onRoomLoginResult (after the admin session passed on it). + // True when it answers this session's login. + bool onLoginResult(const uint8_t* pub_key, bool success) { + if (!loggingIn(pub_key)) return false; + _pending = false; + if (success) { + markLoggedIn(pub_key); + the_mesh.saveRoomPassword(pub_key, _pw); + _outcome = LOGGED_IN; + } else { + // The saved password no longer works: forget it, so the next open asks + // instead of retrying the same bad one forever. + the_mesh.forgetRoomPassword(pub_key); + _outcome = LOGIN_FAILED; + } + memcpy(_outcome_key, pub_key, 4); + return true; + } + + void loop() { + if (_pending && (int32_t)(millis() - _deadline) > 0) { + memcpy(_outcome_key, _pending_key, 4); + cancel(); + _outcome = LOGIN_TIMEOUT; + } + } + + // The last login's outcome, once (NONE when nothing new); key = its room. + Outcome take(uint8_t* key_out = nullptr) { + Outcome o = _outcome; + _outcome = NONE; + if (key_out) memcpy(key_out, _outcome_key, 4); + return o; + } + +private: + static const int TABLE_SIZE = 8; // oldest dropped when full + + void markLoggedIn(const uint8_t* pub_key) { + if (isLoggedIn(pub_key)) return; + int pos; + if (_count < TABLE_SIZE) pos = (_head + _count++) % TABLE_SIZE; + else { pos = _head; _head = (_head + 1) % TABLE_SIZE; } + memcpy(_prefix[pos], pub_key, 4); + } + + void forget(const uint8_t* pub_key) { + for (int i = 0; i < _count; i++) { + if (memcmp(_prefix[(_head + i) % TABLE_SIZE], pub_key, 4) != 0) continue; + for (int j = i; j < _count - 1; j++) + memcpy(_prefix[(_head + j) % TABLE_SIZE], _prefix[(_head + j + 1) % TABLE_SIZE], 4); + _count--; + return; + } + } + + uint8_t _prefix[TABLE_SIZE][4]; + int _head = 0, _count = 0; + char _pw[16] = ""; // of the login in flight, saved on success + uint8_t _pending_key[4] = {0}; + bool _pending = false; + uint32_t _deadline = 0; + Outcome _outcome = NONE; + uint8_t _outcome_key[4] = {0}; +}; diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index bd7e4d74..8839d6db 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -25,6 +25,8 @@ #include "WaypointModel.h" #include "ChannelControl.h" #include "AdminSession.h" +#include "RoomSessions.h" +#include "ContactControl.h" class UiCore : public MyMesh::Listener { public: @@ -53,6 +55,7 @@ public: locator.loop(); trail.loop(); admin.loop(); + rooms.loop(); } UiEventQueue events; // Core → frontend; drained by the frontend's loop() @@ -65,6 +68,7 @@ public: LocatorEngine locator; // active target, geofence crossings, proximity beeper TrailEngine trail; // GPS trail store, sampling, auto-pause, shutdown save AdminSession admin; // remote repeater / room admin: login + CLI round trips + RoomSessions rooms; // room server logins for posting (messages screens) // ── Models ──────────────────────────────────────────────────────────────── MessageHistory history; // channel + DM rings, delivery state, channel unread @@ -240,9 +244,12 @@ public: } // Login answers go to the admin session when it's the one waiting, else to - // the frontend (room server logins from its messages screen). + // the room sessions (logins from the messages screen), then the frontend. void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override { - if (!admin.onLoginResult(pub_key, success, permissions)) _host->onRoomLoginResult(pub_key, success, permissions); + if (!admin.onLoginResult(pub_key, success, permissions)) { + rooms.onLoginResult(pub_key, success); + _host->onRoomLoginResult(pub_key, success, permissions); + } _host->onAdminStateChanged(); } void onAdminReply(const uint8_t* pub_key, const char* text) override { diff --git a/examples/companion_radio/ui-lvgl/ConversationScreen.h b/examples/companion_radio/ui-lvgl/ConversationScreen.h new file mode 100644 index 00000000..e26b452e --- /dev/null +++ b/examples/companion_radio/ui-lvgl/ConversationScreen.h @@ -0,0 +1,314 @@ +#pragma once +// Conversations beyond plain sending -- ui-new's Messages extras: +// - room servers: opening one logs in first (the saved password silently, else +// a password popup), then the thread shows each post under its author; +// - conversation options (hold a DM / room row, or the ⚙ in its thread): +// alerts and favourite for a contact; favourite, log in again / log out +// for a room; +// - message actions (hold a bubble): reply ("@[name] " in the compose field), +// the path it came by / the repeaters that relayed it, set as target; +// - the favourites-only filters of the Chats sections and the contact picker. +// Logic in the UI Core: RoomSessions (logins), contactctl (prefs, reply +// prefix, room post author, path hops), shared with ui-new. +// +// Single-TU fragment: included at the end of ui-lvgl/UITask.cpp (uses the +// thread's s_msg_meta). + +namespace convview { + +enum : uint8_t { A_FAV, A_READ, A_LOGIN, A_LOGOUT }; +enum : uint8_t { M_REPLY, M_TARGET }; + +static uint8_t s_key[PUB_KEY_SIZE]; // the conversation the popup is about +static uint8_t s_login_key[PUB_KEY_SIZE]; +static bool s_login_wait = false; // open the room once this login answers +static lv_obj_t* s_fav_btn = nullptr; +static int s_msg = -1; // s_msg_meta index the message popup is about + +static lv_obj_t* segmented(lv_obj_t* parent, const char** map, int sel, int w, lv_event_cb_t cb) { + lv_obj_t* seg = lv_buttonmatrix_create(parent); + lv_buttonmatrix_set_map(seg, map); + lv_buttonmatrix_set_button_ctrl_all(seg, LV_BUTTONMATRIX_CTRL_CHECKABLE); + lv_buttonmatrix_set_one_checked(seg, true); + lv_buttonmatrix_set_button_ctrl(seg, sel, LV_BUTTONMATRIX_CTRL_CHECKED); + lv_obj_set_size(seg, w, 36); + lv_obj_align(seg, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_set_style_pad_all(seg, 0, 0); + lv_obj_set_style_pad_column(seg, 3, 0); + lv_obj_set_style_bg_opa(seg, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(seg, 0, 0); + lv_obj_set_style_bg_color(seg, lv_color_hex(theme::SURFACE_2), LV_PART_ITEMS); + lv_obj_set_style_bg_color(seg, lv_color_hex(theme::ACCENT_DIM), LV_PART_ITEMS | LV_STATE_CHECKED); + lv_obj_set_style_text_color(seg, lv_color_hex(theme::TEXT), LV_PART_ITEMS); + lv_obj_set_style_text_font(seg, THEME_FONT_SMALL, LV_PART_ITEMS); + lv_obj_set_style_shadow_width(seg, 0, LV_PART_ITEMS); + lv_obj_set_style_radius(seg, 8, LV_PART_ITEMS); + lv_obj_add_event_cb(seg, cb, LV_EVENT_VALUE_CHANGED, NULL); + return seg; +} + +static lv_obj_t* actionRow(lv_obj_t* panel) { + lv_obj_t* acts = lv_obj_create(panel); + styleSurface(acts, theme::BG); + lv_obj_remove_flag(acts, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(acts, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(acts, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(acts, theme::GAP, 0); + return acts; +} + +static lv_obj_t* actionButton(lv_obj_t* acts, const char* text, lv_event_cb_t cb, uint8_t act, bool on) { + lv_obj_t* bt = lv_button_create(acts); + lv_obj_set_height(bt, 40); + lv_obj_set_flex_grow(bt, 1); + lv_obj_set_style_pad_hor(bt, 4, 0); + lv_obj_set_style_radius(bt, theme::RADIUS, 0); + lv_obj_set_style_shadow_width(bt, 0, 0); + lv_obj_set_style_bg_color(bt, lv_color_hex(on ? theme::ACCENT_DIM : theme::SURFACE), 0); + lv_obj_add_event_cb(bt, cb, LV_EVENT_CLICKED, (void*)(uintptr_t)act); + lv_obj_center(label(bt, text, THEME_FONT_SMALL, theme::TEXT)); + return bt; +} + +} // namespace convview + +static void onConvAction(lv_event_t* e) { s_ui->conversationAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } +static void onConvNotif(lv_event_t* e) { + s_ui->conversationNotif((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); +} +static void onMsgAction(lv_event_t* e) { s_ui->messageAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } +static void onRoomLoginKb(lv_event_t* e) { s_ui->roomLoginDone(lv_event_get_code(e) == LV_EVENT_READY); } + +static void onDMRowHold(lv_event_t* e) { + lv_indev_wait_release(lv_indev_active()); // the hold isn't also a tap that opens it + s_ui->conversationMenu(s_dm_rows[(uintptr_t)lv_event_get_user_data(e)]); +} +static void onRoomRow(lv_event_t* e) { s_ui->openRoom(s_room_rows[(uintptr_t)lv_event_get_user_data(e)]); } +static void onRoomRowHold(lv_event_t* e) { + lv_indev_wait_release(lv_indev_active()); + s_ui->conversationMenu(s_room_rows[(uintptr_t)lv_event_get_user_data(e)]); +} +static void onConvThreadMenu(lv_event_t* e) { (void)e; s_ui->conversationMenu(nullptr); } +static void onMsgHold(lv_event_t* e) { + lv_indev_wait_release(lv_indev_active()); + s_ui->messageMenu((int)(uintptr_t)lv_event_get_user_data(e)); +} +static void onChatFilter(lv_event_t* e) { s_ui->toggleChatFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } + +// ── Rooms ───────────────────────────────────────────────────────────────────── + +void UITask::openRoom(const uint8_t* pub_key) { + using namespace convview; + ContactInfo ci; + if (!MessageHistory::contactByPrefix(pub_key, ci)) return; + _core->clearRoomUnread(); + switch (_core->rooms.open(ci)) { + case RoomSessions::OPEN_NOW: + openDM(ci.id.pub_key); + break; + case RoomSessions::LOGGING_IN: // saved password: opens when the room answers + memcpy(s_login_key, ci.id.pub_key, PUB_KEY_SIZE); + s_login_wait = true; + showToast(LV_SYMBOL_REFRESH " Logging in...", 8000); + break; + case RoomSessions::NEED_PASSWORD: + roomLoginPopup(ci.id.pub_key); + break; + default: + showToast("Login failed - not sent"); + break; + } +} + +void UITask::roomLoginPopup(const uint8_t* pub_key) { + using namespace convview; + ContactInfo ci; + if (!MessageHistory::contactByPrefix(pub_key, ci)) return; + memcpy(s_login_key, ci.id.pub_key, PUB_KEY_SIZE); + lv_obj_t* panel = navPopupPanel(ci.name, false); + lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard + _nav_ta = wifiField(panel, "Room password (may be empty)", true); + lv_obj_remove_event_cb(_nav_ta, onWifiField); + lv_textarea_set_max_length(_nav_ta, 15); + lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); // draws the cursor + _nav_kb = kb::create(_nav_overlay, _prefs); + lv_obj_set_size(_nav_kb, LV_PCT(100), 124); + lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_keyboard_set_textarea(_nav_kb, _nav_ta); + lv_obj_add_event_cb(_nav_kb, onRoomLoginKb, LV_EVENT_READY, NULL); + lv_obj_add_event_cb(_nav_kb, onRoomLoginKb, LV_EVENT_CANCEL, NULL); +} + +void UITask::roomLoginDone(bool ok) { + using namespace convview; + ContactInfo ci; + if (!ok || !_nav_ta || !MessageHistory::contactByPrefix(s_login_key, ci)) { navClosePopup(); return; } + bool sent = _core->rooms.login(ci, lv_textarea_get_text(_nav_ta)); + navClosePopup(); + s_login_wait = sent; + showToast(sent ? LV_SYMBOL_REFRESH " Logging in..." : "Login failed - not sent", sent ? 8000 : 2500); +} + +// From loop(), on any screen: a login answer (or its timeout). +void UITask::roomPoll() { + using namespace convview; + uint8_t key[4]; + RoomSessions::Outcome o = _core->rooms.take(key); + if (o == RoomSessions::NONE) return; + bool mine = s_login_wait && memcmp(key, s_login_key, 4) == 0; + if (mine) s_login_wait = false; + if (o == RoomSessions::LOGGED_IN) { + showToast("Logged in", 1200); + if (mine && _screen == SCR_CHATS && !_nav_overlay) openDM(s_login_key); + else if (_screen == SCR_CHATS && !_nav_overlay) buildChats(); + } else if (o == RoomSessions::LOGIN_FAILED) { + showToast("Login failed - wrong password?", 3000); + if (mine && _screen == SCR_CHATS && !_nav_overlay) roomLoginPopup(s_login_key); + } else { + showToast("No answer from the room", 3000); + } +} + +// ── Conversation options ────────────────────────────────────────────────────── + +void UITask::conversationMenu(const uint8_t* pub_key) { + using namespace convview; + ContactInfo ci; + if (!MessageHistory::contactByPrefix(pub_key ? pub_key : _thread_key, ci)) return; + memcpy(s_key, ci.id.pub_key, PUB_KEY_SIZE); + lv_obj_t* panel = navPopupPanel(ci.name, false); + bool room = ci.type == ADV_TYPE_ROOM; + + if (!room) { // alerts: three segments (a dropdown's list runs off screen this low) + static const char* NOTIF[] = { "Default", "Muted", "Always", "" }; + lv_obj_t* row = radioview::settingRow(panel, "Alerts", NULL); + segmented(row, NOTIF, contactctl::notif(_prefs, ci.id.pub_key), 200, onConvNotif); + } else { + bool in = _core->rooms.isLoggedIn(ci.id.pub_key); + lv_obj_t* st = label(panel, in ? LV_SYMBOL_OK " Logged in: you can post" + : "Not logged in this session", THEME_FONT_SMALL, in ? theme::OK : theme::TEXT_MUTED); + lv_obj_set_width(st, LV_PCT(100)); + } + + lv_obj_t* acts = actionRow(panel); + s_fav_btn = actionButton(acts, UI_SYMBOL_STAR " Fav", onConvAction, A_FAV, contactctl::favourite(ci)); + if (!room && _core->dmUnread(ci.id.pub_key) > 0) actionButton(acts, LV_SYMBOL_OK " Read", onConvAction, A_READ, false); + if (room) { + actionButton(acts, LV_SYMBOL_EDIT " Login", onConvAction, A_LOGIN, false); + if (_core->rooms.isLoggedIn(ci.id.pub_key)) actionButton(acts, LV_SYMBOL_CLOSE " Logout", onConvAction, A_LOGOUT, false); + } +} + +void UITask::conversationNotif(int v) { + if (!_prefs || v < 0 || v > 2) return; + contactctl::setNotif(_prefs, convview::s_key, (uint8_t)v); + the_mesh.savePrefs(); +} + +void UITask::conversationAction(uint8_t act) { + using namespace convview; + ContactInfo ci; + if (!MessageHistory::contactByPrefix(s_key, ci)) { navClosePopup(); return; } + switch (act) { + case A_FAV: { + bool on = !contactctl::favourite(ci); + if (!contactctl::setFavourite(ci.id.pub_key, on)) break; + if (s_fav_btn) lv_obj_set_style_bg_color(s_fav_btn, lv_color_hex(on ? theme::ACCENT_DIM : theme::SURFACE), 0); + showToast(on ? "Added to favourites" : "Removed from favourites", 1200); + if (_screen == SCR_CHATS) { buildChats(); conversationMenu(s_key); } // star / filter + break; + } + case A_READ: + _core->clearDMUnread(ci.id.pub_key); + navClosePopup(); + if (_screen == SCR_CHATS) buildChats(); + break; + case A_LOGIN: + roomLoginPopup(ci.id.pub_key); + break; + case A_LOGOUT: + _core->rooms.logout(ci.id.pub_key); + navClosePopup(); + showToast("Logged out - password forgotten", 2000); + if (_screen == SCR_THREAD) showChats(); + else if (_screen == SCR_CHATS) buildChats(); + break; + } +} + +// ── Message actions ─────────────────────────────────────────────────────────── + +void UITask::messageMenu(int idx) { + using namespace convview; + if (idx < 0 || idx >= s_msg_meta_n) return; + const MsgMeta& m = s_msg_meta[idx]; + s_msg = idx; + lv_obj_t* panel = navPopupPanel(m.own ? "My message" : (m.from[0] ? m.from : "Message"), false); + + // The way it came: hops of an incoming message, or the repeaters heard + // relaying our own channel post. + const MessageHistory& h = _core->history; + uint8_t packed = 0; const uint8_t* path = nullptr; bool relay = false; + if (m.pos >= 0) { + if (m.channel) { const ChHistEntry& e = h.chAtPos(m.pos); packed = e.path_len; path = e.path; relay = e.relay_seq != 0; } + else { const DmHistEntry& e = h.dmAtPos(m.pos); packed = e.path_len; path = e.path; } + } + uint8_t hops = contactctl::hopCount(packed); + char text[200]; + int o = 0; + if (hops > 0) { + o = snprintf(text, sizeof(text), relay ? "Relayed by: " : "Path (%u hop%s): ", hops, hops == 1 ? "" : "s"); + for (uint8_t i = 0; i < hops && o < (int)sizeof(text) - 40; i++) { + char nm[33]; + contactctl::hopName(packed, path, i, nm, sizeof(nm)); + o += snprintf(text + o, sizeof(text) - o, "%s%s", i ? (relay ? ", " : " > ") : "", nm); + } + } else if (!m.own) { + snprintf(text, sizeof(text), "Heard directly (no repeaters)"); + } else if (m.channel) { + snprintf(text, sizeof(text), "No repeater heard relaying it yet"); + } else { + snprintf(text, sizeof(text), "Delivery is shown under the message"); // no path kept for our own DMs + } + lv_obj_t* t = label(panel, text, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); + lv_obj_set_width(t, LV_PCT(100)); + + bool can_reply = !m.own && m.from[0] && _compose_ta; + if (!can_reply && m.loc < 0) return; + lv_obj_t* acts = actionRow(panel); + if (can_reply) actionButton(acts, LV_SYMBOL_EDIT " Reply", onMsgAction, M_REPLY, false); + if (m.loc >= 0) actionButton(acts, UI_SYMBOL_COMPASS " Set target", onMsgAction, M_TARGET, false); +} + +void UITask::messageAction(uint8_t act) { + using namespace convview; + if (s_msg < 0 || s_msg >= s_msg_meta_n) { navClosePopup(); return; } + const MsgMeta& m = s_msg_meta[s_msg]; + navClosePopup(); + if (act == M_REPLY && _compose_ta) { + char pre[40]; + contactctl::replyPrefix(m.from, pre, sizeof(pre)); + lv_textarea_set_text(_compose_ta, pre); + setKeyboardVisible(true); + } else if (act == M_TARGET && m.loc >= 0 && m.loc < s_msg_loc_n) { + const MsgLoc& l = s_msg_locs[m.loc]; + navSetTarget(0, nullptr, l.lat, l.lon, l.label); // a place snapshotted from the text + char msg[48]; + snprintf(msg, sizeof(msg), "Target: %s", l.label); + showToast(msg); + } +} + +// ── Filters ─────────────────────────────────────────────────────────────────── + +void UITask::toggleChatFilter(uint8_t which) { + if (!_prefs) return; + switch (which) { + case CF_CHANNELS: _prefs->ch_fav_only ^= 1; break; + case CF_ROOMS: _prefs->room_fav_only ^= 1; break; + case CF_CONTACTS: _prefs->dm_show_all ^= 1; break; + } + the_mesh.savePrefs(); + if (_screen == SCR_CONTACTS) buildContacts(); else buildChats(); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 19ac9475..588180c4 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -177,6 +177,7 @@ void UITask::loop() { if (_screen == SCR_MAP) mapLoop(); if (_screen == SCR_WIFI) pollWifiScan(); if (_screen == SCR_ADMIN) adminPoll(); + roomPoll(); if (_screen == SCR_CLOCK && (int32_t)(millis() - _next_clock_ms) >= 0) { _next_clock_ms = millis() + 100; // stopwatch tenths refreshClock(); @@ -282,7 +283,7 @@ void UITask::onMessageArrived(const UiEvent& ev) { if (_asleep && !wake_disabled && !isClientConnected()) wake(); else if (!_asleep) lv_display_trigger_activity(NULL); showToast(buf, 3000); - if (_screen == SCR_CHATS) buildChats(); // new unread counts + if (_screen == SCR_CHATS && !_nav_overlay) buildChats(); // new unread counts (not under an open popup) } bool UITask::isViewingChannel(uint8_t channel_idx) { @@ -676,6 +677,14 @@ static void onNewChat(lv_event_t* e) { (void)e; s_ui->showContacts(); } static void onChanRowHold(lv_event_t* e); // ChannelScreen.h static void onChanAdd(lv_event_t* e); static void onChanThreadMenu(lv_event_t* e); +static uint8_t s_room_rows[16][PUB_KEY_SIZE]; +static void onDMRowHold(lv_event_t* e); // ConversationScreen.h +static void onRoomRow(lv_event_t* e); +static void onRoomRowHold(lv_event_t* e); +static void onConvThreadMenu(lv_event_t* e); +static void onMsgHold(lv_event_t* e); +static void onChatFilter(lv_event_t* e); +enum : uint8_t { CF_CHANNELS, CF_ROOMS, CF_CONTACTS }; // favourites-only filters // One tappable list row: title, optional muted subtitle, optional badge. static lv_obj_t* listRow(lv_obj_t* parent, const char* title, const char* sub, @@ -707,6 +716,26 @@ static lv_obj_t* sectionTitle(lv_obj_t* parent, const char* text) { return l; } +// Section title with an "All" / "★ Fav" pill on the right that flips the +// section's favourites-only filter. +static void sectionWithFilter(lv_obj_t* parent, const char* text, bool fav_only, uint8_t which) { + lv_obj_t* row = lv_obj_create(parent); + styleSurface(row, theme::BG); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, LV_PCT(100), 26); + lv_obj_align(label(row, text, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_BOTTOM_LEFT, 0, -2); + lv_obj_t* b = lv_button_create(row); + lv_obj_set_size(b, LV_SIZE_CONTENT, 24); + lv_obj_set_style_pad_hor(b, 10, 0); + lv_obj_set_style_pad_ver(b, 0, 0); + lv_obj_set_style_radius(b, 12, 0); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(fav_only ? theme::ACCENT_DIM : theme::SURFACE), 0); + lv_obj_align(b, LV_ALIGN_RIGHT_MID, -2, 0); + lv_obj_add_event_cb(b, onChatFilter, LV_EVENT_CLICKED, (void*)(uintptr_t)which); + lv_obj_center(label(b, fav_only ? UI_SYMBOL_STAR " Fav" : "All", THEME_FONT_SMALL, theme::TEXT)); +} + void UITask::showChats() { _screen = SCR_CHATS; buildChats(); @@ -716,14 +745,18 @@ void UITask::buildChats() { lv_obj_t* body = newScreen("Messages", true); // Channels: favourites first (unless turned off), hold a row for its options - sectionTitle(body, "CHANNELS"); + bool ch_fav_only = _prefs && _prefs->ch_fav_only; + sectionWithFilter(body, "CHANNELS", ch_fav_only, CF_CHANNELS); bool fav_first = !(_prefs && _prefs->fav_sort_off); + int ch_rows = 0; for (int pass = fav_first ? 0 : 1; pass < 2; pass++) { for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { ChannelDetails ch; if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue; bool fav = chanctl::favourite(_prefs, i); + if (ch_fav_only && !fav) continue; if (fav_first && fav != (pass == 0)) continue; + ch_rows++; char title[48], sub[64] = ""; snprintf(title, sizeof(title), "%s%s%s", fav ? UI_SYMBOL_STAR " " : "", ch.name, chanctl::notif(_prefs, i) == chanctl::NOTIF_MUTED ? " " LV_SYMBOL_MUTE : ""); @@ -737,6 +770,7 @@ void UITask::buildChats() { badge(row, _core->history.chUnread(i), _core->history.chUnreadOverflow(i)); } } + if (ch_rows == 0 && ch_fav_only) label(body, "No favourite channels", THEME_FONT_SMALL, theme::TEXT_MUTED); lv_obj_t* add_ch = lv_button_create(body); lv_obj_set_size(add_ch, LV_PCT(100), 32); lv_obj_set_style_bg_color(add_ch, lv_color_hex(theme::BG), 0); @@ -755,21 +789,54 @@ void UITask::buildChats() { bool seen = false; for (int r = 0; r < rows; r++) if (memcmp(s_dm_rows[r], e.prefix, 4) == 0) { seen = true; break; } if (seen) continue; + ContactInfo c; + bool known = MessageHistory::contactByPrefix(e.prefix, c); + if (known && c.type == ADV_TYPE_ROOM) continue; // rooms have their own section memcpy(s_dm_rows[rows], e.prefix, 4); - char name[33]; + char name[48]; contactName(e.prefix, name, sizeof(name)); + if (known && contactctl::favourite(c)) { char t[48]; snprintf(t, sizeof(t), UI_SYMBOL_STAR " %s", name); strcpy(name, t); } + if (known && contactctl::notif(_prefs, c.id.pub_key) == contactctl::NOTIF_MUTED) strncat(name, " " LV_SYMBOL_MUTE, sizeof(name) - strlen(name) - 1); char sub[64]; snprintf(sub, sizeof(sub), "%s%s", e.outgoing ? "Me: " : "", e.text); lv_obj_t* row = listRow(body, name, sub, onOpenDMRow, (void*)(uintptr_t)rows); + if (known) lv_obj_add_event_cb(row, onDMRowHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)rows); badge(row, _core->dmUnread(e.prefix), _core->dmUnreadOverflow(e.prefix)); rows++; } - int room = _core->roomUnread(); - if (room > 0) { - char t[32]; - snprintf(t, sizeof(t), "%d unread room post%s", room, room == 1 ? "" : "s"); - sectionTitle(body, t); + if (rows == 0) label(body, "No conversations yet", THEME_FONT_SMALL, theme::TEXT_MUTED); + + // Room servers: tap logs in (saved password or ask) and opens; hold: options + bool room_fav_only = _prefs && _prefs->room_fav_only; + int room_unread = _core->roomUnread(); + char rt[32]; + if (room_unread > 0) snprintf(rt, sizeof(rt), "ROOMS - %d new", room_unread); + else snprintf(rt, sizeof(rt), "ROOMS"); + sectionWithFilter(body, rt, room_fav_only, CF_ROOMS); + int nrooms = 0, total = the_mesh.getNumContacts(); + const int MAX_ROOMS = (int)(sizeof(s_room_rows) / sizeof(s_room_rows[0])); + for (int pass = fav_first ? 0 : 1; pass < 2; pass++) { + for (int i = 0; i < total && nrooms < MAX_ROOMS; i++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c) || c.type != ADV_TYPE_ROOM) continue; + bool fav = contactctl::favourite(c); + if (room_fav_only && !fav) continue; + if (fav_first && fav != (pass == 0)) continue; + memcpy(s_room_rows[nrooms], c.id.pub_key, PUB_KEY_SIZE); + char title[48], sub[64]; + snprintf(title, sizeof(title), "%s%s", fav ? UI_SYMBOL_STAR " " : "", c.name); + if (_core->history.dmHistCountForContact(c.id.pub_key) > 0) { + const DmHistEntry& e = _core->history.dmAtPos(_core->history.dmHistEntryForContact(c.id.pub_key, 0)); + snprintf(sub, sizeof(sub), "%s%s", e.outgoing ? "Me: " : "", e.text); + } else { + snprintf(sub, sizeof(sub), "%s", _core->rooms.isLoggedIn(c.id.pub_key) ? "Logged in" : "Tap to log in"); + } + lv_obj_t* row = listRow(body, title, sub, onRoomRow, (void*)(uintptr_t)nrooms); + lv_obj_add_event_cb(row, onRoomRowHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)nrooms); + nrooms++; + } } + if (nrooms == 0) label(body, room_fav_only ? "No favourite rooms" : "No room servers known", THEME_FONT_SMALL, theme::TEXT_MUTED); lv_obj_t* add = lv_button_create(body); lv_obj_set_size(add, LV_PCT(100), 36); @@ -789,6 +856,8 @@ void UITask::showContacts() { void UITask::buildContacts() { lv_obj_t* body = newScreen("New message", true); + bool fav_only = _prefs && !_prefs->dm_show_all; // ui-new's default: favourites only + sectionWithFilter(body, "CONTACTS", fav_only, CF_CONTACTS); int total = the_mesh.getNumContacts(); int rows = 0; // +MAX_ANON_CONTACTS: getContactByIdx() takes the raw table index (see @@ -797,11 +866,12 @@ void UITask::buildContacts() { ContactInfo c; if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c)) continue; if (c.type != ADV_TYPE_CHAT) continue; + if (fav_only && !contactctl::favourite(c)) continue; memcpy(s_contact_rows[rows], c.id.pub_key, PUB_KEY_SIZE); listRow(body, c.name, NULL, onOpenContactRow, (void*)(uintptr_t)rows); rows++; } - if (rows == 0) label(body, "No contacts yet", THEME_FONT_BODY, theme::TEXT_MUTED); + if (rows == 0) label(body, fav_only ? "No favourites - tap All" : "No contacts yet", THEME_FONT_BODY, theme::TEXT_MUTED); } // ── Nearby ──────────────────────────────────────────────────────────────────── @@ -1347,14 +1417,14 @@ void UITask::buildThread() { } else { contactName(_thread_key, title, sizeof(title)); ContactInfo c; - can_send = MessageHistory::contactByPrefix(_thread_key, c) && c.type == ADV_TYPE_CHAT; + can_send = MessageHistory::contactByPrefix(_thread_key, c) && (c.type == ADV_TYPE_CHAT || c.type == ADV_TYPE_ROOM); } lv_obj_t* body = newScreen(title, true); lv_obj_set_style_pad_all(body, 0, 0); lv_obj_set_style_pad_row(body, 0, 0); - if (_thread_is_channel && _header) { // channel options + if (_header && (_thread_is_channel || can_send)) { // channel / conversation options lv_obj_set_width(lv_obj_get_child(_header, 1), 200); // title, clear of the button - headerButton(_header, LV_SYMBOL_SETTINGS, onChanThreadMenu, 4, NULL); + headerButton(_header, LV_SYMBOL_SETTINGS, _thread_is_channel ? onChanThreadMenu : onConvThreadMenu, 4, NULL); } _thread_list = lv_obj_create(body); @@ -1439,6 +1509,19 @@ static const int THREAD_MAX_SHOWN = 30; static MsgLoc s_msg_locs[THREAD_MAX_SHOWN]; static int s_msg_loc_n = 0; +// What a held bubble is about: its history ring entry, sender, position slot. +struct MsgMeta { int pos; bool channel; bool own; int loc; char from[32]; }; +static MsgMeta s_msg_meta[THREAD_MAX_SHOWN]; +static int s_msg_meta_n = 0; + +static int noteMsgMeta(int pos, bool channel, bool own, int loc, const char* from) { + if (s_msg_meta_n >= THREAD_MAX_SHOWN) return -1; + MsgMeta& m = s_msg_meta[s_msg_meta_n]; + m.pos = pos; m.channel = channel; m.own = own; m.loc = loc; + snprintf(m.from, sizeof(m.from), "%s", from ? from : ""); + return s_msg_meta_n++; +} + static void onMsgLoc(lv_event_t* e) { uintptr_t v = (uintptr_t)lv_event_get_user_data(e); s_ui->messageLocationAction((int)(v >> 1), (v & 1) != 0); @@ -1459,7 +1542,7 @@ static void msgLocButton(lv_obj_t* parent, const char* text, int idx, bool save, // One message bubble. Own messages right-aligned in amber, others left. // loc_idx >= 0: the text carries a position (s_msg_locs[loc_idx]). static void bubble(lv_obj_t* list, const char* from, const char* text, bool own, - uint32_t ts, const char* status, uint32_t status_col, int loc_idx = -1) { + uint32_t ts, const char* status, uint32_t status_col, int loc_idx = -1, int meta_idx = -1) { // Full-width row that pushes the bubble to its side. lv_obj_t* row = lv_obj_create(list); styleSurface(row, theme::BG); @@ -1473,7 +1556,12 @@ static void bubble(lv_obj_t* list, const char* from, const char* text, bool own, lv_obj_t* b = lv_obj_create(row); lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE); + if (meta_idx >= 0) { // hold: reply / path / target (ConversationScreen.h) + lv_obj_add_event_cb(b, onMsgHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)meta_idx); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); + } else { + lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE); + } lv_obj_set_size(b, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_style_max_width(b, 250, 0); lv_obj_set_style_bg_color(b, lv_color_hex(own ? theme::ACCENT_DIM : theme::SURFACE), 0); @@ -1566,12 +1654,14 @@ void UITask::refreshThread() { const MessageHistory& h = _core->history; const int MAX_SHOWN = THREAD_MAX_SHOWN; s_msg_loc_n = 0; + s_msg_meta_n = 0; if (_thread_is_channel) { int n = h.histCountForChannel(_thread_channel); if (n > MAX_SHOWN) n = MAX_SHOWN; for (int j = n - 1; j >= 0; j--) { // oldest first - const ChHistEntry& e = h.chAtPos(h.histEntryForChannel(_thread_channel, j)); + int pos = h.histEntryForChannel(_thread_channel, j); + const ChHistEntry& e = h.chAtPos(pos); // Channel text is "Sender: body"; our own posts are filed as "Me: body". char from[40] = ""; const char* body = e.text; @@ -1585,14 +1675,18 @@ void UITask::refreshThread() { const char* st = NULL; uint32_t col = theme::TEXT_MUTED; if (own && e.relay_status == ACK_OK) { st = LV_SYMBOL_OK " relayed"; col = theme::OK; } else if (own && e.relay_status == ACK_PENDING) st = "sent"; + int loc = own ? -1 : noteMsgLocation(body, from); bubble(_thread_list, own ? NULL : from, body, own, e.timestamp, st, col, - own ? -1 : noteMsgLocation(body, from)); + loc, noteMsgMeta(pos, true, own, loc, own ? "" : from)); } } else { int n = h.dmHistCountForContact(_thread_key); if (n > MAX_SHOWN) n = MAX_SHOWN; + ContactInfo tc; + bool room = MessageHistory::contactByPrefix(_thread_key, tc) && tc.type == ADV_TYPE_ROOM; for (int j = n - 1; j >= 0; j--) { - const DmHistEntry& e = h.dmAtPos(h.dmHistEntryForContact(_thread_key, j)); + int pos = h.dmHistEntryForContact(_thread_key, j); + const DmHistEntry& e = h.dmAtPos(pos); const char* st = NULL; uint32_t col = theme::TEXT_MUTED; if (e.outgoing) { switch (h.dmEffectiveStatus(e)) { @@ -1602,10 +1696,14 @@ void UITask::refreshThread() { default: st = "sent"; break; } } + // A room post is filed "Author: text": shown under its author. char who[33] = ""; - if (!e.outgoing) contactName(_thread_key, who, sizeof(who)); - bubble(_thread_list, NULL, e.text, e.outgoing, e.timestamp, st, col, - e.outgoing ? -1 : noteMsgLocation(e.text, who)); + const char* text = e.text; + if (!e.outgoing && room) text = contactctl::splitRoomPost(e.text, who, sizeof(who)); + else if (!e.outgoing) contactName(_thread_key, who, sizeof(who)); + int loc = e.outgoing ? -1 : noteMsgLocation(text, who); + bubble(_thread_list, room && !e.outgoing ? who : NULL, text, e.outgoing, e.timestamp, st, col, + loc, e.outgoing ? -1 : noteMsgMeta(pos, false, false, loc, who)); // nothing to show for our own DM } } if (lv_obj_get_child_count(_thread_list) == 0) @@ -1912,3 +2010,5 @@ void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) { _prefs->keyboard_alt_alphabet = (uint8_t)(alt_sel == 0 ? main_idx : alt_sel - 1); the_mesh.savePrefs(); } + +#include "ConversationScreen.h" diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 19966f99..cbb437dd 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -136,6 +136,16 @@ public: void botPick(int idx); void botHour(uint8_t act); void botTextDone(bool ok); + // ConversationScreen.h + void openRoom(const uint8_t* pub_key); + void roomLoginPopup(const uint8_t* pub_key); + void roomLoginDone(bool ok); + void conversationMenu(const uint8_t* pub_key); // nullptr: the open thread's + void conversationAction(uint8_t act); + void conversationNotif(int v); + void messageMenu(int idx); + void messageAction(uint8_t act); + void toggleChatFilter(uint8_t which); void setGps(bool on); bool ensureGps(); // true with a fix; else turns GPS on / says it's waiting @@ -204,6 +214,7 @@ private: void refreshAdminStatus(); void adminLeave(); void adminPoll(); + void roomPoll(); void adminValuePopup(); void adminTextPopup(const char* text, bool digits); void adminReplyPopup(const char* text); diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index dfb7da82..bb1e3b0e 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -682,11 +682,11 @@ class MessagesScreen : public UIScreen { // password), so get one under way instead of opening a history view that // would silently fail to send. Targets _sel_contact. void requireRoomLogin() { - char saved_pw[sizeof(_login_pw)]; - if (the_mesh.getRoomPassword(_sel_contact.id.pub_key, saved_pw, sizeof(saved_pw))) { - // Logged in to this room before, on an earlier boot -- retry with the - // remembered password instead of prompting again. - startRoomLogin(saved_pw); + // Logged in to this room before, on an earlier boot: the Core retries with + // the remembered password instead of prompting again. + RoomSessions::Open o = _task->core().rooms.open(_sel_contact); + if (o != RoomSessions::NEED_PASSWORD) { + if (o != RoomSessions::OPEN_NOW) loginAlert(o == RoomSessions::LOGGING_IN); return; } _login_mode = true; @@ -865,41 +865,13 @@ class MessagesScreen : public UIScreen { if (state == v_set) variant |= m; else variant &= ~m; } - template - static uint8_t prefTableGet(const Entry* tbl, int n, const uint8_t* pub_key, - uint8_t Entry::* val) { - for (int i = 0; i < n; i++) - if (tbl[i].*val && memcmp(tbl[i].prefix, pub_key, 4) == 0) return tbl[i].*val; - return 0; - } - template - static void prefTableSet(Entry* tbl, int n, const uint8_t* pub_key, - uint8_t Entry::* val, uint8_t v) { - for (int i = 0; i < n; i++) - if (tbl[i].*val && memcmp(tbl[i].prefix, pub_key, 4) == 0) { - if (v == 0) memset(&tbl[i], 0, sizeof(tbl[i])); else tbl[i].*val = v; - return; - } - if (v == 0) return; - for (int i = 0; i < n; i++) - if (tbl[i].*val == 0) { memcpy(tbl[i].prefix, pub_key, 4); tbl[i].*val = v; return; } - memcpy(tbl[0].prefix, pub_key, 4); tbl[0].*val = v; // table full — overwrite slot 0 - } - // Channel notif (ui-core/ChannelControl.h): 0 = default, 1 = muted, 2 = force-on. uint8_t chNotifState(uint8_t ch_idx) const { return chanctl::notif(_task->getNodePrefs(), ch_idx); } void setChNotifState(uint8_t ch_idx, uint8_t state) { chanctl::setNotif(_task->getNodePrefs(), ch_idx, state); } - uint8_t dmNotifState(const uint8_t* pub_key) const { - NodePrefs* p = _task->getNodePrefs(); - return p ? prefTableGet(p->dm_notif, NodePrefs::DM_NOTIF_TABLE_MAX, pub_key, - &NodePrefs::DmNotifEntry::state) : 0; - } - void setDmNotifState(const uint8_t* pub_key, uint8_t state) { - NodePrefs* p = _task->getNodePrefs(); - if (p) prefTableSet(p->dm_notif, NodePrefs::DM_NOTIF_TABLE_MAX, pub_key, - &NodePrefs::DmNotifEntry::state, state); - } + // DM notif / melody overrides (ui-core/ContactControl.h). + uint8_t dmNotifState(const uint8_t* pub_key) const { return contactctl::notif(_task->getNodePrefs(), pub_key); } + void setDmNotifState(const uint8_t* pub_key, uint8_t state) { contactctl::setNotif(_task->getNodePrefs(), pub_key, state); } // Channel melody: slot 1 = melody 1 (variant bit clear), 2 = melody 2 (set). uint8_t chNotifMelody(uint8_t ch_idx) const { @@ -911,16 +883,8 @@ class MessagesScreen : public UIScreen { if (p) maskPairSet(p->ch_notif_melody_set, p->ch_notif_melody_2, ch_idx, slot, 2); } - uint8_t dmMelodySlot(const uint8_t* pub_key) const { - NodePrefs* p = _task->getNodePrefs(); - return p ? prefTableGet(p->dm_melody, NodePrefs::DM_MELODY_TABLE_MAX, pub_key, - &NodePrefs::DmMelodyEntry::slot) : 0; - } - void setDmMelody(const uint8_t* pub_key, uint8_t slot) { - NodePrefs* p = _task->getNodePrefs(); - if (p) prefTableSet(p->dm_melody, NodePrefs::DM_MELODY_TABLE_MAX, pub_key, - &NodePrefs::DmMelodyEntry::slot, slot); - } + uint8_t dmMelodySlot(const uint8_t* pub_key) const { return contactctl::melody(_task->getNodePrefs(), pub_key); } + void setDmMelody(const uint8_t* pub_key, uint8_t slot) { contactctl::setMelody(_task->getNodePrefs(), pub_key, slot); } // On-device channel Add/Edit form (Channels tab) — owned by this screen and // delegated to while active(), the same relationship WaypointsView has with @@ -974,79 +938,19 @@ public: else { if (_dm_hist_sel > 0) { _dm_hist_sel++; _dm_hist_scroll++; } } } - // Rooms successfully logged in to this power-on session. RAM-only — the - // server's ACL (see ClientACL) is the real permission store and survives - // reboot on its own, but the device has no way to query it, so this is just - // a local memo to skip re-prompting for a password already entered this - // session when the same room is picked again. - static const int ROOM_LOGIN_TABLE_SIZE = 8; - uint8_t _room_login_prefix[ROOM_LOGIN_TABLE_SIZE][4]; - int _room_login_head = 0, _room_login_count = 0; + // Room logins (who is logged in this session, the saved passwords, the one + // login in flight) are the UI Core's RoomSessions. + bool isRoomLoggedIn(const uint8_t* pub_key) const { return _task->core().rooms.isLoggedIn(pub_key); } - bool isRoomLoggedIn(const uint8_t* pub_key) const { - // Indices are relative to _room_login_head, same as forgetRoomLoggedIn() -- - // direct 0.._room_login_count indexing only happens to work before the - // ring has wrapped once (head==0); after that it silently checks the wrong - // slots. - for (int i = 0; i < _room_login_count; i++) { - int pos = (_room_login_head + i) % ROOM_LOGIN_TABLE_SIZE; - if (memcmp(_room_login_prefix[pos], pub_key, 4) == 0) return true; - } - return false; - } + void loginAlert(bool sent) { _task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500); } - void markRoomLoggedIn(const uint8_t* pub_key) { - if (isRoomLoggedIn(pub_key)) return; - int pos; - if (_room_login_count < ROOM_LOGIN_TABLE_SIZE) { - pos = (_room_login_head + _room_login_count) % ROOM_LOGIN_TABLE_SIZE; - _room_login_count++; - } else { - pos = _room_login_head; - _room_login_head = (_room_login_head + 1) % ROOM_LOGIN_TABLE_SIZE; - } - memcpy(_room_login_prefix[pos], pub_key, 4); - } - - // Reverses markRoomLoggedIn() on explicit Logout -- shifts the ring buffer - // closed over the removed slot so isRoomLoggedIn() goes back to false and - // the next room open prompts for a password instead of skipping it. - void forgetRoomLoggedIn(const uint8_t* pub_key) { - for (int i = 0; i < _room_login_count; i++) { - int pos = (_room_login_head + i) % ROOM_LOGIN_TABLE_SIZE; - if (memcmp(_room_login_prefix[pos], pub_key, 4) == 0) { - for (int j = i; j < _room_login_count - 1; j++) { - int from = (_room_login_head + j + 1) % ROOM_LOGIN_TABLE_SIZE; - int to = (_room_login_head + j) % ROOM_LOGIN_TABLE_SIZE; - memcpy(_room_login_prefix[to], _room_login_prefix[from], 4); - } - _room_login_count--; - return; - } - } - } - - // Password of the room-login attempt currently in flight -- set right - // before sendRoomLogin(), read back in onRoomLoginResult() so a successful - // attempt can be persisted (see MyMesh::saveRoomPassword()). - char _login_pw[16]; - - void startRoomLogin(const char* password) { - strncpy(_login_pw, password, sizeof(_login_pw) - 1); - _login_pw[sizeof(_login_pw) - 1] = 0; - uint32_t est_timeout = 0; // this screen's login isn't a blocking wait (see - // onRoomLoginResult() below), so no deadline needed - bool sent = the_mesh.sendRoomLogin(_sel_contact, password, est_timeout); - _task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500); - } + void startRoomLogin(const char* password) { loginAlert(_task->core().rooms.login(_sel_contact, password)); } // Result of an on-device sendRoomLogin() (MyMesh::onContactResponse(), routed // via the UI Core and UITask::onRoomLoginResult()). Surfaces as a transient alert. void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { (void)permissions; if (success) { - markRoomLoggedIn(pub_key); - the_mesh.saveRoomPassword(pub_key, _login_pw); // Auto-enter the room's chat right after a successful login so the user // doesn't have to press Enter a second time. The login result is async, // so only do it if they're still sitting on this same room in the picker @@ -1056,11 +960,6 @@ public: && memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0) { openDmHistory(); } - } else { - // Saved password (if any) no longer works -- forget it so the next - // ENTER on this room falls back to a manual prompt instead of - // silently retrying the same bad password forever. - the_mesh.forgetRoomPassword(pub_key); } _task->showAlert(success ? "Login OK" : "Login failed", 1200); } @@ -2015,8 +1914,7 @@ public: if (_pin_picker_active) return true; // rebuild below would close the submenu } else if (sel != _ctx_fav_idx) { // Logout: only reachable when isRoomLoggedIn() added this item. - the_mesh.logoutRoom(_sel_contact.id.pub_key); - forgetRoomLoggedIn(_sel_contact.id.pub_key); + _task->core().rooms.logout(_sel_contact.id.pub_key); _task->showAlert("Logged out", 1000); } // Fav is a value row -- Enter never selects it (see cycleRoomCtxValue). @@ -2080,14 +1978,14 @@ public: if (_pick_fav_slot >= 0) { commitPickFavContact(_sel_contact); return true; } if (_pick_bot_room) { if (!isRoomLoggedIn(_sel_contact.id.pub_key)) { - char saved_pw[sizeof(_login_pw)]; - if (the_mesh.getRoomPassword(_sel_contact.id.pub_key, saved_pw, sizeof(saved_pw))) { - // Known password, just not re-established this boot — retry - // silently in the background; the bot target is set below - // regardless of this attempt's outcome (self-heals like any - // other saved room password would on the next real open). - startRoomLogin(saved_pw); - } else { + // Known password, just not re-established this boot: the Core + // retries silently in the background; the bot target is set + // below regardless of this attempt's outcome (self-heals like + // any other saved room password would on the next real open). + RoomSessions::Open o = _task->core().rooms.open(_sel_contact); + if (o == RoomSessions::LOGGING_IN || o == RoomSessions::SEND_FAILED) { + loginAlert(o == RoomSessions::LOGGING_IN); + } else if (o == RoomSessions::NEED_PASSWORD) { // Never logged in — the bot could never post here without a // password, so prompt for one now instead of picking an // unusable target. Commits once submitted (see the KEYBOARD/ diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index f340817c..66cb209a 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -31,6 +31,14 @@ static bool g_sim_ready = false; extern "C" EMSCRIPTEN_KEEPALIVE int sim_is_ready() { return g_sim_ready ? 1 : 0; } + +// Advert now, so a host page's companions learn the room right away (see +// examples/simple_repeater/main.cpp's sim_test_advert_flood()). +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_advert_flood() { + if (!g_sim_ready) return 0; + the_mesh.sendSelfAdvertisement(0, true); + return 1; +} #endif static char command[MAX_POST_TEXT_LEN+1]; diff --git a/variants/sim/web/lvgl.html b/variants/sim/web/lvgl.html index 12fa1071..04950a9e 100644 --- a/variants/sim/web/lvgl.html +++ b/variants/sim/web/lvgl.html @@ -89,6 +89,7 @@ +