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

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