mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-10-09 03:06:39 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <class Entry>
|
||||
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 <class Entry>
|
||||
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
|
||||
@@ -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};
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <class Entry>
|
||||
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 <class Entry>
|
||||
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/
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user