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"; import { useState, useRef, useEffect } from "react";
// Message bubble component // 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 = ({ export const MessageBubble = ({
message, message,
isCurrentUser, isCurrentUser,
isCoworker = false,
onReply, onReply,
onDelete, onDelete,
replyToMessageContent, replyToMessageContent,
@ -27,6 +35,7 @@ export const MessageBubble = ({
}: { }: {
message: MessageList; message: MessageList;
isCurrentUser: boolean; isCurrentUser: boolean;
isCoworker?: boolean;
onReply: (message: MessageList) => void; onReply: (message: MessageList) => void;
onDelete?: (messageId: string) => void; onDelete?: (messageId: string) => void;
replyToMessageContent?: string | undefined; replyToMessageContent?: string | undefined;
@ -63,6 +72,11 @@ export const MessageBubble = ({
isCurrentUser ? "bg-BLUE2 text-WHITE" : "bg-GRAY3" 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 && ( {message.replyTo && (
<div <div
className={`text-xs italic mb-1 pb-1 border-b ${ className={`text-xs italic mb-1 pb-1 border-b ${
@ -109,7 +123,15 @@ export const MessageBubble = ({
</div> </div>
</Link> </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>
<div className="flex flex-col justify-between items-start"> <div className="flex flex-col justify-between items-start">
<div className="flex flex-col gap-1 items-start"> <div className="flex flex-col gap-1 items-start">
@ -135,7 +157,7 @@ export const MessageBubble = ({
<Reply size={14} /> <Reply size={14} />
<span className="text-R12">پاسخ</span> <span className="text-R12">پاسخ</span>
</DropdownMenuItem> </DropdownMenuItem>
{isCurrentUser && ( {isCurrentUser && !isCoworker && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setShowDropdownMenu(false); 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 { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import EmojiPicker from "./EmojiPicker"; import EmojiPicker from "./EmojiPicker";
import type { MessageList } from "../../../src/api/types/models"; 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 // 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 = ({ const MessageInput = ({
onSend, onSend,
onSendImage,
canSendImage = false,
replyToMessage, replyToMessage,
onCancelReply, onCancelReply,
messageText, messageText,
@ -15,6 +27,8 @@ const MessageInput = ({
setReplyToMessage, setReplyToMessage,
}: { }: {
onSend: (text: string) => void; onSend: (text: string) => void;
onSendImage?: (imageUrl: string) => void;
canSendImage?: boolean;
replyToMessage: MessageList | undefined; replyToMessage: MessageList | undefined;
onCancelReply: () => void; onCancelReply: () => void;
inputRef: React.RefObject<HTMLInputElement>; inputRef: React.RefObject<HTMLInputElement>;
@ -23,6 +37,10 @@ const MessageInput = ({
isSendingMessage: boolean; isSendingMessage: boolean;
setReplyToMessage: (message: MessageList | undefined) => void; 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); const [caretPosition, setCaretPosition] = useState<number | null>(null);
// Track current textarea height // Track current textarea height
@ -77,6 +95,65 @@ const MessageInput = ({
setMessageText(e.target.value); 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 ( return (
<> <>
{/* Reply indicator */} {/* Reply indicator */}
@ -130,6 +207,31 @@ const MessageInput = ({
</PopoverContent> </PopoverContent>
</Popover> </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 <textarea
ref={textareaRef} ref={textareaRef}
value={messageText} 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" 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={{ style={{
paddingRight: "40px", paddingRight: canSendImage ? "72px" : "40px",
paddingLeft: "48px", paddingLeft: "48px",
direction: "rtl", direction: "rtl",
}} }}
onKeyDown={(e) => { // Enter inserts a newline. Send is via the button only — matches
if (e.key === "Enter" && !e.shiftKey) { // seller request: multi-line messages are common when replying to
e.preventDefault(); // a customer with details, and accidental Enter-sends were noisy.
handleSend();
}
}}
/> />
<button <button
onClick={handleSend} onClick={handleSend}

View File

@ -22,6 +22,7 @@ const MessageList = forwardRef(
isLoading, isLoading,
isFetching, isFetching,
userId, userId,
teamUserIds,
onReply, onReply,
onDelete, onDelete,
onScroll, onScroll,
@ -31,6 +32,10 @@ const MessageList = forwardRef(
isLoading: boolean; isLoading: boolean;
isFetching: boolean; isFetching: boolean;
userId: string | undefined; 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; onReply: (message: MessageListType) => void;
onDelete?: (messageId: string) => void; onDelete?: (messageId: string) => void;
onScroll?: (event: React.UIEvent<HTMLDivElement>) => void; onScroll?: (event: React.UIEvent<HTMLDivElement>) => void;
@ -163,7 +168,15 @@ const MessageList = forwardRef(
<MessageBubble <MessageBubble
otherParticipant={otherParticipant} otherParticipant={otherParticipant}
message={message} 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} onReply={onReply}
onDelete={onDelete} onDelete={onDelete}
replyToMessageContent={message.replyContent || ""} replyToMessageContent={message.replyContent || ""}

View File

@ -80,6 +80,7 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
replyTo: newMessage.reply_to_id, replyTo: newMessage.reply_to_id,
replyContent: newMessage.reply_content || "", replyContent: newMessage.reply_content || "",
imageUrl: newMessage.image_url || undefined,
}); });
resetNewMessage(); resetNewMessage();
setTimeout(() => { setTimeout(() => {
@ -219,6 +220,26 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
setMessageText(""); 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 // Handle reply to message
const handleReplyClick = (message: MessageListType) => { const handleReplyClick = (message: MessageListType) => {
setReplyToMessage(message); setReplyToMessage(message);
@ -264,6 +285,7 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
isLoading={isLoadingMessages || isLoadingThread} isLoading={isLoadingMessages || isLoadingThread}
isFetching={isFetchingNextPage} isFetching={isFetchingNextPage}
userId={user?.id} userId={user?.id}
teamUserIds={threadDetails?.teamUserIds}
onReply={handleReplyClick} onReply={handleReplyClick}
onDelete={handleDeleteMessage} onDelete={handleDeleteMessage}
onScroll={handleScroll} onScroll={handleScroll}
@ -276,6 +298,8 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
messageText={messageText} messageText={messageText}
setMessageText={setMessageText} setMessageText={setMessageText}
onSend={handleSendMessage} onSend={handleSendMessage}
onSendImage={handleSendImage}
canSendImage={currentUserIsTeam}
replyToMessage={replyToMessage} replyToMessage={replyToMessage}
onCancelReply={() => setReplyToMessage(undefined)} onCancelReply={() => setReplyToMessage(undefined)}
inputRef={inputRef} inputRef={inputRef}

View File

@ -13,9 +13,33 @@ import {
import { useServiceGetProfile } from "../../requestHandler/use-profile-hooks"; import { useServiceGetProfile } from "../../requestHandler/use-profile-hooks";
import UiProvider from "../UiProvider"; import UiProvider from "../UiProvider";
import SellerLogo from "../SellerLogo"; import SellerLogo from "../SellerLogo";
import { Search, User } from "lucide-react"; import { CheckCheck, MessageCircleWarning, Search, User } from "lucide-react";
import ProfilePagesHeader from "../ProfilePagesHeader"; 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 { interface ThreadsPageProps {
type: "user" | "store"; type: "user" | "store";
storeId?: string; storeId?: string;
@ -109,6 +133,24 @@ const ThreadsList = ({
); );
const hasUnread = Number(thread.unreadCount || "0") > 0; 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 // Generate link based on type
const linkTo = const linkTo =
type === "user" type === "user"
@ -128,22 +170,32 @@ const ThreadsList = ({
)} )}
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
<div className="w-full flex justify-between items-center"> <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">
{thread.isCreator {status === "needs-reply" && (
? otherParticipant?.storeName || <MessageCircleWarning
otherParticipant?.username size={16}
: otherParticipant?.name} className="text-RED shrink-0"
</p> aria-label="در انتظار پاسخ شما"
<div className="flex flex-col items-center justify-center"> />
)}
{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>
<div className="flex flex-col items-end justify-center">
{thread.lastMessageTime && ( {thread.lastMessageTime && (
<p className="text-R10 text-GRAY"> <p className="text-R10 text-GRAY">
{new Date(thread.lastMessageTime).toLocaleTimeString( {formatThreadTimestamp(thread.lastMessageTime)}
"fa-IR",
{
hour: "2-digit",
minute: "2-digit",
}
)}
</p> </p>
)} )}
{hasUnread && ( {hasUnread && (
@ -157,9 +209,11 @@ const ThreadsList = ({
className={`text-R14 ${hasUnread ? "text-BLACK font-bold" : "text-GRAY"}`} className={`text-R14 ${hasUnread ? "text-BLACK font-bold" : "text-GRAY"}`}
> >
{thread.lastMessage?.content || {thread.lastMessage?.content ||
(thread.lastMessage?.product (thread.lastMessage?.imageUrl
? `🛍 ${thread.lastMessage.product.title || "محصول"}` ? "📷 تصویر"
: "بدون پیام")} : thread.lastMessage?.product
? `🛍 ${thread.lastMessage.product.title || "محصول"}`
: "بدون پیام")}
</p> </p>
</div> </div>
</div> </div>

View File

@ -41,7 +41,21 @@ export function usePushNotifications() {
const [isSubscribed, setIsSubscribed] = useState(false); const [isSubscribed, setIsSubscribed] = useState(false);
const [busy, setBusy] = 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. // 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(() => { useEffect(() => {
if (!isPushSupported()) { if (!isPushSupported()) {
setPermission("unsupported"); setPermission("unsupported");
@ -50,15 +64,19 @@ export function usePushNotifications() {
setPermission(Notification.permission); setPermission(Notification.permission);
navigator.serviceWorker.ready navigator.serviceWorker.ready
.then((reg) => reg.pushManager.getSubscription()) .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)); .catch(() => setIsSubscribed(false));
}, []); }, [apiBase, token, authHeaders]);
const authHeaders = useCallback(() => {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}, [token]);
const subscribe = useCallback(async (): Promise<boolean> => { const subscribe = useCallback(async (): Promise<boolean> => {
if (!isPushSupported() || !token) return false; if (!isPushSupported() || !token) return false;

View File

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

View File

@ -2347,6 +2347,12 @@ export interface LastMessage {
* @memberof LastMessage * @memberof LastMessage
*/ */
readonly product?: ChatProductCard | null; 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. * Minimal product info rendered as a card inside a chat.
@ -2527,9 +2533,16 @@ export interface MessageList {
* @memberof MessageList * @memberof MessageList
*/ */
readonly product?: ChatProductCard | null; 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;
} }
/** /**
* *
* @export * @export
* @interface NotificationCreate * @interface NotificationCreate
*/ */
@ -6568,9 +6581,15 @@ export interface ThreadDetail {
* @memberof ThreadDetail * @memberof ThreadDetail
*/ */
readonly isCreator?: boolean; 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>;
} }
/** /**
* *
* @export * @export
* @interface ThreadLeaveResponse * @interface ThreadLeaveResponse
*/ */
@ -6631,14 +6650,29 @@ export interface ThreadList {
*/ */
readonly lastMessageTime?: string | null; readonly lastMessageTime?: string | null;
/** /**
* *
* @type {boolean} * @type {boolean}
* @memberof ThreadList * @memberof ThreadList
*/ */
readonly isCreator?: boolean; 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;
} }
/** /**
* *
* @export * @export
* @interface ThreadParticipant * @interface ThreadParticipant
*/ */