diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fa3c279..f6e1b92 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,8 @@ "Bash(npm install *)", "Bash(npx tsc *)", "Bash(npx eslint *)", - "Bash(python -c ' *)" + "Bash(python -c ' *)", + "PowerShell(Get-ChildItem -Path \"c:\\\\Users\\\\bruno\\\\Documents\\\\GitHub\\\\vontor-cz\\\\frontend\\\\src\\\\components\\\\social\" -File -Recurse | Select-Object FullName, @{n='Lines';e={\\(Get-Content $_.FullName | Measure-Object -Line\\).Lines}} | Format-Table -AutoSize)" ] } } diff --git a/backend/social/chat/consumers.py b/backend/social/chat/consumers.py index 4358d84..a6507d6 100644 --- a/backend/social/chat/consumers.py +++ b/backend/social/chat/consumers.py @@ -3,8 +3,9 @@ import json from channels.db import database_sync_to_async from channels.generic.websocket import AsyncWebsocketConsumer +from django.utils import timezone -from .models import Chat, Message +from .models import Chat, ChatReadStatus, Message class ChatConsumer(AsyncWebsocketConsumer): @@ -95,6 +96,14 @@ class ChatConsumer(AsyncWebsocketConsumer): "user": user.username, }) + elif msg_type == "mark_read": + await _mark_read(chat_id=self.chat_id, user=user) + await self.channel_layer.group_send(self.chat_name, { + "type": "read.status", + "user": user.username, + "chat_id": int(self.chat_id), + }) + else: await self.send(text_data=json.dumps({"error": "Unsupported message type."})) @@ -155,6 +164,13 @@ class ChatConsumer(AsyncWebsocketConsumer): "user": event["user"], })) + async def read_status(self, event): + await self.send(text_data=json.dumps({ + "type": "read_status", + "user": event["user"], + "chat_id": event["chat_id"], + })) + # --------------------------------------------------------------------------- # DB helpers (run in thread pool via database_sync_to_async) @@ -179,3 +195,12 @@ def _create_message(chat_id, sender, content, reply_to_id=None): def _toggle_reaction(message_id, user, emoji): message = Message.objects.get(pk=message_id) return message.toggle_reaction(user, emoji) + + +@database_sync_to_async +def _mark_read(chat_id, user): + ChatReadStatus.objects.update_or_create( + user=user, + chat_id=chat_id, + defaults={"last_read_at": timezone.now()}, + ) diff --git a/backend/social/chat/migrations/0002_chatreadstatus.py b/backend/social/chat/migrations/0002_chatreadstatus.py new file mode 100644 index 0000000..fed8844 --- /dev/null +++ b/backend/social/chat/migrations/0002_chatreadstatus.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.7 on 2026-05-19 22:40 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ChatReadStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('last_read_at', models.DateTimeField(auto_now=True)), + ('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_statuses', to='chat.chat')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_read_statuses', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'chat')}, + }, + ), + ] diff --git a/backend/social/chat/models.py b/backend/social/chat/models.py index 8f6ec20..9834462 100644 --- a/backend/social/chat/models.py +++ b/backend/social/chat/models.py @@ -213,4 +213,24 @@ class MessageFile(SoftDeleteModel): uploaded_at = models.DateTimeField(auto_now_add=True) def __str__(self): - return f"Media {self.id} for Message {self.message.id}" \ No newline at end of file + return f"Media {self.id} for Message {self.message.id}" + + +class ChatReadStatus(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='chat_read_statuses', + ) + chat = models.ForeignKey( + Chat, + on_delete=models.CASCADE, + related_name='read_statuses', + ) + last_read_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('user', 'chat') + + def __str__(self): + return f"{self.user} read {self.chat} at {self.last_read_at}" \ No newline at end of file diff --git a/backend/social/chat/serializers.py b/backend/social/chat/serializers.py index d703b3e..c49b045 100644 --- a/backend/social/chat/serializers.py +++ b/backend/social/chat/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers -from .models import Chat, Message, MessageFile, MessageHistory, MessageReaction +from drf_spectacular.utils import extend_schema_field +from .models import Chat, ChatReadStatus, Message, MessageFile, MessageHistory, MessageReaction class MessageFileSerializer(serializers.ModelSerializer): @@ -60,14 +61,28 @@ class MessageSendSerializer(serializers.Serializer): class ChatSerializer(serializers.ModelSerializer): + unread_count = serializers.SerializerMethodField(read_only=True) + + @extend_schema_field(serializers.IntegerField()) + def get_unread_count(self, obj): + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return 0 + last_read = ChatReadStatus.objects.filter( + user=request.user, chat=obj + ).values_list('last_read_at', flat=True).first() + if last_read is None: + return obj.messages.filter(is_deleted=False).count() + return obj.messages.filter(created_at__gt=last_read, is_deleted=False).count() + class Meta: model = Chat fields = [ 'id', 'chat_type', 'owner', 'name', 'icon', 'banner', 'members', 'moderators', - 'hub', 'created_at', 'updated_at', + 'hub', 'created_at', 'updated_at', 'unread_count', ] - read_only_fields = ['owner', 'created_at', 'updated_at'] + read_only_fields = ['owner', 'created_at', 'updated_at', 'unread_count'] class ChatMemberSerializer(serializers.Serializer): diff --git a/frontend/src/api/generated/private/models/chat.ts b/frontend/src/api/generated/private/models/chat.ts index 62dda8d..0f22475 100644 --- a/frontend/src/api/generated/private/models/chat.ts +++ b/frontend/src/api/generated/private/models/chat.ts @@ -22,4 +22,5 @@ export interface Chat { hub?: number | null; readonly created_at: Date; readonly updated_at: Date; + readonly unread_count: number; } diff --git a/frontend/src/api/generated/private/models/patchedChat.ts b/frontend/src/api/generated/private/models/patchedChat.ts index 8e3c89b..0bf062e 100644 --- a/frontend/src/api/generated/private/models/patchedChat.ts +++ b/frontend/src/api/generated/private/models/patchedChat.ts @@ -22,4 +22,5 @@ export interface PatchedChat { hub?: number | null; readonly created_at?: Date; readonly updated_at?: Date; + readonly unread_count?: number; } diff --git a/frontend/src/api/generated/public/models/chat.ts b/frontend/src/api/generated/public/models/chat.ts index 62dda8d..0f22475 100644 --- a/frontend/src/api/generated/public/models/chat.ts +++ b/frontend/src/api/generated/public/models/chat.ts @@ -22,4 +22,5 @@ export interface Chat { hub?: number | null; readonly created_at: Date; readonly updated_at: Date; + readonly unread_count: number; } diff --git a/frontend/src/api/generated/public/models/patchedChat.ts b/frontend/src/api/generated/public/models/patchedChat.ts index 8e3c89b..0bf062e 100644 --- a/frontend/src/api/generated/public/models/patchedChat.ts +++ b/frontend/src/api/generated/public/models/patchedChat.ts @@ -22,4 +22,5 @@ export interface PatchedChat { hub?: number | null; readonly created_at?: Date; readonly updated_at?: Date; + readonly unread_count?: number; } diff --git a/frontend/src/components/social/chat/ChatSidebar.tsx b/frontend/src/components/social/chat/ChatSidebar.tsx index 1f84fd9..c87a061 100644 --- a/frontend/src/components/social/chat/ChatSidebar.tsx +++ b/frontend/src/components/social/chat/ChatSidebar.tsx @@ -76,6 +76,11 @@ export default function ChatSidebar() { {chat.chat_type} + {(chat.unread_count ?? 0) > 0 && ( + + {(chat.unread_count ?? 0) > 99 ? "99+" : chat.unread_count} + + )} ))} diff --git a/frontend/src/hooks/useChatSocket.ts b/frontend/src/hooks/useChatSocket.ts index 6d127e2..5e5face 100644 --- a/frontend/src/hooks/useChatSocket.ts +++ b/frontend/src/hooks/useChatSocket.ts @@ -11,6 +11,7 @@ export type ChatSocketEvent = | { type: "reaction"; message_id: number; emoji: string; user: string; action: "added" | "removed" | "switched" } | { type: "typing"; user: string; is_typing: boolean } | { type: "stop_typing"; user: string } + | { type: "read_status"; user: string; chat_id: number } | { type: "error"; error: string }; interface Opts { @@ -111,6 +112,8 @@ export function useChatSocket({ chatId, onEvent }: Opts) { [send], ); + const sendMarkRead = useCallback(() => send({ type: "mark_read" }), [send]); + return { status, lastEvent, @@ -118,5 +121,6 @@ export function useChatSocket({ chatId, onEvent }: Opts) { sendReply, sendReaction, sendTyping, + sendMarkRead, }; } diff --git a/frontend/src/layouts/social/SocialLayout.tsx b/frontend/src/layouts/social/SocialLayout.tsx index 0fb3421..01a0713 100644 --- a/frontend/src/layouts/social/SocialLayout.tsx +++ b/frontend/src/layouts/social/SocialLayout.tsx @@ -1,5 +1,6 @@ -import { NavLink, Outlet } from "react-router-dom"; +import { NavLink, Outlet, useMatch } from "react-router-dom"; import { useTranslation } from "react-i18next"; +import { useMemo } from "react"; import { FiHome, FiMessageCircle, @@ -9,6 +10,7 @@ import { FiLogOut, } from "react-icons/fi"; import { useAuth } from "@/hooks/useAuth"; +import { useApiSocialChatsList } from "@/api/generated/private/chat/chat"; import Avatar from "@/components/ui/Avatar"; interface NavItem { @@ -35,6 +37,13 @@ export default function SocialLayout() { const { t } = useTranslation("social"); const { user } = useAuth(); const items = buildItems(user?.username); + const isChat = !!useMatch("/social/chats/*"); + + const { data: chatsData } = useApiSocialChatsList(); + const totalUnread = useMemo( + () => (chatsData?.results ?? []).reduce((s, c) => s + (c.unread_count ?? 0), 0), + [chatsData], + ); return (
@@ -43,7 +52,7 @@ export default function SocialLayout() { {/* Main column */} -
+
diff --git a/frontend/src/pages/social/UserProfilePage.tsx b/frontend/src/pages/social/UserProfilePage.tsx index e1bed3e..738ecd9 100644 --- a/frontend/src/pages/social/UserProfilePage.tsx +++ b/frontend/src/pages/social/UserProfilePage.tsx @@ -41,8 +41,6 @@ export default function UserProfilePage() { const posts = postsData?.results ?? []; const p = profile as any; - const displayName = [p?.first_name, p?.last_name].filter(Boolean).join(" ") || p?.username || ""; - return (
{/* Sticky back-nav */} @@ -82,7 +80,7 @@ export default function UserProfilePage() { {/* Avatar — overlaps banner */}
- +
@@ -101,7 +99,7 @@ export default function UserProfilePage() { {/* Profile info */}
-
{displayName}
+
{profile.first_name} {profile.last_name}
@{profile.username}
diff --git a/frontend/src/pages/social/chat/ChatRoomPage.tsx b/frontend/src/pages/social/chat/ChatRoomPage.tsx index 9117e71..b119459 100644 --- a/frontend/src/pages/social/chat/ChatRoomPage.tsx +++ b/frontend/src/pages/social/chat/ChatRoomPage.tsx @@ -3,7 +3,8 @@ import { useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import type { Message as MessageModel } from "@/api/generated/private/models/message"; -import { useApiSocialChatsRetrieve } from "@/api/generated/private/chat/chat"; +import { getApiSocialChatsListQueryKey, useApiSocialChatsRetrieve } from "@/api/generated/private/chat/chat"; +import { useAuth } from "@/hooks/useAuth"; import { useInfiniteMessages } from "@/hooks/useInfiniteMessages"; import { useChatSocket, type ChatSocketEvent } from "@/hooks/useChatSocket"; import { useIntersectionLoader } from "@/hooks/useIntersectionLoader"; @@ -20,6 +21,7 @@ export default function ChatRoomPage() { const chatId = Number(chatIdParam); const queryClient = useQueryClient(); + const { user } = useAuth(); const { data: chat } = useApiSocialChatsRetrieve(String(chatId)); const { messages, @@ -98,6 +100,10 @@ export default function ChatRoomPage() { }); } else if (event.type === "delete_chat_message") { removeMessage(event.message_id); + } else if (event.type === "read_status") { + if (event.user === user?.username) { + queryClient.invalidateQueries({ queryKey: getApiSocialChatsListQueryKey() }); + } } else if (event.type === "typing") { setTypingUsers((prev) => event.is_typing @@ -108,17 +114,24 @@ export default function ChatRoomPage() { setTypingUsers((prev) => prev.filter((u) => u !== event.user)); } }, - [appendMessage, removeMessage, chatId], + [appendMessage, removeMessage, chatId, user, queryClient], ); - const { status, sendMessage, sendReply, sendReaction, sendTyping } = useChatSocket({ + const { status, sendMessage, sendReply, sendReaction, sendTyping, sendMarkRead } = useChatSocket({ chatId: Number.isFinite(chatId) ? chatId : null, onEvent: handleSocketEvent, }); - // Auto-scroll to bottom when new messages arrive. + // Mark the chat as read as soon as the socket opens. + useEffect(() => { + if (status === "open") sendMarkRead(); + }, [status, sendMarkRead]); + + // Auto-scroll to bottom and mark chat as read when new messages arrive. useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "auto" }); + if (status === "open") sendMarkRead(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [messages.length]); function handleSend(text: string, replyToId?: number): boolean {