This commit is contained in:
2026-08-08 19:56:57 +02:00
parent d1f9234457
commit 42ab990f7d
23 changed files with 681 additions and 93 deletions
+29 -6
View File
@@ -145,13 +145,23 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
:to="`/server/${server.id}`"
@contextmenu="onServerContextMenu($event, server)"
>
<v-avatar
:style="{ backgroundColor: getServerColor(server.name) }"
class="d-flex align-center justify-center mx-auto mb-9 font-weight-bold text-caption text-white"
size="28"
<v-badge
class="server-badge d-flex mx-auto mb-9"
:content="server.unread_count"
:model-value="(server.unread_count ?? 0) > 0"
color="primary"
location="bottom right"
offset-x="2"
offset-y="2"
>
{{ getServerInitials(server.name) }}
</v-avatar>
<v-avatar
:style="{ backgroundColor: getServerColor(server.name) }"
class="d-flex align-center justify-center font-weight-bold text-caption text-white"
size="36"
>
{{ getServerInitials(server.name) }}
</v-avatar>
</v-badge>
</router-link>
<v-btn
@@ -233,4 +243,17 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
flex-direction: column;
gap: 1rem;
}
.server-badge {
height: 36px;
width: 36px;
}
.server-badge :deep(.v-badge__badge) {
min-width: 22px;
height: 22px;
padding: 0 5px;
font-size: 0.7rem;
line-height: 22px;
}
</style>
+30 -1
View File
@@ -3,6 +3,7 @@ import 'highlight.js/styles/github-dark.css'
import {computed, nextTick, 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'
@@ -13,11 +14,12 @@ const props = defineProps<{
const channelId = computed(() => props.channelId);
const messageStore = useMessageStore();
const serverStore = useServerStore();
const userStore = useUserStore();
const {renderMarkdown} = useMarkdown()
const messageContainer = ref<HTMLElement | null>(null);
const {messages, loading, loadingBefore, loadingAfter, hasMoreAfter, isAtBottom} = storeToRefs(messageStore);
const {messages, loading, loadingBefore, loadingAfter, hasMoreAfter, isAtBottom, newestId} = storeToRefs(messageStore);
const newMessage = ref('');
const SCROLL_LOAD_THRESHOLD = 120;
@@ -25,6 +27,24 @@ 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 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);
serverStore.applyChannelReadState(props.serverId, targetChannelId, readState.unread_count);
} catch (error) {
console.error('Erreur lors de la mise à jour de la lecture:', error);
}
};
const showRecentMessagesButton = computed(() =>
!loading.value && (hasMoreAfter.value || !isAtBottom.value),
@@ -163,6 +183,7 @@ const returnToRecentMessages = async () => {
paginationLock.value = null;
await messageStore.fetchMessages(channelId.value);
await scrollToBottom();
await markCurrentChannelRead(channelId.value);
};
// Only explicit initial loads and realtime messages received while at the
@@ -173,9 +194,17 @@ watch(messages, async () => {
}
}, {deep: true, flush: 'post'});
watch(isAtBottom, async (atBottom) => {
if (atBottom) {
await markCurrentChannelRead(channelId.value);
}
});
watch(channelId, async (newChannelId) => {
if (newChannelId) {
await messageStore.fetchMessages(newChannelId);
await scrollToBottom();
await markCurrentChannelRead(newChannelId);
}
}, {immediate: true})
</script>
+28 -2
View File
@@ -177,9 +177,22 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
:key="channel.id"
:title="channel.name"
:to="`/server/${serverId}/channel/${channel.id}`"
:class="{ 'font-weight-bold': (channel.unread_count ?? 0) > 0 }"
link
@contextmenu="onChannelContextMenu($event, channel)"
/>
>
<template #append>
<v-chip
v-if="(channel.unread_count ?? 0) > 0"
color="primary"
density="compact"
size="small"
variant="flat"
>
{{ channel.unread_count }}
</v-chip>
</template>
</v-list-item>
</v-list-group>
<!-- Canal orphelin (racine) -->
@@ -188,9 +201,22 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
:key="item.Channel.id"
:title="item.Channel.name"
:to="`/server/${serverId}/channel/${item.Channel.id}`"
:class="{ 'font-weight-bold': (item.Channel.unread_count ?? 0) > 0 }"
link
@contextmenu="onChannelContextMenu($event, item.Channel)"
/>
>
<template #append>
<v-chip
v-if="(item.Channel.unread_count ?? 0) > 0"
color="primary"
density="compact"
size="small"
variant="flat"
>
{{ item.Channel.unread_count }}
</v-chip>
</template>
</v-list-item>
</template>
</v-list>
</v-navigation-drawer>
+2 -1
View File
@@ -9,6 +9,7 @@ interface Channel {
category_id?: string | null
created_at: string
updated_at: string
unread_count?: number
}
/*
@@ -65,4 +66,4 @@ export const useChannelStore = defineStore('channel', {
this.error = null;
}
}
})
})
+18
View File
@@ -17,6 +17,13 @@ export interface Message {
reply_to_id: string | null;
}
export interface ReadStateResponse {
channel_id: string;
last_read_message_id: string | null;
updated_at: string | null;
unread_count: number;
}
interface MessagePage {
messages: Message[];
oldest_id: string | null;
@@ -224,6 +231,17 @@ export const useMessageStore = defineStore("message", {
}
},
async markChannelRead(channelId: string, messageId: string): Promise<ReadStateResponse> {
const response = await useApi().put(`/channels/${channelId}/read-state`, {
last_read_message_id: messageId,
});
if (!response.ok) {
throw new Error(`Read state update failed (${response.status})`);
}
return await response.json() as ReadStateResponse;
},
addRealtimeMessage(message: Message) {
if (message.channel_id !== this.activeChannelId) return;
+30 -2
View File
@@ -9,6 +9,7 @@ export interface Server {
is_default: boolean
created_at: string
updated_at: string
unread_count?: number
}
export const useServerStore = defineStore("server", {
@@ -33,7 +34,10 @@ export const useServerStore = defineStore("server", {
const server: Server = await response.json();
const index = this.servers.findIndex(item => item.id === server.id);
if (index >= 0) this.servers[index] = server;
if (index >= 0) {
server.unread_count ??= this.servers[index].unread_count ?? 0;
this.servers[index] = server;
}
else this.servers.push(server);
return server;
},
@@ -70,7 +74,10 @@ export const useServerStore = defineStore("server", {
}
const updated = await response.json();
const index = this.servers.findIndex(server => server.id === serverId);
if (index >= 0) this.servers[index] = updated;
if (index >= 0) {
updated.unread_count ??= this.servers[index].unread_count ?? 0;
this.servers[index] = updated;
}
return updated;
},
async fetchServerTree(serverId: string) {
@@ -103,6 +110,27 @@ export const useServerStore = defineStore("server", {
return tree.items;
},
applyChannelReadState(serverId: string, channelId: string, unreadCount: number) {
let previousUnreadCount = 0;
for (const item of this.currentTree) {
const channels = "Category" in item ? item.Category[1] : "Channel" in item ? [item.Channel] : [];
const channel = channels.find((candidate: { id: string }) => candidate.id === channelId);
if (channel) {
previousUnreadCount = channel.unread_count ?? 0;
channel.unread_count = unreadCount;
break;
}
}
const server = this.servers.find(candidate => candidate.id === serverId);
if (server) {
server.unread_count = Math.max(
0,
(server.unread_count ?? 0) - previousUnreadCount + unreadCount,
);
}
},
reset() {
this.servers = [];
this.loading = false;