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

273 lines
9.4 KiB
TypeScript
Raw 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 { 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;