Repository: Adamant-im/adamant-im
Pull request #750: Feat/message sending timeouts
Author: login: skranee
Created: 2025-03-27T13:29:34Z | Merged: 2025-05-24T08:43:25Z

I regenerated adamant-wallets and corrected the script a little bit as it was needed for the timeout.

The task: https://trello.com/c/XOLbYQPq/611-connection-feat-message-sending-timeouts-copied-from-ios


Diff:
diff --git a/adamant-wallets b/adamant-wallets
index 42ad6014b..f685722e2 160000
--- a/adamant-wallets
+++ b/adamant-wallets
@@ -1 +1 @@
-Subproject commit 42ad6014b53bb96fd0af052fb00b420dc8a1565f
+Subproject commit f685722e2996835c4c804847eded528b8161f392
diff --git a/scripts/wallets.mjs b/scripts/wallets.mjs
index a6340d7f5..ecade61e2 100644
--- a/scripts/wallets.mjs
+++ b/scripts/wallets.mjs
@@ -77,6 +77,7 @@ async function initCoins() {
       defaultGasLimit: coin.defaultGasLimit,
       defaultGasPriceGwei: coin.defaultGasPriceGwei,
       txFetchInfo: coin.txFetchInfo,
+      timeout: coin.timeout,
       txConsistencyMaxTime: coin.txConsistencyMaxTime,
       defaultOrdinalLevel: coin.defaultOrdinalLevel,
       explorerTx: coin.explorerTx
diff --git a/src/App.vue b/src/App.vue
index d55ccc3ee..bfb5b9564 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -18,6 +18,9 @@ import Notifications from '@/lib/notifications'
 import { ThemeName } from './plugins/vuetify'
 import { useStore } from 'vuex'
 import { useI18n } from 'vue-i18n'
+import { useResendPendingMessages } from '@/hooks/useResendPendingMessages'
+
+useResendPendingMessages()
 
 const store = useStore()
 const isSnackbarShowing = computed(() => store.state.snackbar.show)
diff --git a/src/components/icons/cryptos/QntIcon.vue b/src/components/icons/cryptos/QntIcon.vue
index cc1a35323..b8fea54fd 100644
--- a/src/components/icons/cryptos/QntIcon.vue
+++ b/src/components/icons/cryptos/QntIcon.vue
@@ -3,7 +3,9 @@
     <path
       d="M134.8,44.5l121.9,142.9l81.8-48.1c0,0-98.7-132-125.1-122.7C188.8,25.9,134.8,44.5,134.8,44.5z"
     />
-    <path d="M341.7,139.2c0,0,18.6-32.6,27.8-76.1c0,0,92.6,37.2,57.1,130.5L341.7,139.2z" />
+    <path
+      d="M341.7,139.2c0,0,18.6-32.6,27.8-76.1c0,0,92.6,37.2,57.1,130.5L341.7,139.2z"
+    />
     <path
       d="M426.5,193.6l7.8,97.9l35.5-4.6c0,0,9.2-3.1,7.8-20.1c-1.6-18.7-4.6-57.5-4.6-57.5
 	s1.6-9.3-20-12.4C426.5,192,426.5,193.6,426.5,193.6z"
diff --git a/src/components/icons/cryptos/UniIcon.vue b/src/components/icons/cryptos/UniIcon.vue
index af898e8d1..e5c6d57ec 100644
--- a/src/components/icons/cryptos/UniIcon.vue
+++ b/src/components/icons/cryptos/UniIcon.vue
@@ -38,6 +38,8 @@
 		c0-2.8,0.1-5.1,0.1-5.1c0.1,0,1.3,1,2.8,2.1c6.6,5.3,14,7.6,34.7,10.5c12.1,1.8,19,3.1,25.3,5.3c20,6.6,32.3,20.1,35.3,38.5
 		C395.3,262.7,394.8,272.8,393.5,278z"
     />
-    <path d="M117.1,60c0,0,0,0.1,0.1,0.2c0,0,0,0,0.1,0C117.2,60.1,117.1,60,117.1,60z" />
+    <path
+      d="M117.1,60c0,0,0,0.1,0.1,0.2c0,0,0,0,0.1,0C117.2,60.1,117.1,60,117.1,60z"
+    />
   </g>
 </template>
diff --git a/src/components/icons/cryptos/UsdpIcon.vue b/src/components/icons/cryptos/UsdpIcon.vue
index 252e63445..0899203db 100644
--- a/src/components/icons/cryptos/UsdpIcon.vue
+++ b/src/components/icons/cryptos/UsdpIcon.vue
@@ -1,6 +1,8 @@
 <template>
   <g>
-    <path d="M211.2,176.7c-16.6,0-30,13.4-30,30s13.4,30,30,30h20.7v-60H211.2z" />
+    <path
+      d="M211.2,176.7c-16.6,0-30,13.4-30,30s13.4,30,30,30h20.7v-60H211.2z"
+    />
     <path
       d="M256,16C123.5,16,16,123.5,16,256s107.5,240,240,240s240-107.5,240-240S388.5,16,256,16z
 	 M308.2,373.1h-29.6V431h-46.7v-57.9h-90.1v-37.5h90.1v-60.8h-30c-36.3-0.8-65.4-30-66.3-66.3c-0.8-37.4,28.8-68.4,66.3-69.3h30V81
diff --git a/src/hooks/useResendPendingMessages.ts b/src/hooks/useResendPendingMessages.ts
new file mode 100644
index 000000000..5e91e2a6e
--- /dev/null
+++ b/src/hooks/useResendPendingMessages.ts
@@ -0,0 +1,61 @@
+import { computed, onBeforeUnmount } from 'vue'
+import { useStore } from 'vuex'
+import { watchImmediate } from '@vueuse/core'
+import { MessageType } from '@/lib/constants'
+import { NodeStatusResult } from '@/lib/nodes/abstract.node'
+import { FileData } from '@/lib/files'
+
+type PendingMessage = {
+  recipientId: string
+  timeout: ReturnType<typeof setTimeout>
+  type: number
+  files?: FileData[]
+}
+
+export function useResendPendingMessages() {
+  const store = useStore()
+
+  const admNodes = computed<NodeStatusResult[]>(() => store.getters['nodes/adm'])
+  const ipfsNodes = computed<NodeStatusResult[]>(() => store.getters['nodes/ipfs'])
+  const areAdmNodesOnline = computed(() => admNodes.value.some((node) => node.status === 'online'))
+  const areIpfsNodesOnline = computed(() =>
+    ipfsNodes.value.some((node) => node.status === 'online')
+  )
+  const pendingMessages = computed<Record<string, PendingMessage>>(
+    () => store.state.chat.pendingMessages
+  )
+
+  watchImmediate([areAdmNodesOnline, areIpfsNodesOnline], ([admOnline, ipfsOnline]) => {
+    const hasMessagesWithFiles = Object.values(pendingMessages.value).some(
+      (message) => message.files && message.files.length > 0
+    )
+
+    const isReady = hasMessagesWithFiles ? admOnline && ipfsOnline : admOnline
+
+    if (!isReady) return
+
+    Object.entries(pendingMessages.value).forEach(([messageId, message]) => {
+      ;(message.type === MessageType.BASIC_ENCRYPTED_MESSAGE
+        ? store.dispatch('chat/resendMessage', {
+            recipientId: message.recipientId,
+            messageId
+          })
+        : store.dispatch('chat/resendAttachment', {
+            recipientId: message.recipientId,
+            files: message.files,
+            messageId
+          })
+      ).then((res) => {
+        if (res.success) {
+          store.commit('chat/deletePendingMessage', messageId)
+        }
+      })
+    })
+  })
+
+  onBeforeUnmount(() => {
+    Object.values(pendingMessages.value).forEach((message) => {
+      clearTimeout(message.timeout)
+    })
+  })
+}
diff --git a/src/lib/adamant-api/asset.ts b/src/lib/adamant-api/asset.ts
index 1ab91a895..38cf46f5f 100644
--- a/src/lib/adamant-api/asset.ts
+++ b/src/lib/adamant-api/asset.ts
@@ -201,21 +201,23 @@ export interface AttachmentAsset {
  * AIP-18: https://github.com/Adamant-im/AIPs/pull/54/files
  * @param {Array<FileData>} files
  * @param {string} [comment] Optional comment associated with the transaction
- * @param {Array<[string, string]>} [cids] List of files IDs after uploading to IPFS. First element is the ID of original file, second is ID of preview.
+ * @param {Array<[string]>} [cids] List of files IDs after uploading to IPFS. ID of original file and ID of preview go one by one.
  */
 export function attachmentAsset(
   files: FileData[],
   comment?: string,
-  cids?: [string, string]
+  cids?: string[]
 ): AttachmentAsset {
   return {
-    files: files.map(({ file, width, height, cid, preview, encoded }) => {
+    files: files.map(({ file, width, height, cid, preview, encoded }, index) => {
+      const cidsChunk = index * 2
+
       const name = extractFileName(file.name)
       const extension = extractFileExtension(file.name)!
       const resolution: FileAsset['resolution'] = width && height ? [width, height] : undefined
 
-      const fileCid = cids?.[0] || cid
-      const previewCid = cids?.[1] || preview?.cid
+      const fileCid = (cids && cids[cidsChunk]) || cid
+      const previewCid = (cids && cids[cidsChunk + 1]) || preview?.cid
 
       return {
         mimeType: file.type,
diff --git a/src/lib/constants/cryptos/data.json b/src/lib/constants/cryptos/data.json
index 688fafc1f..34402a088 100644
--- a/src/lib/constants/cryptos/data.json
+++ b/src/lib/constants/cryptos/data.json
@@ -90,6 +90,10 @@
       "oldPendingInterval": 4000,
       "registeredInterval": 4000
     },
+    "timeout": {
+      "message": 300000,
+      "attachment": 300000
+    },
     "defaultOrdinalLevel": 0,
     "explorerTx": "https://explorer.adamant.im/tx/${ID}"
   },
diff --git a/src/lib/nodes/ipfs/IpfsClient.ts b/src/lib/nodes/ipfs/IpfsClient.ts
index ee3b6908f..b320c99cc 100644
--- a/src/lib/nodes/ipfs/IpfsClient.ts
+++ b/src/lib/nodes/ipfs/IpfsClient.ts
@@ -1,4 +1,4 @@
-import { isNodeOfflineError } from '@/lib/nodes/utils/errors'
+import { isNodeOfflineError, AllNodesOfflineError } from '@/lib/nodes/utils/errors'
 import { AxiosProgressEvent } from 'axios'
 import { NODE_LABELS } from '@/lib/nodes/constants'
 import { IpfsNode, Payload, RequestConfig } from './IpfsNode'
@@ -69,7 +69,7 @@ export class IpfsClient extends Client<IpfsNode> {
       // All nodes seem to be offline: let's refresh the statuses
       this.checkHealth()
       // But there's nothing we can do right now
-      return Promise.reject(new Error('No online nodes at the moment'))
+      return Promise.reject(new AllNodesOfflineError('ipfs'))
     }
 
     return node.request(config).catch((error) => {
diff --git a/src/store/modules/chat/index.js b/src/store/modules/chat/index.js
index 8f04497c4..b76729815 100644
--- a/src/store/modules/chat/index.js
+++ b/src/store/modules/chat/index.js
@@ -17,6 +17,7 @@ import { isNumeric } from '@/lib/numericHelpers'
 import {
   CHAT_ACTUALITY_BUFFER_MS,
   Cryptos,
+  CryptosInfo,
   TransactionStatus as TS,
   MessageType
 } from '@/lib/constants'
@@ -24,7 +25,11 @@ import { isStringEqualCI } from '@/lib/textHelpers'
 import { replyMessageAsset, attachmentAsset } from '@/lib/adamant-api/asset'
 import { uploadFiles } from '../../../lib/files'
 import { generateAdamantChats } from './utils/generateAdamantChats'
-import { isAllNodesDisabledError, isAllNodesOfflineError } from '@/lib/nodes/utils/errors'
+import {
+  AllNodesOfflineError,
+  isAllNodesDisabledError,
+  isAllNodesOfflineError
+} from '@/lib/nodes/utils/errors'
 import adamant from '@/lib/adamant'
 
 export let interval
@@ -57,7 +62,8 @@ const state = () => ({
   isFulfilled: false, // false - getChats did not start or in progress, true - getChats finished
   offset: 0, // for loading chat list with pagination. -1 if all of chats loaded
   noActiveNodesDialog: undefined, // true - visible dialog, false - hidden dialog, but shown before, undefined - not shown
-  chatsActualUntil: 0
+  chatsActualUntil: 0,
+  pendingMessages: {}
 })
 
 const getters = {
@@ -531,6 +537,22 @@ const mutations = {
     state.noActiveNodesDialog = value
   },
 
+  addPendingMessage(state, { messageId, recipientId, timeout, type, files }) {
+    state.pendingMessages[messageId] = {
+      recipientId,
+      timeout,
+      type,
+      files
+    }
+  },
+
+  deletePendingMessage(state, messageId) {
+    if (state.pendingMessages[messageId]) {
+      clearTimeout(state.pendingMessages[messageId].timeout)
+      delete state.pendingMessages[messageId]
+    }
+  },
+
   reset(state) {
     state.chats = {}
     state.lastMessageHeight = 0
@@ -741,7 +763,7 @@ const actions = {
    * @param {string} replyToId Optional
    * @returns {Promise}
    */
-  async sendMessage({ commit, rootState }, { message, recipientId, replyToId }) {
+  async sendMessage({ commit, rootState, dispatch }, { message, recipientId, replyToId }) {
     let id
     try {
       const type = replyToId
@@ -788,12 +810,39 @@ const actions = {
         partnerId: recipientId
       })
     } catch (error) {
-      if (id) {
+      if (isAllNodesOfflineError(error)) {
+        // if the error is caused by connection we keep the message in PENDING status
+        // and try to resend it after the connection is restored
+
+        // timeout for self deleting out of pending messages
+        const timeout = setTimeout(() => {
+          dispatch('rejectPendingMessage', {
+            messageId: id,
+            recipientId
+          })
+        }, Number(CryptosInfo.ADM.timeout.message))
+
+        // put the message into pending messages object
+        commit('addPendingMessage', {
+          messageId: id,
+          type: MessageType.BASIC_ENCRYPTED_MESSAGE,
+          recipientId,
+          timeout
+        })
+
         commit('updateMessage', {
           id,
-          status: TS.REJECTED,
+          status: TS.PENDING,
           partnerId: recipientId
         })
+      } else {
+        if (id) {
+          commit('updateMessage', {
+            id,
+            status: TS.REJECTED,
+            partnerId: recipientId
+          })
+        }
       }
       throw error
     }
@@ -809,7 +858,10 @@ const actions = {
    * @param {string} replyToId Optional
    * @returns {Promise}
    */
-  async sendAttachment({ commit, rootState }, { files, message, recipientId, replyToId }) {
+  async sendAttachment(
+    { commit, rootState, dispatch },
+    { files, message, recipientId, replyToId }
+  ) {
     const recipientPublicKey = await getPublicKey(recipientId)
     const senderPublicKey = await getPublicKey(rootState.address)
 
@@ -853,6 +905,7 @@ const actions = {
       newAsset = replyToId
         ? { replyto_id: replyToId, reply_message: attachmentAsset(files, message, uploadData.cids) }
         : attachmentAsset(files, message, uploadData.cids)
+
       commit('updateMessage', {
         id: messageObject.id,
         partnerId: recipientId,
@@ -860,17 +913,19 @@ const actions = {
       })
       console.debug('Updated CIDs after upload', newAsset)
     } catch (err) {
-      commit('updateMessage', {
-        id: messageObject.id,
-        status: TS.REJECTED,
-        partnerId: recipientId
-      })
+      if (!isAllNodesOfflineError(err)) {
+        commit('updateMessage', {
+          id: messageObject.id,
+          status: TS.REJECTED,
+          partnerId: recipientId
+        })
 
-      for (const [cid] of cids) {
-        commit('attachment/resetUploadProgress', { cid }, { root: true })
-      }
+        for (const [cid] of cids) {
+          commit('attachment/resetUploadProgress', { cid }, { root: true })
+        }
 
-      throw err
+        throw err
+      }
     }
 
     for (const [cid] of cids) {
@@ -879,6 +934,10 @@ const actions = {
 
     return queueMessage(newAsset, recipientId, MessageType.RICH_CONTENT_MESSAGE)
       .then((res) => {
+        if (isAllNodesOfflineError(res)) {
+          throw new AllNodesOfflineError('ipfs')
+        }
+
         if (!res.success) {
           throw new Error(i18n.global.t('chats.message_rejected'))
         }
@@ -895,12 +954,40 @@ const actions = {
         return res
       })
       .catch((err) => {
-        // update `message.status` to 'REJECTED'
-        commit('updateMessage', {
-          id: messageObject.id,
-          status: TS.REJECTED,
-          partnerId: recipientId
-        })
+        // update `message.status` to 'REJECTED' if the error is not caused by connection
+        if (!isAllNodesOfflineError(err)) {
+          commit('updateMessage', {
+            id: messageObject.id,
+            status: TS.REJECTED,
+            partnerId: recipientId
+          })
+        } else {
+          // if the error is caused by connection we keep the message in PENDING status
+          // and try to resend it after the connection is restored
+
+          // timeout for self deleting out of pending messages
+          const timeout = setTimeout(() => {
+            dispatch('rejectPendingMessage', {
+              messageId: messageObject.id,
+              recipientId
+            })
+          }, Number(CryptosInfo.ADM.timeout.attachment))
+
+          // put the message into pending messages object
+          commit('addPendingMessage', {
+            messageId: messageObject.id,
+            type: MessageType.RICH_CONTENT_MESSAGE,
+            recipientId,
+            timeout,
+            files
+          })
+
+          commit('updateMessage', {
+            id: messageObject.id,
+            status: TS.PENDING,
+            partnerId: recipientId
+          })
+        }
 
         throw err // call the error again so that it can be processed inside view
       })
@@ -912,7 +999,7 @@ const actions = {
    * @param {number} id Message Id
    * @returns {Promise}
    */
-  resendMessage({ getters, commit }, { recipientId, messageId }) {
+  resendMessage({ getters, commit, dispatch }, { recipientId, messageId }) {
     const message = getters.partnerMessageById(recipientId, messageId)
 
     // update message status from `rejected` to `sent`
@@ -940,29 +1027,138 @@ const actions = {
             throw new Error(i18n.global.t('chats.message_rejected'))
           }
 
-          commit('updateMessage', {
-            id: messageId,
-            realId: res.transactionId,
-            status: TS.REGISTERED,
-            partnerId: recipientId
+          dispatch('registerPendingMessage', {
+            transactionId: res.transactionId,
+            messageId,
+            recipientId
           })
 
           return res
         })
-        .catch((err) => {
-          commit('updateMessage', {
-            id: messageId,
-            status: TS.REJECTED,
-            partnerId: recipientId
-          })
+        .catch((error) => {
+          if (!isAllNodesOfflineError(error)) {
+            dispatch('rejectPendingMessage', {
+              messageId,
+              recipientId
+            })
+          }
 
-          throw err
+          throw error
         })
     }
 
     return Promise.reject(new Error('Message not found in history'))
   },
 
+  /**
+   * Resend attachment, in case the connection fails at upload or at send.
+   * @param {string} recipientId
+   * @param {number} messageId
+   * @param {FileData[]} files
+   * @returns {Promise}
+   */
+  async resendAttachment({ getters, commit, dispatch }, { recipientId, messageId, files }) {
+    const message = getters.partnerMessageById(recipientId, messageId)
+    if (!message) {
+      return Promise.reject(new Error('Message not found in history'))
+    }
+
+    // in case the files were not fully uploaded or uploaded incorrectly we
+    // reupload them from scratch to resend a message with attachments
+    const cidsOld = files.map((file) => [file.cid, file.preview?.cid]).filter(Boolean)
+    for (const [cid] of cidsOld) {
+      commit('attachment/resetUploadProgress', { cid }, { root: true })
+    }
+    commit('updateMessage', {
+      id: messageId,
+      status: TS.PENDING,
+      partnerId: recipientId
+    })
+
+    let uploadData
+    try {
+      uploadData = await uploadFiles(files, (progress) => {
+        for (const [cid] of cidsOld) {
+          commit('attachment/setUploadProgress', { cid, progress }, { root: true })
+        }
+      })
+    } catch (err) {
+      for (const [cid] of cidsOld) {
+        commit('attachment/resetUploadProgress', { cid }, { root: true })
+      }
+
+      if (!isAllNodesOfflineError(err)) {
+        dispatch('rejectPendingMessage', {
+          messageId,
+          recipientId
+        })
+
+        throw err
+      }
+    }
+
+    const newAsset = message.isReply
+      ? {
+          replyto_id: message.asset.replyto_id,
+          reply_message: attachmentAsset(files, message.message, uploadData.cids)
+        }
+      : attachmentAsset(files, message.message, uploadData.cids)
+
+    commit('updateMessage', {
+      id: messageId,
+      asset: newAsset,
+      partnerId: recipientId
+    })
+
+    for (const [cid] of cidsOld) {
+      commit('attachment/resetUploadProgress', { cid }, { root: true })
+    }
+
+    return queueMessage(newAsset, recipientId, MessageType.RICH_CONTENT_MESSAGE)
+      .then((res) => {
+        if (!res.success) {
+          throw new Error(i18n.global.t('chats.message_rejected'))
+        }
+        dispatch('registerPendingMessage', {
+          transactionId: res.transactionId,
+          messageId,
+          recipientId
+        })
+        return res
+      })
+      .catch((err) => {
+        if (!isAllNodesOfflineError(err)) {
+          dispatch('rejectPendingMessage', {
+            messageId,
+            recipientId
+          })
+        }
+
+        throw err
+      })
+  },
+
+  registerPendingMessage({ commit }, { messageId, recipientId, transactionId }) {
+    commit('updateMessage', {
+      id: messageId,
+      realId: transactionId,
+      status: TS.REGISTERED,
+      partnerId: recipientId
+    })
+
+    commit('deletePendingMessage', messageId)
+  },
+
+  rejectPendingMessage({ commit }, { messageId, recipientId }) {
+    commit('updateMessage', {
+      messageId,
+      status: TS.REJECTED,
+      partnerId: recipientId
+    })
+
+    commit('deletePendingMessage', messageId)
+  },
+
   /**
    * React to a message with emoji.
    * After confirmation, `id` and `status` will be updated.


Reviews:
user: login: adamantmm
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-27T14:05:16Z
user: login: dvelikiy
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T09:27:59Z
user: login: dvelikiy
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T09:38:41Z
user: login: dvelikiy
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T09:40:31Z
user: login: dvelikiy
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T09:41:24Z
user: login: dvelikiy
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T09:51:26Z
user: login: dvelikiy
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T09:58:52Z
user: login: dvelikiy
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T10:26:52Z
user: login: skranee
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T10:34:07Z
user: login: skranee
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T10:35:08Z
user: login: skranee
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T10:37:20Z
user: login: skranee
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T10:42:20Z
user: login: skranee
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T17:33:39Z
user: login: dvelikiy
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T18:00:39Z
user: login: dvelikiy
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T18:02:37Z
user: login: skranee
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T08:55:56Z
user: login: adamantmm
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-13T04:52:15Z
user: login: S-FrontendDev
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-13T11:03:49Z

Review comments:
diff_hunk: @@ -893,7 +1007,23 @@ const actions = {
       return queueMessage(messageAsset, recipientId, type)
         .then((res) => {
           if (!res.success) {
-            throw new Error(i18n.global.t('chats.message_rejected'))
+            if (!pendingMessages.has(messageId)) {
+              throw new Error(i18n.global.t('chats.message_rejected'))
+            }
+
+            setTimeout(() => {
+              dispatch('resendMessage', {
path: src/store/modules/chat/index.js
user: login: adamantmm
body: Note that the task is not resending failed messages.
Failed is a final status. A user can manually resend a message with UI. No automatic resend.

This task is how much time a message will be in Pending status until it turn to Failed.

E.g., a user sends a message, and a node replies: "Rejected, not enough ADM to send a message". It's a final state.

E.g., a user sends a message, and there is no Internet connection or no ADM nodes available. A message will be in Pending status. If connection restores before TIMEOUT, an app sends a message. If not, switch status to Failed.

created_at: 2025-03-27T14:05:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,59 @@
+import { computed, onBeforeUnmount } from 'vue'
+import { useStore } from 'vuex'
+import { watchImmediate } from '@vueuse/core'
+import { MessageType } from '@/lib/constants'
+import { NodeStatusResult } from '@/lib/nodes/abstract.node'
+import { FileData } from '@/lib/files'
+
+type PendingMessage = {
+  recipientId: string
+  timeout: ReturnType<typeof setTimeout>
+  type: number
+  files?: FileData[]
+}
+
+export function useResendPendingMessages() {
+  const store = useStore()
+
+  const admNodes = computed<NodeStatusResult[]>(() => store.getters['nodes/adm'])
+  const ipfsNodes = computed<NodeStatusResult[]>(() => store.getters['nodes/ipfs'])
+  const admNodesOnline = computed(() => admNodes.value.some((node) => node.status === 'online'))
path: src/hooks/useResendPendingMessages.ts
user: login: dvelikiy
body: areAdmNodesOnline, areIpfsNodesOnline would be more descriptive for boolean variables
created_at: 2025-05-05T09:27:59Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,59 @@
+import { computed, onBeforeUnmount } from 'vue'
+import { useStore } from 'vuex'
+import { watchImmediate } from '@vueuse/core'
+import { MessageType } from '@/lib/constants'
+import { NodeStatusResult } from '@/lib/nodes/abstract.node'
+import { FileData } from '@/lib/files'
+
+type PendingMessage = {
+  recipientId: string
+  timeout: ReturnType<typeof setTimeout>
+  type: number
+  files?: FileData[]
+}
+
+export function useResendPendingMessages() {
+  const store = useStore()
+
+  const admNodes = computed<NodeStatusResult[]>(() => store.getters['nodes/adm'])
+  const ipfsNodes = computed<NodeStatusResult[]>(() => store.getters['nodes/ipfs'])
+  const admNodesOnline = computed(() => admNodes.value.some((node) => node.status === 'online'))
+  const ipfsNodesOnline = computed(() => ipfsNodes.value.some((node) => node.status === 'online'))
+  const pendingMessages = computed<Record<string, PendingMessage>>(
+    () => store.state.chat.pendingMessages
+  )
+
+  watchImmediate([admNodesOnline, ipfsNodesOnline], ([admOnline, ipfsOnline]) => {
+    const hasMessagesWithFiles = Object.values(pendingMessages.value).some(
+      (message) => message.files && message.files.length > 0
+    )
+
+    const isReady = hasMessagesWithFiles ? admOnline && ipfsOnline : admOnline
+
+    if (!isReady) return
+
+    Object.entries(pendingMessages.value).forEach(([messageId, message]) => {
+      ;(message.type === MessageType.BASIC_ENCRYPTED_MESSAGE
+        ? store.dispatch('chat/resendMessage', {
+            recipientId: message.recipientId,
+            messageId
+          })
+        : store.dispatch('chat/resendAttachment', {
+            recipientId: message.recipientId,
+            files: message.files,
+            messageId
+          })
+      ).then((res) => {
+        if (res.success) {
path: src/hooks/useResendPendingMessages.ts
user: login: dvelikiy
body: Only positive scenario here. Nothing special to do here in case of error?
created_at: 2025-05-05T09:38:41Z
author_association: COLLABORATOR
diff_hunk: @@ -201,21 +201,23 @@ export interface AttachmentAsset {
  * AIP-18: https://github.com/Adamant-im/AIPs/pull/54/files
  * @param {Array<FileData>} files
  * @param {string} [comment] Optional comment associated with the transaction
- * @param {Array<[string, string]>} [cids] List of files IDs after uploading to IPFS. First element is the ID of original file, second is ID of preview.
+ * @param {Array<[string]>} [cids] List of files IDs after uploading to IPFS. ID of original file and ID of preview go one by one.
path: src/lib/adamant-api/asset.ts
user: login: dvelikiy
body: "one by one" = encoded within the same string?
created_at: 2025-05-05T09:40:30Z
author_association: COLLABORATOR
diff_hunk: @@ -201,21 +201,23 @@ export interface AttachmentAsset {
  * AIP-18: https://github.com/Adamant-im/AIPs/pull/54/files
  * @param {Array<FileData>} files
  * @param {string} [comment] Optional comment associated with the transaction
- * @param {Array<[string, string]>} [cids] List of files IDs after uploading to IPFS. First element is the ID of original file, second is ID of preview.
+ * @param {Array<[string]>} [cids] List of files IDs after uploading to IPFS. ID of original file and ID of preview go one by one.
path: src/lib/adamant-api/asset.ts
user: login: dvelikiy
body: or one after another in the array?
created_at: 2025-05-05T09:41:24Z
author_association: COLLABORATOR
diff_hunk: @@ -788,12 +810,37 @@ const actions = {
         partnerId: recipientId
       })
     } catch (error) {
-      if (id) {
+      if (isAllNodesOfflineError(error)) {
+        const timeout = setTimeout(() => {
+          commit('updateMessage', {
+            id,
+            status: TS.REJECTED,
+            partnerId: recipientId
+          })
+
+          commit('deletePendingMessage', id)
+        }, +CryptosInfo.ADM.timeout.message)
path: src/store/modules/chat/index.js
user: login: dvelikiy
body: We might want to discuss using `Number()` instead of `+`
created_at: 2025-05-05T09:51:26Z
author_association: COLLABORATOR
diff_hunk: @@ -896,11 +950,37 @@ const actions = {
       })
       .catch((err) => {
         // update `message.status` to 'REJECTED'
-        commit('updateMessage', {
-          id: messageObject.id,
-          status: TS.REJECTED,
-          partnerId: recipientId
-        })
+        if (!isAllNodesOfflineError(err)) {
path: src/store/modules/chat/index.js
user: login: dvelikiy
body: maybe add a couple of comments to explain the logic
created_at: 2025-05-05T09:58:51Z
author_association: COLLABORATOR
diff_hunk: @@ -949,18 +1029,115 @@ const actions = {
 
           return res
         })
-        .catch((err) => {
+        .catch((error) => {
+          if (!isAllNodesOfflineError(error)) {
+            commit('updateMessage', {
+              messageId,
+              status: TS.REJECTED,
+              partnerId: recipientId
+            })
+
+            commit('deletePendingMessage', messageId)
+          }
+
+          throw error
+        })
+    }
+
+    return Promise.reject(new Error('Message not found in history'))
+  },
+
+  /**
+   * Resend attachment, in case the connection fails at upload or at send.
+   * @param {string} recipientId
+   * @param {number} messageId
+   * @param {FileData[]} files
+   * @returns {Promise}
+   */
+  async resendAttachment({ getters, commit }, { recipientId, messageId, files }) {
+    const message = getters.partnerMessageById(recipientId, messageId)
+    if (!message) {
+      return Promise.reject(new Error('Message not found in history'))
+    }
+
+    const cidsOld = files.map((file) => [file.cid, file.preview?.cid]).filter(Boolean)
+    for (const [cid] of cidsOld) {
+      commit('attachment/resetUploadProgress', { cid }, { root: true })
+    }
+    commit('updateMessage', {
+      id: messageId,
+      status: TS.PENDING,
+      partnerId: recipientId
+    })
+
+    let uploadData
+    try {
+      uploadData = await uploadFiles(files, (progress) => {
+        for (const [cid] of cidsOld) {
+          commit('attachment/setUploadProgress', { cid, progress }, { root: true })
+        }
+      })
+    } catch (err) {
+      for (const [cid] of cidsOld) {
+        commit('attachment/resetUploadProgress', { cid }, { root: true })
+      }
+
+      if (!isAllNodesOfflineError(err)) {
+        commit('updateMessage', {
+          id: messageId,
+          status: TS.REJECTED,
+          partnerId: recipientId
+        })
+
+        commit('deletePendingMessage', messageId)
+
+        throw err
+      }
+    }
+
+    const newAsset = message.replyToId
+      ? {
+          replyto_id: message.replyToId,
+          reply_message: attachmentAsset(files, message.message, uploadData.cids)
+        }
+      : attachmentAsset(files, message.message, uploadData.cids)
+
+    commit('updateMessage', {
+      id: messageId,
+      asset: newAsset,
+      partnerId: recipientId
+    })
+
+    for (const [cid] of cidsOld) {
+      commit('attachment/resetUploadProgress', { cid }, { root: true })
+    }
+
+    return queueMessage(newAsset, recipientId, MessageType.RICH_CONTENT_MESSAGE)
+      .then((res) => {
+        if (!res.success) {
+          throw new Error(i18n.global.t('chats.message_rejected'))
+        }
+        commit('updateMessage', {
+          id: messageId,
+          realId: res.transactionId,
+          status: TS.REGISTERED,
+          partnerId: recipientId
+        })
+        return res
+      })
+      .catch((err) => {
+        if (!isAllNodesOfflineError(err)) {
           commit('updateMessage', {
path: src/store/modules/chat/index.js
user: login: dvelikiy
body: ```
commit('updateMessage'
commit('deletePendingMessage'
```

these two mutations are often used together, we might want to add an action that combines both
`handleMessageSendFailure` ?
    
created_at: 2025-05-05T10:26:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,59 @@
+import { computed, onBeforeUnmount } from 'vue'
+import { useStore } from 'vuex'
+import { watchImmediate } from '@vueuse/core'
+import { MessageType } from '@/lib/constants'
+import { NodeStatusResult } from '@/lib/nodes/abstract.node'
+import { FileData } from '@/lib/files'
+
+type PendingMessage = {
+  recipientId: string
+  timeout: ReturnType<typeof setTimeout>
+  type: number
+  files?: FileData[]
+}
+
+export function useResendPendingMessages() {
+  const store = useStore()
+
+  const admNodes = computed<NodeStatusResult[]>(() => store.getters['nodes/adm'])
+  const ipfsNodes = computed<NodeStatusResult[]>(() => store.getters['nodes/ipfs'])
+  const admNodesOnline = computed(() => admNodes.value.some((node) => node.status === 'online'))
+  const ipfsNodesOnline = computed(() => ipfsNodes.value.some((node) => node.status === 'online'))
+  const pendingMessages = computed<Record<string, PendingMessage>>(
+    () => store.state.chat.pendingMessages
+  )
+
+  watchImmediate([admNodesOnline, ipfsNodesOnline], ([admOnline, ipfsOnline]) => {
+    const hasMessagesWithFiles = Object.values(pendingMessages.value).some(
+      (message) => message.files && message.files.length > 0
+    )
+
+    const isReady = hasMessagesWithFiles ? admOnline && ipfsOnline : admOnline
+
+    if (!isReady) return
+
+    Object.entries(pendingMessages.value).forEach(([messageId, message]) => {
+      ;(message.type === MessageType.BASIC_ENCRYPTED_MESSAGE
+        ? store.dispatch('chat/resendMessage', {
+            recipientId: message.recipientId,
+            messageId
+          })
+        : store.dispatch('chat/resendAttachment', {
+            recipientId: message.recipientId,
+            files: message.files,
+            messageId
+          })
+      ).then((res) => {
+        if (res.success) {
path: src/hooks/useResendPendingMessages.ts
user: login: skranee
body: In case there's an error with connection again, the message will just stay in pendindMessages, but theoretically speaking it's possible that user will have no ADM while trying to resend a message, so it's a good mention, will correct it
created_at: 2025-05-05T10:34:07Z
author_association: MEMBER
diff_hunk: @@ -201,21 +201,23 @@ export interface AttachmentAsset {
  * AIP-18: https://github.com/Adamant-im/AIPs/pull/54/files
  * @param {Array<FileData>} files
  * @param {string} [comment] Optional comment associated with the transaction
- * @param {Array<[string, string]>} [cids] List of files IDs after uploading to IPFS. First element is the ID of original file, second is ID of preview.
+ * @param {Array<[string]>} [cids] List of files IDs after uploading to IPFS. ID of original file and ID of preview go one by one.
path: src/lib/adamant-api/asset.ts
user: login: skranee
body: [cid, previewCid, cid2, previewCid2, ..., cidN, previewCidN]
created_at: 2025-05-05T10:35:07Z
author_association: MEMBER
diff_hunk: @@ -788,12 +810,37 @@ const actions = {
         partnerId: recipientId
       })
     } catch (error) {
-      if (id) {
+      if (isAllNodesOfflineError(error)) {
+        const timeout = setTimeout(() => {
+          commit('updateMessage', {
+            id,
+            status: TS.REJECTED,
+            partnerId: recipientId
+          })
+
+          commit('deletePendingMessage', id)
+        }, +CryptosInfo.ADM.timeout.message)
path: src/store/modules/chat/index.js
user: login: skranee
body: Readability purposes?
created_at: 2025-05-05T10:37:20Z
author_association: MEMBER
diff_hunk: @@ -949,18 +1029,115 @@ const actions = {
 
           return res
         })
-        .catch((err) => {
+        .catch((error) => {
+          if (!isAllNodesOfflineError(error)) {
+            commit('updateMessage', {
+              messageId,
+              status: TS.REJECTED,
+              partnerId: recipientId
+            })
+
+            commit('deletePendingMessage', messageId)
+          }
+
+          throw error
+        })
+    }
+
+    return Promise.reject(new Error('Message not found in history'))
+  },
+
+  /**
+   * Resend attachment, in case the connection fails at upload or at send.
+   * @param {string} recipientId
+   * @param {number} messageId
+   * @param {FileData[]} files
+   * @returns {Promise}
+   */
+  async resendAttachment({ getters, commit }, { recipientId, messageId, files }) {
+    const message = getters.partnerMessageById(recipientId, messageId)
+    if (!message) {
+      return Promise.reject(new Error('Message not found in history'))
+    }
+
+    const cidsOld = files.map((file) => [file.cid, file.preview?.cid]).filter(Boolean)
+    for (const [cid] of cidsOld) {
+      commit('attachment/resetUploadProgress', { cid }, { root: true })
+    }
+    commit('updateMessage', {
+      id: messageId,
+      status: TS.PENDING,
+      partnerId: recipientId
+    })
+
+    let uploadData
+    try {
+      uploadData = await uploadFiles(files, (progress) => {
+        for (const [cid] of cidsOld) {
+          commit('attachment/setUploadProgress', { cid, progress }, { root: true })
+        }
+      })
+    } catch (err) {
+      for (const [cid] of cidsOld) {
+        commit('attachment/resetUploadProgress', { cid }, { root: true })
+      }
+
+      if (!isAllNodesOfflineError(err)) {
+        commit('updateMessage', {
+          id: messageId,
+          status: TS.REJECTED,
+          partnerId: recipientId
+        })
+
+        commit('deletePendingMessage', messageId)
+
+        throw err
+      }
+    }
+
+    const newAsset = message.replyToId
+      ? {
+          replyto_id: message.replyToId,
+          reply_message: attachmentAsset(files, message.message, uploadData.cids)
+        }
+      : attachmentAsset(files, message.message, uploadData.cids)
+
+    commit('updateMessage', {
+      id: messageId,
+      asset: newAsset,
+      partnerId: recipientId
+    })
+
+    for (const [cid] of cidsOld) {
+      commit('attachment/resetUploadProgress', { cid }, { root: true })
+    }
+
+    return queueMessage(newAsset, recipientId, MessageType.RICH_CONTENT_MESSAGE)
+      .then((res) => {
+        if (!res.success) {
+          throw new Error(i18n.global.t('chats.message_rejected'))
+        }
+        commit('updateMessage', {
+          id: messageId,
+          realId: res.transactionId,
+          status: TS.REGISTERED,
+          partnerId: recipientId
+        })
+        return res
+      })
+      .catch((err) => {
+        if (!isAllNodesOfflineError(err)) {
           commit('updateMessage', {
path: src/store/modules/chat/index.js
user: login: skranee
body: It makes sense, but I guess if we want to, it's better to either make a combined `handleResendMessage` that will reject the message and delete it from pending ones or register it and also delete from pending depending on passed prop, or two different methods, one for success and the other for failure scenario.
created_at: 2025-05-05T10:42:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,59 @@
+import { computed, onBeforeUnmount } from 'vue'
+import { useStore } from 'vuex'
+import { watchImmediate } from '@vueuse/core'
+import { MessageType } from '@/lib/constants'
+import { NodeStatusResult } from '@/lib/nodes/abstract.node'
+import { FileData } from '@/lib/files'
+
+type PendingMessage = {
+  recipientId: string
+  timeout: ReturnType<typeof setTimeout>
+  type: number
+  files?: FileData[]
+}
+
+export function useResendPendingMessages() {
+  const store = useStore()
+
+  const admNodes = computed<NodeStatusResult[]>(() => store.getters['nodes/adm'])
+  const ipfsNodes = computed<NodeStatusResult[]>(() => store.getters['nodes/ipfs'])
+  const admNodesOnline = computed(() => admNodes.value.some((node) => node.status === 'online'))
+  const ipfsNodesOnline = computed(() => ipfsNodes.value.some((node) => node.status === 'online'))
+  const pendingMessages = computed<Record<string, PendingMessage>>(
+    () => store.state.chat.pendingMessages
+  )
+
+  watchImmediate([admNodesOnline, ipfsNodesOnline], ([admOnline, ipfsOnline]) => {
+    const hasMessagesWithFiles = Object.values(pendingMessages.value).some(
+      (message) => message.files && message.files.length > 0
+    )
+
+    const isReady = hasMessagesWithFiles ? admOnline && ipfsOnline : admOnline
+
+    if (!isReady) return
+
+    Object.entries(pendingMessages.value).forEach(([messageId, message]) => {
+      ;(message.type === MessageType.BASIC_ENCRYPTED_MESSAGE
+        ? store.dispatch('chat/resendMessage', {
+            recipientId: message.recipientId,
+            messageId
+          })
+        : store.dispatch('chat/resendAttachment', {
+            recipientId: message.recipientId,
+            files: message.files,
+            messageId
+          })
+      ).then((res) => {
+        if (res.success) {
path: src/hooks/useResendPendingMessages.ts
user: login: skranee
body: I reviewed the code and understood that the error is already handled in methods such as `resendMessage` and `resendAttachment` so there's no need in doing that inside of the hook
created_at: 2025-05-06T17:33:39Z
author_association: MEMBER
diff_hunk: @@ -788,12 +810,37 @@ const actions = {
         partnerId: recipientId
       })
     } catch (error) {
-      if (id) {
+      if (isAllNodesOfflineError(error)) {
+        const timeout = setTimeout(() => {
+          commit('updateMessage', {
+            id,
+            status: TS.REJECTED,
+            partnerId: recipientId
+          })
+
+          commit('deletePendingMessage', id)
+        }, +CryptosInfo.ADM.timeout.message)
path: src/store/modules/chat/index.js
user: login: dvelikiy
body: Yes
created_at: 2025-05-07T18:00:39Z
author_association: COLLABORATOR
diff_hunk: @@ -949,18 +1029,115 @@ const actions = {
 
           return res
         })
-        .catch((err) => {
+        .catch((error) => {
+          if (!isAllNodesOfflineError(error)) {
+            commit('updateMessage', {
+              messageId,
+              status: TS.REJECTED,
+              partnerId: recipientId
+            })
+
+            commit('deletePendingMessage', messageId)
+          }
+
+          throw error
+        })
+    }
+
+    return Promise.reject(new Error('Message not found in history'))
+  },
+
+  /**
+   * Resend attachment, in case the connection fails at upload or at send.
+   * @param {string} recipientId
+   * @param {number} messageId
+   * @param {FileData[]} files
+   * @returns {Promise}
+   */
+  async resendAttachment({ getters, commit }, { recipientId, messageId, files }) {
+    const message = getters.partnerMessageById(recipientId, messageId)
+    if (!message) {
+      return Promise.reject(new Error('Message not found in history'))
+    }
+
+    const cidsOld = files.map((file) => [file.cid, file.preview?.cid]).filter(Boolean)
+    for (const [cid] of cidsOld) {
+      commit('attachment/resetUploadProgress', { cid }, { root: true })
+    }
+    commit('updateMessage', {
+      id: messageId,
+      status: TS.PENDING,
+      partnerId: recipientId
+    })
+
+    let uploadData
+    try {
+      uploadData = await uploadFiles(files, (progress) => {
+        for (const [cid] of cidsOld) {
+          commit('attachment/setUploadProgress', { cid, progress }, { root: true })
+        }
+      })
+    } catch (err) {
+      for (const [cid] of cidsOld) {
+        commit('attachment/resetUploadProgress', { cid }, { root: true })
+      }
+
+      if (!isAllNodesOfflineError(err)) {
+        commit('updateMessage', {
+          id: messageId,
+          status: TS.REJECTED,
+          partnerId: recipientId
+        })
+
+        commit('deletePendingMessage', messageId)
+
+        throw err
+      }
+    }
+
+    const newAsset = message.replyToId
+      ? {
+          replyto_id: message.replyToId,
+          reply_message: attachmentAsset(files, message.message, uploadData.cids)
+        }
+      : attachmentAsset(files, message.message, uploadData.cids)
+
+    commit('updateMessage', {
+      id: messageId,
+      asset: newAsset,
+      partnerId: recipientId
+    })
+
+    for (const [cid] of cidsOld) {
+      commit('attachment/resetUploadProgress', { cid }, { root: true })
+    }
+
+    return queueMessage(newAsset, recipientId, MessageType.RICH_CONTENT_MESSAGE)
+      .then((res) => {
+        if (!res.success) {
+          throw new Error(i18n.global.t('chats.message_rejected'))
+        }
+        commit('updateMessage', {
+          id: messageId,
+          realId: res.transactionId,
+          status: TS.REGISTERED,
+          partnerId: recipientId
+        })
+        return res
+      })
+      .catch((err) => {
+        if (!isAllNodesOfflineError(err)) {
           commit('updateMessage', {
path: src/store/modules/chat/index.js
user: login: dvelikiy
body: A combined `handleResendMessage` sounds ok to me
created_at: 2025-05-07T18:02:37Z
author_association: COLLABORATOR
diff_hunk: @@ -949,18 +1029,115 @@ const actions = {
 
           return res
         })
-        .catch((err) => {
+        .catch((error) => {
+          if (!isAllNodesOfflineError(error)) {
+            commit('updateMessage', {
+              messageId,
+              status: TS.REJECTED,
+              partnerId: recipientId
+            })
+
+            commit('deletePendingMessage', messageId)
+          }
+
+          throw error
+        })
+    }
+
+    return Promise.reject(new Error('Message not found in history'))
+  },
+
+  /**
+   * Resend attachment, in case the connection fails at upload or at send.
+   * @param {string} recipientId
+   * @param {number} messageId
+   * @param {FileData[]} files
+   * @returns {Promise}
+   */
+  async resendAttachment({ getters, commit }, { recipientId, messageId, files }) {
+    const message = getters.partnerMessageById(recipientId, messageId)
+    if (!message) {
+      return Promise.reject(new Error('Message not found in history'))
+    }
+
+    const cidsOld = files.map((file) => [file.cid, file.preview?.cid]).filter(Boolean)
+    for (const [cid] of cidsOld) {
+      commit('attachment/resetUploadProgress', { cid }, { root: true })
+    }
+    commit('updateMessage', {
+      id: messageId,
+      status: TS.PENDING,
+      partnerId: recipientId
+    })
+
+    let uploadData
+    try {
+      uploadData = await uploadFiles(files, (progress) => {
+        for (const [cid] of cidsOld) {
+          commit('attachment/setUploadProgress', { cid, progress }, { root: true })
+        }
+      })
+    } catch (err) {
+      for (const [cid] of cidsOld) {
+        commit('attachment/resetUploadProgress', { cid }, { root: true })
+      }
+
+      if (!isAllNodesOfflineError(err)) {
+        commit('updateMessage', {
+          id: messageId,
+          status: TS.REJECTED,
+          partnerId: recipientId
+        })
+
+        commit('deletePendingMessage', messageId)
+
+        throw err
+      }
+    }
+
+    const newAsset = message.replyToId
+      ? {
+          replyto_id: message.replyToId,
+          reply_message: attachmentAsset(files, message.message, uploadData.cids)
+        }
+      : attachmentAsset(files, message.message, uploadData.cids)
+
+    commit('updateMessage', {
+      id: messageId,
+      asset: newAsset,
+      partnerId: recipientId
+    })
+
+    for (const [cid] of cidsOld) {
+      commit('attachment/resetUploadProgress', { cid }, { root: true })
+    }
+
+    return queueMessage(newAsset, recipientId, MessageType.RICH_CONTENT_MESSAGE)
+      .then((res) => {
+        if (!res.success) {
+          throw new Error(i18n.global.t('chats.message_rejected'))
+        }
+        commit('updateMessage', {
+          id: messageId,
+          realId: res.transactionId,
+          status: TS.REGISTERED,
+          partnerId: recipientId
+        })
+        return res
+      })
+      .catch((err) => {
+        if (!isAllNodesOfflineError(err)) {
           commit('updateMessage', {
path: src/store/modules/chat/index.js
user: login: skranee
body: I decided to split logic into `registerPendingMessage` and `rejectPendingMessage`. Seems more readable to me
created_at: 2025-05-08T08:55:56Z
author_association: MEMBER

PR comments:
user: login: vercel[bot]
created_at: 2025-03-27T13:29:39Z
body: [vc]: #BBkkFo7AUnJMbJmjza9MVF/RulYQslYPj1YtgZAuTrE=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJhZGFtYW50LWltIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2FkYW1hbnQtdGVhbS9hZGFtYW50LWltL0ZYMVo2UHMzNm1CdnJkTFFtV1RQc1dYcmpDR3ciLCJwcmV2aWV3VXJsIjoiYWRhbWFudC1pbS1naXQtZmVhdC1tZXNzYWdlLXNlbmRpbmctdGltZW91dHMtYWRhbWFudC10ZWFtLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiYWRhbWFudC1pbS1naXQtZmVhdC1tZXNzYWdlLXNlbmRpbmctdGltZW91dHMtYWRhbWFudC10ZWFtLnZlcmNlbC5hcHAifSwicm9vdERpcmVjdG9yeSI6bnVsbH1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **adamant-im** | ✅ Ready ([Inspect](https://vercel.com/adamant-team/adamant-im/FX1Z6Ps36mBvrdLQmWTPsWXrjCGw)) | [Visit Preview](https://adamant-im-git-feat-message-sending-timeouts-adamant-team.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/adamant-im-git-feat-message-sending-timeouts-adamant-team.vercel.app?via=pr-comment-feedback-link) | May 8, 2025 10:17am |


author_association: NONE
user: login: skranee
created_at: 2025-03-27T13:30:01Z
body: ![](https://github.trello.services/images/mini-trello-icon.png) [{Connection} Feat: Message sending timeouts (copied from iOS)](https://trello.com/c/XOLbYQPq/611-connection-feat-message-sending-timeouts-copied-from-ios)
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-03-27T13:31:35Z
body: Deployed to https://msg-adamant-pr-750.surge.sh 🚀
<!-- thollander/actions-comment-pull-request "deploy-message" -->
author_association: NONE
user: login: adamantmm
created_at: 2025-05-13T04:52:48Z
body: @dvelikiy @S-FrontendDev @bludnic 
Please review
author_association: MEMBER
user: login: adamant-al
created_at: 2025-05-16T05:21:52Z
body: @dvelikiy @bludnic
Please review
author_association: MEMBER
user: login: adamant-al
created_at: 2025-05-23T05:09:36Z
body: @skranee @S-FrontendDev @Linhead @graycraft 
Ps review and merge

author_association: MEMBER
user: login: S-FrontendDev
created_at: 2025-05-24T04:40:39Z
body: @adamant-al 2nd approve please
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-05-24T08:43:50Z
body: Successfully tore down https://msg-adamant-pr-750.surge.sh 🥲
<!-- thollander/actions-comment-pull-request "teardown-message" -->
author_association: NONE