- 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>
214 lines
6.6 KiB
TypeScript
214 lines
6.6 KiB
TypeScript
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;
|