feat(l1): older messages from the flash log, Delete after and Delete history

Past the oldest message in a conversation, Up reads the next 12 from the log.
Pages, not a longer list: the messages screen measures its whole list every
frame. Also from the full-screen view, and for a conversation the ring has lost.

Settings > Messages > Delete after hides older messages at once and frees the
flash a segment at a time, never with the clock unset or behind the log; Delete
history clears the log and the rings. A batch is also written after 12 new
messages, as a room's backlog could leave the ring unsaved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-30 10:01:29 +01:00
co-authored by Claude Opus 5.5
parent bf7b262645
commit 9b04d15da4
11 changed files with 424 additions and 51 deletions
+1 -1
View File
@@ -117,5 +117,5 @@ values above that are set from the phone app or the CLI.
| `ADVERT_NAME='"name"'` | Sets the default node name baked into a fresh device, instead of the hex of the first 4 bytes of its public key. Note the doubled quoting — it's a C string literal passed through a build flag. |
| `MAP_VECTOR` | Wio Tracker L2: turns on the unfinished vector map (regions from `tools/maps/osm_vector.py`). Without it the vector map stays off whatever was saved. |
| `HIST_ARCHIVE` | Wio Tracker L2: message history also kept on the SD card (`ui-lvgl/HistoryStore.h`). Set in its solo build. |
| `HIST_FLASH` | Wio Tracker L1: message history also kept on the QSPI flash, across a power-off (`ui-core/HistoryLog.h`, Settings > Messages > Keep messages, off by default). Implies `HIST_ARCHIVE`'s hooks. Set in its solo builds. |
| `HIST_FLASH` | Wio Tracker L1: message history also kept on the QSPI flash, across a power-off and past what RAM holds, read back a page at a time (`ui-core/HistoryLog.h`, Settings > Messages > Keep messages, off by default). Implies `HIST_ARCHIVE`'s hooks. Set in its solo builds. |
| `MESHCORE_VERSION='"x.y"'` | Overrides the upstream MeshCore base-version string shown in diagnostics, for boards whose port hasn't been rebased onto the latest yet. Cosmetic only — doesn't change protocol behaviour. |
+1 -1
View File
@@ -45,7 +45,7 @@ build flag it needs.
| Channels, direct, rooms | yes | yes | yes | yes | yes, one screen with sections |
| Opens on the first unread, "New" line | yes | yes | yes | yes | yes |
| Bubbles | 2-line boxes | whole text | whole text | whole text | whole text |
| History kept | 48 ch / 32 DM | same; the L1s can keep it on flash across a power-off (`HIST_FLASH`) | same | same | 256 / 128 in memory, all on SD (`HIST_ARCHIVE`) |
| History kept | 48 ch / 32 DM | same; the L1s can keep up to 2500 more on flash (`HIST_FLASH`) | same | same | 256 / 128 in memory, all on SD (`HIST_ARCHIVE`) |
| Path of a message | Path list | same | same | same | path diagram on the card |
| Quick messages, placeholders | yes | yes | yes | yes | yes |
| Bytes left while typing | yes | yes | yes | yes | yes |
+1
View File
@@ -349,6 +349,7 @@ static void sanitize(NodePrefs& p) {
flag(p.fav_sort, 1);
flag(p.msg_wake, 1);
if (p.msg_keep >= NodePrefs::MSG_KEEP_COUNT) p.msg_keep = 0;
if (p.msg_expire >= NodePrefs::CONTACT_EXPIRY_COUNT) p.msg_expire = 0;
if (p.contact_expiry_idx >= NodePrefs::CONTACT_EXPIRY_COUNT) p.contact_expiry_idx = 0;
if (p.dst_rule >= NodePrefs::DST_COUNT) p.dst_rule = NodePrefs::DST_OFF;
if (p.display_brightness_pct > 100) p.display_brightness_pct = 0;
+4 -1
View File
@@ -333,6 +333,9 @@ struct NodePrefsFields {
// Settings > Messages > Keep messages, boards with HIST_FLASH: how many
// messages in all stay on flash (index into msgKeepTotal(); 0 = off, none).
uint8_t msg_keep;
// Settings > Messages > Delete after: messages older than this go, from the
// list at once (index into contactExpiryDays(); 0 = never).
uint8_t msg_expire;
// ui-new's new-message alert: the banner from the top, an envelope with a
// count in the corner, or nothing (the sound and the wake still happen).
static const uint8_t MSG_ALERT_NORMAL = 0, MSG_ALERT_COMPACT = 1, MSG_ALERT_OFF = 2;
@@ -900,7 +903,7 @@ class NodePrefs : public NodePrefsFields, public ConfigSerializer {
s.def("dm_all", dm_show_all); s.def("room_fav", room_fav_only);
s.hex("ch_fav", ch_fav_bitmask); s.def("ch_fav_only", ch_fav_only);
s.hex("favs", favourite_contacts); s.hex("fav_kinds", favourite_kinds);
s.def("fav_sort", fav_sort); s.def("expiry", contact_expiry_idx); s.def("msg_keep", msg_keep);
s.def("fav_sort", fav_sort); s.def("expiry", contact_expiry_idx); s.def("msg_keep", msg_keep); s.def("msg_expire", msg_expire);
}
void gpioSolo(Section& s) {
s.def("m1", gpio1_mode); s.def("m2", gpio2_mode); s.def("m3", gpio3_mode); s.def("m4", gpio4_mode);
+262 -24
View File
@@ -16,7 +16,9 @@
// gives no warning) leaves the log as it was before the batch.
//
// A batch is written SAVE_DELAY_MS after the first change not yet saved -- not
// after the last, or a busy channel would never get there -- and at shutdown().
// after the last, or a busy channel would never get there -- at shutdown(), and
// after SAVE_BURST new messages: a room's backlog can arrive faster than the
// delay, and what leaves the ring unsaved is gone.
// A record is written once, so a message waits for its final delivery state;
// until then the entries behind it wait too, to keep the log in order.
//
@@ -24,12 +26,24 @@
// the log can be walked from the end. restore() fills the RAM rings from there.
// A channel's messages carry channelKey(), so they follow the channel, not the
// slot; those of a channel since deleted aren't restored.
//
// Older messages, past what the rings hold, are read a page at a time
// (pageBegin*(), pageOlder(), pageNewer()): the messages screen measures its
// whole list every frame, so it gets a short one, MessageHistory::older, never
// the log. A page is found by walking the log, as a conversation's records lie
// among all the others; a place in it (Spot) stays good as the log grows.
//
// Settings > Messages > Delete after hides what's older at once; the flash is
// freed a whole segment at a time, once the newest record in it is that old.
class HistoryLog : public HistArchive {
public:
static const uint8_t SEGMENTS = 8;
static const uint32_t SAVE_DELAY_MS = 60000;
static const uint32_t SETTLE_SECS = 60; // an own channel post no repeater has echoed yet
static const uint8_t SAVE_BURST = 12;
static const uint32_t PRUNE_EVERY_MS = 3600000;
static const int PAGE = 12;
void begin(NodePrefs* prefs, MessageHistory* history) {
_prefs = prefs;
@@ -41,14 +55,15 @@ public:
scan();
restore();
}
_next_prune = millis() + SAVE_DELAY_MS;
history->setArchive(this);
}
// ── HistArchive ────────────────────────────────────────────────────────────
// arc_id orders the entries of this run; a restored one keeps 0, already saved.
void chAppend(ChHistEntry& e) override { e.arc_id = ++_seq; touch(); }
void chAppend(ChHistEntry& e) override { e.arc_id = ++_seq; appended(); }
void chUpdate(const ChHistEntry&) override { touch(); }
void dmAppend(DmHistEntry& e) override { e.arc_id = ++_seq; touch(); }
void dmAppend(DmHistEntry& e) override { e.arc_id = ++_seq; appended(); }
void dmUpdate(const DmHistEntry&) override { touch(); }
void loop() {
@@ -58,10 +73,108 @@ public:
if (on) touch(); else forget();
}
if (_dirty && (int32_t)(millis() - _due) >= 0) flush(false);
if (_on && (int32_t)(millis() - _next_prune) >= 0) {
_next_prune = millis() + PRUNE_EVERY_MS;
prune(_ch);
prune(_dm);
}
}
void onShutdown() { if (_dirty && _prefs->msg_keep) flush(true); }
// Settings > Messages > Delete history: the log and the rings.
void erase() {
pageEnd();
if (_fs) forget();
_history->clearAll();
}
// ── Older messages ─────────────────────────────────────────────────────────
// Start reading a conversation from just past what the ring holds of it.
// False when the log has nothing more; true: a page is in MessageHistory::older.
bool pageBeginCh(int slot) {
ChannelDetails ch;
if (!the_mesh.getChannel(slot, ch) || !ch.name[0]) return false;
int in_ring = 0;
for (int j = 0; j < _history->chHistCount(); j++) {
const ChHistEntry& e = _history->chAtPos(_history->chHistPosNewest(j));
if (e.ch_idx == slot && e.arc_id <= _ch_done) in_ring++;
}
return pageBegin(&_ch, channelKey(ch), (uint8_t)slot, in_ring);
}
bool pageBeginDm(const uint8_t* prefix) {
int in_ring = 0;
for (int j = 0; j < _history->dmHistCount(); j++) {
const DmHistEntry& e = _history->dmAtPos(_history->dmHistPosNewest(j));
if (memcmp(e.prefix, prefix, 4) == 0 && e.arc_id <= _dm_done) in_ring++;
}
uint32_t key;
memcpy(&key, prefix, 4);
return pageBegin(&_dm, key, 0, in_ring);
}
int pageCount() const { return _pg_n; }
void pageEnd() {
free(_history->older);
_history->older = nullptr;
_pg = nullptr;
_pg_n = 0;
}
// The next page back; false, and the page as it was, at the end of the log.
bool pageOlder() {
if (!_pg) return false;
int got = 0;
Spot start = _lo;
Spot end = back(start, [&](File& f, uint32_t, uint32_t pos, const Rec& r) {
uint8_t buf[REC_MAX];
if (readBody(f, pos, r, buf)) fill(got++, r, buf);
return got < PAGE;
});
if (!got) return false;
_pg_n = got;
_hi = start;
_lo = end;
return true;
}
// The next page forward, up to where the ring takes over; false at that point.
bool pageNewer() {
if (!_pg) return false;
int got = 0;
Spot first = { 0, 0 }, past = _hi;
char p[24];
for (uint32_t seg = _hi.seg; seg && seg <= _pg->last && seg <= _ceil.seg && got < PAGE; seg++) {
filePath(*_pg, seg, p, sizeof(p));
File f = _fs->open(p, FILE_O_READ);
if (!f) continue;
uint32_t size = f.size(), pos = seg == _hi.seg ? _hi.pos : 0;
uint32_t stop = seg == _ceil.seg && _ceil.pos < size ? _ceil.pos : size;
while (pos < stop && got < PAGE) {
uint8_t buf[REC_MAX];
Rec r;
if (!f.seek(pos) || f.read(&r, sizeof(r)) != (int)sizeof(r) || !readBody(f, pos, r, buf)) break;
uint32_t next = pos + sizeof(r) + pathBytes(r.path_len) + r.text_len + 1;
if (mine(r, cutoff())) {
if (!got) first = { seg, pos };
fill(got++, r, buf);
past = { seg, next };
}
pos = next;
}
f.close();
}
if (!got) return false;
for (int i = 0, j = got - 1; i < j; i++, j--) { // index 0 is the newest
OlderSlot t = _history->older[i];
_history->older[i] = _history->older[j];
_history->older[j] = t;
}
_pg_n = got;
_lo = first;
_hi = past;
return true;
}
private:
static constexpr const char* ROOT = "/h";
@@ -82,12 +195,17 @@ private:
uint32_t first, last;
uint16_t in_last;
};
struct Spot { uint32_t seg, pos; }; // a place in a log; seg 0: before its start
void touch() {
if (!_on || _dirty) return;
_dirty = true;
_due = millis() + SAVE_DELAY_MS;
}
void appended() {
touch();
if (_on && ++_unsaved >= SAVE_BURST) _due = millis();
}
static void dirPath(const Log& l, uint32_t seg, char* out, size_t n) {
snprintf(out, n, "%s/%c%lu", ROOT, l.kind, (unsigned long)seg);
@@ -107,10 +225,19 @@ private:
return r.text_len < MSG_TEXT_BUF && len == (int)sizeof(Rec) + pathBytes(r.path_len) + r.text_len + 1;
}
// Timestamps older than this are past Settings > Messages > Delete after (0:
// keep all). Never with a clock not set, or behind the log's newest message:
// a wrong clock must not cost messages.
uint32_t cutoff() const {
uint32_t days = NodePrefs::contactExpiryDays(_prefs->msg_expire), now = rtc_clock.getCurrentTime();
return days && now >= 1000000000UL && now >= _newest ? now - days * 86400UL : 0;
}
// ── Writing ────────────────────────────────────────────────────────────────
void flush(bool all) {
uint32_t t0 = millis();
_dirty = false;
_unsaved = 0;
bool more = flushCh(all);
if (flushDm(all)) more = true;
MESH_DEBUG_PRINTLN("HistoryLog: flush %lu ms", (unsigned long)(millis() - t0));
@@ -170,6 +297,7 @@ private:
buf[n] = (uint8_t)(n + 1); n++;
if ((int)f.write(buf, n) != n) return false;
l.in_last++;
if (ts > _newest) _newest = ts;
return true;
}
@@ -187,9 +315,22 @@ private:
}
}
// Drops the segments that have gone past Delete after, never the newest.
void prune(Log& l) {
uint32_t cut = cutoff();
char p[24];
while (cut && l.first && l.first < l.last) {
Rec r;
if (lastRec(l, l.first, r) && r.timestamp >= cut) break;
dirPath(l, l.first++, p, sizeof(p));
_fs->rmdir_r(p);
}
}
void forget() {
_fs->rmdir_r(ROOT);
_dirty = false;
_newest = 0;
_ch = { 'c', 0, 0, 0 };
_dm = { 'd', 0, 0, 0 };
_ch_done = _dm_done = 0; // on again: what the rings hold goes back in
@@ -227,9 +368,25 @@ private:
uint8_t len;
for (uint32_t pos = f.size(); pos > 0 && stepBack(f, pos, len); pos -= len) n++;
f.close();
Rec r;
if (n && lastRec(l, l.last, r) && r.timestamp > _newest) _newest = r.timestamp;
return n;
}
// The last record of a segment.
bool lastRec(const Log& l, uint32_t seg, Rec& r) {
char p[24];
filePath(l, seg, p, sizeof(p));
File f = _fs->open(p, FILE_O_READ);
if (!f) return false;
uint32_t pos = f.size();
uint8_t len;
bool ok = pos > 0 && stepBack(f, pos, len) && f.seek(pos - len) &&
f.read(&r, sizeof(r)) == (int)sizeof(r) && sane(r, len);
f.close();
return ok;
}
// The record ending at pos: its length, false when that makes no sense.
static bool stepBack(File& f, uint32_t pos, uint8_t& len) {
return f.seek(pos - 1) && f.read(&len, 1) == 1 && len > sizeof(Rec) && len <= pos;
@@ -255,7 +412,7 @@ private:
void restoreLog(const Log& l, int want, const uint32_t* keys) {
if (!l.last) return;
char p[24];
uint32_t seg0 = l.last, off0 = 0;
uint32_t seg0 = l.last, off0 = 0, cut = cutoff();
int got = 0;
for (uint32_t seg = l.last; seg >= l.first && got < want; seg--) {
filePath(l, seg, p, sizeof(p));
@@ -267,7 +424,7 @@ private:
pos -= len;
Rec r;
if (!f.seek(pos) || f.read(&r, sizeof(r)) != (int)sizeof(r) || !sane(r, len)) { pos += len; break; }
if (!keys || chSlot(keys, r.key) >= 0) got++;
if ((!keys || chSlot(keys, r.key) >= 0) && r.timestamp >= cut) got++;
}
seg0 = seg;
off0 = pos;
@@ -285,7 +442,7 @@ private:
int rest = pathBytes(r.path_len) + r.text_len + 1;
if (r.text_len >= MSG_TEXT_BUF || f.read(buf, rest) != rest) break;
if (buf[rest - 1] != (uint8_t)(sizeof(r) + rest)) break;
restoreRec(r, buf, keys);
if (r.timestamp >= cut) restoreRec(r, buf, keys);
pos += sizeof(r) + rest;
}
f.close();
@@ -293,40 +450,121 @@ private:
}
void restoreRec(const Rec& r, const uint8_t* buf, const uint32_t* keys) {
int pn = pathBytes(r.path_len);
if (keys) {
int slot = chSlot(keys, r.key);
if (slot < 0) return;
ChHistEntry e;
memset(&e, 0, sizeof(e));
e.ch_idx = (uint8_t)slot;
e.timestamp = r.timestamp;
e.relay_status = r.flags & STATUS_MASK;
e.path_len = r.path_len;
memcpy(e.path, buf, pn);
memcpy(e.text, buf + pn, r.text_len);
fillCh(e, r, buf, (uint8_t)slot);
_history->restoreCh(e);
} else {
DmHistEntry e;
memset(&e, 0, sizeof(e));
memcpy(e.prefix, &r.key, 4);
e.outgoing = (r.flags & OUTGOING) ? 1 : 0;
e.timestamp = r.timestamp;
e.msg_ts = r.msg_ts;
e.ack_status = r.flags & STATUS_MASK;
e.path_len = r.path_len;
memcpy(e.path, buf, pn);
memcpy(e.text, buf + pn, r.text_len);
fillDm(e, r, buf);
_history->restoreDm(e);
}
}
static void fillCh(ChHistEntry& e, const Rec& r, const uint8_t* buf, uint8_t slot) {
int pn = pathBytes(r.path_len);
memset(&e, 0, sizeof(e));
e.ch_idx = slot;
e.timestamp = r.timestamp;
e.relay_status = r.flags & STATUS_MASK;
e.path_len = r.path_len;
memcpy(e.path, buf, pn);
memcpy(e.text, buf + pn, r.text_len);
}
static void fillDm(DmHistEntry& e, const Rec& r, const uint8_t* buf) {
int pn = pathBytes(r.path_len);
memset(&e, 0, sizeof(e));
memcpy(e.prefix, &r.key, 4);
e.outgoing = (r.flags & OUTGOING) ? 1 : 0;
e.timestamp = r.timestamp;
e.msg_ts = r.msg_ts;
e.ack_status = r.flags & STATUS_MASK;
e.path_len = r.path_len;
memcpy(e.path, buf, pn);
memcpy(e.text, buf + pn, r.text_len);
}
// ── Older messages ─────────────────────────────────────────────────────────
bool pageBegin(Log* l, uint32_t key, uint8_t slot, int in_ring) {
pageEnd();
if (!_on || !l->last) return false;
_history->older = (OlderSlot*)malloc(PAGE * sizeof(OlderSlot));
if (!_history->older) return false;
_pg = l;
_pg_key = key;
_pg_slot = slot;
Spot from = { l->last, 0xFFFFFFFFUL };
int skip = in_ring;
if (skip > 0) from = back(from, [&](File&, uint32_t, uint32_t, const Rec&) { return --skip > 0; });
_ceil = _lo = _hi = from;
if (pageOlder()) return true;
pageEnd();
return false;
}
bool mine(const Rec& r, uint32_t cut) const { return r.key == _pg_key && r.timestamp >= cut; }
// Walks the conversation's records back from `from` until fn says stop:
// where it stopped, or the log's start. fn(file, seg, pos, record).
template <class F> Spot back(Spot from, F fn) {
const Log& l = *_pg;
uint32_t cut = cutoff();
char p[24];
for (uint32_t seg = from.seg; seg && seg >= l.first; seg--) {
filePath(l, seg, p, sizeof(p));
File f = _fs->open(p, FILE_O_READ);
if (!f) continue;
uint32_t pos = f.size();
if (seg == from.seg && from.pos < pos) pos = from.pos;
uint8_t len;
while (pos > 0 && stepBack(f, pos, len)) {
pos -= len;
Rec r;
if (!f.seek(pos) || f.read(&r, sizeof(r)) != (int)sizeof(r) || !sane(r, len)) break;
if (mine(r, cut) && !fn(f, seg, pos, r)) {
f.close();
return { seg, pos };
}
}
f.close();
}
return { 0, 0 };
}
static bool readBody(File& f, uint32_t pos, const Rec& r, uint8_t* buf) {
int rest = pathBytes(r.path_len) + r.text_len + 1;
return r.text_len < MSG_TEXT_BUF && f.seek(pos + sizeof(r)) && f.read(buf, rest) == rest &&
buf[rest - 1] == (uint8_t)(sizeof(r) + rest);
}
void fill(int i, const Rec& r, const uint8_t* buf) {
OlderSlot& o = _history->older[i];
if (_pg == &_ch) {
fillCh(o.ch, r, buf, _pg_slot);
if (o.ch.relay_status == ACK_PENDING) o.ch.relay_status = ACK_NONE;
} else {
fillDm(o.dm, r, buf);
if (o.dm.ack_status == ACK_PENDING) o.dm.ack_status = ACK_NONE;
}
}
NodePrefs* _prefs = nullptr;
MessageHistory* _history = nullptr;
FILESYSTEM* _fs = nullptr;
bool _on = false, _dirty = false;
uint32_t _due = 0;
uint32_t _due = 0, _next_prune = 0;
uint8_t _unsaved = 0;
uint32_t _newest = 0; // the newest timestamp in the log
uint32_t _seq = 0, _ch_done = 0, _dm_done = 0; // arc_id: the last given, the last saved
Log _ch = { 'c', 0, 0, 0 };
Log _dm = { 'd', 0, 0, 0 };
// The page being read: its log and conversation, where it starts and where
// the one after it does, and where the ring takes over from the log.
Log* _pg = nullptr;
uint32_t _pg_key = 0;
uint8_t _pg_slot = 0;
int _pg_n = 0;
Spot _lo = { 0, 0 }, _hi = { 0, 0 }, _ceil = { 0, 0 };
};
@@ -113,6 +113,11 @@ public:
};
#endif
#ifdef HIST_FLASH
// A page of messages read back from the flash log (HistoryLog.h).
union OlderSlot { ChHistEntry ch; DmHistEntry dm; };
#endif
// Boards with PSRAM raise these (the L2: -D HIST_CH_MAX=256 -D HIST_DM_MAX=128,
// ~80 KB in PSRAM with UiCore); the defaults are sized for the nRF52's heap.
#ifndef HIST_CH_MAX
@@ -274,8 +279,16 @@ public:
#endif
}
#ifdef HIST_FLASH
// A position past the ring is a slot of the page of older messages, for the
// messages screen, which reaches both the same way. Allocated while it's open.
OlderSlot* older = nullptr;
ChHistEntry& chAtPos(int pos) { return pos >= CH_HIST_MAX ? older[pos - CH_HIST_MAX].ch : _hist[pos]; }
const ChHistEntry& chAtPos(int pos) const { return pos >= CH_HIST_MAX ? older[pos - CH_HIST_MAX].ch : _hist[pos]; }
#else
ChHistEntry& chAtPos(int pos) { return _hist[pos]; }
const ChHistEntry& chAtPos(int pos) const { return _hist[pos]; }
#endif
// ── Per-channel unread counters ─────────────────────────────────────────────
// Always clamped to what the ring still holds for that channel. The raw
@@ -541,8 +554,13 @@ public:
// Recent DM contacts, newest first, deduped. Resolves the 4-byte _dm_hist
// prefix to a 6-byte pub_key prefix by walking the contact list once per
// unique sender. Returns the number filled.
#ifdef HIST_FLASH
DmHistEntry& dmAtPos(int pos) { return pos >= DM_HIST_MAX ? older[pos - DM_HIST_MAX].dm : _dm_hist[pos]; }
const DmHistEntry& dmAtPos(int pos) const { return pos >= DM_HIST_MAX ? older[pos - DM_HIST_MAX].dm : _dm_hist[pos]; }
#else
DmHistEntry& dmAtPos(int pos) { return _dm_hist[pos]; }
const DmHistEntry& dmAtPos(int pos) const { return _dm_hist[pos]; }
#endif
// Whole DM ring, newest first (j = 0 newest) -- for "recent conversations".
int dmHistCount() const { return _dm_hist_count; }
@@ -21,7 +21,7 @@ static const Key KEYS[] = {
K("autoadd", manual_add_contacts), KB("autoadd.chat", autoadd_config, 0x02), KB("autoadd.repeater", autoadd_config, 0x04),
KB("autoadd.room", autoadd_config, 0x08), KB("autoadd.sensor", autoadd_config, 0x10),
KB("autoadd.overwrite", autoadd_config, 0x01), K("autoadd.max.hops", autoadd_max_hops),
K("contacts.expiry", contact_expiry_idx), K("contacts.fav.first", fav_sort), K("contacts.channel.order", ch_sort), K("map.flow", flow_show), K("dm.resend", dm_resend_count), K("messages.keep", msg_keep),
K("contacts.expiry", contact_expiry_idx), K("contacts.fav.first", fav_sort), K("contacts.channel.order", ch_sort), K("map.flow", flow_show), K("dm.resend", dm_resend_count), K("messages.keep", msg_keep), K("messages.expire", msg_expire),
K("display.brightness", display_brightness), K("display.off", auto_off_secs), K("display.wake", msg_wake),
K("display.alert", msg_alert), K("lock", auto_lock), K("lock.clock", lock_compact),
K("display.text.size", ui_text_scale), K("display.icon.size", ui_icon_scale),
@@ -140,6 +140,9 @@ static void optResend(uint8_t v, char* b, int n, const NodePrefs&) {
static void optMsgKeep(uint8_t v, char* b, int n, const NodePrefs&) {
if (v == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%u", (unsigned)NodePrefs::msgKeepTotal(v));
}
static void optMsgExpire(uint8_t v, char* b, int n, const NodePrefs&) {
if (v == 0) snprintf(b, n, "Never"); else snprintf(b, n, "%u days", (unsigned)NodePrefs::contactExpiryDays(v));
}
#endif
static void optBattDisplay(uint8_t v, char* b, int n, const NodePrefs&) {
static const char* L[] = { "Icon", "Percent", "Voltage" };
@@ -296,6 +299,8 @@ static const Setting ALL[] = {
#ifdef HIST_FLASH
IDX("Keep messages", "On flash, after a power-off too", SEC_MESSAGES, msg_keep, NodePrefs::MSG_KEEP_COUNT,
optMsgKeep, nullptr),
IDX("Delete after", "Messages older than this", SEC_MESSAGES, msg_expire, NodePrefs::CONTACT_EXPIRY_COUNT,
optMsgExpire, nullptr),
#endif
IDX("Contact expiry", "Prune after inactivity", SEC_CONTACTS, contact_expiry_idx,
@@ -110,6 +110,9 @@ public:
void clearAllDMUnread() { dm_unread.clearAll(); }
// Every conversation read: direct, channels, rooms.
void markAllRead() { _room_unread = 0; dm_unread.clearAll(); history.clearAllChannelUnread(); }
#ifdef HIST_FLASH
void deleteHistory() { history_log.erase(); markAllRead(); } // Settings > Messages > Delete history
#endif
// ── Actions ───────────────────────────────────────────────────────────────
// Send a DM composed on the device and file it (with end-to-end ACK tracking
+106 -23
View File
@@ -142,6 +142,7 @@ class MessagesScreen : public UIScreen {
// DM_HIST view state (the ring itself is in _history).
int _dm_hist_sel, _dm_hist_scroll;
bool _older = false; // showing a page of the flash log, not the ring
FullscreenMsgView _dm_fs;
// "New" over the first message unread when the conversation opened (index
@@ -742,6 +743,7 @@ class MessagesScreen : public UIScreen {
}
_reply_mode = false;
_share_mode = false;
dropOlder(); // a reply from an older page: back to the newest
if (_sending_to_channel) {
_hist_sel = 0;
_hist_scroll = 0;
@@ -1057,10 +1059,10 @@ public:
// relabeling a different one in under them. At sel <= 0 (already at newest,
// or -1 == compose button focused) there's nothing to preserve.
bool isViewingChannel(uint8_t ch_idx) const {
return _phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx;
return _phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx && !_older;
}
bool isViewingDM(const uint8_t* pub_key) const {
return _phase == DM_HIST && memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0;
return _phase == DM_HIST && memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0 && !_older;
}
void onViewedHistoryGrew(bool channel) {
if (channel) {
@@ -1102,6 +1104,73 @@ public:
// Open the message history for the currently selected contact/room and reset
// the scroll/selection view state. Shared by the Enter-on-contact path and the
// post-login auto-enter.
// The conversation being shown: the ring's entries for it, or a page of
// older ones from the flash log. A list of a few either way, as a render
// measures all of it. `_older` is that page.
int chViewCount() const {
return _older ? olderCount() : _history.histCountForChannel(_sel_channel_idx);
}
int chViewPos(int j) const {
if (_older) return j < olderCount() ? MessageHistory::CH_HIST_MAX + j : -1;
return _history.histEntryForChannel(_sel_channel_idx, j);
}
int dmViewCount() const {
return _older ? olderCount() : _history.dmHistCountForContact(_sel_contact.id.pub_key);
}
int dmViewPos(int j) const {
if (_older) return j < olderCount() ? MessageHistory::DM_HIST_MAX + j : -1;
return _history.dmHistEntryForContact(_sel_contact.id.pub_key, j);
}
#ifdef HIST_FLASH
int olderCount() const { return _task->core().history_log.pageCount(); }
void dropOlder() {
if (_older) _task->core().history_log.pageEnd();
_older = false;
}
// Past the oldest entry: the next page back. True when that's handled
// (at the very end nothing moves); false with nothing older, as a plain list.
bool turnOlder(bool channel) {
HistoryLog& log = _task->core().history_log;
if (_older) {
if (!log.pageOlder()) return true;
} else {
if (!(channel ? log.pageBeginCh(_sel_channel_idx) : log.pageBeginDm(_sel_contact.id.pub_key))) return false;
_older = true;
_new_idx = -1;
_anchor_new = false;
}
(channel ? _hist_sel : _dm_hist_sel) = 0;
(channel ? _hist_scroll : _dm_hist_scroll) = 0;
return true;
}
// Past the newest of a page: the next one forward, then the ring again.
bool turnNewer(bool channel) {
if (!_older) return false;
int count;
if (_task->core().history_log.pageNewer()) {
count = olderCount();
} else {
bool shown = (channel ? _fs : _dm_fs).active;
leaveOlder(channel);
(channel ? _fs : _dm_fs).active = shown;
_new_idx = -1;
count = channel ? chViewCount() : dmViewCount();
}
(channel ? _hist_sel : _dm_hist_sel) = count - 1;
(channel ? _hist_scroll : _dm_hist_scroll) = count > _hist_visible ? count - _hist_visible : 0;
return true;
}
#else
int olderCount() const { return 0; }
void dropOlder() {}
bool turnOlder(bool) { return false; }
bool turnNewer(bool) { return false; }
#endif
void leaveOlder(bool channel) {
dropOlder();
if (channel) beginChannelView(_sel_channel_idx); else beginDmView();
}
void openDmHistory() {
beginDmView();
_phase = DM_HIST;
@@ -1110,6 +1179,7 @@ public:
// Opening a conversation: on its first unread message ("New" above it), else
// on the compose row under the newest.
void beginDmView() {
dropOlder();
const uint8_t* key = _sel_contact.id.pub_key;
_new_idx = _history.firstUnreadDm(key, _task->getDMUnread(key));
_anchor_new = _new_idx >= 0;
@@ -1119,6 +1189,7 @@ public:
_dm_fs.active = false;
}
void beginChannelView(uint8_t channel_idx) {
dropOlder();
_sel_channel_idx = channel_idx;
_unread_at_entry = (int)_history.chUnread(channel_idx);
_unread_base = 0;
@@ -1187,7 +1258,7 @@ public:
// resulting count down into the store. The ring lives in _history, but this
// "what has the user seen on screen" logic is pure phase-machine state.
void updateChannelUnread() {
if (_sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return;
if (_sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS || _older) return;
// histEntryForChannel is newest-first: index 0 = newest, higher = older;
// the unread from opening are _unread_base onwards. Count everything
// actually rendered on screen as seen — not just the highlighted row — so
@@ -1207,6 +1278,7 @@ public:
}
void reset() {
dropOlder();
_phase = MODE_SELECT;
_mode_sel = 0;
_contact_sel = _contact_scroll = 0;
@@ -1540,7 +1612,7 @@ public:
display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name));
if (_dm_fs.active && _dm_hist_sel >= 0) {
int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
int ring_pos = dmViewPos(_dm_hist_sel);
if (ring_pos >= 0) {
const DmHistEntry& e = _history.dmAtPos(ring_pos);
char sender_buf[33];
@@ -1549,7 +1621,7 @@ public:
const char* body = dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM,
filtered_name, sender_buf, sizeof(sender_buf));
const char* sender = sender_buf;
int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key);
int dm_count = dmViewCount();
int ret = _dm_fs.render(display, sender, body,
_dm_hist_sel < dm_count - 1,
_dm_hist_sel > 0);
@@ -1576,7 +1648,7 @@ public:
snprintf(title, sizeof(title), "%.23s", filtered_name);
drawScreenHeader(display, title, -1, 0, true, _ctx_menu.active);
int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key);
int dm_count = dmViewCount();
uint32_t now_ts = rtc_clock.getCurrentTime();
bool is_room = (_sel_contact.type == ADV_TYPE_ROOM);
@@ -1605,13 +1677,13 @@ public:
return t;
};
auto bodyAt = [&](int idx, char* sb, size_t sbn) -> const char* {
int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx);
int rp = dmViewPos(idx);
if (rp < 0) return nullptr;
return skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, sb, sbn));
};
// Height of compact bubble idx at a given scrollbar reserve.
auto compactH = [&](int idx, int rsv) {
int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx);
int rp = dmViewPos(idx);
if (rp < 0) return lh + 1;
char sb[33];
const char* body = bodyAt(idx, sb, sizeof(sb));
@@ -1651,7 +1723,7 @@ public:
for (int ii = 0; ii < MAX_VIS_BOXES && (s + ii) < dm_count; ii++) {
int bh = compact_dm ? compactH(s + ii, reserve) : fixed_bh;
if (expand) {
int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, s + ii);
int rp = dmViewPos(s + ii);
if (rp >= 0) {
char hsb[33];
const char* hbody = skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, hsb, sizeof(hsb)));
@@ -1683,7 +1755,7 @@ public:
int y = box_ys[i];
int bh = box_hs[i];
int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, item);
int ring_pos = dmViewPos(item);
if (ring_pos < 0) continue;
const DmHistEntry& e = _history.dmAtPos(ring_pos);
@@ -1780,8 +1852,8 @@ public:
} else if (_phase == CHANNEL_HIST) {
if (_fs.active && _hist_sel >= 0) {
int fs_hist_count = _history.histCountForChannel(_sel_channel_idx);
int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel);
int fs_hist_count = chViewCount();
int ring_pos = chViewPos(_hist_sel);
if (ring_pos >= 0) {
const char* ftext = _history.chAtPos(ring_pos).text;
const char* fsep = strstr(ftext, ": ");
@@ -1834,7 +1906,7 @@ public:
}
drawScreenHeader(display, title, -1, 0, true, _ctx_menu.active);
int ch_hist_count = _history.histCountForChannel(_sel_channel_idx);
int ch_hist_count = chViewCount();
uint32_t now_ts = rtc_clock.getCurrentTime();
// On a tall enough screen (expandHistory): boxes as tall as their whole
@@ -1847,7 +1919,7 @@ public:
return computeHistScroll(display, expand, ch_hist_count, _hist_scroll,
hist_start_y, cby, lh,
[&](int idx) -> const char* {
int rp = _history.histEntryForChannel(_sel_channel_idx, idx);
int rp = chViewPos(idx);
if (rp < 0) return nullptr;
const char* t = _history.chAtPos(rp).text;
const char* s = strstr(t, ": ");
@@ -1868,7 +1940,7 @@ public:
for (int ii = 0; ii < MAX_VIS_BOXES && (s + ii) < ch_hist_count; ii++) {
int bh = fixed_bh;
if (expand) {
int rp = _history.histEntryForChannel(_sel_channel_idx, s + ii);
int rp = chViewPos(s + ii);
if (rp >= 0) {
const char* rtext = _history.chAtPos(rp).text;
const char* rsep = strstr(rtext, ": ");
@@ -1903,7 +1975,7 @@ public:
int y = box_ys[i];
int bh = box_hs[i];
int ring_pos = _history.histEntryForChannel(_sel_channel_idx, item);
int ring_pos = chViewPos(item);
if (ring_pos < 0) continue;
const char* text = _history.chAtPos(ring_pos).text;
@@ -2402,7 +2474,7 @@ public:
}
} else if (_phase == DM_HIST) {
int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key);
int dm_count = dmViewCount();
if (_dm_fs.active) {
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
@@ -2418,12 +2490,14 @@ public:
auto res = _dm_fs.handleInput(c);
if (res == FullscreenMsgView::PREV) {
if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_fs.scroll = 0; }
else if (turnOlder(false)) _dm_fs.scroll = 0;
} else if (res == FullscreenMsgView::NEXT) {
if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_fs.scroll = 0; }
else if (turnNewer(false)) _dm_fs.scroll = 0;
} else if (res == FullscreenMsgView::CLOSE) {
_dm_fs.active = false;
} else if (res == FullscreenMsgView::REPLY) {
int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
int ring_pos = dmViewPos(_dm_hist_sel);
if (ring_pos >= 0) {
bool reply_ok = !_history.dmAtPos(ring_pos).outgoing;
if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos));
@@ -2443,6 +2517,7 @@ public:
}
return true;
}
if (c == KEY_CANCEL && _older) { leaveOlder(false); return true; }
if (c == KEY_CANCEL) {
if (_direct_entry) {
_direct_entry = false;
@@ -2458,12 +2533,14 @@ public:
// now walks toward newer (lower index, physically downward) and off the
// bottom (index 0) reaches the compose row, which sits right below it.
if (c == KEY_UP) {
if (dm_count == 0 && turnOlder(false)) return true; // the ring has lost this conversation
if (_dm_hist_sel == -1 && dm_count > 0) { _dm_hist_sel = 0; _dm_hist_scroll = 0; }
else if (_dm_hist_sel >= 0 && _dm_hist_sel < dm_count - 1) {
_dm_hist_sel++;
if (_dm_hist_sel >= _dm_hist_scroll + _hist_visible)
_dm_hist_scroll = _dm_hist_sel - _hist_visible + 1;
} else if (_dm_hist_sel == dm_count - 1) {
if (turnOlder(false)) return true;
// Oldest (top of the list) -> wrap straight to the newest (bottom
// of the list), same ring-wrap every other list in the UI does at
// its ends -- the compose row sits below the list itself and is
@@ -2478,6 +2555,7 @@ public:
_dm_hist_sel--;
if (_dm_hist_sel < _dm_hist_scroll) _dm_hist_scroll = _dm_hist_sel;
} else if (_dm_hist_sel == 0) {
if (turnNewer(false)) return true;
_dm_hist_sel = -1;
} else if (_dm_hist_sel == -1 && dm_count > 0) {
// Compose row -> wrap to the oldest message (top of the list).
@@ -2497,7 +2575,7 @@ public:
return true;
}
if (c == KEY_CONTEXT_MENU && _dm_hist_sel >= 0) {
int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
int ring_pos = dmViewPos(_dm_hist_sel);
if (ring_pos >= 0) {
bool reply_ok = !_history.dmAtPos(ring_pos).outgoing;
if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos));
@@ -2507,7 +2585,7 @@ public:
}
} else if (_phase == CHANNEL_HIST) {
int ch_hist_count = _history.histCountForChannel(_sel_channel_idx);
int ch_hist_count = chViewCount();
if (_fs.active) {
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
@@ -2523,12 +2601,14 @@ public:
auto res = _fs.handleInput(c);
if (res == FullscreenMsgView::PREV) {
if (_hist_sel < ch_hist_count - 1) { _hist_sel++; _fs.scroll = 0; updateChannelUnread(); }
else if (turnOlder(true)) _fs.scroll = 0;
} else if (res == FullscreenMsgView::NEXT) {
if (_hist_sel > 0) { _hist_sel--; _fs.scroll = 0; updateChannelUnread(); }
else if (turnNewer(true)) _fs.scroll = 0;
} else if (res == FullscreenMsgView::CLOSE) {
_fs.active = false;
} else if (res == FullscreenMsgView::REPLY) {
int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel);
int ring_pos = chViewPos(_hist_sel);
if (ring_pos >= 0)
buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text), ring_pos, true);
}
@@ -2545,6 +2625,7 @@ public:
}
return true;
}
if (c == KEY_CANCEL && _older) { leaveOlder(true); return true; }
if (c == KEY_CANCEL) {
if (_direct_entry) { _direct_entry = false; _task->gotoHomeScreen(); }
else { enterChannelList(_sel_channel_idx); }
@@ -2554,11 +2635,13 @@ public:
// render block above) — UP/DOWN swap direction accordingly, same as the
// DM history handler above.
if (c == KEY_UP) {
if (ch_hist_count == 0 && turnOlder(true)) return true; // the ring has lost this channel
if (_hist_sel == -1 && ch_hist_count > 0) { _hist_sel = 0; _hist_scroll = 0; }
else if (_hist_sel >= 0 && _hist_sel < ch_hist_count - 1) {
_hist_sel++;
if (_hist_sel >= _hist_scroll + _hist_visible) _hist_scroll = _hist_sel - _hist_visible + 1;
} else if (_hist_sel == ch_hist_count - 1) {
if (turnOlder(true)) return true;
// Oldest -> wrap straight to the newest, same as the DM history handler.
_hist_sel = 0;
_hist_scroll = 0;
@@ -2568,7 +2651,7 @@ public:
}
if (c == KEY_DOWN) {
if (_hist_sel > 0) { _hist_sel--; if (_hist_sel < _hist_scroll) _hist_scroll = _hist_sel; }
else if (_hist_sel == 0) _hist_sel = -1;
else if (_hist_sel == 0) { if (turnNewer(true)) return true; _hist_sel = -1; }
else if (_hist_sel == -1 && ch_hist_count > 0) {
// Compose row -> wrap to the oldest message.
_hist_sel = ch_hist_count - 1;
@@ -2589,7 +2672,7 @@ public:
return true;
}
if (c == KEY_CONTEXT_MENU && _hist_sel >= 0) {
int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel);
int ring_pos = chViewPos(_hist_sel);
if (ring_pos >= 0)
buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text), ring_pos, true);
return true;
@@ -73,6 +73,9 @@ class SettingsScreen : public UIScreen {
// Messages section
SECTION_MESSAGES,
SCHEMA_MESSAGES,
#ifdef HIST_FLASH
DELETE_HISTORY,
#endif
MSG_SLOT_0, MSG_SLOT_1, MSG_SLOT_2, MSG_SLOT_3, MSG_SLOT_4,
MSG_SLOT_5, MSG_SLOT_6, MSG_SLOT_7, MSG_SLOT_8, MSG_SLOT_9,
Count,
@@ -585,6 +588,10 @@ class SettingsScreen : public UIScreen {
val("Rooms", (p && p->room_fav_only) ? "Fav" : "All");
} else if (item == PRUNE_NOW) {
display.print("Prune now"); // action row: Enter counts + confirms + removes
#ifdef HIST_FLASH
} else if (item == DELETE_HISTORY) {
display.print("Delete history"); // action row: Enter confirms + erases
#endif
} else if (isMsgSlot(item)) {
// A message template reads from the left, cut at the edge (scrolls when selected).
int slot = msgSlotIndex(item);
@@ -994,6 +1001,15 @@ public:
// Contacts > "Prune now" confirm
if (_prune_confirm.active) {
auto res = _prune_confirm.handleInput(c);
#ifdef HIST_FLASH
if (_selected == DELETE_HISTORY) {
if (res == PopupMenu::SELECTED && _prune_confirm.selectedIndex() == 0) { // "Delete"
_task->core().deleteHistory();
_task->showAlert("History deleted", 1400);
}
return true;
}
#endif
if (res == PopupMenu::SELECTED && _prune_confirm.selectedIndex() == 0) { // "Remove"
int n = the_mesh.pruneStaleContacts();
char msg[24];
@@ -1238,6 +1254,12 @@ public:
}
return true;
}
#ifdef HIST_FLASH
if (_selected == DELETE_HISTORY && enter) {
_prune_confirm.beginConfirm("Delete all messages?", "Delete");
return true;
}
#endif
if (isMsgSlot(_selected) && enter) {
int slot = msgSlotIndex(_selected);
_edit_slot = slot;