Files
oxspeak_server/frontend/src/pages/server/channel/index.vue
T
2026-08-22 20:34:43 +02:00

462 lines
16 KiB
Vue

<script lang="ts" setup>
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 {useServerStore} from '@/stores/server';
import {useUserStore} from "@/stores/user.ts";
import {useMarkdown} from '@/composables/useMarkdown'
import {onReloadAll} from '@/plugins/events.ts'
import EmojiPicker from '@/components/EmojiPicker.vue'
import {useEmojiStore, type Emoji} from '@/stores/emoji.ts'
import {useAuthStore} from '@/stores/auth.ts'
import {useConversationStore} from '@/stores/conversation'
import {useRoute, useRouter} from 'vue-router'
const props = defineProps<{
serverId?: string
channelId: string
}>();
const channelId = computed(() => props.channelId);
const messageStore = useMessageStore();
const serverStore = useServerStore();
const userStore = useUserStore();
const emojiStore = useEmojiStore();
const authStore = useAuthStore();
const conversationStore = useConversationStore()
const route = useRoute()
const router = useRouter()
const {renderMarkdown} = useMarkdown()
const messageContainer = ref<HTMLElement | null>(null);
const {messages, loading, loadingBefore, loadingAfter, hasMoreAfter, isAtBottom, newestId} = 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);
const markedMessageByChannel = new Map<string, string>();
const reactionPending = ref(new Set<string>());
const isConversation = computed(() => route.name === 'home-conversation')
const addParticipantDialog = ref(false)
const selectedParticipantIds = ref<string[]>([])
const addingParticipants = ref(false)
const currentConversation = computed(() => conversationStore.conversations.find(item => item.id === channelId.value))
const availableParticipants = computed(() => userStore.users.filter(user =>
user.id !== authStore.currentUser?.id &&
!currentConversation.value?.participants.some(participant => participant.id === user.id) &&
!selectedParticipantIds.value.includes(user.id),
))
const markCurrentChannelRead = async (targetChannelId: string) => {
if (messageStore.activeChannelId !== targetChannelId || !newestId.value) return;
const messageId = newestId.value;
if (markedMessageByChannel.get(targetChannelId) === messageId) return;
try {
const readState = await messageStore.markChannelRead(targetChannelId, messageId);
if (messageStore.activeChannelId !== targetChannelId) return;
markedMessageByChannel.set(targetChannelId, messageId);
if (isConversation.value) conversationStore.applyReadState(targetChannelId, readState.unread_count)
else if (props.serverId) serverStore.applyChannelReadState(props.serverId, targetChannelId, readState.unread_count);
} catch (error) {
console.error('Erreur lors de la mise à jour de la lecture:', error);
}
};
const forkConversation = async () => {
if (!selectedParticipantIds.value.length) return
addingParticipants.value = true
try {
const conversation = await conversationStore.forkConversation(channelId.value, selectedParticipantIds.value)
addParticipantDialog.value = false
selectedParticipantIds.value = []
await router.push(`/conversation/${conversation.id}`)
} finally { addingParticipants.value = false }
}
const showRecentMessagesButton = computed(() =>
!loading.value && (hasMoreAfter.value || !isAtBottom.value),
);
interface ScrollAnchor {
id: string;
top: number;
}
const scrollToBottom = async () => {
await nextTick();
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
messageStore.setAtBottom(true);
paginationLock.value = null;
lastScrollTop.value = messageContainer.value.scrollTop;
}
};
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();
}
}
};
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 = '';
if (wasAtBottom) await scrollToBottom();
} catch (e) {
console.error('Erreur lors de l\'envoi du message:', e);
}
};
const reactionKey = (messageId: string, emojiId: string, skinTone: number | null) =>
`${messageId}:${emojiId}:${skinTone ?? 0}`;
const toggleReaction = async (messageId: string, emojiId: string, skinTone: number | null, active: boolean) => {
const key = reactionKey(messageId, emojiId, skinTone);
if (reactionPending.value.has(key)) return;
reactionPending.value.add(key);
try {
if (active) await messageStore.removeReaction(messageId, emojiId, skinTone);
else await messageStore.addReaction(messageId, emojiId);
} catch (error) {
console.error('Erreur lors de la modification de la réaction:', error);
} finally {
reactionPending.value.delete(key);
}
};
const addReaction = (messageId: string, emoji: Emoji) =>
toggleReaction(messageId, emoji.id, null, false);
const emojiText = (emojiId: string, sequence: string | null) => {
const emoji = emojiStore.emojisById[emojiId];
if (emoji?.emoji_type === 'custom') return null;
return sequence ?? emoji?.unicode_sequence ?? '';
};
const reactionIsActive = (userIds: string[]) => Boolean(authStore.currentUser?.id && userIds.includes(authStore.currentUser.id));
const returnToRecentMessages = async () => {
paginationLock.value = null;
await messageStore.fetchMessages(channelId.value);
await scrollToBottom();
await markCurrentChannelRead(channelId.value);
};
let stopReloadAll: (() => void) | null = null;
onMounted(() => {
stopReloadAll = onReloadAll(() => messageStore.fetchMessages(channelId.value));
});
onUnmounted(() => {
stopReloadAll?.();
});
// 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(isAtBottom, async (atBottom) => {
if (atBottom) {
await markCurrentChannelRead(channelId.value);
}
});
watch(channelId, async (newChannelId) => {
if (newChannelId) {
if (props.serverId) await emojiStore.fetchEmojis(props.serverId);
await messageStore.fetchMessages(newChannelId);
await scrollToBottom();
await markCurrentChannelRead(newChannelId);
}
}, {immediate: true})
watch(() => props.serverId, (newServerId) => {
if (newServerId) void emojiStore.fetchEmojis(newServerId);
}, {immediate: true});
</script>
<template>
<v-container class="pa-0 fill-height d-flex flex-column channel-layout" fluid>
<v-sheet v-if="isConversation" class="px-4 py-2 d-flex align-center" border>
<v-icon class="mr-3" icon="mdi-account-multiple-outline" />
<span class="font-weight-medium">{{ currentConversation?.title ?? 'Discussion' }}</span>
<v-spacer />
<v-btn prepend-icon="mdi-account-plus" variant="text" @click="addParticipantDialog = true">Ajouter</v-btn>
</v-sheet>
<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>
<v-avatar color="grey-lighten-2" size="40">
<v-icon icon="mdi-account"></v-icon>
</v-avatar>
</template>
<v-list-item-title class="d-flex align-center">
<span class="font-weight-bold text-subtitle-1 mr-2">{{ userStore.usersById[msg.user_id]?.username }}</span>
<span class="text-caption text-grey">{{ msg.created_at }}</span>
</v-list-item-title>
<div class="text-body-1 text-high-emphasis opacity-100 mt-1">
<div class="markdown-content" v-html="renderMarkdown(msg.content)"></div>
</div>
<div class="d-flex flex-wrap align-center ga-1 mt-2">
<v-chip
v-for="reaction in msg.reactions"
:key="`${reaction.emoji_id}:${reaction.skin_tone ?? 0}`"
:color="reactionIsActive(reaction.user_ids) ? 'primary' : undefined"
:variant="reactionIsActive(reaction.user_ids) ? 'flat' : 'outlined'"
class="reaction-chip"
size="small"
@click="toggleReaction(msg.id, reaction.emoji_id, reaction.skin_tone, reactionIsActive(reaction.user_ids))"
>
<img v-if="emojiStore.emojisById[reaction.emoji_id]?.emoji_type === 'custom' && reaction.asset_url" :alt="reaction.name" :src="reaction.asset_url" />
<span v-else>{{ emojiText(reaction.emoji_id, reaction.unicode_sequence) }}</span>
<span class="ml-1">{{ reaction.count }}</span>
</v-chip>
<v-menu :close-on-content-click="false">
<template #activator="{props: menuProps}">
<v-btn v-bind="menuProps" density="compact" icon="mdi-plus" size="small" variant="text" />
</template>
<EmojiPicker :emojis="emojiStore.emojis" @select="(emoji) => addReaction(msg.id, emoji)" />
</v-menu>
</div>
</v-list-item>
</v-list>
<v-progress-linear v-if="loadingAfter" color="primary" indeterminate />
</div>
<div v-if="showRecentMessagesButton" class="recent-messages-button">
<v-tooltip location="top" text="Revenir aux messages récents">
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="tooltipProps"
aria-label="Revenir aux messages récents"
color="primary"
elevation="4"
icon="mdi-arrow-down-bold"
:loading="loading"
@click="returnToRecentMessages"
/>
</template>
</v-tooltip>
</div>
<v-sheet class="pa-4 flex-shrink-0" width="100%">
<v-textarea
v-model="newMessage"
auto-grow
bg-color="grey-lighten-4"
density="compact"
flat
hide-details
max-rows="5"
placeholder="Envoyer un message..."
rounded="lg"
rows="1"
variant="solo-filled"
@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>
</template>
<template v-slot:append-inner>
<v-btn color="grey-darken-1" density="compact" icon="mdi-gift" variant="text"></v-btn>
<v-btn color="grey-darken-1" density="compact" icon="mdi-sticker-emoji" variant="text"></v-btn>
<v-menu :close-on-content-click="false">
<template #activator="{props: menuProps}">
<v-btn v-bind="menuProps" color="grey-darken-1" density="compact" icon="mdi-emoticon" variant="text" />
</template>
<EmojiPicker :emojis="emojiStore.emojis" @select="(emoji) => { newMessage += emoji.emoji_type === 'custom' ? `:${emoji.name}:` : (emoji.unicode_sequence ?? '') }" />
</v-menu>
</template>
</v-textarea>
</v-sheet>
</v-container>
<v-dialog v-model="addParticipantDialog" width="420">
<v-card>
<v-card-title>Nouvelle discussion de groupe</v-card-title>
<v-card-text>
<v-select v-model="selectedParticipantIds" :items="availableParticipants" item-title="username" item-value="id" label="Ajouter des participants" multiple chips closable-chips />
</v-card-text>
<v-card-actions><v-spacer /><v-btn @click="addParticipantDialog = false">Annuler</v-btn><v-btn color="primary" :loading="addingParticipants" :disabled="!selectedParticipantIds.length" @click="forkConversation">Créer</v-btn></v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.message-container {
height: 0;
}
.channel-layout {
position: relative;
}
.recent-messages-button {
position: absolute;
right: 16px;
bottom: 92px;
z-index: 2;
}
.markdown-content :deep(p) {
margin-bottom: 0;
}
.markdown-content :deep(code) {
background-color: rgba(0, 0, 0, 0.05);
padding: 2px 4px;
border-radius: 4px;
font-family: monospace;
}
.markdown-content :deep(pre) {
background-color: #2d2d2d;
color: #ccc;
padding: 10px;
border-radius: 4px;
margin: 8px 0;
overflow-x: auto;
}
.reaction-chip img {
width: 18px;
height: 18px;
object-fit: contain;
}
</style>