182 lines
5.8 KiB
TypeScript
182 lines
5.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import Link from "next/link";
|
|
import { api } from "@/lib/api";
|
|
import { useAuth } from "@/lib/auth";
|
|
import { useI18n } from "@/lib/i18n";
|
|
import { useToast } from "@/lib/toast";
|
|
import type { ChatMessage, ConversationItem } from "@/lib/types";
|
|
import RingNext from "@/components/RingNext";
|
|
import { useRingSteps } from "@/lib/rings";
|
|
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
|
|
|
|
export default function ChatThreadClient({ convId }: { convId: string }) {
|
|
const { token, user } = useAuth();
|
|
const { t } = useI18n();
|
|
const { toast } = useToast();
|
|
const rings = useRingSteps();
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [conv, setConv] = useState<ConversationItem | null>(null);
|
|
const [text, setText] = useState("");
|
|
const [sending, setSending] = useState(false);
|
|
const [loading, setLoading] = useState(Boolean(token));
|
|
const [draftReady, setDraftReady] = useState(false);
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
const draftKey = `nomadro-chat-draft:${convId}`;
|
|
|
|
const load = () => {
|
|
if (!token) return;
|
|
api
|
|
.getMessages(token, convId)
|
|
.then(setMessages)
|
|
.catch(() => setMessages([]))
|
|
.finally(() => setLoading(false));
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
api.getConversation(token, convId).then(setConv).catch(() => setConv(null));
|
|
load();
|
|
const id = setInterval(load, 4000);
|
|
return () => clearInterval(id);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [token, convId]);
|
|
|
|
useEffect(() => {
|
|
const draft = loadDraft<{ text?: string }>(draftKey);
|
|
if (draft?.text?.trim()) {
|
|
setText(draft.text);
|
|
toast(t.chat.draftRestored, "info");
|
|
}
|
|
setDraftReady(true);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [convId]);
|
|
|
|
useEffect(() => {
|
|
if (!draftReady || !token) return;
|
|
const id = window.setTimeout(() => {
|
|
if (!text.trim()) {
|
|
clearDraft(draftKey);
|
|
return;
|
|
}
|
|
saveDraft(draftKey, { text });
|
|
}, 400);
|
|
return () => window.clearTimeout(id);
|
|
}, [text, draftReady, token, draftKey]);
|
|
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [messages]);
|
|
|
|
const send = async () => {
|
|
if (!token || !text.trim() || sending) return;
|
|
setSending(true);
|
|
try {
|
|
await api.sendMessage(token, convId, text.trim());
|
|
setText("");
|
|
clearDraft(draftKey);
|
|
load();
|
|
} catch {
|
|
toast(t.chat.sendFail, "error");
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
};
|
|
|
|
const peer = conv?.peer;
|
|
const peerHref = peer?.userId ? `/members/${peer.userId}` : peer?.id ? `/members/${peer.id}` : "/dating";
|
|
|
|
if (!user || !token) {
|
|
return (
|
|
<div className="chat-thread-page">
|
|
<div className="container">
|
|
<div className="dating-gate reveal">
|
|
<h2>{t.chat.loginTitle}</h2>
|
|
<p>{t.chat.loginDesc}</p>
|
|
<div className="dating-empty-actions">
|
|
<Link href={`/login?next=/chat/${convId}`} className="btn btn-primary">
|
|
{t.nav.login}
|
|
</Link>
|
|
<Link href="/meetups" className="btn btn-ghost">
|
|
{t.dating.meetAtEvents}
|
|
</Link>
|
|
<Link href="/dating" className="btn btn-ghost">
|
|
{t.nav.dating}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="chat-thread-page">
|
|
<div className="container chat-thread-wrap">
|
|
<nav className="detail-nav">
|
|
<Link href="/chat">← {t.chat.back}</Link>
|
|
<span> · </span>
|
|
<Link href="/dating">{t.nav.dating}</Link>
|
|
<span> · </span>
|
|
<Link href="/meetups">{t.nav.meetups}</Link>
|
|
</nav>
|
|
|
|
{peer && (
|
|
<div className="chat-thread-peer reveal">
|
|
<Link href={peerHref} className="chat-thread-peer-link">
|
|
<span className="chat-avatar">{peer.photo || peer.name?.[0] || "💬"}</span>
|
|
<div>
|
|
<strong>{peer.name}</strong>
|
|
<p className="dating-meta">{peer.location || t.dating.globalNomad}</p>
|
|
</div>
|
|
</Link>
|
|
<Link href={peerHref} className="btn btn-ghost btn-sm">
|
|
{t.dating.viewProfile}
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
<div className="chat-thread-messages">
|
|
{loading && messages.length === 0 && <p className="dest-empty">{t.chat.loadingMessages}</p>}
|
|
{!loading && messages.length === 0 && (
|
|
<div className="notif-empty">
|
|
<p className="dest-empty">{t.chat.sayHi}</p>
|
|
<div className="dating-empty-actions">
|
|
<Link href="/meetups" className="btn btn-sm">
|
|
{t.dating.meetAtEvents}
|
|
</Link>
|
|
<Link href="/dating" className="btn btn-sm">
|
|
{t.nav.dating}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{messages.map((m) => (
|
|
<div key={m.id} className={`chat-bubble${m.mine ? " mine" : ""}`}>
|
|
{m.body}
|
|
</div>
|
|
))}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
<div className="chat-compose">
|
|
<input
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
placeholder={t.chat.placeholder}
|
|
onKeyDown={(e) => e.key === "Enter" && void send()}
|
|
disabled={sending}
|
|
/>
|
|
<button type="button" className="btn btn-primary" disabled={sending || !text.trim()} onClick={() => void send()}>
|
|
{sending ? "…" : t.chat.send}
|
|
</button>
|
|
</div>
|
|
<RingNext steps={rings.afterDating} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|