feat: open a conversation on its first unread message, under a "New" line

Both UIs: a channel, DM or room with unread messages opens on the oldest of
them (MessageHistory::firstUnreadChannel / firstUnreadDm, own messages not
counted), a "New" line above it, so it reads down to the newest. Nothing
unread: the newest, as before.

L1: the first unread is selected and scrolled up top with the line in view;
the channel's unread count now counts down as you read towards the newest.
L2: the page holding it, scrolled to the line; an incoming message scrolls
down only when the list is already at its end. Sim hooks: sim_unread (L1),
sim_open("unread") (L2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-08 22:20:04 +02:00
co-authored by Claude Opus 5.5
parent 5e87686183
commit aaf35351d7
8 changed files with 271 additions and 63 deletions
@@ -413,6 +413,31 @@ public:
return -1;
}
// Where reading resumes on opening a conversation with `unread` unread: the
// newest-first index (as histEntryForChannel / dmHistEntryForContact) of the
// oldest of the `unread` newest received messages -- our own don't count --
// or of the oldest held when some were pushed out. -1 when none is unread.
int firstUnreadChannel(int ch_idx, int unread) const {
int j = 0, last = -1;
for (int i = _hist_count - 1; i >= 0 && unread > 0; i--) {
const ChHistEntry& e = _hist[(_hist_head + i) % CH_HIST_MAX];
if (e.ch_idx != (uint8_t)ch_idx) continue;
last = j++;
if (strncmp(e.text, "Me: ", 4) != 0) unread--;
}
return last;
}
int firstUnreadDm(const uint8_t* prefix, int unread) const {
int j = 0, last = -1;
for (int i = _dm_hist_count - 1; i >= 0 && unread > 0; i--) {
const DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
if (memcmp(e.prefix, prefix, 4) != 0) continue;
last = j++;
if (!e.outgoing) unread--;
}
return last;
}
// Effective status for display. A pending ACK only reads as failed once its
// deadline has passed AND no auto-resends remain — while resends_left > 0 the
// entry stays pending (tickDmResends() retries / finalises it). Safety net for
+22
View File
@@ -224,6 +224,28 @@ public:
if (viewing && pos >= 0) _host->onViewedHistoryGrew(true);
return pos;
}
#ifdef SIM_PLATFORM
// Sim tests: channel 0 and the first chat contact get 12 read messages, then 5 new.
void simUnreadDemo() {
uint32_t now = rtc_clock.getCurrentTime();
static const char* const W[] = { "Alice", "Bob", "Me", "Carol" };
const uint8_t* dm = nullptr;
ContactInfo c;
for (int i = 0; i < the_mesh.getNumContacts() && !dm; i++)
if (the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c) && c.type == ADV_TYPE_CHAT) dm = c.id.pub_key;
for (int i = 0; i < 17; i++) {
char t[48];
snprintf(t, sizeof(t), "%s: message %d", i < 12 ? W[i % 4] : W[i % 2], i + 1);
addChannelMsg(0, t, now - (17 - i) * 120);
if (dm) {
bool out = i < 12 && i % 3 == 2;
if (!out) dm_unread.onIncoming(dm);
addDMMsg(dm, out, strstr(t, ": ") + 2, now - (17 - i) * 120);
}
if (i == 11) { history.setChUnread(0, 0); if (dm) clearDMUnread(dm); }
}
}
#endif
void armChannelRelay(int pos, uint32_t seq) override { history.armChannelRelay(pos, seq); }
void onChannelRelayed(uint32_t seq, const uint8_t* repeater_hash = nullptr, uint8_t hash_size = 0) override {
history.markChannelRelayed(seq, repeater_hash, hash_size);
+56 -7
View File
@@ -3620,22 +3620,33 @@ void UITask::setKeyboardVisible(bool show) {
}
}
static const int THREAD_MAX_SHOWN = 50; // newest bubbles built per conversation
// Opens on the first unread (newest-first index `first`, -1: none), "New"
// above it: the page that holds it, a few read ones above.
void UITask::threadOpenAt(int first) {
_thread_new = first;
_thread_new_on = false;
_thread_skip = first < THREAD_MAX_SHOWN ? 0 : first - (THREAD_MAX_SHOWN - 4);
}
void UITask::openChannel(uint8_t channel_idx) {
_thread_is_channel = true;
_thread_channel = channel_idx;
_thread_skip = 0;
_core->history.setChUnread(channel_idx, 0);
MessageHistory& h = _core->history;
threadOpenAt(h.firstUnreadChannel(channel_idx, h.chUnread(channel_idx)));
h.setChUnread(channel_idx, 0);
_screen = SCR_THREAD;
buildThread();
}
void UITask::openDM(const uint8_t* pub_key) {
_thread_is_channel = false;
_thread_skip = 0;
memset(_thread_key, 0, sizeof(_thread_key));
ContactInfo c;
if (MessageHistory::contactByPrefix(pub_key, c)) memcpy(_thread_key, c.id.pub_key, PUB_KEY_SIZE);
else memcpy(_thread_key, pub_key, 4);
threadOpenAt(_core->history.firstUnreadDm(_thread_key, _core->dmUnread(_thread_key)));
_core->clearDMUnread(_thread_key);
_screen = SCR_THREAD;
buildThread();
@@ -3742,7 +3753,6 @@ uint32_t UITask::threadSignature() const {
// Positions found in the shown messages ([WAY] / [LOC] / plain "lat,lon"),
// for the Go / Save buttons under such a bubble.
struct MsgLoc { int32_t lat, lon; char label[WAYPOINT_LABEL_LEN * 2]; };
static const int THREAD_MAX_SHOWN = 50; // newest bubbles built per conversation
static MsgLoc* s_msg_locs = psramBuf<MsgLoc>(THREAD_MAX_SHOWN);
static int s_msg_loc_n = 0;
@@ -3765,6 +3775,28 @@ struct ThreadRow { uint32_t key, state; lv_obj_t* row; lv_obj_t* age; };
static ThreadRow* s_th_rows = psramBuf<ThreadRow>(THREAD_MAX_SHOWN);
static lv_obj_t* s_older_lbl = nullptr; // "Older messages (n)" on the page's top
static bool s_th_rows_newest = false; // built for the newest page (the one patched)
static lv_obj_t* s_new_div = nullptr; // "New" above the first unread
// The line with "New" over the first message unread when the thread opened.
static lv_obj_t* newDivider(lv_obj_t* list) {
lv_obj_t* d = lv_obj_create(list);
lv_obj_remove_style_all(d);
lv_obj_set_size(d, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(d, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(d, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(d, 6, 0);
lv_obj_remove_flag(d, LV_OBJ_FLAG_CLICKABLE);
for (int i = 0; i < 3; i++) {
if (i == 1) { label(d, "New", THEME_FONT_SMALL, theme::ACCENT); continue; }
lv_obj_t* l = lv_obj_create(d);
lv_obj_remove_style_all(l);
lv_obj_set_height(l, 1);
lv_obj_set_flex_grow(l, 1);
lv_obj_set_style_bg_color(l, lv_color_hex(theme::ACCENT), 0);
lv_obj_set_style_bg_opa(l, LV_OPA_COVER, 0);
}
return d;
}
// The shown message a bubble (or a button in it) belongs to, or -1.
static int threadRowOf(lv_obj_t* o) {
@@ -4134,7 +4166,11 @@ void UITask::refreshThread() {
drop--;
}
if (kept > 0) {
for (int r = 0; r < drop; r++) lv_obj_delete(s_th_rows[r].row);
bool at_end = lv_obj_get_scroll_bottom(_thread_list) <= theme::PAD; // not when reading further up
for (int r = 0; r < drop; r++) {
if (s_new_div && s_th_rows[r].key == _thread_new_key) { lv_obj_delete(s_new_div); s_new_div = nullptr; }
lv_obj_delete(s_th_rows[r].row);
}
memmove(s_th_rows, s_th_rows + drop, kept * sizeof(ThreadRow));
memmove(s_msg_meta, s_msg_meta + drop, kept * sizeof(MsgMeta));
for (int r = 0; r < kept; r++) {
@@ -4155,7 +4191,7 @@ void UITask::refreshThread() {
for (int r = kept; r < n; r++) build(r);
if (s_older_lbl) { char t[40]; olderText(t, sizeof(t)); setText(s_older_lbl, t); }
s_th_rows_n = s_msg_meta_n = s_msg_loc_n = n;
if (n > kept) { // new messages: down to them
if (n > kept && at_end) { // new messages: down to them
layoutNow(_thread_list);
lv_obj_scroll_to_y(_thread_list, LV_COORD_MAX, LV_ANIM_OFF);
}
@@ -4171,13 +4207,20 @@ void UITask::refreshThread() {
pageButton(_thread_list, t, 1);
s_older_lbl = lv_obj_get_child(lv_obj_get_child(_thread_list, -1), 0);
}
for (int i = 0; i < n; i++) build(i); // oldest first
s_new_div = nullptr;
for (int i = 0; i < n; i++) { // oldest first
if (_thread_new >= 0 && _thread_skip + n - 1 - i == _thread_new) { _thread_new_key = keyOf(i); _thread_new_on = true; }
if (_thread_new_on && !s_new_div && keyOf(i) == _thread_new_key && (i > 0 || s_older_lbl)) s_new_div = newDivider(_thread_list);
build(i);
}
_thread_new = -1;
s_th_rows_n = s_msg_meta_n = s_msg_loc_n = n;
s_th_rows_newest = _thread_skip == 0; // a page back is built whole each time
if (_thread_skip > 0) pageButton(_thread_list, LV_SYMBOL_DOWN " Newer messages", -1);
if (n == 0) label(_thread_list, "No messages yet", THEME_FONT_BODY, theme::TEXT_MUTED);
layoutNow(_thread_list);
lv_obj_scroll_to_y(_thread_list, _thread_scroll_top ? 0 : LV_COORD_MAX, LV_ANIM_OFF);
if (s_new_div && !_thread_scroll_top) lv_obj_scroll_to_view(s_new_div, LV_ANIM_OFF); // from the bottom: "New" on top
_thread_scroll_top = false;
}
@@ -4673,6 +4716,11 @@ void UITask::simDm() {
}
}
void UITask::simUnread() {
_core->simUnreadDemo();
if (_screen == SCR_CHATS) refreshChats();
}
// Sim tests: straight to a screen by name.
extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) {
if (!s_ui) return;
@@ -4697,6 +4745,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) {
if (!strncmp(name, "compose@", 8)) { s_ui->simCompose(name + 8); return; }
if (!strcmp(name, "dm")) { s_ui->simDm(); return; }
if (!strcmp(name, "send")) { s_ui->simSend(); return; }
if (!strcmp(name, "unread")) { s_ui->simUnread(); return; }
if (!strcmp(name, "vector")) { s_ui->setVectorMap(true); return; }
if (!strcmp(name, "maptools")) { s_ui->navToolsPopup(); return; }
if (!strcmp(name, "areasel")) { s_ui->areaSelectBegin(); return; }
@@ -130,6 +130,7 @@ public:
void simMapAt(const char* spec); // Map tools > Live tiles
void simCompose(const char* text); // the open thread's field: this text, keyboard up
void simDm(); // the first chat contact's thread
void simUnread(); // read and new messages to open on
void simSend() { sendFromCompose(); } // the field, sent
// Navigation map (NavMap.h)
void navTargetsPopup();
@@ -425,6 +426,7 @@ private:
void buildNode();
void refreshNode();
void buildThread();
void threadOpenAt(int first);
void refreshThread();
void refreshThreadAges();
uint32_t threadSignature() const;
@@ -539,6 +541,9 @@ private:
bool _thread_is_channel = false;
int _thread_skip = 0; // newest messages above the page shown (Older / Newer)
bool _thread_scroll_top = false;
int _thread_new = -1; // on opening: newest-first index of the first unread (-1: none)
uint32_t _thread_new_key = 0; // and that message, marked with "New" while the thread is open
bool _thread_new_on = false;
int loadThreadPage(int& total);
uint8_t _thread_channel = 0;
uint8_t _thread_key[PUB_KEY_SIZE] = {0};
+142 -56
View File
@@ -47,7 +47,8 @@ class MessagesScreen : public UIScreen {
int _hist_sel, _hist_scroll;
FullscreenMsgView _fs;
int _unread_at_entry; // channel unread count when entering CHANNEL_HIST
int _viewing_max_seen; // highest _hist_sel reached in current session
int _unread_base = 0; // messages filed since: the unread are indices base.. base+count-1
int _seen_lo = 0, _seen_hi = -1; // the indices shown so far this session
// KEYBOARD
KeyboardWidget* _kb;
@@ -143,6 +144,11 @@ class MessagesScreen : public UIScreen {
int _dm_hist_sel, _dm_hist_scroll;
FullscreenMsgView _dm_fs;
// "New" over the first message unread when the conversation opened (index
// as _hist_sel / _dm_hist_sel, -1: none); the first render scrolls it up top.
int _new_idx = -1;
bool _anchor_new = false;
int _hist_visible = 2; // updated in render(); for history list scroll clamping
void expandMsg(const char* tmpl, char* out, int out_len) const { // ui-core/MessageText.h
@@ -681,7 +687,6 @@ class MessagesScreen : public UIScreen {
// Filed at index 0 by sendText(): the unread index range is stale, and
// the user is active in this channel -- fully read.
_unread_at_entry = 0;
_viewing_max_seen = 0;
} else {
_dm_hist_sel = 0;
_dm_hist_scroll = 0;
@@ -969,7 +974,7 @@ public:
_sel_channel_idx(0), _sending_to_channel(false),
_msg_sel(0), _msg_scroll(0), _active_msg_count(0),
_hist_sel(0), _hist_scroll(0),
_unread_at_entry(0), _viewing_max_seen(0),
_unread_at_entry(0),
_history(task->core().history),
_dm_hist_sel(-1), _dm_hist_scroll(0),
_ctx_dirty(false), _pin_picker_active(false), _direct_entry(false), _reply_mode(false),
@@ -1003,8 +1008,14 @@ public:
return _phase == DM_HIST && memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0;
}
void onViewedHistoryGrew(bool channel) {
if (channel) { if (_hist_sel > 0) { _hist_sel++; _hist_scroll++; } }
else { if (_dm_hist_sel > 0) { _dm_hist_sel++; _dm_hist_scroll++; } }
if (channel) {
if (_hist_sel > 0) { _hist_sel++; _hist_scroll++; }
_unread_base++;
if (_seen_hi >= 0) { _seen_lo++; _seen_hi++; }
} else {
if (_dm_hist_sel > 0) { _dm_hist_sel++; _dm_hist_scroll++; }
}
if (_new_idx >= 0) _new_idx++;
}
// Room logins (who is logged in this session, the saved passwords, the one
@@ -1037,11 +1048,56 @@ public:
// the scroll/selection view state. Shared by the Enter-on-contact path and the
// post-login auto-enter.
void openDmHistory() {
_task->clearDMUnread(_sel_contact.id.pub_key);
_dm_hist_sel = -1;
beginDmView();
_phase = DM_HIST;
}
// Opening a conversation: on its first unread message ("New" above it), else
// on the compose row under the newest.
void beginDmView() {
const uint8_t* key = _sel_contact.id.pub_key;
_new_idx = _history.firstUnreadDm(key, _task->getDMUnread(key));
_anchor_new = _new_idx >= 0;
_task->clearDMUnread(key);
_dm_hist_sel = _new_idx;
_dm_hist_scroll = 0;
_dm_fs.active = false;
_phase = DM_HIST;
}
void beginChannelView(uint8_t channel_idx) {
_sel_channel_idx = channel_idx;
_unread_at_entry = (int)_history.chUnread(channel_idx);
_unread_base = 0;
_seen_lo = 0; _seen_hi = -1;
_new_idx = _history.firstUnreadChannel(channel_idx, _unread_at_entry);
_anchor_new = _new_idx >= 0;
_hist_scroll = 0;
_hist_sel = _new_idx >= 0 ? _new_idx : _history.histCountForChannel(channel_idx) > 0 ? 0 : -1;
_fs.active = false;
}
// On the first unread (sel), opening: the scroll showing it highest with
// "New" above it (stack(s) lays out from s, sets div_y when "New" fits).
template <class Stack> static int anchorScroll(Stack stack, const int& div_y, int sel, int count) {
int pick = -1;
for (int s = sel > 8 ? sel - 8 : 0; s <= sel; s++) {
int n = stack(s);
if (s + n - 1 < sel) continue;
if (div_y >= 0 || sel + 1 >= count) return s;
if (pick < 0) pick = s;
}
return pick >= 0 ? pick : sel;
}
// "New" between two lines, lh tall, over the first message unread on opening.
static void drawNewDivider(DisplayDriver& d, int y, int w, int lh) {
const char* t = "New";
int tw = d.getTextWidth(t);
int tx = (w - tw) / 2, mid = y + lh / 2;
d.setColor(DisplayDriver::LIGHT);
d.fillRect(1, mid, tx - 4, 1);
d.fillRect(tx + tw + 3, mid, w - 1 - (tx + tw + 3), 1);
d.setCursor(tx, y + 1);
d.print(t);
}
@@ -1070,15 +1126,21 @@ public:
// "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;
// histEntryForChannel is newest-first: index 0 = newest (unread), higher = older.
// Count everything actually rendered on screen as seen — not just the
// highlighted row — so a taller screen that fits more boxes at once marks
// more read up front, instead of requiring a press per row.
int seen_to = _hist_scroll + _hist_visible - 1;
if (_hist_sel > seen_to) seen_to = _hist_sel;
if (seen_to > _viewing_max_seen) _viewing_max_seen = seen_to;
// Each step down from 0 sees one more message; seen count = max_seen + 1.
int remaining = _unread_at_entry - (_viewing_max_seen + 1);
// 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
// a taller screen that fits more boxes at once marks more read up front,
// instead of requiring a press per row. Reading starts at the first unread
// and goes down to the newest, so what's been shown is one stretch.
int lo = _hist_scroll, hi = _hist_scroll + _hist_visible - 1;
if (_hist_sel >= 0 && _hist_sel < lo) lo = _hist_sel;
if (_hist_sel > hi) hi = _hist_sel;
if (_seen_hi < 0 || lo < _seen_lo) _seen_lo = lo;
if (hi > _seen_hi) _seen_hi = hi;
int a = _seen_lo > _unread_base ? _seen_lo : _unread_base;
int b = _unread_base + _unread_at_entry - 1;
if (_seen_hi < b) b = _seen_hi;
int remaining = _unread_at_entry - (b >= a ? b - a + 1 : 0);
_history.setChUnread(_sel_channel_idx, (uint8_t)(remaining > 0 ? remaining : 0));
}
@@ -1109,7 +1171,8 @@ public:
_pick_fav_slot = -1;
_direct_entry = false;
_unread_at_entry = 0;
_viewing_max_seen = 0;
_new_idx = -1;
_anchor_new = false;
_ch_view.reset();
_ch_view_pending_rebuild = false;
}
@@ -1235,10 +1298,7 @@ public:
void enterDM(const ContactInfo& ci) {
_sel_contact = ci;
_task->clearDMUnread(ci.id.pub_key);
_dm_hist_sel = -1;
_dm_hist_scroll = 0;
_dm_fs.active = false;
beginDmView();
_room_mode = false;
_phase = DM_HIST;
_direct_entry = true;
@@ -1249,10 +1309,7 @@ public:
// to. Caller must have already reset() the screen.
void enterRoom(const ContactInfo& ci) {
_sel_contact = ci;
_task->clearDMUnread(ci.id.pub_key);
_dm_hist_sel = -1;
_dm_hist_scroll = 0;
_dm_fs.active = false;
beginDmView();
_room_mode = true;
_phase = DM_HIST;
_direct_entry = true;
@@ -1262,12 +1319,7 @@ public:
// Open a channel's history directly (used by the Favourites dial). Caller
// must have already reset() the screen.
void enterChannel(uint8_t channel_idx) {
_sel_channel_idx = channel_idx;
_unread_at_entry = (int)_history.chUnread(channel_idx);
_hist_scroll = 0;
_hist_sel = _history.histCountForChannel(channel_idx) > 0 ? 0 : -1;
_viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0;
_fs.active = false;
beginChannelView(channel_idx);
_phase = CHANNEL_HIST;
_direct_entry = true;
}
@@ -1503,7 +1555,8 @@ public:
// Fixed-track scrollbar metrics + a stable gutter reserve (decided by a
// whole-list fit test, not last frame's visible count) so message boxes
// don't reflow their width as messages arrive.
HistScroll hs = compact_dm
auto scrollMetrics = [&]() {
return compact_dm
? computeHistScrollH(display, dm_count, _dm_hist_scroll, hist_start_y, cby, 1, compactH)
: computeHistScroll(display, expand, dm_count, _dm_hist_scroll,
hist_start_y, cby, lh,
@@ -1514,20 +1567,24 @@ public:
char tmp_sender[33];
return bodyAt(idx, tmp_sender, sizeof(tmp_sender));
});
};
HistScroll hs = scrollMetrics();
int reserve = hs.reserve;
{
// Stack boxes upward from just above the compose row, so item 0 (the
// newest message) lands at the bottom of the list and older messages
// sit progressively higher — same anchor convention as a typical
// messenger, instead of newest-at-top. box_ys[i] still corresponds
// to item (_dm_hist_scroll + i), same as before; only its y flips.
int div_y = -1; // "New", when shown
// Stack boxes upward from just above the compose row, so item 0 (the
// newest message) lands at the bottom of the list and older messages
// sit progressively higher — same anchor convention as a typical
// messenger, instead of newest-at-top. box_ys[i] still corresponds
// to item (s + i), same as before; only its y flips.
auto stack = [&](int s) {
const int fixed_bh = 2 * lh + 1;
const int box_gap = expand ? 2 : 1;
int cur_y = cby - box_gap; // reserve the same gap against compose as between boxes
for (int ii = 0; ii < MAX_VIS_BOXES && (_dm_hist_scroll + ii) < dm_count; ii++) {
int bh = compact_dm ? compactH(_dm_hist_scroll + ii, reserve) : fixed_bh;
n_vis = 0; div_y = -1;
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, _dm_hist_scroll + ii);
int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, s + ii);
if (rp >= 0) {
char hsb[33];
const char* hbody = skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, hsb, sizeof(hsb)));
@@ -1538,8 +1595,20 @@ public:
if (box_top < hist_start_y) break;
box_ys[n_vis] = box_top; box_hs[n_vis] = bh; n_vis++;
cur_y = box_top - box_gap;
if (s + ii == _new_idx && _new_idx + 1 < dm_count) { // "New" over it, below the older ones
if (cur_y - lh < hist_start_y) break;
div_y = cur_y - lh;
cur_y = div_y - box_gap;
}
}
return n_vis;
};
if (_anchor_new) {
_anchor_new = false;
_dm_hist_scroll = anchorScroll(stack, div_y, _dm_hist_sel, dm_count);
hs = scrollMetrics();
}
stack(_dm_hist_scroll);
_hist_visible = n_vis;
for (int i = 0; i < n_vis && (_dm_hist_scroll + i) < dm_count; i++) {
int item = _dm_hist_scroll + i;
@@ -1614,6 +1683,8 @@ public:
drawHeadedBubble(display, y, bh, lh, sel, reserve, e.outgoing, sender, body, age, mark, expand, mq_delay);
}
if (div_y >= 0) drawNewDivider(display, div_y, display.width() - reserve, lh);
if (dm_count == 0) {
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet");
@@ -1705,7 +1776,8 @@ public:
const int MAX_VIS_BOXES = 8;
int box_ys[MAX_VIS_BOXES], box_hs[MAX_VIS_BOXES], n_vis = 0;
// Fixed-track scrollbar metrics + stable gutter reserve (see DM history above).
HistScroll hs = computeHistScroll(display, expand, ch_hist_count, _hist_scroll,
auto scrollMetrics = [&]() {
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);
@@ -1714,18 +1786,22 @@ public:
const char* s = strstr(t, ": ");
return skipReplyPrefix(s ? s + 2 : t);
});
};
HistScroll hs = scrollMetrics();
int reserve = hs.reserve;
{
// Stack boxes upward from just above the compose row — see the DM
// history block above for why (newest at the bottom, like a typical
// messenger). box_ys[i] still corresponds to item (_hist_scroll + i).
int div_y = -1; // "New", when shown
// Stack boxes upward from just above the compose row — see the DM
// history block above for why (newest at the bottom, like a typical
// messenger). box_ys[i] still corresponds to item (s + i).
auto stack = [&](int s) {
const int fixed_bh = 2 * lh + 1;
const int box_gap = expand ? 2 : 1;
int cur_y = cby - box_gap; // reserve the same gap against compose as between boxes
for (int ii = 0; ii < MAX_VIS_BOXES && (_hist_scroll + ii) < ch_hist_count; ii++) {
n_vis = 0; div_y = -1;
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, _hist_scroll + ii);
int rp = _history.histEntryForChannel(_sel_channel_idx, s + ii);
if (rp >= 0) {
const char* rtext = _history.chAtPos(rp).text;
const char* rsep = strstr(rtext, ": ");
@@ -1737,8 +1813,20 @@ public:
if (box_top < hist_start_y) break;
box_ys[n_vis] = box_top; box_hs[n_vis] = bh; n_vis++;
cur_y = box_top - box_gap;
if (s + ii == _new_idx && _new_idx + 1 < ch_hist_count) { // "New" over it, below the older ones
if (cur_y - lh < hist_start_y) break;
div_y = cur_y - lh;
cur_y = div_y - box_gap;
}
}
return n_vis;
};
if (_anchor_new) {
_anchor_new = false;
_hist_scroll = anchorScroll(stack, div_y, _hist_sel, ch_hist_count);
hs = scrollMetrics();
}
stack(_hist_scroll);
_hist_visible = n_vis;
updateChannelUnread(); // mark everything in the just-computed visible window as seen
@@ -1784,6 +1872,8 @@ public:
drawHeadedBubble(display, y, bh, lh, sel, reserve, outgoing, sender, body, age, mark, expand, mq_delay);
}
if (div_y >= 0) drawNewDivider(display, div_y, display.width() - reserve, lh);
if (ch_hist_count == 0) {
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet");
@@ -2200,16 +2290,12 @@ public:
if (_pick_target) { commitPickTargetChannel(_sel_channel_idx); return true; }
if (_pick_fav_slot >= 0) { commitPickFavChannel(_sel_channel_idx); return true; }
if (_pick_bot_channel) { commitPickBotChannel(_sel_channel_idx); return true; }
int hc = _history.histCountForChannel(_sel_channel_idx);
_unread_at_entry = (int)_history.chUnread(_sel_channel_idx);
_hist_scroll = 0;
_hist_sel = hc > 0 ? 0 : -1;
_viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0;
beginChannelView(_sel_channel_idx);
_phase = CHANNEL_HIST;
// Not updateChannelUnread() here: _hist_visible is still whatever this
// channel's first render() hasn't computed yet (stale, shared with
// DM_HIST) — calling it now could ratchet _viewing_max_seen past what's
// actually about to be shown. render() calls it once that's fresh.
// DM_HIST) — calling it now could mark more seen than is about to be
// shown. render() calls it once that's fresh.
if (_share_mode) beginShareCompose(true);
return true;
}
@@ -2210,6 +2210,17 @@ void UITask::simMessage(const char* text) {
onMessageArrived(ev);
}
// Sim tests: read and new messages (UiCore::simUnreadDemo), then channel 0
// (which 0) or the first chat contact (1) opened as from the list.
void UITask::simUnread(int which) {
if (which < 0) { _core->simUnreadDemo(); return; }
if (which == 0) { openChannelHistory(0); return; }
for (int i = 0; i < the_mesh.getNumContacts(); i++) {
ContactInfo c;
if (the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c) && c.type == ADV_TYPE_CHAT) { openContactDM(c); return; }
}
}
void UITask::simCompose(const char* text) {
gotoMessagesScreen();
((MessagesScreen*)messages_screen)->simCompose(text);
@@ -2716,6 +2727,11 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_compose(const char* text) {
if (g_sim_ui_task_for_js) g_sim_ui_task_for_js->simCompose(text);
}
// Sim tests: -1 files read and new messages, 0 opens channel 0, 1 the first DM.
extern "C" EMSCRIPTEN_KEEPALIVE void sim_unread(int which) {
if (g_sim_ui_task_for_js) g_sim_ui_task_for_js->simUnread(which);
}
// Sim tests: a channel message arrives, alerted in Settings > Message alert's
// style (0 Normal, 1 Compact, 2 Off).
extern "C" EMSCRIPTEN_KEEPALIVE void sim_message(const char* text, int style) {
+1
View File
@@ -284,6 +284,7 @@ public:
#ifdef SIM_PLATFORM
void simCompose(const char* text); // sim tests: a channel post's keyboard holding `text`
void simMessage(const char* text); // sim tests: a channel message arrives
void simUnread(int which); // sim tests: read and new messages, opened
#endif
void openContactDM(const ContactInfo& ci);
void openChannelHistory(uint8_t channel_idx); // Favourites dial: open a pinned channel