168 lines
5.8 KiB
TypeScript
168 lines
5.8 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useEffect, useRef, useState, useCallback } from "react";
|
|||
|
|
import { useParams, useRouter } from "next/navigation";
|
|||
|
|
import { ChevronRight, Send, User } from "lucide-react";
|
|||
|
|
import { cn } from "@/lib/utils";
|
|||
|
|
import { formatApiError } from "@/lib/hooks";
|
|||
|
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
|||
|
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
|||
|
|
import {
|
|||
|
|
MessageView,
|
|||
|
|
MatchView,
|
|||
|
|
listMatches,
|
|||
|
|
listMessages,
|
|||
|
|
sendMessage,
|
|||
|
|
photoUrl,
|
|||
|
|
} from "@/lib/api";
|
|||
|
|
|
|||
|
|
const POLL_MS = 4000;
|
|||
|
|
|
|||
|
|
export default function ChatPage() {
|
|||
|
|
const params = useParams<{ id: string }>();
|
|||
|
|
const router = useRouter();
|
|||
|
|
const matchId = Number(params.id);
|
|||
|
|
|
|||
|
|
const [match, setMatch] = useState<MatchView | null>(null);
|
|||
|
|
const [messages, setMessages] = useState<MessageView[] | null>(null);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
const [draft, setDraft] = useState("");
|
|||
|
|
const [sending, setSending] = useState(false);
|
|||
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|||
|
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|||
|
|
|
|||
|
|
const refreshMessages = useCallback(async () => {
|
|||
|
|
try {
|
|||
|
|
const msgs = await listMessages(matchId);
|
|||
|
|
setMessages(msgs);
|
|||
|
|
} catch (err) {
|
|||
|
|
setError(formatApiError(err));
|
|||
|
|
}
|
|||
|
|
}, [matchId]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
listMatches()
|
|||
|
|
.then((matches) => {
|
|||
|
|
const found = matches.find((m) => m.id === matchId);
|
|||
|
|
setMatch(found ?? null);
|
|||
|
|
})
|
|||
|
|
.catch(() => {});
|
|||
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|||
|
|
refreshMessages();
|
|||
|
|
|
|||
|
|
pollRef.current = setInterval(refreshMessages, POLL_MS);
|
|||
|
|
return () => {
|
|||
|
|
if (pollRef.current) clearInterval(pollRef.current);
|
|||
|
|
};
|
|||
|
|
}, [matchId, refreshMessages]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
bottomRef.current?.scrollIntoView({ block: "end" });
|
|||
|
|
}, [messages?.length]);
|
|||
|
|
|
|||
|
|
async function handleSend() {
|
|||
|
|
const text = draft.trim();
|
|||
|
|
if (!text || sending) return;
|
|||
|
|
setSending(true);
|
|||
|
|
setDraft("");
|
|||
|
|
try {
|
|||
|
|
const msg = await sendMessage(matchId, text);
|
|||
|
|
setMessages((prev) => (prev ? [...prev, msg] : [msg]));
|
|||
|
|
} catch (err) {
|
|||
|
|
setError(formatApiError(err));
|
|||
|
|
} finally {
|
|||
|
|
setSending(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (error && !messages) {
|
|||
|
|
return (
|
|||
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-4 p-6 text-center">
|
|||
|
|
<ErrorBanner message={error} />
|
|||
|
|
<button
|
|||
|
|
onClick={() => router.push("/messages")}
|
|||
|
|
className="text-[13px] font-bold text-(--color-brand-600)"
|
|||
|
|
>
|
|||
|
|
برگرد به پیامها
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if (!messages) return <FullScreenLoader />;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex flex-col min-h-full">
|
|||
|
|
<header className="flex items-center gap-3 px-4 pt-[calc(env(safe-area-inset-top)+12px)] pb-3 border-b border-(--color-border-subtle) sticky top-0 bg-(--color-surface-default) z-10">
|
|||
|
|
<button
|
|||
|
|
onClick={() => router.push("/messages")}
|
|||
|
|
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) shrink-0"
|
|||
|
|
>
|
|||
|
|
<ChevronRight size={20} className="text-(--color-content-secondary)" />
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => match && router.push(`/home/${match.user_id}`)}
|
|||
|
|
className="flex items-center gap-2.5 flex-1 min-w-0"
|
|||
|
|
>
|
|||
|
|
<div className="w-10 h-10 rounded-full overflow-hidden bg-(--color-bg-tertiary) flex items-center justify-center shrink-0">
|
|||
|
|
{match?.photos[0] ? (
|
|||
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|||
|
|
<img src={photoUrl(match.photos[0])} alt={match.first_name} className="w-full h-full object-cover" />
|
|||
|
|
) : (
|
|||
|
|
<User size={18} className="text-(--color-content-disabled)" />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<p className="font-bold text-[15px] text-(--color-content-primary) truncate">
|
|||
|
|
{match?.first_name ?? "..."}
|
|||
|
|
</p>
|
|||
|
|
</button>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<div className="flex-1 flex flex-col gap-3 px-4 py-4 overflow-y-auto">
|
|||
|
|
{messages.length === 0 ? (
|
|||
|
|
<p className="text-[13px] text-(--color-content-tertiary) text-center py-8">
|
|||
|
|
هنوز پیامی نیست؛ اولین نفر باش که سلام میده 👋
|
|||
|
|
</p>
|
|||
|
|
) : (
|
|||
|
|
messages.map((m) => (
|
|||
|
|
<div
|
|||
|
|
key={m.id}
|
|||
|
|
className={cn("flex flex-col max-w-[75%]", m.is_mine ? "self-start items-start" : "self-end items-end")}
|
|||
|
|
>
|
|||
|
|
<div
|
|||
|
|
className={cn(
|
|||
|
|
"px-4 py-2.5 text-[14px] leading-6",
|
|||
|
|
m.is_mine
|
|||
|
|
? "bg-(--color-brand-500) text-white rounded-2xl rounded-br-md"
|
|||
|
|
: "bg-(--color-bg-secondary) text-(--color-content-primary) rounded-2xl rounded-bl-md"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{m.text}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))
|
|||
|
|
)}
|
|||
|
|
<div ref={bottomRef} />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<ErrorBanner message={error} />
|
|||
|
|
|
|||
|
|
<div className="flex items-center gap-2 px-4 py-3 border-t border-(--color-border-subtle) pb-[calc(env(safe-area-inset-bottom)+12px)]">
|
|||
|
|
<input
|
|||
|
|
value={draft}
|
|||
|
|
onChange={(e) => setDraft(e.target.value)}
|
|||
|
|
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
|||
|
|
placeholder="پیامت رو بنویس..."
|
|||
|
|
className="flex-1 h-11 rounded-(--radius-full) bg-(--color-bg-secondary) px-4 text-[14px] focus:outline-none focus:ring-2 focus:ring-(--color-brand-200)"
|
|||
|
|
/>
|
|||
|
|
<button
|
|||
|
|
onClick={handleSend}
|
|||
|
|
disabled={!draft.trim() || sending}
|
|||
|
|
className="w-11 h-11 rounded-full bg-(--color-brand-500) text-white flex items-center justify-center disabled:opacity-40 shrink-0"
|
|||
|
|
>
|
|||
|
|
<Send size={17} />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|