From 00f0544a47c6a3cf1574cef563573d547ca758c7 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Tue, 9 Jun 2026 15:02:25 +0200 Subject: [PATCH] feat(channels): UI for raw resend + clarify the edit-message button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #5 of 5. Wires the user-facing controls for the raw-resend feature. Channel messages (own): - The existing arrow-repeat button only pasted content into the composer for hand-edits, which the "Resend" tooltip mis-named as a true resend. Rename it to "Edit message" with a pencil-square icon. - Add a new arrow-repeat button that POSTs to /api/messages//resend. Tooltip explains the actual semantics ("rebroadcast same packet so unreached repeaters can pick it up"). Spins .btn icon while in flight, shows a toast on result. Rendered only when the cached /api/status.supports_raw_resend is true (firmware ≥1.16). - Inject the same buttons in updateMessageMetaDOM so history items loaded before window.deviceCaps was populated still get the new button on the next echo-driven meta refresh. DM messages: rename the equivalent paste-button to "Edit message" with the same pencil icon for UI consistency. The protocol-level retry stays unchanged — there's no per-DM raw resend button (auto-retry covers it). Co-Authored-By: Claude Opus 4.7 --- app/static/js/app.js | 84 ++++++++++++++++++++++++++++++++++++++++---- app/static/js/dm.js | 10 ++++-- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/app/static/js/app.js b/app/static/js/app.js index 7f11fba..3c30982 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1237,13 +1237,35 @@ function updateMessageMetaDOM(wrapper, meta) { if (meta.packet_hash) { const actionsEl = msgDiv.querySelector('.message-actions'); if (actionsEl && !actionsEl.querySelector('[title="View in Analyzer"]')) { - const resendBtn = actionsEl.querySelector('[title="Resend"]'); + // Anchor analyzer before whichever action button comes first + // (post-rename: "Edit message"; legacy renders may still say "Resend") + const anchor = actionsEl.querySelector('[title="Edit message"]') + || actionsEl.querySelector('[title="Resend"]') + || actionsEl.querySelector('[title^="Resend"]'); const analyzerBtn = document.createElement('button'); analyzerBtn.className = 'btn btn-outline-secondary btn-msg-action'; analyzerBtn.setAttribute('onclick', `openMessageAnalyzer('${meta.packet_hash}')`); analyzerBtn.title = 'View in Analyzer'; analyzerBtn.innerHTML = ''; - actionsEl.insertBefore(analyzerBtn, resendBtn); + if (anchor) actionsEl.insertBefore(analyzerBtn, anchor); + else actionsEl.appendChild(analyzerBtn); + } + } + + // Add raw-resend button if supported and missing. The initial render + // path also injects this, but messages loaded from history before + // window.deviceCaps was populated by loadStatus get it here on the + // next echo-driven meta refresh. + const msgId = wrapper.dataset.msgId; + if (window.deviceCaps?.supports_raw_resend && msgId) { + const actionsEl = msgDiv.querySelector('.message-actions'); + if (actionsEl && !actionsEl.querySelector('.btn-raw-resend')) { + const rawBtn = document.createElement('button'); + rawBtn.className = 'btn btn-outline-secondary btn-msg-action btn-raw-resend'; + rawBtn.setAttribute('onclick', `resendChannelMessageRaw(${msgId}, this)`); + rawBtn.title = 'Resend (rebroadcast same packet so unreached repeaters can pick it up)'; + rawBtn.innerHTML = ''; + actionsEl.appendChild(rawBtn); } } } @@ -1329,9 +1351,14 @@ function createMessageElement(msg) { ` : ''} - + ${window.deviceCaps?.supports_raw_resend ? ` + + ` : ''} @@ -1541,8 +1568,11 @@ function quoteTo(username, content) { } /** - * Resend a message (paste content back to input) - * @param {string} content - Message content to resend + * Edit-message helper: paste an own message's content back into the composer + * so the user can tweak it before sending. NOT a true resend — every press + * produces a new packet hash. The arrow-repeat button next to this one does + * the actual raw resend (resendChannelMessageRaw). + * @param {string} content - Message content to paste */ function resendMessage(content) { const input = document.getElementById('messageInput'); @@ -1551,6 +1581,42 @@ function resendMessage(content) { input.focus(); } +/** + * Raw resend: re-broadcast the exact same packet bytes so repeaters that + * already forwarded it dedupe via packet-hash, while unreached repeaters + * pick it up. Backend returns 400 if the message has no raw_packet snapshot + * (sent before this feature shipped) or if the firmware is too old. + * + * @param {number} msgId - channel_messages.id + * @param {HTMLElement} btn - the clicked button, used to spin the icon during the call + */ +async function resendChannelMessageRaw(msgId, btn) { + if (!msgId || btn?.dataset.busy === '1') return; + const icon = btn?.querySelector('i'); + if (btn) { + btn.dataset.busy = '1'; + btn.disabled = true; + if (icon) icon.classList.add('spin'); + } + try { + const resp = await fetch(`/api/messages/${msgId}/resend`, { method: 'POST' }); + const data = await resp.json().catch(() => ({})); + if (resp.ok && data.success) { + showNotification(`Resent (${data.bytes ?? '?'} B) — waiting for echoes…`, 'info'); + } else { + showNotification(`Resend failed: ${data.error || resp.statusText}`, 'danger'); + } + } catch (err) { + showNotification(`Resend network error: ${err.message || err}`, 'danger'); + } finally { + if (btn) { + btn.dataset.busy = '0'; + btn.disabled = false; + if (icon) icon.classList.remove('spin'); + } + } +} + async function ignoreContactFromChat(pubkey) { try { const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/ignore`, { @@ -1681,6 +1747,12 @@ async function loadStatus() { if (data.success) { updateStatus(data.connected ? 'connected' : 'disconnected'); + // Cache device capabilities so the message renderer can decide + // whether to expose the raw-resend button (firmware ≥1.16 only). + window.deviceCaps = { + supports_raw_resend: !!data.supports_raw_resend, + fw_ver_code: data.fw_ver_code ?? null, + }; } } catch (error) { console.error('Error loading status:', error); diff --git a/app/static/js/dm.js b/app/static/js/dm.js index bddc124..e6df242 100644 --- a/app/static/js/dm.js +++ b/app/static/js/dm.js @@ -1293,11 +1293,15 @@ function displayMessages(messages) { retryInfo = `
${initialText}
`; } - // Resend button for own messages + // Edit-message button for own messages. DM auto-retry handles the + // "true resend" case at the protocol level, so this button stays as + // a paste-to-composer helper. Renamed/icon-swapped to match the + // channel UI's distinction between "edit" (pencil) and the channel- + // only raw resend (arrow-repeat). const resendBtn = msg.is_own ? `
-
` : '';