diff --git a/.gitignore b/.gitignore index 074abff..fe47fef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target /.idea -*.db* \ No newline at end of file +*.db* +/media/* \ No newline at end of file diff --git a/frontend/src/pages/server/channel/index.vue b/frontend/src/pages/server/channel/index.vue index d51cce8..7b54d08 100644 --- a/frontend/src/pages/server/channel/index.vue +++ b/frontend/src/pages/server/channel/index.vue @@ -2,7 +2,7 @@ import 'highlight.js/styles/github-dark.css' import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue'; import {storeToRefs} from 'pinia'; -import {useMessageStore} from '@/stores/message'; +import {useMessageStore, type Attachment} from '@/stores/message'; import {useServerStore} from '@/stores/server'; import {useUserStore} from "@/stores/user.ts"; import {useMarkdown} from '@/composables/useMarkdown' @@ -32,6 +32,27 @@ const {renderMarkdown} = useMarkdown() const messageContainer = ref(null); const {messages, loading, loadingBefore, loadingAfter, hasMoreAfter, isAtBottom, newestId} = storeToRefs(messageStore); const newMessage = ref(''); +const fileInput = ref(null); +type PendingAttachment = { + id: string; + file: File; + filename: string; + mimeType: string; + previewUrl: string | null; + progress: number | null; + status: 'uploading' | 'uploaded' | 'error'; + attachment: Attachment | null; + error: string | null; + abortController: AbortController | null; +}; + +const pendingAttachments = ref([]); +const sendingMessage = ref(false); +const hasUploadingAttachments = computed(() => pendingAttachments.value.some(item => item.status === 'uploading')); +const hasFailedAttachments = computed(() => pendingAttachments.value.some(item => item.status === 'error')); +const uploadedAttachmentIds = computed(() => pendingAttachments.value + .filter(item => item.status === 'uploaded' && item.attachment) + .map(item => item.attachment!.id)); const SCROLL_LOAD_THRESHOLD = 120; const SCROLL_BOTTOM_TOLERANCE = 4; @@ -199,20 +220,119 @@ const handleScroll = async () => { }; const sendMessage = async () => { - if (!newMessage.value.trim()) return; + if (!newMessage.value.trim() && !uploadedAttachmentIds.value.length) return; + if (hasUploadingAttachments.value || hasFailedAttachments.value) return; const content = newMessage.value; const wasAtBottom = messageStore.isAtBottom; try { - await messageStore.sendMessage(channelId.value, content); + sendingMessage.value = true; + await messageStore.sendMessage(channelId.value, content, uploadedAttachmentIds.value); newMessage.value = ''; + clearPendingAttachments(); if (wasAtBottom) await scrollToBottom(); } catch (e) { console.error('Erreur lors de l\'envoi du message:', e); + } finally { + sendingMessage.value = false; } }; +const openFilePicker = () => fileInput.value?.click(); + +const findPendingAttachment = (id: string) => + pendingAttachments.value.find(item => item.id === id); + +const uploadPendingAttachment = async (pending: PendingAttachment, targetChannelId: string) => { + const current = findPendingAttachment(pending.id); + if (!current) return; + current.abortController?.abort(); + const controller = new AbortController(); + current.abortController = controller; + current.status = 'uploading'; + current.progress = 0; + current.error = null; + current.attachment = null; + try { + const attachment = await messageStore.uploadAttachment( + targetChannelId, + current.file, + progress => { + const reactivePending = findPendingAttachment(pending.id); + if (reactivePending?.abortController === controller) reactivePending.progress = progress; + }, + controller.signal, + ); + const reactivePending = findPendingAttachment(pending.id); + if (reactivePending?.abortController !== controller) return; + reactivePending.attachment = attachment; + reactivePending.progress = 100; + reactivePending.status = 'uploaded'; + } catch (error) { + const reactivePending = findPendingAttachment(pending.id); + if (reactivePending?.abortController !== controller) return; + reactivePending.abortController = null; + if (error instanceof DOMException && error.name === 'AbortError') return; + reactivePending.status = 'error'; + reactivePending.error = error instanceof Error ? error.message : 'Échec de l\'upload'; + } finally { + const reactivePending = findPendingAttachment(pending.id); + if (reactivePending?.abortController === controller) reactivePending.abortController = null; + } +}; + +const handleFileSelection = async (event: Event) => { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + input.value = ''; + if (!files.length) return; + const targetChannelId = channelId.value; + const pending = files.map((file, index): PendingAttachment => ({ + id: `${file.name}-${file.lastModified}-${index}-${crypto.randomUUID()}`, + file, + filename: file.name, + mimeType: file.type || 'application/octet-stream', + previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null, + progress: 0, + status: 'uploading', + attachment: null, + error: null, + abortController: null, + })); + pendingAttachments.value.push(...pending); + await Promise.all(pending.map(item => uploadPendingAttachment(item, targetChannelId))); +}; + +const removeAttachment = (id: string) => { + const index = pendingAttachments.value.findIndex(item => item.id === id); + if (index === -1) return; + const [removed] = pendingAttachments.value.splice(index, 1); + removed.abortController?.abort(); + if (removed.previewUrl) URL.revokeObjectURL(removed.previewUrl); +}; + +const retryAttachment = (pending: PendingAttachment) => { + void uploadPendingAttachment(pending, channelId.value); +}; + +const clearPendingAttachments = () => { + const pendingAttachmentsToClear = pendingAttachments.value; + pendingAttachments.value = []; + for (const pending of pendingAttachmentsToClear) { + pending.abortController?.abort(); + if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl); + } +}; + +const isImageAttachment = (attachment: Attachment) => attachment.mime_type.startsWith('image/'); +const isImagePending = (attachment: PendingAttachment) => attachment.mimeType.startsWith('image/'); +const formatFileSize = (size: number) => { + if (size < 1024) return `${size} o`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} Ko`; + return `${(size / (1024 * 1024)).toFixed(1)} Mo`; +}; + const reactionKey = (messageId: string, emojiId: string, skinTone: number | null) => `${messageId}:${emojiId}:${skinTone ?? 0}`; @@ -256,6 +376,7 @@ onMounted(() => { onUnmounted(() => { stopReloadAll?.(); + clearPendingAttachments(); }); // Only explicit initial loads and realtime messages received while at the @@ -272,7 +393,8 @@ watch(isAtBottom, async (atBottom) => { } }); -watch(channelId, async (newChannelId) => { +watch(channelId, async (newChannelId, previousChannelId) => { + if (previousChannelId && previousChannelId !== newChannelId) clearPendingAttachments(); if (newChannelId) { await emojiStore.fetchEmojis(props.serverId ?? null); await messageStore.fetchMessages(newChannelId); @@ -330,6 +452,23 @@ watch(() => props.serverId, (newServerId) => {
+ +
props.serverId, (newServerId) => {
+ +
+
+ + +
+ {{ attachment.filename }} +
+ + + + {{ attachment.progress === null ? 'Envoi en cours…' : `${attachment.progress} %` }} + +
+ Envoyé + {{ attachment.error }} +
+ + +
+
props.serverId, (newServerId) => { rounded="lg" rows="1" variant="solo-filled" + :disabled="hasUploadingAttachments || hasFailedAttachments || sendingMessage" @keydown.enter.exact.prevent="sendMessage" >