feat: load images and audio files with retry (#160)
This commit is contained in:
parent
1969596a8e
commit
742d6d6195
@ -1,7 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||||
import BaseBubble from './Base.vue';
|
import BaseBubble from './Base.vue';
|
||||||
import Button from 'next/button/Button.vue';
|
import Button from 'next/button/Button.vue';
|
||||||
import Icon from 'next/icon/Icon.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';
|
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||||
|
|
||||||
const emit = defineEmits(['error']);
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
||||||
@ -20,14 +20,18 @@ const attachment = computed(() => {
|
|||||||
return attachments.value[0];
|
return attachments.value[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasError = ref(false);
|
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
|
||||||
|
type: 'image',
|
||||||
|
});
|
||||||
|
|
||||||
const showGallery = ref(false);
|
const showGallery = ref(false);
|
||||||
const isDownloading = ref(false);
|
const isDownloading = ref(false);
|
||||||
|
|
||||||
const handleError = () => {
|
onMounted(() => {
|
||||||
hasError.value = true;
|
if (attachment.value?.dataUrl) {
|
||||||
emit('error');
|
loadWithRetry(attachment.value.dataUrl);
|
||||||
};
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const downloadAttachment = async () => {
|
const downloadAttachment = async () => {
|
||||||
const { fileType, dataUrl, extension } = attachment.value;
|
const { fileType, dataUrl, extension } = attachment.value;
|
||||||
@ -40,6 +44,10 @@ const downloadAttachment = async () => {
|
|||||||
isDownloading.value = false;
|
isDownloading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
hasError.value = true;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -54,14 +62,12 @@ const downloadAttachment = async () => {
|
|||||||
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
|
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="relative group rounded-lg overflow-hidden">
|
<div v-else-if="isLoaded" class="relative group rounded-lg overflow-hidden">
|
||||||
<img
|
<img
|
||||||
class="skip-context-menu"
|
class="skip-context-menu"
|
||||||
:src="attachment.dataUrl"
|
:src="attachment.dataUrl"
|
||||||
:width="attachment.width"
|
:width="attachment.width"
|
||||||
:height="attachment.height"
|
:height="attachment.height"
|
||||||
@click="onClick"
|
|
||||||
@error="handleError"
|
|
||||||
/>
|
/>
|
||||||
<div
|
<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"
|
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"
|
v-model:show="showGallery"
|
||||||
:attachment="useSnakeCase(attachment)"
|
:attachment="useSnakeCase(attachment)"
|
||||||
:all-attachments="filteredCurrentChatAttachments"
|
:all-attachments="filteredCurrentChatAttachments"
|
||||||
@error="handleError"
|
@error="handleImageError"
|
||||||
@close="() => (showGallery = false)"
|
@close="() => (showGallery = false)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import {
|
|||||||
ref,
|
ref,
|
||||||
getCurrentInstance,
|
getCurrentInstance,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
|
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
|
||||||
import { downloadFile } from '@chatwoot/utils';
|
import { downloadFile } from '@chatwoot/utils';
|
||||||
@ -23,6 +25,11 @@ defineOptions({
|
|||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
|
||||||
|
type: 'audio',
|
||||||
|
});
|
||||||
|
|
||||||
const timeStampURL = computed(() => {
|
const timeStampURL = computed(() => {
|
||||||
return timeStampAppendedURL(attachment.dataUrl);
|
return timeStampAppendedURL(attachment.dataUrl);
|
||||||
});
|
});
|
||||||
@ -38,19 +45,20 @@ const playbackSpeed = ref(1);
|
|||||||
const { uid } = getCurrentInstance();
|
const { uid } = getCurrentInstance();
|
||||||
|
|
||||||
const onLoadedMetadata = () => {
|
const onLoadedMetadata = () => {
|
||||||
duration.value = audioPlayer.value?.duration;
|
if (audioPlayer.value) {
|
||||||
|
duration.value = audioPlayer.value.duration;
|
||||||
|
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const playbackSpeedLabel = computed(() => {
|
const playbackSpeedLabel = computed(() => {
|
||||||
return `${playbackSpeed.value}x`;
|
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(() => {
|
onMounted(() => {
|
||||||
duration.value = audioPlayer.value?.duration;
|
if (attachment.dataUrl) {
|
||||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
loadWithRetry(attachment.dataUrl);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for global audio play events and pause if it's not this audio
|
// Listen for global audio play events and pause if it's not this audio
|
||||||
@ -121,71 +129,83 @@ const downloadAudio = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<audio
|
|
||||||
ref="audioPlayer"
|
|
||||||
controls
|
|
||||||
class="hidden"
|
|
||||||
playsinline
|
|
||||||
@loadedmetadata="onLoadedMetadata"
|
|
||||||
@timeupdate="onTimeUpdate"
|
|
||||||
@ended="onEnd"
|
|
||||||
>
|
|
||||||
<source :src="timeStampURL" />
|
|
||||||
</audio>
|
|
||||||
<div
|
<div
|
||||||
|
v-if="hasError"
|
||||||
v-bind="$attrs"
|
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">
|
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
|
||||||
<button class="p-0 border-0 size-8" @click="playOrPause">
|
<p class="mb-0 text-n-slate-11 text-sm">
|
||||||
<Icon
|
{{ t('COMPONENTS.MEDIA.AUDIO_UNAVAILABLE') }}
|
||||||
v-if="isPlaying"
|
</p>
|
||||||
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>
|
</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>
|
</template>
|
||||||
|
|||||||
66
app/javascript/dashboard/composables/loadWithRetry.js
Normal file
66
app/javascript/dashboard/composables/loadWithRetry.js
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"MEDIA": {
|
"MEDIA": {
|
||||||
"IMAGE_UNAVAILABLE": "This image is no longer available.",
|
"IMAGE_UNAVAILABLE": "This image is no longer available.",
|
||||||
|
"AUDIO_UNAVAILABLE": "This audio is no longer available.",
|
||||||
"LOADING_FAILED": "Loading failed"
|
"LOADING_FAILED": "Loading failed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"MEDIA": {
|
"MEDIA": {
|
||||||
"IMAGE_UNAVAILABLE": "Esta imagem não está mais disponível.",
|
"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"
|
"LOADING_FAILED": "Falha no carregamento"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user