nomadweb/frontend/src/components/ChatThreadClient.tsx
eric fe4f0f5983 Fix member profiles, chat peer header, and like-back CTAs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 06:43:48 -05:00

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>
);
}