feat: load images and audio files with retry (#160)

This commit is contained in:
Gabriel Jablonski 2025-11-30 19:14:41 -03:00 committed by GitHub
parent 1969596a8e
commit 742d6d6195
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 174 additions and 80 deletions

View File

@ -1,7 +1,8 @@
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
import BaseBubble from './Base.vue';
import Button from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue';
@ -11,7 +12,6 @@ import { downloadFile } from '@chatwoot/utils';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
const emit = defineEmits(['error']);
const { t } = useI18n();
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
@ -20,14 +20,18 @@ const attachment = computed(() => {
return attachments.value[0];
});
const hasError = ref(false);
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
type: 'image',
});
const showGallery = ref(false);
const isDownloading = ref(false);
const handleError = () => {
hasError.value = true;
emit('error');
};
onMounted(() => {
if (attachment.value?.dataUrl) {
loadWithRetry(attachment.value.dataUrl);
}
});
const downloadAttachment = async () => {
const { fileType, dataUrl, extension } = attachment.value;
@ -40,6 +44,10 @@ const downloadAttachment = async () => {
isDownloading.value = false;
}
};
const handleImageError = () => {
hasError.value = true;
};
</script>
<template>
@ -54,14 +62,12 @@ const downloadAttachment = async () => {
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
</p>
</div>
<div v-else class="relative group rounded-lg overflow-hidden">
<div v-else-if="isLoaded" class="relative group rounded-lg overflow-hidden">
<img
class="skip-context-menu"
:src="attachment.dataUrl"
:width="attachment.width"
:height="attachment.height"
@click="onClick"
@error="handleError"
/>
<div
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"
@ -86,7 +92,7 @@ const downloadAttachment = async () => {
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@error="handleError"
@error="handleImageError"
@close="() => (showGallery = false)"
/>
</template>

View File

@ -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 () => {
</script>
<template>
<audio
ref="audioPlayer"
controls
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
>
<source :src="timeStampURL" />
</audio>
<div
v-if="hasError"
v-bind="$attrs"
class="rounded-xl w-full gap-2 p-1.5 bg-n-alpha-white flex flex-col items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
class="flex items-center gap-1 text-center rounded-lg p-2 bg-n-alpha-white border border-n-container"
>
<div class="flex gap-1 w-full flex-1 items-center justify-start">
<button class="p-0 border-0 size-8" @click="playOrPause">
<Icon
v-if="isPlaying"
class="size-8"
icon="i-teenyicons-pause-small-solid"
/>
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
</button>
<div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div>
<div
v-if="attachment.transcribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
>
{{ attachment.transcribedText }}
</div>
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
<p class="mb-0 text-n-slate-11 text-sm">
{{ t('COMPONENTS.MEDIA.AUDIO_UNAVAILABLE') }}
</p>
</div>
<template v-else-if="isLoaded">
<audio
ref="audioPlayer"
controls
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
>
<source :src="timeStampURL" />
</audio>
<div
v-bind="$attrs"
class="rounded-xl w-full gap-2 p-1.5 bg-n-alpha-white flex flex-col items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
>
<div class="flex gap-1 w-full flex-1 items-center justify-start">
<button class="p-0 border-0 size-8" @click="playOrPause">
<Icon
v-if="isPlaying"
class="size-8"
icon="i-teenyicons-pause-small-solid"
/>
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
</button>
<div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div>
<div
v-if="attachment.transcribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
>
{{ attachment.transcribedText }}
</div>
</div>
</template>
</template>

View File

@ -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,
};
};

View File

@ -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"
}
},

View File

@ -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"
}
},