- 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>
265 lines
9.3 KiB
TypeScript
265 lines
9.3 KiB
TypeScript
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>
|
||
);
|
||
};
|