Vitron-Front/app/components/thread/ThreadChatPage.tsx
fazli 225161249e feat(chat+push): image attachments, day-labeled inbox with reply state, sub self-heal
- MessageInput: paperclip button (team-only, controlled by teamUserIds
  from ThreadDetail) uploads through the media service and hands the URL
  to the WebSocket; Enter now inserts a newline — sending is button-only
  since accidental Enter-sends were noisy for staff replies.
- MessageBubble: renders image messages inline (tap to open), shows the
  coworker's username at the top of team-side bubbles so staff can tell
  who typed what.
- MessageList / ThreadChatPage: any message from a store team member
  (owner or active staff) renders on the "our" side of the bubble even
  for a viewer who isn't the sender, using team_user_ids from
  ThreadDetail. Delete option stays gated to the actual sender.
- ThreadsPage: inbox now shows smart-relative timestamps
  (today HH:MM / دیروز HH:MM / N روز پیش / Persian date), plus a
  reply-state icon per row (red bell = customer waiting, blue double-check
  = you replied and they've seen it, nothing = you replied not yet seen).
  Image-only last messages preview as "📷 تصویر".
- Push notifications: on mount we re-POST the current PushSubscription so
  a browser-rotated sub or a backend row deleted after a 410 auto-heals.
- useWebSocket: SendMessagePayload and NewMessageEvent carry image_url so
  the server relay can round-trip images.
- API types: MessageList.imageUrl, LastMessage.imageUrl,
  ThreadList.lastMessageFromTeam/otherPartySeenLast, ThreadDetail.teamUserIds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 13:22:44 +00:00

312 lines
9.4 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 || "",
imageUrl: newMessage.image_url || undefined,
});
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("");
};
// Send an image the user already uploaded. Content stays empty; the bubble
// renders the image with no accompanying text.
const handleSendImage = (imageUrl: string) => {
if (isConnected) {
sendWebSocketMessage(
"",
replyToMessage?.id,
replyToMessage?.content,
imageUrl,
);
}
setReplyToMessage(undefined);
};
// Team membership check: current user in teamUserIds → can attach images.
const currentUserIsTeam = !!(
user?.id &&
threadDetails?.teamUserIds?.includes(user.id)
);
// 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}
teamUserIds={threadDetails?.teamUserIds}
onReply={handleReplyClick}
onDelete={handleDeleteMessage}
onScroll={handleScroll}
/>
</div>
{/* Message input component */}
<MessageInput
setReplyToMessage={setReplyToMessage}
isSendingMessage={false}
messageText={messageText}
setMessageText={setMessageText}
onSend={handleSendMessage}
onSendImage={handleSendImage}
canSendImage={currentUserIsTeam}
replyToMessage={replyToMessage}
onCancelReply={() => setReplyToMessage(undefined)}
inputRef={inputRef}
/>
</>
)}
</div>
);
}