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";
|
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);
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 || ""}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,6 +2533,13 @@ 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;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -6568,6 +6581,12 @@ 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>;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -6636,6 +6655,21 @@ export interface ThreadList {
|
|||||||
* @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;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user