Vitron-Front/app/components/thread/ThreadsPage.tsx
fazli 225161249e 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>
2026-07-19 13:22:44 +00:00

226 lines
8.1 KiB
TypeScript

import { Link } from "@remix-run/react";
import AppInput from "../AppInput";
import { useState } from "react";
import {
useGetUserThreads,
useGetStoreThreads,
} from "../../requestHandler/use-chat-hooks";
import {
ThreadList,
ThreadParticipant,
UserProfile,
} from "../../../src/api/types";
import { useServiceGetProfile } from "../../requestHandler/use-profile-hooks";
import UiProvider from "../UiProvider";
import SellerLogo from "../SellerLogo";
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;
}
export default function ThreadsPage({ type, storeId }: ThreadsPageProps) {
const [searchValue, setSearchValue] = useState("");
// Seller dashboard shows the STORE's inbox; the profile page shows the user's
// personal (buyer) chats. Two different endpoints so the two never mix.
const userThreads = useGetUserThreads();
const storeThreads = useGetStoreThreads(type === "store" ? storeId : undefined);
const {
data: threads,
isLoading,
isError,
refetch,
} = type === "store" ? storeThreads : userThreads;
const sortedThreads = threads?.sort((a, b) => {
const dateA = new Date(a.lastMessageTime || "2025-01-18T00:00:00.000Z");
const dateB = new Date(b.lastMessageTime || "2025-01-18T00:00:00.000Z");
return dateB.getTime() - dateA.getTime();
});
const { data: dataProfile, isFetching: isFetchingProfile } =
useServiceGetProfile();
return (
<div
className={`flex flex-col w-full ${isLoading || isFetchingProfile ? "" : "pb-20"}`}
>
<ProfilePagesHeader title="پیام‌ها" />
<div className="w-full px-4 py-2">
<div className="w-full h-[40px] flex items-center gap-2 bg-WHITE2 rounded-[10px]">
<AppInput
inputIcon={Search}
inputDir="rtl"
inputTitle={""}
className="bg-WHITE2"
inputValue={searchValue}
inputPlaceholder={"جستجو..."}
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchValue(value);
}}
/>
</div>
</div>
<UiProvider
isLoading={isLoading || isFetchingProfile}
isError={isError}
isEmpty={!!(threads && threads.length === 0)}
onRetry={refetch}
type="threads"
>
<ThreadsList
searchValue={searchValue}
threads={sortedThreads}
dataProfile={dataProfile}
type={type}
storeId={storeId}
/>
</UiProvider>
</div>
);
}
const ThreadsList = ({
searchValue,
threads,
dataProfile,
type,
storeId,
}: {
searchValue: string;
threads: ThreadList[] | undefined;
dataProfile: UserProfile | undefined;
type: "user" | "store";
storeId?: string;
}) => {
const filteredThreads = threads?.filter((thread) => {
return thread.participants?.some((participant: ThreadParticipant) =>
participant.username?.toLowerCase().includes(searchValue.toLowerCase())
);
});
return (
<div className="flex flex-col w-full">
{filteredThreads?.map((thread) => {
const otherParticipant = thread.participants?.find(
(participant: ThreadParticipant) =>
participant.username !== dataProfile?.username
);
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"
? `/profile/threads/${thread.id}`
: `/store/${storeId}/threads/${thread.id}`;
return (
<Link to={linkTo} key={thread.id} className="w-full">
<div className={`w-full h-[80px] flex items-center px-4 gap-4`}>
{thread.isCreator ? (
<SellerLogo
src={otherParticipant?.storeLogo}
alt="Participant Avatar"
/>
) : (
<User size={48} className="text-GRAY" />
)}
<div className="flex flex-col w-full">
<div className="w-full flex justify-between items-center">
<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>
<div className="flex flex-col items-end justify-center">
{thread.lastMessageTime && (
<p className="text-R10 text-GRAY">
{formatThreadTimestamp(thread.lastMessageTime)}
</p>
)}
{hasUnread && (
<div className="w-5 h-5 flex items-center justify-center bg-blue-500 text-WHITE text-R10 rounded-full">
{thread.unreadCount}
</div>
)}
</div>
</div>
<p
className={`text-R14 ${hasUnread ? "text-BLACK font-bold" : "text-GRAY"}`}
>
{thread.lastMessage?.content ||
(thread.lastMessage?.imageUrl
? "📷 تصویر"
: thread.lastMessage?.product
? `🛍 ${thread.lastMessage.product.title || "محصول"}`
: "بدون پیام")}
</p>
</div>
</div>
</Link>
);
})}
</div>
);
};