- 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>
273 lines
9.4 KiB
TypeScript
273 lines
9.4 KiB
TypeScript
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,
|
||
setMessageText,
|
||
isSendingMessage,
|
||
setReplyToMessage,
|
||
}: {
|
||
onSend: (text: string) => void;
|
||
onSendImage?: (imageUrl: string) => void;
|
||
canSendImage?: boolean;
|
||
replyToMessage: MessageList | undefined;
|
||
onCancelReply: () => void;
|
||
inputRef: React.RefObject<HTMLInputElement>;
|
||
messageText: string;
|
||
setMessageText: React.Dispatch<React.SetStateAction<string>>;
|
||
isSendingMessage: boolean;
|
||
setReplyToMessage: (message: MessageList | undefined) => void;
|
||
}) => {
|
||
const token = useAuthToken();
|
||
const apiBase = getApiBaseUrl();
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||
const [caretPosition, setCaretPosition] = useState<number | null>(null);
|
||
// Track current textarea height
|
||
|
||
const handleSend = () => {
|
||
if (!messageText.trim() || isSendingMessage) return;
|
||
onSend(messageText.trim());
|
||
};
|
||
|
||
// Handle emoji selection
|
||
const handleSelectEmoji = (selectedEmoji: string) => {
|
||
if (textareaRef.current) {
|
||
// Get current cursor position
|
||
const cursorPosition =
|
||
caretPosition !== null
|
||
? caretPosition
|
||
: textareaRef.current.selectionStart;
|
||
|
||
// Insert emoji at cursor position
|
||
const newText =
|
||
messageText.substring(0, cursorPosition) +
|
||
selectedEmoji +
|
||
messageText.substring(cursorPosition);
|
||
|
||
setMessageText(newText);
|
||
|
||
// After state update, set cursor position after the inserted emoji
|
||
setTimeout(() => {
|
||
if (textareaRef.current) {
|
||
const newPosition = cursorPosition + selectedEmoji.length;
|
||
textareaRef.current.focus();
|
||
textareaRef.current.setSelectionRange(newPosition, newPosition);
|
||
}
|
||
}, 0);
|
||
} else {
|
||
// Fallback if ref not available
|
||
setMessageText((prev) => prev + selectedEmoji);
|
||
}
|
||
};
|
||
|
||
// Store caret position on selection change
|
||
const handleTextareaSelect = () => {
|
||
if (textareaRef.current) {
|
||
setCaretPosition(textareaRef.current.selectionStart);
|
||
}
|
||
};
|
||
|
||
// Auto-growing textarea ref
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
// Handle text change and update height
|
||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||
setMessageText(e.target.value);
|
||
};
|
||
|
||
const handlePickImage = () => {
|
||
if (!canSendImage || isUploadingImage) return;
|
||
fileInputRef.current?.click();
|
||
};
|
||
|
||
const handleFileSelected = async (
|
||
e: React.ChangeEvent<HTMLInputElement>,
|
||
) => {
|
||
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<string, string> = {};
|
||
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 */}
|
||
{replyToMessage && (
|
||
<div className="max-w-md mx-auto bg-GRAY1 p-2 mb-2 rounded-lg flex items-center">
|
||
<div className="flex-1 text-GRAY overflow-hidden">
|
||
<p className="text-R12">در پاسخ به:</p>
|
||
<p className="text-R14 truncate">{replyToMessage.content}</p>
|
||
</div>
|
||
<button onClick={() => setReplyToMessage(undefined)} className="ml-2">
|
||
<X className="h-5 w-5 text-GRAY" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="max-w-md mx-auto fixed px-2 bottom-0 pt-2 pb-[calc(0.5rem_+_env(safe-area-inset-bottom))] w-full bg-WHITE">
|
||
{replyToMessage && (
|
||
<div className="bg-GRAY3 rounded-lg p-2 flex justify-between items-center mb-2">
|
||
<div className="flex items-center">
|
||
<Reply className="w-4 h-4 mr-2" />
|
||
<span className="text-sm truncate max-w-[250px]">
|
||
{replyToMessage.content}
|
||
</span>
|
||
</div>
|
||
<button
|
||
onClick={onCancelReply}
|
||
className="text-GRAY hover:text-BLACK2"
|
||
aria-label="حذف پاسخ"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="relative rounded-[12px] bg-WHITE3 flex items-end">
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<button
|
||
className="absolute right-3 bottom-3 w-6 h-6 flex items-center justify-center"
|
||
aria-label="انتخاب ایموجی"
|
||
>
|
||
<SmilePlus className="text-GRAY hover:text-BLACK" size={20} />
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
align="start"
|
||
side="top"
|
||
className="w-[280px] p-0"
|
||
sideOffset={5}
|
||
>
|
||
<EmojiPicker onSelectEmoji={handleSelectEmoji} />
|
||
</PopoverContent>
|
||
</Popover>
|
||
|
||
{canSendImage && (
|
||
<>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept={ALLOWED_IMAGE_TYPES.join(",")}
|
||
onChange={handleFileSelected}
|
||
className="hidden"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={handlePickImage}
|
||
disabled={isUploadingImage}
|
||
className="absolute right-10 bottom-3 w-6 h-6 flex items-center justify-center disabled:opacity-50"
|
||
aria-label="ارسال تصویر"
|
||
>
|
||
{isUploadingImage ? (
|
||
<Loader2 className="w-5 h-5 animate-spin text-GRAY" />
|
||
) : (
|
||
<ImageIcon className="w-5 h-5 text-GRAY hover:text-BLACK" />
|
||
)}
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={messageText}
|
||
onChange={handleTextChange}
|
||
onSelect={handleTextareaSelect}
|
||
placeholder={
|
||
replyToMessage
|
||
? "پاسخ خود را بنویسید..."
|
||
: "پیام خود را بنویسید..."
|
||
}
|
||
className="hide-scrollbar overflow-y-auto h-12 flex w-full rounded-[12px] border border-input bg-transparent px-3 pr-10 py-2 text-base shadow-sm resize-none outline-none transition-height duration-100"
|
||
style={{
|
||
paddingRight: canSendImage ? "72px" : "40px",
|
||
paddingLeft: "48px",
|
||
direction: "rtl",
|
||
}}
|
||
// Enter inserts a newline. Send is via the button only — matches
|
||
// seller request: multi-line messages are common when replying to
|
||
// a customer with details, and accidental Enter-sends were noisy.
|
||
/>
|
||
<button
|
||
onClick={handleSend}
|
||
disabled={!messageText.trim()}
|
||
className="absolute left-3 bottom-2 p-2 bg-BLUE2 text-WHITE rounded-full flex items-center justify-center disabled:opacity-50"
|
||
aria-label="ارسال پیام"
|
||
>
|
||
{isSendingMessage ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<ArrowRight className="w-4 h-4 transform rotate-180" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
};
|
||
export default MessageInput;
|