"use client"; import { useEffect, useMemo, useRef, useState } from "react"; 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, apiForm, http, getWsUrl } from "@/lib/api"; 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 { 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 [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 [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 [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 taRef = useRef(null); const flashTimer = useRef | null>(null); const fileRef = 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 { data: convData } = useQuery({ queryKey: ["conversations"], queryFn: () => http.get<{ conversations: Conversation[] }>("/api/chats/conversations"), refetchInterval: 20_000, }); 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 { data: messages } = useQuery({ queryKey: ["chat", active], queryFn: () => http.get<{ messages: ChatMessage[]; hasMore: boolean; pinned?: ChatMessage[] }>( `/api/chats/${active}/messages`, ), enabled: !!active, refetchInterval: active ? 5000 : false, }); 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; const el = threadRef.current?.querySelector(`[data-mid="${id}"]`); if (el) requestAnimationFrame(() => el.scrollIntoView({ block: "center", behavior: "smooth" })); }, [matchIdx, msgSearchOpen, matchCount, msgQuery]); 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 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); 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); 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 === "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_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); 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); try { const { attachment } = await apiForm<{ attachment: ChatAttachment }>("/api/chats/upload", toForm(file)); 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 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); try { const { attachment } = await apiForm<{ attachment: ChatAttachment }>("/api/chats/upload", toForm(blob, mime)); 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 thread = useMemo(() => buildThread(msgs, me?.role), [msgs, me?.role]); const emptyThread = msgs.length === 0; 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 ? ( <>

{partner.name}

{partnerTyping ? (

در حال تایپ است

) : (

{roleLabel(partner.role)}

)}
) : ( <>

گفتگوها

)} {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 && (

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

)} {conversations.length === 0 && !composeOpen && (

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

{(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 && (

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

)} {emptyThread && (

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

پاسخ کمتر از یک روز
)} {canLoadMore && (
)} {thread.map((seg, i) => seg.kind === "date" ? (
{seg.label}
) : ( <> {firstUnread && seg.msgs.some((mm) => mm.id === firstUnread) && (
پیام‌های جدید
)}
{!seg.mine && ( )}
{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 && ( )} {m.body && (

)} {m.body && ( {clock(m.createdAt)} {seg.mine && (isPending ? ( ) : ( ))} )} )}
{!!m.reactions?.length && (
void toggleReact(m, em)} />
)} setReactId(reactId === m.id ? null : m.id)} onReactEmoji={(em) => { setReactId(null); void toggleReact(m, em); }} onReply={() => setReplyTo(m)} onCopy={() => copyText(m)} onPin={() => void togglePin(m)} onDelete={() => armDelete(m.id)} />
); })}
) )}
{jumpDown && ( { const el = threadRef.current; if (el) el.scrollTop = el.scrollHeight; wasNearBottomRef.current = true; setJumpDown(false); }} aria-label="برو به آخرین پیام" className="absolute bottom-3 end-3 z-30 grid h-9 w-9 place-items-center rounded-full border-2 border-primary/50 bg-card/95 text-primary shadow-soft ring-2 ring-white/40 backdrop-blur-sm transition-colors hover:border-primary hover:bg-primary hover:text-primary-foreground active:scale-90 dark:bg-[#1a1e33]/95 dark:ring-black/30" > )}
{partnerTyping && (
{partner?.name} در حال تایپ است…
)} {emptyThread && (
{(me?.role === "student" ? STUDENT_SUGGESTIONS : MENTOR_SUGGESTIONS).map((q) => ( ))}
)}
{recording && (
{Array.from({ length: 9 }).map((_, i) => { const on = recLevel > (i + 1) / 9; return ( ); })}
{clockDur(recSec)}
)} {(attPref || uploading) && (
{uploading && ( )} {uploading ? ( <> در حال آپلود… ) : attPref ? ( <> {attPref.type === "image" ? ( ) : ( )}

{attPref.name}

{bytes(attPref.size)}

{attPref.type === "image" && ( )} ) : null}
)} {replyTo && (
در پاسخ به {isMineMsg(replyTo, me?.role) ? "خودتان" : partner?.name ?? "پیام"} {quotePreview(replyTo)}
)}
{recUrl ? ( void sendVoice()} /> ) : ( <>
{emojiOpen && (
ایموجی
{EMOJIS.map((e) => ( ))}
)}
{draft.trim() || attPref ? ( ) : ( )}
{ setDraft(v); if (v.trim()) signalTyping(); }} onSend={() => send()} placeholder="پیام خود را بنویسید…" disabled={sending || uploading} />
)}
)}
)} {preview && ( setPreview(null)} className="fixed inset-0 z-[80] flex flex-col items-center justify-center gap-3 bg-black/85 p-4 backdrop-blur-2xl" >
{preview.type === "image" ? (

{preview.name}

) : ( )}
{preview.type === "image" ? (
e.stopPropagation()}>
) : (
e.stopPropagation()}>
)}

{bytes(preview.size)}

)}
{notif && ( { setActive(notif.partner); setOpen(true); setNotif(null); }} className="fixed bottom-24 start-5 end-5 z-[64] flex max-w-full items-center gap-2.5 rounded-2xl border-2 border-white/50 bg-card/85 p-2.5 text-right shadow-offset backdrop-blur-2xl sm:end-auto sm:max-w-[300px]" > {notif.name} {notif.body} )} {me && !open && (
{unreadTotal > 0 && ( )} setOpen(true)} aria-label="گفتگوها" className="press relative grid h-14 w-14 place-items-center rounded-2xl shadow-offset transition-colors bg-gradient-to-tr from-primary to-accent text-primary-foreground" > {unreadTotal > 0 && ( {fa(unreadTotal)} )}
)} ); } // ---- attachments ----------------------------------------------------------- function isGifName(att: ChatAttachment) { return /\.gif($|\?)/i.test(att.name || "") || /\.gif($|\?)/i.test(att.url || ""); } function GifSticker({ att, mine, time, pending, onPreview, }: { att: ChatAttachment; mine: boolean; time: string; pending?: boolean; onPreview?: (att: ChatAttachment) => void; }) { return (
{clock(time)} {mine && (pending ? ( ) : ( ))}
); } function MediaOnly({ att, mine, time, onPreview, }: { att: ChatAttachment; mine: boolean; time: string; onPreview?: (att: ChatAttachment) => void; }) { const previewTimer = useRef | null>(null); useEffect( () => () => { if (previewTimer.current) clearTimeout(previewTimer.current); }, [] ); const openPreview = (a: ChatAttachment) => { if (previewTimer.current) clearTimeout(previewTimer.current); previewTimer.current = setTimeout(() => onPreview?.(a), 230); }; const cancelPreview = () => { if (previewTimer.current) clearTimeout(previewTimer.current); previewTimer.current = null; }; const corner = mine ? "end-1.5 bottom-1.5" : "start-1.5 bottom-1.5"; const chip = ( {clock(time)} ); if (att.type === "image") { return ( ); } if (att.type === "video") { return (
onPreview?.(att)} /> {clock(time)}
); } if (att.type === "audio") { return (
{clock(time)} {mine && }
); } return ( ); } function AttachmentView({ att, mine, time, onPreview, }: { att: ChatAttachment; mine: boolean; time: string; onPreview?: (att: ChatAttachment) => void; }) { const previewTimer = useRef | null>(null); useEffect( () => () => { if (previewTimer.current) clearTimeout(previewTimer.current); }, [] ); const openPreview = (a: ChatAttachment) => { if (previewTimer.current) clearTimeout(previewTimer.current); previewTimer.current = setTimeout(() => onPreview?.(a), 230); }; const cancelPreview = () => { if (previewTimer.current) clearTimeout(previewTimer.current); previewTimer.current = null; }; if (att.type === "image") { return (
{clock(time)}
); } if (att.type === "video") { return (
onPreview?.(att)} /> {clock(time)}
); } if (att.type === "audio") { return (
{clock(time)} {mine && }
); } return ( e.preventDefault()} className={cn( "mb-0.5 flex items-center gap-2 rounded-xl bg-background/50 px-3 py-2", !mine && "border border-border/50" )} > {att.name} {bytes(att.size)} ); } let activeVoice: HTMLAudioElement | null = null; function VoiceRecPreview({ url, dur, sending, onCancel, onSend, }: { url: string; dur: number; sending: boolean; onCancel: () => void; onSend: () => void; }) { const audioRef = useRef(null); const canvasRef = useRef(null); const peaksRef = useRef([]); const [playing, setPlaying] = useState(false); const [cur, setCur] = useState(0); const [ready, setReady] = useState(false); useEffect(() => { let ctx: AudioContext | null = null; let cleanup = false; const a = new Audio(url); audioRef.current = a; const onTime = () => setCur(a.currentTime); const onEnd = () => { setPlaying(false); setCur(0); }; a.addEventListener("timeupdate", onTime); a.addEventListener("ended", onEnd); fetch(url) .then((r) => r.arrayBuffer()) .then((buf) => { if (cleanup) return; const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; ctx = new AC(); ctx.decodeAudioData( buf, (audio) => { if (cleanup) return; peaksRef.current = computePeaks(audio, 80); setReady(true); }, () => { if (!cleanup) setReady(true); } ); }) .catch(() => { if (!cleanup) setReady(true); }); return () => { cleanup = true; a.pause(); a.removeEventListener("timeupdate", onTime); a.removeEventListener("ended", onEnd); void ctx?.close().catch(() => undefined); }; }, [url]); useEffect(() => { drawWave(canvasRef.current, peaksRef.current, cur, dur); }, [cur, dur, ready]); const toggle = () => { const a = audioRef.current; if (!a) return; if (a.paused) { void a.play().catch(() => undefined); setPlaying(true); } else { a.pause(); setPlaying(false); } }; const seekFromEvent = (e: React.PointerEvent) => { const a = audioRef.current; const cv = canvasRef.current; if (!a || !cv || dur <= 0) return; const rect = cv.getBoundingClientRect(); if (rect.width === 0) return; const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); a.currentTime = ratio * dur; setCur(a.currentTime); }; return (
{playing ? "در حال پخش…" : ""} {clockDur(cur)} / {clockDur(dur)}
{ if (e.buttons & 1) seekFromEvent(e); }} className="mt-1 h-[28px] w-full cursor-pointer touch-none" aria-label="نوار پخش پیش‌نمایش صدا" />
); } function isSingleEmoji(text: string): boolean { const s = text.trim(); if (!s) return false; // ZWJ sequences (e.g. family) are one grapheme — do not use string.length. if (typeof Intl !== "undefined" && "Segmenter" in Intl) { const parts = [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s)]; if (parts.length !== 1) return false; } const RE = /^(?:(?:\p{Regional_Indicator}){2}|\p{Extended_Pictographic}(?:\p{Emoji_Modifier}\uFE0F?|\uFE0F|\uFE0E)?(?:\u200D\p{Extended_Pictographic}(?:\p{Emoji_Modifier}\uFE0F?|\uFE0F|\uFE0E)?)*)$/u; return RE.test(s); } function computePeaks(audio: AudioBuffer, buckets: number): number[] { const ch = audio.length > 0 ? audio.getChannelData(0) : new Float32Array(0); const step = Math.max(1, Math.floor(ch.length / buckets)); const out: number[] = []; for (let i = 0; i < buckets; i++) { let max = 0; for (let j = i * step; j < Math.min((i + 1) * step, ch.length); j++) { const v = Math.abs(ch[j]); if (v > max) max = v; } out.push(Math.min(1, max * 3.6)); } return out; } function drawWave( cv: HTMLCanvasElement | null, peaks: number[], cur: number, dur: number ) { if (!cv) return; const px = window.devicePixelRatio || 1; const rect = cv.getBoundingClientRect(); if (rect.width < 1) return; const w = rect.width; const h = rect.height; if (cv.width !== Math.round(w * px) || cv.height !== Math.round(h * px)) { cv.width = Math.round(w * px); cv.height = Math.round(h * px); } const g = cv.getContext("2d"); if (!g) return; g.clearRect(0, 0, cv.width, cv.height); g.scale(px, px); const css = getComputedStyle(document.documentElement); const playedC = css.getPropertyValue("--color-primary").trim() || "#6d63f0"; const n = Math.max(32, peaks.length || 32); const playedRatio = dur > 0 ? Math.min(1, Math.max(0, cur / dur)) : 0; const slot = w / n; const bw = Math.max(2, slot * 0.62); for (let i = 0; i < n; i++) { const pk = peaks[i] ?? 0.22 + 0.42 * Math.abs(Math.sin(i * 1.3 + 0.5)); const bh = Math.max(3, pk * (h - 4)); const x = i * slot + (slot - bw) / 2; const y = (h - bh) / 2; g.fillStyle = i / n <= playedRatio ? playedC : "rgba(120,122,150,0.35)"; g.beginPath(); g.roundRect(x, y, bw, bh, 2); g.fill(); } g.setTransform(1, 0, 0, 1, 0, 0); } function stopActiveVoice() { if (activeVoice) { const prev = activeVoice; activeVoice = null; prev.pause(); } } function ChatVideoPlayer({ att, onPreview, }: { att: ChatAttachment; onPreview?: () => void; }) { const wrapRef = useRef(null); const videoRef = useRef(null); const [playing, setPlaying] = useState(false); const [cur, setCur] = useState(0); const [dur, setDur] = useState(0); const [muted, setMuted] = useState(false); const [buffering, setBuffering] = useState(false); const [showUI, setShowUI] = useState(false); const toggle = () => { const v = videoRef.current; if (!v) return; if (v.paused) { void v.play().catch(() => undefined); } else { v.pause(); } }; const seek = (t: number) => { const v = videoRef.current; if (!v || !Number.isFinite(t)) return; v.currentTime = t; setCur(t); }; const fullscreen = () => { const el = wrapRef.current; if (!el) return; if (document.fullscreenElement) { void document.exitFullscreen().catch(() => undefined); } else { void el.requestFullscreen().catch(() => undefined); } }; const prog = dur > 0 ? Math.min(cur / dur, 1) : 0; return (
setShowUI(true)} onMouseLeave={() => setShowUI(false)} >