chat implemented, testing needed

This commit is contained in:
2026-05-20 00:52:56 +02:00
parent c7de2dbcdc
commit d52af2c495
14 changed files with 144 additions and 17 deletions

View File

@@ -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)"
]
}
}

View File

@@ -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()},
)

View File

@@ -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')},
},
),
]

View File

@@ -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}"
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}"

View File

@@ -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):

View File

@@ -22,4 +22,5 @@ export interface Chat {
hub?: number | null;
readonly created_at: Date;
readonly updated_at: Date;
readonly unread_count: number;
}

View File

@@ -22,4 +22,5 @@ export interface PatchedChat {
hub?: number | null;
readonly created_at?: Date;
readonly updated_at?: Date;
readonly unread_count?: number;
}

View File

@@ -22,4 +22,5 @@ export interface Chat {
hub?: number | null;
readonly created_at: Date;
readonly updated_at: Date;
readonly unread_count: number;
}

View File

@@ -22,4 +22,5 @@ export interface PatchedChat {
hub?: number | null;
readonly created_at?: Date;
readonly updated_at?: Date;
readonly unread_count?: number;
}

View File

@@ -76,6 +76,11 @@ export default function ChatSidebar() {
{chat.chat_type}
</div>
</div>
{(chat.unread_count ?? 0) > 0 && (
<span className="ml-auto shrink-0 rounded-full bg-brand-accent px-1.5 py-0.5 text-[10px] font-bold leading-none text-brand-bg">
{(chat.unread_count ?? 0) > 99 ? "99+" : chat.unread_count}
</span>
)}
</NavLink>
</li>
))}

View File

@@ -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,
};
}

View File

@@ -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 (
<div className="min-h-screen w-full">
@@ -43,7 +52,7 @@ export default function SocialLayout() {
<aside className="sticky top-0 hidden h-screen w-[72px] flex-shrink-0 flex-col items-center justify-between py-6 md:flex md:w-[220px] md:items-start">
<div className="flex w-full flex-col gap-1.5">
<div className="mb-4 px-2 text-xl font-bold text-rainbow hidden md:block">
vontor
vontor.cz
</div>
{items.map((it) => (
<NavLink
@@ -59,6 +68,11 @@ export default function SocialLayout() {
>
{it.icon}
<span className="hidden md:inline">{t(it.labelKey)}</span>
{it.to === "/social/chats" && totalUnread > 0 && (
<span className="ml-auto rounded-full bg-brand-accent px-1.5 py-0.5 text-[10px] font-bold leading-none text-brand-bg">
{totalUnread > 99 ? "99+" : totalUnread}
</span>
)}
</NavLink>
))}
</div>
@@ -80,7 +94,7 @@ export default function SocialLayout() {
</aside>
{/* Main column */}
<main className="min-h-screen flex-1 border-x border-brand-lines/15 max-w-[640px]">
<main className={`flex-1 border-x border-brand-lines/15 max-w-[640px] ${isChat ? "h-screen overflow-hidden" : "min-h-screen"}`}>
<Outlet />
</main>

View File

@@ -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 (
<div>
{/* Sticky back-nav */}
@@ -82,7 +80,7 @@ export default function UserProfilePage() {
{/* Avatar — overlaps banner */}
<div className="absolute -bottom-10 left-4">
<div className="rounded-full ring-4 ring-brand-bg">
<Avatar name={displayName} src={p?.avatar ?? null} size={80} />
<Avatar name={`${profile.first_name} ${profile.last_name}`} src={p?.avatar ?? null} size={80} />
</div>
</div>
@@ -101,7 +99,7 @@ export default function UserProfilePage() {
{/* Profile info */}
<div className="border-b border-brand-lines/10 px-4 pb-4 pt-12">
<div className="text-xl font-bold text-brand-text">{displayName}</div>
<div className="text-xl font-bold text-brand-text">{profile.first_name} {profile.last_name}</div>
<div className="text-sm text-brand-text/50">@{profile.username}</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-brand-text/40">

View File

@@ -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 {