"use client"; import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import { useRouter } from "next/navigation"; import { AnimatePresence, motion } from "framer-motion"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDown, ArrowRight, Check, CheckCheck, ChevronDown, ChevronRight, ChevronUp, Clock, CornerDownLeft, Copy, Download, ExternalLink, FileText, Film, Loader2, Maximize, MessageSquare, Mic, Paperclip, Pause, Pencil, Pin, PinOff, Play, Search, Smile, SmilePlus, Sparkles, Square, Trash2, Volume2, VolumeX, X, } from "lucide-react"; import { api, http, getWsUrl, toUserError } from "@/lib/api"; import { apiFormProgress, saveRemoteFile } from "@/lib/transfer"; import { mediaObjectKey } from "@/lib/media"; import { useCachedObjectUrl } from "@/hooks/use-cached-media"; import type { ChatAttachment, ChatMessage, ChatReaction, Conversation, Mentor } from "@/lib/types"; import { useAuth } from "@/lib/auth-store"; import { cn } from "@/lib/utils"; import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { ThemeToggle } from "@/components/theme-toggle"; import { toast } from "@/components/providers"; import { AppleEmoji, AppleEmojiText } from "@/components/apple-emoji"; import { softPop, softSpring, softTween } from "@/lib/motion"; const MAX_LEN = 4000; const LAVA_STYLES: React.CSSProperties[] = [ { background: "radial-gradient(circle at 40% 35%, color-mix(in srgb, var(--color-primary) 42%, transparent), transparent 70%)", }, { background: "radial-gradient(circle at 55% 45%, color-mix(in srgb, var(--color-secondary) 38%, transparent), transparent 72%)", }, { background: "radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--color-accent) 30%, transparent), transparent 68%)", }, ]; function LavaLamp() { const orbs = [ { cls: "-start-14 -top-14 h-60 w-60", x: [0, 110, 40, 140, 0], y: [0, 70, 150, 45, 0], s: [1, 1.3, 1.05, 1.35, 1], dur: 26, }, { cls: "-end-16 top-1/4 h-52 w-52", x: [0, -90, -30, -120, 0], y: [0, -55, -130, -35, 0], s: [1, 1.2, 1.1, 1.28, 1], dur: 22, }, { cls: "bottom-2 start-1/4 h-40 w-40", x: [0, 60, -40, 95, 0], y: [0, -70, -40, -110, 0], s: [1, 1.25, 0.95, 1.3, 1], dur: 30, }, ]; return (
{orbs.map((o, i) => ( ))}
); } export function ChatWidget() { const me = useAuth((s) => s.user); const qc = useQueryClient(); const router = useRouter(); const [open, setOpen] = useState(false); const [active, setActive] = useState(null); const [composeOpen, setComposeOpen] = useState(false); const [draft, setDraft] = useState(""); const [query, setQuery] = useState(""); const [sending, setSending] = useState(false); const [pending, setPending] = useState([]); const [partnerTyping, setPartnerTyping] = useState(false); const [attPref, setAttPref] = useState(null); const [uploading, setUploading] = useState(false); const [uploadPct, setUploadPct] = useState(0); const [notif, setNotif] = useState<{ id: string; partner: number; name: string; body: string } | null>(null); const [preview, setPreview] = useState(null); const [replyTo, setReplyTo] = useState(null); const [emojiOpen, setEmojiOpen] = useState(false); const [dragOver, setDragOver] = useState(false); const [jumpDown, setJumpDown] = useState(false); const [older, setOlder] = useState([]); const [canLoadMore, setCanLoadMore] = useState(false); const [historyAnchor, setHistoryAnchor] = useState(null); const [loadingOlder, setLoadingOlder] = useState(false); const [delArm, setDelArm] = useState(null); const [reactId, setReactId] = useState(null); const [heartMsg, setHeartMsg] = useState(null); const [hearts, setHearts] = useState>({}); const [msgSearchOpen, setMsgSearchOpen] = useState(false); const [msgQuery, setMsgQuery] = useState(""); const [matchIdx, setMatchIdx] = useState(0); const [pinIdx, setPinIdx] = useState(0); const [flashId, setFlashId] = useState(null); const [editingId, setEditingId] = useState(null); const [editDraft, setEditDraft] = useState(""); const [editBusy, setEditBusy] = useState(false); const taRef = useRef(null); const flashTimer = useRef | null>(null); const fileRef = useRef(null); const replaceRef = useRef(null); const replaceTarget = useRef(null); const wsRef = useRef(null); const typingSentAt = useRef(0); const typingClearTimer = useRef | null>(null); const notifTimer = useRef | null>(null); const delArmTimer = useRef | null>(null); const heartTimer = useRef | null>(null); const threadRef = useRef(null); const wasNearBottomRef = useRef(true); const suppressAutoScrollRef = useRef(false); const jumpTargetRef = useRef(null); const pendingDraftRef = useRef(null); const activeRef = useRef(active); activeRef.current = active; const convRef = useRef([]); const convQuery = useQuery({ queryKey: ["conversations"], queryFn: () => http.get<{ conversations: Conversation[] }>("/api/chats/conversations"), refetchInterval: 20_000, }); const convData = convQuery.data; const convLoading = convQuery.isPending; const conversations = convData?.conversations ?? []; convRef.current = conversations; const unreadTotal = useMemo( () => conversations.reduce((n, c) => n + (c.unreadCount > 0 ? c.unreadCount : 0), 0), [conversations] ); const { data: loveList } = useQuery({ queryKey: ["mentors"], queryFn: () => http.get<{ mentors: Mentor[] }>("/api/mentors"), enabled: !active && open, }); const messagesQuery = useQuery({ queryKey: ["chat", active], queryFn: () => http.get<{ messages: ChatMessage[]; hasMore: boolean; pinned?: ChatMessage[] }>( `/api/chats/${active}/messages`, ), enabled: !!active, refetchInterval: active ? 5000 : false, }); const messages = messagesQuery.data; const messagesLoading = !!active && messagesQuery.isPending; const pinned = messages?.pinned ?? []; const pinnedIds = useMemo(() => new Set(pinned.map((p) => p.id)), [pinned]); const activePin = pinned.length ? pinned[Math.min(pinIdx, pinned.length - 1)] ?? null : null; useEffect(() => { setPinIdx(0); }, [active]); useEffect(() => { if (pinned.length === 0) { setPinIdx(0); return; } setPinIdx((i) => (i >= pinned.length ? 0 : i)); }, [pinned.length]); useEffect(() => { if (messages && typeof messages.hasMore === "boolean") setCanLoadMore(messages.hasMore); }, [messages?.hasMore, active]); const msgs = useMemo(() => { const all = [...older, ...(messages?.messages ?? []), ...pending]; if (!all.length) return all; const byId = new Map(); for (const m of all) byId.set(m.id, m); return [...byId.values()].sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ); }, [messages, older, pending]); const matchIds = useMemo(() => { const q0 = normText(msgQuery).trim(); if (!q0) return [] as number[]; return msgs .filter( (m) => !!m.body && normText(m.body).includes(q0) || (!!m.attachment?.name && normText(m.attachment.name).includes(q0)) ) .map((m) => m.id); }, [msgs, msgQuery]); const matchCount = matchIds.length; const matchSet = useMemo(() => new Set(matchIds), [matchIds]); useEffect(() => { setMatchIdx(0); }, [msgQuery]); useEffect(() => { if (!msgSearchOpen || !matchCount) return; const id = matchIds[matchIdx]; if (id == null) return; setHistoryAnchor(id); }, [matchIdx, msgSearchOpen, matchCount, matchIds]); const loadOlder = async () => { const before = older[0]?.id ?? messages?.messages?.[0]?.id; if (!active || !before || loadingOlder) return; setLoadingOlder(true); try { const res = await http.get<{ messages: ChatMessage[]; hasMore: boolean }>( `/api/chats/${active}/messages?before=${before}`, ); setOlder((prev) => { const seen = new Set(prev.map((m) => m.id)); const add = res.messages.filter((m) => !seen.has(m.id)); return [...add, ...prev]; }); setCanLoadMore(res.hasMore); } catch { /* ignore */ } finally { setLoadingOlder(false); } }; const delMsg = async (id: number) => { setDelArm(null); if (delArmTimer.current) clearTimeout(delArmTimer.current); if (!active) return; try { await http.del<{ ok: boolean }>(`/api/chats/${active}/messages/${id}`); setOlder((prev) => prev.filter((m) => m.id !== id)); setPending((prev) => prev.filter((m) => m.id !== id)); qc.invalidateQueries({ queryKey: ["chat", active] }); qc.invalidateQueries({ queryKey: ["chat"] }); qc.invalidateQueries({ queryKey: ["conversations"] }); } catch { /* ignore */ } }; const saveEdit = async () => { if (!active || editingId == null || editBusy) return; const body = editDraft.trim(); if (!body || body.length > MAX_LEN) { toast.error("پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد"); return; } setEditBusy(true); try { const res = await http.put<{ message: ChatMessage }>(`/api/chats/${active}/messages/${editingId}`, { body }); applyEdited(res.message); setEditingId(null); setEditDraft(""); qc.invalidateQueries({ queryKey: ["conversations"] }); } catch (err) { toast.error(toUserError(err, "ویرایش ممکن نشد")); } finally { setEditBusy(false); } }; const armDelete = (id: number) => { if (delArm === id) { void delMsg(id); return; } setDelArm(id); if (delArmTimer.current) clearTimeout(delArmTimer.current); delArmTimer.current = setTimeout(() => setDelArm(null), 3000); }; const togglePin = async (m: ChatMessage) => { if (!active || m.id <= 0) return; try { await api<{ ok: boolean; message: ChatMessage }>(`/api/chats/${active}/messages/${m.id}/pin`, { method: "POST", }); qc.invalidateQueries({ queryKey: ["chat", active] }); } catch (e) { toast.error((e as Error)?.message || "پین پیام ممکن نشد"); } }; const flashMessage = (id: number) => { const thread = threadRef.current; const el = thread?.querySelector(`[data-mid="${id}"]`) as HTMLElement | null; if (!thread || !el) return false; const elRect = el.getBoundingClientRect(); const threadRect = thread.getBoundingClientRect(); const nextTop = thread.scrollTop + (elRect.top - threadRect.top) - thread.clientHeight / 2 + el.clientHeight / 2; thread.scrollTo({ top: Math.max(0, nextTop), behavior: "smooth" }); wasNearBottomRef.current = false; setJumpDown(true); setFlashId(id); if (flashTimer.current) clearTimeout(flashTimer.current); flashTimer.current = setTimeout(() => { setFlashId((cur) => (cur === id ? null : cur)); }, 1500); return true; }; const clearJumpTarget = () => { jumpTargetRef.current = null; window.setTimeout(() => { suppressAutoScrollRef.current = false; }, 500); }; const jumpToMessage = async (id: number) => { if (!active) return; suppressAutoScrollRef.current = true; jumpTargetRef.current = id; if (flashMessage(id)) { clearJumpTarget(); return; } if (msgs.some((m) => m.id === id)) return; setLoadingOlder(true); try { const res = await http.get<{ messages: ChatMessage[]; hasMore?: boolean; hasMoreBefore?: boolean; pinned?: ChatMessage[]; }>(`/api/chats/${active}/messages?around=${id}`); if (!res.messages?.length) { clearJumpTarget(); toast.error("پیام پیدا نشد"); return; } setOlder((prev) => { const byId = new Map(); for (const m of [...res.messages, ...prev]) byId.set(m.id, m); return [...byId.values()].sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ); }); if (typeof res.hasMoreBefore === "boolean") setCanLoadMore(res.hasMoreBefore); else if (typeof res.hasMore === "boolean") setCanLoadMore(res.hasMore); qc.setQueryData(["chat", active], (old: typeof messages) => { if (!old) return old; return { ...old, pinned: res.pinned ?? old.pinned }; }); // Paint + jump handled by effect watching msgs / jumpTargetRef. } catch { clearJumpTarget(); toast.error("بارگذاری پیام ممکن نشد"); } finally { setLoadingOlder(false); } }; const toggleReact = async (m: ChatMessage, emoji: string) => { if (!active || m.id <= 0) return; try { await api<{ ok: boolean }>(`/api/chats/${active}/messages/${m.id}/reaction`, { method: "POST", body: { emoji } }); qc.invalidateQueries({ queryKey: ["chat"] }); } catch { toast.error("ثبت واکنش ممکن نشد"); } }; const like = (m: ChatMessage) => { if (m.id <= 0) return; setHearts((h) => ({ ...h, [m.id]: (h[m.id] ?? 0) + 1 })); setHeartMsg(m.id); if (heartTimer.current) clearTimeout(heartTimer.current); heartTimer.current = setTimeout(() => setHeartMsg(null), 720); void toggleReact(m, "❤️"); }; useEffect(() => { if (!active) return; api<{ ok: boolean }>(`/api/chats/${active}/read`, { method: "POST" }) .then(() => qc.invalidateQueries({ queryKey: ["conversations"] })) .catch(() => undefined); }, [active, qc]); useEffect(() => { if (active && threadRef.current) { threadRef.current.scrollTop = threadRef.current.scrollHeight; } wasNearBottomRef.current = true; suppressAutoScrollRef.current = false; jumpTargetRef.current = null; }, [active]); useEffect(() => { const el = threadRef.current; if (!el) return; if (suppressAutoScrollRef.current) return; if (wasNearBottomRef.current || pending.length) { el.scrollTop = el.scrollHeight; } }, [msgs, pending]); useEffect(() => { const id = jumpTargetRef.current; if (id == null) return; if (!msgs.some((m) => m.id === id)) return; let cancelled = false; const tryFlash = (attempt: number) => { if (cancelled) return; if (flashMessage(id)) { clearJumpTarget(); return; } if (attempt < 8) { window.setTimeout(() => tryFlash(attempt + 1), 40); } else { clearJumpTarget(); toast.error("پیام پیدا نشد"); } }; requestAnimationFrame(() => tryFlash(0)); return () => { cancelled = true; }; }, [msgs]); useEffect(() => { if (!active || !me) return; if (draft) localStorage.setItem(`mt.draft.${me.id}.${active}`, draft); else localStorage.removeItem(`mt.draft.${me.id}.${active}`); }, [draft, active, me]); useEffect(() => { setPartnerTyping(false); return () => { if (typingClearTimer.current) clearTimeout(typingClearTimer.current); }; }, [active]); useEffect(() => { setReplyTo(null); setEmojiOpen(false); setOlder([]); setCanLoadMore(false); setHistoryAnchor(null); setDelArm(null); if (delArmTimer.current) clearTimeout(delArmTimer.current); setReactId(null); setHeartMsg(null); setHearts({}); if (heartTimer.current) clearTimeout(heartTimer.current); setFlashId(null); if (flashTimer.current) clearTimeout(flashTimer.current); setMsgSearchOpen(false); setMsgQuery(""); setMatchIdx(0); setEditingId(null); setEditDraft(""); if (pendingDraftRef.current != null) { const d = pendingDraftRef.current; pendingDraftRef.current = null; setDraft(d); } else { setDraft(active ? localStorage.getItem(`mt.draft.${me?.id}.${active}`) ?? "" : ""); } }, [active, me?.id]); useEffect(() => { if (open && active) { const t = setTimeout(() => taRef.current?.focus(), 140); return () => clearTimeout(t); } }, [open, active]); useEffect(() => { if (!open) setNotif(null); }, [open]); useEffect(() => { const onOpen = (ev: Event) => { const detail = (ev as CustomEvent<{ open?: boolean; with?: number; draft?: string }>).detail; if (typeof detail?.draft === "string") { pendingDraftRef.current = detail.draft; } if (detail?.with) { setActive(detail.with); } else if (typeof detail?.draft === "string") { setDraft(detail.draft); pendingDraftRef.current = null; } setOpen(detail?.open ?? true); }; window.addEventListener("pargar:chat", onOpen); return () => window.removeEventListener("pargar:chat", onOpen); }, []); useEffect(() => { const { accessToken: token, user } = useAuth.getState(); let cancelled = false; if (!user) return; // Prefer bearer subprotocol; fall back to httpOnly cookie on same-origin. const ws = token ? new WebSocket(getWsUrl(), ["bearer", token]) : new WebSocket(getWsUrl()); wsRef.current = ws; ws.onmessage = (ev) => { try { const p = JSON.parse(ev.data) as { type?: string; from?: number; item?: ChatMessage }; if (p.type === "chat_message") { qc.invalidateQueries({ queryKey: ["conversations"] }); if (p.from && activeRef.current === p.from) { qc.invalidateQueries({ queryKey: ["chat"] }); api<{ ok: boolean }>(`/api/chats/${p.from}/read`, { method: "POST" }).catch(() => undefined); } const mine = p.from === useAuth.getState().user?.id; if (!mine && !cancelled) { const activeThread = !!activeRef.current && activeRef.current === p.from; if (!activeThread) { const conv = convRef.current.find((c) => c.partner.id === p.from); if (conv) { setNotif({ id: `${Date.now()}-${p.from}`, partner: p.from ?? 0, name: conv.partner.name, body: p.item ? previewOf(p.item) : "پیام جدید", }); if (notifTimer.current) clearTimeout(notifTimer.current); notifTimer.current = setTimeout(() => setNotif(null), 5000); } } } } else if (p.type === "chat_read") { if (p.from && activeRef.current === p.from) qc.invalidateQueries({ queryKey: ["chat", p.from] }); qc.invalidateQueries({ queryKey: ["conversations"] }); } else if (p.type === "typing") { if (p.from && activeRef.current === p.from) { setPartnerTyping(true); if (typingClearTimer.current) clearTimeout(typingClearTimer.current); typingClearTimer.current = setTimeout(() => setPartnerTyping(false), 4500); } } else if (p.type === "chat_reaction") { if (p.from && activeRef.current === p.from) qc.invalidateQueries({ queryKey: ["chat"] }); } else if (p.type === "chat_edit") { const item = p.item; if (item && p.from && activeRef.current === p.from) { qc.setQueryData<{ messages: ChatMessage[]; hasMore: boolean; pinned?: ChatMessage[] }>( ["chat", activeRef.current], (old) => { if (!old) return old; return { ...old, messages: old.messages.map((m) => (m.id === item.id ? item : m)), pinned: old.pinned?.map((m) => (m.id === item.id ? item : m)), }; }, ); setOlder((prev) => prev.map((m) => (m.id === item.id ? item : m))); } qc.invalidateQueries({ queryKey: ["conversations"] }); } else if (p.type === "chat_delete") { const deletedId = (p as { id?: number }).id; if (deletedId) { setOlder((prev) => prev.filter((m) => m.id !== deletedId)); setPending((prev) => prev.filter((m) => m.id !== deletedId)); } qc.invalidateQueries({ queryKey: ["conversations"] }); qc.invalidateQueries({ queryKey: ["chat"] }); } else if (p.type === "chat_pin") { qc.invalidateQueries({ queryKey: ["chat"] }); } } catch { /* ignore */ } }; return () => { cancelled = true; wsRef.current = null; ws.close(); }; }, [qc]); const signalTyping = () => { const ws = wsRef.current; if (!ws || ws.readyState !== WebSocket.OPEN || !active) return; const now = Date.now(); if (now - typingSentAt.current < 2400) return; typingSentAt.current = now; ws.send(JSON.stringify({ type: "typing", to: active })); }; const partner = conversations.find((c) => c.partner.id === active)?.partner ?? (loveList?.mentors ?? []).find((m) => m.id === active); const send = async (text?: string, att?: ChatAttachment | null) => { const body = (text ?? draft).trim(); if (!active || (!body && !att && !attPref) || body.length > MAX_LEN || sending) return; setSending(true); const attachment = att ?? attPref ?? undefined; const quoteId = replyTo?.id; const uid = useAuth.getState().user; const temp: ChatMessage = { id: -Date.now(), userId: uid?.role === "mentor" ? active : (uid?.id ?? 0), mentorId: uid?.role === "mentor" ? (uid?.id ?? 0) : active, senderRole: (uid?.role ?? "student") as ChatMessage["senderRole"], body, createdAt: new Date().toISOString(), ...(attachment ? { attachment } : {}), ...(quoteId ? { replyTo: quoteId } : {}), }; setDraft(""); setAttPref(null); setReplyTo(null); setHistoryAnchor(null); wasNearBottomRef.current = true; setPending((p) => [...p, temp]); try { const res = await api<{ message: ChatMessage }>(`/api/chats/${active}/messages`, { method: "POST", body: { body, attachment, replyTo: quoteId }, }); qc.setQueryData<{ messages: ChatMessage[]; hasMore: boolean }>(["chat", active], (old) => { const list = old?.messages ?? []; if (!list.some((x) => x.id === res.message.id)) { return { ...old, messages: [...list, res.message] } as typeof old; } return old; }); setPending([]); qc.invalidateQueries({ queryKey: ["conversations"] }); } catch (err) { setPending([]); setDraft(body); setAttPref(attachment ?? null); setReplyTo(replyTo); toast.error((err as Error).message || "ارسال ممکن نشد"); } finally { setSending(false); } }; const pickFile = () => fileRef.current?.click(); const uploadSelected = async (file: File) => { if (file.size > 25 * 1024 * 1024) { toast.error("فایل باید کمتر از ۲۵ مگابایت باشد"); return; } setUploading(true); setUploadPct(0); try { const { attachment } = await apiFormProgress<{ attachment: ChatAttachment }>("/api/chats/upload", toForm(file), setUploadPct); setAttPref(attachment); } catch (err) { toast.error((err as Error).message || "آپلود ممکن نشد"); } finally { setUploading(false); } }; const onFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ""; if (file) await uploadSelected(file); }; const beginReplace = (m: ChatMessage) => { const kind = m.attachment?.type; if (kind !== "image" && kind !== "video" && kind !== "audio") return; replaceTarget.current = m; const input = replaceRef.current; if (!input) return; input.accept = kind === "image" ? "image/*" : kind === "video" ? "video/*" : "audio/*"; input.click(); }; const onReplaceFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ""; const target = replaceTarget.current; replaceTarget.current = null; if (!file || !target || !active) return; if (file.size > 25 * 1024 * 1024) { toast.error("فایل باید کمتر از ۲۵ مگابایت باشد"); return; } setUploading(true); setUploadPct(0); try { const { attachment } = await apiFormProgress<{ attachment: ChatAttachment }>("/api/chats/upload", toForm(file), setUploadPct); if (attachment.type !== target.attachment?.type) { toast.error("فایل جدید باید از همان نوع باشد"); return; } const res = await http.put<{ message: ChatMessage }>(`/api/chats/${active}/messages/${target.id}`, { body: target.body ?? "", attachment, }); applyEdited(res.message); qc.invalidateQueries({ queryKey: ["conversations"] }); } catch (err) { toast.error(toUserError(err, "جایگذاری ممکن نشد")); } finally { setUploading(false); } }; const voiceRef = useRef<{ recorder: MediaRecorder | null; stream: MediaStream | null; chunks: Blob[]; timer: ReturnType; start: number; live?: () => void; } | null>(null); const [recSec, setRecSec] = useState(0); const [recLevel, setRecLevel] = useState(0); const [recUrl, setRecUrl] = useState(null); const [recBlob, setRecBlob] = useState(null); const [recMime, setRecMime] = useState(undefined); const [recDur, setRecDur] = useState(0); useEffect(() => { return () => { const v = voiceRef.current; if (v?.recorder && v.recorder.state !== "inactive") v.recorder.stop(); v?.stream?.getTracks().forEach((t) => t.stop()); if (v?.timer) clearInterval(v.timer); }; }, []); useEffect(() => { if (!preview) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setPreview(null); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [preview]); const cancelVoicePreview = () => { if (recUrl) URL.revokeObjectURL(recUrl); setRecUrl(null); setRecBlob(null); setRecMime(undefined); setRecDur(0); }; const sendVoice = async () => { const blob = recBlob; const mime = recMime; if (!blob || !active || sending || uploading) return; cancelVoicePreview(); setUploading(true); setUploadPct(0); try { const { attachment } = await apiFormProgress<{ attachment: ChatAttachment }>("/api/chats/upload", toForm(blob, mime), setUploadPct); await send("", attachment); } catch (err) { toast.error((err as Error).message || "ارسال صوتی ممکن نشد"); } finally { setUploading(false); } }; const toggleMic = async () => { const v = voiceRef.current; if (v?.recorder && v.recorder.state === "recording") { v.recorder.stop(); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mime = pickMime(); const recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined); const chunks: Blob[] = []; recorder.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); }; recorder.onstop = async () => { const blob = new Blob(chunks, { type: mime ?? "audio/webm" }); const secs = Math.round((Date.now() - (voiceRef.current?.start ?? Date.now())) / 1000); clearInterval(voiceRef.current?.timer); voiceRef.current?.live?.(); stream.getTracks().forEach((t) => t.stop()); setRecSec(0); setRecLevel(0); voiceRef.current = null; if (secs < 1 || blob.size === 0) { toast.error("پیام صوتی خیلی کوتاه بود"); return; } setRecUrl(URL.createObjectURL(blob)); setRecBlob(blob); setRecMime(mime); setRecDur(secs); }; recorder.start(250); let liveStop: (() => void) | undefined; try { const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (Ctx) { const ctx = new Ctx(); const src = ctx.createMediaStreamSource(stream); const analyser = ctx.createAnalyser(); analyser.fftSize = 256; analyser.smoothingTimeConstant = 0.6; src.connect(analyser); const data = new Uint8Array(analyser.frequencyBinCount); const liveTick = window.setInterval(() => { analyser.getByteTimeDomainData(data); let max = 0; for (let i = 0; i < data.length; i++) { const v = Math.abs(data[i] - 128) / 128; if (v > max) max = v; } setRecLevel(Math.max(0.05, Math.min(1, max * 1.8))); }, 60); liveStop = () => { clearInterval(liveTick); void ctx.close(); }; } } catch { /* mic level meter optional */ } voiceRef.current = { recorder, stream, chunks, timer: setInterval(() => setRecSec((s) => s + 1), 1000), start: Date.now(), live: liveStop, }; } catch { toast.error("دسترسی به میکروفون ممکن نشد"); } }; const cancelVoice = () => { const v = voiceRef.current; if (v?.stream) { v.live?.(); v.stream.getTracks().forEach((t) => t.stop()); clearInterval(v.timer); setRecSec(0); setRecLevel(0); voiceRef.current = null; } }; const sortedConvs = useMemo(() => { const q = query.trim().toLowerCase(); const list = [...conversations].sort((a, b) => { const ta = a.lastMessage ? new Date(a.lastMessage.createdAt).getTime() : 0; const tb = b.lastMessage ? new Date(b.lastMessage.createdAt).getTime() : 0; return tb - ta; }); if (!q) return list; return list.filter( (c) => c.partner.name.toLowerCase().includes(q) || (c.lastMessage?.body ?? "").toLowerCase().includes(q) ); }, [conversations, query]); const CHAT_PAGE = 40; const view = useMemo(() => { if (!msgs.length) return { items: msgs, start: 0, end: 0 }; let end = msgs.length; if (historyAnchor != null) { const idx = msgs.findIndex((m) => m.id === historyAnchor); if (idx >= 0) end = idx + 1; } const start = Math.max(0, end - CHAT_PAGE); return { items: msgs.slice(start, end), start, end }; }, [msgs, historyAnchor]); const thread = useMemo(() => buildThread(view.items, me?.role), [view.items, me?.role]); useEffect(() => { if (!msgSearchOpen || !matchCount) return; const id = matchIds[matchIdx]; if (id == null) return; const el = threadRef.current?.querySelector(`[data-mid="${id}"]`); if (el) requestAnimationFrame(() => el.scrollIntoView({ block: "center", behavior: "smooth" })); }, [matchIdx, msgSearchOpen, matchCount, matchIds, view.items]); const showEarlier = async () => { const first = view.items[0]; if (!first || loadingOlder) return; wasNearBottomRef.current = false; suppressAutoScrollRef.current = true; if (view.start > 0) { setHistoryAnchor(first.id); requestAnimationFrame(() => { threadRef.current?.querySelector(`[data-mid="${first.id}"]`)?.scrollIntoView({ block: "start" }); suppressAutoScrollRef.current = false; }); return; } if (!canLoadMore) { suppressAutoScrollRef.current = false; return; } await loadOlder(); setHistoryAnchor(first.id); requestAnimationFrame(() => { threadRef.current?.querySelector(`[data-mid="${first.id}"]`)?.scrollIntoView({ block: "end" }); suppressAutoScrollRef.current = false; }); }; const emptyThread = !messagesLoading && !messagesQuery.isError && msgs.length === 0; const openProfile = (id: number) => { setOpen(false); router.push(`/profile/${id}`); }; const applyEdited = (item: ChatMessage) => { qc.setQueryData<{ messages: ChatMessage[]; hasMore: boolean; pinned?: ChatMessage[] }>( ["chat", activeRef.current], (old) => { if (!old) return old; return { ...old, messages: old.messages.map((m) => (m.id === item.id ? item : m)), pinned: old.pinned?.map((m) => (m.id === item.id ? item : m)), }; }, ); setOlder((prev) => prev.map((m) => (m.id === item.id ? item : m))); }; const atLimit = draft.length >= MAX_LEN; const recording = !!voiceRef.current?.recorder && voiceRef.current.recorder.state === "recording"; const firstUnread = useMemo(() => { if (!me) return null; for (const m of msgs) { if (!isMineMsg(m, me.role) && !m.readAt) return m.id; } return null; }, [msgs, me]); return ( <> {open && ( <> { setOpen(false); setActive(null); }} className="fixed inset-0 z-[65] bg-background/35 backdrop-blur-[2px]" />
{active && partner ? ( <> ) : ( <>

گفتگوها

)} {active && ( )} {!active && ( )}
{!active ? (
setQuery(e.target.value)} placeholder="جستجو در گفتگوها…" dir="rtl" className="h-9 w-full rounded-xl border-2 border-border bg-background/60 ps-8 pe-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/80 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring" />
{composeOpen && (

شروع گفتگوی جدید

{(loveList?.mentors ?? []).map((m) => ( ))} {(loveList?.mentors ?? []).length === 0 && (

مخاطب مجازی برای گفتگو وجود ندارد.

)}
)} {sortedConvs.map((c) => ( ))} {sortedConvs.length === 0 && conversations.length > 0 && (

گفتگویی یافت نشد.

)} {convLoading && conversations.length === 0 && (
{[0, 1, 2].map((i) => ( ))}
)} {conversations.length === 0 && !composeOpen && !convLoading && (

برای شروع، روی مداد بزنید

{(loveList?.mentors ?? []).map((m) => ( ))}
)}
) : (
{msgSearchOpen && (
setMsgQuery(e.target.value)} onKeyDown={(e) => { if (!matchCount) return; if (e.key === "Escape") { e.preventDefault(); setMsgSearchOpen(false); setMsgQuery(""); setMatchIdx(0); } else if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); setMatchIdx((i) => (i + 1) % matchCount); } else if (e.key === "Enter" && e.shiftKey) { e.preventDefault(); setMatchIdx((i) => (i - 1 + matchCount) % matchCount); } }} placeholder="جستجو در این گفتگو…" dir="rtl" autoFocus className="h-8 min-w-0 flex-1 rounded-lg border-2 border-border bg-background/70 px-2.5 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring" /> {msgQuery.trim() !== "" && ( {matchCount ? `${fa(matchIdx + 1)}/${fa(matchCount)}` : "پیدا نشد"} )}
)} {activePin && (
{pinned.length > 1 && ( )}
)}
{ const el = threadRef.current; if (!el) return; const near = el.scrollHeight - el.scrollTop - el.clientHeight < 140; wasNearBottomRef.current = near; setJumpDown(!near); }} onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={(e) => { e.preventDefault(); setDragOver(false); const f = e.dataTransfer.files?.[0]; if (f) void uploadSelected(f); }} dir="ltr" className="relative z-10 h-full min-h-0 space-y-1 overflow-y-auto overscroll-contain p-3 chat-scroll" > {dragOver && (

فایل را رها کنید

)} {messagesLoading && } {messagesQuery.isError && !messagesLoading && (

بارگذاری پیام‌ها ممکن نشد

)} {emptyThread && (

سلام کنید — کمک بگیرید، مشکلات را بگویید یا موفقیت را جشن بگیرید.

پاسخ کمتر از یک روز
)} {(view.start > 0 || canLoadMore) && (
)} {thread.map((seg, i) => seg.kind === "date" ? (
{seg.label}
) : ( <> {firstUnread && seg.msgs.some((mm) => mm.id === firstUnread) && (
پیام‌های جدید
)}
{!seg.mine && partner && ( )}
{seg.msgs.map((m, idx) => { const isLast = idx === seg.msgs.length - 1; const mediaOnly = !!m.attachment && !m.body; const isPending = m.id < 0; const isGif = mediaOnly && isGifName(m.attachment!); const onlyEmoji = !m.replyTo && !m.attachment && !!m.body && isSingleEmoji(m.body); const q0 = msgQuery.trim(); const dimmed = q0 !== "" && matchCount > 0 && !matchSet.has(m.id); const isCurrent = matchSet.has(m.id) && m.id === matchIds[matchIdx]; return (
like(m)} onClick={(e) => { const t = e.target as HTMLElement; if (t.closest("button, a, input, [role='button']")) return; setReactId((cur) => (cur === m.id ? null : m.id)); }} className={cn( "relative isolate group/msg max-w-full min-w-0", !isLast && "mb-1", dimmed && "opacity-30 saturate-50", isCurrent && "ring-2 ring-accent rounded-xl", )} > {heartMsg === m.id && reactId !== m.id && ( )} {mediaOnly && isGif ? ( ) : mediaOnly ? ( <> ) : onlyEmoji ? ( <>

) : ( <> {m.replyTo && ( x.id === m.replyTo) ?? null} mine={seg.mine} partnerName={partner?.name} meRole={me?.role} /> )} {m.attachment && ( )} {editingId === m.id ? (