From 225161249e596204e54e8c7affe8a6bc7a4c00a1 Mon Sep 17 00:00:00 2001 From: fazli Date: Sun, 19 Jul 2026 13:22:44 +0000 Subject: [PATCH] feat(chat+push): image attachments, day-labeled inbox with reply state, sub self-heal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/components/thread/MessageBubble.tsx | 26 ++++- app/components/thread/MessageInput.tsx | 115 +++++++++++++++++++++-- app/components/thread/MessageList.tsx | 15 ++- app/components/thread/ThreadChatPage.tsx | 24 +++++ app/components/thread/ThreadsPage.tsx | 90 ++++++++++++++---- app/hooks/usePushNotifications.ts | 34 +++++-- app/hooks/useWebSocket.ts | 9 +- src/api/types/models/index.ts | 42 ++++++++- 8 files changed, 310 insertions(+), 45 deletions(-) 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 && ( + <> + + + + )} +