diff --git a/app/device_manager.py b/app/device_manager.py index 487b0c8..4affc71 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -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}") diff --git a/app/static/js/app.js b/app/static/js/app.js index 7245413..7f11fba 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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//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();