This commit is contained in:
2026-08-23 16:02:17 +02:00
parent 8d57310ec6
commit 5e97e9c223
26 changed files with 818 additions and 77 deletions
+268 -5
View File
@@ -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<HTMLElement | null>(null);
const {messages, loading, loadingBefore, loadingAfter, hasMoreAfter, isAtBottom, newestId} = storeToRefs(messageStore);
const newMessage = ref('');
const fileInput = ref<HTMLInputElement | null>(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<PendingAttachment[]>([]);
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) => {
<div class="markdown-content" v-html="renderMarkdown(msg.content)"></div>
</div>
<div v-if="msg.attachments?.length" class="attachments-grid mt-2">
<a
v-for="attachment in msg.attachments"
:key="attachment.id"
class="attachment-card"
:class="{ 'attachment-image': isImageAttachment(attachment) }"
:href="attachment.url"
:download="isImageAttachment(attachment) ? undefined : attachment.filename"
target="_blank"
>
<img v-if="isImageAttachment(attachment)" :src="attachment.url" :alt="attachment.filename" />
<v-icon v-else icon="mdi-file-download-outline" size="28" />
<span class="attachment-name">{{ attachment.filename }}</span>
<span class="text-caption text-grey">{{ formatFileSize(attachment.file_size) }}</span>
</a>
</div>
<div class="d-flex flex-wrap align-center ga-1 mt-2">
<v-chip
v-for="reaction in msg.reactions"
@@ -375,6 +514,39 @@ watch(() => props.serverId, (newServerId) => {
</div>
<v-sheet class="pa-4 flex-shrink-0" width="100%">
<input ref="fileInput" accept="*/*" class="d-none" multiple type="file" @change="handleFileSelection" />
<div v-if="pendingAttachments.length" class="selected-attachments mb-2">
<div v-for="attachment in pendingAttachments" :key="attachment.id" class="selected-attachment">
<img v-if="isImagePending(attachment)" :src="attachment.previewUrl ?? attachment.attachment?.url" :alt="attachment.filename" />
<v-icon v-else icon="mdi-file-outline" />
<div class="selected-attachment-details">
<span class="text-truncate" :title="attachment.filename">{{ attachment.filename }}</span>
<div v-if="attachment.status === 'uploading'" class="attachment-progress">
<v-progress-linear
v-if="attachment.progress !== null"
:model-value="attachment.progress"
color="primary"
height="6"
rounded
/>
<v-progress-linear v-else color="primary" height="6" indeterminate rounded />
<span class="text-caption text-medium-emphasis">
{{ attachment.progress === null ? 'Envoi en cours…' : `${attachment.progress} %` }}
</span>
</div>
<span v-else-if="attachment.status === 'uploaded'" class="text-caption text-success">Envoyé</span>
<span v-else class="text-caption text-error">{{ attachment.error }}</span>
</div>
<v-btn
v-if="attachment.status === 'error'"
icon="mdi-refresh"
size="x-small"
variant="text"
@click="retryAttachment(attachment)"
/>
<v-btn icon="mdi-close" size="x-small" variant="text" @click="removeAttachment(attachment.id)" />
</div>
</div>
<v-textarea
v-model="newMessage"
auto-grow
@@ -387,10 +559,11 @@ watch(() => props.serverId, (newServerId) => {
rounded="lg"
rows="1"
variant="solo-filled"
:disabled="hasUploadingAttachments || hasFailedAttachments || sendingMessage"
@keydown.enter.exact.prevent="sendMessage"
>
<template v-slot:prepend-inner>
<v-btn color="grey-darken-1" density="compact" icon="mdi-plus-circle" variant="text"></v-btn>
<v-btn :disabled="sendingMessage" color="grey-darken-1" density="compact" icon="mdi-plus-circle" variant="text" @click="openFilePicker"></v-btn>
</template>
<template v-slot:append-inner>
<v-btn color="grey-darken-1" density="compact" icon="mdi-gift" variant="text"></v-btn>
@@ -458,4 +631,94 @@ watch(() => props.serverId, (newServerId) => {
height: 18px;
object-fit: contain;
}
.attachments-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.attachment-card {
display: flex;
width: 220px;
min-height: 56px;
align-items: center;
gap: 8px;
padding: 8px;
color: inherit;
text-decoration: none;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 8px;
}
.attachment-card.attachment-image {
display: block;
width: auto;
max-width: min(420px, 100%);
padding: 0;
overflow: hidden;
}
.attachment-card img {
display: block;
max-width: 100%;
max-height: 360px;
object-fit: contain;
}
.attachment-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.selected-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.selected-attachment {
display: flex;
max-width: 240px;
min-width: 0;
align-items: center;
gap: 6px;
padding: 4px 6px;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 6px;
}
.selected-attachment-details {
min-width: 0;
flex: 1;
}
.selected-attachment-details > .text-truncate {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-progress {
display: flex;
align-items: center;
gap: 6px;
}
.attachment-progress .v-progress-linear {
min-width: 80px;
flex: 1;
}
.selected-attachment img {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 4px;
}
</style>
+77 -1
View File
@@ -22,6 +22,20 @@ export interface Message {
updated_at: string | null;
reply_to_id: string | null;
reactions: ReactionGroup[];
attachments: Attachment[];
}
export interface Attachment {
id: string;
filename: string;
file_size: number;
mime_type: string;
created_at: string;
url: string;
}
export interface AttachmentUploadResponse {
attachments: Attachment[];
}
export interface ReactionGroup {
@@ -242,12 +256,74 @@ export const useMessageStore = defineStore("message", {
}
},
async sendMessage(channelId: string, content: string) {
uploadAttachment(
channelId: string,
file: File,
onProgress?: (progress: number | null) => void,
signal?: AbortSignal,
): Promise<Attachment> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
let abortHandler: (() => void) | null = null;
const cleanup = () => {
if (abortHandler && signal) signal.removeEventListener('abort', abortHandler);
abortHandler = null;
};
if (signal?.aborted) {
reject(new DOMException('Attachment upload aborted', 'AbortError'));
return;
}
xhr.open('POST', '/api/attachments');
xhr.withCredentials = true;
xhr.upload.addEventListener('progress', event => {
onProgress?.(event.lengthComputable ? Math.round((event.loaded / event.total) * 100) : null);
});
xhr.addEventListener('load', () => {
cleanup();
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(`Attachment upload failed (${xhr.status})`));
return;
}
try {
const payload = JSON.parse(xhr.responseText) as AttachmentUploadResponse;
const attachment = payload.attachments?.[0];
if (!attachment) throw new Error('Attachment upload returned no attachment');
resolve(attachment);
} catch (error) {
reject(error);
}
});
xhr.addEventListener('error', () => {
cleanup();
reject(new Error('Attachment upload failed'));
});
xhr.addEventListener('abort', () => {
cleanup();
reject(new DOMException('Attachment upload aborted', 'AbortError'));
});
if (signal) {
abortHandler = () => xhr.abort();
signal.addEventListener('abort', abortHandler, {once: true});
}
const form = new FormData();
form.append('channel_id', channelId);
form.append('files', file, file.name);
onProgress?.(0);
xhr.send(form);
});
},
async sendMessage(channelId: string, content: string, fileIds: string[] = []) {
try {
const response = await useApi().post("/messages", {
channel_id: channelId,
content,
reply_to_id: null,
file_ids: fileIds,
});
if (!response.ok) {
throw new Error(`Message sending failed (${response.status})`);