Backend: enrich message reply data (include created_at and media_files) and ensure chat owners are treated as members; tighten/extend permission checks and message query filters; fix hub routers so moderators/tags routes are resolved before hub detail; accept hub id from request.data in hub permission/tag views; add PostHub serializer and expose hub_detail on posts. Frontend: update generated API models (postHub, replyTo, members_detail, hub_detail); add hub-related pages/routes and components (HubCard, HubHeader, Tags) and a hub posts feed hook; rework message UI and composer to show richer reply previews (media thumbnails, timestamps), adjust video preload to metadata; add tag selection UI to PostComposer and wire hub tags fetching. Also: minor UI/UX improvements and generated model exports updated to match backend changes.
381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
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 { FiMoreVertical } from "react-icons/fi";
|
|
import type { Message as MessageModel } from "@/api/generated/private/models/message";
|
|
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";
|
|
import { messagesQueryKey, type CursorPaginated } from "@/api/social/feed";
|
|
import { sendChatMessage } from "@/api/social/chatSend";
|
|
import Message from "@/components/social/chat/Message";
|
|
import MessageComposer from "@/components/social/chat/MessageComposer";
|
|
import ChatSettingsModal from "@/components/social/chat/ChatSettingsModal";
|
|
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 { user } = useAuth();
|
|
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 [settingsOpen, setSettingsOpen] = useState(false);
|
|
const bottomRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// --- reply-jump logic ---
|
|
const [highlightedId, setHighlightedId] = useState<number | null>(null);
|
|
/** Message id we're waiting to appear in the DOM while fetching older pages. */
|
|
const [pendingScrollId, setPendingScrollId] = useState<number | null>(null);
|
|
/** IDs removed by a delete WS event — so we don't hunt for them in older pages. */
|
|
const deletedMessageIds = useRef(new Set<number>());
|
|
|
|
function flashMessage(id: number) {
|
|
setHighlightedId(id);
|
|
setTimeout(() => setHighlightedId(null), 1500);
|
|
}
|
|
|
|
function scrollToMessage(id: number) {
|
|
// Don't chase a message that was deleted during this session.
|
|
if (deletedMessageIds.current.has(id)) return;
|
|
|
|
const el = document.getElementById(`msg-${id}`);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
flashMessage(id);
|
|
} else if (hasNextPage) {
|
|
// Message not rendered yet — start fetching older pages until it appears.
|
|
setPendingScrollId(id);
|
|
void fetchNextPage();
|
|
}
|
|
// else: exhausted history, message not found — silently ignore.
|
|
}
|
|
|
|
// After every page load, try to resolve a pending scroll-to.
|
|
useEffect(() => {
|
|
if (!pendingScrollId) return;
|
|
const el = document.getElementById(`msg-${pendingScrollId}`);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
flashMessage(pendingScrollId);
|
|
setPendingScrollId(null);
|
|
} else if (!isFetchingNextPage) {
|
|
if (hasNextPage) {
|
|
void fetchNextPage(); // keep paging back until found
|
|
} else {
|
|
setPendingScrollId(null); // exhausted history, give up
|
|
}
|
|
}
|
|
// Re-run whenever a new page arrives or fetching state changes.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [messages.length, isFetchingNextPage]);
|
|
|
|
// 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) => {
|
|
deletedMessageIds.current.add(messageId);
|
|
// Also cancel any pending scroll to this message.
|
|
setPendingScrollId((prev) => (prev === messageId ? null : prev));
|
|
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
|
|
// Remove the deleted message itself.
|
|
.filter((m) => m.id !== messageId)
|
|
// Tombstone reply_to.content for any message quoting the deleted one.
|
|
.map((m) =>
|
|
m.reply_to?.id === messageId
|
|
? { ...m, reply_to: { ...m.reply_to, content: undefined } }
|
|
: m,
|
|
),
|
|
})),
|
|
};
|
|
});
|
|
},
|
|
[queryClient, chatId],
|
|
);
|
|
|
|
const handleSocketEvent = useCallback(
|
|
(event: ChatSocketEvent) => {
|
|
if (event.type === "new_chat_message" || event.type === "new_reply_chat_message") {
|
|
let replyTo: MessageModel["reply_to"] = null;
|
|
if (event.reply_to_id != null) {
|
|
const cached = queryClient.getQueryData<{ pages: CursorPaginated<MessageModel>[] }>(
|
|
messagesQueryKey(chatId),
|
|
);
|
|
const found = cached?.pages.flatMap((p) => p.results).find((m) => m.id === event.reply_to_id);
|
|
replyTo = found
|
|
? { id: found.id, content: found.content, sender: found.sender }
|
|
: { id: event.reply_to_id, content: undefined, sender: { id: 0, username: "…", avatar: null } };
|
|
}
|
|
appendMessage({
|
|
id: event.message_id,
|
|
chat: chatId,
|
|
sender: { id: event.sender_id, username: event.sender, avatar: event.sender_avatar } as never,
|
|
reply_to: replyTo,
|
|
content: event.message,
|
|
is_edited: false,
|
|
edited_at: null,
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
media_files: (event.media_files ?? []) as never,
|
|
reactions: [],
|
|
});
|
|
} 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
|
|
? 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));
|
|
} else if (event.type === "reaction") {
|
|
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.map((m) => {
|
|
if (m.id !== event.message_id) return m;
|
|
let reactions = [...(m.reactions ?? [])] as typeof m.reactions;
|
|
if (event.action === "added") {
|
|
const alreadyExists = reactions.some(
|
|
(r) => (r.user as unknown as number) === event.user_id && r.emoji === event.emoji,
|
|
);
|
|
if (!alreadyExists) {
|
|
reactions = [...reactions, { id: -Date.now(), user: event.user_id, emoji: event.emoji, created_at: new Date().toISOString() } as never];
|
|
}
|
|
} else if (event.action === "removed") {
|
|
reactions = reactions.filter((r) => !((r.user as unknown as number) === event.user_id && r.emoji === event.emoji));
|
|
} else if (event.action === "switched") {
|
|
reactions = reactions.map((r) => (r.user as unknown as number) === event.user_id ? { ...r, emoji: event.emoji } : r);
|
|
}
|
|
return { ...m, reactions };
|
|
}),
|
|
})),
|
|
};
|
|
});
|
|
}
|
|
},
|
|
[appendMessage, removeMessage, chatId, user, queryClient],
|
|
);
|
|
|
|
const { status, sendMessage, sendReply, sendReaction, sendTyping, sendMarkRead } = useChatSocket({
|
|
chatId: Number.isFinite(chatId) ? chatId : null,
|
|
onEvent: handleSocketEvent,
|
|
});
|
|
|
|
// Mark the chat as read as soon as the socket opens.
|
|
useEffect(() => {
|
|
if (status === "open") sendMarkRead();
|
|
}, [status, sendMarkRead]);
|
|
|
|
// Auto-scroll to bottom when new messages arrive.
|
|
// Suppressed while chasing a reply-jump so both effects don't fight each other.
|
|
useEffect(() => {
|
|
if (!pendingScrollId) {
|
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
|
}
|
|
if (status === "open") sendMarkRead();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [messages.length]);
|
|
|
|
async function handleSend(text: string, replyToId?: number, files?: File[]): Promise<boolean> {
|
|
// Files must go via HTTP multipart; WS handles text-only messages.
|
|
if (files?.length) {
|
|
try {
|
|
const msg = await sendChatMessage({ chatId, content: text || undefined, replyToId, files });
|
|
appendMessage(msg); // WS broadcast will deduplicate via the id-check in appendMessage
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
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>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSettingsOpen(true)}
|
|
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-brand-text/60 hover:bg-brand-lines/15 hover:text-brand-text transition-colors"
|
|
aria-label={t("chat.settings.title")}
|
|
>
|
|
<FiMoreVertical size={17} />
|
|
</button>
|
|
</header>
|
|
|
|
{chat && (
|
|
<ChatSettingsModal
|
|
chat={chat as Parameters<typeof ChatSettingsModal>[0]["chat"]}
|
|
open={settingsOpen}
|
|
onClose={() => setSettingsOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
<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")} />
|
|
)}
|
|
|
|
{/* Beginning-of-chat banner — shown only once all pages are confirmed loaded */}
|
|
{!hasNextPage && !isLoading && chat && (
|
|
<div className="flex flex-col items-center gap-3 px-6 py-10 text-center">
|
|
<Avatar
|
|
name={chat.name ?? `Chat ${chatId}`}
|
|
src={chat.icon ?? undefined}
|
|
size={72}
|
|
/>
|
|
<div>
|
|
<p className="text-base font-bold text-brand-text">
|
|
{chat.name || `Chat #${chatId}`}
|
|
</p>
|
|
<p className="mt-1 text-xs text-brand-text/50">
|
|
{t("chat.room.chatBeginning")}
|
|
</p>
|
|
<p className="mt-0.5 text-xs text-brand-text/40">
|
|
{t("chat.room.chatCreated", {
|
|
date: new Date(chat.created_at).toLocaleDateString("cs-CZ", {
|
|
day: "numeric",
|
|
month: "long",
|
|
year: "numeric",
|
|
}),
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className="h-px w-24 bg-brand-lines/20" />
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((m) => (
|
|
<Message
|
|
key={m.id}
|
|
message={m}
|
|
chat={chat ?? null}
|
|
onReply={setReplyTo}
|
|
onReact={(msg, emoji) => sendReaction(msg.id, emoji)}
|
|
highlighted={m.id === highlightedId}
|
|
onScrollToMessage={scrollToMessage}
|
|
/>
|
|
))}
|
|
|
|
<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>
|
|
);
|
|
}
|