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:
parent
56fcce35c8
commit
225161249e
@ -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);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 || ""}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
/**
|
||||
*
|
||||
|
||||
Loading…
Reference in New Issue
Block a user