diff --git a/docs/solo/messages.md b/docs/solo/messages.md index c3645656..dab6d65e 100644 --- a/docs/solo/messages.md +++ b/docs/solo/messages.md @@ -16,6 +16,10 @@ and a small hop count: on a received message, how many repeaters it came through; on your own, how many repeaters were heard passing it on. In channels and rooms it also names the sender. +A conversation with unread messages opens on the first of them, under a +**New** line, so you read down to the newest. With nothing unread it opens +at the newest. + **Enter** on a message opens it full screen; **Left / Right** there pages to the older and newer one. diff --git a/examples/companion_radio/ui-core/MessageHistory.h b/examples/companion_radio/ui-core/MessageHistory.h index 81bc1dee..b49cc93b 100644 --- a/examples/companion_radio/ui-core/MessageHistory.h +++ b/examples/companion_radio/ui-core/MessageHistory.h @@ -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 diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index 7927712a..6bb9524a 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -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); diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index dbe91eef..ab810e18 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -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(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(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; } diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 36314432..178163cb 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -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}; diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index f4dc70a4..fb5d6700 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -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 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; } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index f7a76a2e..004d0ac2 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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) { diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 20af904e..d60a4d43 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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