Files
pargar/frontend/src/components/mentor-chat.tsx
T

3836 lines
155 KiB
TypeScript
Raw Normal View History

"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 (
<div aria-hidden className="pointer-events-none absolute inset-0 -z-0 opacity-60">
{orbs.map((o, i) => (
<motion.div
key={i}
className={cn("absolute rounded-full blur-[70px] will-change-transform", o.cls)}
style={LAVA_STYLES[i]}
animate={{ x: o.x, y: o.y, scale: o.s }}
transition={{ duration: o.dur, repeat: Infinity, ease: [0.45, 0.05, 0.25, 1], delay: i * 2.5 }}
/>
))}
</div>
);
}
export function ChatWidget() {
const me = useAuth((s) => s.user);
const qc = useQueryClient();
const router = useRouter();
const [open, setOpen] = useState(false);
const [active, setActive] = useState<number | null>(null);
const [composeOpen, setComposeOpen] = useState(false);
const [draft, setDraft] = useState("");
const [query, setQuery] = useState("");
const [sending, setSending] = useState(false);
const [pending, setPending] = useState<ChatMessage[]>([]);
const [partnerTyping, setPartnerTyping] = useState(false);
const [attPref, setAttPref] = useState<ChatAttachment | null>(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<ChatAttachment | null>(null);
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
const [emojiOpen, setEmojiOpen] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [jumpDown, setJumpDown] = useState(false);
const [older, setOlder] = useState<ChatMessage[]>([]);
const [canLoadMore, setCanLoadMore] = useState(false);
const [historyAnchor, setHistoryAnchor] = useState<number | null>(null);
const [loadingOlder, setLoadingOlder] = useState(false);
const [delArm, setDelArm] = useState<number | null>(null);
const [reactId, setReactId] = useState<number | null>(null);
const [heartMsg, setHeartMsg] = useState<number | null>(null);
const [hearts, setHearts] = useState<Record<number, number>>({});
const [msgSearchOpen, setMsgSearchOpen] = useState(false);
const [msgQuery, setMsgQuery] = useState("");
const [matchIdx, setMatchIdx] = useState(0);
const [pinIdx, setPinIdx] = useState(0);
const [flashId, setFlashId] = useState<number | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [editDraft, setEditDraft] = useState("");
const [editBusy, setEditBusy] = useState(false);
const taRef = useRef<HTMLTextAreaElement>(null);
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const replaceRef = useRef<HTMLInputElement>(null);
const replaceTarget = useRef<ChatMessage | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const typingSentAt = useRef(0);
const typingClearTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const notifTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const delArmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const heartTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const threadRef = useRef<HTMLDivElement>(null);
const wasNearBottomRef = useRef(true);
const suppressAutoScrollRef = useRef(false);
const jumpTargetRef = useRef<number | null>(null);
const pendingDraftRef = useRef<string | null>(null);
const activeRef = useRef(active);
activeRef.current = active;
const convRef = useRef<Conversation[]>([]);
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<number, ChatMessage>();
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<number, ChatMessage>();
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<typeof setInterval>;
start: number;
live?: () => void;
} | null>(null);
const [recSec, setRecSec] = useState(0);
const [recLevel, setRecLevel] = useState(0);
const [recUrl, setRecUrl] = useState<string | null>(null);
const [recBlob, setRecBlob] = useState<Blob | null>(null);
const [recMime, setRecMime] = useState<string | undefined>(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 (
<>
<AnimatePresence>
{open && (
<>
<motion.div
key="mt-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={softTween}
onClick={() => {
setOpen(false);
setActive(null);
}}
className="fixed inset-0 z-[65] bg-background/35 backdrop-blur-[2px]"
/>
<motion.div
key="mt-panel"
initial={{ opacity: 0, y: 24, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 24, scale: 0.96 }}
transition={softSpring}
className="fixed bottom-24 start-3 end-3 z-[66] flex h-[min(640px,calc(100svh-10rem))] w-auto flex-col overflow-hidden rounded-[26px] border border-white/60 bg-white/55 shadow-[0_24px_70px_-18px_rgba(42,39,69,0.4)] ring-1 ring-black/5 backdrop-blur-2xl sm:bottom-6 sm:start-5 sm:end-auto sm:h-[min(720px,calc(100svh-3rem))] sm:w-[420px] dark:border-white/15 dark:bg-[#14172b]/60 dark:ring-white/10 xl:start-8 xl:w-[440px]"
>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-l from-primary via-accent to-gold" />
<div aria-hidden className="pointer-events-none absolute -end-16 -top-16 h-40 w-40 rounded-full bg-accent/25 blur-3xl" />
<div aria-hidden className="pointer-events-none absolute -start-16 -top-10 h-36 w-36 rounded-full bg-primary/20 blur-3xl" />
<div className="relative flex items-center gap-3 border-b-2 border-white/50 bg-white/30 px-4 py-3 backdrop-blur-md dark:border-white/10 dark:bg-white/5">
{active && partner ? (
<>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-lg" onClick={() => setActive(null)}>
<ChevronRight className="h-4 w-4" />
</Button>
<button
type="button"
onClick={() => openProfile(partner.id)}
className="relative shrink-0 rounded-full"
aria-label={`پروفایل ${partner.name}`}
>
<UserAvatar name={partner.name} className="h-9 w-9" {...avatarPropsOf(partner)} />
<span
className={cn(
"absolute -bottom-0.5 -end-0.5 h-2.5 w-2.5 rounded-full border-2 border-card",
partner.online ? "bg-success shadow-[0_0_6px_rgba(34,197,94,0.8)]" : "bg-muted-foreground/40"
)}
/>
</button>
<button
type="button"
onClick={() => openProfile(partner.id)}
className="min-w-0 flex-1 text-start"
aria-label={`پروفایل ${partner.name}`}
>
<p className="truncate text-sm font-extrabold">{partner.name}</p>
{partnerTyping ? (
<p className="flex items-center gap-1 text-[11px] font-bold text-primary">
<TypingDots /> در حال تایپ است
</p>
) : (
<p className="text-[11px] text-muted-foreground">{roleLabel(partner.role)}</p>
)}
</button>
</>
) : (
<>
<div className="grid h-9 w-9 place-items-center rounded-xl bg-primary/15">
<MessageSquare className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<p className="text-sm font-extrabold">گفتگوها</p>
</div>
</>
)}
<button
onClick={() => {
setOpen(false);
setActive(null);
}}
aria-label="بستن"
className="grid h-8 w-8 place-items-center rounded-lg bg-muted/60 text-muted-foreground transition-colors hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
{active && (
<button
onClick={() => setMsgSearchOpen((o) => !o)}
aria-label="جستجو در گفتگو"
className={cn(
"grid h-8 w-8 place-items-center rounded-lg bg-muted/60 transition-colors",
msgSearchOpen ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
<Search className="h-4 w-4" />
</button>
)}
{!active && (
<button
onClick={() => setComposeOpen((value) => !value)}
aria-label="شروع گفتگوی جدید"
title="گفتگوی جدید"
className={cn(
"grid h-8 w-8 place-items-center rounded-lg bg-muted/60 transition-colors",
composeOpen ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
<Pencil className="h-4 w-4" />
</button>
)}
<ThemeToggle />
</div>
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{!active ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 p-3 pb-1">
<div className="relative">
<Search className="absolute start-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/70" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="جستجو در گفتگوها…"
dir="rtl"
className="h-9 w-full rounded-xl border-2 border-border bg-background/60 ps-8 pe-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/80 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</div>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto overscroll-contain p-3 chat-scroll">
{composeOpen && (
<div className="mb-3 rounded-2xl border-2 border-primary/20 bg-primary/5 p-3">
<p className="mb-2 text-xs font-bold text-muted-foreground">شروع گفتگوی جدید</p>
{(loveList?.mentors ?? []).map((m) => (
<button
key={m.id}
onClick={() => {
setActive(m.id);
setComposeOpen(false);
}}
className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-sm transition-colors hover:bg-primary/10"
>
<UserAvatar name={m.name} className="h-8 w-8" {...avatarPropsOf(m)} />
<span className="font-medium">{m.name}</span>
<span className="ms-auto text-[10px] text-muted-foreground">{roleLabel(m.role)}</span>
</button>
))}
{(loveList?.mentors ?? []).length === 0 && (
<p className="text-xs text-muted-foreground">مخاطب مجازی برای گفتگو وجود ندارد.</p>
)}
</div>
)}
{sortedConvs.map((c) => (
<button
key={c.partner.id}
onClick={() => {
setActive(c.partner.id);
setComposeOpen(false);
}}
className="flex w-full items-center gap-3 rounded-2xl border-2 border-border/70 bg-background/50 px-3 py-2.5 text-right text-sm transition-colors hover:border-primary/40 hover:bg-background/80"
>
<div className="relative shrink-0">
<UserAvatar name={c.partner.name} className="h-10 w-10" {...avatarPropsOf(c.partner)} />
<span
className={cn(
"absolute -bottom-0.5 -end-0.5 h-2.5 w-2.5 rounded-full border-2 border-card",
c.partner.online ? "bg-success" : "bg-muted-foreground/40"
)}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate font-bold">{c.partner.name}</p>
<span className="shrink-0 text-[10px] text-muted-foreground">
{c.lastMessage ? clock(c.lastMessage.createdAt) : ""}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5">
<p
dir="auto"
className={cn(
"truncate text-xs",
c.unreadCount > 0 ? "font-bold text-foreground" : "text-muted-foreground"
)}
>
{c.lastMessage ? previewOf(c.lastMessage) : "هنوز پیامی نیست"}
</p>
{c.unreadCount > 0 && (
<span className="ms-auto grid h-5 min-w-5 shrink-0 place-items-center rounded-full bg-accent px-1.5 text-[10px] font-black text-accent-foreground">
{fa(c.unreadCount)}
</span>
)}
</div>
</div>
</button>
))}
{sortedConvs.length === 0 && conversations.length > 0 && (
<p className="px-1 py-3 text-center text-xs text-muted-foreground">گفتگویی یافت نشد.</p>
)}
{convLoading && conversations.length === 0 && (
<div className="space-y-2" aria-busy="true" aria-label="در حال بارگذاری گفتگوها">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-14 rounded-2xl" />
))}
</div>
)}
{conversations.length === 0 && !composeOpen && !convLoading && (
<div className="space-y-1 p-1">
<p className="mb-2 text-xs font-bold text-muted-foreground">برای شروع، روی مداد بزنید</p>
{(loveList?.mentors ?? []).map((m) => (
<button
key={m.id}
onClick={() => {
setActive(m.id);
setComposeOpen(false);
}}
className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-sm transition-colors hover:bg-primary/10"
>
<UserAvatar name={m.name} className="h-7 w-7" {...avatarPropsOf(m)} />
<span className="font-medium">{m.name}</span>
</button>
))}
</div>
)}
</div>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
{msgSearchOpen && (
<div className="relative z-30 flex items-center gap-1.5 border-b-2 border-white/40 bg-background/70 px-3 py-2 backdrop-blur-md dark:border-white/10">
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
value={msgQuery}
onChange={(e) => 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() !== "" && (
<span className={cn("shrink-0 text-[11px] font-bold tabular-nums", matchCount ? "text-muted-foreground" : "text-destructive")}>
{matchCount ? `${fa(matchIdx + 1)}/${fa(matchCount)}` : "پیدا نشد"}
</span>
)}
<button
onClick={() => setMatchIdx((i) => (i - 1 + matchCount) % matchCount)}
disabled={!matchCount}
aria-label="نتیجه قبلی"
className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
onClick={() => setMatchIdx((i) => (i + 1) % matchCount)}
disabled={!matchCount}
aria-label="نتیجه بعدی"
className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
>
<ChevronDown className="h-4 w-4" />
</button>
<button
onClick={() => {
setMsgSearchOpen(false);
setMsgQuery("");
setMatchIdx(0);
}}
aria-label="بستن جستجو"
className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{activePin && (
<div className="relative z-30 shrink-0 border-b border-gold/30 bg-gold/10 px-2.5 py-1.5 backdrop-blur-md">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void jumpToMessage(activePin.id)}
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg px-1.5 py-1 text-start transition hover:bg-gold/15"
title="پرش به پیام پین‌شده"
>
<Pin className="h-3.5 w-3.5 shrink-0 text-gold" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 text-[10px] font-extrabold text-gold">
پین شده
{pinned.length > 1 && (
<span className="tabular-nums text-gold/80">
{fa(pinIdx + 1)}/{fa(pinned.length)}
</span>
)}
</span>
<PinnedPreview m={activePin} />
</span>
</button>
{pinned.length > 1 && (
<button
type="button"
onClick={() => setPinIdx((i) => (i + 1) % pinned.length)}
className="shrink-0 rounded-md px-2 py-1 text-[10px] font-bold text-muted-foreground transition-colors hover:bg-card hover:text-foreground"
title="پین بعدی"
>
بعدی
</button>
)}
<button
type="button"
onClick={() => void togglePin(activePin)}
className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-card hover:text-foreground"
aria-label="برداشتن پین"
title="برداشتن پین"
>
<PinOff className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
<div className="relative min-h-0 flex-1 overflow-hidden">
<LavaLamp />
<div
ref={threadRef}
onScroll={() => {
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 && (
<div className="pointer-events-none absolute inset-2 z-20 grid place-items-center rounded-2xl border-2 border-dashed border-primary bg-primary/10 backdrop-blur-sm">
<p className="text-sm font-extrabold text-primary">فایل را رها کنید</p>
</div>
)}
{messagesLoading && <ChatThreadSkeleton />}
{messagesQuery.isError && !messagesLoading && (
<div className="mx-auto max-w-[260px] space-y-3 pt-8 text-center">
<p className="text-sm font-medium text-destructive">بارگذاری پیام‌ها ممکن نشد</p>
<button
type="button"
onClick={() => void messagesQuery.refetch()}
className="rounded-full border border-border px-3 py-1 text-[11px] font-bold"
>
تلاش دوباره
</button>
</div>
)}
{emptyThread && (
<div className="mx-auto max-w-[260px] space-y-3 pt-8 text-center">
<div className="mx-auto grid h-12 w-12 place-items-center rounded-2xl bg-primary/10">
<MessageSquare className="h-6 w-6 text-primary" />
</div>
<p className="text-sm font-medium text-muted-foreground">
سلام کنید — کمک بگیرید، مشکلات را بگویید یا موفقیت را جشن بگیرید.
</p>
<span className="inline-flex items-center gap-1 rounded-full border border-accent/40 bg-accent/15 px-3 py-1 text-[11px] font-bold text-foreground dark:bg-accent/25">
<Sparkles className="h-3 w-3" /> پاسخ کمتر از یک روز
</span>
</div>
)}
{(view.start > 0 || canLoadMore) && (
<div className="flex justify-center py-1.5">
<button
onClick={() => void showEarlier()}
disabled={loadingOlder}
className="inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-card/80 px-3 py-1 text-[10px] font-extrabold text-muted-foreground shadow-soft transition-colors hover:border-primary/40 hover:text-primary disabled:opacity-60"
>
{loadingOlder ? (
<>
<Loader2 className="h-3 w-3 animate-spin" /> در حال بارگذاری…
</>
) : (
<>
<ChevronRight className="h-3 w-3 rotate-90" /> پیام‌های قبلی
</>
)}
</button>
</div>
)}
{thread.map((seg, i) =>
seg.kind === "date" ? (
<div key={`d-${i}`} className="flex justify-center py-1.5">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-card/80 px-3 py-1 text-[10px] font-bold text-muted-foreground shadow-soft">
<span className="h-1 w-1 rounded-full bg-primary/60" />
{seg.label}
</span>
</div>
) : (
<>
{firstUnread && seg.msgs.some((mm) => mm.id === firstUnread) && (
<div className="my-1.5 flex w-full items-center gap-2">
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-accent/50" />
<span className="inline-flex items-center gap-1.5 rounded-full border border-accent/30 bg-accent/10 px-3 py-1 text-[10px] font-extrabold text-accent-foreground">
<Sparkles className="h-3 w-3" /> پیام‌های جدید
</span>
<div className="h-px flex-1 bg-gradient-to-l from-transparent to-accent/50" />
</div>
)}
<div className="group flex w-full items-end gap-1.5">
{!seg.mine && partner && (
<button
type="button"
onClick={() => openProfile(partner.id)}
className="mb-0.5 shrink-0 rounded-full"
aria-label={`پروفایل ${partner.name}`}
>
<UserAvatar name={partner.name} className="h-7 w-7" {...avatarPropsOf(partner)} />
</button>
)}
<div className={cn("flex min-w-0 max-w-[min(100%,20rem)] items-end gap-1 sm:max-w-[24rem]", seg.mine ? "ml-auto" : "mr-auto")}>
<div className={cn("flex min-w-0 max-w-full flex-col", seg.mine ? "items-end" : "items-start")}>
{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 (
<div
key={m.id}
data-mid={m.id}
onDoubleClick={() => 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",
)}
>
<AnimatePresence>
{heartMsg === m.id && reactId !== m.id && (
<motion.span
key={hearts[m.id] ?? 0}
initial={{ scale: 0.2, opacity: 0, y: 10, rotate: -12 }}
animate={{ scale: 1, opacity: 1, y: 0, rotate: 6 }}
exit={{ scale: 1.35, opacity: 0, y: -20, rotate: 0 }}
transition={softPop}
className="pointer-events-none absolute inset-0 z-40 grid place-items-center"
>
<AppleEmoji emoji="❤️" size={48} />
</motion.span>
)}
</AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={softTween}
className={cn(
mediaOnly && !isGif
? "overflow-hidden"
: onlyEmoji || isGif
? "bg-transparent px-1.5 py-1"
: "max-w-full px-3.5 py-2 text-sm leading-relaxed",
!onlyEmoji &&
!isGif &&
(seg.mine
? cn(
"bg-primary text-primary-foreground shadow-[0_1px_1px_rgba(0,0,0,0.12)]",
isLast ? "rounded-[18px] rounded-tr-[6px]" : "rounded-[14px]",
)
: cn(
"border border-border/60 bg-card shadow-[0_1px_1px_rgba(0,0,0,0.06)]",
isLast ? "rounded-[18px] rounded-tl-[6px]" : "rounded-[14px]",
)),
flashId === m.id && "chat-msg-glow",
)}
>
{mediaOnly && isGif ? (
<GifSticker
att={m.attachment!}
mine={seg.mine}
time={m.createdAt}
pending={isPending}
read={!!m.readAt}
edited={!!m.editedAt}
onPreview={setPreview}
/>
) : mediaOnly ? (
<>
<MediaOnly att={m.attachment!} mine={seg.mine} time={m.createdAt} edited={!!m.editedAt} read={!!m.readAt} pending={isPending} onPreview={setPreview} />
</>
) : onlyEmoji ? (
<>
<p
dir="auto"
className="whitespace-pre-wrap break-words leading-[1.15] [unicode-bidi:plaintext]"
>
<AppleEmoji emoji={m.body!} size={54} />
</p>
</>
) : (
<>
{m.replyTo && (
<QuoteBox
q={msgs.find((x) => x.id === m.replyTo) ?? null}
mine={seg.mine}
partnerName={partner?.name}
meRole={me?.role}
/>
)}
{m.attachment && (
<AttachmentView
att={m.attachment}
mine={seg.mine}
time={m.createdAt}
onPreview={setPreview}
/>
)}
{editingId === m.id ? (
<div className="space-y-2">
<textarea
value={editDraft}
onChange={(e) => setEditDraft(e.target.value)}
dir={bubbleDir(editDraft)}
rows={3}
maxLength={MAX_LEN}
className="w-full min-w-[12rem] resize-none rounded-lg bg-background px-2 py-1 text-sm text-foreground outline-none"
/>
<div className="flex justify-end gap-1">
<button
type="button"
onClick={() => setEditingId(null)}
className="rounded-full px-2 py-1 text-[10px] font-bold text-foreground"
>
انصراف
</button>
<button
type="button"
disabled={editBusy}
onClick={() => void saveEdit()}
className="rounded-full bg-foreground/10 px-2 py-1 text-[10px] font-bold text-foreground disabled:opacity-50"
>
{editBusy ? "…" : "ذخیره"}
</button>
</div>
</div>
) : m.body ? (
<p
dir={bubbleDir(m.body)}
className={cn("whitespace-pre-wrap break-words text-start [overflow-wrap:anywhere]", m.attachment && "mt-1")}
>
<HighlightBody text={m.body} q={msgQuery} />
</p>
) : null}
{m.body && editingId !== m.id && (
<span
className={cn(
"mt-1 flex items-center gap-1 text-[10px] leading-none",
seg.mine ? "justify-end" : "justify-start"
)}
>
{m.editedAt && (
<span className={cn("opacity-75", seg.mine ? "text-primary-foreground" : "text-muted-foreground")}>
ویرایش‌شده
</span>
)}
<span className={cn("opacity-75", seg.mine ? "text-primary-foreground" : "text-muted-foreground")}>
{clock(m.createdAt)}
</span>
{seg.mine &&
(isPending ? (
<Clock className="h-3.5 w-3.5 animate-spin text-primary-foreground/75 [animation-duration:1.6s]" />
) : (
<SeenTicks read={!!m.readAt} />
))}
</span>
)}
</>
)}
</motion.div>
{!!m.reactions?.length && (
<div
className={cn(
"relative z-20 -mt-1.5 w-fit",
seg.mine ? "ms-auto -me-1.5" : "me-auto -ms-1.5"
)}
>
<ReactChips
reactions={m.reactions}
mine={seg.mine}
onReact={(em) => void toggleReact(m, em)}
/>
</div>
)}
{editingId !== m.id && (
<MsgActions
mine={seg.mine}
voice={m.attachment?.type === "audio"}
media={mediaOnly}
armed={delArm === m.id}
pinned={pinnedIds.has(m.id) || !!m.pinned}
open={reactId === m.id}
onReact={() => 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)}
/>
)}
</div>
);
})}
</div>
</div>
</div>
</>
)
)}
</div>
<AnimatePresence>
{(jumpDown || (historyAnchor != null && view.end < msgs.length)) && (
<motion.button
key="jump-down"
initial={{ opacity: 0, y: 10, scale: 0.85 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.85 }}
transition={softSpring}
onClick={() => {
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"
>
<ArrowDown className="h-4 w-4" />
</motion.button>
)}
</AnimatePresence>
</div>
{partnerTyping && (
<div className="flex shrink-0 items-center gap-2 border-t border-border/40 bg-background/40 px-4 py-1.5 text-xs font-medium text-muted-foreground">
<TypingDots />
{partner?.name} در حال تایپ است…
</div>
)}
{emptyThread && (
<div className="flex shrink-0 flex-wrap gap-1.5 px-3 pt-2">
{(me?.role === "student" ? STUDENT_SUGGESTIONS : MENTOR_SUGGESTIONS).map((q) => (
<button
key={q}
onClick={() => send(q)}
disabled={sending}
className="rounded-full border-2 border-primary/30 bg-primary/5 px-3 py-1 text-[11px] font-bold text-primary transition-colors hover:bg-primary/15 disabled:opacity-50"
>
{q}
</button>
))}
</div>
)}
<div className="relative z-20 mx-2 mb-2 shrink-0 pt-1">
{recording && (
<div className="mb-2 flex items-center gap-2 rounded-xl bg-destructive/10 px-3 py-2">
<div className="flex h-3.5 min-w-9 items-center justify-center gap-[2px]">
{Array.from({ length: 9 }).map((_, i) => {
const on = recLevel > (i + 1) / 9;
return (
<span
key={i}
className={cn("w-[3px] rounded-full transition-colors duration-75", on ? "bg-destructive" : "bg-destructive/25")}
style={{ height: `${12 + ((i % 3) + 1) * 14}%` }}
/>
);
})}
</div>
<span className="flex-1 text-xs font-bold text-destructive tabular-nums">{clockDur(recSec)}</span>
<button onClick={cancelVoice} aria-label="انصراف از ضبط" className="text-destructive/70 hover:text-destructive">
<X className="h-4 w-4" />
</button>
<button onClick={toggleMic} aria-label="پایان ضبط" className="text-destructive">
<Square className="h-4 w-4 fill-current" />
</button>
</div>
)}
{(attPref || uploading) && (
<div className="relative mb-2 flex items-center gap-2 overflow-hidden rounded-xl border-2 border-border/70 bg-muted/40 px-3 py-2">
{uploading && (
<span className="pointer-events-none absolute inset-x-0 bottom-0 h-1 overflow-hidden bg-primary/10">
<span
className="block h-full bg-primary transition-[width] duration-150"
style={{ width: `${Math.round(uploadPct * 100)}%` }}
/>
</span>
)}
{uploading ? (
<>
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="flex-1 text-xs font-medium text-muted-foreground">
در حال آپلود… {fa(Math.round(uploadPct * 100))}٪
</span>
</>
) : attPref ? (
<>
{attPref.type === "image" ? (
<button
type="button"
onClick={() => setPreview(attPref)}
aria-label="پیش‌نمایش تصویر"
className="group relative h-28 w-28 shrink-0 cursor-zoom-in overflow-hidden rounded-xl border-2 border-border/70"
>
<CachedImg
src={attPref.url}
alt={attPref.name}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<span className="absolute inset-0 grid place-items-center bg-black/0 opacity-0 transition-opacity group-hover:bg-black/30 group-hover:opacity-100">
<Maximize className="h-6 w-6 text-white drop-shadow" />
</span>
</button>
) : (
<AttThumb att={attPref} />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-bold">{attPref.name}</p>
<p className="text-[10px] text-muted-foreground">{bytes(attPref.size)}</p>
</div>
{attPref.type === "image" && (
<button
onClick={() => setPreview(attPref)}
aria-label="پیش‌نمایش"
title="پیش‌نمایش"
className="hidden text-muted-foreground transition-colors hover:text-foreground sm:inline-flex"
>
<Maximize className="h-4 w-4" />
</button>
)}
<button onClick={() => setAttPref(null)} aria-label="حذف ضمیمه" className="text-muted-foreground hover:text-destructive">
<X className="h-4 w-4" />
</button>
</>
) : null}
</div>
)}
<AnimatePresence initial={false}>
{replyTo && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={softTween}
className="overflow-hidden"
>
<div className="mb-2 flex items-center gap-2 rounded-lg border-s-4 border-primary bg-primary/5 px-3 py-1.5">
<CornerDownLeft className="h-3.5 w-3.5 shrink-0 text-primary" />
<span className="min-w-0 flex-1">
<span className="block text-[11px] font-bold text-primary">
در پاسخ به {isMineMsg(replyTo, me?.role) ? "خودتان" : partner?.name ?? "پیام"}
</span>
<span dir="auto" className="block truncate text-xs text-muted-foreground [unicode-bidi:plaintext]">
{quotePreview(replyTo)}
</span>
</span>
<button onClick={() => setReplyTo(null)} aria-label="لغو پاسخ" className="text-muted-foreground transition-colors hover:text-destructive">
<X className="h-4 w-4" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{recUrl ? (
<VoiceRecPreview
url={recUrl}
dur={recDur}
sending={sending || uploading}
onCancel={cancelVoicePreview}
onSend={() => void sendVoice()}
/>
) : (
<>
<div className="relative">
<AnimatePresence initial={false}>
{emojiOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={softTween}
className="overflow-hidden"
>
<div className="mb-2 grid w-full grid-cols-8 gap-1 rounded-2xl border border-white/60 bg-white/75 p-2 shadow-soft backdrop-blur-xl dark:border-white/15 dark:bg-[#1a1e33]/80">
<div className="col-span-8 mb-1 flex items-center justify-between px-1">
<span className="text-[10px] font-extrabold text-muted-foreground">ایموجی</span>
<button onClick={() => setEmojiOpen(false)} aria-label="بستن" className="text-muted-foreground transition-colors hover:text-destructive">
<X className="h-3.5 w-3.5" />
</button>
</div>
{EMOJIS.map((e) => (
<button
key={e}
onClick={() => {
if (draft.length + e.length > MAX_LEN) return;
setDraft((d) => d + e);
signalTyping();
}}
className="grid h-8 w-8 place-items-center rounded-lg transition-colors hover:bg-primary/10 active:scale-90"
aria-label={e}
>
<AppleEmoji emoji={e} size={22} />
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<div className="flex items-center gap-1.5 rounded-xl border-2 border-border/70 bg-card/85 p-1.5 shadow-soft transition-colors focus-within:border-primary/50 dark:border-border/70">
{draft.trim() || attPref ? (
<Button
onClick={() => send()}
disabled={sending || uploading || atLimit}
className="h-10 w-10 shrink-0 rounded-xl bg-primary text-primary-foreground transition-all hover:bg-primary/90 active:scale-90"
aria-label="ارسال"
>
<ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
className={cn(
"h-10 w-10 shrink-0 rounded-xl transition-colors",
recording ? "bg-destructive/15 text-destructive" : "text-muted-foreground"
)}
onClick={toggleMic}
disabled={uploading || sending}
aria-label="پیام صوتی"
>
<Mic className="h-4 w-4 rtl:-scale-x-100" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-10 w-10 shrink-0 rounded-xl text-muted-foreground" onClick={pickFile} disabled={uploading || sending || recording} aria-label="پیوست فایل">
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Paperclip className="h-4 w-4" />}
</Button>
<div className="min-w-0 flex-1">
<AutoTextarea
inputRef={taRef}
value={draft}
onChange={(v) => {
setDraft(v);
if (v.trim()) signalTyping();
}}
onSend={() => send()}
placeholder="پیام خود را بنویسید…"
disabled={sending || uploading}
/>
</div>
<Button
variant="ghost"
size="icon"
className={cn("h-10 w-10 shrink-0 rounded-xl", emojiOpen ? "bg-primary/10 text-primary" : "text-muted-foreground")}
onClick={() => setEmojiOpen((o) => !o)}
disabled={sending || recording}
aria-label="ایموجی"
>
<Smile className="h-4 w-4" />
</Button>
</div>
</div>
</>
)}
</div>
</div>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
<input
ref={fileRef}
type="file"
accept="image/*,video/*,audio/*,.pdf,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.csv,.json"
className="hidden"
onChange={onFile}
/>
<input ref={replaceRef} type="file" className="hidden" onChange={(e) => void onReplaceFile(e)} />
<AnimatePresence>
{preview && (
<motion.div
key="img-preview"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={softTween}
onClick={() => 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"
>
<div className="pointer-events-auto flex w-full max-w-2xl items-center justify-between gap-3">
{preview.type === "image" ? (
<p className="min-w-0 truncate text-sm font-bold text-white" dir="auto">
{preview.name}
</p>
) : (
<span />
)}
<div className="flex shrink-0 items-center gap-2">
<a
href={preview.url}
target="_blank"
rel="noreferrer"
className="grid h-9 w-9 place-items-center rounded-full bg-white/10 text-white transition-colors hover:bg-white hover:text-black"
aria-label="باز کردن در تب جدید"
title="باز کردن در تب جدید"
>
<ExternalLink className="h-4 w-4" />
</a>
<PreviewSaveButton url={preview.url} name={preview.name} cache={preview.type !== "video"} />
<button
onClick={() => setPreview(null)}
aria-label="بستن"
className="grid h-9 w-9 place-items-center rounded-full bg-white/10 text-white transition-colors hover:bg-destructive"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{preview.type === "image" ? (
<div className="pointer-events-auto" onClick={(e) => e.stopPropagation()}>
<ZoomableImg key={preview.url} src={preview.url} alt={preview.name} />
</div>
) : (
<div className="pointer-events-auto" onClick={(e) => e.stopPropagation()}>
<video src={preview.url} controls autoPlay className="max-h-[72vh] max-w-[92vw] rounded-2xl" />
</div>
)}
<p className="text-xs text-white/60">{bytes(preview.size)}</p>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{notif && (
<motion.button
key={notif.id}
initial={{ opacity: 0, y: 14, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.96 }}
transition={softSpring}
onClick={() => {
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]"
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-accent/15">
<MessageSquare className="h-4 w-4 text-accent" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-extrabold">{notif.name}</span>
<span dir="auto" className="block truncate text-[11px] text-muted-foreground">
{notif.body}
</span>
</span>
<span className="h-2 w-2 shrink-0 rounded-full bg-success" />
</motion.button>
)}
</AnimatePresence>
{me && !open && (
<div className="fixed bottom-5 start-5 z-[64]">
<div className="relative">
{unreadTotal > 0 && (
<span className="pointer-events-none absolute -inset-1 -z-10 animate-ping rounded-2xl bg-accent/40" aria-hidden />
)}
<motion.button
initial={{ scale: 0, rotate: -12 }}
animate={{ scale: 1, rotate: 0 }}
transition={softSpring}
onClick={() => 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"
>
<MessageSquare className="h-6 w-6" />
{unreadTotal > 0 && (
<span className="pointer-events-none absolute -start-1.5 -top-1.5 grid h-6 min-w-6 place-items-center rounded-full border-2 border-background bg-accent px-1 text-[11px] font-black text-accent-foreground">
{fa(unreadTotal)}
</span>
)}
</motion.button>
</div>
</div>
)}
</>
);
}
// ---- 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 (
<div className={cn("flex max-w-full flex-col", mine ? "items-end" : "items-start")}>
<button
type="button"
onClick={() => onPreview?.(att)}
className="group relative block cursor-zoom-in overflow-hidden rounded-xl outline-none"
aria-label={`باز کردن ${att.name}`}
>
<CachedImg
src={att.url}
alt={att.name}
className="max-h-44 w-auto max-w-full rounded-xl object-contain transition-transform duration-200 group-hover:scale-[1.03]"
/>
</button>
<span className="mt-0.5 flex items-center gap-1 px-1.5 text-[10px] leading-none">
{edited && <span className="text-muted-foreground/80">ویرایش‌شده</span>}
<span className="text-muted-foreground/80">{clock(time)}</span>
{mine &&
(pending ? (
<Clock className="h-3 w-3 animate-spin text-muted-foreground/80 [animation-duration:1.6s]" />
) : (
<SeenTicks read={read} />
))}
</span>
</div>
);
}
function CachedImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
const shown = useCachedObjectUrl(src);
if (!shown) {
return <span className={cn("block animate-pulse bg-muted/50", className)} aria-hidden />;
}
return <img src={shown} alt={alt} draggable={false} className={className} />;
}
function FileSaveButton({ att, mine, className }: { att: ChatAttachment; mine: boolean; className?: string }) {
const [pct, setPct] = useState<number | null>(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 (
<div
className={cn(
"relative flex w-full items-center gap-2 overflow-hidden rounded-xl bg-background/50 px-3 py-2 text-start",
!mine && "border border-border/50",
className,
)}
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-primary/15">
<FileText className="h-4 w-4 text-primary" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-bold" dir="auto">{att.name}</span>
<span className="block text-[10px] text-muted-foreground">
{pct !== null ? `در حال دانلود… ${fa(Math.round(pct * 100))}٪` : bytes(att.size)}
</span>
</span>
<button
type="button"
onClick={(e) => void save(e)}
disabled={pct !== null}
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-background hover:text-foreground disabled:opacity-70"
aria-label="دانلود فایل"
title="دانلود"
>
{pct !== null ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
</button>
{pct !== null && (
<span className="absolute inset-x-0 bottom-0 h-1 bg-primary/15">
<span className="block h-full bg-primary transition-[width] duration-150" style={{ width: `${Math.round(pct * 100)}%` }} />
</span>
)}
</div>
);
}
function PreviewSaveButton({ url, name, cache = true }: { url: string; name: string; cache?: boolean }) {
const [pct, setPct] = useState<number | null>(null);
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (pct !== null) return;
setPct(0);
void saveRemoteFile(url, name, setPct, { cache })
.catch((err) => toast.error((err as Error).message || "دانلود ممکن نشد"))
.finally(() => setPct(null));
}}
className="relative grid h-9 w-9 place-items-center overflow-hidden rounded-full bg-white/10 text-white transition-colors hover:bg-white hover:text-black"
aria-label={pct !== null ? `دانلود ${fa(Math.round(pct * 100))} درصد` : "دانلود"}
title="دانلود"
>
{pct !== null ? (
<span className="text-[10px] font-black tabular-nums">{fa(Math.round(pct * 100))}</span>
) : (
<Download className="h-4 w-4" />
)}
{pct !== null && (
<span className="absolute inset-x-1 bottom-1 h-0.5 overflow-hidden rounded-full bg-white/30">
<span className="block h-full bg-white" style={{ width: `${Math.round(pct * 100)}%` }} />
</span>
)}
</button>
);
}
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<ReturnType<typeof setTimeout> | 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 ? (
<Clock className="h-3 w-3 animate-spin text-white/80 [animation-duration:1.6s]" />
) : (
<SeenTicks read={read} />
)
) : null;
const chip = (
<span className={cn("absolute bottom-1.5 flex items-center gap-1 rounded-md bg-black/55 px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur-sm", corner)}>
<span>{edited ? `ویرایش‌شده · ${clock(time)}` : clock(time)}</span>
{receipt}
</span>
);
if (att.type === "image") {
return (
<button
type="button"
onClick={() => openPreview(att)}
onDoubleClick={cancelPreview}
className="relative block w-full cursor-zoom-in"
aria-label={`باز کردن ${att.name}`}
>
<CachedImg src={att.url} alt={att.name} className="max-h-72 w-full object-cover" />
{chip}
</button>
);
}
if (att.type === "video") {
return (
<div className="relative overflow-hidden">
<ChatVideoPlayer att={att} onPreview={() => onPreview?.(att)} />
<span className="pointer-events-none absolute end-1.5 top-1.5 flex items-center gap-1 rounded-full bg-black/55 px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur-sm">
<span>{edited ? `ویرایش‌شده · ${clock(time)}` : clock(time)}</span>
{receipt}
</span>
</div>
);
}
if (att.type === "audio") {
return (
<div className="px-3 py-2">
<VoicePlayer att={att} mine={mine} />
<span className={cn("mt-1 flex items-center gap-1 text-[10px] leading-none", mine ? "justify-end" : "justify-start")}>
{edited && <span className={cn("opacity-75", mine ? "text-primary-foreground/70" : "text-muted-foreground/70")}>ویرایش‌شده</span>}
<span className={cn("opacity-75", mine ? "text-primary-foreground/70" : "text-muted-foreground/70")}>{clock(time)}</span>
{mine && (pending ? <Clock className="h-3 w-3 animate-spin opacity-70 [animation-duration:1.6s]" /> : <SeenTicks read={read} />)}
</span>
</div>
);
}
return (
<div className="px-3 py-2">
<FileSaveButton att={att} mine={mine} />
<span className={cn("mt-1 flex items-center gap-1 text-[10px] leading-none", mine ? "justify-end" : "justify-start")}>
<span className={cn("opacity-75", mine ? "text-primary-foreground/70" : "text-muted-foreground/70")}>{clock(time)}</span>
{mine && (pending ? <Clock className="h-3 w-3 animate-spin opacity-70 [animation-duration:1.6s]" /> : <SeenTicks read={read} />)}
</span>
</div>
);
}
function AttachmentView({
att,
mine,
time,
onPreview,
}: {
att: ChatAttachment;
mine: boolean;
time: string;
onPreview?: (att: ChatAttachment) => void;
}) {
const previewTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<div className={cn("relative -m-3.5 mb-1.5 overflow-hidden rounded-t-2xl", mine ? "rounded-l-2xl" : "rounded-r-2xl")}>
<button
type="button"
onClick={() => openPreview(att)}
onDoubleClick={cancelPreview}
className="block w-full cursor-zoom-in"
aria-label={`باز کردن ${att.name}`}
>
<CachedImg src={att.url} alt={att.name} className="max-h-56 w-full object-cover" />
</button>
<span className="absolute bottom-1.5 end-1.5 rounded-md bg-black/50 px-1.5 py-0.5 text-[10px] font-bold text-white">
{clock(time)}
</span>
</div>
);
}
if (att.type === "video") {
return (
<div className="relative">
<ChatVideoPlayer att={att} onPreview={() => onPreview?.(att)} />
<span className="pointer-events-none absolute end-1.5 top-1.5 rounded-full bg-black/50 px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur-sm">
{clock(time)}
</span>
</div>
);
}
if (att.type === "audio") {
return (
<div className="mb-1">
<VoicePlayer att={att} mine={mine} />
<div
className={cn(
"mt-0.5 flex items-center gap-1 text-[10px] leading-none",
mine ? "justify-end" : "justify-start"
)}
>
<span className={mine ? "text-primary-foreground/70" : "text-muted-foreground/70"}>{clock(time)}</span>
</div>
</div>
);
}
return <FileSaveButton att={att} mine={mine} className="mb-0.5" />;
}
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<HTMLAudioElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const peaksRef = useRef<number[]>([]);
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<HTMLCanvasElement>) => {
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 (
<div className="mb-2 flex items-center gap-2 rounded-xl border border-white/80 bg-white/60 px-3 py-2 shadow-[0_4px_16px_-8px_rgba(42,39,69,0.3)] backdrop-blur-sm dark:border-white/15 dark:bg-white/10">
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={onCancel}
aria-label="حذف پیش‌نمایش"
className="h-9 w-9 rounded-xl text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
onClick={onSend}
disabled={sending}
aria-label="ارسال پیام صوتی"
className="h-9 w-9 rounded-xl bg-primary px-0 text-primary-foreground transition-all hover:bg-primary/90 active:scale-90"
>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowRight className="h-4 w-4" />}
</Button>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-[9px] font-bold text-muted-foreground/70">{playing ? "در حال پخش…" : ""}</span>
<span dir="ltr" className="shrink-0 text-[10px] font-bold text-muted-foreground tabular-nums">
{clockDur(cur)} / {clockDur(dur)}
</span>
</div>
<canvas
ref={canvasRef}
dir="ltr"
onPointerDown={seekFromEvent}
onPointerMove={(e) => {
if (e.buttons & 1) seekFromEvent(e);
}}
className="mt-1 h-[28px] w-full cursor-pointer touch-none"
aria-label="نوار پخش پیش‌نمایش صدا"
/>
</div>
<button
onClick={toggle}
aria-label={playing ? "توقف پخش" : "پخش پیش‌نمایش"}
className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground shadow-md shadow-primary/30 transition-transform active:scale-90"
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
</div>
);
}
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<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(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 (
<div
ref={wrapRef}
className="group/video relative"
onMouseEnter={() => setShowUI(true)}
onMouseLeave={() => setShowUI(false)}
>
<video
ref={videoRef}
src={att.url}
preload="metadata"
controls={false}
playsInline
onClick={toggle}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onWaiting={() => setBuffering(true)}
onPlaying={() => setBuffering(false)}
onTimeUpdate={(e) => setCur(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => setDur(e.currentTarget.duration || 0)}
onEnded={() => {
setPlaying(false);
setCur(0);
}}
className="block max-h-64 w-full bg-black object-contain"
/>
<button
type="button"
onClick={toggle}
aria-label={playing ? "توقف" : "پخش"}
className={cn(
"absolute inset-0 grid place-items-center transition-opacity duration-200",
playing && !showUI ? "opacity-0" : "opacity-100"
)}
>
<span
className={cn(
"grid h-14 w-14 place-items-center rounded-full bg-black/45 text-white shadow-lg backdrop-blur-sm transition-transform active:scale-90",
!playing && "animate-pulse"
)}
>
{buffering ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : playing ? (
<Pause className="h-6 w-6" />
) : (
<Play className="h-6 w-6 translate-x-0.5" />
)}
</span>
</button>
{onPreview && (
<button
type="button"
onClick={onPreview}
aria-label="بزرگنمایی"
className="absolute end-2 top-2 grid h-8 w-8 place-items-center rounded-full bg-black/40 text-white opacity-0 transition-opacity backdrop-blur-sm hover:bg-black/60 group-hover/video:opacity-100"
>
<Maximize className="h-4 w-4" />
</button>
)}
<div
className={cn(
"absolute inset-x-0 bottom-0 px-2.5 pb-2 pt-8 transition-opacity duration-200",
showUI || buffering ? "opacity-100" : "opacity-0"
)}
style={{ background: "linear-gradient(to top, rgba(0,0,0,0.65), transparent)" }}
>
<div className="relative h-5 w-full cursor-pointer">
<div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 overflow-hidden rounded-full bg-white/25">
<div className="h-full rounded-full bg-white" style={{ width: `${prog * 100}%` }} />
</div>
<input
type="range"
min={0}
max={dur || 1}
step={0.1}
value={Math.min(cur, dur || 0)}
onChange={(e) => seek(+e.target.value)}
aria-label="موقعیت ویدیو"
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</div>
<div className="flex items-center gap-2 text-white">
<button type="button" onClick={toggle} aria-label={playing ? "توقف" : "پخش"} className="grid h-6 w-6 place-items-center rounded-full transition-colors hover:bg-white/20">
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<span className="min-w-12 text-[10px] tabular-nums text-white/90">
{fmtDur(cur)} / {fmtDur(dur)}
</span>
<div className="flex-1" />
<button
type="button"
onClick={() => {
const v = videoRef.current;
if (!v) return;
v.muted = !muted;
setMuted(!muted);
}}
aria-label={muted ? "باز کردن صدا" : "بی‌صدا"}
className="grid h-6 w-6 place-items-center rounded-full transition-colors hover:bg-white/20"
>
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<button type="button" onClick={fullscreen} aria-label="تمام‌صفحه" className="grid h-6 w-6 place-items-center rounded-full transition-colors hover:bg-white/20">
<Maximize className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
function VoicePlayer({ att, mine }: { att: ChatAttachment; mine: boolean }) {
const objectKey = mediaObjectKey(att.url);
const audioRef = useRef<HTMLAudioElement | null>(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<number[]>(() => voiceBars(objectKey || att.name, 44));
const [live, setLive] = useState<number[] | null>(null);
const [downloading, setDownloading] = useState<number | null>(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<HTMLDivElement | null>(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<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
scrubbingRef.current = true;
seekTo(e.clientX);
e.currentTarget.setPointerCapture?.(e.pointerId);
};
const onScrubMove = (e: React.PointerEvent<HTMLDivElement>) => {
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 (
<div className="relative flex items-center gap-2.5 overflow-hidden rounded-xl bg-background/50 p-2">
{downloading !== null && (
<span className="absolute inset-x-0 bottom-0 h-1 bg-primary/15">
<span className="block h-full bg-primary transition-[width] duration-150" style={{ width: `${Math.round(downloading * 100)}%` }} />
</span>
)}
<button
type="button"
onClick={toggle}
aria-label={playing ? "توقف" : "پخش"}
className={cn(
"grid h-10 w-10 shrink-0 place-items-center rounded-full transition-transform active:scale-90",
mine ? "bg-primary-foreground/90 text-primary" : "bg-primary text-primary-foreground"
)}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 translate-x-0.5" />}
</button>
<div className="min-w-0 flex-1">
<div
ref={scrubRef}
data-scrub
onPointerDown={onScrubDown}
onPointerMove={onScrubMove}
onPointerUp={endScrub}
onPointerCancel={endScrub}
onClick={(e) => {
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 (
<span
key={i}
style={{ height: `${Math.max(h * 100, 8)}%` }}
className={cn(
"w-[3px] rounded-full",
isLit
? mine
? "bg-primary-foreground/90"
: "bg-primary"
: mine
? "bg-primary-foreground/35"
: "bg-foreground/25"
)}
/>
);
})}
<span
aria-hidden
className="pointer-events-none absolute bottom-0 start-0 h-[3px] rounded-full transition-[width] duration-100 ease-linear"
style={{
width: `${progress * 100}%`,
background: mine ? "var(--primary-foreground)" : "var(--primary)",
opacity: 0.9,
}}
/>
{dur > 0 && (
<span
aria-hidden
className="pointer-events-none absolute top-1/2 z-10 h-3.5 w-3.5 rounded-full border-2 border-background shadow-sm"
style={{
background: mine ? "var(--primary-foreground)" : "var(--primary)",
left: `calc(${(1 - progress) * 100}% - 7px)`,
transform: "translateY(-50%)",
}}
/>
)}
</div>
<div className="mt-0.5 flex items-center justify-between text-[10px] tabular-nums">
<span className={mine ? "text-primary-foreground/70" : "text-muted-foreground"}>{fmtDur(current)}</span>
<span className={mine ? "text-primary-foreground/70" : "text-muted-foreground"}>
{downloading !== null ? `دانلود ${fa(Math.round(downloading * 100))}٪` : fmtDur(dur)}
</span>
</div>
</div>
<button
type="button"
onClick={saveVoice}
disabled={downloading !== null}
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg transition-colors disabled:opacity-70",
mine ? "text-primary-foreground/80 hover:bg-primary-foreground/15" : "text-muted-foreground hover:bg-background"
)}
aria-label="دانلود صدا"
title="دانلود"
>
{downloading !== null ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
</button>
</div>
);
}
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<HTMLImageElement>(null);
const pointers = useRef(new Map<number, { x: number; y: number }>());
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 (
<img
ref={imgRef}
src={shown || undefined}
alt={alt ?? ""}
draggable={false}
onDoubleClick={(e) => {
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 <CachedImg src={att.url} alt={att.name} className="h-10 w-10 shrink-0 rounded-lg object-cover" />;
if (att.type === "video")
return (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-primary/15">
<Film className="h-4 w-4 text-primary" />
</span>
);
if (att.type === "audio")
return (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-primary/15">
<Mic className="h-4 w-4 text-primary" />
</span>
);
return (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-primary/15">
<FileText className="h-4 w-4 text-primary" />
</span>
);
}
// ---- 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 (
<div className="space-y-3 pt-2" aria-busy="true" aria-label="در حال بارگذاری پیام‌ها">
{[0, 1, 2, 3].map((i) => (
<div key={i} className={cn("flex", i % 2 ? "justify-end" : "justify-start")}>
<Skeleton className={cn("h-14 rounded-2xl", i % 2 ? "w-40" : "w-52")} />
</div>
))}
</div>
);
}
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<string, string> = { 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 (
<span dir="auto" className="mt-0.5 block leading-none [unicode-bidi:plaintext]">
<AppleEmoji emoji={body} size={28} />
</span>
);
}
if (att?.type === "image") {
return (
<span className="mt-0.5 flex min-w-0 items-center gap-2">
<CachedImg
src={att.url}
alt=""
className={cn(
"shrink-0 object-cover",
gif ? "h-9 w-9 object-contain" : "h-9 w-9 rounded-lg",
)}
/>
{body ? (
<span className="line-clamp-1 text-xs text-foreground/90">{body}</span>
) : (
<span className="text-[11px] text-muted-foreground">{gif ? "GIF" : "عکس"}</span>
)}
</span>
);
}
if (att?.type === "video") {
return (
<span className="mt-0.5 flex min-w-0 items-center gap-2">
<span className="relative grid h-9 w-9 shrink-0 place-items-center overflow-hidden rounded-lg bg-primary/15">
<Film className="h-4 w-4 text-primary" />
</span>
<span className="line-clamp-1 text-xs text-foreground/90">{body || "ویدیو"}</span>
</span>
);
}
if (att?.type === "audio") {
return (
<span className="mt-0.5 inline-flex max-w-full items-center gap-1.5 rounded-full border border-border/50 bg-card/70 px-2.5 py-1 text-xs text-foreground/90">
<Mic className="h-3.5 w-3.5 shrink-0 text-primary" />
<span className="truncate">{body || "پیام صوتی"}</span>
</span>
);
}
if (att?.type === "file") {
return (
<span className="mt-0.5 inline-flex max-w-full items-center gap-1.5 rounded-xl border border-border/50 bg-card/70 px-2.5 py-1 text-xs text-foreground/90">
<FileText className="h-3.5 w-3.5 shrink-0 text-primary" />
<span className="truncate">{body || att.name || "فایل"}</span>
</span>
);
}
if (body) {
return (
<span className="mt-0.5 inline-block max-w-full truncate rounded-[14px] rounded-tr-[5px] border border-border/50 bg-card/80 px-2.5 py-1 text-xs leading-snug text-foreground/90">
{body}
</span>
);
}
return <span className="mt-0.5 block text-xs text-muted-foreground">پیام پین‌شده</span>;
}
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<string, string> = {
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 (
<span
tabIndex={0}
className={cn(
"group/receipt pointer-events-auto inline-flex cursor-default items-center overflow-hidden rounded-full text-[10px] font-extrabold leading-none outline-none transition-all duration-200",
"hover:gap-0.5 hover:bg-background hover:px-1.5 hover:py-0.5 hover:shadow-sm hover:ring-1",
"focus-visible:gap-0.5 focus-visible:bg-background focus-visible:px-1.5 focus-visible:py-0.5 focus-visible:shadow-sm focus-visible:ring-1",
seen
? "text-success hover:ring-success/50 focus-visible:ring-success/50"
: "text-current opacity-80 hover:text-foreground hover:opacity-100 hover:ring-border focus-visible:text-foreground focus-visible:opacity-100 focus-visible:ring-border",
)}
aria-label={label}
>
{seen ? (
<CheckCheck className="h-3.5 w-3.5 shrink-0" strokeWidth={2.75} />
) : (
<Check className="h-3.5 w-3.5 shrink-0" strokeWidth={2.6} />
)}
<span className="grid grid-cols-[0fr] transition-[grid-template-columns] duration-200 group-hover/receipt:grid-cols-[1fr] group-focus-visible/receipt:grid-cols-[1fr]">
<span className="overflow-hidden">
<span className="whitespace-nowrap ps-0.5">{label}</span>
</span>
</span>
</span>
);
}
function TypingDots() {
return (
<span className="inline-flex items-center gap-0.5" aria-hidden>
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-1 w-1 animate-bounce rounded-full bg-current opacity-70"
style={{ animationDelay: `${i * 130}ms` }}
/>
))}
</span>
);
}
function AutoTextarea({
value,
onChange,
onSend,
placeholder,
disabled,
inputRef,
}: {
value: string;
onChange: (v: string) => void;
onSend: () => void;
placeholder?: string;
disabled?: boolean;
inputRef?: React.Ref<HTMLTextAreaElement>;
}) {
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 140)}px`;
}, [value]);
return (
<textarea
ref={(el) => {
ref.current = el;
if (typeof inputRef === "function") inputRef(el);
else if (inputRef) (inputRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = el;
}}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
onSend();
}
}}
rows={1}
placeholder={placeholder}
disabled={disabled}
maxLength={MAX_LEN}
dir="auto"
className="my-1 max-h-36 min-h-10 w-full resize-none bg-transparent px-1.5 py-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
/>
);
}
type Seg = { kind: "date"; label: string } | { kind: "stack"; mine: boolean; msgs: ChatMessage[] };
function isMineMsg(m: Pick<ChatMessage, "senderRole">, role?: string): boolean {
return !!role && m.senderRole === role;
}
function buildThread(msgs: ChatMessage[], role?: string): Seg[] {
const out: Seg[] = [];
let cur: ChatMessage[] = [];
let curMine: boolean | null = null;
msgs.forEach((m, i) => {
const mine = isMineMsg(m, role);
const prev = i > 0 ? msgs[i - 1] : undefined;
const sameDay = prev && dateKey(prev.createdAt) === dateKey(m.createdAt);
if (!sameDay) {
if (cur.length) out.push({ kind: "stack", mine: curMine ?? false, msgs: cur });
cur = [];
out.push({ kind: "date", label: dateLabel(m.createdAt) });
curMine = mine;
} else if (mine !== curMine) {
out.push({ kind: "stack", mine: curMine ?? false, msgs: cur });
cur = [];
curMine = mine;
}
cur.push(m);
});
if (cur.length) out.push({ kind: "stack", mine: curMine ?? false, msgs: cur });
return out;
}
const EMOJIS = [
"😀", "😂", "🥰", "😍", "😉", "😎", "🤗", "🙃",
"🤔", "😅", "🥲", "😭", "😤", "😡", "🥳", "🤓",
"👍", "👎", "👏", "🙏", "💪", "🤝", "❤️", "💯",
"✨", "🔥", "🌟", "🎉", "🎯", "🚀", "📌", "✅",
];
const QUICK_REACTIONS = ["❤️", "👍", "😂", "😮", "🔥", "🎉"];
function quotePreview(m: ChatMessage): string {
if (m.body) return m.body;
if (!m.attachment) return "پیام";
switch (m.attachment.type) {
case "image":
return "📷 تصویر";
case "video":
return "🎬 ویدیو";
case "audio":
return "🎙️ پیام صوتی";
default:
return `📄 ${m.attachment.name}`;
}
}
async function copyText(m: ChatMessage): Promise<void> {
const text = m.body || quotePreview(m);
try {
await navigator.clipboard.writeText(text);
toast.success("در کلیپ‌بورد کپی شد");
} catch {
toast.error("کپی ممکن نشد");
}
}
function MsgActions({
mine,
voice,
media,
armed,
pinned,
open,
onReact,
onReactEmoji,
onReply,
onCopy,
onPin,
onEdit,
editLabel,
onDelete,
}: {
mine: boolean;
voice: boolean;
media: boolean;
armed: boolean;
pinned?: boolean;
open: boolean;
onReact: () => void;
onReactEmoji: (emoji: string) => void;
onReply: () => void;
onCopy: () => void;
onPin: () => void;
onEdit?: () => void;
editLabel?: string;
onDelete: () => void;
}) {
return (
<div
className={cn(
"pointer-events-none absolute z-30 translate-y-1 scale-90 opacity-0 transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover/msg:pointer-events-auto group-hover/msg:translate-y-0 group-hover/msg:scale-100 group-hover/msg:opacity-100 group-focus-within/msg:pointer-events-auto group-focus-within/msg:translate-y-0 group-focus-within/msg:scale-100 group-focus-within/msg:opacity-100",
open && "pointer-events-auto translate-y-0 scale-100 opacity-100",
mine ? "end-1" : "start-1",
media ? "top-2" : "-top-3.5"
)}
>
<motion.div
initial={{ opacity: 0, scale: 0.85, y: 4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.85, y: 4 }}
transition={softPop}
className="flex items-center gap-0.5 rounded-full border border-solid border-white/80 bg-white/95 p-0.5 backdrop-blur-md dark:border-white/15 dark:bg-[#171a2e]/95"
>
<div className="flex items-center gap-0.5">
<div className="relative">
<button
onClick={onReact}
aria-label="واکنش"
className={cn(
"grid h-6 w-6 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent/15 hover:text-accent",
open && "bg-accent/15 text-accent"
)}
>
<SmilePlus className="h-3.5 w-3.5" />
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ opacity: 0, scale: 0.7, y: 6 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.7, y: 6 }}
transition={softPop}
className={cn(
"absolute z-40 flex items-center gap-0.5",
media ? "top-full mt-1.5" : "bottom-full mb-1.5",
mine ? "end-0" : "start-0"
)}
>
<div className="flex items-center gap-0.5 rounded-full border border-solid border-white/80 bg-white/95 p-1 backdrop-blur-md dark:border-white/15 dark:bg-[#171a2e]/95">
{QUICK_REACTIONS.map((em) => (
<motion.button
key={em}
type="button"
whileHover={{ scale: 1.3, rotate: -6 }}
whileTap={{ scale: 0.8 }}
onClick={() => onReactEmoji(em)}
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ ...softPop, delay: 0.04 * QUICK_REACTIONS.indexOf(em) }}
aria-label={em}
>
<span className="grid h-6 w-6 place-items-center">
<AppleEmoji emoji={em} size={16} />
</span>
</motion.button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<button
onClick={onReply}
aria-label="پاسخ"
className="grid h-6 w-6 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary"
>
<CornerDownLeft className="h-3.5 w-3.5" />
</button>
<button
onClick={onPin}
aria-label={pinned ? "برداشتن پین" : "پین"}
title={pinned ? "برداشتن پین" : "پین"}
className={cn(
"grid h-6 w-6 place-items-center rounded-full transition-colors",
pinned
? "bg-gold/20 text-gold"
: "text-muted-foreground hover:bg-gold/15 hover:text-gold",
)}
>
{pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
</button>
{!voice && (
<button
onClick={onCopy}
aria-label="کپی"
className="grid h-6 w-6 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary"
>
<Copy className="h-3.5 w-3.5" />
</button>
)}
{mine && onEdit && (
<button
onClick={onEdit}
aria-label={editLabel || "ویرایش پیام"}
title={editLabel || "ویرایش پیام"}
className="grid h-6 w-6 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
{mine && (
<button
onClick={onDelete}
aria-label="حذف پیام"
title={armed ? "حذف؟" : "حذف"}
className={cn(
"grid h-6 w-6 place-items-center rounded-full transition-colors",
armed
? "bg-destructive text-destructive-foreground shadow-[0_0_0_3px_rgba(239,68,68,0.25)]"
: "text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</motion.div>
</div>
);
}
function ReactChips({
reactions,
mine,
onReact,
}: {
reactions: ChatReaction[];
mine: boolean;
onReact: (emoji: string) => void;
}) {
return (
<div className={cn("flex flex-wrap items-center gap-1", mine ? "justify-end" : "justify-start")}>
{reactions.map((r) => (
<motion.button
key={r.emoji}
initial={{ opacity: 0, scale: 0.5, y: 4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={softPop}
whileHover={{ scale: 1.12 }}
whileTap={{ scale: 0.85 }}
onClick={() => onReact(r.emoji)}
className={cn(
"flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-bold leading-none backdrop-blur-sm",
r.mine
? "border-accent bg-accent/20 text-accent"
: "border-border/70 bg-card/90 text-muted-foreground"
)}
>
<AppleEmoji emoji={r.emoji} size={14} />
{r.count > 1 && <span className="tabular-nums">{fa(r.count)}</span>}
</motion.button>
))}
</div>
);
}
function QuoteBox({
q,
mine,
partnerName,
meRole,
}: {
q: ChatMessage | null;
mine: boolean;
partnerName?: string;
meRole?: string;
}) {
const author = q
? isMineMsg(q, meRole)
? "شما"
: q.senderRole === "mentor"
? "منتور"
: partnerName ?? "هنرجو"
: null;
return (
<div
className={cn(
"mb-1.5 flex max-w-full min-w-0 items-center gap-2 rounded-lg border-s-4 px-2.5 py-1.5",
mine ? "border-white/80 bg-white/15" : "border-primary/50 bg-accent/10"
)}
>
<CornerDownLeft className={cn("h-3.5 w-3.5 shrink-0", mine ? "text-primary-foreground/80" : "text-accent")} />
<div className="min-w-0 flex-1">
<span className={cn("block text-[10px] font-black leading-tight", mine ? "text-primary-foreground" : "text-accent")}>
{author ?? "پیام قبلی"}
</span>
<span dir="auto" className={cn("block truncate text-xs font-medium [unicode-bidi:plaintext]", mine ? "text-primary-foreground/90" : "text-foreground/80")}>
{q ? quotePreview(q) : "این پیام دیگر موجود نیست"}
</span>
</div>
</div>
);
}
function HighlightBody({ text, q }: { text: string; q: string }) {
const qn = normText(q).trim();
if (!qn) return <AppleEmojiText text={text} size={18} />;
const nodes: React.ReactNode[] = [];
let cursor = 0;
let key = 0;
while (cursor < text.length) {
const m = findNormMatch(text, qn, cursor);
if (!m) break;
if (m.at > cursor) {
nodes.push(<AppleEmojiText key={`t${key++}`} text={text.slice(cursor, m.at)} size={18} />);
}
nodes.push(
<mark
key={key++}
className="rounded-sm bg-accent/40 px-0.5 text-inherit ring-1 ring-accent/40"
style={{ unicodeBidi: "plaintext" }}
>
<AppleEmojiText text={text.slice(m.at, Math.min(m.at + m.len, text.length))} size={18} />
</mark>
);
cursor = m.at + m.len;
}
if (cursor < text.length) {
nodes.push(<AppleEmojiText key={`t${key++}`} text={text.slice(cursor)} size={18} />);
}
return <>{nodes}</>;
}
function normText(s: string): string {
return s
.replace(/[\u064B-\u0652\u0640]/g, "")
.split("")
.map(normChar)
.join("")
.toLocaleLowerCase();
}
function normChar(c: string): string {
switch (c) {
case "\u064A": // Arabic yeh ي
case "\u0649": // alef maksura ى
return "ی";
case "\u0643": // Arabic kaf ك
return "ک";
case "\u0623": // أ
case "\u0625": // إ
case "\u0622": // آ
return "ا";
case "\u0629": // teh marbuta ة
return "ه";
case "\u0624": // waw with hamza ؤ
return "و";
default:
return c;
}
}
function findNormMatch(orig: string, qNorm: string, from: number): { at: number; len: number } | null {
const normd: string[] = [];
const map: number[] = [];
for (let i = from; i < orig.length; i++) {
const ch = orig[i];
if (/[\u064B-\u0652\u0640]/.test(ch)) continue;
normd.push(normChar(ch).toLocaleLowerCase());
map.push(i);
}
const ni = normd.join("").indexOf(qNorm);
if (ni < 0) return null;
return { at: map[ni], len: qNorm.length };
}