init
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import 'highlight.js/styles/github-dark.css'
|
||||
import {computed, nextTick, onMounted, ref, watch} from 'vue';
|
||||
import {useRoute} from 'vue-router';
|
||||
import {computed, nextTick, ref, watch} from 'vue';
|
||||
import {storeToRefs} from 'pinia';
|
||||
import {useMessageStore} from '@/stores/message';
|
||||
import {useUserStore} from "@/stores/user.ts";
|
||||
@@ -13,25 +12,129 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const channelId = computed(() => props.channelId);
|
||||
|
||||
const route = useRoute();
|
||||
const messageStore = useMessageStore();
|
||||
const userStore = useUserStore();
|
||||
const {renderMarkdown} = useMarkdown()
|
||||
|
||||
// Référence vers l'élément scrollable
|
||||
const messageContainer = ref<HTMLElement | null>(null);
|
||||
|
||||
// "messages" ici est une référence réactive liée au store
|
||||
const {messages, loading} = storeToRefs(messageStore);
|
||||
|
||||
const {messages, loading, loadingBefore, loadingAfter} = storeToRefs(messageStore);
|
||||
const newMessage = ref('');
|
||||
|
||||
const SCROLL_LOAD_THRESHOLD = 120;
|
||||
const SCROLL_BOTTOM_TOLERANCE = 4;
|
||||
const paginationLock = ref<'before' | 'after' | null>(null);
|
||||
const paginationLockScrollTop = ref(0);
|
||||
const lastScrollTop = ref(0);
|
||||
|
||||
interface ScrollAnchor {
|
||||
id: string;
|
||||
top: number;
|
||||
}
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
if (messageContainer.value) {
|
||||
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
|
||||
messageStore.setAtBottom(true);
|
||||
}
|
||||
};
|
||||
|
||||
const getMessageElements = () => Array.from(
|
||||
messageContainer.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? [],
|
||||
);
|
||||
|
||||
const captureAnchor = (edge: 'top' | 'bottom'): ScrollAnchor | null => {
|
||||
const container = messageContainer.value;
|
||||
if (!container) return null;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const visible = getMessageElements().filter(element => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
|
||||
});
|
||||
const element = edge === 'top' ? visible[0] : visible[visible.length - 1];
|
||||
if (!element?.dataset.messageId) return null;
|
||||
|
||||
return {
|
||||
id: element.dataset.messageId,
|
||||
top: element.getBoundingClientRect().top,
|
||||
};
|
||||
};
|
||||
|
||||
const restoreAnchor = async (anchor: ScrollAnchor | null) => {
|
||||
if (!anchor || !messageContainer.value) return;
|
||||
await nextTick();
|
||||
|
||||
const element = getMessageElements().find(item => item.dataset.messageId === anchor.id);
|
||||
if (element) {
|
||||
messageContainer.value.scrollTop += element.getBoundingClientRect().top - anchor.top;
|
||||
}
|
||||
};
|
||||
|
||||
const updateScrollState = () => {
|
||||
const container = messageContainer.value;
|
||||
if (!container) return;
|
||||
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
messageStore.setAtBottom(distanceFromBottom <= SCROLL_BOTTOM_TOLERANCE);
|
||||
};
|
||||
|
||||
const setPaginationLock = (direction: 'before' | 'after') => {
|
||||
if (!messageContainer.value) return;
|
||||
paginationLock.value = direction;
|
||||
paginationLockScrollTop.value = messageContainer.value.scrollTop;
|
||||
lastScrollTop.value = messageContainer.value.scrollTop;
|
||||
};
|
||||
|
||||
const loadBefore = async () => {
|
||||
const anchor = captureAnchor('top');
|
||||
const change = await messageStore.fetchBefore(channelId.value);
|
||||
if (change) {
|
||||
await restoreAnchor(anchor);
|
||||
setPaginationLock('before');
|
||||
}
|
||||
};
|
||||
|
||||
const loadAfter = async () => {
|
||||
const anchor = captureAnchor('bottom');
|
||||
const change = await messageStore.fetchAfter(channelId.value);
|
||||
if (change) {
|
||||
await restoreAnchor(anchor);
|
||||
setPaginationLock('after');
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = async () => {
|
||||
const container = messageContainer.value;
|
||||
if (!container) return;
|
||||
|
||||
const currentScrollTop = container.scrollTop;
|
||||
const scrollDelta = currentScrollTop - lastScrollTop.value;
|
||||
lastScrollTop.value = currentScrollTop;
|
||||
|
||||
if (paginationLock.value === 'after') {
|
||||
if (scrollDelta < -1 || currentScrollTop > paginationLockScrollTop.value + 2) {
|
||||
paginationLock.value = null;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else if (paginationLock.value === 'before') {
|
||||
if (scrollDelta > 1 || currentScrollTop < paginationLockScrollTop.value - 2) {
|
||||
paginationLock.value = null;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
updateScrollState();
|
||||
|
||||
if (container.scrollTop <= SCROLL_LOAD_THRESHOLD && !loadingBefore.value) {
|
||||
await loadBefore();
|
||||
} else {
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
if (distanceFromBottom <= SCROLL_LOAD_THRESHOLD && !loadingAfter.value) {
|
||||
await loadAfter();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -39,47 +142,53 @@ const sendMessage = async () => {
|
||||
if (!newMessage.value.trim()) return;
|
||||
|
||||
const content = newMessage.value;
|
||||
const wasAtBottom = messageStore.isAtBottom;
|
||||
|
||||
try {
|
||||
await messageStore.sendMessage(channelId.value, content);
|
||||
newMessage.value = ''; // On vide le champ après succès
|
||||
await scrollToBottom();
|
||||
newMessage.value = '';
|
||||
if (wasAtBottom) await scrollToBottom();
|
||||
} catch (e) {
|
||||
// Gérer l'erreur (ex: notification toast)
|
||||
console.error('Erreur lors de l\'envoi du message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (channelId.value) {
|
||||
messageStore.fetchMessages(channelId.value);
|
||||
// Only explicit initial loads and realtime messages received while at the
|
||||
// bottom request an automatic scroll. Pagination restores its own anchor.
|
||||
watch(messages, async () => {
|
||||
if (messageStore.consumeScrollToBottomRequest()) {
|
||||
await scrollToBottom();
|
||||
}
|
||||
});
|
||||
}, {deep: true, flush: 'post'});
|
||||
|
||||
watch(channelId, (newChannelId) => {
|
||||
watch(channelId, async (newChannelId) => {
|
||||
if (newChannelId) {
|
||||
messageStore.fetchMessages(newChannelId);
|
||||
await messageStore.fetchMessages(newChannelId);
|
||||
}
|
||||
}, {immediate: true})
|
||||
|
||||
// Scroll automatique quand la liste des messages change (nouveaux messages reçus)
|
||||
watch(messages, () => {
|
||||
scrollToBottom();
|
||||
}, {deep: true});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Conteneur principal prenant toute la hauteur -->
|
||||
<v-container class="pa-0 fill-height d-flex flex-column" fluid>
|
||||
|
||||
<!-- Zone des messages (scrollable) -->
|
||||
<div
|
||||
ref="messageContainer"
|
||||
class="flex-grow-1 overflow-y-auto w-100 message-container"
|
||||
@scroll.passive="handleScroll"
|
||||
>
|
||||
<v-progress-linear v-if="loadingBefore" color="primary" indeterminate />
|
||||
|
||||
<v-progress-circular
|
||||
v-if="loading && !messages.length"
|
||||
class="d-block mx-auto mt-4"
|
||||
color="primary"
|
||||
indeterminate
|
||||
/>
|
||||
|
||||
<v-list bg-color="transparent" lines="three">
|
||||
<v-list-item
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:data-message-id="msg.id"
|
||||
class="px-4 py-1"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
@@ -98,9 +207,10 @@ watch(messages, () => {
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-progress-linear v-if="loadingAfter" color="primary" indeterminate />
|
||||
</div>
|
||||
|
||||
<!-- Zone de saisie fixe en bas -->
|
||||
<v-sheet class="pa-4 flex-shrink-0" width="100%">
|
||||
<v-textarea
|
||||
v-model="newMessage"
|
||||
@@ -126,13 +236,11 @@ watch(messages, () => {
|
||||
</template>
|
||||
</v-textarea>
|
||||
</v-sheet>
|
||||
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-container {
|
||||
/* Assure que la zone gère son scroll indépendamment */
|
||||
height: 0;
|
||||
}
|
||||
|
||||
@@ -155,4 +263,4 @@ watch(messages, () => {
|
||||
margin: 8px 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+257
-41
@@ -2,7 +2,12 @@ import {defineStore} from "pinia";
|
||||
import {useApi} from "@/composables/useApi.ts";
|
||||
import {onGatewayEvent} from "@/plugins/events.ts";
|
||||
|
||||
interface Message {
|
||||
// Change this value to adjust the maximum number of messages kept in the DOM.
|
||||
// Directional loads automatically use half of this window.
|
||||
export const MESSAGE_WINDOW_SIZE = 50;
|
||||
export const MESSAGE_SHIFT_SIZE = Math.max(1, Math.floor(MESSAGE_WINDOW_SIZE / 2));
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
user_id: string;
|
||||
@@ -12,78 +17,289 @@ interface Message {
|
||||
reply_to_id: string | null;
|
||||
}
|
||||
|
||||
interface MessagePage {
|
||||
messages: Message[];
|
||||
oldest_id: string | null;
|
||||
newest_id: string | null;
|
||||
has_more_before: boolean;
|
||||
has_more_after: boolean;
|
||||
}
|
||||
|
||||
interface WindowChange {
|
||||
addedIds: string[];
|
||||
removedIds: string[];
|
||||
}
|
||||
|
||||
function compareMessages(left: Message, right: Message): number {
|
||||
if (left.id < right.id) return -1;
|
||||
if (left.id > right.id) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function mergeMessages(messages: Message[]): Message[] {
|
||||
const byId = new Map<string, Message>();
|
||||
for (const message of messages) {
|
||||
byId.set(message.id, message);
|
||||
}
|
||||
return Array.from(byId.values()).sort(compareMessages);
|
||||
}
|
||||
|
||||
async function requestPage(
|
||||
channelId: string,
|
||||
params: { limit: number; before_id?: string; after_id?: string },
|
||||
): Promise<MessagePage> {
|
||||
const query = new URLSearchParams({
|
||||
channel_id: channelId,
|
||||
limit: String(params.limit),
|
||||
});
|
||||
|
||||
if (params.before_id) query.set("before_id", params.before_id);
|
||||
if (params.after_id) query.set("after_id", params.after_id);
|
||||
|
||||
const response = await useApi().get(`/messages?${query.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Message loading failed (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<MessagePage>;
|
||||
}
|
||||
|
||||
export const useMessageStore = defineStore("message", {
|
||||
state: () => ({
|
||||
messages: [] as Message[],
|
||||
activeChannelId: null as string | null,
|
||||
oldestId: null as string | null,
|
||||
newestId: null as string | null,
|
||||
hasMoreBefore: false,
|
||||
hasMoreAfter: false,
|
||||
loading: false,
|
||||
loadingBefore: false,
|
||||
loadingAfter: false,
|
||||
isAtBottom: true,
|
||||
scrollToBottomRequested: false,
|
||||
requestVersion: 0,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchMessages(channel_id: string) {
|
||||
updateBoundaries(page: MessagePage) {
|
||||
this.oldestId = page.oldest_id ?? this.messages[0]?.id ?? null;
|
||||
this.newestId = page.newest_id ?? this.messages[this.messages.length - 1]?.id ?? null;
|
||||
this.hasMoreBefore = page.has_more_before;
|
||||
this.hasMoreAfter = page.has_more_after;
|
||||
},
|
||||
|
||||
updateLocalBoundaries() {
|
||||
this.oldestId = this.messages[0]?.id ?? null;
|
||||
this.newestId = this.messages[this.messages.length - 1]?.id ?? null;
|
||||
},
|
||||
|
||||
async fetchMessages(channelId: string) {
|
||||
const requestVersion = ++this.requestVersion;
|
||||
this.activeChannelId = channelId;
|
||||
this.messages = [];
|
||||
this.oldestId = null;
|
||||
this.newestId = null;
|
||||
this.hasMoreBefore = false;
|
||||
this.hasMoreAfter = false;
|
||||
this.isAtBottom = true;
|
||||
this.loading = true;
|
||||
|
||||
// Query params
|
||||
let params = new URLSearchParams();
|
||||
params.append("channel_id", channel_id);
|
||||
const queryString = params.toString();
|
||||
|
||||
try {
|
||||
const api = useApi();
|
||||
// Utilisation du paramètre pour cibler le channel
|
||||
const response = await api.get(`/messages${queryString ? `?${queryString}` : ""}`);
|
||||
this.messages = await response.json();
|
||||
const page = await requestPage(channelId, {limit: MESSAGE_WINDOW_SIZE});
|
||||
if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return;
|
||||
|
||||
this.messages = mergeMessages(page.messages).slice(-MESSAGE_WINDOW_SIZE);
|
||||
this.updateBoundaries(page);
|
||||
this.scrollToBottomRequested = true;
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du chargement des messages:", error);
|
||||
if (requestVersion === this.requestVersion) {
|
||||
console.error("Erreur lors du chargement des messages:", error);
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
if (requestVersion === this.requestVersion) {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
async sendMessage(channelId: string, content: string) {
|
||||
const api = useApi();
|
||||
console.log("channelId", channelId);
|
||||
try {
|
||||
// Envoi au serveur pour persistance
|
||||
const response = await api.post('/messages', {
|
||||
channel_id: channelId,
|
||||
content: content,
|
||||
reply_to_id: null
|
||||
});
|
||||
const newMessage = await response.json();
|
||||
|
||||
// Ajout local immédiat (optimistic update)
|
||||
// this.messages.push(newMessage);
|
||||
async fetchBefore(channelId: string): Promise<WindowChange | null> {
|
||||
if (
|
||||
this.activeChannelId !== channelId ||
|
||||
!this.oldestId ||
|
||||
!this.hasMoreBefore ||
|
||||
this.loadingBefore ||
|
||||
this.loadingAfter
|
||||
) return null;
|
||||
|
||||
const requestVersion = this.requestVersion;
|
||||
const previousIds = new Set(this.messages.map(message => message.id));
|
||||
this.loadingBefore = true;
|
||||
|
||||
try {
|
||||
const page = await requestPage(channelId, {
|
||||
limit: MESSAGE_SHIFT_SIZE,
|
||||
before_id: this.oldestId,
|
||||
});
|
||||
if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return null;
|
||||
|
||||
const incoming = mergeMessages(page.messages);
|
||||
const merged = mergeMessages([...incoming, ...this.messages]);
|
||||
this.messages = merged.slice(0, MESSAGE_WINDOW_SIZE);
|
||||
this.updateBoundaries(page);
|
||||
this.oldestId = this.messages[0]?.id ?? null;
|
||||
this.newestId = this.messages[this.messages.length - 1]?.id ?? null;
|
||||
|
||||
return {
|
||||
addedIds: incoming.filter(message => !previousIds.has(message.id)).map(message => message.id),
|
||||
removedIds: merged.slice(0, -MESSAGE_WINDOW_SIZE).map(message => message.id),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du chargement des messages précédents:", error);
|
||||
return null;
|
||||
} finally {
|
||||
if (requestVersion === this.requestVersion) {
|
||||
this.loadingBefore = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async fetchAfter(channelId: string): Promise<WindowChange | null> {
|
||||
if (
|
||||
this.activeChannelId !== channelId ||
|
||||
!this.newestId ||
|
||||
!this.hasMoreAfter ||
|
||||
this.loadingBefore ||
|
||||
this.loadingAfter
|
||||
) return null;
|
||||
|
||||
const requestVersion = this.requestVersion;
|
||||
const previousIds = new Set(this.messages.map(message => message.id));
|
||||
this.loadingAfter = true;
|
||||
|
||||
try {
|
||||
const page = await requestPage(channelId, {
|
||||
limit: MESSAGE_SHIFT_SIZE,
|
||||
after_id: this.newestId,
|
||||
});
|
||||
if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return null;
|
||||
|
||||
const incoming = mergeMessages(page.messages);
|
||||
const merged = mergeMessages([...this.messages, ...incoming]);
|
||||
this.messages = merged.slice(-MESSAGE_WINDOW_SIZE);
|
||||
this.updateBoundaries(page);
|
||||
this.oldestId = this.messages[0]?.id ?? null;
|
||||
this.newestId = this.messages[this.messages.length - 1]?.id ?? null;
|
||||
|
||||
return {
|
||||
addedIds: incoming.filter(message => !previousIds.has(message.id)).map(message => message.id),
|
||||
removedIds: merged.slice(0, Math.max(0, merged.length - MESSAGE_WINDOW_SIZE)).map(message => message.id),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du chargement des messages suivants:", error);
|
||||
return null;
|
||||
} finally {
|
||||
if (requestVersion === this.requestVersion) {
|
||||
this.loadingAfter = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async sendMessage(channelId: string, content: string) {
|
||||
try {
|
||||
const response = await useApi().post("/messages", {
|
||||
channel_id: channelId,
|
||||
content,
|
||||
reply_to_id: null,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Message sending failed (${response.status})`);
|
||||
}
|
||||
|
||||
const newMessage = await response.json() as Message;
|
||||
this.addRealtimeMessage(newMessage);
|
||||
return newMessage;
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de l'envoi du message:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
addRealtimeMessage(message: Message) {
|
||||
if (message.channel_id !== this.activeChannelId) return;
|
||||
|
||||
const existingIndex = this.messages.findIndex(current => current.id === message.id);
|
||||
if (existingIndex !== -1) {
|
||||
this.messages[existingIndex] = message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isAtBottom && this.newestId && message.id > this.newestId) {
|
||||
this.hasMoreAfter = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.messages = mergeMessages([...this.messages, message]).slice(-MESSAGE_WINDOW_SIZE);
|
||||
this.updateLocalBoundaries();
|
||||
this.hasMoreAfter = false;
|
||||
if (this.isAtBottom) {
|
||||
this.scrollToBottomRequested = true;
|
||||
}
|
||||
},
|
||||
|
||||
updateMessage(message: Message) {
|
||||
if (message.channel_id !== this.activeChannelId) return;
|
||||
const index = this.messages.findIndex(current => current.id === message.id);
|
||||
if (index !== -1) this.messages[index] = message;
|
||||
},
|
||||
|
||||
removeMessage(id: string) {
|
||||
const index = this.messages.findIndex(message => message.id === id);
|
||||
if (index === -1) return;
|
||||
this.messages.splice(index, 1);
|
||||
this.updateLocalBoundaries();
|
||||
},
|
||||
|
||||
setAtBottom(value: boolean) {
|
||||
this.isAtBottom = value;
|
||||
},
|
||||
|
||||
consumeScrollToBottomRequest() {
|
||||
const requested = this.scrollToBottomRequested;
|
||||
this.scrollToBottomRequested = false;
|
||||
return requested;
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.requestVersion += 1;
|
||||
this.messages = [];
|
||||
}
|
||||
}
|
||||
this.activeChannelId = null;
|
||||
this.oldestId = null;
|
||||
this.newestId = null;
|
||||
this.hasMoreBefore = false;
|
||||
this.hasMoreAfter = false;
|
||||
this.loading = false;
|
||||
this.loadingBefore = false;
|
||||
this.loadingAfter = false;
|
||||
this.isAtBottom = true;
|
||||
this.scrollToBottomRequested = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
onGatewayEvent("Message", (payload) => {
|
||||
const store = useMessageStore();
|
||||
|
||||
switch (payload.action) {
|
||||
case "add":
|
||||
const exists = store.messages.some(m => m.id === payload.content.id);
|
||||
if (!exists) {
|
||||
store.messages.push(payload.content);
|
||||
}
|
||||
store.addRealtimeMessage(payload.content as Message);
|
||||
break;
|
||||
case "update":
|
||||
const updateIndex = store.messages.findIndex(m => m.id === payload.content.id);
|
||||
if (updateIndex !== -1) {
|
||||
store.messages[updateIndex] = payload.content;
|
||||
}
|
||||
store.updateMessage(payload.content as Message);
|
||||
break;
|
||||
case "remove":
|
||||
const removeIndex = store.messages.findIndex(m => m.id === payload.content);
|
||||
if (removeIndex !== -1) {
|
||||
store.messages.splice(removeIndex, 1);
|
||||
}
|
||||
store.removeMessage(String(payload.content));
|
||||
break;
|
||||
default:
|
||||
console.warn("Action non gérée :", payload.action);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user