Add retry functionality for failed messages in ConversationViewer and enhance message handling in MessagesPage

This commit is contained in:
Sudo-Ivan committed 2026-03-10 04:26:43 -05:00
1 parent 30ef8856dc
commit 99c8775963
2 files changed
+133 -30

No files matched your search

@@ -838,6 +838,15 @@
>
{{ chatItem.lxmf_message.state === "rejected" ? "Rejected" : "Failed" }}
</span>
<button
v-if="['failed', 'cancelled'].includes(chatItem.lxmf_message.state)"
type="button"
class="ml-0.5 p-0.5 rounded hover:bg-white/20 transition-colors"
title="Retry sending"
@click.stop="retrySendingMessage(chatItem)"
>
<MaterialDesignIcon icon-name="refresh" class="size-3 text-white" />
</button>
<!-- delivered: double check -->
<MaterialDesignIcon
@@ -1214,6 +1223,21 @@
<MaterialDesignIcon icon-name="code-json" class="size-4 text-gray-400" />
<span class="font-medium">View Raw LXM</span>
</button>
<button
v-if="
messageContextMenu.chatItem?.is_outbound &&
['failed', 'cancelled'].includes(messageContextMenu.chatItem?.lxmf_message?.state)
"
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-all active:scale-95"
@click="
retrySendingMessage(messageContextMenu.chatItem);
messageContextMenu.show = false;
"
>
<MaterialDesignIcon icon-name="refresh" class="size-4" />
<span class="font-medium">Retry</span>
</button>
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
<button
type="button"
@@ -2613,52 +2637,54 @@ export default {
}
GlobalEmitter.emit("compose-new-message", destinationHash);
},
normalizeLxmfMessage(msg, isOutbound) {
const normalized = { ...msg };
if (!normalized.created_at && normalized.timestamp) {
normalized.created_at = new Date(normalized.timestamp * 1000).toISOString();
}
if (isOutbound && normalized.state === "unknown") {
normalized.state = "outbound";
}
return normalized;
},
onLxmfMessageReceived(lxmfMessage) {
// only add if it's for the current conversation
if (lxmfMessage.source_hash !== this.selectedPeer?.destination_hash) {
return;
}
// add inbound message to ui
this.chatItems.push({
type: "lxmf_message",
lxmf_message: lxmfMessage,
is_outbound: false,
lxmf_message: this.normalizeLxmfMessage(lxmfMessage, false),
});
// mark conversation as read
const conversation = this.findConversation(this.selectedPeer.destination_hash);
if (conversation) {
this.markConversationAsRead(conversation);
}
// auto scroll to bottom if we want to
if (this.autoScrollOnNewMessage) {
this.scrollMessagesToBottom();
}
// auto load audio
this.autoLoadAudioAttachments();
},
onLxmfMessageCreated(lxmfMessage) {
// only add if it's for the current conversation
if (lxmfMessage.destination_hash !== this.selectedPeer?.destination_hash) {
return;
}
// add new outbound lxmf message from server
if (!this.isLxmfMessageInUi(lxmfMessage.hash)) {
this.chatItems.push({
type: "lxmf_message",
lxmf_message: lxmfMessage,
lxmf_message: this.normalizeLxmfMessage(lxmfMessage, true),
is_outbound: true,
});
}
// auto load audio
this.autoLoadAudioAttachments();
},
onLxmfMessageUpdated(lxmfMessage) {
// find existing chat item by lxmf message hash
const lxmfMessageHash = lxmfMessage.hash;
const chatItemIndex = this.chatItems.findIndex(
(chatItem) => chatItem.lxmf_message?.hash === lxmfMessageHash
@@ -2667,8 +2693,6 @@ export default {
return;
}
// update lxmf message from server, while ensuring ui updates from nested object change
// we merge to preserve client-side only fields or database fields not present in state updates
this.chatItems[chatItemIndex].lxmf_message = {
...this.chatItems[chatItemIndex].lxmf_message,
...lxmfMessage,
@@ -3271,16 +3295,14 @@ export default {
},
});
// add outbound message to ui
if (!this.isLxmfMessageInUi(response.data.lxmf_message.hash)) {
this.chatItems.push({
type: "lxmf_message",
lxmf_message: response.data.lxmf_message,
lxmf_message: this.normalizeLxmfMessage(response.data.lxmf_message, true),
is_outbound: true,
});
}
} else {
// send first image with message text and other fields
const firstImage = images[0];
const firstFields = {
...fields,
@@ -3300,16 +3322,14 @@ export default {
},
});
// add outbound message to ui
if (!this.isLxmfMessageInUi(response.data.lxmf_message.hash)) {
this.chatItems.push({
type: "lxmf_message",
lxmf_message: response.data.lxmf_message,
lxmf_message: this.normalizeLxmfMessage(response.data.lxmf_message, true),
is_outbound: true,
});
}
// send subsequent images as separate messages with image name as content
for (let i = 1; i < images.length; i++) {
const image = images[i];
const subsequentFields = {
@@ -3326,17 +3346,15 @@ export default {
},
});
// add outbound message to ui
if (!this.isLxmfMessageInUi(subResponse.data.lxmf_message.hash)) {
this.chatItems.push({
type: "lxmf_message",
lxmf_message: subResponse.data.lxmf_message,
lxmf_message: this.normalizeLxmfMessage(subResponse.data.lxmf_message, true),
is_outbound: true,
});
}
} catch (subError) {
console.error(`Failed to send image ${i + 1}:`, subError);
// we continue sending other images even if one fails
}
}
}
@@ -3390,11 +3408,9 @@ export default {
}
},
async retrySendingMessage(chatItem) {
// force delete existing message
await this.deleteChatItem(chatItem, false);
try {
// send message to reticulum
const replyQuoted =
chatItem.lxmf_message.fields?.reply_quoted_content ||
(chatItem.lxmf_message.reply_to_hash &&
@@ -3409,19 +3425,16 @@ export default {
},
});
// add outbound message to ui
if (!this.isLxmfMessageInUi(response.data.lxmf_message.hash)) {
this.chatItems.push({
type: "lxmf_message",
lxmf_message: response.data.lxmf_message,
lxmf_message: this.normalizeLxmfMessage(response.data.lxmf_message, true),
is_outbound: true,
});
}
// always scroll to bottom since we just sent a message
this.scrollMessagesToBottom();
} catch (e) {
// show error
const message = e.response?.data?.message ?? "failed to send message";
DialogUtils.alert(message);
console.log(e);
@@ -262,8 +262,10 @@ export default {
return;
}
const existingConversation = this.conversations.find((c) => c.destination_hash === destinationHash);
this.onPeerClick({
display_name: "Unknown Peer",
display_name: existingConversation?.display_name ?? "Anonymous Peer",
custom_display_name: existingConversation?.custom_display_name ?? null,
destination_hash: destinationHash,
});
},
@@ -291,10 +293,17 @@ export default {
break;
}
case "lxmf.delivery": {
// reload conversations when a new message is received
await this.getConversations();
break;
}
case "lxmf_message_created": {
this.onOutboundMessageCreated(json.lxmf_message);
break;
}
case "lxmf_message_state_updated": {
this.onOutboundMessageStateUpdated(json.lxmf_message);
break;
}
case "lxmf.telemetry": {
// update tracking status if peer matches
const destHash = json.destination_hash;
@@ -422,6 +431,87 @@ export default {
this.isLoadingMore = false;
}
},
peerHashFromMessage(msg) {
return msg.is_incoming ? msg.source_hash : msg.destination_hash;
},
onOutboundMessageCreated(msg) {
const peerHash = this.peerHashFromMessage(msg);
const idx = this.conversations.findIndex((c) => c.destination_hash === peerHash);
if (idx !== -1) {
const conv = this.conversations[idx];
conv.latest_message_preview = msg.content;
conv.latest_message_title = msg.title;
conv.latest_message_created_at = msg.timestamp;
conv.updated_at = new Date(msg.timestamp * 1000).toISOString();
} else {
const peer = this.peers[peerHash];
this.conversations.unshift({
destination_hash: peerHash,
display_name: peer?.display_name ?? this.selectedPeer?.display_name ?? "Anonymous Peer",
custom_display_name: peer?.custom_display_name ?? this.selectedPeer?.custom_display_name ?? null,
contact_image: peer?.contact_image ?? null,
lxmf_user_icon: peer?.lxmf_user_icon ?? null,
is_unread: false,
is_tracking: peer?.is_tracking ?? false,
failed_messages_count: 0,
has_attachments: false,
latest_message_preview: msg.content,
latest_message_title: msg.title,
latest_message_created_at: msg.timestamp,
updated_at: new Date(msg.timestamp * 1000).toISOString(),
is_contact: false,
});
this.resolvePeerDisplayName(peerHash);
}
},
onOutboundMessageStateUpdated(msg) {
const peerHash = this.peerHashFromMessage(msg);
const conv = this.conversations.find((c) => c.destination_hash === peerHash);
if (!conv) return;
const oldState = conv._lastKnownState;
const newState = msg.state;
conv._lastKnownState = newState;
if (newState === "failed" && oldState !== "failed") {
conv.failed_messages_count = (conv.failed_messages_count || 0) + 1;
} else if (oldState === "failed" && newState !== "failed") {
conv.failed_messages_count = Math.max(0, (conv.failed_messages_count || 1) - 1);
}
},
async resolvePeerDisplayName(peerHash) {
try {
const response = await window.axios.get(`/api/v1/lxmf/conversations`, {
params: { search: peerHash, limit: 1 },
});
const results = response.data.conversations;
if (!results || results.length === 0) return;
const fresh = results[0];
if (fresh.destination_hash !== peerHash) return;
const conv = this.conversations.find((c) => c.destination_hash === peerHash);
if (conv) {
if (fresh.display_name) conv.display_name = fresh.display_name;
if (fresh.custom_display_name) conv.custom_display_name = fresh.custom_display_name;
if (fresh.contact_image) conv.contact_image = fresh.contact_image;
if (fresh.lxmf_user_icon) conv.lxmf_user_icon = fresh.lxmf_user_icon;
if (fresh.is_contact) conv.is_contact = fresh.is_contact;
}
if (this.selectedPeer && this.selectedPeer.destination_hash === peerHash) {
if (fresh.display_name && fresh.display_name !== this.selectedPeer.display_name) {
this.selectedPeer = {
...this.selectedPeer,
display_name: fresh.display_name,
custom_display_name: fresh.custom_display_name ?? this.selectedPeer.custom_display_name,
};
}
}
} catch {
// non-critical
}
},
async getFolders() {
try {
const response = await window.axios.get("/api/v1/lxmf/folders");