chat implemented, testing needed
This commit is contained in:
@@ -7,7 +7,8 @@
|
|||||||
"Bash(npm install *)",
|
"Bash(npm install *)",
|
||||||
"Bash(npx tsc *)",
|
"Bash(npx tsc *)",
|
||||||
"Bash(npx eslint *)",
|
"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.db import database_sync_to_async
|
||||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
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):
|
class ChatConsumer(AsyncWebsocketConsumer):
|
||||||
@@ -95,6 +96,14 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
|||||||
"user": user.username,
|
"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:
|
else:
|
||||||
await self.send(text_data=json.dumps({"error": "Unsupported message type."}))
|
await self.send(text_data=json.dumps({"error": "Unsupported message type."}))
|
||||||
|
|
||||||
@@ -155,6 +164,13 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
|||||||
"user": event["user"],
|
"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)
|
# 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):
|
def _toggle_reaction(message_id, user, emoji):
|
||||||
message = Message.objects.get(pk=message_id)
|
message = Message.objects.get(pk=message_id)
|
||||||
return message.toggle_reaction(user, emoji)
|
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):
|
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}"
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from rest_framework import serializers
|
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):
|
class MessageFileSerializer(serializers.ModelSerializer):
|
||||||
@@ -60,14 +61,28 @@ class MessageSendSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class ChatSerializer(serializers.ModelSerializer):
|
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:
|
class Meta:
|
||||||
model = Chat
|
model = Chat
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'chat_type', 'owner', 'name',
|
'id', 'chat_type', 'owner', 'name',
|
||||||
'icon', 'banner', 'members', 'moderators',
|
'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):
|
class ChatMemberSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ export interface Chat {
|
|||||||
hub?: number | null;
|
hub?: number | null;
|
||||||
readonly created_at: Date;
|
readonly created_at: Date;
|
||||||
readonly updated_at: Date;
|
readonly updated_at: Date;
|
||||||
|
readonly unread_count: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ export interface PatchedChat {
|
|||||||
hub?: number | null;
|
hub?: number | null;
|
||||||
readonly created_at?: Date;
|
readonly created_at?: Date;
|
||||||
readonly updated_at?: Date;
|
readonly updated_at?: Date;
|
||||||
|
readonly unread_count?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ export interface Chat {
|
|||||||
hub?: number | null;
|
hub?: number | null;
|
||||||
readonly created_at: Date;
|
readonly created_at: Date;
|
||||||
readonly updated_at: Date;
|
readonly updated_at: Date;
|
||||||
|
readonly unread_count: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ export interface PatchedChat {
|
|||||||
hub?: number | null;
|
hub?: number | null;
|
||||||
readonly created_at?: Date;
|
readonly created_at?: Date;
|
||||||
readonly updated_at?: Date;
|
readonly updated_at?: Date;
|
||||||
|
readonly unread_count?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ export default function ChatSidebar() {
|
|||||||
{chat.chat_type}
|
{chat.chat_type}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type ChatSocketEvent =
|
|||||||
| { type: "reaction"; message_id: number; emoji: string; user: string; action: "added" | "removed" | "switched" }
|
| { type: "reaction"; message_id: number; emoji: string; user: string; action: "added" | "removed" | "switched" }
|
||||||
| { type: "typing"; user: string; is_typing: boolean }
|
| { type: "typing"; user: string; is_typing: boolean }
|
||||||
| { type: "stop_typing"; user: string }
|
| { type: "stop_typing"; user: string }
|
||||||
|
| { type: "read_status"; user: string; chat_id: number }
|
||||||
| { type: "error"; error: string };
|
| { type: "error"; error: string };
|
||||||
|
|
||||||
interface Opts {
|
interface Opts {
|
||||||
@@ -111,6 +112,8 @@ export function useChatSocket({ chatId, onEvent }: Opts) {
|
|||||||
[send],
|
[send],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sendMarkRead = useCallback(() => send({ type: "mark_read" }), [send]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
lastEvent,
|
lastEvent,
|
||||||
@@ -118,5 +121,6 @@ export function useChatSocket({ chatId, onEvent }: Opts) {
|
|||||||
sendReply,
|
sendReply,
|
||||||
sendReaction,
|
sendReaction,
|
||||||
sendTyping,
|
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 { useTranslation } from "react-i18next";
|
||||||
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
FiHome,
|
FiHome,
|
||||||
FiMessageCircle,
|
FiMessageCircle,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
FiLogOut,
|
FiLogOut,
|
||||||
} from "react-icons/fi";
|
} from "react-icons/fi";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { useApiSocialChatsList } from "@/api/generated/private/chat/chat";
|
||||||
import Avatar from "@/components/ui/Avatar";
|
import Avatar from "@/components/ui/Avatar";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -35,6 +37,13 @@ export default function SocialLayout() {
|
|||||||
const { t } = useTranslation("social");
|
const { t } = useTranslation("social");
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const items = buildItems(user?.username);
|
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 (
|
return (
|
||||||
<div className="min-h-screen w-full">
|
<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">
|
<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="flex w-full flex-col gap-1.5">
|
||||||
<div className="mb-4 px-2 text-xl font-bold text-rainbow hidden md:block">
|
<div className="mb-4 px-2 text-xl font-bold text-rainbow hidden md:block">
|
||||||
vontor
|
vontor.cz
|
||||||
</div>
|
</div>
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -59,6 +68,11 @@ export default function SocialLayout() {
|
|||||||
>
|
>
|
||||||
{it.icon}
|
{it.icon}
|
||||||
<span className="hidden md:inline">{t(it.labelKey)}</span>
|
<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>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +94,7 @@ export default function SocialLayout() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main column */}
|
{/* 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 />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ export default function UserProfilePage() {
|
|||||||
|
|
||||||
const posts = postsData?.results ?? [];
|
const posts = postsData?.results ?? [];
|
||||||
const p = profile as any;
|
const p = profile as any;
|
||||||
const displayName = [p?.first_name, p?.last_name].filter(Boolean).join(" ") || p?.username || "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Sticky back-nav */}
|
{/* Sticky back-nav */}
|
||||||
@@ -82,7 +80,7 @@ export default function UserProfilePage() {
|
|||||||
{/* Avatar — overlaps banner */}
|
{/* Avatar — overlaps banner */}
|
||||||
<div className="absolute -bottom-10 left-4">
|
<div className="absolute -bottom-10 left-4">
|
||||||
<div className="rounded-full ring-4 ring-brand-bg">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,7 +99,7 @@ export default function UserProfilePage() {
|
|||||||
|
|
||||||
{/* Profile info */}
|
{/* Profile info */}
|
||||||
<div className="border-b border-brand-lines/10 px-4 pb-4 pt-12">
|
<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="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">
|
<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 { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Message as MessageModel } from "@/api/generated/private/models/message";
|
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 { useInfiniteMessages } from "@/hooks/useInfiniteMessages";
|
||||||
import { useChatSocket, type ChatSocketEvent } from "@/hooks/useChatSocket";
|
import { useChatSocket, type ChatSocketEvent } from "@/hooks/useChatSocket";
|
||||||
import { useIntersectionLoader } from "@/hooks/useIntersectionLoader";
|
import { useIntersectionLoader } from "@/hooks/useIntersectionLoader";
|
||||||
@@ -20,6 +21,7 @@ export default function ChatRoomPage() {
|
|||||||
const chatId = Number(chatIdParam);
|
const chatId = Number(chatIdParam);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { user } = useAuth();
|
||||||
const { data: chat } = useApiSocialChatsRetrieve(String(chatId));
|
const { data: chat } = useApiSocialChatsRetrieve(String(chatId));
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -98,6 +100,10 @@ export default function ChatRoomPage() {
|
|||||||
});
|
});
|
||||||
} else if (event.type === "delete_chat_message") {
|
} else if (event.type === "delete_chat_message") {
|
||||||
removeMessage(event.message_id);
|
removeMessage(event.message_id);
|
||||||
|
} else if (event.type === "read_status") {
|
||||||
|
if (event.user === user?.username) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: getApiSocialChatsListQueryKey() });
|
||||||
|
}
|
||||||
} else if (event.type === "typing") {
|
} else if (event.type === "typing") {
|
||||||
setTypingUsers((prev) =>
|
setTypingUsers((prev) =>
|
||||||
event.is_typing
|
event.is_typing
|
||||||
@@ -108,17 +114,24 @@ export default function ChatRoomPage() {
|
|||||||
setTypingUsers((prev) => prev.filter((u) => u !== event.user));
|
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,
|
chatId: Number.isFinite(chatId) ? chatId : null,
|
||||||
onEvent: handleSocketEvent,
|
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(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||||
|
if (status === "open") sendMarkRead();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [messages.length]);
|
}, [messages.length]);
|
||||||
|
|
||||||
function handleSend(text: string, replyToId?: number): boolean {
|
function handleSend(text: string, replyToId?: number): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user