fix(websocket): update message metadata in-place without full chat reload

Instead of reloading the entire message list when echo data arrives,
now updates only the affected message elements in the DOM:

- Add data-msg-id attribute to message wrappers for targeted lookup
- Add GET /api/messages/<id>/meta endpoint returning metadata for a
  single message (computes pkt_payload, looks up echoes, analyzer URL)
- Replace loadMessages() echo handler with refreshMessagesMeta() that
  finds messages missing metadata and updates them individually
- Fix path_len=0 treated as falsy (use ?? instead of ||)

Flow: message appears instantly via WebSocket (with SNR + hops), then
~2s later echo data triggers targeted meta fetch → route info and
analyzer button appear smoothly without any chat window reload.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-15 12:49:56 +01:00
parent 0e15df430f
commit 3a26da18fd
3 changed files with 220 additions and 6 deletions

View File

@@ -328,6 +328,13 @@ class Database:
)
return cursor.lastrowid
def get_channel_message_by_id(self, msg_id: int) -> Optional[Dict]:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM channel_messages WHERE id = ?", (msg_id,)
).fetchone()
return dict(row) if row else None
def get_channel_messages(self, channel_idx: int = None, limit: int = 50,
offset: int = 0, days: int = None) -> List[Dict]:
with self._connect() as conn:

View File

@@ -514,6 +514,76 @@ def get_messages():
}), 500
@api_bp.route('/messages/<int:msg_id>/meta', methods=['GET'])
def get_message_meta(msg_id):
"""Return metadata (SNR, hops, route, analyzer URL) for a single channel message."""
try:
db = _get_db()
if not db:
return jsonify({'success': False, 'error': 'No database'}), 500
row = db.get_channel_message_by_id(msg_id)
if not row:
return jsonify({'success': False, 'error': 'Not found'}), 404
pkt_payload = row.get('pkt_payload')
sender_ts = row.get('sender_timestamp')
ch_idx = row.get('channel_idx', 0)
txt_type = row.get('txt_type', 0)
# Compute pkt_payload if not stored
if not pkt_payload and sender_ts:
_, channels_list = get_channels_cached()
channel_secrets = {}
if channels_list:
for ch_info in channels_list:
ch_key = ch_info.get('key', '')
ci = ch_info.get('index')
if ch_key and ci is not None:
channel_secrets[ci] = ch_key
if ch_idx in channel_secrets:
raw_text = None
raw_json_str = row.get('raw_json')
if raw_json_str:
try:
raw_text = json.loads(raw_json_str).get('text')
except (json.JSONDecodeError, TypeError):
pass
if not raw_text:
is_own = bool(row.get('is_own', 0))
if is_own:
device_name = runtime_config.get_device_name() or ''
raw_text = f"{device_name}: {row.get('content', '')}" if device_name else row.get('content', '')
else:
sender = row.get('sender', '')
raw_text = f"{sender}: {row.get('content', '')}" if sender else row.get('content', '')
pkt_payload = compute_pkt_payload(
channel_secrets[ch_idx], sender_ts, txt_type, raw_text
)
meta = {
'success': True,
'snr': row.get('snr'),
'path_len': row.get('path_len'),
'pkt_payload': pkt_payload,
}
if pkt_payload:
meta['analyzer_url'] = compute_analyzer_url(pkt_payload)
echoes = db.get_echoes_for_message(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]
return jsonify(meta)
except Exception as e:
logger.error(f"Error fetching message meta: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/messages', methods=['POST'])
def send_message():
"""

View File

@@ -422,16 +422,15 @@ function connectChatSocket() {
}
});
// Real-time echo data — refresh messages to pick up route/analyzer metadata
// Real-time echo data — update metadata for specific messages (no full reload)
let echoRefreshTimer = null;
chatSocket.on('echo', (data) => {
if (currentArchiveDate) return; // Don't refresh archive view
if (data.direction !== 'incoming') return; // Only care about incoming echoes
// Debounce: wait for echoes to settle, then refresh once
// Debounce: wait for echoes to settle, then update affected messages
if (echoRefreshTimer) clearTimeout(echoRefreshTimer);
echoRefreshTimer = setTimeout(() => {
echoRefreshTimer = null;
loadMessages();
refreshMessagesMeta();
}, 2000);
});
@@ -917,8 +916,8 @@ function appendMessageFromSocket(data) {
timestamp: data.timestamp || Math.floor(Date.now() / 1000),
is_own: !!data.is_own,
channel_idx: data.channel_idx,
snr: data.snr || null,
path_len: data.path_len || null,
snr: data.snr ?? null,
path_len: data.path_len ?? null,
echo_paths: [],
echo_snrs: [],
analyzer_url: data.analyzer_url || null,
@@ -939,12 +938,150 @@ function appendMessageFromSocket(data) {
markChannelAsRead(currentChannelIdx, msg.timestamp);
}
/**
* 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() {
const container = document.getElementById('messagesList');
if (!container) return;
// 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;
try {
const resp = await fetch(`/api/messages/${msgId}/meta`);
const meta = await resp.json();
if (!meta.success) continue;
updateMessageMetaDOM(wrapper, meta);
} catch (e) {
console.error(`Error fetching meta for msg #${msgId}:`, e);
}
}
}
/**
* Update metadata and action buttons in-place for a single message wrapper.
*/
function updateMessageMetaDOM(wrapper, meta) {
const isOwn = wrapper.classList.contains('own');
// Build meta info string
let metaParts = [];
const displaySnr = (meta.snr !== undefined && meta.snr !== null) ? meta.snr
: (meta.echo_snrs && meta.echo_snrs.length > 0) ? meta.echo_snrs[0] : null;
if (displaySnr !== null) {
metaParts.push(`SNR: ${displaySnr.toFixed(1)} dB`);
}
if (meta.path_len !== undefined && meta.path_len !== null) {
metaParts.push(`Hops: ${meta.path_len}`);
}
// 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,
}));
}
if (paths && paths.length > 0) {
const firstPath = paths[0];
const segments = firstPath.path ? firstPath.path.match(/.{1,2}/g) || [] : [];
const shortPath = segments.length > 4
? `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}`
: segments.join('\u2192');
const pathsData = encodeURIComponent(JSON.stringify(paths));
const routeLabel = paths.length > 1 ? `Route (${paths.length})` : 'Route';
metaParts.push(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}')">${routeLabel}: ${shortPath}</span>`);
}
const metaInfo = metaParts.join(' | ');
if (!isOwn) {
// Update or insert .message-meta div
const msgDiv = wrapper.querySelector('.message.other');
if (!msgDiv) return;
let metaEl = msgDiv.querySelector('.message-meta');
if (metaInfo) {
if (!metaEl) {
metaEl = document.createElement('div');
metaEl.className = 'message-meta';
const actionsEl = msgDiv.querySelector('.message-actions');
msgDiv.insertBefore(metaEl, actionsEl);
}
metaEl.innerHTML = metaInfo;
}
// Add analyzer button if not already present
if (meta.analyzer_url) {
const actionsEl = msgDiv.querySelector('.message-actions');
if (actionsEl && !actionsEl.querySelector('[title="View in Analyzer"]')) {
const ignoreBtn = actionsEl.querySelector('[title^="Ignore"]');
const analyzerBtn = document.createElement('button');
analyzerBtn.className = 'btn btn-outline-secondary btn-msg-action';
analyzerBtn.setAttribute('onclick', `window.open('${meta.analyzer_url}', 'meshcore-analyzer')`);
analyzerBtn.title = 'View in Analyzer';
analyzerBtn.innerHTML = '<i class="bi bi-clipboard-data"></i>';
actionsEl.insertBefore(analyzerBtn, ignoreBtn);
}
}
} else {
// Own messages: update echo badge and analyzer button
const msgDiv = wrapper.querySelector('.message.own');
if (!msgDiv) return;
// Update echo badge
if (meta.echo_paths && meta.echo_paths.length > 0) {
const echoPaths = [...new Set(meta.echo_paths.map(p => p.substring(0, 2)))];
const echoCount = echoPaths.length;
const pathDisplay = echoPaths.length > 0 ? ` (${echoPaths.join(', ')})` : '';
const actionsEl = msgDiv.querySelector('.message-actions');
if (actionsEl) {
let badge = actionsEl.querySelector('.echo-badge');
if (!badge) {
badge = document.createElement('span');
badge.className = 'echo-badge';
actionsEl.insertBefore(badge, actionsEl.firstChild);
}
badge.title = `Heard by ${echoCount} repeater(s): ${echoPaths.join(', ')}`;
badge.innerHTML = `<i class="bi bi-broadcast"></i> ${echoCount}${pathDisplay}`;
}
}
// Add analyzer button
if (meta.analyzer_url) {
const actionsEl = msgDiv.querySelector('.message-actions');
if (actionsEl && !actionsEl.querySelector('[title="View in Analyzer"]')) {
const resendBtn = actionsEl.querySelector('[title="Resend"]');
const analyzerBtn = document.createElement('button');
analyzerBtn.className = 'btn btn-outline-secondary btn-msg-action';
analyzerBtn.setAttribute('onclick', `window.open('${meta.analyzer_url}', 'meshcore-analyzer')`);
analyzerBtn.title = 'View in Analyzer';
analyzerBtn.innerHTML = '<i class="bi bi-clipboard-data"></i>';
actionsEl.insertBefore(analyzerBtn, resendBtn);
}
}
}
}
/**
* Create message DOM element
*/
function createMessageElement(msg) {
const wrapper = document.createElement('div');
wrapper.className = `message-wrapper ${msg.is_own ? 'own' : 'other'}`;
if (msg.id) wrapper.dataset.msgId = msg.id;
const time = formatTime(msg.timestamp);