updated chat andlayout
This commit is contained in:
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -38,6 +39,53 @@ export default function ChatRoomPage() {
|
||||
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>(
|
||||
() => {
|
||||
@@ -68,6 +116,9 @@ export default function ChatRoomPage() {
|
||||
|
||||
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[];
|
||||
@@ -77,7 +128,15 @@ export default function ChatRoomPage() {
|
||||
...old,
|
||||
pages: old.pages.map((p) => ({
|
||||
...p,
|
||||
results: p.results.filter((m) => m.id !== messageId),
|
||||
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,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -108,7 +167,7 @@ export default function ChatRoomPage() {
|
||||
edited_at: null,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
media_files: [],
|
||||
media_files: (event.media_files ?? []) as never,
|
||||
reactions: [],
|
||||
});
|
||||
} else if (event.type === "delete_chat_message") {
|
||||
@@ -140,14 +199,27 @@ export default function ChatRoomPage() {
|
||||
if (status === "open") sendMarkRead();
|
||||
}, [status, sendMarkRead]);
|
||||
|
||||
// Auto-scroll to bottom and mark chat as read when new messages arrive.
|
||||
// Auto-scroll to bottom when new messages arrive.
|
||||
// Suppressed while chasing a reply-jump so both effects don't fight each other.
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||
if (!pendingScrollId) {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -214,6 +286,35 @@ export default function ChatRoomPage() {
|
||||
<EmptyState message={t("chat.room.noMessages")} />
|
||||
)}
|
||||
|
||||
{/* Beginning-of-chat banner — shown only once all pages are confirmed loaded */}
|
||||
{!hasNextPage && !isLoading && messages.length > 0 && 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}
|
||||
@@ -221,6 +322,8 @@ export default function ChatRoomPage() {
|
||||
chat={chat ?? null}
|
||||
onReply={setReplyTo}
|
||||
onReact={(msg, emoji) => sendReaction(msg.id, emoji)}
|
||||
highlighted={m.id === highlightedId}
|
||||
onScrollToMessage={scrollToMessage}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user