init
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import 'highlight.js/styles/github-dark.css'
|
import 'highlight.js/styles/github-dark.css'
|
||||||
import {computed, nextTick, onMounted, ref, watch} from 'vue';
|
import {computed, nextTick, ref, watch} from 'vue';
|
||||||
import {useRoute} from 'vue-router';
|
|
||||||
import {storeToRefs} from 'pinia';
|
import {storeToRefs} from 'pinia';
|
||||||
import {useMessageStore} from '@/stores/message';
|
import {useMessageStore} from '@/stores/message';
|
||||||
import {useUserStore} from "@/stores/user.ts";
|
import {useUserStore} from "@/stores/user.ts";
|
||||||
@@ -13,25 +12,129 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const channelId = computed(() => props.channelId);
|
const channelId = computed(() => props.channelId);
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const messageStore = useMessageStore();
|
const messageStore = useMessageStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const {renderMarkdown} = useMarkdown()
|
const {renderMarkdown} = useMarkdown()
|
||||||
|
|
||||||
// Référence vers l'élément scrollable
|
|
||||||
const messageContainer = ref<HTMLElement | null>(null);
|
const messageContainer = ref<HTMLElement | null>(null);
|
||||||
|
const {messages, loading, loadingBefore, loadingAfter} = storeToRefs(messageStore);
|
||||||
// "messages" ici est une référence réactive liée au store
|
|
||||||
const {messages, loading} = storeToRefs(messageStore);
|
|
||||||
|
|
||||||
const newMessage = ref('');
|
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 () => {
|
const scrollToBottom = async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
if (messageContainer.value) {
|
if (messageContainer.value) {
|
||||||
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
|
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;
|
if (!newMessage.value.trim()) return;
|
||||||
|
|
||||||
const content = newMessage.value;
|
const content = newMessage.value;
|
||||||
|
const wasAtBottom = messageStore.isAtBottom;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await messageStore.sendMessage(channelId.value, content);
|
await messageStore.sendMessage(channelId.value, content);
|
||||||
newMessage.value = ''; // On vide le champ après succès
|
newMessage.value = '';
|
||||||
await scrollToBottom();
|
if (wasAtBottom) await scrollToBottom();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Gérer l'erreur (ex: notification toast)
|
console.error('Erreur lors de l\'envoi du message:', e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
// Only explicit initial loads and realtime messages received while at the
|
||||||
if (channelId.value) {
|
// bottom request an automatic scroll. Pagination restores its own anchor.
|
||||||
messageStore.fetchMessages(channelId.value);
|
watch(messages, async () => {
|
||||||
|
if (messageStore.consumeScrollToBottomRequest()) {
|
||||||
|
await scrollToBottom();
|
||||||
}
|
}
|
||||||
});
|
}, {deep: true, flush: 'post'});
|
||||||
|
|
||||||
watch(channelId, (newChannelId) => {
|
watch(channelId, async (newChannelId) => {
|
||||||
if (newChannelId) {
|
if (newChannelId) {
|
||||||
messageStore.fetchMessages(newChannelId);
|
await messageStore.fetchMessages(newChannelId);
|
||||||
}
|
}
|
||||||
}, {immediate: true})
|
}, {immediate: true})
|
||||||
|
|
||||||
// Scroll automatique quand la liste des messages change (nouveaux messages reçus)
|
|
||||||
watch(messages, () => {
|
|
||||||
scrollToBottom();
|
|
||||||
}, {deep: true});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Conteneur principal prenant toute la hauteur -->
|
|
||||||
<v-container class="pa-0 fill-height d-flex flex-column" fluid>
|
<v-container class="pa-0 fill-height d-flex flex-column" fluid>
|
||||||
|
|
||||||
<!-- Zone des messages (scrollable) -->
|
|
||||||
<div
|
<div
|
||||||
ref="messageContainer"
|
ref="messageContainer"
|
||||||
class="flex-grow-1 overflow-y-auto w-100 message-container"
|
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 bg-color="transparent" lines="three">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="msg in messages"
|
v-for="msg in messages"
|
||||||
:key="msg.id"
|
:key="msg.id"
|
||||||
|
:data-message-id="msg.id"
|
||||||
class="px-4 py-1"
|
class="px-4 py-1"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
@@ -98,9 +207,10 @@ watch(messages, () => {
|
|||||||
</div>
|
</div>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
|
|
||||||
|
<v-progress-linear v-if="loadingAfter" color="primary" indeterminate />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zone de saisie fixe en bas -->
|
|
||||||
<v-sheet class="pa-4 flex-shrink-0" width="100%">
|
<v-sheet class="pa-4 flex-shrink-0" width="100%">
|
||||||
<v-textarea
|
<v-textarea
|
||||||
v-model="newMessage"
|
v-model="newMessage"
|
||||||
@@ -126,13 +236,11 @@ watch(messages, () => {
|
|||||||
</template>
|
</template>
|
||||||
</v-textarea>
|
</v-textarea>
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
|
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.message-container {
|
.message-container {
|
||||||
/* Assure que la zone gère son scroll indépendamment */
|
|
||||||
height: 0;
|
height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+255
-39
@@ -2,7 +2,12 @@ import {defineStore} from "pinia";
|
|||||||
import {useApi} from "@/composables/useApi.ts";
|
import {useApi} from "@/composables/useApi.ts";
|
||||||
import {onGatewayEvent} from "@/plugins/events.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;
|
id: string;
|
||||||
channel_id: string;
|
channel_id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -12,78 +17,289 @@ interface Message {
|
|||||||
reply_to_id: string | null;
|
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", {
|
export const useMessageStore = defineStore("message", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
messages: [] as Message[],
|
messages: [] as Message[],
|
||||||
|
activeChannelId: null as string | null,
|
||||||
|
oldestId: null as string | null,
|
||||||
|
newestId: null as string | null,
|
||||||
|
hasMoreBefore: false,
|
||||||
|
hasMoreAfter: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
loadingBefore: false,
|
||||||
|
loadingAfter: false,
|
||||||
|
isAtBottom: true,
|
||||||
|
scrollToBottomRequested: false,
|
||||||
|
requestVersion: 0,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
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;
|
this.loading = true;
|
||||||
|
|
||||||
// Query params
|
|
||||||
let params = new URLSearchParams();
|
|
||||||
params.append("channel_id", channel_id);
|
|
||||||
const queryString = params.toString();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = useApi();
|
const page = await requestPage(channelId, {limit: MESSAGE_WINDOW_SIZE});
|
||||||
// Utilisation du paramètre pour cibler le channel
|
if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return;
|
||||||
const response = await api.get(`/messages${queryString ? `?${queryString}` : ""}`);
|
|
||||||
this.messages = await response.json();
|
this.messages = mergeMessages(page.messages).slice(-MESSAGE_WINDOW_SIZE);
|
||||||
|
this.updateBoundaries(page);
|
||||||
|
this.scrollToBottomRequested = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestVersion === this.requestVersion) {
|
||||||
console.error("Erreur lors du chargement des messages:", error);
|
console.error("Erreur lors du chargement des messages:", error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (requestVersion === this.requestVersion) {
|
||||||
this.loading = false;
|
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)
|
async fetchBefore(channelId: string): Promise<WindowChange | null> {
|
||||||
// this.messages.push(newMessage);
|
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) {
|
} catch (error) {
|
||||||
console.error("Erreur lors de l'envoi du message:", error);
|
console.error("Erreur lors de l'envoi du message:", error);
|
||||||
throw 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() {
|
reset() {
|
||||||
|
this.requestVersion += 1;
|
||||||
this.messages = [];
|
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) => {
|
onGatewayEvent("Message", (payload) => {
|
||||||
const store = useMessageStore();
|
const store = useMessageStore();
|
||||||
|
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case "add":
|
case "add":
|
||||||
const exists = store.messages.some(m => m.id === payload.content.id);
|
store.addRealtimeMessage(payload.content as Message);
|
||||||
if (!exists) {
|
|
||||||
store.messages.push(payload.content);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case "update":
|
case "update":
|
||||||
const updateIndex = store.messages.findIndex(m => m.id === payload.content.id);
|
store.updateMessage(payload.content as Message);
|
||||||
if (updateIndex !== -1) {
|
|
||||||
store.messages[updateIndex] = payload.content;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case "remove":
|
case "remove":
|
||||||
const removeIndex = store.messages.findIndex(m => m.id === payload.content);
|
store.removeMessage(String(payload.content));
|
||||||
if (removeIndex !== -1) {
|
|
||||||
store.messages.splice(removeIndex, 1);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn("Action non gérée :", payload.action);
|
console.warn("Action non gérée :", payload.action);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
pub use sea_orm_migration::prelude::*;
|
pub use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
mod m20220101_000001_create_table;
|
mod m20220101_000001_create_table;
|
||||||
|
mod m20260808_000002_add_message_channel_id_id_index;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl MigratorTrait for Migrator {
|
impl MigratorTrait for Migrator {
|
||||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||||
vec![Box::new(m20220101_000001_create_table::Migration)]
|
vec![
|
||||||
|
Box::new(m20220101_000001_create_table::Migration),
|
||||||
|
Box::new(m20260808_000002_add_message_channel_id_id_index::Migration),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_message_channel_id_id")
|
||||||
|
.table(Alias::new("message"))
|
||||||
|
.col(Alias::new("channel_id"))
|
||||||
|
.col(Alias::new("id"))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.name("idx_message_channel_id_id")
|
||||||
|
.table(Alias::new("message"))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate test messages directly in the project's SQLite database."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import random
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
WORD_POOL = (
|
||||||
|
"message", "canal", "serveur", "utilisateur", "test", "donnee", "histoire",
|
||||||
|
"discussion", "contenu", "generation", "curseur", "fenetre", "lecture",
|
||||||
|
"chargement", "conversation", "exemple", "texte", "systeme", "application",
|
||||||
|
"client", "serveur", "base", "requete", "resultat", "information", "session",
|
||||||
|
"connexion", "fonction", "version", "contenu", "rapide", "simple", "aleatoire",
|
||||||
|
"important", "nouveau", "ancien", "prochain", "precedent", "visible", "local",
|
||||||
|
"distant", "stable", "chronologique", "variable", "longueur", "performance",
|
||||||
|
"validation", "operation", "transaction", "historique", "position", "defilement",
|
||||||
|
)
|
||||||
|
MESSAGE_MARKER_FORMAT = "[{number:04d}]"
|
||||||
|
|
||||||
|
|
||||||
|
def positive_int(value: str) -> int:
|
||||||
|
parsed = int(value)
|
||||||
|
if parsed <= 0:
|
||||||
|
raise argparse.ArgumentTypeError("must be greater than zero")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def parse_uuid(value: str, option_name: str) -> uuid.UUID:
|
||||||
|
try:
|
||||||
|
return uuid.UUID(value)
|
||||||
|
except ValueError as error:
|
||||||
|
raise argparse.ArgumentTypeError(f"{option_name} is not a valid UUID: {value}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def next_uuid(previous: uuid.UUID | None) -> uuid.UUID:
|
||||||
|
"""Return a UUID v7 strictly greater than the previous generated ID."""
|
||||||
|
generated = uuid.uuid7()
|
||||||
|
if previous is not None and generated.int <= previous.int:
|
||||||
|
generated = uuid.UUID(int=previous.int + 1)
|
||||||
|
return generated
|
||||||
|
|
||||||
|
|
||||||
|
def random_message(
|
||||||
|
rng: random.Random,
|
||||||
|
min_words: int,
|
||||||
|
max_words: int,
|
||||||
|
marker: str,
|
||||||
|
) -> str:
|
||||||
|
# The marker itself counts as one word in the requested range.
|
||||||
|
body_count = rng.randint(max(0, min_words - 1), max_words - 1)
|
||||||
|
body = " ".join(rng.choices(WORD_POOL, k=body_count))
|
||||||
|
return f"{marker} {body}".rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--db",
|
||||||
|
type=Path,
|
||||||
|
default=Path("oxspeak.db"),
|
||||||
|
help="SQLite database path (default: oxspeak.db)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--channel-id", required=True, help="target channel UUID")
|
||||||
|
parser.add_argument("--user-id", required=True, help="author user UUID")
|
||||||
|
parser.add_argument(
|
||||||
|
"--count",
|
||||||
|
required=True,
|
||||||
|
type=positive_int,
|
||||||
|
help="number of messages to insert",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-words",
|
||||||
|
type=positive_int,
|
||||||
|
default=10,
|
||||||
|
help="minimum number of words per message (default: 10)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-words",
|
||||||
|
type=positive_int,
|
||||||
|
default=500,
|
||||||
|
help="maximum number of words per message (default: 500)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--seed",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="optional seed to reproduce generated contents",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--batch-size",
|
||||||
|
type=positive_int,
|
||||||
|
default=500,
|
||||||
|
help="number of rows inserted per batch (default: 500)",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_target_exists(
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
table: str,
|
||||||
|
identifier: bytes,
|
||||||
|
label: str,
|
||||||
|
) -> None:
|
||||||
|
row = connection.execute(
|
||||||
|
f'SELECT 1 FROM "{table}" WHERE id = ? LIMIT 1',
|
||||||
|
(identifier,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ValueError(f"{label} does not exist in the database")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_messages(
|
||||||
|
database: Path,
|
||||||
|
channel_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
count: int,
|
||||||
|
batch_size: int,
|
||||||
|
min_words: int,
|
||||||
|
max_words: int,
|
||||||
|
seed: int | None,
|
||||||
|
) -> None:
|
||||||
|
started_at = time.monotonic()
|
||||||
|
connection = sqlite3.connect(database)
|
||||||
|
connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
connection.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ensure_target_exists(connection, "channel", channel_id.bytes, "channel")
|
||||||
|
ensure_target_exists(connection, "user", user_id.bytes, "user")
|
||||||
|
|
||||||
|
previous_id: uuid.UUID | None = None
|
||||||
|
inserted = 0
|
||||||
|
rng = random.Random(seed)
|
||||||
|
|
||||||
|
connection.execute("BEGIN")
|
||||||
|
try:
|
||||||
|
while inserted < count:
|
||||||
|
current_batch_size = min(batch_size, count - inserted)
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for offset in range(current_batch_size):
|
||||||
|
message_id = next_uuid(previous_id)
|
||||||
|
previous_id = message_id
|
||||||
|
message_number = inserted + offset + 1
|
||||||
|
marker = MESSAGE_MARKER_FORMAT.format(number=message_number)
|
||||||
|
content = random_message(rng, min_words, max_words, marker)
|
||||||
|
created_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
message_id.bytes,
|
||||||
|
channel_id.bytes,
|
||||||
|
user_id.bytes,
|
||||||
|
content,
|
||||||
|
created_at,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO message
|
||||||
|
(id, channel_id, user_id, content, created_at, updated_at, reply_to_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
inserted += current_batch_size
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - started_at
|
||||||
|
print(f"Inserted {count} messages into {database} in {elapsed:.2f}s")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.max_words < args.min_words:
|
||||||
|
parser.error("--max-words must be greater than or equal to --min-words")
|
||||||
|
|
||||||
|
try:
|
||||||
|
channel_id = parse_uuid(args.channel_id, "--channel-id")
|
||||||
|
user_id = parse_uuid(args.user_id, "--user-id")
|
||||||
|
generate_messages(
|
||||||
|
database=args.db,
|
||||||
|
channel_id=channel_id,
|
||||||
|
user_id=user_id,
|
||||||
|
count=args.count,
|
||||||
|
batch_size=args.batch_size,
|
||||||
|
min_words=args.min_words,
|
||||||
|
max_words=args.max_words,
|
||||||
|
seed=args.seed,
|
||||||
|
)
|
||||||
|
except (OSError, sqlite3.Error, ValueError) as error:
|
||||||
|
parser.error(str(error))
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+12
-12
@@ -14,6 +14,15 @@ pub struct MessageResponse {
|
|||||||
pub reply_to_id: Option<Uuid>,
|
pub reply_to_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct MessagePageResponse {
|
||||||
|
pub messages: Vec<MessageResponse>,
|
||||||
|
pub oldest_id: Option<Uuid>,
|
||||||
|
pub newest_id: Option<Uuid>,
|
||||||
|
pub has_more_before: bool,
|
||||||
|
pub has_more_after: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct CreateMessageRequest {
|
pub struct CreateMessageRequest {
|
||||||
pub channel_id: Uuid,
|
pub channel_id: Uuid,
|
||||||
@@ -26,19 +35,10 @@ pub struct UpdateMessageRequest {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize, utoipa::IntoParams)]
|
#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
|
||||||
pub struct MessageQueryParams {
|
pub struct MessageQueryParams {
|
||||||
pub channel_id: Option<uuid::Uuid>,
|
pub channel_id: Uuid,
|
||||||
pub before_id: Option<Uuid>,
|
pub before_id: Option<Uuid>,
|
||||||
|
pub after_id: Option<Uuid>,
|
||||||
pub limit: Option<u64>,
|
pub limit: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MessageQueryParams {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
channel_id: None,
|
|
||||||
before_id: None,
|
|
||||||
limit: Some(50),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
use super::types::MessageFilter;
|
use super::types::MessageFilter;
|
||||||
use crate::models::{channel, message};
|
use crate::models::message;
|
||||||
use crate::repositories::{AnyResult, RepositoryContext};
|
use crate::repositories::{AnyResult, RepositoryContext};
|
||||||
use event_bus::Scope;
|
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const DEFAULT_MESSAGE_LIMIT: u64 = 20;
|
||||||
|
pub const MAX_MESSAGE_LIMIT: u64 = 100;
|
||||||
|
|
||||||
|
pub struct MessagePage {
|
||||||
|
pub messages: Vec<message::Model>,
|
||||||
|
pub has_more_before: bool,
|
||||||
|
pub has_more_after: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct MessageRepository {
|
pub struct MessageRepository {
|
||||||
@@ -21,7 +30,11 @@ impl MessageRepository {
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn filter(&self, filter: MessageFilter) -> AnyResult<Vec<message::Model>> {
|
pub async fn filter(&self, filter: MessageFilter) -> AnyResult<MessagePage> {
|
||||||
|
let limit = filter
|
||||||
|
.limit
|
||||||
|
.unwrap_or(DEFAULT_MESSAGE_LIMIT)
|
||||||
|
.clamp(1, MAX_MESSAGE_LIMIT);
|
||||||
let mut query = message::Entity::find();
|
let mut query = message::Entity::find();
|
||||||
|
|
||||||
if let Some(channel_id) = filter.channel_id {
|
if let Some(channel_id) = filter.channel_id {
|
||||||
@@ -32,11 +45,80 @@ impl MessageRepository {
|
|||||||
query = query.filter(message::Column::Id.lt(before_id));
|
query = query.filter(message::Column::Id.lt(before_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(limit) = filter.limit {
|
if let Some(after_id) = filter.after_id {
|
||||||
query = query.order_by_desc(message::Column::Id).limit(limit);
|
query = query
|
||||||
|
.filter(message::Column::Id.gt(after_id))
|
||||||
|
.order_by_asc(message::Column::Id);
|
||||||
|
} else {
|
||||||
|
query = query.order_by_desc(message::Column::Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(query.all(&self.context.db).await?)
|
let mut messages = query.limit(limit + 1).all(&self.context.db).await?;
|
||||||
|
let has_more_in_direction = messages.len() > limit as usize;
|
||||||
|
messages.truncate(limit as usize);
|
||||||
|
|
||||||
|
// Queries that walk backwards are executed in descending order so the
|
||||||
|
// database can stop as soon as it has found the requested rows. The UI
|
||||||
|
// always receives chronological order.
|
||||||
|
if filter.after_id.is_none() {
|
||||||
|
messages.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (has_more_before, has_more_after) = if filter.after_id.is_some() {
|
||||||
|
let has_messages_before = self
|
||||||
|
.exists_on_or_before(filter.channel_id, filter.after_id.unwrap())
|
||||||
|
.await?;
|
||||||
|
(has_messages_before, has_more_in_direction)
|
||||||
|
} else if filter.before_id.is_some() {
|
||||||
|
let has_messages_after = self
|
||||||
|
.exists_on_or_after(filter.channel_id, filter.before_id.unwrap())
|
||||||
|
.await?;
|
||||||
|
(has_more_in_direction, has_messages_after)
|
||||||
|
} else {
|
||||||
|
(has_more_in_direction, false)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(MessagePage {
|
||||||
|
messages,
|
||||||
|
has_more_before,
|
||||||
|
has_more_after,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exists_on_or_before(&self, channel_id: Option<Uuid>, id: Uuid) -> AnyResult<bool> {
|
||||||
|
let mut query = message::Entity::find()
|
||||||
|
.select_only()
|
||||||
|
.column(message::Column::Id)
|
||||||
|
.filter(message::Column::Id.lte(id));
|
||||||
|
|
||||||
|
if let Some(channel_id) = channel_id {
|
||||||
|
query = query.filter(message::Column::ChannelId.eq(channel_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(query
|
||||||
|
.limit(1)
|
||||||
|
.into_tuple::<Uuid>()
|
||||||
|
.one(&self.context.db)
|
||||||
|
.await?
|
||||||
|
.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exists_on_or_after(&self, channel_id: Option<Uuid>, id: Uuid) -> AnyResult<bool> {
|
||||||
|
let mut query = message::Entity::find()
|
||||||
|
.select_only()
|
||||||
|
.column(message::Column::Id)
|
||||||
|
.filter(message::Column::Id.gte(id));
|
||||||
|
|
||||||
|
if let Some(channel_id) = channel_id {
|
||||||
|
query = query.filter(message::Column::ChannelId.eq(channel_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(query
|
||||||
|
.limit(1)
|
||||||
|
.into_tuple::<Uuid>()
|
||||||
|
.one(&self.context.db)
|
||||||
|
.await?
|
||||||
|
.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_by_channel(&self, channel_id: uuid::Uuid) -> AnyResult<Vec<message::Model>> {
|
pub async fn get_by_channel(&self, channel_id: uuid::Uuid) -> AnyResult<Vec<message::Model>> {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub struct ServerTree {
|
|||||||
pub struct MessageFilter {
|
pub struct MessageFilter {
|
||||||
pub channel_id: Option<uuid::Uuid>,
|
pub channel_id: Option<uuid::Uuid>,
|
||||||
pub before_id: Option<uuid::Uuid>,
|
pub before_id: Option<uuid::Uuid>,
|
||||||
|
pub after_id: Option<uuid::Uuid>,
|
||||||
pub limit: Option<u64>,
|
pub limit: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
use crate::domain::dto::message::{CreateMessageRequest, MessageQueryParams, MessageResponse, UpdateMessageRequest};
|
|
||||||
use crate::core::state::AppState;
|
use crate::core::state::AppState;
|
||||||
|
use crate::domain::dto::message::{
|
||||||
|
CreateMessageRequest, MessagePageResponse, MessageQueryParams, MessageResponse,
|
||||||
|
UpdateMessageRequest,
|
||||||
|
};
|
||||||
use crate::http::context::CurrentUser;
|
use crate::http::context::CurrentUser;
|
||||||
use crate::http::error::HTTPError;
|
use crate::http::error::HTTPError;
|
||||||
use crate::routes::message::mapper;
|
use crate::routes::message::mapper;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Liste tous les messages
|
/// Liste une fenêtre paginée de messages
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/messages",
|
path = "/messages",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Liste des messages récupérée avec succès", body = [MessageResponse]),
|
(status = 200, description = "Fenêtre de messages récupérée avec succès", body = MessagePageResponse),
|
||||||
|
(status = 400, description = "Curseurs incompatibles ou canal manquant"),
|
||||||
(status = 500, description = "Erreur interne du serveur")
|
(status = 500, description = "Erreur interne du serveur")
|
||||||
),
|
),
|
||||||
params(
|
params(
|
||||||
@@ -26,15 +30,29 @@ use uuid::Uuid;
|
|||||||
pub async fn get_all(
|
pub async fn get_all(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(filters): Query<MessageQueryParams>,
|
Query(filters): Query<MessageQueryParams>,
|
||||||
) -> Result<Json<Vec<MessageResponse>>, HTTPError> {
|
) -> Result<Json<MessagePageResponse>, HTTPError> {
|
||||||
|
if filters.before_id.is_some() && filters.after_id.is_some() {
|
||||||
|
return Err(HTTPError::BadRequest(
|
||||||
|
"before_id and after_id cannot be used together".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let params = mapper::query_params_to_message_filter(filters);
|
let params = mapper::query_params_to_message_filter(filters);
|
||||||
let messages = state.repositories.message.filter(params).await?;
|
let page = state.repositories.message.filter(params).await?;
|
||||||
Ok(Json(
|
let oldest_id = page.messages.first().map(|message| message.id);
|
||||||
messages
|
let newest_id = page.messages.last().map(|message| message.id);
|
||||||
|
|
||||||
|
Ok(Json(MessagePageResponse {
|
||||||
|
messages: page
|
||||||
|
.messages
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(mapper::message_model_to_message_response)
|
.map(mapper::message_model_to_message_response)
|
||||||
.collect(),
|
.collect(),
|
||||||
))
|
oldest_id,
|
||||||
|
newest_id,
|
||||||
|
has_more_before: page.has_more_before,
|
||||||
|
has_more_after: page.has_more_after,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère un message par son ID
|
/// Récupère un message par son ID
|
||||||
@@ -105,7 +123,11 @@ pub async fn create(
|
|||||||
))?;
|
))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = state.services.message.create_message(payload.channel_id, user.id, payload.content).await?;
|
let message = state
|
||||||
|
.services
|
||||||
|
.message
|
||||||
|
.create_message(payload.channel_id, user.id, payload.content)
|
||||||
|
.await?;
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(mapper::message_model_to_message_response(message)),
|
Json(mapper::message_model_to_message_response(message)),
|
||||||
@@ -150,7 +172,11 @@ pub async fn update(
|
|||||||
return Err(HTTPError::Forbidden);
|
return Err(HTTPError::Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = state.services.message.update_message(id, payload.content).await?;
|
let message = state
|
||||||
|
.services
|
||||||
|
.message
|
||||||
|
.update_message(id, payload.content)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Json(mapper::message_model_to_message_response(message)))
|
Ok(Json(mapper::message_model_to_message_response(message)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use crate::models::message;
|
|
||||||
use crate::repositories::types::MessageFilter;
|
|
||||||
use crate::domain::dto::message::{
|
use crate::domain::dto::message::{
|
||||||
CreateMessageRequest, MessageQueryParams, MessageResponse, UpdateMessageRequest,
|
CreateMessageRequest, MessageQueryParams, MessageResponse, UpdateMessageRequest,
|
||||||
};
|
};
|
||||||
|
use crate::models::message;
|
||||||
|
use crate::repositories::types::MessageFilter;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sea_orm::Set;
|
use sea_orm::Set;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -48,8 +48,9 @@ pub fn update_request_to_am(
|
|||||||
|
|
||||||
pub fn query_params_to_message_filter(params: MessageQueryParams) -> MessageFilter {
|
pub fn query_params_to_message_filter(params: MessageQueryParams) -> MessageFilter {
|
||||||
MessageFilter {
|
MessageFilter {
|
||||||
channel_id: params.channel_id,
|
channel_id: Some(params.channel_id),
|
||||||
before_id: params.before_id,
|
before_id: params.before_id,
|
||||||
|
after_id: params.after_id,
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user