"use client";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { useRouter } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowDown,
ArrowRight,
Check,
CheckCheck,
ChevronDown,
ChevronRight,
ChevronUp,
Clock,
CornerDownLeft,
Copy,
Download,
ExternalLink,
FileText,
Film,
Loader2,
Maximize,
MessageSquare,
Mic,
Paperclip,
Pause,
Pencil,
Pin,
PinOff,
Play,
Search,
Smile,
SmilePlus,
Sparkles,
Square,
Trash2,
Volume2,
VolumeX,
X,
} from "lucide-react";
import { api, http, getWsUrl, toUserError } from "@/lib/api";
import { apiFormProgress, saveRemoteFile } from "@/lib/transfer";
import { mediaObjectKey } from "@/lib/media";
import { useCachedObjectUrl } from "@/hooks/use-cached-media";
import type { ChatAttachment, ChatMessage, ChatReaction, Conversation, Mentor } from "@/lib/types";
import { useAuth } from "@/lib/auth-store";
import { cn } from "@/lib/utils";
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ThemeToggle } from "@/components/theme-toggle";
import { toast } from "@/components/providers";
import { AppleEmoji, AppleEmojiText } from "@/components/apple-emoji";
import { softPop, softSpring, softTween } from "@/lib/motion";
const MAX_LEN = 4000;
const LAVA_STYLES: React.CSSProperties[] = [
{
background:
"radial-gradient(circle at 40% 35%, color-mix(in srgb, var(--color-primary) 42%, transparent), transparent 70%)",
},
{
background:
"radial-gradient(circle at 55% 45%, color-mix(in srgb, var(--color-secondary) 38%, transparent), transparent 72%)",
},
{
background:
"radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--color-accent) 30%, transparent), transparent 68%)",
},
];
function LavaLamp() {
const orbs = [
{
cls: "-start-14 -top-14 h-60 w-60",
x: [0, 110, 40, 140, 0],
y: [0, 70, 150, 45, 0],
s: [1, 1.3, 1.05, 1.35, 1],
dur: 26,
},
{
cls: "-end-16 top-1/4 h-52 w-52",
x: [0, -90, -30, -120, 0],
y: [0, -55, -130, -35, 0],
s: [1, 1.2, 1.1, 1.28, 1],
dur: 22,
},
{
cls: "bottom-2 start-1/4 h-40 w-40",
x: [0, 60, -40, 95, 0],
y: [0, -70, -40, -110, 0],
s: [1, 1.25, 0.95, 1.3, 1],
dur: 30,
},
];
return (
{orbs.map((o, i) => (
))}
);
}
export function ChatWidget() {
const me = useAuth((s) => s.user);
const qc = useQueryClient();
const router = useRouter();
const [open, setOpen] = useState(false);
const [active, setActive] = useState(null);
const [composeOpen, setComposeOpen] = useState(false);
const [draft, setDraft] = useState("");
const [query, setQuery] = useState("");
const [sending, setSending] = useState(false);
const [pending, setPending] = useState([]);
const [partnerTyping, setPartnerTyping] = useState(false);
const [attPref, setAttPref] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadPct, setUploadPct] = useState(0);
const [notif, setNotif] = useState<{ id: string; partner: number; name: string; body: string } | null>(null);
const [preview, setPreview] = useState(null);
const [replyTo, setReplyTo] = useState(null);
const [emojiOpen, setEmojiOpen] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [jumpDown, setJumpDown] = useState(false);
const [older, setOlder] = useState([]);
const [canLoadMore, setCanLoadMore] = useState(false);
const [historyAnchor, setHistoryAnchor] = useState(null);
const [loadingOlder, setLoadingOlder] = useState(false);
const [delArm, setDelArm] = useState(null);
const [reactId, setReactId] = useState(null);
const [heartMsg, setHeartMsg] = useState(null);
const [hearts, setHearts] = useState>({});
const [msgSearchOpen, setMsgSearchOpen] = useState(false);
const [msgQuery, setMsgQuery] = useState("");
const [matchIdx, setMatchIdx] = useState(0);
const [pinIdx, setPinIdx] = useState(0);
const [flashId, setFlashId] = useState(null);
const [editingId, setEditingId] = useState(null);
const [editDraft, setEditDraft] = useState("");
const [editBusy, setEditBusy] = useState(false);
const taRef = useRef(null);
const flashTimer = useRef | null>(null);
const fileRef = useRef(null);
const replaceRef = useRef(null);
const replaceTarget = useRef(null);
const wsRef = useRef(null);
const typingSentAt = useRef(0);
const typingClearTimer = useRef | null>(null);
const notifTimer = useRef | null>(null);
const delArmTimer = useRef | null>(null);
const heartTimer = useRef | null>(null);
const threadRef = useRef(null);
const wasNearBottomRef = useRef(true);
const suppressAutoScrollRef = useRef(false);
const jumpTargetRef = useRef(null);
const pendingDraftRef = useRef(null);
const activeRef = useRef(active);
activeRef.current = active;
const convRef = useRef([]);
const convQuery = useQuery({
queryKey: ["conversations"],
queryFn: () => http.get<{ conversations: Conversation[] }>("/api/chats/conversations"),
refetchInterval: 20_000,
});
const convData = convQuery.data;
const convLoading = convQuery.isPending;
const conversations = convData?.conversations ?? [];
convRef.current = conversations;
const unreadTotal = useMemo(
() => conversations.reduce((n, c) => n + (c.unreadCount > 0 ? c.unreadCount : 0), 0),
[conversations]
);
const { data: loveList } = useQuery({
queryKey: ["mentors"],
queryFn: () => http.get<{ mentors: Mentor[] }>("/api/mentors"),
enabled: !active && open,
});
const messagesQuery = useQuery({
queryKey: ["chat", active],
queryFn: () =>
http.get<{ messages: ChatMessage[]; hasMore: boolean; pinned?: ChatMessage[] }>(
`/api/chats/${active}/messages`,
),
enabled: !!active,
refetchInterval: active ? 5000 : false,
});
const messages = messagesQuery.data;
const messagesLoading = !!active && messagesQuery.isPending;
const pinned = messages?.pinned ?? [];
const pinnedIds = useMemo(() => new Set(pinned.map((p) => p.id)), [pinned]);
const activePin = pinned.length ? pinned[Math.min(pinIdx, pinned.length - 1)] ?? null : null;
useEffect(() => {
setPinIdx(0);
}, [active]);
useEffect(() => {
if (pinned.length === 0) {
setPinIdx(0);
return;
}
setPinIdx((i) => (i >= pinned.length ? 0 : i));
}, [pinned.length]);
useEffect(() => {
if (messages && typeof messages.hasMore === "boolean") setCanLoadMore(messages.hasMore);
}, [messages?.hasMore, active]);
const msgs = useMemo(() => {
const all = [...older, ...(messages?.messages ?? []), ...pending];
if (!all.length) return all;
const byId = new Map();
for (const m of all) byId.set(m.id, m);
return [...byId.values()].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
}, [messages, older, pending]);
const matchIds = useMemo(() => {
const q0 = normText(msgQuery).trim();
if (!q0) return [] as number[];
return msgs
.filter(
(m) =>
!!m.body && normText(m.body).includes(q0) || (!!m.attachment?.name && normText(m.attachment.name).includes(q0))
)
.map((m) => m.id);
}, [msgs, msgQuery]);
const matchCount = matchIds.length;
const matchSet = useMemo(() => new Set(matchIds), [matchIds]);
useEffect(() => {
setMatchIdx(0);
}, [msgQuery]);
useEffect(() => {
if (!msgSearchOpen || !matchCount) return;
const id = matchIds[matchIdx];
if (id == null) return;
setHistoryAnchor(id);
}, [matchIdx, msgSearchOpen, matchCount, matchIds]);
const loadOlder = async () => {
const before = older[0]?.id ?? messages?.messages?.[0]?.id;
if (!active || !before || loadingOlder) return;
setLoadingOlder(true);
try {
const res = await http.get<{ messages: ChatMessage[]; hasMore: boolean }>(
`/api/chats/${active}/messages?before=${before}`,
);
setOlder((prev) => {
const seen = new Set(prev.map((m) => m.id));
const add = res.messages.filter((m) => !seen.has(m.id));
return [...add, ...prev];
});
setCanLoadMore(res.hasMore);
} catch {
/* ignore */
} finally {
setLoadingOlder(false);
}
};
const delMsg = async (id: number) => {
setDelArm(null);
if (delArmTimer.current) clearTimeout(delArmTimer.current);
if (!active) return;
try {
await http.del<{ ok: boolean }>(`/api/chats/${active}/messages/${id}`);
setOlder((prev) => prev.filter((m) => m.id !== id));
setPending((prev) => prev.filter((m) => m.id !== id));
qc.invalidateQueries({ queryKey: ["chat", active] });
qc.invalidateQueries({ queryKey: ["chat"] });
qc.invalidateQueries({ queryKey: ["conversations"] });
} catch {
/* ignore */
}
};
const saveEdit = async () => {
if (!active || editingId == null || editBusy) return;
const body = editDraft.trim();
if (!body || body.length > MAX_LEN) {
toast.error("پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد");
return;
}
setEditBusy(true);
try {
const res = await http.put<{ message: ChatMessage }>(`/api/chats/${active}/messages/${editingId}`, { body });
applyEdited(res.message);
setEditingId(null);
setEditDraft("");
qc.invalidateQueries({ queryKey: ["conversations"] });
} catch (err) {
toast.error(toUserError(err, "ویرایش ممکن نشد"));
} finally {
setEditBusy(false);
}
};
const armDelete = (id: number) => {
if (delArm === id) {
void delMsg(id);
return;
}
setDelArm(id);
if (delArmTimer.current) clearTimeout(delArmTimer.current);
delArmTimer.current = setTimeout(() => setDelArm(null), 3000);
};
const togglePin = async (m: ChatMessage) => {
if (!active || m.id <= 0) return;
try {
await api<{ ok: boolean; message: ChatMessage }>(`/api/chats/${active}/messages/${m.id}/pin`, {
method: "POST",
});
qc.invalidateQueries({ queryKey: ["chat", active] });
} catch (e) {
toast.error((e as Error)?.message || "پین پیام ممکن نشد");
}
};
const flashMessage = (id: number) => {
const thread = threadRef.current;
const el = thread?.querySelector(`[data-mid="${id}"]`) as HTMLElement | null;
if (!thread || !el) return false;
const elRect = el.getBoundingClientRect();
const threadRect = thread.getBoundingClientRect();
const nextTop =
thread.scrollTop + (elRect.top - threadRect.top) - thread.clientHeight / 2 + el.clientHeight / 2;
thread.scrollTo({ top: Math.max(0, nextTop), behavior: "smooth" });
wasNearBottomRef.current = false;
setJumpDown(true);
setFlashId(id);
if (flashTimer.current) clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => {
setFlashId((cur) => (cur === id ? null : cur));
}, 1500);
return true;
};
const clearJumpTarget = () => {
jumpTargetRef.current = null;
window.setTimeout(() => {
suppressAutoScrollRef.current = false;
}, 500);
};
const jumpToMessage = async (id: number) => {
if (!active) return;
suppressAutoScrollRef.current = true;
jumpTargetRef.current = id;
if (flashMessage(id)) {
clearJumpTarget();
return;
}
if (msgs.some((m) => m.id === id)) return;
setLoadingOlder(true);
try {
const res = await http.get<{
messages: ChatMessage[];
hasMore?: boolean;
hasMoreBefore?: boolean;
pinned?: ChatMessage[];
}>(`/api/chats/${active}/messages?around=${id}`);
if (!res.messages?.length) {
clearJumpTarget();
toast.error("پیام پیدا نشد");
return;
}
setOlder((prev) => {
const byId = new Map();
for (const m of [...res.messages, ...prev]) byId.set(m.id, m);
return [...byId.values()].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
});
if (typeof res.hasMoreBefore === "boolean") setCanLoadMore(res.hasMoreBefore);
else if (typeof res.hasMore === "boolean") setCanLoadMore(res.hasMore);
qc.setQueryData(["chat", active], (old: typeof messages) => {
if (!old) return old;
return { ...old, pinned: res.pinned ?? old.pinned };
});
// Paint + jump handled by effect watching msgs / jumpTargetRef.
} catch {
clearJumpTarget();
toast.error("بارگذاری پیام ممکن نشد");
} finally {
setLoadingOlder(false);
}
};
const toggleReact = async (m: ChatMessage, emoji: string) => {
if (!active || m.id <= 0) return;
try {
await api<{ ok: boolean }>(`/api/chats/${active}/messages/${m.id}/reaction`, { method: "POST", body: { emoji } });
qc.invalidateQueries({ queryKey: ["chat"] });
} catch {
toast.error("ثبت واکنش ممکن نشد");
}
};
const like = (m: ChatMessage) => {
if (m.id <= 0) return;
setHearts((h) => ({ ...h, [m.id]: (h[m.id] ?? 0) + 1 }));
setHeartMsg(m.id);
if (heartTimer.current) clearTimeout(heartTimer.current);
heartTimer.current = setTimeout(() => setHeartMsg(null), 720);
void toggleReact(m, "❤️");
};
useEffect(() => {
if (!active) return;
api<{ ok: boolean }>(`/api/chats/${active}/read`, { method: "POST" })
.then(() => qc.invalidateQueries({ queryKey: ["conversations"] }))
.catch(() => undefined);
}, [active, qc]);
useEffect(() => {
if (active && threadRef.current) {
threadRef.current.scrollTop = threadRef.current.scrollHeight;
}
wasNearBottomRef.current = true;
suppressAutoScrollRef.current = false;
jumpTargetRef.current = null;
}, [active]);
useEffect(() => {
const el = threadRef.current;
if (!el) return;
if (suppressAutoScrollRef.current) return;
if (wasNearBottomRef.current || pending.length) {
el.scrollTop = el.scrollHeight;
}
}, [msgs, pending]);
useEffect(() => {
const id = jumpTargetRef.current;
if (id == null) return;
if (!msgs.some((m) => m.id === id)) return;
let cancelled = false;
const tryFlash = (attempt: number) => {
if (cancelled) return;
if (flashMessage(id)) {
clearJumpTarget();
return;
}
if (attempt < 8) {
window.setTimeout(() => tryFlash(attempt + 1), 40);
} else {
clearJumpTarget();
toast.error("پیام پیدا نشد");
}
};
requestAnimationFrame(() => tryFlash(0));
return () => {
cancelled = true;
};
}, [msgs]);
useEffect(() => {
if (!active || !me) return;
if (draft) localStorage.setItem(`mt.draft.${me.id}.${active}`, draft);
else localStorage.removeItem(`mt.draft.${me.id}.${active}`);
}, [draft, active, me]);
useEffect(() => {
setPartnerTyping(false);
return () => {
if (typingClearTimer.current) clearTimeout(typingClearTimer.current);
};
}, [active]);
useEffect(() => {
setReplyTo(null);
setEmojiOpen(false);
setOlder([]);
setCanLoadMore(false);
setHistoryAnchor(null);
setDelArm(null);
if (delArmTimer.current) clearTimeout(delArmTimer.current);
setReactId(null);
setHeartMsg(null);
setHearts({});
if (heartTimer.current) clearTimeout(heartTimer.current);
setFlashId(null);
if (flashTimer.current) clearTimeout(flashTimer.current);
setMsgSearchOpen(false);
setMsgQuery("");
setMatchIdx(0);
setEditingId(null);
setEditDraft("");
if (pendingDraftRef.current != null) {
const d = pendingDraftRef.current;
pendingDraftRef.current = null;
setDraft(d);
} else {
setDraft(active ? localStorage.getItem(`mt.draft.${me?.id}.${active}`) ?? "" : "");
}
}, [active, me?.id]);
useEffect(() => {
if (open && active) {
const t = setTimeout(() => taRef.current?.focus(), 140);
return () => clearTimeout(t);
}
}, [open, active]);
useEffect(() => {
if (!open) setNotif(null);
}, [open]);
useEffect(() => {
const onOpen = (ev: Event) => {
const detail = (ev as CustomEvent<{ open?: boolean; with?: number; draft?: string }>).detail;
if (typeof detail?.draft === "string") {
pendingDraftRef.current = detail.draft;
}
if (detail?.with) {
setActive(detail.with);
} else if (typeof detail?.draft === "string") {
setDraft(detail.draft);
pendingDraftRef.current = null;
}
setOpen(detail?.open ?? true);
};
window.addEventListener("pargar:chat", onOpen);
return () => window.removeEventListener("pargar:chat", onOpen);
}, []);
useEffect(() => {
const { accessToken: token, user } = useAuth.getState();
let cancelled = false;
if (!user) return;
// Prefer bearer subprotocol; fall back to httpOnly cookie on same-origin.
const ws = token ? new WebSocket(getWsUrl(), ["bearer", token]) : new WebSocket(getWsUrl());
wsRef.current = ws;
ws.onmessage = (ev) => {
try {
const p = JSON.parse(ev.data) as { type?: string; from?: number; item?: ChatMessage };
if (p.type === "chat_message") {
qc.invalidateQueries({ queryKey: ["conversations"] });
if (p.from && activeRef.current === p.from) {
qc.invalidateQueries({ queryKey: ["chat"] });
api<{ ok: boolean }>(`/api/chats/${p.from}/read`, { method: "POST" }).catch(() => undefined);
}
const mine = p.from === useAuth.getState().user?.id;
if (!mine && !cancelled) {
const activeThread = !!activeRef.current && activeRef.current === p.from;
if (!activeThread) {
const conv = convRef.current.find((c) => c.partner.id === p.from);
if (conv) {
setNotif({
id: `${Date.now()}-${p.from}`,
partner: p.from ?? 0,
name: conv.partner.name,
body: p.item ? previewOf(p.item) : "پیام جدید",
});
if (notifTimer.current) clearTimeout(notifTimer.current);
notifTimer.current = setTimeout(() => setNotif(null), 5000);
}
}
}
} else if (p.type === "chat_read") {
if (p.from && activeRef.current === p.from) qc.invalidateQueries({ queryKey: ["chat", p.from] });
qc.invalidateQueries({ queryKey: ["conversations"] });
} else if (p.type === "typing") {
if (p.from && activeRef.current === p.from) {
setPartnerTyping(true);
if (typingClearTimer.current) clearTimeout(typingClearTimer.current);
typingClearTimer.current = setTimeout(() => setPartnerTyping(false), 4500);
}
} else if (p.type === "chat_reaction") {
if (p.from && activeRef.current === p.from) qc.invalidateQueries({ queryKey: ["chat"] });
} else if (p.type === "chat_edit") {
const item = p.item;
if (item && p.from && activeRef.current === p.from) {
qc.setQueryData<{ messages: ChatMessage[]; hasMore: boolean; pinned?: ChatMessage[] }>(
["chat", activeRef.current],
(old) => {
if (!old) return old;
return {
...old,
messages: old.messages.map((m) => (m.id === item.id ? item : m)),
pinned: old.pinned?.map((m) => (m.id === item.id ? item : m)),
};
},
);
setOlder((prev) => prev.map((m) => (m.id === item.id ? item : m)));
}
qc.invalidateQueries({ queryKey: ["conversations"] });
} else if (p.type === "chat_delete") {
const deletedId = (p as { id?: number }).id;
if (deletedId) {
setOlder((prev) => prev.filter((m) => m.id !== deletedId));
setPending((prev) => prev.filter((m) => m.id !== deletedId));
}
qc.invalidateQueries({ queryKey: ["conversations"] });
qc.invalidateQueries({ queryKey: ["chat"] });
} else if (p.type === "chat_pin") {
qc.invalidateQueries({ queryKey: ["chat"] });
}
} catch {
/* ignore */
}
};
return () => {
cancelled = true;
wsRef.current = null;
ws.close();
};
}, [qc]);
const signalTyping = () => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN || !active) return;
const now = Date.now();
if (now - typingSentAt.current < 2400) return;
typingSentAt.current = now;
ws.send(JSON.stringify({ type: "typing", to: active }));
};
const partner =
conversations.find((c) => c.partner.id === active)?.partner ??
(loveList?.mentors ?? []).find((m) => m.id === active);
const send = async (text?: string, att?: ChatAttachment | null) => {
const body = (text ?? draft).trim();
if (!active || (!body && !att && !attPref) || body.length > MAX_LEN || sending) return;
setSending(true);
const attachment = att ?? attPref ?? undefined;
const quoteId = replyTo?.id;
const uid = useAuth.getState().user;
const temp: ChatMessage = {
id: -Date.now(),
userId: uid?.role === "mentor" ? active : (uid?.id ?? 0),
mentorId: uid?.role === "mentor" ? (uid?.id ?? 0) : active,
senderRole: (uid?.role ?? "student") as ChatMessage["senderRole"],
body,
createdAt: new Date().toISOString(),
...(attachment ? { attachment } : {}),
...(quoteId ? { replyTo: quoteId } : {}),
};
setDraft("");
setAttPref(null);
setReplyTo(null);
setHistoryAnchor(null);
wasNearBottomRef.current = true;
setPending((p) => [...p, temp]);
try {
const res = await api<{ message: ChatMessage }>(`/api/chats/${active}/messages`, {
method: "POST",
body: { body, attachment, replyTo: quoteId },
});
qc.setQueryData<{ messages: ChatMessage[]; hasMore: boolean }>(["chat", active], (old) => {
const list = old?.messages ?? [];
if (!list.some((x) => x.id === res.message.id)) {
return { ...old, messages: [...list, res.message] } as typeof old;
}
return old;
});
setPending([]);
qc.invalidateQueries({ queryKey: ["conversations"] });
} catch (err) {
setPending([]);
setDraft(body);
setAttPref(attachment ?? null);
setReplyTo(replyTo);
toast.error((err as Error).message || "ارسال ممکن نشد");
} finally {
setSending(false);
}
};
const pickFile = () => fileRef.current?.click();
const uploadSelected = async (file: File) => {
if (file.size > 25 * 1024 * 1024) {
toast.error("فایل باید کمتر از ۲۵ مگابایت باشد");
return;
}
setUploading(true);
setUploadPct(0);
try {
const { attachment } = await apiFormProgress<{ attachment: ChatAttachment }>("/api/chats/upload", toForm(file), setUploadPct);
setAttPref(attachment);
} catch (err) {
toast.error((err as Error).message || "آپلود ممکن نشد");
} finally {
setUploading(false);
}
};
const onFile = async (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) await uploadSelected(file);
};
const beginReplace = (m: ChatMessage) => {
const kind = m.attachment?.type;
if (kind !== "image" && kind !== "video" && kind !== "audio") return;
replaceTarget.current = m;
const input = replaceRef.current;
if (!input) return;
input.accept = kind === "image" ? "image/*" : kind === "video" ? "video/*" : "audio/*";
input.click();
};
const onReplaceFile = async (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
e.target.value = "";
const target = replaceTarget.current;
replaceTarget.current = null;
if (!file || !target || !active) return;
if (file.size > 25 * 1024 * 1024) {
toast.error("فایل باید کمتر از ۲۵ مگابایت باشد");
return;
}
setUploading(true);
setUploadPct(0);
try {
const { attachment } = await apiFormProgress<{ attachment: ChatAttachment }>("/api/chats/upload", toForm(file), setUploadPct);
if (attachment.type !== target.attachment?.type) {
toast.error("فایل جدید باید از همان نوع باشد");
return;
}
const res = await http.put<{ message: ChatMessage }>(`/api/chats/${active}/messages/${target.id}`, {
body: target.body ?? "",
attachment,
});
applyEdited(res.message);
qc.invalidateQueries({ queryKey: ["conversations"] });
} catch (err) {
toast.error(toUserError(err, "جایگذاری ممکن نشد"));
} finally {
setUploading(false);
}
};
const voiceRef = useRef<{
recorder: MediaRecorder | null;
stream: MediaStream | null;
chunks: Blob[];
timer: ReturnType;
start: number;
live?: () => void;
} | null>(null);
const [recSec, setRecSec] = useState(0);
const [recLevel, setRecLevel] = useState(0);
const [recUrl, setRecUrl] = useState(null);
const [recBlob, setRecBlob] = useState(null);
const [recMime, setRecMime] = useState(undefined);
const [recDur, setRecDur] = useState(0);
useEffect(() => {
return () => {
const v = voiceRef.current;
if (v?.recorder && v.recorder.state !== "inactive") v.recorder.stop();
v?.stream?.getTracks().forEach((t) => t.stop());
if (v?.timer) clearInterval(v.timer);
};
}, []);
useEffect(() => {
if (!preview) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setPreview(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [preview]);
const cancelVoicePreview = () => {
if (recUrl) URL.revokeObjectURL(recUrl);
setRecUrl(null);
setRecBlob(null);
setRecMime(undefined);
setRecDur(0);
};
const sendVoice = async () => {
const blob = recBlob;
const mime = recMime;
if (!blob || !active || sending || uploading) return;
cancelVoicePreview();
setUploading(true);
setUploadPct(0);
try {
const { attachment } = await apiFormProgress<{ attachment: ChatAttachment }>("/api/chats/upload", toForm(blob, mime), setUploadPct);
await send("", attachment);
} catch (err) {
toast.error((err as Error).message || "ارسال صوتی ممکن نشد");
} finally {
setUploading(false);
}
};
const toggleMic = async () => {
const v = voiceRef.current;
if (v?.recorder && v.recorder.state === "recording") {
v.recorder.stop();
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mime = pickMime();
const recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
const chunks: Blob[] = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
recorder.onstop = async () => {
const blob = new Blob(chunks, { type: mime ?? "audio/webm" });
const secs = Math.round((Date.now() - (voiceRef.current?.start ?? Date.now())) / 1000);
clearInterval(voiceRef.current?.timer);
voiceRef.current?.live?.();
stream.getTracks().forEach((t) => t.stop());
setRecSec(0);
setRecLevel(0);
voiceRef.current = null;
if (secs < 1 || blob.size === 0) {
toast.error("پیام صوتی خیلی کوتاه بود");
return;
}
setRecUrl(URL.createObjectURL(blob));
setRecBlob(blob);
setRecMime(mime);
setRecDur(secs);
};
recorder.start(250);
let liveStop: (() => void) | undefined;
try {
const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (Ctx) {
const ctx = new Ctx();
const src = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.6;
src.connect(analyser);
const data = new Uint8Array(analyser.frequencyBinCount);
const liveTick = window.setInterval(() => {
analyser.getByteTimeDomainData(data);
let max = 0;
for (let i = 0; i < data.length; i++) {
const v = Math.abs(data[i] - 128) / 128;
if (v > max) max = v;
}
setRecLevel(Math.max(0.05, Math.min(1, max * 1.8)));
}, 60);
liveStop = () => {
clearInterval(liveTick);
void ctx.close();
};
}
} catch {
/* mic level meter optional */
}
voiceRef.current = {
recorder,
stream,
chunks,
timer: setInterval(() => setRecSec((s) => s + 1), 1000),
start: Date.now(),
live: liveStop,
};
} catch {
toast.error("دسترسی به میکروفون ممکن نشد");
}
};
const cancelVoice = () => {
const v = voiceRef.current;
if (v?.stream) {
v.live?.();
v.stream.getTracks().forEach((t) => t.stop());
clearInterval(v.timer);
setRecSec(0);
setRecLevel(0);
voiceRef.current = null;
}
};
const sortedConvs = useMemo(() => {
const q = query.trim().toLowerCase();
const list = [...conversations].sort((a, b) => {
const ta = a.lastMessage ? new Date(a.lastMessage.createdAt).getTime() : 0;
const tb = b.lastMessage ? new Date(b.lastMessage.createdAt).getTime() : 0;
return tb - ta;
});
if (!q) return list;
return list.filter(
(c) => c.partner.name.toLowerCase().includes(q) || (c.lastMessage?.body ?? "").toLowerCase().includes(q)
);
}, [conversations, query]);
const CHAT_PAGE = 40;
const view = useMemo(() => {
if (!msgs.length) return { items: msgs, start: 0, end: 0 };
let end = msgs.length;
if (historyAnchor != null) {
const idx = msgs.findIndex((m) => m.id === historyAnchor);
if (idx >= 0) end = idx + 1;
}
const start = Math.max(0, end - CHAT_PAGE);
return { items: msgs.slice(start, end), start, end };
}, [msgs, historyAnchor]);
const thread = useMemo(() => buildThread(view.items, me?.role), [view.items, me?.role]);
useEffect(() => {
if (!msgSearchOpen || !matchCount) return;
const id = matchIds[matchIdx];
if (id == null) return;
const el = threadRef.current?.querySelector(`[data-mid="${id}"]`);
if (el) requestAnimationFrame(() => el.scrollIntoView({ block: "center", behavior: "smooth" }));
}, [matchIdx, msgSearchOpen, matchCount, matchIds, view.items]);
const showEarlier = async () => {
const first = view.items[0];
if (!first || loadingOlder) return;
wasNearBottomRef.current = false;
suppressAutoScrollRef.current = true;
if (view.start > 0) {
setHistoryAnchor(first.id);
requestAnimationFrame(() => {
threadRef.current?.querySelector(`[data-mid="${first.id}"]`)?.scrollIntoView({ block: "start" });
suppressAutoScrollRef.current = false;
});
return;
}
if (!canLoadMore) {
suppressAutoScrollRef.current = false;
return;
}
await loadOlder();
setHistoryAnchor(first.id);
requestAnimationFrame(() => {
threadRef.current?.querySelector(`[data-mid="${first.id}"]`)?.scrollIntoView({ block: "end" });
suppressAutoScrollRef.current = false;
});
};
const emptyThread = !messagesLoading && !messagesQuery.isError && msgs.length === 0;
const openProfile = (id: number) => {
setOpen(false);
router.push(`/profile/${id}`);
};
const applyEdited = (item: ChatMessage) => {
qc.setQueryData<{ messages: ChatMessage[]; hasMore: boolean; pinned?: ChatMessage[] }>(
["chat", activeRef.current],
(old) => {
if (!old) return old;
return {
...old,
messages: old.messages.map((m) => (m.id === item.id ? item : m)),
pinned: old.pinned?.map((m) => (m.id === item.id ? item : m)),
};
},
);
setOlder((prev) => prev.map((m) => (m.id === item.id ? item : m)));
};
const atLimit = draft.length >= MAX_LEN;
const recording = !!voiceRef.current?.recorder && voiceRef.current.recorder.state === "recording";
const firstUnread = useMemo(() => {
if (!me) return null;
for (const m of msgs) {
if (!isMineMsg(m, me.role) && !m.readAt) return m.id;
}
return null;
}, [msgs, me]);
return (
<>
{open && (
<>
{
setOpen(false);
setActive(null);
}}
className="fixed inset-0 z-[65] bg-background/35 backdrop-blur-[2px]"
/>
{active && partner ? (
<>
>
) : (
<>
>
)}
{active && (
)}
{!active && (
)}
{!active ? (
{composeOpen && (
شروع گفتگوی جدید
{(loveList?.mentors ?? []).map((m) => (
))}
{(loveList?.mentors ?? []).length === 0 && (
مخاطب مجازی برای گفتگو وجود ندارد.
)}
)}
{sortedConvs.map((c) => (
))}
{sortedConvs.length === 0 && conversations.length > 0 && (
گفتگویی یافت نشد.
)}
{convLoading && conversations.length === 0 && (
{[0, 1, 2].map((i) => (
))}
)}
{conversations.length === 0 && !composeOpen && !convLoading && (
برای شروع، روی مداد بزنید
{(loveList?.mentors ?? []).map((m) => (
))}
)}
) : (
{msgSearchOpen && (
setMsgQuery(e.target.value)}
onKeyDown={(e) => {
if (!matchCount) return;
if (e.key === "Escape") { e.preventDefault(); setMsgSearchOpen(false); setMsgQuery(""); setMatchIdx(0); }
else if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); setMatchIdx((i) => (i + 1) % matchCount); }
else if (e.key === "Enter" && e.shiftKey) { e.preventDefault(); setMatchIdx((i) => (i - 1 + matchCount) % matchCount); }
}}
placeholder="جستجو در این گفتگو…"
dir="rtl"
autoFocus
className="h-8 min-w-0 flex-1 rounded-lg border-2 border-border bg-background/70 px-2.5 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
{msgQuery.trim() !== "" && (
{matchCount ? `${fa(matchIdx + 1)}/${fa(matchCount)}` : "پیدا نشد"}
)}
)}
{activePin && (
{pinned.length > 1 && (
)}
)}
{
const el = threadRef.current;
if (!el) return;
const near = el.scrollHeight - el.scrollTop - el.clientHeight < 140;
wasNearBottomRef.current = near;
setJumpDown(!near);
}}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) void uploadSelected(f);
}}
dir="ltr"
className="relative z-10 h-full min-h-0 space-y-1 overflow-y-auto overscroll-contain p-3 chat-scroll"
>
{dragOver && (
)}
{messagesLoading &&
}
{messagesQuery.isError && !messagesLoading && (
بارگذاری پیامها ممکن نشد
)}
{emptyThread && (
سلام کنید — کمک بگیرید، مشکلات را بگویید یا موفقیت را جشن بگیرید.
پاسخ کمتر از یک روز
)}
{(view.start > 0 || canLoadMore) && (
)}
{thread.map((seg, i) =>
seg.kind === "date" ? (
{seg.label}
) : (
<>
{firstUnread && seg.msgs.some((mm) => mm.id === firstUnread) && (
)}
{!seg.mine && partner && (
)}
{seg.msgs.map((m, idx) => {
const isLast = idx === seg.msgs.length - 1;
const mediaOnly = !!m.attachment && !m.body;
const isPending = m.id < 0;
const isGif = mediaOnly && isGifName(m.attachment!);
const onlyEmoji =
!m.replyTo && !m.attachment && !!m.body && isSingleEmoji(m.body);
const q0 = msgQuery.trim();
const dimmed = q0 !== "" && matchCount > 0 && !matchSet.has(m.id);
const isCurrent = matchSet.has(m.id) && m.id === matchIds[matchIdx];
return (
like(m)}
onClick={(e) => {
const t = e.target as HTMLElement;
if (t.closest("button, a, input, [role='button']")) return;
setReactId((cur) => (cur === m.id ? null : m.id));
}}
className={cn(
"relative isolate group/msg max-w-full min-w-0",
!isLast && "mb-1",
dimmed && "opacity-30 saturate-50",
isCurrent && "ring-2 ring-accent rounded-xl",
)}
>
{heartMsg === m.id && reactId !== m.id && (
)}
{mediaOnly && isGif ? (
) : mediaOnly ? (
<>
>
) : onlyEmoji ? (
<>
>
) : (
<>
{m.replyTo && (
x.id === m.replyTo) ?? null}
mine={seg.mine}
partnerName={partner?.name}
meRole={me?.role}
/>
)}
{m.attachment && (
)}
{editingId === m.id ? (
) : m.body ? (
) : null}
{m.body && editingId !== m.id && (
{m.editedAt && (
ویرایششده
)}
{clock(m.createdAt)}
{seg.mine &&
(isPending ? (
) : (
))}
)}
>
)}
{!!m.reactions?.length && (
void toggleReact(m, em)}
/>
)}
{editingId !== m.id && (
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)}
onEdit={
seg.mine && m.id > 0 && (canReplaceMedia(m) || !!m.body)
? () => {
setReactId(null);
if (canReplaceMedia(m)) {
beginReplace(m);
return;
}
setEditingId(m.id);
setEditDraft(m.body);
}
: undefined
}
editLabel={canReplaceMedia(m) ? "جایگذاری" : "ویرایش پیام"}
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 && (
{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 ? (
<>
در حال آپلود… {fa(Math.round(uploadPct * 100))}٪
>
) : 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}
/>
>
)}
)}
>
)}
void onReplaceFile(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 -----------------------------------------------------------
function isGifName(att: ChatAttachment) {
return /\.gif($|\?)/i.test(att.name || "") || /\.gif($|\?)/i.test(att.url || "");
}
function canReplaceMedia(m: ChatMessage): boolean {
const kind = m.attachment?.type;
return kind === "image" || kind === "video" || kind === "audio";
}
function GifSticker({
att,
mine,
time,
pending,
read,
edited,
onPreview,
}: {
att: ChatAttachment;
mine: boolean;
time: string;
pending?: boolean;
read?: boolean;
edited?: boolean;
onPreview?: (att: ChatAttachment) => void;
}) {
return (
{edited && ویرایششده}
{clock(time)}
{mine &&
(pending ? (
) : (
))}
);
}
function CachedImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
const shown = useCachedObjectUrl(src);
if (!shown) {
return ;
}
return
;
}
function FileSaveButton({ att, mine, className }: { att: ChatAttachment; mine: boolean; className?: string }) {
const [pct, setPct] = useState(null);
const save = async (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (pct !== null) return;
setPct(0);
try {
await saveRemoteFile(att.url, att.name, setPct);
} catch (err) {
toast.error((err as Error).message || "دانلود ممکن نشد");
} finally {
setPct(null);
}
};
return (
{att.name}
{pct !== null ? `در حال دانلود… ${fa(Math.round(pct * 100))}٪` : bytes(att.size)}
{pct !== null && (
)}
);
}
function PreviewSaveButton({ url, name, cache = true }: { url: string; name: string; cache?: boolean }) {
const [pct, setPct] = useState(null);
return (
);
}
function MediaOnly({
att,
mine,
time,
edited,
read,
pending,
onPreview,
}: {
att: ChatAttachment;
mine: boolean;
time: string;
edited?: boolean;
read?: boolean;
pending?: boolean;
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 receipt = mine ? (
pending ? (
) : (
)
) : null;
const chip = (
{edited ? `ویرایششده · ${clock(time)}` : clock(time)}
{receipt}
);
if (att.type === "image") {
return (
);
}
if (att.type === "video") {
return (
onPreview?.(att)} />
{edited ? `ویرایششده · ${clock(time)}` : clock(time)}
{receipt}
);
}
if (att.type === "audio") {
return (
{edited && ویرایششده}
{clock(time)}
{mine && (pending ? : )}
);
}
return (
{clock(time)}
{mine && (pending ? : )}
);
}
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 (
);
}
return ;
}
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)}
);
}
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)}
>
);
}
function VoicePlayer({ att, mine }: { att: ChatAttachment; mine: boolean }) {
const objectKey = mediaObjectKey(att.url);
const audioRef = useRef(null);
const liveRef = useRef<{ ctx: AudioContext; analyser: AnalyserNode } | null>(null);
const rafRef = useRef(0);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const [bars] = useState(() => voiceBars(objectKey || att.name, 44));
const [live, setLive] = useState(null);
const [downloading, setDownloading] = useState(null);
useEffect(() => {
const a = new Audio();
a.preload = "metadata";
a.src = att.url;
audioRef.current = a;
const onMeta = () => setDuration(Number.isFinite(a.duration) ? a.duration : 0);
const onTime = () => setCurrent(a.currentTime);
const onEnd = () => {
setPlaying(false);
setCurrent(0);
setLive(null);
if (activeVoice === a) activeVoice = null;
};
a.addEventListener("loadedmetadata", onMeta);
a.addEventListener("durationchange", onMeta);
a.addEventListener("timeupdate", onTime);
a.addEventListener("ended", onEnd);
return () => {
a.pause();
a.src = "";
if (activeVoice === a) activeVoice = null;
a.removeEventListener("loadedmetadata", onMeta);
a.removeEventListener("durationchange", onMeta);
a.removeEventListener("timeupdate", onTime);
a.removeEventListener("ended", onEnd);
audioRef.current = null;
liveRef.current = null;
};
// The signed query can refresh without a new recording. Playback stays on this object.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [objectKey]);
useEffect(() => {
return () => stopActiveVoice();
}, []);
const startPulse = () => {
const lr = liveRef.current;
if (!lr) return;
const analyser = lr.analyser;
const freq = new Uint8Array(analyser.frequencyBinCount);
const loop = () => {
analyser.getByteFrequencyData(freq);
const vals: number[] = [];
for (let i = 0; i < 44; i++) {
const f = freq[i] / 255;
vals.push(Math.max(0.12, Math.min(1, f * 1.5)));
}
setLive(vals);
rafRef.current = requestAnimationFrame(loop);
};
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(loop);
};
const stopPulse = () => {
cancelAnimationFrame(rafRef.current);
setLive(null);
};
const saveVoice = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (downloading !== null) return;
setDownloading(0);
void saveRemoteFile(att.url, att.name || "voice", setDownloading)
.catch(() => toast.error("دانلود صدا ممکن نشد"))
.finally(() => setDownloading(null));
};
const toggle = () => {
const a = audioRef.current;
if (!a) return;
if (playing) {
stopPulse();
a.pause();
setPlaying(false);
return;
}
stopActiveVoice();
activeVoice = a;
if (!a.src) a.src = att.url;
if (!liveRef.current) {
try {
const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (Ctx) {
const ctx = new Ctx();
const src = ctx.createMediaElementSource(a);
const analyser = ctx.createAnalyser();
analyser.fftSize = 128;
analyser.smoothingTimeConstant = 0.7;
src.connect(analyser);
analyser.connect(ctx.destination);
void ctx.resume();
liveRef.current = { ctx, analyser };
}
} catch {
/* waveform peaks optional */
}
}
void liveRef.current?.ctx.resume();
void a.play().then(
() => {
setPlaying(true);
if (liveRef.current) startPulse();
},
() => {
setPlaying(false);
toast.error("پخش صدا ممکن نشد");
}
);
};
const seek = (v: number) => {
const a = audioRef.current;
if (!a) return;
a.currentTime = v;
setCurrent(v);
};
const scrubRef = useRef(null);
const scrubbingRef = useRef(false);
const seekTo = (clientX: number) => {
if (dur <= 0) return;
const el = scrubRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
let a: HTMLAudioElement | null = null;
try {
a = audioRef.current;
} catch {
/* noop */
}
const v = ratio * dur;
if (a) {
try {
a.currentTime = v;
} catch {
/* noop */
}
}
setCurrent(v);
};
const onScrubDown = (e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
scrubbingRef.current = true;
seekTo(e.clientX);
e.currentTarget.setPointerCapture?.(e.pointerId);
};
const onScrubMove = (e: React.PointerEvent) => {
if (scrubbingRef.current) seekTo(e.clientX);
};
const endScrub = () => {
scrubbingRef.current = false;
};
const dur = Number.isFinite(duration) && duration > 0 ? duration : 0;
const progress = dur > 0 ? Math.min(current / dur, 1) : 0;
const shown = live ?? bars;
return (
{downloading !== null && (
)}
{
e.preventDefault();
e.stopPropagation();
}}
onDoubleClick={(e) => e.stopPropagation()}
className="relative flex h-8 cursor-pointer touch-none select-none items-end gap-[3px]"
>
{shown.map((h, i) => {
const isLit = playing || i / shown.length <= progress;
return (
);
})}
{dur > 0 && (
)}
{fmtDur(current)}
{downloading !== null ? `دانلود ${fa(Math.round(downloading * 100))}٪` : fmtDur(dur)}
);
}
function voiceBars(seed: string, n: number) {
let h = 2166136261;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
const bars: number[] = [];
for (let i = 0; i < n; i++) {
h = Math.imul(h ^ (h >>> 15), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
h ^= h >>> 16;
const r = (h >>> 0) / 4294967296;
const env = Math.exp(-Math.pow((i / (n - 1) - 0.5) * 2.4, 2));
bars.push(Math.max(0.12, Math.min(1, (0.35 + r * 0.65) * (0.35 + env * 0.65))));
}
return bars;
}
function fmtDur(sec: number) {
if (!Number.isFinite(sec) || sec < 0) sec = 0;
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${fa(m)}:${fa(s).padStart(2, "۰")}`;
}
function ZoomableImg({ src, alt }: { src: string; alt?: string }) {
const shown = useCachedObjectUrl(src);
const [scale, setScale] = useState(1);
const [off, setOff] = useState({ x: 0, y: 0 });
const [grabbing, setGrabbing] = useState(false);
const imgRef = useRef(null);
const pointers = useRef(new Map());
const pinchRef = useRef<{ dist: number; scale: number } | null>(null);
const scaleRef = useRef(1);
const clampScale = (s: number) => Math.min(6, Math.max(1, s));
const setSc = (s: number) => {
const v = clampScale(s);
scaleRef.current = v;
setScale(v);
};
useEffect(() => {
setSc(1);
setOff({ x: 0, y: 0 });
pointers.current.clear();
pinchRef.current = null;
setGrabbing(false);
}, [src]);
const clampOff = (x: number, y: number) => {
const el = imgRef.current;
const w = (el?.offsetWidth ?? 0) / 2;
const h = (el?.offsetHeight ?? 0) / 2;
const maxX = Math.max(0, w * (scaleRef.current - 1));
const maxY = Math.max(0, h * (scaleRef.current - 1));
return { x: Math.min(maxX, Math.max(-maxX, x)), y: Math.min(maxY, Math.max(-maxY, y)) };
};
useEffect(() => {
const el = imgRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const s = clampScale(scaleRef.current * (e.deltaY < 0 ? 1.18 : 1 / 1.18));
setSc(s);
if (s === 1) setOff({ x: 0, y: 0 });
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => Math.hypot(a.x - b.x, a.y - b.y);
const onPointerDown = (e: React.PointerEvent) => {
e.stopPropagation();
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
const m = pointers.current;
m.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (m.size === 2) {
const [a, b] = [...m.values()];
pinchRef.current = { dist: dist(a, b), scale: scaleRef.current };
} else if (m.size === 1 && scaleRef.current > 1) {
setGrabbing(true);
}
};
const onPointerMove = (e: React.PointerEvent) => {
const m = pointers.current;
if (!m.has(e.pointerId)) return;
const prev = m.get(e.pointerId)!;
if (m.size === 2) {
const [a, b] = [...m.values()];
const start = pinchRef.current;
if (start) setSc(start.scale * (dist(a, b) / start.dist));
} else if (m.size === 1 && scaleRef.current > 1) {
setOff((o) => clampOff(o.x + (e.clientX - prev.x), o.y + (e.clientY - prev.y)));
}
m.set(e.pointerId, { x: e.clientX, y: e.clientY });
};
const endPointer = (e: React.PointerEvent) => {
pointers.current.delete(e.pointerId);
pinchRef.current = null;
if (pointers.current.size === 0) setGrabbing(false);
};
const toggleZoom = () => {
if (scaleRef.current > 1) {
setSc(1);
setOff({ x: 0, y: 0 });
} else {
setSc(2.2);
}
};
return (
{
e.stopPropagation();
toggleZoom();
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endPointer}
onPointerCancel={endPointer}
style={{ transform: `translate(${off.x}px, ${off.y}px) scale(${scale})` }}
className={cn(
"max-h-[72vh] max-w-[92vw] select-none touch-none rounded-2xl object-contain shadow-offset",
scale > 1 ? (grabbing ? "cursor-grabbing" : "cursor-grab") : "cursor-zoom-in"
)}
/>
);
}
function AttThumb({ att }: { att: ChatAttachment }) {
if (att.type === "image")
return ;
if (att.type === "video")
return (
);
if (att.type === "audio")
return (
);
return (
);
}
// ---- helpers ----------------------------------------------------------------
const STUDENT_SUGGESTIONS = [
"سلام! به کمک نیاز دارم.",
"قفل شدهام — لطفاً حسابم را فعال کنید.",
"در آزمون گیر کردهام و راهنمایی میخواهم.",
];
const MENTOR_SUGGESTIONS = [
"سلام! چطور میتوانم کمکتان کنم؟",
"برای بازگشایی حساب، آزمون را دوباره امتحان کنید.",
"به پیشرفتتان افتخار میکنم — ادامه دهید.",
];
function isMentorOf(role: string) {
return role === "mentor" || role === "admin";
}
function bubbleDir(text: string): "rtl" | "ltr" {
for (const ch of text) {
if (ch === " " || ch === "\n" || ch === "\t") continue;
const code = ch.codePointAt(0) ?? 0;
const rtl =
(code >= 0x0600 && code <= 0x06ff) ||
(code >= 0x0750 && code <= 0x077f) ||
(code >= 0x08a0 && code <= 0x08ff) ||
(code >= 0xfb50 && code <= 0xfdff) ||
(code >= 0xfe70 && code <= 0xfeff);
if (rtl) return "rtl";
if ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) return "ltr";
}
return "rtl";
}
function ChatThreadSkeleton() {
return (
{[0, 1, 2, 3].map((i) => (
))}
);
}
function roleLabel(role: string) {
if (role === "mentor") return "منتور";
if (role === "admin") return "ادمین";
return "هنرجو";
}
function fa(n: number) {
return n.toLocaleString("fa-IR");
}
function bytes(n: number) {
if (n < 1024) return `${fa(n)} بایت`;
if (n < 1024 * 1024) return `${fa(Math.round(n / 1024))} کیلوبایت`;
return `${fa(Number((n / (1024 * 1024)).toFixed(1).replace(/\.0$/, "")))} مگابایت`;
}
function clock(iso: string) {
return new Date(iso).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" });
}
function clockDur(secs: number) {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${fa(m)}:${fa(s).padStart(2, "۰")}`;
}
function dateKey(iso: string) {
const d = new Date(iso);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
function dateLabel(iso: string) {
const d = new Date(iso);
const now = new Date();
const y = new Date(now);
y.setDate(now.getDate() - 1);
if (dateKey(iso) === dateKey(now.toISOString())) return "امروز";
if (dateKey(iso) === dateKey(y.toISOString())) return "دیروز";
return d.toLocaleDateString("fa-IR", { day: "numeric", month: "long" });
}
function previewOf(m: ChatMessage) {
if (m.attachment) {
const names: Record = { image: "عکس", video: "ویدیو", audio: "پیام صوتی", file: m.attachment.name };
const label = names[m.attachment.type] ?? m.attachment.name;
return m.body ? `${m.body}` : label;
}
return m.body;
}
function PinnedPreview({ m }: { m: ChatMessage }) {
const body = (m.body ?? "").trim();
const att = m.attachment;
const onlyEmoji = !att && !!body && isSingleEmoji(body);
const gif = att && isGifName(att);
if (onlyEmoji) {
return (
);
}
if (att?.type === "image") {
return (
{body ? (
{body}
) : (
{gif ? "GIF" : "عکس"}
)}
);
}
if (att?.type === "video") {
return (
{body || "ویدیو"}
);
}
if (att?.type === "audio") {
return (
{body || "پیام صوتی"}
);
}
if (att?.type === "file") {
return (
{body || att.name || "فایل"}
);
}
if (body) {
return (
{body}
);
}
return پیام پینشده;
}
function toForm(file: File | Blob, mime?: string) {
const fd = new FormData();
const isFile = file instanceof File;
// Prefer .weba / .m4a so audio/webm is never classified as video/webm on the server.
// Important: check webm BEFORE "opus" — MediaRecorder often reports audio/webm;codecs=opus.
let name: string;
let type: string;
if (isFile) {
name = file.name;
type = file.type || mimeForExt(file.name);
} else {
const m = (mime ?? "audio/webm").toLowerCase();
if (m.includes("webm")) {
name = `voice-${Date.now()}.weba`;
type = "audio/webm";
} else if (m.includes("mp4") || m.includes("m4a") || m.includes("aac")) {
name = `voice-${Date.now()}.m4a`;
type = "audio/mp4";
} else if (m.startsWith("audio/ogg") || m === "audio/opus" || m.startsWith("audio/opus")) {
name = `voice-${Date.now()}.ogg`;
type = "audio/ogg";
} else {
name = `voice-${Date.now()}.weba`;
type = "audio/webm";
}
}
fd.append("file", new File([file], name, { type }));
return fd;
}
function mimeForExt(name: string) {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
const map: Record = {
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", avif: "image/avif",
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", m4v: "video/mp4",
mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4", aac: "audio/aac", ogg: "audio/ogg", opus: "audio/ogg",
weba: "audio/webm",
pdf: "application/pdf", zip: "application/zip", txt: "text/plain", md: "text/markdown", csv: "text/csv", json: "application/json",
doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
};
return map[ext] ?? "application/octet-stream";
}
function pickMime() {
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
if (typeof MediaRecorder === "undefined") return "";
for (const c of candidates) {
if (MediaRecorder.isTypeSupported(c)) return c;
}
return "";
}
function SeenTicks({ read }: { read?: boolean }) {
const seen = !!read;
const label = seen ? "دیده شد" : "ارسال شد";
return (
{seen ? (
) : (
)}
{label}
);
}
function TypingDots() {
return (
{[0, 1, 2].map((i) => (
))}
);
}
function AutoTextarea({
value,
onChange,
onSend,
placeholder,
disabled,
inputRef,
}: {
value: string;
onChange: (v: string) => void;
onSend: () => void;
placeholder?: string;
disabled?: boolean;
inputRef?: React.Ref;
}) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 140)}px`;
}, [value]);
return (