diff --git a/.env.local b/.env.local index 459e423..28d0b93 100644 --- a/.env.local +++ b/.env.local @@ -44,5 +44,12 @@ PAYMENT_API_URL=http://127.0.0.1:8000 NEXT_PUBLIC_MIROTALK_URL=https://mirotalk.nomadro.cn NEXT_PUBLIC_LOUNGE_URL=https://lounge.nomadro.cn +NEXT_PUBLIC_LISTMONK_URL=https://listmonk.nomadro.cn +LISTMONK_API_URL=https://listmonk.nomadro.cn + +NEXT_PUBLIC_NTFY_URL=https://ntfy.nomadro.cn +NTFY_URL=https://ntfy.nomadro.cn +NTFY_ENABLED=true + # 本地调试可自动完成支付(无需真实回调) DEV_AUTO_PAY=true diff --git a/app/[locale]/chat/[id]/page.tsx b/app/[locale]/chat/[id]/page.tsx new file mode 100644 index 0000000..85ecb4f --- /dev/null +++ b/app/[locale]/chat/[id]/page.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { FormEvent, useCallback, useEffect, useRef, useState } from "react"; +import { Link } from "@/i18n/navigation"; +import { useLocale, useTranslation } from "@/i18n/navigation"; +import { useParams } from "next/navigation"; +import Footer from "@/app/components/Footer"; +import { fetchAuthMe } from "@/app/lib/api-client"; +import { + fetchConversation, + fetchMessages, + sendChatMessage, + type ChatMessage, + type ConversationItem, +} from "@/app/lib/chat"; +import { mediaUrl } from "@/app/lib/media"; + +const POLL_MS = 4000; + +function formatMessageTime(value?: string, locale = "zh"): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleTimeString(locale === "zh" ? "zh-CN" : "en-US", { + hour: "2-digit", + minute: "2-digit", + }); +} + +export default function ChatThreadPage() { + const params = useParams(); + const conversationId = String(params?.id || ""); + const { t } = useTranslation("chat"); + const locale = useLocale(); + + const [conversation, setConversation] = useState(null); + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(""); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + const [error, setError] = useState(""); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const bottomRef = useRef(null); + const listRef = useRef(null); + + const scrollToBottom = useCallback(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, []); + + const loadThread = useCallback(async () => { + if (!conversationId) return; + try { + const [meta, items] = await Promise.all([ + fetchConversation(conversationId), + fetchMessages(conversationId), + ]); + setConversation(meta); + setMessages(items); + setError(""); + } catch (err) { + setError(err instanceof Error ? err.message : t("loadError")); + } finally { + setLoading(false); + } + }, [conversationId, t]); + + useEffect(() => { + fetchAuthMe().then((data) => setIsLoggedIn(Boolean(data?.user?.id))); + }, []); + + useEffect(() => { + loadThread(); + }, [loadThread]); + + useEffect(() => { + if (!conversationId || !isLoggedIn) return undefined; + const timer = window.setInterval(() => { + fetchMessages(conversationId) + .then((items) => setMessages(items)) + .catch(() => undefined); + }, POLL_MS); + return () => window.clearInterval(timer); + }, [conversationId, isLoggedIn]); + + useEffect(() => { + scrollToBottom(); + }, [messages, scrollToBottom]); + + const handleSend = async (event: FormEvent) => { + event.preventDefault(); + const body = draft.trim(); + if (!body || sending || !conversationId) return; + setSending(true); + setError(""); + try { + const message = await sendChatMessage(conversationId, body); + setMessages((prev) => [...prev, message]); + setDraft(""); + setConversation((prev) => + prev + ? { + ...prev, + lastMessagePreview: body.replace(/\n/g, " ").slice(0, 180), + lastMessageAt: message.created || prev.lastMessageAt, + } + : prev + ); + } catch (err) { + setError(err instanceof Error ? err.message : t("sendError")); + } finally { + setSending(false); + } + }; + + const peerName = conversation?.peer?.name || (locale === "zh" ? "成员" : "Member"); + + return ( +
+
+
+ + ← {t("back")} + + {conversation?.peer ? ( +
+ {peerName} +
+

{peerName}

+ {conversation.peer.location ? ( +

📍 {conversation.peer.location}

+ ) : null} +
+
+ ) : ( +

{t("title")}

+ )} +
+
+ +
+ {!isLoggedIn ? ( +
+
+

{t("loginRequired")}

+ + {t("loginAction")} + +
+
+ ) : loading ? ( +
+
+
+ ) : error && !messages.length ? ( +
+
+

{error}

+ + {t("back")} + +
+
+ ) : ( +
+ {!messages.length ? ( +
+

{t("emptyThread")}

+
+ ) : ( + messages.map((message) => ( +
+
+

{message.body}

+

+ {formatMessageTime(message.created, locale)} +

+
+
+ )) + )} +
+
+ )} + + {isLoggedIn && !loading ? ( +
+ {error && messages.length ? ( +

{error}

+ ) : null} +
+