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

265 lines
9.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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 { Reply, MoreVertical, Trash2, Check } from "lucide-react";
import { Link } from "@remix-run/react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { formatTime, formatDate2 } from "../../utils/helpers";
import { MessageList, ThreadParticipant } from "../../../src/api/types/models";
import { Dialog, DialogContent, DialogOverlay } from "../ui/dialog";
import { Button } from "../ui/button";
import { useState, useRef, useEffect } from "react";
// Message bubble component
//
// isCurrentUser: whether this message belongs to "our side" of the chat.
// In seller-side threads that includes coworkers, not just yourself.
// isCoworker: this message was written by a teammate (owner or staff) other
// than the viewer. We still put it on the right side, but we surface the
// sender's name so you can tell who typed it — and we don't offer to
// delete it (backend only lets a user delete their own messages).
export const MessageBubble = ({
message,
isCurrentUser,
isCoworker = false,
onReply,
onDelete,
replyToMessageContent,
onReplyClick,
isHighlighted = false,
otherParticipant,
}: {
message: MessageList;
isCurrentUser: boolean;
isCoworker?: boolean;
onReply: (message: MessageList) => void;
onDelete?: (messageId: string) => void;
replyToMessageContent?: string | undefined;
onReplyClick?: (replyToId: string) => void;
isHighlighted?: boolean;
otherParticipant: ThreadParticipant | undefined;
}) => {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showDropdownMenu, setShowDropdownMenu] = useState(false);
const messageRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isHighlighted && messageRef.current) {
messageRef.current.classList.add("highlight-message");
// Remove highlight after animation completes
const timeout = setTimeout(() => {
if (messageRef.current) {
messageRef.current.classList.remove("highlight-message");
}
}, 1500);
return () => clearTimeout(timeout);
}
}, [isHighlighted]);
return (
<>
<div
className={`flex items-end gap-1 ${!isCurrentUser ? "flex-row-reverse" : "justify-start flex-row"} mb-2`}
>
<div
ref={messageRef}
className={`rounded-xl px-4 py-3 max-w-[70%] ${
isCurrentUser ? "bg-BLUE2 text-WHITE" : "bg-GRAY3"
}`}
>
{isCoworker && message.senderUsername && (
<div className="text-R10 text-WHITE/80 mb-1 font-bold">
{message.senderUsername}
</div>
)}
{message.replyTo && (
<div
className={`text-xs italic mb-1 pb-1 border-b ${
isCurrentUser ? "border-WHITE/30" : "border-GRAY2/30"
} cursor-pointer hover:opacity-80`}
onClick={() => onReplyClick?.(message.replyTo || "")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onReplyClick?.(message.replyTo || "");
}
}}
role="button"
tabIndex={0}
aria-label="پیام اصلی را نمایش بده"
>
{/* Display replied message content if available */}
{replyToMessageContent || "پاسخ به پیام"}
</div>
)}
{message.product && (
<Link
to={`/product/${message.product.id}`}
className="flex items-center gap-2 mb-1 w-[210px] max-w-full rounded-lg bg-WHITE border border-GRAY3 p-2"
>
{message.product.primaryImage ? (
<img
src={message.product.primaryImage}
alt=""
className="w-12 h-12 rounded-md object-cover shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-md bg-WHITE3 shrink-0" />
)}
<div className="min-w-0 text-right">
<p className="text-R12 text-BLACK truncate">
{message.product.title}
</p>
{message.product.basePrice && (
<p className="text-R10 text-GRAY mt-0.5">
{parseInt(message.product.basePrice).toLocaleString("fa-IR")}{" "}
تومان
</p>
)}
</div>
</Link>
)}
{message.imageUrl && (
<img
src={message.imageUrl}
alt=""
className="max-w-[240px] rounded-lg mb-1 cursor-pointer"
onClick={() => window.open(message.imageUrl, "_blank")}
/>
)}
{message.content && <p className="whitespace-pre-wrap">{message.content}</p>}
</div>
<div className="flex flex-col justify-between items-start">
<div className="flex flex-col gap-1 items-start">
<DropdownMenu
open={showDropdownMenu}
onOpenChange={setShowDropdownMenu}
modal={false}
>
<DropdownMenuTrigger asChild>
<button
onClick={() => setShowDropdownMenu(true)}
className="w-5 h-5 hover:bg-GRAY2 rounded-full flex items-center justify-center cursor-pointer"
aria-label="more options icon"
>
<MoreVertical size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32 z-1">
<DropdownMenuItem
onClick={() => onReply(message)}
className="cursor-pointer justify-end"
>
<Reply size={14} />
<span className="text-R12">پاسخ</span>
</DropdownMenuItem>
{isCurrentUser && !isCoworker && (
<DropdownMenuItem
onClick={() => {
setShowDropdownMenu(false);
setShowDeleteDialog(true);
}}
className="cursor-pointer text-red-500 justify-end"
>
<Trash2 size={14} />
<span className="text-R12">حذف پیام</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-1">
<p
className={`text-R10 ${
!isCurrentUser ? "text-right" : "text-left"
} text-GRAY`}
>
{formatTime(message.createdAt || "")}
</p>
{isCurrentUser && (
<div className="text-GRAY">
{new Date(message.createdAt || "").getTime() <
new Date(otherParticipant?.lastReadAt || "").getTime() ? (
<div className="flex items-center">
<Check size={12} className="text-blue-500" />
<Check
size={12}
className="text-blue-500 relative left-2"
/>
</div>
) : (
<Check size={12} />
)}
</div>
)}
</div>
</div>
</div>
<DeleteConfirmBox
showDeleteDialog={showDeleteDialog}
setShowDeleteDialog={setShowDeleteDialog}
confirmDelete={() => onDelete?.(message.id || "")}
/>
</>
);
};
const DeleteConfirmBox = ({
showDeleteDialog,
setShowDeleteDialog,
confirmDelete,
}: {
showDeleteDialog: boolean;
setShowDeleteDialog: (show: boolean) => void;
confirmDelete: () => void;
}) => {
return (
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogOverlay className="bg-BLACK/20" />
<DialogContent className="p-4 bg-WHITE rounded-[15px] w-[calc(100%-40px)]">
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-3 items-center w-full">
<Trash2 size={24} className="text-RED" />
<p className="text-B12 font-bold mt-1">حذف پیام</p>
<p className="text-R12">میخواهید این پیام را حذف کنید؟</p>
</div>
<div className="flex flex-row gap-6 w-full">
<Button
className="w-[100%]"
size={"sm"}
variant="dark"
onClick={() => setShowDeleteDialog(false)}
>
انصراف
</Button>
<Button
className="w-[100%]"
size={"sm"}
variant="primary"
onClick={() => {
confirmDelete();
setShowDeleteDialog(false);
}}
>
حذف پیام
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
// Date separator component
export const DateSeparator = ({ date }: { date: string }) => {
return (
<div className="flex items-center justify-center my-4">
<div className="border-t border-GRAY3 flex-grow"></div>
<div className="px-3 text-R12 text-GRAY">{formatDate2(date)}</div>
<div className="border-t border-GRAY3 flex-grow"></div>
</div>
);
};