462 lines
16 KiB
Vue
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>
|