chat implemented, testing needed
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()},
|
||||
)
|
||||
|
||||
28
backend/social/chat/migrations/0002_chatreadstatus.py
Normal file
28
backend/social/chat/migrations/0002_chatreadstatus.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -214,3 +214,23 @@ class MessageFile(SoftDeleteModel):
|
||||
|
||||
def __str__(self):
|
||||
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}"
|
||||
@@ -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):
|
||||
|
||||
@@ -22,4 +22,5 @@ export interface Chat {
|
||||
hub?: number | null;
|
||||
readonly created_at: Date;
|
||||
readonly updated_at: Date;
|
||||
readonly unread_count: number;
|
||||
}
|
||||
|
||||
@@ -22,4 +22,5 @@ export interface PatchedChat {
|
||||
hub?: number | null;
|
||||
readonly created_at?: Date;
|
||||
readonly updated_at?: Date;
|
||||
readonly unread_count?: number;
|
||||
}
|
||||
|
||||
@@ -22,4 +22,5 @@ export interface Chat {
|
||||
hub?: number | null;
|
||||
readonly created_at: Date;
|
||||
readonly updated_at: Date;
|
||||
readonly unread_count: number;
|
||||
}
|
||||
|
||||
@@ -22,4 +22,5 @@ export interface PatchedChat {
|
||||
hub?: number | null;
|
||||
readonly created_at?: Date;
|
||||
readonly updated_at?: Date;
|
||||
readonly unread_count?: number;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user