Vitron-Front/app/components/thread/MessageList.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

214 lines
6.6 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 { isDifferentDay } from "../../utils/helpers";
import { DateSeparator, MessageBubble } from "./MessageBubble";
import {
MessageList as MessageListType,
ThreadParticipant,
} from "../../../src/api/types/models";
import { Loader2, ArrowDown } from "lucide-react";
import {
useState,
useRef,
useEffect,
forwardRef,
ForwardedRef,
useImperativeHandle,
} from "react";
// Message List component
const MessageList = forwardRef(
(
{
messages,
isLoading,
isFetching,
userId,
teamUserIds,
onReply,
onDelete,
onScroll,
otherParticipant,
}: {
messages: MessageListType[];
isLoading: boolean;
isFetching: boolean;
userId: string | undefined;
// When present, any message sender in this set is treated as "our side"
// (right-aligned, blue). Lets staff see coworkers' messages on the team
// side of the store inbox instead of confusing them with customer messages.
teamUserIds?: readonly string[];
onReply: (message: MessageListType) => void;
onDelete?: (messageId: string) => void;
onScroll?: (event: React.UIEvent<HTMLDivElement>) => void;
otherParticipant: ThreadParticipant | undefined;
},
ref: ForwardedRef<{
scrollToBottom: () => void;
getContainer: () => HTMLDivElement | null;
}>
) => {
const [highlightedMessageId, setHighlightedMessageId] = useState<
string | null
>(null);
const messageRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [showScrollButton, setShowScrollButton] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Expose methods to parent component
useImperativeHandle(ref, () => ({
scrollToBottom: () => {
if (containerRef.current) {
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight,
});
}
},
getContainer: () => containerRef.current,
}));
// Handle scroll events to show/hide the scroll button
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = (e: Event) => {
// Show button when scrolled up more than 300px from bottom
const isScrolledUp =
container.scrollHeight -
container.scrollTop -
container.clientHeight >
300;
setShowScrollButton(isScrolledUp);
// Call parent's onScroll handler if provided
if (onScroll && e instanceof UIEvent) {
onScroll(e as unknown as React.UIEvent<HTMLDivElement>);
}
};
container.addEventListener("scroll", handleScroll);
return () => container.removeEventListener("scroll", handleScroll);
}, [onScroll]);
const scrollToBottom = () => {
if (containerRef.current) {
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight + 100,
behavior: "smooth",
});
}
};
if (isLoading && !messages.length) {
return (
<p className="text-GRAY text-center">درحال بارگذاری پیامها...</p>
);
}
if (messages.length === 0) {
return (
<p className="text-GRAY text-center">
هنوز پیامی ارسال نشده. مکالمه را شروع کنید!
</p>
);
}
// Handle reply click to scroll to original message
const handleReplyClick = (replyToId: string) => {
const targetMessage = messages.find((m) => m.id === replyToId);
if (targetMessage) {
// Reset the highlighted message ID first to ensure the animation can trigger again
setHighlightedMessageId(null);
// Use setTimeout to ensure the state update has processed
setTimeout(() => {
// Then set the highlighted message ID
setHighlightedMessageId(replyToId);
// Scroll to the message
const messageElement = messageRefs.current[replyToId];
if (messageElement) {
messageElement.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}, 300);
}
};
return (
<div
className="flex flex-col pt-[60px] relative h-[calc(100vh-64px)] overflow-auto px-2 pb-8"
style={{ scrollbarWidth: "inherit" }}
ref={containerRef}
onScroll={onScroll}
>
{isFetching && (
<div className="flex items-center justify-center py-2">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
)}
{messages.map((message, index) => (
<div
key={message.id || index}
ref={(el) => {
if (message.id) {
messageRefs.current[message.id] = el;
}
}}
>
{/* Date separator - show when day changes between messages */}
{(index === 0 ||
isDifferentDay(
message.createdAt || "",
messages[index - 1]?.createdAt
)) && <DateSeparator date={message.createdAt || ""} />}
{/* Message bubble */}
<MessageBubble
otherParticipant={otherParticipant}
message={message}
isCurrentUser={
message.sender === userId ||
(!!message.sender && !!teamUserIds?.includes(message.sender))
}
isCoworker={
message.sender !== userId &&
!!message.sender &&
!!teamUserIds?.includes(message.sender)
}
onReply={onReply}
onDelete={onDelete}
replyToMessageContent={message.replyContent || ""}
onReplyClick={handleReplyClick}
isHighlighted={highlightedMessageId === message.id}
/>
</div>
))}
{/* Floating scroll to bottom button with animation */}
<div
className={`fixed bottom-16 right-4 z-30 transition-all duration-300 ${
showScrollButton
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-10 pointer-events-none"
}`}
>
<button
onClick={scrollToBottom}
className="w-8 h-8 bg-primary text-WHITE rounded-full flex items-center justify-center shadow-lg hover:bg-primary/90 transition-all z-10"
aria-label="رفتن به پایین"
>
<ArrowDown className="w-4 h-4" />
</button>
</div>
</div>
);
}
);
// Add display name
MessageList.displayName = "MessageList";
export default MessageList;