"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 { Archive, ArchiveRestore, ArrowDown, ArrowRight, Bell, BellOff, Bookmark, Check, CheckCheck, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock, CornerDownLeft, Copy, Download, ExternalLink, FileText, Film, Image as ImageIcon, Loader2, Maximize, Megaphone, 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, downloadAllAsZip, 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, formatJalaliStamp } 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"; import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge"; 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) => ( ))}
); } type PeekPreview = { name: string; saved: boolean; partner: Conversation["partner"]; messages: ChatMessage[]; loading: boolean; }; const SWIPE_ACTION_PX = 84; const ROW_EASE = "transform 200ms cubic-bezier(0.22,1,0.36,1)"; function ConversationRow({ c, rowName, saved, canRaise, nextPinRank, shiftY, lifting, settling, freezeMotion, shellH, onOpen, onRaise, onPinGesture, onArmPeek, onEndPeek, onDisarmPeek, heldRef, }: { c: Conversation; rowName: string; saved: boolean; canRaise: boolean; nextPinRank: number; shiftY: number; lifting: boolean; settling: boolean; freezeMotion: boolean; shellH: number; onOpen: () => void; onRaise?: () => void; onPinGesture?: (phase: "start" | "move" | "end" | "cancel", clientY: number) => void; onArmPeek: () => () => void; onEndPeek: () => void; onDisarmPeek: () => void; heldRef: { current: boolean }; }) { const qc = useQueryClient(); const pinGestureRef = useRef(onPinGesture); pinGestureRef.current = onPinGesture; const showPin = !saved; const showArchive = !saved; const showMute = !saved; const actionCount = (showPin ? 1 : 0) + (showArchive ? 1 : 0) + (showMute ? 1 : 0) + (canRaise && onRaise ? 1 : 0); const actionPx = actionCount * SWIPE_ACTION_PX; const revealRef = useRef(0); const [reveal, setReveal] = useState(0); const [dragging, setDragging] = useState(false); const skipClick = useRef(false); const rtlRef = useRef(true); useEffect(() => { rtlRef.current = document.documentElement.dir !== "ltr"; }, []); useEffect(() => { setReveal((n) => { const next = Math.min(n, actionPx); revealRef.current = next; return next; }); }, [actionPx]); const setShift = (n: number) => { const next = Math.max(0, Math.min(actionPx, n)); revealRef.current = next; setReveal(next); }; const savePrefs = (patch: { pinnedRank: number | null; muted: boolean; archived: boolean }) => { void http.put(`/api/chats/${c.partner.id}/prefs`, patch).then(() => qc.invalidateQueries({ queryKey: ["conversations"] })); setShift(0); }; const yTransition = freezeMotion || (lifting && !settling) ? "none" : ROW_EASE; return (
{actionCount > 0 && (
{showPin && ( )} {canRaise && onRaise && ( )} {showMute && ( )} {showArchive && ( )}
)}
); } function PeekThread({ messages, role, saved, partner, }: { messages: ChatMessage[]; role?: string; saved: boolean; partner: Conversation["partner"]; }) { const shown = messages.slice(-16); const thread = buildThread(shown, role); if (shown.length === 0) return null; return (
{thread.map((seg, i) => seg.kind === "date" ? (
{seg.label}
) : (
{!seg.mine && !saved && ( )}
{seg.msgs.map((m, idx) => ( ))}
), )}
); } function PeekBubble({ m, mine, last, messages, partnerName, role, }: { m: ChatMessage; mine: boolean; last: boolean; messages: ChatMessage[]; partnerName: string; role?: string; }) { const atts = messageAttachments(m); const multi = atts.length > 1; const mediaOnly = atts.length > 0 && !m.body; const isGif = !multi && mediaOnly && isGifName(atts[0]); const onlyEmoji = !m.replyTo && atts.length === 0 && !!m.body && isSingleEmoji(m.body); return (
{multi ? ( <> {m.body ? (

) : null} {m.body && ( {m.editedAt && ویرایش‌شده} {clock(m.createdAt)} {mine && } )} ) : mediaOnly && isGif ? ( ) : mediaOnly ? ( ) : onlyEmoji ? (

) : ( <> {m.replyTo != null && ( x.id === m.replyTo) ?? null} mine={mine} partnerName={partnerName} meRole={role} /> )} {m.attachment && } {m.body ? (

) : null} {m.body && ( {m.editedAt && ویرایش‌شده} {clock(m.createdAt)} {mine && } )} )}
{!!m.reactions?.length && (
undefined} />
)}
); } 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 [showArchived, setShowArchived] = useState(false); const [peek, setPeek] = useState(null); const [broadcastOpen, setBroadcastOpen] = useState(false); const [broadcastAll, setBroadcastAll] = useState(true); const [broadcastIds, setBroadcastIds] = useState([]); const [broadcastConfirm, setBroadcastConfirm] = useState(false); const [broadcastBusy, setBroadcastBusy] = useState(false); const [pinDragView, setPinDragView] = useState<{ id: number; dy: number; to: number; rowH: number; shellH: number; phase: "drag" | "settle" | "commit"; } | null>(null); const pinDragRef = useRef<{ id: number; startY: number; from: number; rowH: number; shellH: number; to: number; dy: number; ids: number[]; minDy: number; maxDy: number; } | null>(null); const pinEpoch = useRef(0); const pinLandTimer = useRef(null); const pendingPinOrder = useRef(null); const sendBroadcastRef = useRef<(opts?: { attachment?: ChatAttachment | null; skipConfirm?: boolean }) => Promise>(async () => {}); const peekToken = useRef(0); const peekArmed = useRef(false); const [sending, setSending] = useState(false); const [pending, setPending] = useState([]); const [partnerTyping, setPartnerTyping] = useState(false); const [attPref, setAttPref] = useState([]); 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 [editAdds, setEditAdds] = useState([]); const [editRemoved, setEditRemoved] = useState([]); const [editReplacements, setEditReplacements] = useState>({}); const [editBusy, setEditBusy] = useState(false); const taRef = useRef(null); const flashTimer = useRef | null>(null); const fileRef = useRef(null); const photoRef = useRef(null); const editFileRef = useRef(null); const editPick = useRef<{ mode: "add" | "replace"; index: number }>({ mode: "add", index: 0 }); 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", showArchived, me?.id], queryFn: async () => { type ConvPayload = { conversations: Conversation[]; mutedAll?: boolean }; const data = await http.get( `/api/chats/conversations${showArchived ? "?archived=1" : ""}`, ); const meId = me?.id; if (meId == null) return data; const rest = data.conversations.filter((c) => c.partner.id !== meId); if (showArchived) return { ...data, conversations: rest }; let saved = data.conversations.find((c) => c.partner.id === meId); if (!saved?.lastMessage) { try { const archived = await http.get("/api/chats/conversations?archived=1"); const rescued = archived.conversations.find((c) => c.partner.id === meId); if (rescued) saved = rescued; } catch { /* inbox row still stands in for saved messages */ } } if (!saved) return { ...data, conversations: rest }; return { ...data, conversations: [{ ...saved, archived: false }, ...rest] }; }, refetchInterval: 20_000, }); const convData = convQuery.data; const convLoading = convQuery.isPending; const conversations = useMemo(() => { const list = convData?.conversations ?? []; const meId = me?.id; if (meId == null) return list; const saved = list.find((c) => c.partner.id === meId); const rest = list.filter((c) => c.partner.id !== meId); if (showArchived || !saved) return rest; return [{ ...saved, archived: false }, ...rest]; }, [convData?.conversations, showArchived, me?.id]); 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[]; total: number }>("/api/mentors?page=1&pageSize=50"), 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)) || messageAttachments(m).some((a) => !!a.name && normText(a.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 closeEdit = () => { setEditingId(null); setEditDraft(""); setEditAdds([]); setEditRemoved([]); setEditReplacements({}); }; const saveEdit = async () => { if (!active || editingId == null || editBusy) return; const current = msgs.find((m) => m.id === editingId); const original = current ? messageAttachments(current) : []; const kept = original.filter((_, index) => !editRemoved.includes(index)); const body = editDraft.trim(); if (body.length > MAX_LEN) { toast.error("پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد"); return; } if (!body && kept.length === 0 && editAdds.length === 0) { toast.error("پیام باید متن یا پیوست داشته باشد"); return; } const replacements = Object.entries(editReplacements) .filter(([index]) => !editRemoved.includes(Number(index))) .map(([index, attachment]) => ({ index: Number(index), attachment })); setEditBusy(true); try { const res = await http.put<{ message: ChatMessage }>(`/api/chats/${active}/messages/${editingId}`, { body, ...(editAdds.length ? { addAttachments: editAdds } : {}), ...(editRemoved.length ? { removeIndexes: editRemoved } : {}), ...(replacements.length ? { replacements } : {}), }); applyEdited(res.message); closeEdit(); 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(""); setEditAdds([]); setEditRemoved([]); setEditReplacements({}); 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 && !conv.muted) { 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 | ChatAttachment[] | null) => { const body = (text ?? draft).trim(); const attachments = pickedAttachments(att, attPref); if (!active || (!body && attachments.length === 0) || body.length > MAX_LEN || sending) return; setSending(true); const attachment = attachments[0]; 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, attachments } : {}), ...(quoteId ? { replyTo: quoteId } : {}), }; setDraft(""); setAttPref([]); 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, replyTo: quoteId, ...(attachment ? { attachment, attachments } : {}), }, }); 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(attachments); setReplyTo(replyTo); toast.error((err as Error).message || "ارسال ممکن نشد"); } finally { setSending(false); } }; const pickFile = () => fileRef.current?.click(); const pickPhotos = () => photoRef.current?.click(); const uploadSelected = async (files: File[]) => { const room = maxChatAttachments - attPref.length; if (room <= 0) { toast.error("در هر پیام حداکثر ۱۰ پیوست می‌توان فرستاد"); return; } const batch = files.slice(0, room); if (files.length > room) toast.error("در هر پیام حداکثر ۱۰ پیوست می‌توان فرستاد"); const accepted = batch.filter((file) => { if (file.size > 25 * 1024 * 1024) { toast.error("فایل باید کمتر از ۲۵ مگابایت باشد"); return false; } return true; }); if (!accepted.length) return; setUploading(true); setUploadPct(0); const added: ChatAttachment[] = []; try { for (let i = 0; i < accepted.length; i++) { const { attachment } = await apiFormProgress<{ attachment: ChatAttachment }>( "/api/chats/upload", toForm(accepted[i]), (ratio) => setUploadPct((i + ratio) / accepted.length), ); added.push(attachment); } setAttPref((prev) => [...prev, ...added].slice(0, maxChatAttachments)); setBroadcastConfirm(false); } catch (err) { if (added.length) setAttPref((prev) => [...prev, ...added].slice(0, maxChatAttachments)); toast.error((err as Error).message || "آپلود ممکن نشد"); } finally { setUploading(false); } }; const onFile = async (e: React.ChangeEvent) => { const files = e.target.files ? Array.from(e.target.files) : []; e.target.value = ""; if (files.length) await uploadSelected(files); }; const editAccept = "image/*,video/*,audio/*,.pdf,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.csv,.json"; const beginEditAdd = () => { if (editingId == null) return; const current = msgs.find((m) => m.id === editingId); const original = current ? messageAttachments(current) : []; const room = maxChatAttachments - (original.length - editRemoved.length + editAdds.length); if (room <= 0) { toast.error("در هر پیام حداکثر ۱۰ پیوست می‌توان فرستاد"); return; } editPick.current = { mode: "add", index: 0 }; const input = editFileRef.current; if (!input) return; input.accept = editAccept; input.multiple = true; input.click(); }; const beginEditReplace = (index: number) => { if (editingId == null) return; const current = msgs.find((m) => m.id === editingId); const att = current ? messageAttachments(current)[index] : undefined; if (!att) return; editPick.current = { mode: "replace", index }; const input = editFileRef.current; if (!input) return; input.multiple = false; input.accept = att.type === "image" ? "image/*" : att.type === "video" ? "video/*" : att.type === "audio" ? "audio/*" : editAccept; input.click(); }; const onEditPick = async (e: React.ChangeEvent) => { const files = e.target.files ? Array.from(e.target.files) : []; e.target.value = ""; const pick = editPick.current; if (!files.length || editingId == null) return; const current = msgs.find((m) => m.id === editingId); const original = current ? messageAttachments(current) : []; const room = maxChatAttachments - (original.length - editRemoved.length + editAdds.length); const batch = pick.mode === "replace" ? files.slice(0, 1) : files.slice(0, Math.max(room, 0)); if (pick.mode === "add" && files.length > batch.length) toast.error("در هر پیام حداکثر ۱۰ پیوست می‌توان فرستاد"); const accepted = batch.filter((file) => { if (file.size > 25 * 1024 * 1024) { toast.error("فایل باید کمتر از ۲۵ مگابایت باشد"); return false; } return true; }); if (!accepted.length) return; setUploading(true); setUploadPct(0); try { if (pick.mode === "replace") { const { attachment } = await apiFormProgress<{ attachment: ChatAttachment }>( "/api/chats/upload", toForm(accepted[0]), setUploadPct, ); const prev = original[pick.index]; if (!prev || attachment.type !== prev.type) { toast.error("فایل جدید باید از همان نوع باشد"); return; } setEditReplacements((cur) => ({ ...cur, [pick.index]: attachment })); setEditRemoved((cur) => cur.filter((index) => index !== pick.index)); return; } const added: ChatAttachment[] = []; for (let i = 0; i < accepted.length; i++) { const { attachment } = await apiFormProgress<{ attachment: ChatAttachment }>( "/api/chats/upload", toForm(accepted[i]), (ratio) => setUploadPct((i + ratio) / accepted.length), ); added.push(attachment); } setEditAdds((prev) => [...prev, ...added].slice(0, maxChatAttachments)); } 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 (opts?: { broadcast?: boolean }) => { const blob = recBlob; const mime = recMime; if (!blob || sending || uploading) return; if (!opts?.broadcast && !active) return; cancelVoicePreview(); setUploading(true); setUploadPct(0); try { const { attachment } = await apiFormProgress<{ attachment: ChatAttachment }>("/api/chats/upload", toForm(blob, mime), setUploadPct); if (opts?.broadcast) await sendBroadcastRef.current({ attachment, skipConfirm: true }); else 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; } }; useEffect(() => { try { localStorage.removeItem("pargar-chat-pin"); sessionStorage.removeItem("pargar-chat-unlocked"); } catch { /* storage unavailable */ } }, []); const sortedConvs = useMemo(() => { const q = query.trim().toLowerCase(); const list = [...conversations].sort((a, b) => { if ((a.partner.id === me?.id) !== (b.partner.id === me?.id)) return a.partner.id === me?.id ? -1 : 1; const ap = a.pinnedRank; const bp = b.pinnedRank; if ((ap != null) !== (bp != null)) return ap != null ? -1 : 1; if (ap != null && bp != null && ap !== bp) return ap - bp; 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.partner.id === me?.id && "پیام‌های ذخیره‌شده".includes(q)) || (c.lastMessage?.body ?? "").toLowerCase().includes(q) ); }, [conversations, query, me?.id]); const orderedPeople = useMemo(() => { const list = (loveList?.mentors ?? []).filter((person) => person.id !== me?.id); return list.slice().sort((a, b) => { const onlineDelta = Number(!!b.online) - Number(!!a.online); if (onlineDelta !== 0) return onlineDelta; return a.name.localeCompare(b.name, "fa"); }); }, [loveList?.mentors, me?.id]); const pinOrderIds = useMemo( () => sortedConvs.filter((c) => c.pinnedRank != null && c.partner.id !== me?.id).map((c) => c.partner.id), [sortedConvs, me?.id], ); const rowShift = (id: number) => { if (!pinDragView || pinDragView.phase === "commit") return 0; const from = pinOrderIds.indexOf(pinDragView.id); const index = pinOrderIds.indexOf(id); if (from < 0 || index < 0) return 0; if (id === pinDragView.id) return pinDragView.dy; if (from < pinDragView.to && index > from && index <= pinDragView.to) return -pinDragView.rowH; if (pinDragView.to < from && index >= pinDragView.to && index < from) return pinDragView.rowH; return 0; }; const persistPinOrder = async (ids: number[]) => { const byId = new Map(conversations.map((c) => [c.partner.id, c])); qc.setQueryData<{ conversations: Conversation[] }>(["conversations", showArchived, me?.id], (old) => { if (!old) return old; const rank = new Map(ids.map((id, index) => [id, index])); return { ...old, conversations: old.conversations.map((c) => (rank.has(c.partner.id) ? { ...c, pinnedRank: rank.get(c.partner.id)! } : c)), }; }); try { await Promise.all( ids.map((id, index) => { const row = byId.get(id); if (!row || row.pinnedRank === index) return Promise.resolve(); return http.put(`/api/chats/${id}/prefs`, { pinnedRank: index, muted: row.muted, archived: row.archived }); }), ); } catch (err) { toast.error(toUserError(err, "جابه‌جایی سنجاق ممکن نشد")); } finally { qc.invalidateQueries({ queryKey: ["conversations"] }); } }; const raisePin = (id: number) => { const index = pinOrderIds.indexOf(id); if (index <= 0) return; const next = [...pinOrderIds]; const previous = next[index - 1]; next[index - 1] = next[index]!; next[index] = previous!; void persistPinOrder(next); }; const onPinGesture = (row: Conversation, phase: "start" | "move" | "end" | "cancel", clientY: number) => { if (phase === "start") { if (pinLandTimer.current != null || pendingPinOrder.current) { if (pinLandTimer.current != null) { window.clearTimeout(pinLandTimer.current); pinLandTimer.current = null; } pinEpoch.current += 1; const pending = pendingPinOrder.current; pendingPinOrder.current = null; if (pending) void persistPinOrder(pending); setPinDragView(null); return; } const ids = pinOrderIds; const from = ids.indexOf(row.partner.id); if (from < 0) return; const el = document.querySelector(`[data-conv="${row.partner.id}"]`) as HTMLElement | null; const rect = el?.getBoundingClientRect(); const shellH = el?.offsetHeight || Math.round(rect?.height ?? 64) || 64; let rowH = shellH + 2; if (el && rect) { const next = el.nextElementSibling as HTMLElement | null; if (next) rowH = Math.max(shellH, next.getBoundingClientRect().top - rect.top); else { const prev = el.previousElementSibling as HTMLElement | null; if (prev) rowH = Math.max(shellH, rect.top - prev.getBoundingClientRect().top); } } const list = el?.closest(".chat-scroll") as HTMLElement | null; const listRect = list?.getBoundingClientRect(); const minDy = rect && listRect ? Math.min(0, listRect.top + 2 - rect.top) : -2000; const maxDy = rect && listRect ? Math.max(0, listRect.bottom - 2 - rect.bottom) : 2000; pinDragRef.current = { id: row.partner.id, startY: clientY, from, rowH, shellH, to: from, dy: 0, ids, minDy, maxDy }; setPinDragView({ id: row.partner.id, dy: 0, to: from, rowH, shellH, phase: "drag" }); return; } const drag = pinDragRef.current; if (!drag || drag.id !== row.partner.id) return; if (phase === "move") { const dy = Math.max(drag.minDy, Math.min(drag.maxDy, clientY - drag.startY)); const slots = Math.round(dy / drag.rowH); const to = Math.max(0, Math.min(drag.ids.length - 1, drag.from + slots)); drag.to = to; drag.dy = dy; setPinDragView({ id: drag.id, dy, to, rowH: drag.rowH, shellH: drag.shellH, phase: "drag" }); return; } pinDragRef.current = null; const shouldCommit = phase === "end" && drag.to !== drag.from; const epoch = ++pinEpoch.current; const targetDy = shouldCommit ? (drag.to - drag.from) * drag.rowH : 0; const targetTo = shouldCommit ? drag.to : drag.from; const nextOrder = (() => { if (!shouldCommit) return null; const next = [...drag.ids]; const [moved] = next.splice(drag.from, 1); if (moved == null) return null; next.splice(drag.to, 0, moved); return next; })(); const land = () => { if (pinEpoch.current !== epoch) return; pinLandTimer.current = null; pendingPinOrder.current = null; if (nextOrder) void persistPinOrder(nextOrder); setPinDragView({ id: drag.id, dy: 0, to: drag.from, rowH: drag.rowH, shellH: drag.shellH, phase: "commit" }); requestAnimationFrame(() => { if (pinEpoch.current !== epoch) return; setPinDragView((cur) => (cur?.phase === "commit" ? null : cur)); }); }; const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduce || Math.abs(drag.dy - targetDy) < 0.5) { land(); return; } pendingPinOrder.current = nextOrder; setPinDragView({ id: drag.id, dy: drag.dy, to: targetTo, rowH: drag.rowH, shellH: drag.shellH, phase: "settle" }); requestAnimationFrame(() => { if (pinEpoch.current !== epoch) return; setPinDragView((cur) => (cur && cur.phase === "settle" && cur.id === drag.id ? { ...cur, dy: targetDy } : cur)); pinLandTimer.current = window.setTimeout(land, 240); }); }; 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 broadcastNames = useMemo(() => { const people = loveList?.mentors ?? []; return broadcastIds.flatMap((id) => { const person = people.find((item) => item.id === id); return person ? [person.name] : []; }); }, [broadcastIds, loveList?.mentors]); const broadcastTargetIds = async () => { if (!broadcastAll) return broadcastIds.filter((id) => id !== me?.id); const ids: number[] = []; let page = 1; let total = 0; do { const res = await http.get<{ mentors: Mentor[]; total: number }>(`/api/mentors?page=${page}&pageSize=50`); total = res.total ?? 0; for (const person of res.mentors ?? []) { if (person.id !== me?.id) ids.push(person.id); } if ((res.mentors ?? []).length === 0 || page * 50 >= total) break; page += 1; } while (page <= 40); return ids; }; const sendBroadcast = async (opts?: { attachment?: ChatAttachment | null; attachments?: ChatAttachment[]; skipConfirm?: boolean }) => { const body = draft.trim(); const attachments = opts?.attachments ?? pickedAttachments(opts?.attachment, attPref); const attachment = attachments[0]; if (broadcastBusy) return; if (!body && attachments.length === 0) return; if (broadcastAll) { if (!broadcastConfirm && !opts?.skipConfirm) return; } else if (broadcastIds.length === 0) { return; } setBroadcastBusy(true); try { if (!attachment) { await http.post("/api/chats/broadcast", { body, all: broadcastAll, userIds: broadcastAll ? [] : broadcastIds }); } else { const ids = await broadcastTargetIds(); for (let i = 0; i < ids.length; i += 4) { const chunk = ids.slice(i, i + 4); await Promise.all( chunk.map((id) => api(`/api/chats/${id}/messages`, { method: "POST", body: { body, attachment, attachments } }), ), ); } } setBroadcastOpen(false); setBroadcastConfirm(false); setBroadcastAll(true); setBroadcastIds([]); setDraft(""); setAttPref([]); setEmojiOpen(false); cancelVoicePreview(); toast.success("ارسال شد"); qc.invalidateQueries({ queryKey: ["conversations"] }); } catch (err) { toast.error(toUserError(err, "ارسال ممکن نشد")); } finally { setBroadcastBusy(false); } }; sendBroadcastRef.current = sendBroadcast; const armBroadcast = () => { if (broadcastBusy || uploading || sending) return; const ready = !!draft.trim() || attPref.length > 0 || !!recBlob; if (!ready) return; if (!broadcastAll && broadcastIds.length === 0) return; if (broadcastAll && !broadcastConfirm) { setBroadcastConfirm(true); return; } if (recBlob) void sendVoice({ broadcast: true }); else void sendBroadcast(); }; 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]); const savedChat = !!partner && partner.id === me?.id; const disarmPeek = () => { peekArmed.current = false; }; const endPeek = () => { peekToken.current += 1; setPeek(null); }; const armPeek = (c: Conversation, rowName: string) => { const token = ++peekToken.current; const saved = c.partner.id === me?.id; const timer = window.setTimeout(() => { if (peekToken.current !== token) return; peekArmed.current = true; setPeek({ name: rowName, saved, partner: c.partner, messages: [], loading: true }); void http .get<{ messages: ChatMessage[] }>(`/api/chats/${c.partner.id}/peek`) .then((res) => { if (peekToken.current !== token) return; setPeek({ name: rowName, saved, partner: c.partner, messages: res.messages ?? [], loading: false }); }) .catch(() => { if (peekToken.current !== token) return; setPeek((cur) => (cur ? { ...cur, loading: false, messages: [] } : null)); }); }, 480); return () => window.clearTimeout(timer); }; return ( <> {open && ( <> { setOpen(false); setActive(null); }} className="fixed inset-0 z-[65] bg-background/35 backdrop-blur-[2px]" />
{broadcastOpen && !active ? ( <>

پیام همگانی

) : active && partner ? ( <> {savedChat ? (

پیام‌های ذخیره‌شده

) : ( <> )} ) : ( <>

گفتگوها

)} {!active && ( )} {active && ( )} {!active && !broadcastOpen && ( <> )}
{peek && (
{peek.saved ? ( ) : ( )}

{peek.name} {!peek.saved && isVerifiedRole(peek.partner.role) && }

{peek.loading ? : }
)}
{!active && broadcastOpen ? (
{!broadcastAll && (
{orderedPeople.map((person) => { const picked = broadcastIds.includes(person.id); return ( ); })} {orderedPeople.length === 0 && (

کسی برای انتخاب نیست.

)}
)}

{broadcastAudienceLine(broadcastAll, broadcastNames)}

{broadcastAll && broadcastConfirm && (

برای همهٔ فعال‌ها فرستاده شود؟

)}
{recording && (
{clockDur(recSec)}
)} { setAttPref((prev) => prev.filter((_, i) => i !== index)); setBroadcastConfirm(false); }} onPreview={setPreview} /> {emojiOpen && (
{EMOJIS.map((emoji) => ( ))}
)} {recUrl ? ( { cancelVoicePreview(); setBroadcastConfirm(false); }} onSend={armBroadcast} /> ) : (
{draft.trim() || attPref.length > 0 ? ( ) : ( )}
{ setDraft(value.slice(0, MAX_LEN)); setBroadcastConfirm(false); }} onSend={armBroadcast} placeholder={"\u200Fپیام خود را بنویسید…"} disabled={broadcastBusy || uploading} />
)}
) : !active ? (
setQuery(e.target.value)} placeholder="جستجو در گفتگوها…" dir="rtl" className="h-11 w-full rounded-2xl border border-white/70 bg-white/45 ps-10 pe-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/75 focus-visible:ring-2 focus-visible:ring-ring dark:border-white/10 dark:bg-white/5" />
{composeOpen && (

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

{me?.role !== "student" && ( )} {orderedPeople.map((m) => ( ))} {orderedPeople.length === 0 && (

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

)}
)} {sortedConvs.map((c) => { const rowName = c.partner.id === me?.id ? "پیام‌های ذخیره‌شده" : c.partner.name; const pins = sortedConvs.filter((x) => x.pinnedRank != null); const pinIndex = pinOrderIds.indexOf(c.partner.id); const canRaise = c.pinnedRank != null && c.partner.id !== me?.id && pinIndex > 0; return ( { setActive(c.partner.id); setComposeOpen(false); }} onRaise={canRaise ? () => raisePin(c.partner.id) : undefined} onPinGesture={ c.pinnedRank != null && c.partner.id !== me?.id ? (phase, clientY) => onPinGesture(c, phase, clientY) : undefined } onArmPeek={() => armPeek(c, rowName)} onEndPeek={endPeek} onDisarmPeek={disarmPeek} heldRef={peekArmed} /> ); })} {sortedConvs.length === 0 && conversations.length > 0 && (

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

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

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

{orderedPeople.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 files = e.dataTransfer.files ? Array.from(e.dataTransfer.files) : []; if (files.length) void uploadSelected(files); }} 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 && !savedChat && (

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

پاسخ کمتر از یک روز
)} {(view.start > 0 || canLoadMore) && (
)} {thread.map((seg, i) => seg.kind === "date" ? (
{seg.label}
) : ( <> {firstUnread && seg.msgs.some((mm) => mm.id === firstUnread) && (
پیام‌های جدید
)}
{!seg.mine && partner && !savedChat && ( )}
{seg.msgs.map((m, idx) => { const isLast = idx === seg.msgs.length - 1; const atts = messageAttachments(m); const multi = atts.length > 1; const mediaOnly = atts.length > 0 && !m.body; const isPending = m.id < 0; const isGif = !multi && mediaOnly && isGifName(atts[0]); const onlyEmoji = !m.replyTo && atts.length === 0 && !!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 && ( )} {editingId === m.id ? ( x.id === m.replyTo) ?? null : null} partnerName={partner?.name} meRole={me?.role} onlyEmoji={onlyEmoji} draft={editDraft} onDraft={setEditDraft} adds={editAdds} removed={editRemoved} replacements={editReplacements} busy={editBusy || uploading} onAdd={beginEditAdd} onReplace={beginEditReplace} onClearReplace={(index) => setEditReplacements((cur) => { const next = { ...cur }; delete next[index]; return next; }) } onToggleRemove={(index) => setEditRemoved((cur) => cur.includes(index) ? cur.filter((i) => i !== index) : [...cur, index], ) } onRemoveAdd={(index) => setEditAdds((cur) => cur.filter((_, i) => i !== index))} onCancel={closeEdit} onSave={() => void saveEdit()} onPreview={setPreview} /> ) : multi ? ( <> {m.replyTo && ( x.id === m.replyTo) ?? null} mine={seg.mine} partnerName={partner?.name} meRole={me?.role} /> )} {m.body ? (

) : null} {m.body && editingId !== m.id && ( {m.editedAt && ( ویرایش‌شده )} {clock(m.createdAt)} {seg.mine && (isPending ? ( ) : ( ))} )} ) : mediaOnly && isGif ? ( ) : mediaOnly ? ( <> ) : onlyEmoji ? ( <>

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

) : null} {m.body && editingId !== m.id && ( {m.editedAt && ( ویرایش‌شده )} {clock(m.createdAt)} {seg.mine && (isPending ? ( ) : ( ))} )} )}
{!!m.reactions?.length && (
void toggleReact(m, em)} />
)} {!!m.buttons?.length && !seg.mine && (
{m.buttons.map((b) => ( ))}
)} {editingId !== m.id && ( setReactId(reactId === m.id ? null : m.id)} onReactEmoji={(em) => { setReactId(null); void toggleReact(m, em); }} onReply={() => setReplyTo(m)} onSave={() => { void http.post("/api/me/saved", { body: m.body, attachmentUrl: m.attachment?.url ?? "", attachmentType: m.attachment?.type ?? "", attachmentName: m.attachment?.name ?? "", }).then(() => { toast.success("در پیام‌های ذخیره‌شده قرار گرفت"); qc.invalidateQueries({ queryKey: ["conversations"] }); if (me) qc.invalidateQueries({ queryKey: ["chat", me.id] }); }); }} onCopy={() => copyText(m)} onPin={() => void togglePin(m)} onEdit={ seg.mine && m.id > 0 && (atts.length > 0 || !!m.body) ? () => { setReactId(null); setEditingId(m.id); setEditDraft(m.body ?? ""); setEditAdds([]); setEditRemoved([]); setEditReplacements({}); } : undefined } editLabel="ویرایش پیام" onDelete={() => armDelete(m.id)} /> )}
); })}
) )}
{(jumpDown || (historyAnchor != null && view.end < msgs.length)) && ( { setHistoryAnchor(null); wasNearBottomRef.current = true; setJumpDown(false); requestAnimationFrame(() => { const el = threadRef.current; if (el) el.scrollTop = el.scrollHeight; }); }} 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 && !savedChat && (
{partner?.name} در حال تایپ است…
)} {emptyThread && !savedChat && (
{(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)}
)} setAttPref((prev) => prev.filter((_, i) => i !== index))} onPreview={setPreview} /> {replyTo && (
در پاسخ به {isMineMsg(replyTo, me?.role) ? "خودتان" : partner?.name ?? "پیام"} {quotePreview(replyTo)}
)}
{recUrl ? ( void sendVoice()} /> ) : ( <>
{emojiOpen && (
ایموجی
{EMOJIS.map((e) => ( ))}
)}
{draft.trim() || attPref.length > 0 ? ( ) : ( )}
{ setDraft(v); if (v.trim()) signalTyping(); }} onSend={() => send()} placeholder={"\u200Fپیام خود را بنویسید…"} disabled={sending || uploading} />
)}
)}
)} void onEditPick(e)} /> {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 ----------------------------------------------------------- const maxChatAttachments = 10; function messageAttachments(m: ChatMessage): ChatAttachment[] { if (m.attachments && m.attachments.length > 0) return m.attachments; return m.attachment ? [m.attachment] : []; } function pickedAttachments( att: ChatAttachment | ChatAttachment[] | null | undefined, pref: ChatAttachment[], ): ChatAttachment[] { if (att === undefined) return pref; if (att === null) return []; return Array.isArray(att) ? att : [att]; } function isGifName(att: ChatAttachment) { return /\.gif($|\?)/i.test(att.name || "") || /\.gif($|\?)/i.test(att.url || ""); } function ChatEditPane({ message, mine, atts, quote, partnerName, meRole, onlyEmoji, draft, onDraft, adds, removed, replacements, busy, onAdd, onReplace, onClearReplace, onToggleRemove, onRemoveAdd, onCancel, onSave, onPreview, }: { message: ChatMessage; mine: boolean; atts: ChatAttachment[]; quote: ChatMessage | null; partnerName?: string; meRole?: string; onlyEmoji: boolean; draft: string; onDraft: (value: string) => void; adds: ChatAttachment[]; removed: number[]; replacements: Record; busy: boolean; onAdd: () => void; onReplace: (index: number) => void; onClearReplace: (index: number) => void; onToggleRemove: (index: number) => void; onRemoveAdd: (index: number) => void; onCancel: () => void; onSave: () => void; onPreview: (att: ChatAttachment) => void; }) { return ( <> {message.replyTo && ( )} {onlyEmoji && } {atts.length > 0 && ( )}
{atts.map((att, index) => { const gone = removed.includes(index); const next = replacements[index]; return (
{att.name} {gone ? ( ) : ( <> {next && ( )} )}
); })} {adds.map((att, index) => (
افزوده: {att.name}
))}