feat(ui): a route written in a message shown on the map (L2 Route, L1 Show route)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-26 19:46:28 +01:00
co-authored by Claude Opus 5.5
parent 009bde0db4
commit 81c551425e
8 changed files with 301 additions and 11 deletions
+132
View File
@@ -0,0 +1,132 @@
#pragma once
// A route written in a message -- a bot's {path} / !path reply ("3f:a1:c8",
// "3fa1:c802") -- placed on the map: this device, then each hop's repeater,
// then the node the message came from when it has a position. The route a
// reply carries is the way our own message went to the bot, so it starts
// here.
//
// Finding one: two or more hex groups of one width (2 or 4 characters, i.e.
// 1- or 2-byte hashes) joined by ':', standing alone. A clock time (12:30,
// 12:30:45) reads the same, so all-decimal groups that make a time aren't
// one. A single hop ("3f") is too like an ordinary word to tell.
//
// Placing a hop: a repeater with a position whose key starts with the hash
// (PacketFlow::placeHash); of several, the nearest the point before it. A hop
// that can't be placed is left out of the line, still counted and named.
//
// Header-only Core, single TU (see UiCore.h).
class HopRoute {
public:
static const int MAX_HOPS = 16;
static const int MAX_PTS = MAX_HOPS + 2; // here, the hops, the sender
struct Pt {
int32_t lat, lon;
int16_t node; // the contact (getContactByIdx), -1 this device
int8_t hop; // its hop (0-based), -1 here, -2 the sender
};
// The first route in `text`: its hashes (w bytes each) into `hashes`, the
// count into `n`.
static bool find(const char* text, uint8_t* hashes, uint8_t& n, uint8_t& w) {
for (const char* p = text; p && *p; p++) {
if (p != text && (isalnum((unsigned char)p[-1]) || p[-1] == ':')) continue;
int gw = 0;
while (isxdigit((unsigned char)p[gw])) gw++;
if (gw != 2 && gw != 4) continue;
uint8_t k = 0;
bool decimal = true, ok = true;
int vals[3] = {0, 0, 0};
const char* q = p;
for (;;) {
for (int i = 0; i < gw; i++) if (!isxdigit((unsigned char)q[i])) { ok = false; break; }
if (!ok || isalnum((unsigned char)q[gw])) { ok = false; break; }
if (k < MAX_HOPS) {
for (int b = 0; b < gw / 2; b++) hashes[k * (gw / 2) + b] = (uint8_t)((hexv(q[2 * b]) << 4) | hexv(q[2 * b + 1]));
}
for (int i = 0; i < gw; i++) if (!isdigit((unsigned char)q[i])) decimal = false;
if (k < 3 && gw == 2) vals[k] = (q[0] - '0') * 10 + (q[1] - '0');
k++;
q += gw;
if (*q != ':') break;
q++;
}
if (!ok || k < 2) continue;
if (decimal && gw == 2 && k <= 3 && vals[0] <= 23 && vals[1] <= 59 && (k < 3 || vals[2] <= 59)) continue; // a time
n = k > MAX_HOPS ? MAX_HOPS : k;
w = (uint8_t)(gw / 2);
return true;
}
return false;
}
static bool has(const char* text) {
uint8_t h[MAX_HOPS * 2], n, w;
return find(text, h, n, w);
}
// Places the route in `text` (see find()), from (own_lat, own_lon) when
// `own`, to the contact keyed `sender_key` (prefix of 4+ bytes) or named
// `sender_name` when one has a position. False when the text holds none.
bool show(const char* text, bool own, int32_t own_lat, int32_t own_lon,
const uint8_t* sender_key, const char* sender_name) {
if (!find(text, _hash, _n, _w)) return false;
_pts = 0;
_placed = 0;
if (own) addPt(own_lat, own_lon, -1, -1);
for (uint8_t i = 0; i < _n; i++) {
const bool near = _pts > 0;
int32_t la, lo;
int16_t idx;
if (PacketFlow::placeHash(&_hash[i * _w], _w, true, near ? _pt[_pts - 1].lat : 0, near ? _pt[_pts - 1].lon : 0,
near, la, lo, idx)) {
addPt(la, lo, idx, (int8_t)i);
_placed++;
}
}
ContactInfo* c = sender_key ? the_mesh.lookupContactByPubKey(sender_key, 4) : nullptr;
if (!c && sender_name && sender_name[0]) c = byName(sender_name);
if (c && (c->gps_lat || c->gps_lon)) addPt(c->gps_lat, c->gps_lon, -1, -2);
_active = true;
return true;
}
void close() { _active = false; }
bool active() const { return _active; }
uint8_t hops() const { return _n; }
uint8_t placed() const { return _placed; }
int points() const { return _pts; }
const Pt& point(int i) const { return _pt[i]; }
// A hop's name: its repeater's (as contactctl::hopName), else "?3F".
void hopName(int i, char* out, size_t len) const {
contactctl::hopName((uint8_t)((_w - 1) << 6 | _n), _hash, i, out, len);
}
// "3 of 4 hops on the map" (or "4 hops on the map").
void summary(char* out, size_t len) const {
if (_placed == _n) snprintf(out, len, "%u hop%s on the map", (unsigned)_n, _n == 1 ? "" : "s");
else snprintf(out, len, "%u of %u hops on the map", (unsigned)_placed, (unsigned)_n);
}
private:
static int hexv(char c) { return c <= '9' ? c - '0' : (c | 0x20) - 'a' + 10; }
void addPt(int32_t lat, int32_t lon, int16_t node, int8_t hop) {
if (_pts < MAX_PTS) _pt[_pts++] = { lat, lon, node, hop };
}
static ContactInfo* byName(const char* name) {
for (int idx = MAX_ANON_CONTACTS; ; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) return nullptr;
if (strcmp(c.name, name) == 0) return the_mesh.lookupContactByPubKey(c.id.pub_key, PUB_KEY_SIZE);
}
}
uint8_t _hash[MAX_HOPS * 2];
uint8_t _n = 0, _w = 1, _placed = 0;
Pt _pt[MAX_PTS];
int _pts = 0;
bool _active = false;
};
@@ -248,6 +248,7 @@ private:
return false;
}
public: // HopRoute places a route's hops the same way
// A contact with a position whose key starts with `hash` (`size` bytes):
// repeaters only when `repeaters`; with several, the nearest to (nlat, nlon)
// when `near`, else the first.
@@ -268,6 +269,7 @@ private:
return found;
}
private:
// The contact's index (getContactByIdx) by its key; -1 none.
static int16_t indexOf(const uint8_t* key) {
for (int idx = MAX_ANON_CONTACTS; ; idx++) {
@@ -30,6 +30,7 @@
#include "AdminSession.h"
#include "RoomSessions.h"
#include "ContactControl.h"
#include "HopRoute.h" // after ContactControl (hopName)
class UiCore : public MyMesh::Listener {
public:
@@ -71,6 +72,7 @@ public:
PathEditor route; // route to a node: edit, trace out and back, save
CourseEngine course; // GPS position + course-over-ground ring
PacketFlow flow; // packets heard, as lines between nodes for the map
HopRoute hop_route; // a route written in a message ({path}), placed for the map
LiveShareEngine live_share; // [LOC] auto-share session + peers' shared positions
LocatorEngine locator; // active target, geofence crossings, proximity beeper
TrailEngine trail; // GPS trail store, sampling, auto-pause, shutdown save
@@ -17,7 +17,7 @@
namespace convview {
enum : uint8_t { A_FAV, A_READ, A_LOGIN, A_LOGOUT, A_PIN };
enum : uint8_t { M_REPLY, M_TARGET };
enum : uint8_t { M_REPLY, M_TARGET, M_ROUTE };
static uint8_t s_key[PUB_KEY_SIZE]; // the conversation the popup is about
static uint8_t s_login_key[PUB_KEY_SIZE];
@@ -368,10 +368,12 @@ void UITask::messageMenu(int idx) {
}
bool can_reply = !m.own && m.from[0] && _compose_ta;
if (!can_reply && m.loc < 0) return;
const bool route = HopRoute::has(body); // a bot's {path} reply: the route on the map
if (!can_reply && m.loc < 0 && !route) return;
lv_obj_t* acts = buttonBar(panel);
if (can_reply) barButton(acts, LV_SYMBOL_EDIT " Reply", onMsgAction, M_REPLY, false);
if (m.loc >= 0) barButton(acts, UI_SYMBOL_COMPASS " Set target", onMsgAction, M_TARGET, false);
if (route) barButton(acts, UI_SYMBOL_MAP " Route", onMsgAction, M_ROUTE, false);
}
// The open message popup, every half second on a conversation: the age in
@@ -419,6 +421,18 @@ void UITask::messageAction(uint8_t act) {
char msg[48];
snprintf(msg, sizeof(msg), "Target: %s", l.label);
showToast(msg);
} else if (act == M_ROUTE && m.pos >= 0) {
// The way our message went to whoever wrote this: from here, to them.
const char* text = m.channel ? s_th_ch[m.pos].text : s_th_dm[m.pos].text;
int32_t lat, lon;
const bool own = _core->course.currentLocation(lat, lon) || _core->fixedPosition(lat, lon);
if (!_core->hop_route.show(text, own, lat, lon, m.channel ? nullptr : _thread_key, m.from)) return;
if (_core->hop_route.points() == 0) { showToast("None of its hops has a position"); _core->hop_route.close(); return; }
_map_nav = true; // the nodes map (openMap(false)), with the route's bar
_map_nodes = true;
_route_show = true;
showMap(false);
routeMapFrame();
}
}
+57 -7
View File
@@ -200,10 +200,12 @@ static int s_sig_n = 0;
static lv_obj_t* s_mark_layer = nullptr;
// The route being picked (Route > Map): its points as lat/lon, and in layer px
// from the last layout, drawn as a line under the markers.
static const int ROUTE_PTS = 10;
static const int ROUTE_PTS = HopRoute::MAX_PTS; // a picked route has 10 at most
static int32_t s_route_lat[ROUTE_PTS], s_route_lon[ROUTE_PTS];
static int s_route_n = 0;
static lv_point_precise_t s_route_px[ROUTE_PTS];
static int8_t s_route_hop[ROUTE_PTS]; // a shown message route's: each point's hop (-1 none)
static uint8_t s_route_key[ROUTE_PTS][4]; // and its repeater's key prefix
static bool s_route_on = false;
static int s_mark_count = 0;
static int s_mark_prev = 0; // during a rebuild: how many the last one left, reused where unchanged
@@ -693,7 +695,7 @@ double UITask::mapCenterBias() const {
// The bar along the map's bottom edge: the target's (Navigation map, while
// there is one) or the route pick's.
int UITask::mapBottomH() const {
if (_route_pick) return navmap::BAR_H;
if (_route_pick || _route_show) return navmap::BAR_H;
return _map_nav && _nav_bar && !lv_obj_has_flag(_nav_bar, LV_OBJ_FLAG_HIDDEN) ? navmap::BAR_H : 0;
}
@@ -736,6 +738,8 @@ void UITask::openMap(bool nav) {
// One map everywhere (trail, target, tools); from Nodes it adds the nodes.
_map_nav = true;
_map_nodes = !nav;
_route_show = false; // a message's route is shown by its own way in (messageAction)
_core->hop_route.close();
showMap();
}
@@ -903,7 +907,7 @@ void UITask::buildMap() {
lv_obj_add_flag(_map_hint, LV_OBJ_FLAG_HIDDEN);
if (_map_nav) buildNavControls(body);
_route_bar = _route_lbl = nullptr;
if (_route_pick) buildRouteBar(body);
if (_route_pick || _route_show) buildRouteBar(body);
mapPlaceBottom();
layoutNow(body);
@@ -1177,6 +1181,11 @@ void UITask::layoutMapMarks(double left, double top, int w, int h) {
int at = ne.has_key ? _core->route.indexOf(ne.pub_key) : -1;
if (at >= 0) m.order = (uint8_t)(at + 1);
}
if (_route_show && m.vis && m.kind == MK_NODE && m.idx < _nearby->count()) { // a message's route: its hops numbered
const NearbyModel::Entry& ne = _nearby->at(m.idx);
for (int i = 0; ne.has_key && i < s_route_n; i++)
if (s_route_hop[i] >= 0 && memcmp(s_route_key[i], ne.pub_key, 4) == 0) { m.order = (uint8_t)(s_route_hop[i] + 1); break; }
}
if (!m.vis) continue;
m.x = (int16_t)lround(x);
m.y = (int16_t)lround(y);
@@ -1213,8 +1222,8 @@ void UITask::layoutMapMarks(double left, double top, int w, int h) {
if (lw) { x1 = min(x1, lx); y1 = min(y1, ly); x2 = max(x2, lx + lw); y2 = max(y2, ly + lh); }
a = { la.x1 + x1 - 1, la.y1 + y1 - 1, la.x1 + x2 + 1, la.y1 + y2 + 1 };
};
s_route_on = _route_pick;
if (_route_pick) { // the route's points, in px (any distance away: the line is clipped by the layer)
s_route_on = _route_pick || _route_show;
if (s_route_on) { // the route's points, in px (any distance away: the line is clipped by the layer)
for (int i = 0; i < s_route_n; i++) {
double rx = lonToTileX(s_route_lon[i] / 1e6, _map_z) * TILE_PX - left;
double ry = latToTileY(s_route_lat[i] / 1e6, _map_z) * TILE_PX - top;
@@ -1339,7 +1348,7 @@ void UITask::mapFlowTick() {
// ── Picking a route (Route > Map) ─────────────────────────────────────────────
static void onRouteUndo(lv_event_t* e) { (void)e; s_ui->routeMapUndo(); }
static void onRouteDone(lv_event_t* e) { (void)e; s_ui->routeMapDone(); }
static void onRouteDone(lv_event_t* e) { (void)e; if (s_ui->routeShowing()) s_ui->routeShowDone(); else s_ui->routeMapDone(); }
// A bar over the nav bar: the route so far, Undo (last hop out) and Done.
void UITask::buildRouteBar(lv_obj_t* body) {
@@ -1366,6 +1375,12 @@ void UITask::buildRouteBar(lv_obj_t* body) {
lv_obj_add_event_cb(done, onRouteDone, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(done, LV_SYMBOL_OK " Done", THEME_FONT_SMALL, theme::TEXT));
stylePrimary(done);
if (_route_show) { // only looked at: the label has the room Undo would take
lv_obj_set_width(_route_lbl, lv_pct(75));
lv_label_set_long_mode(_route_lbl, LV_LABEL_LONG_DOT);
routeMapRefresh();
return;
}
lv_obj_t* undo = lv_button_create(_route_bar);
lv_obj_set_size(undo, 64, 34);
lv_obj_align(undo, LV_ALIGN_RIGHT_MID, -76, 0);
@@ -1377,6 +1392,16 @@ void UITask::buildRouteBar(lv_obj_t* body) {
routeMapRefresh();
}
// Done (or Back) on a message's route: back to the conversation.
void UITask::routeShowDone() {
_route_show = false;
_core->hop_route.close();
_screen = SCR_THREAD;
_nav_back = true;
buildThread();
_nav_back = false;
}
void UITask::routeMapUndo() {
_core->route.removeLast();
routeMapRefresh();
@@ -1385,8 +1410,33 @@ void UITask::routeMapUndo() {
// The route changed: its points for the line, the text, the markers.
void UITask::routeMapRefresh() {
using namespace mapview;
PathEditor& R = _core->route;
s_route_n = 0;
if (_route_show) { // a message's route: here, its hops placed, the sender
const HopRoute& H = _core->hop_route;
for (int i = 0; i < H.points() && s_route_n < ROUTE_PTS; i++) {
const HopRoute::Pt& p = H.point(i);
s_route_lat[s_route_n] = p.lat; s_route_lon[s_route_n] = p.lon;
s_route_hop[s_route_n] = p.hop;
ContactInfo c;
if (p.node >= 0 && the_mesh.getContactByIdx(p.node, c)) memcpy(s_route_key[s_route_n], c.id.pub_key, 4);
else s_route_hop[s_route_n] = -1;
s_route_n++;
}
if (_route_lbl) {
char t[128], nm[33];
int o = 0;
H.summary(t, sizeof(t));
o = strlen(t);
for (int i = 0; i < H.hops() && o < (int)sizeof(t) - 8; i++) {
H.hopName(i, nm, sizeof(nm));
o += snprintf(t + o, sizeof(t) - o, "%s%.10s", i ? " > " : ": ", nm);
}
lv_label_set_text(_route_lbl, t);
}
if (_map_area) layoutMap();
return;
}
PathEditor& R = _core->route;
auto add = [&](int32_t lat, int32_t lon) {
if ((lat != 0 || lon != 0) && s_route_n < ROUTE_PTS) { s_route_lat[s_route_n] = lat; s_route_lon[s_route_n] = lon; s_route_n++; }
};
@@ -1313,6 +1313,7 @@ void UITask::back() {
break;
case SCR_MAP:
if (_route_pick) { routeMapDone(); break; }
if (_route_show) { routeShowDone(); break; }
if (_nav_overlay) navClosePopup();
else if (areaSelecting()) areaSelectEnd();
else if (_dl_overlay) mapDownloadClose();
@@ -135,6 +135,8 @@ public:
void routePick(int row);
void routeMapTap(const uint8_t* pub_key, uint8_t type);
void routeMapDone();
void routeShowDone();
bool routeShowing() const { return _route_show; }
void routeMapRefresh();
void routeMapUndo();
void routeMapFrame();
@@ -665,6 +667,7 @@ private:
uint32_t _next_flow_ms = 0; // the map's packet flow repaint
uint8_t _flow_legend_tick = 0; // its legend, every 10th repaint
bool _route_pick = false; // the nodes map taps repeaters into the route
bool _route_show = false; // the nodes map shows a message's route (_core->hop_route)
lv_obj_t* _route_bar = nullptr;
lv_obj_t* _route_lbl = nullptr;
@@ -91,7 +91,7 @@ class MessagesScreen : public UIScreen {
// location (a {loc} string or a [WAY] share), plus Path/Relayed by when the
// entry has hop data recorded. _fs_act maps each visible row back to an
// action so the index math survives the conditional layout.
enum FsAct : uint8_t { FS_REPLY, FS_NAV, FS_SAVE, FS_TARGET, FS_PATH };
enum FsAct : uint8_t { FS_REPLY, FS_NAV, FS_SAVE, FS_TARGET, FS_PATH, FS_ROUTE };
uint8_t _fs_act[5];
int _fs_act_n = 0;
// Which history entry the currently-open Options menu (and, after FS_PATH is
@@ -366,8 +366,9 @@ class MessagesScreen : public UIScreen {
if (is_channel) path_is_relay = (_history.chAtPos(ring_pos).relay_seq != 0);
}
bool has_path_item = hop_count > 0;
const bool has_route = HopRoute::has(body); // a bot's {path} reply: the route on a map
int n = (reply_allowed ? 1 : 0) + (has_loc ? 3 : 0) + (has_path_item ? 1 : 0);
int n = (reply_allowed ? 1 : 0) + (has_loc ? 3 : 0) + (has_path_item ? 1 : 0) + (has_route ? 1 : 0);
if (n == 0) return;
_fs_act_n = 0;
_ctx_menu.begin("Options", n);
@@ -381,6 +382,82 @@ class MessagesScreen : public UIScreen {
_ctx_menu.addItem(_fs_path_item);
_fs_act[_fs_act_n++] = FS_PATH;
}
if (has_route) { _ctx_menu.addItem("Show route"); _fs_act[_fs_act_n++] = FS_ROUTE; }
}
// The route in the message stashed by buildFsMenu(), placed: the way our
// message went to whoever wrote this, so from here to them.
void showRoute() {
if (_fs_menu_pos < 0) return;
const char* text;
const uint8_t* key = nullptr;
char name[32] = "";
if (_fs_menu_is_channel) { // "Name: text"
text = _history.chAtPos(_fs_menu_pos).text;
const char* sep = strstr(text, ": ");
if (sep && sep - text < (int)sizeof(name)) { memcpy(name, text, sep - text); name[sep - text] = '\0'; }
} else {
text = _history.dmAtPos(_fs_menu_pos).text;
key = _history.dmAtPos(_fs_menu_pos).prefix;
}
int32_t lat, lon;
const bool own = _task->currentLocation(lat, lon) || _task->core().fixedPosition(lat, lon);
HopRoute& R = _task->core().hop_route;
if (!R.show(text, own, lat, lon, key, name)) return;
if (R.points() == 0) { R.close(); _task->showAlert("No hop has a position", 1200); }
}
// The route shown (showRoute()): the points fitted to the screen, joined;
// you, each hop's number, the sender; under it how many hops are placed.
void renderRoute(DisplayDriver& d) {
const HopRoute& R = _task->core().hop_route;
drawScreenHeader(d, "Route");
const int W = d.width(), H = d.height(), lh = d.getLineHeight(), s = miniIconScale(d);
const int top = d.listStart(), bar_y = H - lh - 1;
const int ax = 1, ay = top, aw = W - 2, ah = bar_y - top - 2;
d.setColor(UIColor::primary_txt);
int32_t mnla = 0, mxla = 0, mnlo = 0, mxlo = 0;
for (int i = 0; i < R.points(); i++) {
const HopRoute::Pt& p = R.point(i);
if (i == 0 || p.lat < mnla) mnla = p.lat; if (i == 0 || p.lat > mxla) mxla = p.lat;
if (i == 0 || p.lon < mnlo) mnlo = p.lon; if (i == 0 || p.lon > mxlo) mxlo = p.lon;
}
float kx = cosf(((mnla + mxla) / 2.0e6f) * (float)M_PI / 180.0f);
if (kx < 0.05f) kx = 0.05f;
float span_la = (float)(mxla - mnla), span_lo = (float)(mxlo - mnlo) * kx;
if (span_la < 2000.0f) span_la = 2000.0f;
if (span_lo < 2000.0f) span_lo = 2000.0f;
const int m = 4 * s + d.getCharWidth(); // the marks and a hop's number clear of the edge
const float fw = (float)(aw - 2 * m), fh = (float)(ah - 2 * m);
const float scale = fh / span_la < fw / span_lo ? fh / span_la : fw / span_lo;
const float cla = (mnla + mxla) / 2.0f, clo = (mnlo + mxlo) / 2.0f;
auto proj = [&](const HopRoute::Pt& p, float& x, float& y) {
x = ax + aw / 2 + ((float)p.lon - clo) * kx * scale;
y = ay + ah / 2 - ((float)p.lat - cla) * scale;
};
float x0 = 0, y0 = 0;
for (int i = 0; i < R.points(); i++) {
float x, y; proj(R.point(i), x, y);
if (i > 0) gfx::drawLineClipped(d, x0, y0, x, y, ax, ay, aw, ah);
x0 = x; y0 = y;
}
for (int i = 0; i < R.points(); i++) {
const HopRoute::Pt& p = R.point(i);
float fx, fy; proj(p, fx, fy);
const int x = (int)lroundf(fx), y = (int)lroundf(fy);
if (p.hop == -1) miniIconDrawCentered(d, x, y, ICON_MAP_CURRENT);
else if (p.hop == -2) miniIconDrawCentered(d, x, y, ICON_MAP_CONTACT);
else {
miniIconDrawCentered(d, x, y, ICON_MAP_DOT);
char t[4];
snprintf(t, sizeof(t), "%d", p.hop + 1);
d.setCursor(x + 2 * s + 1, y - lh / 2);
d.print(t);
}
}
char t[40];
R.summary(t, sizeof(t));
d.drawTextCentered(W / 2, bar_y, t);
}
// Opens the path/relay detail popup for the entry stashed by buildFsMenu().
@@ -433,6 +510,8 @@ class MessagesScreen : public UIScreen {
_nav_label[0] ? _nav_label : "Msg loc");
} else if (a == FS_PATH) {
showPathDetail();
} else if (a == FS_ROUTE) {
showRoute();
}
}
@@ -1143,6 +1222,7 @@ public:
_ctx_menu.active = false;
_ctx_dirty = false;
_nav_active = false;
_task->core().hop_route.close();
_share_mode = false;
_pick_target = false;
_pick_bot_channel = false;
@@ -1329,6 +1409,7 @@ public:
// Navigate-to-location view sits over everything else while active.
if (_nav_active) { renderNav(display); return 1000; }
if (_task->core().hop_route.active()) { renderRoute(display); return 5000; }
int lh = display.getLineHeight();
int item_h = display.lineStep();
@@ -1934,6 +2015,11 @@ public:
if (c == KEY_CANCEL) _nav_active = false;
return true;
}
// A message's route on the map: Back returns to the message.
if (_task->core().hop_route.active()) {
if (c == KEY_CANCEL) _task->core().hop_route.close();
return true;
}
if (_phase == MODE_SELECT) {
// Context menu (Mark-all-read) takes precedence while active.
if (_ctx_menu.active) {