chat implemented, testing needed
This commit is contained in:
@@ -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