mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-10-22 14:11:37 +00:00
Add retry functionality for failed messages in ConversationViewer and enhance message handling in MessagesPage
This commit is contained in:
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");
|
||||
|
||||
Reference in new issue
Block a user