feat(chat): drop SNR/hops from the message line, complete the routes popup, colour the hash size

The line under an incoming channel message showed the SNR and hop count of
the one copy the firmware decoded, while the popup listed only the copies
relayed by repeaters. With the sender in direct range that read as a
contradiction ("Hops: 0" above, "Hops: 1" on every popup entry), because
the direct copy has an empty path and was filtered out of the echo arrays.

The line is now just "Route (n): <hash size> | Region: <name>", and the
popup carries everything it used to: the echo arrays include every copy
heard, in the order heard (ties on the 1 s received_at broken by id), a
direct copy is listed as "Direct" with 0 hops and no copy/analyzer action,
and the copy the node actually decoded is marked "received" — matched on
hop count and SNR against the message row, so a copy missing from the RX
log never mis-marks the next one.

The hash size is coloured red / orange / green for 1 / 2 / 3 bytes,
semi-bold, with per-theme tokens kept at or above 5:1 on the bubble; the
whole line goes from 0.7 to 0.8 rem and --text-meta from ~2:1 to 4.5:1,
since it was hard to read on a big screen. The popup now opens to the
right of its tap target: with the route first in the line, a
right-anchored popup ran under the channel sidebar on a desktop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
MarekWoandClaude Fable 5.1 committed 2026-09-07 17:28:39 +02:00
1 parent 0c94bf0ee5
commit fa622baf21
9 files changed
+152 -66

No files matched your search

+6 -3
View File
@@ -1229,7 +1229,7 @@ class Database:
def get_echoes_for_message(self, pkt_payload: str) -> List[Dict]:
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM echoes WHERE pkt_payload = ? ORDER BY received_at ASC",
"SELECT * FROM echoes WHERE pkt_payload = ? ORDER BY received_at ASC, id ASC",
(pkt_payload,)
).fetchall()
return [dict(r) for r in rows]
@@ -1237,7 +1237,10 @@ class Database:
def get_echoes_for_payloads(self, payloads: List[str]) -> Dict[str, List[Dict]]:
"""Batch-fetch echoes for many pkt_payloads with chunked IN queries.
Returns {pkt_payload: [echo dicts ordered by received_at]}.
Returns {pkt_payload: [echo dicts ordered by received_at]}. The
received_at column has one-second resolution, so ties are broken by
id to keep the order the copies were actually heard in — the first
one is the copy the firmware delivered.
Chunked at 500 to stay under SQLite's host-parameter limit."""
result: Dict[str, List[Dict]] = {}
if not payloads:
@@ -1248,7 +1251,7 @@ class Database:
placeholders = ",".join("?" * len(chunk))
rows = conn.execute(
f"""SELECT * FROM echoes WHERE pkt_payload IN ({placeholders})
ORDER BY received_at ASC""",
ORDER BY received_at ASC, id ASC""",
chunk
).fetchall()
for r in rows:
+20 -8
View File
@@ -621,10 +621,7 @@ def get_messages():
for msg, row in zip(messages, db_messages):
echoes = echoes_by_payload.get(msg.get('pkt_payload')) or []
if echoes:
msg['echo_count'] = len(echoes)
msg['echo_paths'] = [e.get('path', '') for e in echoes if e.get('path')]
msg['echo_snrs'] = [e.get('snr') for e in echoes if e.get('snr') is not None]
msg['echo_hash_sizes'] = [e.get('hash_size', 1) for e in echoes if e.get('path')]
msg.update(_echo_fields(echoes))
region = _resolve_message_region(row, msg.get('pkt_payload'), echoes, regions)
if region:
msg['region'] = region
@@ -756,6 +753,24 @@ def get_path_analyzer_messages():
return jsonify({'success': False, 'error': str(e)}), 500
def _echo_fields(echoes: list) -> dict:
"""The echo arrays a message carries to the frontend, index-aligned.
One entry per copy of the packet the node heard, in the order it heard
them, so the first entry is normally the copy the firmware delivered
(later copies are dropped as duplicates but still RX-logged). A copy
heard straight from the sender has an empty path; it is kept, since it
is the delivered copy whenever the sender was in direct range, and the
routes popup lists it as "Direct".
"""
return {
'echo_count': len(echoes),
'echo_paths': [e.get('path') or '' for e in echoes],
'echo_snrs': [e.get('snr') for e in echoes],
'echo_hash_sizes': [e.get('hash_size') or 1 for e in echoes],
}
def _resolve_message_region(row: dict, pkt_payload, echoes: list, regions):
"""Name of the region this packet was flood-scoped to, or None.
@@ -801,10 +816,7 @@ def _build_message_meta(row: dict, pkt_payload, echoes: list, regions=None) -> d
if pkt_payload:
meta['packet_hash'] = compute_packet_hash(pkt_payload)
if echoes:
meta['echo_count'] = len(echoes)
meta['echo_paths'] = [e.get('path', '') for e in echoes if e.get('path')]
meta['echo_snrs'] = [e.get('snr') for e in echoes if e.get('snr') is not None]
meta['echo_hash_sizes'] = [e.get('hash_size', 1) for e in echoes if e.get('path')]
meta.update(_echo_fields(echoes))
region = _resolve_message_region(row, pkt_payload, echoes, regions)
if region:
meta['region'] = region
+23 -1
View File
@@ -465,11 +465,18 @@ main {
/* Message Metadata */
.message-meta {
font-size: 0.7rem;
font-size: 0.8rem;
color: var(--text-meta);
margin-top: 0.25rem;
}
/* Per-hop hash size in the route line: 1 byte collides easily on a big mesh
(least desirable), 3 bytes is the best. Tokens per theme in theme.css. */
.path-hash { font-weight: 600; } /* colour alone was too faint at this size on the light theme */
.path-hash-1 { color: var(--path-hash-1); }
.path-hash-2 { color: var(--path-hash-2); }
.path-hash-3 { color: var(--path-hash-3); }
/* Reply Button */
.btn-reply {
font-size: 0.7rem;
@@ -1615,6 +1622,21 @@ main {
opacity: 0.6;
}
/* A copy heard straight from the sender: no route to copy or to draw */
.path-popup .path-entry.direct {
cursor: default;
}
.path-popup .path-entry.direct:active {
opacity: 1;
}
/* The copy the node actually decoded (popup is dark in both themes) */
.path-popup .path-delivered {
color: #7dd39a;
white-space: nowrap;
}
.path-popup .path-entry:last-child {
border-bottom: none;
}
+8 -2
View File
@@ -22,7 +22,10 @@
--text-primary: #212529;
--text-secondary: #495057;
--text-muted: #6c757d;
--text-meta: #adb5bd;
--text-meta: #6c757d; /* 4.5:1 on --msg-other-bg; the old #adb5bd was 2:1 */
--path-hash-1: #b8433a; /* 1-byte hashes: softened red, 5.1:1 on the bubble */
--path-hash-2: #9c5811; /* 2-byte: burnt orange, 5.2:1 */
--path-hash-3: #207a45; /* 3-byte: muted green, 5.1:1 */
--dm-meta-color: #8b939b;
/* Borders */
@@ -179,7 +182,10 @@
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--text-meta: #475569;
--text-meta: #8593a8; /* 4.7:1 on --msg-other-bg; the old #475569 was 1.9:1 */
--path-hash-1: #f08a80;
--path-hash-2: #f0b061;
--path-hash-3: #7dd39a;
--dm-meta-color: #5c6d82;
/* Borders */
+83 -47
View File
@@ -1499,34 +1499,65 @@ async function refreshMessagesMeta(forceIds = []) {
}
}
// Colour class per per-hop hash size: 1 byte is the least desirable (hashes
// collide on a big mesh), 3 bytes the best.
const PATH_HASH_CLASS = { 1: 'path-hash-1', 2: 'path-hash-2', 3: 'path-hash-3' };
/**
* Build the "SNR | Hops | Route | Region" line shown under an incoming
* channel message. `src` is a /api/messages row or a /api/messages/meta
* entry (same field names); `paths` is the echo-derived paths array.
* Turn a message's echo arrays (index-aligned, in the order the copies were
* heard) into popup entries. A copy heard straight from the sender has an
* empty path; the popup lists it as "Direct". `delivered` marks the copy the
* firmware actually decoded \u2014 matched on hop count and SNR against the
* message itself, since later copies were dropped as duplicates but still
* RX-logged. Own messages have no delivered copy (no hop count on the row).
* Returns null when nothing was heard.
*/
function buildEchoPaths(src) {
if (!src.echo_paths || src.echo_paths.length === 0) return null;
const paths = src.echo_paths.map((p, i) => {
const hashSize = (src.echo_hash_sizes ? src.echo_hash_sizes[i] : src.path_hash_size) || 1;
return {
path: p || '',
snr: src.echo_snrs ? src.echo_snrs[i] : null,
hash_size: hashSize,
hops: p ? Math.ceil(p.length / (hashSize * 2)) : 0,
delivered: false,
};
});
const msgHops = src.hop_count ?? (src.path_len !== null && src.path_len !== undefined ? (src.path_len & 0x3F) : null);
if (msgHops !== null) {
const msgSnr = (src.snr !== undefined && src.snr !== null) ? src.snr : null;
// Both SNRs are the same radio reading quantised to 0.25 dB; allow one quantum.
const hit = paths.find(p => p.hops === msgHops
&& (msgSnr === null || p.snr === null || p.snr === undefined || Math.abs(p.snr - msgSnr) <= 0.26));
if (hit) hit.delivered = true;
}
return paths;
}
/**
* Build the "Route | Region" line shown under an incoming channel message.
* `src` is a /api/messages row or a /api/messages/meta entry (same field
* names); `paths` comes from buildEchoPaths().
*
* The route part names only the per-hop hash size ("2 bytes"): the routes
* themselves live in the popup, since a 3-byte, 5-hop route no longer fits
* next to SNR and hops on a phone. Region shows only when the packet was
* The route part names only the per-hop hash size ("2 bytes"), coloured by
* how good that size is: the routes themselves, with their SNR and hop
* counts, live in the popup, since even one 3-byte, 5-hop route no longer
* fits under a message on a phone. Region shows only when the packet was
* flood-scoped to a region this instance knows (Settings \u2192 Channels).
*/
function buildMessageMetaInfo(src, paths) {
const metaParts = [];
// Use message SNR, or fall back to first echo path SNR
const displaySnr = (src.snr !== undefined && src.snr !== null) ? src.snr
: (src.echo_snrs && src.echo_snrs.length > 0) ? src.echo_snrs[0] : null;
if (displaySnr !== null) {
metaParts.push(`SNR: ${displaySnr.toFixed(1)} dB`);
}
const hopCount = src.hop_count ?? (src.path_len !== null && src.path_len !== undefined ? (src.path_len & 0x3F) : null);
if (hopCount !== null) {
metaParts.push(tHtml('chat.route_hops', { count: hopCount }));
}
if (paths && paths.length > 0) {
const sizeLabel = tn('chat.route_hash_bytes', paths[0].hash_size || 1);
const hashSize = paths[0].hash_size || 1;
// The translated string is escaped as a whole, so the coloured span is
// spliced in afterwards through a placeholder the catalog never contains.
const TOKEN = '%%ROUTE%%';
const sizeHtml = `<span class="path-hash ${PATH_HASH_CLASS[hashSize] || ''}">${escapeHtml(tn('chat.route_hash_bytes', hashSize))}</span>`;
const routeText = (paths.length > 1
? tHtml('chat.route_multi', { count: paths.length, route: TOKEN })
: tHtml('chat.route', { route: TOKEN })).replace(TOKEN, sizeHtml);
const pathsData = encodeURIComponent(JSON.stringify(paths));
const routeText = paths.length > 1
? tHtml('chat.route_multi', { count: paths.length, route: sizeLabel })
: tHtml('chat.route', { route: sizeLabel });
metaParts.push(`<span class="path-info" title="${tHtml('chat.route_hash_title')}" onclick="showPathsPopup(this, '${pathsData}', '${src.packet_hash || ''}')">${routeText}</span>`);
}
if (src.region) {
@@ -1541,16 +1572,7 @@ function buildMessageMetaInfo(src, paths) {
function updateMessageMetaDOM(wrapper, meta) {
const isOwn = wrapper.classList.contains('own');
// Build paths from echo data
let paths = null;
if (meta.echo_paths && meta.echo_paths.length > 0) {
paths = meta.echo_paths.map((p, i) => ({
path: p,
snr: meta.echo_snrs ? meta.echo_snrs[i] : null,
hash_size: meta.echo_hash_sizes ? meta.echo_hash_sizes[i] : (meta.path_hash_size || 1),
}));
}
const metaInfo = buildMessageMetaInfo(meta, paths);
const metaInfo = buildMessageMetaInfo(meta, buildEchoPaths(meta));
if (!isOwn) {
// Update or insert .message-meta div
@@ -1591,7 +1613,7 @@ function updateMessageMetaDOM(wrapper, meta) {
const echoHashSize = (meta.echo_hash_sizes && meta.echo_hash_sizes.length > 0)
? meta.echo_hash_sizes[0] : (meta.path_hash_size || 1);
const echoPrefixLen = echoHashSize * 2;
const echoPaths = [...new Set(meta.echo_paths.map(p => p.substring(0, echoPrefixLen).toUpperCase()))];
const echoPaths = [...new Set(meta.echo_paths.filter(Boolean).map(p => p.substring(0, echoPrefixLen).toUpperCase()))];
const echoCount = echoPaths.length;
const pathDisplay = echoPaths.length > 0 ? ` (${echoPaths.join(', ')})` : '';
const actionsEl = msgDiv.querySelector('.message-actions');
@@ -1657,13 +1679,7 @@ function createMessageElement(msg) {
const time = formatTime(msg.timestamp);
// Build paths from echo data if not already present
if (!msg.paths && msg.echo_paths && msg.echo_paths.length > 0) {
msg.paths = msg.echo_paths.map((p, i) => ({
path: p,
snr: msg.echo_snrs ? msg.echo_snrs[i] : null,
hash_size: msg.echo_hash_sizes ? msg.echo_hash_sizes[i] : (msg.path_hash_size || 1),
}));
}
if (!msg.paths) msg.paths = buildEchoPaths(msg);
const metaInfo = buildMessageMetaInfo(msg, msg.paths);
@@ -1674,7 +1690,7 @@ function createMessageElement(msg) {
const echoHS = (msg.echo_hash_sizes && msg.echo_hash_sizes.length > 0)
? msg.echo_hash_sizes[0] : (msg.path_hash_size || 1);
const echoPrefixLen2 = echoHS * 2;
const echoPaths = [...new Set((msg.echo_paths || []).map(p => p.substring(0, echoPrefixLen2).toUpperCase()))];
const echoPaths = [...new Set((msg.echo_paths || []).filter(Boolean).map(p => p.substring(0, echoPrefixLen2).toUpperCase()))];
const echoCount = echoPaths.length;
const pathDisplay = echoPaths.length > 0 ? ` (${echoPaths.join(', ')})` : '';
const echoDisplay = echoCount > 0
@@ -2070,18 +2086,30 @@ function showPathsPopup(element, encodedPaths, packetHash) {
segments.push(p.path.substring(j, j + pChunkLen).toUpperCase());
}
}
const fullRoute = segments.join(' \u2192 ');
// No path at all: the copy came straight from the sender (0 hops)
const isDirect = segments.length === 0;
const fullRoute = isDirect ? tHtml('chat.route_direct') : segments.join(' \u2192 ');
const commaRoute = segments.join(',');
const snr = p.snr !== null && p.snr !== undefined ? `${p.snr.toFixed(1)} dB` : '?';
const hops = segments.length;
const entry = document.createElement('div');
entry.className = 'path-entry';
entry.className = 'path-entry' + (isDirect ? ' direct' : '');
const body = document.createElement('span');
body.className = 'path-route';
body.innerHTML = `${fullRoute}<span class="path-detail">SNR: ${snr} | ${tHtml('chat.route_hops', { count: hops })}</span>`;
// The copy the node actually decoded; the others were heard later and dropped as duplicates
const delivered = p.delivered
? ` \u00b7 <span class="path-delivered" title="${tHtml('chat.route_delivered_title')}"><i class="bi bi-check2"></i> ${tHtml('chat.route_delivered')}</span>`
: '';
body.innerHTML = `${fullRoute}<span class="path-detail">SNR: ${snr} | ${tHtml('chat.route_hops', { count: hops })}${delivered}</span>`;
entry.appendChild(body);
if (isDirect) {
// Nothing to copy and nothing to draw on the Analyzer map
popup.appendChild(entry);
return;
}
const copyBtn = document.createElement('i');
copyBtn.className = 'bi bi-clipboard path-copy';
copyBtn.title = t('common.copy_route');
@@ -2118,11 +2146,19 @@ function showPathsPopup(element, encodedPaths, packetHash) {
element.style.position = 'relative';
element.appendChild(popup);
// Adjust if popup overflows viewport
const rect = popup.getBoundingClientRect();
if (rect.left < 4) {
popup.style.right = 'auto';
popup.style.left = '0';
// Open to the right of the tap target. It sits at the start of the line
// under a channel message, so a right-anchored popup would run under the
// channel sidebar on a desktop; flip only when the viewport's right edge
// is in the way (own DMs, where the target sits on the right).
popup.style.right = 'auto';
popup.style.left = '0';
if (popup.getBoundingClientRect().right > window.innerWidth - 4) {
popup.style.left = 'auto';
popup.style.right = '0';
if (popup.getBoundingClientRect().left < 4) {
popup.style.right = 'auto';
popup.style.left = '0';
}
}
// Auto-dismiss after configured timeout (unless disabled) or on outside tap
+4 -1
View File
@@ -148,6 +148,9 @@
"chat.region_title": "Click to change region for this channel",
"chat.route": "Route: {route}",
"chat.route_analyzer_title": "Show this route on the Path Analyzer map",
"chat.route_delivered": "received",
"chat.route_delivered_title": "The copy your node received and decoded. The other copies arrived later and were dropped as duplicates",
"chat.route_direct": "Direct",
"chat.route_hash_bytes": {
"one": "{count} byte",
"other": "{count} bytes"
@@ -1248,7 +1251,7 @@
"settings.chat.quote_len": "Quote length (bytes)",
"settings.chat.quote_len_help": "Default max UTF-8 bytes for truncated quotes",
"settings.chat.route_popup": "Route popup",
"settings.chat.route_popup_desc": "The popup shown when tapping \"SNR | Hops\" under a message. Also applies to DMs.",
"settings.chat.route_popup_desc": "The popup shown when tapping the Route line under a message. Also applies to DMs.",
"settings.contacts.auto_ignore": "Automatically add new contacts to \"Ignored\"",
"settings.contacts.auto_ignore_help": "Every new advert is automatically marked as Ignored — no notifications, no badge. Contacts still appear under Existing Contacts (Cache) and can be promoted with 'To Device'. Requires Manual approval ON.",
"settings.contacts.manual_approval": "Manual approval enabled",
+4 -1
View File
@@ -150,6 +150,9 @@
"chat.region_title": "Kliknij, aby zmienić region dla tego kanału",
"chat.route": "Trasa: {route}",
"chat.route_analyzer_title": "Pokaż tę trasę na mapie Analizatora ścieżek",
"chat.route_delivered": "odebrana",
"chat.route_delivered_title": "Kopia, którą Twoje urządzenie odebrało i odkodowało. Pozostałe kopie dotarły później i zostały odrzucone jako duplikaty",
"chat.route_direct": "Bezpośrednio",
"chat.route_hash_bytes": {
"one": "{count} bajt",
"few": "{count} bajty",
@@ -1322,7 +1325,7 @@
"settings.chat.quote_len": "Długość cytatu (bajty)",
"settings.chat.quote_len_help": "Domyślna maks. liczba bajtów UTF-8 dla skróconych cytatów",
"settings.chat.route_popup": "Popup trasy",
"settings.chat.route_popup_desc": "Popup pokazywany po dotknięciu \"SNR | Hops\" pod wiadomością. Dotyczy także DM-ów.",
"settings.chat.route_popup_desc": "Popup pokazywany po dotknięciu linii Trasa pod wiadomością. Dotyczy także DM-ów.",
"settings.contacts.auto_ignore": "Automatycznie dodawaj nowe kontakty do \"Ignorowanych\"",
"settings.contacts.auto_ignore_help": "Każdy nowy advert jest automatycznie oznaczany jako ignorowany — bez powiadomień, bez plakietki. Kontakty nadal pojawiają się w Istniejących kontaktach (pamięć) i można je przenieść przyciskiem \"Na urządzenie\". Wymaga włączonego zatwierdzania ręcznego.",
"settings.contacts.manual_approval": "Zatwierdzanie ręczne włączone",
+3 -2
View File
@@ -213,8 +213,9 @@ Your own messages carry a small row of action buttons:
### Group Chat Message Routes
When your node overheard how a channel message travelled, a **Route** line appears under the message, saying how many routes were heard and how wide their per-hop hashes are (e.g. `Route (3): 2 bytes`). Tap it to open a popup listing every route the message arrived by, each with its SNR and hop count:
When your node heard a channel message, a **Route** line appears under it, saying how many copies of the message were heard and how wide their per-hop hashes are, e.g. `Route (3): 2 bytes`. The hash size is coloured: **red** for 1 byte (such hashes collide easily on a big mesh), **orange** for 2 bytes and **green** for 3 bytes. Tap the line to open a popup listing every copy in the order they were heard, each with its route, SNR and hop count:
- The copy your node actually decoded is marked **✓ received**; the others arrived later and were dropped as duplicates, but they still show where else the message travelled. A copy heard straight from the sender, with no repeater in between, is listed as **Direct** with 0 hops.
- Tap a route to open the **[Path Analyzer](#path-analyzer)** on the Map view, with that message selected and that exact route drawn — the quickest way to see where a message physically travelled.
- Each route also has a small clipboard icon to copy it in comma-separated form (e.g. `92,AD,40,D1`) for use in the console `change_path` command.
@@ -946,7 +947,7 @@ The app automatically picks one of four retry strategies depending on the contac
- **Live view days** - Number of days of messages shown in the live view (older messages are archived)
**Route popup** (applies to both channel messages and DMs):
- **Auto-close after (s)** - Seconds before the route popup (shown when tapping "SNR | Hops" under a message) closes automatically (default: 8)
- **Auto-close after (s)** - Seconds before the route popup (shown when tapping the Route line under a message) closes automatically (default: 8)
- **Don't close automatically** - Popup stays open until you tap outside it
### Interface Tab
+1 -1
View File
@@ -12,7 +12,7 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
### Features
- **The line under an incoming channel message now names the region it was sent in, and no longer spells out the route.** A MeshCore sender can stamp a packet with a *region* — a flood scope such as `pl` that only repeaters allowing it pass on (see the previous release). The stamp is not the name but a 16-bit code derived from the region's key, so the interface can recognise it only for regions you have added under **Settings → Channels**: a message stamped with one of them now ends its `SNR | Hops | Route` line with **Region: pl**, while an unstamped message, or one from a region you have not configured, shows nothing extra. The route part changed too. It used to print the first route inline (`Route (3): 142C→1122→271C→EE6C`), which with 2- or 3-byte hashes and a few hops crowded everything else off a phone screen. It now only says how wide the per-hop hashes are — `Route (3): 2 bytes` — and tapping it opens the same popup as before, listing every route the message arrived by, each one openable in the Path Analyzer or copied to the clipboard. The region stamp is kept from this version on, so messages received before the update have no region to show.
- **The line under an incoming channel message now names the region it was sent in, and no longer spells out the route, SNR or hop count.** A MeshCore sender can stamp a packet with a *region* — a flood scope such as `pl` that only repeaters allowing it pass on (see the previous release). The stamp is not the name but a 16-bit code derived from the region's key, so the interface can recognise it only for regions you have added under **Settings → Channels**: a message stamped with one of them now ends its Route line with **Region: pl**, while an unstamped message, or one from a region you have not configured, shows nothing extra. The rest of the line changed too. It used to print the SNR, the hop count and the first route inline (`SNR: -5.5 dB | Hops: 4 | Route (3): 142C→1122→271C→EE6C`), which with 2- or 3-byte hashes crowded everything else off a phone screen — and the SNR and hops described only the one copy your node decoded, not the others listed in the popup, which read as a contradiction whenever the sender was in direct range. The line now only says how many copies were heard and how wide their per-hop hashes are, `Route (3): 2 bytes`, with the size coloured **red**, **orange** or **green** for 1, 2 or 3 bytes, and tapping it opens the same popup as before. That popup is now complete: it lists every copy in the order heard, including one that came **Direct** from the sender with no repeater in between, and marks the copy your node actually decoded with **✓ received** — so the SNR and hop count that used to sit under the message are still there, next to the route they belong to. The whole line is also a size larger and darker, since it was hard to read on a big screen. The region stamp is kept from this version on, so messages received before the update have no region to show.
---