init
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
Reference in New Issue
Block a user