feat(channels): UI for raw resend + clarify the edit-message button

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/<id>/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 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-06-09 15:02:25 +02:00
parent 201bc137e5
commit 00f0544a47
2 changed files with 85 additions and 9 deletions
+78 -6
View File
@@ -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 = '<i class="bi bi-clipboard-data"></i>';
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 = '<i class="bi bi-arrow-repeat"></i>';
actionsEl.appendChild(rawBtn);
}
}
}
@@ -1329,9 +1351,14 @@ function createMessageElement(msg) {
<i class="bi bi-clipboard-data"></i>
</button>
` : ''}
<button class="btn btn-outline-secondary btn-msg-action" onclick='resendMessage(${JSON.stringify(msg.content)})' title="Resend">
<i class="bi bi-arrow-repeat"></i>
<button class="btn btn-outline-secondary btn-msg-action" onclick='resendMessage(${JSON.stringify(msg.content)})' title="Edit message">
<i class="bi bi-pencil-square"></i>
</button>
${window.deviceCaps?.supports_raw_resend ? `
<button class="btn btn-outline-secondary btn-msg-action btn-raw-resend" onclick="resendChannelMessageRaw(${msg.id}, this)" title="Resend (rebroadcast same packet so unreached repeaters can pick it up)">
<i class="bi bi-arrow-repeat"></i>
</button>
` : ''}
</div>
</div>
</div>
@@ -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);
+7 -3
View File
@@ -1293,11 +1293,15 @@ function displayMessages(messages) {
retryInfo = `<div class="dm-delivery-meta dm-retry-info" data-dm-id="${msg.id || ''}">${initialText}</div>`;
}
// 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 ? `
<div class="dm-actions">
<button class="btn btn-outline-secondary btn-sm dm-action-btn" onclick='resendMessage(${JSON.stringify(msg.content)})' title="Resend">
<i class="bi bi-arrow-repeat"></i>
<button class="btn btn-outline-secondary btn-sm dm-action-btn" onclick='resendMessage(${JSON.stringify(msg.content)})' title="Edit message">
<i class="bi bi-pencil-square"></i>
</button>
</div>
` : '';