Vitron-Front/app/components/thread/ThreadChatPage.tsx
Arda Samadi b4d5f8d8db fix(ios): respect safe-area insets so chrome clears the notch/home bar
Content was drawing under the iOS status bar and home indicator because
the app uses viewport-fit=cover + black-translucent but the fixed/sticky
chrome didn't reserve the safe areas.

- bottom nav (MyLayout): move the 50px row into an inner box and let the
  bar extend by env(safe-area-inset-bottom) so icons sit above the home
  indicator (previously the fixed height clipped the inset padding).
- top headers gain env(safe-area-inset-top): HomePageTopBar, ExploreTopBar,
  SearchTopBar, CartHeader, ChatHeader, and wrapper-padded HeaderWithSupport
  / ProfilePagesHeader (so their absolutely-positioned buttons shift too).
- chat input pads bottom by the inset; chat scroll area height accounts for
  the taller header/input.
- _index.tsx no longer overrides the viewport meta (it was dropping
  viewport-fit=cover on the home page); the global one in root.tsx wins.

env(safe-area-inset-*) is 0 on non-notch devices, so desktop/Android render
identically — only iOS gains the padding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:42:51 +03:30

288 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
useGetThreadDetails,
useGetThreadMessages,
setMessagesCache,
deleteMessageFromCache,
} from "../../requestHandler/use-chat-hooks";
import { useState, useRef, useEffect } from "react";
import { Loader2 } from "lucide-react";
import { useServiceGetProfile } from "../../requestHandler/use-profile-hooks";
import MessageList from "./MessageList";
import ChatHeader from "./ChatHeader";
import MessageInput from "./MessageInput";
import {
MessageList as MessageListType,
ThreadDetail,
ThreadParticipant,
} from "../../../src/api/types/models";
import useWebSocket from "../../hooks/useWebSocket";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "../../hooks/use-toast";
interface ThreadChatPageProps {
threadId: string;
}
export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
// Scroll to bottom on initial load
const [isInitialLoad, setIsInitialLoad] = useState(true);
const { data: threadDetails, isLoading: isLoadingThread } =
useGetThreadDetails(threadId);
const [replyToMessage, setReplyToMessage] = useState<
MessageListType | undefined
>(undefined);
// Load messages using infinite query
const {
data: messagesData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: isLoadingMessages,
} = useGetThreadMessages(threadId);
// Flatten all pages of messages into a single array
const allMessages = messagesData?.pages
? messagesData.pages.flatMap((page) => page.results)
: [];
const messageListRef = useRef<{
scrollToBottom: () => void;
getContainer: () => HTMLDivElement | null;
}>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [messageText, setMessageText] = useState("");
const { data: user } = useServiceGetProfile();
const [prevScrollHeight, setPrevScrollHeight] = useState(0);
const queryClient = useQueryClient();
// WebSocket implementation
const {
isConnected,
sendWebSocketMessage,
markMessagesAsRead,
deleteMessage,
newMessage,
readReceipt,
messageDeleted,
resetReadReceipt,
resetNewMessage,
resetMessageDeleted,
} = useWebSocket(threadId, user?.id);
// Handle new messages from WebSocket
useEffect(() => {
if (newMessage) {
setMessagesCache(queryClient, threadId, {
id: newMessage.message_id,
content: newMessage.content,
sender: newMessage.sender_id,
createdAt: new Date().toISOString(),
replyTo: newMessage.reply_to_id,
replyContent: newMessage.reply_content || "",
});
resetNewMessage();
setTimeout(() => {
if (messageListRef.current) {
messageListRef.current.scrollToBottom();
}
}, 100);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newMessage]);
const otherParticipant =
threadDetails?.participants?.find(
(participant) => participant.username !== user?.username
) || undefined;
useEffect(() => {
if (isConnected && !isLoadingMessages && allMessages.length > 0) {
markMessagesAsRead();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isConnected, isLoadingMessages, allMessages.length]);
useEffect(() => {
return () => {
queryClient.removeQueries({
queryKey: ["getThreadMessages", threadId],
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (readReceipt) {
queryClient.setQueryData(
["getThreadDetails", threadId],
(oldData: ThreadDetail) => {
if (!oldData) return oldData;
return {
...oldData,
participants: oldData.participants?.map(
(participant: ThreadParticipant) => {
if (
participant.username &&
participant.username.toString() ===
readReceipt.reader_username
) {
return {
...participant,
lastReadAt: new Date().toISOString(),
};
}
return participant;
}
),
};
}
);
resetReadReceipt();
}
}, [readReceipt, threadId, queryClient, resetReadReceipt]);
useEffect(() => {
if (
!isLoadingMessages &&
messageListRef.current &&
allMessages.length > 0 &&
isConnected
) {
if (isInitialLoad) {
setIsInitialLoad(false);
setTimeout(() => {
if (messageListRef.current) {
messageListRef.current.scrollToBottom();
}
}, 100);
}
}
}, [isLoadingMessages, allMessages.length, isInitialLoad, isConnected]);
// Add effect to handle message deleted events
useEffect(() => {
if (messageDeleted) {
deleteMessageFromCache(queryClient, threadId, messageDeleted.message_id);
resetMessageDeleted();
const isOwner = messageDeleted.sender_id === user?.id;
if (isOwner) {
toast({
title: "پیام با موفقیت حذف شد",
description: "پیام شما با موفقیت حذف شد.",
});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messageDeleted]);
// Handle scroll to load more messages
const handleScroll = () => {
if (!messageListRef.current || isInitialLoad) return;
const container = messageListRef.current.getContainer();
if (!container) return;
const { scrollTop } = container;
// Load more messages when scrolled near the top
if (scrollTop < 100 && hasNextPage && !isFetchingNextPage) {
// Save the current scroll height before loading more messages
setPrevScrollHeight(container.scrollHeight);
fetchNextPage();
}
};
// Maintain scroll position after loading more messages
useEffect(() => {
if (!isFetchingNextPage && prevScrollHeight > 0 && messageListRef.current) {
const container = messageListRef.current.getContainer();
if (!container) return;
// Calculate the new position by finding the difference in height
const newScrollHeight = container.scrollHeight;
const scrollDifference = newScrollHeight - prevScrollHeight;
// Adjust scroll position to maintain the view
container.scrollTop = scrollDifference;
// Reset the previous height
setPrevScrollHeight(0);
}
}, [isFetchingNextPage, prevScrollHeight]);
// Handle sending message
const handleSendMessage = (text: string) => {
if (isConnected) {
sendWebSocketMessage(text, replyToMessage?.id, replyToMessage?.content);
}
setReplyToMessage(undefined);
setMessageText("");
};
// Handle reply to message
const handleReplyClick = (message: MessageListType) => {
setReplyToMessage(message);
// Focus on the input field
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 0);
};
// Handle message deletion
const handleDeleteMessage = (messageId: string) => {
if (isConnected) {
deleteMessage(messageId);
}
};
return (
<div className="flex flex-col">
<ChatHeader
isConnected={isConnected}
otherParticipant={otherParticipant}
threadDetails={threadDetails}
/>
{!isConnected ? (
<div className="flex flex-col items-center justify-center h-[300px] w-full">
<Loader2 size={24} className="animate-spin mt-10" />
<p className="text-R12 text-GRAY">
در حال اتصال به سرور پیامرسانی...
</p>
</div>
) : (
<>
<div
className={`hide-scrollbar flex-grow w-full max-h-[calc(100vh_-_${replyToMessage ? "120px" : "75px"}_-_env(safe-area-inset-top)_-_env(safe-area-inset-bottom))] flex flex-col`}
>
{/* Messages list */}
<MessageList
otherParticipant={otherParticipant}
ref={messageListRef}
messages={[...allMessages].reverse()}
isLoading={isLoadingMessages || isLoadingThread}
isFetching={isFetchingNextPage}
userId={user?.id}
onReply={handleReplyClick}
onDelete={handleDeleteMessage}
onScroll={handleScroll}
/>
</div>
{/* Message input component */}
<MessageInput
setReplyToMessage={setReplyToMessage}
isSendingMessage={false}
messageText={messageText}
setMessageText={setMessageText}
onSend={handleSendMessage}
replyToMessage={replyToMessage}
onCancelReply={() => setReplyToMessage(undefined)}
inputRef={inputRef}
/>
</>
)}
</div>
);
}