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>
This commit is contained in:
MarekWo
2026-06-09 14:32:44 +02:00
parent 4729055900
commit d23e865f35
2 changed files with 54 additions and 11 deletions
+30 -2
View File
@@ -1325,15 +1325,26 @@ class DeviceManager:
logger.debug(f"Echo ({direction}): path={path} snr={snr} hash_size={hash_size} pkt={pkt_payload[:16]}...")
# Carry msg_id when the echo was correlated to a sent message —
# the UI uses it to force-refresh that specific badge, bypassing
# the "already has route info, skip" guard in refreshMessagesMeta.
correlated_msg_id = (self._pending_echo.get('msg_id')
if self._pending_echo
and self._pending_echo.get('pkt_payload') == pkt_payload
else None)
# Emit SocketIO event for real-time UI update
if self.socketio:
self.socketio.emit('echo', {
payload = {
'pkt_payload': pkt_payload,
'path': path,
'snr': snr,
'direction': direction,
'hash_size': hash_size,
}, namespace='/chat')
}
if correlated_msg_id is not None:
payload['msg_id'] = correlated_msg_id
self.socketio.emit('echo', payload, namespace='/chat')
def _is_manual_approval_enabled(self) -> bool:
"""Check if manual contact approval is enabled (from database)."""
@@ -1800,6 +1811,23 @@ class DeviceManager:
logger.warning(f"Resend msg #{msg_id} failed: payload={payload}")
return {'success': False, 'error': f'Device rejected resend: {err}'}
logger.info(f"Resent channel msg #{msg_id} via CMD_SEND_RAW_PACKET ({len(raw_packet)} bytes)")
# Re-arm echo correlation so the next 60s of incoming echoes for
# this packet hash get classified as 'sent' and carry msg_id in
# the SocketIO emit — that's what tells the UI to extend the
# repeater list on the existing badge instead of skipping it.
stored_pkt_payload = msg.get('pkt_payload')
if stored_pkt_payload:
with self._echo_lock:
self._pending_echo = {
'timestamp': time.time(),
'channel_idx': msg.get('channel_idx', 0),
'msg_id': msg_id,
'pkt_payload': stored_pkt_payload,
'expected_payloads': {stored_pkt_payload},
'guess_pkt_payload': stored_pkt_payload,
}
return {'success': True, 'message': 'Resent', 'id': msg_id, 'bytes': len(raw_packet)}
except Exception as e:
logger.error(f"resend_channel_message #{msg_id} failed: {e}")
+24 -9
View File
@@ -455,13 +455,22 @@ function connectChatSocket() {
// Real-time echo data — update metadata for specific messages (no full reload)
let echoRefreshTimer = null;
const targetedRefreshIds = new Set(); // msg_ids that must bypass the "already has route" skip
chatSocket.on('echo', (data) => {
if (currentArchiveDate) return; // Don't refresh archive view
// When the backend tags the echo with a specific msg_id (e.g. echoes
// arriving after a resend), record it so the debounced refresh
// re-fetches that message's meta even if its badge is already drawn.
if (data && typeof data.msg_id === 'number') {
targetedRefreshIds.add(data.msg_id);
}
// Debounce: wait for echoes to settle, then update affected messages
if (echoRefreshTimer) clearTimeout(echoRefreshTimer);
echoRefreshTimer = setTimeout(() => {
echoRefreshTimer = null;
refreshMessagesMeta();
const ids = Array.from(targetedRefreshIds);
targetedRefreshIds.clear();
refreshMessagesMeta(ids);
}, 2000);
});
@@ -1089,23 +1098,29 @@ function appendMessageFromSocket(data) {
* Refresh metadata (SNR, hops, route, analyzer) for messages missing it.
* Fetches /api/messages/<id>/meta for each incomplete message, updates DOM in-place.
*/
async function refreshMessagesMeta() {
async function refreshMessagesMeta(forceIds = []) {
const container = document.getElementById('messagesList');
if (!container) return;
const forced = new Set((forceIds || []).map(String));
// Find message wrappers that don't have full metadata yet
const wrappers = container.querySelectorAll('.message-wrapper[data-msg-id]');
for (const wrapper of wrappers) {
// Skip messages that already have meta info with route/analyzer data
const metaEl = wrapper.querySelector('.message-meta');
const actionsEl = wrapper.querySelector('.message-actions');
const hasRoute = metaEl && metaEl.querySelector('.path-info');
const hasAnalyzer = actionsEl && actionsEl.querySelector('[title="View in Analyzer"]');
if (hasRoute && hasAnalyzer) continue;
const msgId = wrapper.dataset.msgId;
if (!msgId || msgId.startsWith('_pending_')) continue;
// Skip messages that already have meta info with route/analyzer data,
// unless this msg_id was explicitly forced (e.g. by post-resend echoes
// that need the existing badge re-fetched to extend the repeater list).
if (!forced.has(msgId)) {
const metaEl = wrapper.querySelector('.message-meta');
const actionsEl = wrapper.querySelector('.message-actions');
const hasRoute = metaEl && metaEl.querySelector('.path-info');
const hasAnalyzer = actionsEl && actionsEl.querySelector('[title="View in Analyzer"]');
if (hasRoute && hasAnalyzer) continue;
}
try {
const resp = await fetch(`/api/messages/${msgId}/meta`);
const meta = await resp.json();