From 742d6d61959ebd7240bae0afb2f36df0f6f91561 Mon Sep 17 00:00:00 2001 From: Gabriel Jablonski Date: Sun, 30 Nov 2025 19:14:41 -0300 Subject: [PATCH] feat: load images and audio files with retry (#160) --- .../components-next/message/bubbles/Image.vue | 28 ++-- .../components-next/message/chips/Audio.vue | 158 ++++++++++-------- .../dashboard/composables/loadWithRetry.js | 66 ++++++++ .../dashboard/i18n/locale/en/settings.json | 1 + .../dashboard/i18n/locale/pt_BR/settings.json | 1 + 5 files changed, 174 insertions(+), 80 deletions(-) create mode 100644 app/javascript/dashboard/composables/loadWithRetry.js diff --git a/app/javascript/dashboard/components-next/message/bubbles/Image.vue b/app/javascript/dashboard/components-next/message/bubbles/Image.vue index 2484bb06c..9a17846a7 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Image.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Image.vue @@ -1,7 +1,8 @@ diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue index 667d2d7b6..c9b13091f 100644 --- a/app/javascript/dashboard/components-next/message/chips/Audio.vue +++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue @@ -6,6 +6,8 @@ import { ref, getCurrentInstance, } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry'; import Icon from 'next/icon/Icon.vue'; import { timeStampAppendedURL } from 'dashboard/helper/URLHelper'; import { downloadFile } from '@chatwoot/utils'; @@ -23,6 +25,11 @@ defineOptions({ inheritAttrs: false, }); +const { t } = useI18n(); +const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({ + type: 'audio', +}); + const timeStampURL = computed(() => { return timeStampAppendedURL(attachment.dataUrl); }); @@ -38,19 +45,20 @@ const playbackSpeed = ref(1); const { uid } = getCurrentInstance(); const onLoadedMetadata = () => { - duration.value = audioPlayer.value?.duration; + if (audioPlayer.value) { + duration.value = audioPlayer.value.duration; + audioPlayer.value.playbackRate = playbackSpeed.value; + } }; const playbackSpeedLabel = computed(() => { return `${playbackSpeed.value}x`; }); -// There maybe a chance that the audioPlayer ref is not available -// When the onLoadMetadata is called, so we need to set the duration -// value when the component is mounted onMounted(() => { - duration.value = audioPlayer.value?.duration; - audioPlayer.value.playbackRate = playbackSpeed.value; + if (attachment.dataUrl) { + loadWithRetry(attachment.dataUrl); + } }); // Listen for global audio play events and pause if it's not this audio @@ -121,71 +129,83 @@ const downloadAudio = async () => { diff --git a/app/javascript/dashboard/composables/loadWithRetry.js b/app/javascript/dashboard/composables/loadWithRetry.js new file mode 100644 index 000000000..574eef33c --- /dev/null +++ b/app/javascript/dashboard/composables/loadWithRetry.js @@ -0,0 +1,66 @@ +import { ref } from 'vue'; + +export const useLoadWithRetry = (config = {}) => { + const maxRetry = config.maxRetry || 3; + const backoff = config.backoff || 1000; + const type = config.type || ''; + + const isLoaded = ref(false); + const hasError = ref(false); + + const loadWithRetry = async url => { + const attemptLoad = async () => { + return new Promise((resolve, reject) => { + let media; + if (type === 'image') { + media = new Image(); + media.onload = () => resolve(); + media.onerror = () => reject(new Error('Failed to load image')); + } else if (type === 'audio') { + media = new Audio(); + media.onloadedmetadata = () => resolve(); + media.onerror = () => reject(new Error('Failed to load audio')); + } else { + fetch(url) + .then(res => { + if (res.ok) resolve(); + else reject(new Error('Failed to load resource')); + }) + .catch(err => reject(err)); + return; + } + media.src = url; + }); + }; + + const sleep = ms => { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); + }; + + const retry = async (attempt = 0) => { + try { + await attemptLoad(); + hasError.value = false; + isLoaded.value = true; + } catch (error) { + if (attempt + 1 >= maxRetry) { + hasError.value = true; + isLoaded.value = false; + return; + } + await sleep(backoff * (attempt + 1)); + await retry(attempt + 1); + } + }; + + await retry(); + }; + + return { + isLoaded, + hasError, + loadWithRetry, + }; +}; diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 1e810c50e..078ccba35 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -302,6 +302,7 @@ }, "MEDIA": { "IMAGE_UNAVAILABLE": "This image is no longer available.", + "AUDIO_UNAVAILABLE": "This audio is no longer available.", "LOADING_FAILED": "Loading failed" } }, diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json index f2510c80a..368c1cd7c 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json @@ -302,6 +302,7 @@ }, "MEDIA": { "IMAGE_UNAVAILABLE": "Esta imagem não está mais disponível.", + "AUDIO_UNAVAILABLE": "Este áudio não está mais disponível.", "LOADING_FAILED": "Falha no carregamento" } },