Skip to content

Commit d23e865

Browse files
MarekWoclaude
andcommitted
feat(channels): merge post-resend echoes into existing repeater badge
PR #4 of 5. After a successful resend, re-arm _pending_echo with the original msg_id and known pkt_payload so echoes from previously-unreached repeaters that pick up the rebroadcast are classified as 'sent' and carry msg_id in the SocketIO emit. The frontend echo handler now collects forced msg_ids and passes them to refreshMessagesMeta(forceIds), which bypasses the "already has route info, skip" guard for those ids. End result: clicking resend extends the repeater list on the existing message's badge in place — no duplicate row, no stale count. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4729055 commit d23e865

2 files changed

Lines changed: 54 additions & 11 deletions

File tree

app/device_manager.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1325,15 +1325,26 @@ def _process_echo(self, pkt_payload: str, path: str, snr: float = None,
13251325

13261326
logger.debug(f"Echo ({direction}): path={path} snr={snr} hash_size={hash_size} pkt={pkt_payload[:16]}...")
13271327

1328+
# Carry msg_id when the echo was correlated to a sent message —
1329+
# the UI uses it to force-refresh that specific badge, bypassing
1330+
# the "already has route info, skip" guard in refreshMessagesMeta.
1331+
correlated_msg_id = (self._pending_echo.get('msg_id')
1332+
if self._pending_echo
1333+
and self._pending_echo.get('pkt_payload') == pkt_payload
1334+
else None)
1335+
13281336
# Emit SocketIO event for real-time UI update
13291337
if self.socketio:
1330-
self.socketio.emit('echo', {
1338+
payload = {
13311339
'pkt_payload': pkt_payload,
13321340
'path': path,
13331341
'snr': snr,
13341342
'direction': direction,
13351343
'hash_size': hash_size,
1336-
}, namespace='/chat')
1344+
}
1345+
if correlated_msg_id is not None:
1346+
payload['msg_id'] = correlated_msg_id
1347+
self.socketio.emit('echo', payload, namespace='/chat')
13371348

13381349
def _is_manual_approval_enabled(self) -> bool:
13391350
"""Check if manual contact approval is enabled (from database)."""
@@ -1800,6 +1811,23 @@ def resend_channel_message(self, msg_id: int) -> Dict:
18001811
logger.warning(f"Resend msg #{msg_id} failed: payload={payload}")
18011812
return {'success': False, 'error': f'Device rejected resend: {err}'}
18021813
logger.info(f"Resent channel msg #{msg_id} via CMD_SEND_RAW_PACKET ({len(raw_packet)} bytes)")
1814+
1815+
# Re-arm echo correlation so the next 60s of incoming echoes for
1816+
# this packet hash get classified as 'sent' and carry msg_id in
1817+
# the SocketIO emit — that's what tells the UI to extend the
1818+
# repeater list on the existing badge instead of skipping it.
1819+
stored_pkt_payload = msg.get('pkt_payload')
1820+
if stored_pkt_payload:
1821+
with self._echo_lock:
1822+
self._pending_echo = {
1823+
'timestamp': time.time(),
1824+
'channel_idx': msg.get('channel_idx', 0),
1825+
'msg_id': msg_id,
1826+
'pkt_payload': stored_pkt_payload,
1827+
'expected_payloads': {stored_pkt_payload},
1828+
'guess_pkt_payload': stored_pkt_payload,
1829+
}
1830+
18031831
return {'success': True, 'message': 'Resent', 'id': msg_id, 'bytes': len(raw_packet)}
18041832
except Exception as e:
18051833
logger.error(f"resend_channel_message #{msg_id} failed: {e}")

app/static/js/app.js

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -455,13 +455,22 @@ function connectChatSocket() {
455455

456456
// Real-time echo data — update metadata for specific messages (no full reload)
457457
let echoRefreshTimer = null;
458+
const targetedRefreshIds = new Set(); // msg_ids that must bypass the "already has route" skip
458459
chatSocket.on('echo', (data) => {
459460
if (currentArchiveDate) return; // Don't refresh archive view
461+
// When the backend tags the echo with a specific msg_id (e.g. echoes
462+
// arriving after a resend), record it so the debounced refresh
463+
// re-fetches that message's meta even if its badge is already drawn.
464+
if (data && typeof data.msg_id === 'number') {
465+
targetedRefreshIds.add(data.msg_id);
466+
}
460467
// Debounce: wait for echoes to settle, then update affected messages
461468
if (echoRefreshTimer) clearTimeout(echoRefreshTimer);
462469
echoRefreshTimer = setTimeout(() => {
463470
echoRefreshTimer = null;
464-
refreshMessagesMeta();
471+
const ids = Array.from(targetedRefreshIds);
472+
targetedRefreshIds.clear();
473+
refreshMessagesMeta(ids);
465474
}, 2000);
466475
});
467476

@@ -1089,23 +1098,29 @@ function appendMessageFromSocket(data) {
10891098
* Refresh metadata (SNR, hops, route, analyzer) for messages missing it.
10901099
* Fetches /api/messages/<id>/meta for each incomplete message, updates DOM in-place.
10911100
*/
1092-
async function refreshMessagesMeta() {
1101+
async function refreshMessagesMeta(forceIds = []) {
10931102
const container = document.getElementById('messagesList');
10941103
if (!container) return;
10951104

1105+
const forced = new Set((forceIds || []).map(String));
1106+
10961107
// Find message wrappers that don't have full metadata yet
10971108
const wrappers = container.querySelectorAll('.message-wrapper[data-msg-id]');
10981109
for (const wrapper of wrappers) {
1099-
// Skip messages that already have meta info with route/analyzer data
1100-
const metaEl = wrapper.querySelector('.message-meta');
1101-
const actionsEl = wrapper.querySelector('.message-actions');
1102-
const hasRoute = metaEl && metaEl.querySelector('.path-info');
1103-
const hasAnalyzer = actionsEl && actionsEl.querySelector('[title="View in Analyzer"]');
1104-
if (hasRoute && hasAnalyzer) continue;
1105-
11061110
const msgId = wrapper.dataset.msgId;
11071111
if (!msgId || msgId.startsWith('_pending_')) continue;
11081112

1113+
// Skip messages that already have meta info with route/analyzer data,
1114+
// unless this msg_id was explicitly forced (e.g. by post-resend echoes
1115+
// that need the existing badge re-fetched to extend the repeater list).
1116+
if (!forced.has(msgId)) {
1117+
const metaEl = wrapper.querySelector('.message-meta');
1118+
const actionsEl = wrapper.querySelector('.message-actions');
1119+
const hasRoute = metaEl && metaEl.querySelector('.path-info');
1120+
const hasAnalyzer = actionsEl && actionsEl.querySelector('[title="View in Analyzer"]');
1121+
if (hasRoute && hasAnalyzer) continue;
1122+
}
1123+
11091124
try {
11101125
const resp = await fetch(`/api/messages/${msgId}/meta`);
11111126
const meta = await resp.json();

0 commit comments

Comments
 (0)