diff --git a/app/components/thread/MessageBubble.tsx b/app/components/thread/MessageBubble.tsx
index 2f12749..8ee236e 100644
--- a/app/components/thread/MessageBubble.tsx
+++ b/app/components/thread/MessageBubble.tsx
@@ -15,9 +15,17 @@ 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,
@@ -27,6 +35,7 @@ export const MessageBubble = ({
}: {
message: MessageList;
isCurrentUser: boolean;
+ isCoworker?: boolean;
onReply: (message: MessageList) => void;
onDelete?: (messageId: string) => void;
replyToMessageContent?: string | undefined;
@@ -63,6 +72,11 @@ export const MessageBubble = ({
isCurrentUser ? "bg-BLUE2 text-WHITE" : "bg-GRAY3"
}`}
>
+ {isCoworker && message.senderUsername && (
+
+ {message.senderUsername}
+
+ )}
{message.replyTo && (
)}
- {message.content &&
{message.content}
}
+ {message.imageUrl && (
+
window.open(message.imageUrl, "_blank")}
+ />
+ )}
+ {message.content &&
{message.content}
}
@@ -135,7 +157,7 @@ export const MessageBubble = ({
پاسخ
- {isCurrentUser && (
+ {isCurrentUser && !isCoworker && (
{
setShowDropdownMenu(false);
diff --git a/app/components/thread/MessageInput.tsx b/app/components/thread/MessageInput.tsx
index bd17039..8046e88 100644
--- a/app/components/thread/MessageInput.tsx
+++ b/app/components/thread/MessageInput.tsx
@@ -1,12 +1,24 @@
-import { ArrowRight, Loader2, Reply, SmilePlus, X } from "lucide-react";
+import { ArrowRight, Image as ImageIcon, Loader2, Reply, SmilePlus, X } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { useRef, useState } from "react";
import EmojiPicker from "./EmojiPicker";
import type { MessageList } from "../../../src/api/types/models";
+import { useAuthToken } from "../../hooks/use-root-data";
+import { getApiBaseUrl } from "../../utils/api-config";
+import { toast } from "../../hooks/use-toast";
+
+const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
+const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic", "image/heif"];
// Message input component
+//
+// `canSendImage` toggles the paperclip button — only team members (owner or
+// active staff of the thread's seller) can attach images. The buyer side
+// leaves it off so customers can only send text.
const MessageInput = ({
onSend,
+ onSendImage,
+ canSendImage = false,
replyToMessage,
onCancelReply,
messageText,
@@ -15,6 +27,8 @@ const MessageInput = ({
setReplyToMessage,
}: {
onSend: (text: string) => void;
+ onSendImage?: (imageUrl: string) => void;
+ canSendImage?: boolean;
replyToMessage: MessageList | undefined;
onCancelReply: () => void;
inputRef: React.RefObject;
@@ -23,6 +37,10 @@ const MessageInput = ({
isSendingMessage: boolean;
setReplyToMessage: (message: MessageList | undefined) => void;
}) => {
+ const token = useAuthToken();
+ const apiBase = getApiBaseUrl();
+ const fileInputRef = useRef(null);
+ const [isUploadingImage, setIsUploadingImage] = useState(false);
const [caretPosition, setCaretPosition] = useState(null);
// Track current textarea height
@@ -77,6 +95,65 @@ const MessageInput = ({
setMessageText(e.target.value);
};
+ const handlePickImage = () => {
+ if (!canSendImage || isUploadingImage) return;
+ fileInputRef.current?.click();
+ };
+
+ const handleFileSelected = async (
+ e: React.ChangeEvent,
+ ) => {
+ const file = e.target.files?.[0];
+ // Reset the input so the same file can be re-picked after cancel.
+ if (e.target) e.target.value = "";
+ if (!file || !canSendImage || !onSendImage) return;
+
+ if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
+ toast({
+ title: "فرمت پشتیبانی نمیشود",
+ description: "فقط تصاویر JPG / PNG / WebP / HEIC مجاز است.",
+ variant: "destructive",
+ });
+ return;
+ }
+ if (file.size > MAX_IMAGE_BYTES) {
+ toast({
+ title: "حجم زیاد",
+ description: "حداکثر حجم تصویر ۸ مگابایت است.",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ setIsUploadingImage(true);
+ try {
+ const form = new FormData();
+ form.append("file", file);
+ form.append("usage", "chat");
+ const headers: Record = {};
+ if (token) headers["Authorization"] = `Bearer ${token}`;
+ const res = await fetch(`${apiBase}/api/media/v1/upload/`, {
+ method: "POST",
+ headers,
+ body: form,
+ });
+ if (!res.ok) throw new Error(`upload failed: ${res.status}`);
+ const data = await res.json();
+ const url: string | undefined = data.originalUrl || data.original_url;
+ if (!url) throw new Error("no url in upload response");
+ onSendImage(url);
+ } catch (err) {
+ console.error("chat image upload failed:", err);
+ toast({
+ title: "خطا در ارسال تصویر",
+ description: "لطفاً دوباره تلاش کنید.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsUploadingImage(false);
+ }
+ };
+
return (
<>
{/* Reply indicator */}
@@ -130,6 +207,31 @@ const MessageInput = ({
+ {canSendImage && (
+ <>
+
+
+ {isUploadingImage ? (
+
+ ) : (
+
+ )}
+
+ >
+ )}
+