added frontend for social + feed partiali working
This commit is contained in:
205
frontend/src/pages/social/chat/ChatRoomPage.tsx
Normal file
205
frontend/src/pages/social/chat/ChatRoomPage.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
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 { useInfiniteMessages } from "@/hooks/useInfiniteMessages";
|
||||
import { useChatSocket, type ChatSocketEvent } from "@/hooks/useChatSocket";
|
||||
import { useIntersectionLoader } from "@/hooks/useIntersectionLoader";
|
||||
import { messagesQueryKey, type CursorPaginated } from "@/api/social/feed";
|
||||
import Message from "@/components/social/chat/Message";
|
||||
import MessageComposer from "@/components/social/chat/MessageComposer";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import EmptyState from "@/components/ui/EmptyState";
|
||||
import Avatar from "@/components/ui/Avatar";
|
||||
|
||||
export default function ChatRoomPage() {
|
||||
const { t } = useTranslation("social");
|
||||
const { chatId: chatIdParam } = useParams<{ chatId: string }>();
|
||||
const chatId = Number(chatIdParam);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: chat } = useApiSocialChatsRetrieve(String(chatId));
|
||||
const {
|
||||
messages,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteMessages({ chatId });
|
||||
|
||||
const [replyTo, setReplyTo] = useState<MessageModel | null>(null);
|
||||
const [typingUsers, setTypingUsers] = useState<string[]>([]);
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Top sentinel triggers loading older messages (scroll-back).
|
||||
const topSentinelRef = useIntersectionLoader<HTMLDivElement>(
|
||||
() => {
|
||||
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
|
||||
},
|
||||
{ enabled: hasNextPage && !isLoading },
|
||||
);
|
||||
|
||||
// Append a freshly received message into the cursor cache.
|
||||
const appendMessage = useCallback(
|
||||
(msg: MessageModel) => {
|
||||
queryClient.setQueryData<{
|
||||
pages: CursorPaginated<MessageModel>[];
|
||||
pageParams: unknown[];
|
||||
}>(messagesQueryKey(chatId), (old) => {
|
||||
if (!old) return old as never;
|
||||
const [first, ...rest] = old.pages;
|
||||
if (!first) return old;
|
||||
if (first.results.some((m) => m.id === msg.id)) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: [{ ...first, results: [msg, ...first.results] }, ...rest],
|
||||
};
|
||||
});
|
||||
},
|
||||
[queryClient, chatId],
|
||||
);
|
||||
|
||||
const removeMessage = useCallback(
|
||||
(messageId: number) => {
|
||||
queryClient.setQueryData<{
|
||||
pages: CursorPaginated<MessageModel>[];
|
||||
pageParams: unknown[];
|
||||
}>(messagesQueryKey(chatId), (old) => {
|
||||
if (!old) return old as never;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((p) => ({
|
||||
...p,
|
||||
results: p.results.filter((m) => m.id !== messageId),
|
||||
})),
|
||||
};
|
||||
});
|
||||
},
|
||||
[queryClient, chatId],
|
||||
);
|
||||
|
||||
const handleSocketEvent = useCallback(
|
||||
(event: ChatSocketEvent) => {
|
||||
if (event.type === "new_chat_message" || event.type === "new_reply_chat_message") {
|
||||
appendMessage({
|
||||
id: event.message_id,
|
||||
chat: chatId,
|
||||
sender: null,
|
||||
reply_to: event.type === "new_reply_chat_message" ? event.reply_to_id : null,
|
||||
content: event.message,
|
||||
is_edited: false,
|
||||
edited_at: null,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
media_files: [],
|
||||
reactions: [],
|
||||
});
|
||||
} else if (event.type === "delete_chat_message") {
|
||||
removeMessage(event.message_id);
|
||||
} else if (event.type === "typing") {
|
||||
setTypingUsers((prev) =>
|
||||
event.is_typing
|
||||
? prev.includes(event.user) ? prev : [...prev, event.user]
|
||||
: prev.filter((u) => u !== event.user),
|
||||
);
|
||||
} else if (event.type === "stop_typing") {
|
||||
setTypingUsers((prev) => prev.filter((u) => u !== event.user));
|
||||
}
|
||||
},
|
||||
[appendMessage, removeMessage, chatId],
|
||||
);
|
||||
|
||||
const { status, sendMessage, sendReply, sendReaction, sendTyping } = useChatSocket({
|
||||
chatId: Number.isFinite(chatId) ? chatId : null,
|
||||
onEvent: handleSocketEvent,
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive.
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||
}, [messages.length]);
|
||||
|
||||
function handleSend(text: string, replyToId?: number): boolean {
|
||||
return replyToId ? sendReply(text, replyToId) : sendMessage(text);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(chatId)) {
|
||||
return <EmptyState message={t("chat.room.selectChat")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center gap-3 border-b border-brand-lines/15 px-4 py-3">
|
||||
<Avatar
|
||||
name={chat?.name ?? `chat ${chatId}`}
|
||||
src={chat?.icon ?? undefined}
|
||||
size={36}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-brand-text">
|
||||
{chat?.name || `Chat #${chatId}`}
|
||||
</div>
|
||||
{status !== "open" && (
|
||||
<div className="text-xs text-brand-text/60">
|
||||
{t("chat.room.disconnected")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{hasNextPage && (
|
||||
<div ref={topSentinelRef} className="flex justify-center py-2">
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner size={18} />
|
||||
) : (
|
||||
<span className="text-xs text-brand-text/50">
|
||||
{t("chat.room.loadingHistory")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-6">
|
||||
<Spinner size={24} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && messages.length === 0 && (
|
||||
<EmptyState message={t("chat.room.noMessages")} />
|
||||
)}
|
||||
|
||||
{messages.map((m) => (
|
||||
<Message
|
||||
key={m.id}
|
||||
message={m}
|
||||
chat={chat ?? null}
|
||||
onReply={setReplyTo}
|
||||
onReact={(msg, emoji) => sendReaction(msg.id, emoji)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{typingUsers.length > 0 && (
|
||||
<div className="px-4 py-1 text-xs text-brand-text/60">
|
||||
{typingUsers.length === 1
|
||||
? t("chat.room.typing", { user: typingUsers[0] })
|
||||
: t("chat.room.typingMany")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MessageComposer
|
||||
disabled={status !== "open"}
|
||||
replyTo={replyTo}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
onSend={handleSend}
|
||||
onTyping={sendTyping}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user