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>
This commit is contained in:
fazli 2026-07-19 13:22:44 +00:00
parent 56fcce35c8
commit 225161249e
8 changed files with 310 additions and 45 deletions

View File

@ -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 && (
<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 ${
@ -109,7 +123,15 @@ export const MessageBubble = ({
</div>
</Link>
)}
{message.content && <p>{message.content}</p>}
{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">
@ -135,7 +157,7 @@ export const MessageBubble = ({
<Reply size={14} />
<span className="text-R12">پاسخ</span>
</DropdownMenuItem>
{isCurrentUser && (
{isCurrentUser && !isCoworker && (
<DropdownMenuItem
onClick={() => {
setShowDropdownMenu(false);

View File

@ -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<HTMLInputElement>;
@ -23,6 +37,10 @@ const MessageInput = ({
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
@ -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<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 */}
@ -130,6 +207,31 @@ const MessageInput = ({
</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}
@ -142,16 +244,13 @@ const MessageInput = ({
}
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: "40px",
paddingRight: canSendImage ? "72px" : "40px",
paddingLeft: "48px",
direction: "rtl",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
// 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}

View File

@ -22,6 +22,7 @@ const MessageList = forwardRef(
isLoading,
isFetching,
userId,
teamUserIds,
onReply,
onDelete,
onScroll,
@ -31,6 +32,10 @@ const MessageList = forwardRef(
isLoading: boolean;
isFetching: boolean;
userId: string | undefined;
// When present, any message sender in this set is treated as "our side"
// (right-aligned, blue). Lets staff see coworkers' messages on the team
// side of the store inbox instead of confusing them with customer messages.
teamUserIds?: readonly string[];
onReply: (message: MessageListType) => void;
onDelete?: (messageId: string) => void;
onScroll?: (event: React.UIEvent<HTMLDivElement>) => void;
@ -163,7 +168,15 @@ const MessageList = forwardRef(
<MessageBubble
otherParticipant={otherParticipant}
message={message}
isCurrentUser={message.sender === userId}
isCurrentUser={
message.sender === userId ||
(!!message.sender && !!teamUserIds?.includes(message.sender))
}
isCoworker={
message.sender !== userId &&
!!message.sender &&
!!teamUserIds?.includes(message.sender)
}
onReply={onReply}
onDelete={onDelete}
replyToMessageContent={message.replyContent || ""}

View File

@ -80,6 +80,7 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
createdAt: new Date().toISOString(),
replyTo: newMessage.reply_to_id,
replyContent: newMessage.reply_content || "",
imageUrl: newMessage.image_url || undefined,
});
resetNewMessage();
setTimeout(() => {
@ -219,6 +220,26 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
setMessageText("");
};
// Send an image the user already uploaded. Content stays empty; the bubble
// renders the image with no accompanying text.
const handleSendImage = (imageUrl: string) => {
if (isConnected) {
sendWebSocketMessage(
"",
replyToMessage?.id,
replyToMessage?.content,
imageUrl,
);
}
setReplyToMessage(undefined);
};
// Team membership check: current user in teamUserIds → can attach images.
const currentUserIsTeam = !!(
user?.id &&
threadDetails?.teamUserIds?.includes(user.id)
);
// Handle reply to message
const handleReplyClick = (message: MessageListType) => {
setReplyToMessage(message);
@ -264,6 +285,7 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
isLoading={isLoadingMessages || isLoadingThread}
isFetching={isFetchingNextPage}
userId={user?.id}
teamUserIds={threadDetails?.teamUserIds}
onReply={handleReplyClick}
onDelete={handleDeleteMessage}
onScroll={handleScroll}
@ -276,6 +298,8 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
messageText={messageText}
setMessageText={setMessageText}
onSend={handleSendMessage}
onSendImage={handleSendImage}
canSendImage={currentUserIsTeam}
replyToMessage={replyToMessage}
onCancelReply={() => setReplyToMessage(undefined)}
inputRef={inputRef}

View File

@ -13,9 +13,33 @@ import {
import { useServiceGetProfile } from "../../requestHandler/use-profile-hooks";
import UiProvider from "../UiProvider";
import SellerLogo from "../SellerLogo";
import { Search, User } from "lucide-react";
import { CheckCheck, MessageCircleWarning, Search, User } from "lucide-react";
import ProfilePagesHeader from "../ProfilePagesHeader";
// Persian-style relative timestamp for the inbox list. Older messages need a
// day label so the seller can tell "today's message" from "3 months ago"
// without reading the date.
function formatThreadTimestamp(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const now = new Date();
const startOfDay = (x: Date) =>
new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const dayDiff = Math.round(
(startOfDay(now) - startOfDay(d)) / (1000 * 60 * 60 * 24),
);
const timePart = d.toLocaleTimeString("fa-IR", {
hour: "2-digit",
minute: "2-digit",
});
if (dayDiff <= 0) return timePart; // today: just HH:MM
if (dayDiff === 1) return `دیروز ${timePart}`;
if (dayDiff < 7) return `${dayDiff} روز پیش`;
// Older than a week: show the date in Persian calendar.
return d.toLocaleDateString("fa-IR");
}
interface ThreadsPageProps {
type: "user" | "store";
storeId?: string;
@ -109,6 +133,24 @@ const ThreadsList = ({
);
const hasUnread = Number(thread.unreadCount || "0") > 0;
// Seller-inbox only. Shows what state the thread is in so the team
// can see at a glance which chats need attention.
// - "needs-reply" → last message is from the customer (or nobody
// on the team has replied yet).
// - "unread-by-customer" → team replied, customer hasn't seen it.
// - "seen" → team replied and the customer opened it.
const showStatus = type === "store";
const lastFromTeam = !!thread.lastMessageFromTeam;
const otherSeen = !!thread.otherPartySeenLast;
const status: "needs-reply" | "unread-by-customer" | "seen" | null =
!showStatus
? null
: !lastFromTeam
? "needs-reply"
: otherSeen
? "seen"
: "unread-by-customer";
// Generate link based on type
const linkTo =
type === "user"
@ -128,22 +170,32 @@ const ThreadsList = ({
)}
<div className="flex flex-col w-full">
<div className="w-full flex justify-between items-center">
<p className={`text-R14 ${hasUnread ? "font-bold" : ""}`}>
<div className="flex items-center gap-1 min-w-0">
{status === "needs-reply" && (
<MessageCircleWarning
size={16}
className="text-RED shrink-0"
aria-label="در انتظار پاسخ شما"
/>
)}
{status === "seen" && (
<CheckCheck
size={16}
className="text-blue-500 shrink-0"
aria-label="پیام دیده شده"
/>
)}
<p className={`text-R14 truncate ${hasUnread || status === "needs-reply" ? "font-bold" : ""}`}>
{thread.isCreator
? otherParticipant?.storeName ||
otherParticipant?.username
: otherParticipant?.name}
</p>
<div className="flex flex-col items-center justify-center">
</div>
<div className="flex flex-col items-end justify-center">
{thread.lastMessageTime && (
<p className="text-R10 text-GRAY">
{new Date(thread.lastMessageTime).toLocaleTimeString(
"fa-IR",
{
hour: "2-digit",
minute: "2-digit",
}
)}
{formatThreadTimestamp(thread.lastMessageTime)}
</p>
)}
{hasUnread && (
@ -157,7 +209,9 @@ const ThreadsList = ({
className={`text-R14 ${hasUnread ? "text-BLACK font-bold" : "text-GRAY"}`}
>
{thread.lastMessage?.content ||
(thread.lastMessage?.product
(thread.lastMessage?.imageUrl
? "📷 تصویر"
: thread.lastMessage?.product
? `🛍 ${thread.lastMessage.product.title || "محصول"}`
: "بدون پیام")}
</p>

View File

@ -41,7 +41,21 @@ export function usePushNotifications() {
const [isSubscribed, setIsSubscribed] = useState(false);
const [busy, setBusy] = useState(false);
const authHeaders = useCallback(() => {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}, [token]);
// Reflect current permission + subscription state on mount.
//
// We *also* re-POST the sub to the backend on every mount. Reasons:
// - iOS Safari silently expires push subs after a few days of inactivity;
// the local `getSubscription()` still returns the token but the backend
// row may have been 410'd and deleted. Re-POSTing recreates it.
// - Multi-device: a sub registered on device A may not exist server-side
// for device B. Idempotent upsert (`update_or_create` by endpoint) makes
// this safe to do on every visit.
useEffect(() => {
if (!isPushSupported()) {
setPermission("unsupported");
@ -50,15 +64,19 @@ export function usePushNotifications() {
setPermission(Notification.permission);
navigator.serviceWorker.ready
.then((reg) => reg.pushManager.getSubscription())
.then((sub) => setIsSubscribed(!!sub))
.then((sub) => {
setIsSubscribed(!!sub);
if (sub && token) {
// Fire-and-forget refresh; failure is non-fatal.
fetch(`${apiBase}/api/notif/v1/push/subscribe/`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(sub.toJSON()),
}).catch(() => {});
}
})
.catch(() => setIsSubscribed(false));
}, []);
const authHeaders = useCallback(() => {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}, [token]);
}, [apiBase, token, authHeaders]);
const subscribe = useCallback(async (): Promise<boolean> => {
if (!isPushSupported() || !token) return false;

View File

@ -69,8 +69,8 @@ const useWebSocket = (threadId: string, userId: string | undefined) => {
const sendWebSocketMessage = (
content: string,
replyToId?: string | null,
// messageId?: string | null,
replyContent?: string | null
replyContent?: string | null,
imageUrl?: string | null,
) => {
if (!socket || socket.readyState !== WebSocket.OPEN || !userId) {
console.error("WebSocket is not connected");
@ -80,10 +80,9 @@ const useWebSocket = (threadId: string, userId: string | undefined) => {
const message: SendMessagePayload = {
type: "new_message",
content,
// sender_id: userId, // Remove this as it's not needed in client->server communication
...(replyToId && { reply_to_id: replyToId }),
...(replyContent && { reply_content: replyContent }),
// ...(messageId && { message_id: messageId }),
...(imageUrl && { image_url: imageUrl }),
};
socket.send(JSON.stringify(message));
@ -148,6 +147,7 @@ interface SendMessagePayload extends WebSocketMessage {
content: string;
reply_to_id?: string | null;
reply_content?: string | null;
image_url?: string | null;
}
interface MarkReadPayload extends WebSocketMessage {
@ -162,6 +162,7 @@ interface NewMessageEvent extends WebSocketMessage {
created_at: string;
reply_to_id?: string | null;
reply_content?: string | null;
image_url?: string | null;
}
interface ReadReceiptEvent extends WebSocketMessage {

View File

@ -2347,6 +2347,12 @@ export interface LastMessage {
* @memberof LastMessage
*/
readonly product?: ChatProductCard | null;
/**
* Image URL when the last message is a seller-attached image.
* @type {string}
* @memberof LastMessage
*/
readonly imageUrl?: string | null;
}
/**
* Minimal product info rendered as a card inside a chat.
@ -2527,6 +2533,13 @@ export interface MessageList {
* @memberof MessageList
*/
readonly product?: ChatProductCard | null;
/**
* URL of an image attachment. Only set for seller-side messages buyers
* can only send text.
* @type {string}
* @memberof MessageList
*/
readonly imageUrl?: string | null;
}
/**
*
@ -6568,6 +6581,12 @@ export interface ThreadDetail {
* @memberof ThreadDetail
*/
readonly isCreator?: boolean;
/**
* user_ids of the store's team (owner + active staff). Empty for peer-to-peer threads.
* @type {Array<string>}
* @memberof ThreadDetail
*/
readonly teamUserIds?: Array<string>;
}
/**
*
@ -6636,6 +6655,21 @@ export interface ThreadList {
* @memberof ThreadList
*/
readonly isCreator?: boolean;
/**
* True if the last message in the thread was written by the store team
* (owner or active staff). Used by the seller inbox to show
* "you replied" vs "customer waiting".
* @type {boolean}
* @memberof ThreadList
*/
readonly lastMessageFromTeam?: boolean;
/**
* True if the other party (the customer, from the seller side) has
* already seen the last message.
* @type {boolean}
* @memberof ThreadList
*/
readonly otherPartySeenLast?: boolean;
}
/**
*