"use client";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { useRouter } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Archive,
ArchiveRestore,
ArrowDown,
ArrowRight,
Bell,
BellOff,
Bookmark,
Check,
CheckCheck,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Clock,
CornerDownLeft,
Copy,
Download,
ExternalLink,
FileText,
Film,
Image as ImageIcon,
Loader2,
Maximize,
Megaphone,
MessageSquare,
Mic,
Paperclip,
Pause,
Pencil,
Pin,
PinOff,
Play,
Search,
Smile,
SmilePlus,
Sparkles,
Square,
Trash2,
Volume2,
VolumeX,
X,
} from "lucide-react";
import { api, http, getWsUrl, toUserError } from "@/lib/api";
import { apiFormProgress, downloadAllAsZip, saveRemoteFile } from "@/lib/transfer";
import { mediaObjectKey } from "@/lib/media";
import { useCachedObjectUrl } from "@/hooks/use-cached-media";
import type { ChatAttachment, ChatMessage, ChatReaction, Conversation, Mentor } from "@/lib/types";
import { useAuth } from "@/lib/auth-store";
import { cn, formatJalaliStamp } from "@/lib/utils";
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ThemeToggle } from "@/components/theme-toggle";
import { toast } from "@/components/providers";
import { AppleEmoji, AppleEmojiText } from "@/components/apple-emoji";
import { softPop, softSpring, softTween } from "@/lib/motion";
import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge";
const MAX_LEN = 4000;
const LAVA_STYLES: React.CSSProperties[] = [
{
background:
"radial-gradient(circle at 40% 35%, color-mix(in srgb, var(--color-primary) 42%, transparent), transparent 70%)",
},
{
background:
"radial-gradient(circle at 55% 45%, color-mix(in srgb, var(--color-secondary) 38%, transparent), transparent 72%)",
},
{
background:
"radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--color-accent) 30%, transparent), transparent 68%)",
},
];
function LavaLamp() {
const orbs = [
{
cls: "-start-14 -top-14 h-60 w-60",
x: [0, 110, 40, 140, 0],
y: [0, 70, 150, 45, 0],
s: [1, 1.3, 1.05, 1.35, 1],
dur: 26,
},
{
cls: "-end-16 top-1/4 h-52 w-52",
x: [0, -90, -30, -120, 0],
y: [0, -55, -130, -35, 0],
s: [1, 1.2, 1.1, 1.28, 1],
dur: 22,
},
{
cls: "bottom-2 start-1/4 h-40 w-40",
x: [0, 60, -40, 95, 0],
y: [0, -70, -40, -110, 0],
s: [1, 1.25, 0.95, 1.3, 1],
dur: 30,
},
];
return (
{orbs.map((o, i) => (
))}
);
}
type PeekPreview = {
name: string;
saved: boolean;
partner: Conversation["partner"];
messages: ChatMessage[];
loading: boolean;
};
const SWIPE_ACTION_PX = 84;
const ROW_EASE = "transform 200ms cubic-bezier(0.22,1,0.36,1)";
function ConversationRow({
c,
rowName,
saved,
canRaise,
nextPinRank,
shiftY,
lifting,
settling,
freezeMotion,
shellH,
onOpen,
onRaise,
onPinGesture,
onArmPeek,
onEndPeek,
onDisarmPeek,
heldRef,
}: {
c: Conversation;
rowName: string;
saved: boolean;
canRaise: boolean;
nextPinRank: number;
shiftY: number;
lifting: boolean;
settling: boolean;
freezeMotion: boolean;
shellH: number;
onOpen: () => void;
onRaise?: () => void;
onPinGesture?: (phase: "start" | "move" | "end" | "cancel", clientY: number) => void;
onArmPeek: () => () => void;
onEndPeek: () => void;
onDisarmPeek: () => void;
heldRef: { current: boolean };
}) {
const qc = useQueryClient();
const pinGestureRef = useRef(onPinGesture);
pinGestureRef.current = onPinGesture;
const showPin = !saved;
const showArchive = !saved;
const showMute = !saved;
const actionCount =
(showPin ? 1 : 0) + (showArchive ? 1 : 0) + (showMute ? 1 : 0) + (canRaise && onRaise ? 1 : 0);
const actionPx = actionCount * SWIPE_ACTION_PX;
const revealRef = useRef(0);
const [reveal, setReveal] = useState(0);
const [dragging, setDragging] = useState(false);
const skipClick = useRef(false);
const rtlRef = useRef(true);
useEffect(() => {
rtlRef.current = document.documentElement.dir !== "ltr";
}, []);
useEffect(() => {
setReveal((n) => {
const next = Math.min(n, actionPx);
revealRef.current = next;
return next;
});
}, [actionPx]);
const setShift = (n: number) => {
const next = Math.max(0, Math.min(actionPx, n));
revealRef.current = next;
setReveal(next);
};
const savePrefs = (patch: { pinnedRank: number | null; muted: boolean; archived: boolean }) => {
void http.put(`/api/chats/${c.partner.id}/prefs`, patch).then(() => qc.invalidateQueries({ queryKey: ["conversations"] }));
setShift(0);
};
const yTransition = freezeMotion || (lifting && !settling) ? "none" : ROW_EASE;
return (
{actionCount > 0 && (
{showPin && (
)}
{canRaise && onRaise && (
)}
{showMute && (
)}
{showArchive && (
)}
)}
);
}
function PeekThread({
messages,
role,
saved,
partner,
}: {
messages: ChatMessage[];
role?: string;
saved: boolean;
partner: Conversation["partner"];
}) {
const shown = messages.slice(-16);
const thread = buildThread(shown, role);
if (shown.length === 0) return null;
return (
{thread.map((seg, i) =>
seg.kind === "date" ? (
{seg.label}
) : (
{!seg.mine && !saved && (
)}
{seg.msgs.map((m, idx) => (
))}
),
)}
);
}
function PeekBubble({
m,
mine,
last,
messages,
partnerName,
role,
}: {
m: ChatMessage;
mine: boolean;
last: boolean;
messages: ChatMessage[];
partnerName: string;
role?: string;
}) {
const atts = messageAttachments(m);
const multi = atts.length > 1;
const mediaOnly = atts.length > 0 && !m.body;
const isGif = !multi && mediaOnly && isGifName(atts[0]);
const onlyEmoji = !m.replyTo && atts.length === 0 && !!m.body && isSingleEmoji(m.body);
return (
{multi ? (
<>
{m.body ? (
) : null}
{m.body && (
{m.editedAt && ویرایششده}
{clock(m.createdAt)}
{mine && }
)}
>
) : mediaOnly && isGif ? (
) : mediaOnly ? (
) : onlyEmoji ? (
) : (
<>
{m.replyTo != null && (
x.id === m.replyTo) ?? null} mine={mine} partnerName={partnerName} meRole={role} />
)}
{m.attachment && }
{m.body ? (
) : null}
{m.body && (
{m.editedAt && ویرایششده}
{clock(m.createdAt)}
{mine && }
)}
>
)}
{!!m.reactions?.length && (
undefined} />
)}
);
}
export function ChatWidget() {
const me = useAuth((s) => s.user);
const qc = useQueryClient();
const router = useRouter();
const [open, setOpen] = useState(false);
const [active, setActive] = useState(null);
const [composeOpen, setComposeOpen] = useState(false);
const [draft, setDraft] = useState("");
const [query, setQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [peek, setPeek] = useState(null);
const [broadcastOpen, setBroadcastOpen] = useState(false);
const [broadcastAll, setBroadcastAll] = useState(true);
const [broadcastIds, setBroadcastIds] = useState([]);
const [broadcastConfirm, setBroadcastConfirm] = useState(false);
const [broadcastBusy, setBroadcastBusy] = useState(false);
const [pinDragView, setPinDragView] = useState<{
id: number;
dy: number;
to: number;
rowH: number;
shellH: number;
phase: "drag" | "settle" | "commit";
} | null>(null);
const pinDragRef = useRef<{
id: number;
startY: number;
from: number;
rowH: number;
shellH: number;
to: number;
dy: number;
ids: number[];
minDy: number;
maxDy: number;
} | null>(null);
const pinEpoch = useRef(0);
const pinLandTimer = useRef(null);
const pendingPinOrder = useRef(null);
const sendBroadcastRef = useRef<(opts?: { attachment?: ChatAttachment | null; skipConfirm?: boolean }) => Promise>(async () => {});
const peekToken = useRef(0);
const peekArmed = useRef(false);
const [sending, setSending] = useState(false);
const [pending, setPending] = useState([]);
const [partnerTyping, setPartnerTyping] = useState(false);
const [attPref, setAttPref] = useState([]);
const [uploading, setUploading] = useState(false);
const [uploadPct, setUploadPct] = useState(0);
const [notif, setNotif] = useState<{ id: string; partner: number; name: string; body: string } | null>(null);
const [preview, setPreview] = useState(null);
const [replyTo, setReplyTo] = useState(null);
const [emojiOpen, setEmojiOpen] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [jumpDown, setJumpDown] = useState(false);
const [older, setOlder] = useState([]);
const [canLoadMore, setCanLoadMore] = useState(false);
const [historyAnchor, setHistoryAnchor] = useState(null);
const [loadingOlder, setLoadingOlder] = useState(false);
const [delArm, setDelArm] = useState(null);
const [reactId, setReactId] = useState(null);
const [heartMsg, setHeartMsg] = useState(null);
const [hearts, setHearts] = useState>({});
const [msgSearchOpen, setMsgSearchOpen] = useState(false);
const [msgQuery, setMsgQuery] = useState("");
const [matchIdx, setMatchIdx] = useState(0);
const [pinIdx, setPinIdx] = useState(0);
const [flashId, setFlashId] = useState(null);
const [editingId, setEditingId] = useState(null);
const [editDraft, setEditDraft] = useState("");
const [editAdds, setEditAdds] = useState([]);
const [editRemoved, setEditRemoved] = useState([]);
const [editReplacements, setEditReplacements] = useState>({});
const [editBusy, setEditBusy] = useState(false);
const taRef = useRef(null);
const flashTimer = useRef | null>(null);
const fileRef = useRef(null);
const photoRef = useRef(null);
const editFileRef = useRef(null);
const editPick = useRef<{ mode: "add" | "replace"; index: number }>({ mode: "add", index: 0 });
const wsRef = useRef(null);
const typingSentAt = useRef(0);
const typingClearTimer = useRef | null>(null);
const notifTimer = useRef | null>(null);
const delArmTimer = useRef | null>(null);
const heartTimer = useRef | null>(null);
const threadRef = useRef(null);
const wasNearBottomRef = useRef(true);
const suppressAutoScrollRef = useRef(false);
const jumpTargetRef = useRef(null);
const pendingDraftRef = useRef(null);
const activeRef = useRef(active);
activeRef.current = active;
const convRef = useRef([]);
const convQuery = useQuery({
queryKey: ["conversations", showArchived, me?.id],
queryFn: async () => {
type ConvPayload = { conversations: Conversation[]; mutedAll?: boolean };
const data = await http.get(
`/api/chats/conversations${showArchived ? "?archived=1" : ""}`,
);
const meId = me?.id;
if (meId == null) return data;
const rest = data.conversations.filter((c) => c.partner.id !== meId);
if (showArchived) return { ...data, conversations: rest };
let saved = data.conversations.find((c) => c.partner.id === meId);
if (!saved?.lastMessage) {
try {
const archived = await http.get("/api/chats/conversations?archived=1");
const rescued = archived.conversations.find((c) => c.partner.id === meId);
if (rescued) saved = rescued;
} catch {
/* inbox row still stands in for saved messages */
}
}
if (!saved) return { ...data, conversations: rest };
return { ...data, conversations: [{ ...saved, archived: false }, ...rest] };
},
refetchInterval: 20_000,
});
const convData = convQuery.data;
const convLoading = convQuery.isPending;
const conversations = useMemo(() => {
const list = convData?.conversations ?? [];
const meId = me?.id;
if (meId == null) return list;
const saved = list.find((c) => c.partner.id === meId);
const rest = list.filter((c) => c.partner.id !== meId);
if (showArchived || !saved) return rest;
return [{ ...saved, archived: false }, ...rest];
}, [convData?.conversations, showArchived, me?.id]);
convRef.current = conversations;
const unreadTotal = useMemo(
() => conversations.reduce((n, c) => n + (c.unreadCount > 0 ? c.unreadCount : 0), 0),
[conversations]
);
const { data: loveList } = useQuery({
queryKey: ["mentors"],
queryFn: () => http.get<{ mentors: Mentor[]; total: number }>("/api/mentors?page=1&pageSize=50"),
enabled: !active && open,
});
const messagesQuery = useQuery({
queryKey: ["chat", active],
queryFn: () =>
http.get<{ messages: ChatMessage[]; hasMore: boolean; pinned?: ChatMessage[] }>(
`/api/chats/${active}/messages`,
),
enabled: !!active,
refetchInterval: active ? 5000 : false,
});
const messages = messagesQuery.data;
const messagesLoading = !!active && messagesQuery.isPending;
const pinned = messages?.pinned ?? [];
const pinnedIds = useMemo(() => new Set(pinned.map((p) => p.id)), [pinned]);
const activePin = pinned.length ? pinned[Math.min(pinIdx, pinned.length - 1)] ?? null : null;
useEffect(() => {
setPinIdx(0);
}, [active]);
useEffect(() => {
if (pinned.length === 0) {
setPinIdx(0);
return;
}
setPinIdx((i) => (i >= pinned.length ? 0 : i));
}, [pinned.length]);
useEffect(() => {
if (messages && typeof messages.hasMore === "boolean") setCanLoadMore(messages.hasMore);
}, [messages?.hasMore, active]);
const msgs = useMemo(() => {
const all = [...older, ...(messages?.messages ?? []), ...pending];
if (!all.length) return all;
const byId = new Map();
for (const m of all) byId.set(m.id, m);
return [...byId.values()].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
}, [messages, older, pending]);
const matchIds = useMemo(() => {
const q0 = normText(msgQuery).trim();
if (!q0) return [] as number[];
return msgs
.filter(
(m) =>
(!!m.body && normText(m.body).includes(q0)) ||
messageAttachments(m).some((a) => !!a.name && normText(a.name).includes(q0))
)
.map((m) => m.id);
}, [msgs, msgQuery]);
const matchCount = matchIds.length;
const matchSet = useMemo(() => new Set(matchIds), [matchIds]);
useEffect(() => {
setMatchIdx(0);
}, [msgQuery]);
useEffect(() => {
if (!msgSearchOpen || !matchCount) return;
const id = matchIds[matchIdx];
if (id == null) return;
setHistoryAnchor(id);
}, [matchIdx, msgSearchOpen, matchCount, matchIds]);
const loadOlder = async () => {
const before = older[0]?.id ?? messages?.messages?.[0]?.id;
if (!active || !before || loadingOlder) return;
setLoadingOlder(true);
try {
const res = await http.get<{ messages: ChatMessage[]; hasMore: boolean }>(
`/api/chats/${active}/messages?before=${before}`,
);
setOlder((prev) => {
const seen = new Set(prev.map((m) => m.id));
const add = res.messages.filter((m) => !seen.has(m.id));
return [...add, ...prev];
});
setCanLoadMore(res.hasMore);
} catch {
/* ignore */
} finally {
setLoadingOlder(false);
}
};
const delMsg = async (id: number) => {
setDelArm(null);
if (delArmTimer.current) clearTimeout(delArmTimer.current);
if (!active) return;
try {
await http.del<{ ok: boolean }>(`/api/chats/${active}/messages/${id}`);
setOlder((prev) => prev.filter((m) => m.id !== id));
setPending((prev) => prev.filter((m) => m.id !== id));
qc.invalidateQueries({ queryKey: ["chat", active] });
qc.invalidateQueries({ queryKey: ["chat"] });
qc.invalidateQueries({ queryKey: ["conversations"] });
} catch {
/* ignore */
}
};
const closeEdit = () => {
setEditingId(null);
setEditDraft("");
setEditAdds([]);
setEditRemoved([]);
setEditReplacements({});
};
const saveEdit = async () => {
if (!active || editingId == null || editBusy) return;
const current = msgs.find((m) => m.id === editingId);
const original = current ? messageAttachments(current) : [];
const kept = original.filter((_, index) => !editRemoved.includes(index));
const body = editDraft.trim();
if (body.length > MAX_LEN) {
toast.error("پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد");
return;
}
if (!body && kept.length === 0 && editAdds.length === 0) {
toast.error("پیام باید متن یا پیوست داشته باشد");
return;
}
const replacements = Object.entries(editReplacements)
.filter(([index]) => !editRemoved.includes(Number(index)))
.map(([index, attachment]) => ({ index: Number(index), attachment }));
setEditBusy(true);
try {
const res = await http.put<{ message: ChatMessage }>(`/api/chats/${active}/messages/${editingId}`, {
body,
...(editAdds.length ? { addAttachments: editAdds } : {}),
...(editRemoved.length ? { removeIndexes: editRemoved } : {}),
...(replacements.length ? { replacements } : {}),
});
applyEdited(res.message);
closeEdit();
qc.invalidateQueries({ queryKey: ["conversations"] });
} catch (err) {
toast.error(toUserError(err, "ویرایش ممکن نشد"));
} finally {
setEditBusy(false);
}
};
const armDelete = (id: number) => {
if (delArm === id) {
void delMsg(id);
return;
}
setDelArm(id);
if (delArmTimer.current) clearTimeout(delArmTimer.current);
delArmTimer.current = setTimeout(() => setDelArm(null), 3000);
};
const togglePin = async (m: ChatMessage) => {
if (!active || m.id <= 0) return;
try {
await api<{ ok: boolean; message: ChatMessage }>(`/api/chats/${active}/messages/${m.id}/pin`, {
method: "POST",
});
qc.invalidateQueries({ queryKey: ["chat", active] });
} catch (e) {
toast.error((e as Error)?.message || "پین پیام ممکن نشد");
}
};
const flashMessage = (id: number) => {
const thread = threadRef.current;
const el = thread?.querySelector(`[data-mid="${id}"]`) as HTMLElement | null;
if (!thread || !el) return false;
const elRect = el.getBoundingClientRect();
const threadRect = thread.getBoundingClientRect();
const nextTop =
thread.scrollTop + (elRect.top - threadRect.top) - thread.clientHeight / 2 + el.clientHeight / 2;
thread.scrollTo({ top: Math.max(0, nextTop), behavior: "smooth" });
wasNearBottomRef.current = false;
setJumpDown(true);
setFlashId(id);
if (flashTimer.current) clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => {
setFlashId((cur) => (cur === id ? null : cur));
}, 1500);
return true;
};
const clearJumpTarget = () => {
jumpTargetRef.current = null;
window.setTimeout(() => {
suppressAutoScrollRef.current = false;
}, 500);
};
const jumpToMessage = async (id: number) => {
if (!active) return;
suppressAutoScrollRef.current = true;
jumpTargetRef.current = id;
if (flashMessage(id)) {
clearJumpTarget();
return;
}
if (msgs.some((m) => m.id === id)) return;
setLoadingOlder(true);
try {
const res = await http.get<{
messages: ChatMessage[];
hasMore?: boolean;
hasMoreBefore?: boolean;
pinned?: ChatMessage[];
}>(`/api/chats/${active}/messages?around=${id}`);
if (!res.messages?.length) {
clearJumpTarget();
toast.error("پیام پیدا نشد");
return;
}
setOlder((prev) => {
const byId = new Map();
for (const m of [...res.messages, ...prev]) byId.set(m.id, m);
return [...byId.values()].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
});
if (typeof res.hasMoreBefore === "boolean") setCanLoadMore(res.hasMoreBefore);
else if (typeof res.hasMore === "boolean") setCanLoadMore(res.hasMore);
qc.setQueryData(["chat", active], (old: typeof messages) => {
if (!old) return old;
return { ...old, pinned: res.pinned ?? old.pinned };
});
// Paint + jump handled by effect watching msgs / jumpTargetRef.
} catch {
clearJumpTarget();
toast.error("بارگذاری پیام ممکن نشد");
} finally {
setLoadingOlder(false);
}
};
const toggleReact = async (m: ChatMessage, emoji: string) => {
if (!active || m.id <= 0) return;
try {
await api<{ ok: boolean }>(`/api/chats/${active}/messages/${m.id}/reaction`, { method: "POST", body: { emoji } });
qc.invalidateQueries({ queryKey: ["chat"] });
} catch {
toast.error("ثبت واکنش ممکن نشد");
}
};
const like = (m: ChatMessage) => {
if (m.id <= 0) return;
setHearts((h) => ({ ...h, [m.id]: (h[m.id] ?? 0) + 1 }));
setHeartMsg(m.id);
if (heartTimer.current) clearTimeout(heartTimer.current);
heartTimer.current = setTimeout(() => setHeartMsg(null), 720);
void toggleReact(m, "❤️");
};
useEffect(() => {
if (!active) return;
api<{ ok: boolean }>(`/api/chats/${active}/read`, { method: "POST" })
.then(() => qc.invalidateQueries({ queryKey: ["conversations"] }))
.catch(() => undefined);
}, [active, qc]);
useEffect(() => {
if (active && threadRef.current) {
threadRef.current.scrollTop = threadRef.current.scrollHeight;
}
wasNearBottomRef.current = true;
suppressAutoScrollRef.current = false;
jumpTargetRef.current = null;
}, [active]);
useEffect(() => {
const el = threadRef.current;
if (!el) return;
if (suppressAutoScrollRef.current) return;
if (wasNearBottomRef.current || pending.length) {
el.scrollTop = el.scrollHeight;
}
}, [msgs, pending]);
useEffect(() => {
const id = jumpTargetRef.current;
if (id == null) return;
if (!msgs.some((m) => m.id === id)) return;
let cancelled = false;
const tryFlash = (attempt: number) => {
if (cancelled) return;
if (flashMessage(id)) {
clearJumpTarget();
return;
}
if (attempt < 8) {
window.setTimeout(() => tryFlash(attempt + 1), 40);
} else {
clearJumpTarget();
toast.error("پیام پیدا نشد");
}
};
requestAnimationFrame(() => tryFlash(0));
return () => {
cancelled = true;
};
}, [msgs]);
useEffect(() => {
if (!active || !me) return;
if (draft) localStorage.setItem(`mt.draft.${me.id}.${active}`, draft);
else localStorage.removeItem(`mt.draft.${me.id}.${active}`);
}, [draft, active, me]);
useEffect(() => {
setPartnerTyping(false);
return () => {
if (typingClearTimer.current) clearTimeout(typingClearTimer.current);
};
}, [active]);
useEffect(() => {
setReplyTo(null);
setEmojiOpen(false);
setOlder([]);
setCanLoadMore(false);
setHistoryAnchor(null);
setDelArm(null);
if (delArmTimer.current) clearTimeout(delArmTimer.current);
setReactId(null);
setHeartMsg(null);
setHearts({});
if (heartTimer.current) clearTimeout(heartTimer.current);
setFlashId(null);
if (flashTimer.current) clearTimeout(flashTimer.current);
setMsgSearchOpen(false);
setMsgQuery("");
setMatchIdx(0);
setEditingId(null);
setEditDraft("");
setEditAdds([]);
setEditRemoved([]);
setEditReplacements({});
if (pendingDraftRef.current != null) {
const d = pendingDraftRef.current;
pendingDraftRef.current = null;
setDraft(d);
} else {
setDraft(active ? localStorage.getItem(`mt.draft.${me?.id}.${active}`) ?? "" : "");
}
}, [active, me?.id]);
useEffect(() => {
if (open && active) {
const t = setTimeout(() => taRef.current?.focus(), 140);
return () => clearTimeout(t);
}
}, [open, active]);
useEffect(() => {
if (!open) setNotif(null);
}, [open]);
useEffect(() => {
const onOpen = (ev: Event) => {
const detail = (ev as CustomEvent<{ open?: boolean; with?: number; draft?: string }>).detail;
if (typeof detail?.draft === "string") {
pendingDraftRef.current = detail.draft;
}
if (detail?.with) {
setActive(detail.with);
} else if (typeof detail?.draft === "string") {
setDraft(detail.draft);
pendingDraftRef.current = null;
}
setOpen(detail?.open ?? true);
};
window.addEventListener("pargar:chat", onOpen);
return () => window.removeEventListener("pargar:chat", onOpen);
}, []);
useEffect(() => {
const { accessToken: token, user } = useAuth.getState();
let cancelled = false;
if (!user) return;
// Prefer bearer subprotocol; fall back to httpOnly cookie on same-origin.
const ws = token ? new WebSocket(getWsUrl(), ["bearer", token]) : new WebSocket(getWsUrl());
wsRef.current = ws;
ws.onmessage = (ev) => {
try {
const p = JSON.parse(ev.data) as { type?: string; from?: number; item?: ChatMessage };
if (p.type === "chat_message") {
qc.invalidateQueries({ queryKey: ["conversations"] });
if (p.from && activeRef.current === p.from) {
qc.invalidateQueries({ queryKey: ["chat"] });
api<{ ok: boolean }>(`/api/chats/${p.from}/read`, { method: "POST" }).catch(() => undefined);
}
const mine = p.from === useAuth.getState().user?.id;
if (!mine && !cancelled) {
const activeThread = !!activeRef.current && activeRef.current === p.from;
if (!activeThread) {
const conv = convRef.current.find((c) => c.partner.id === p.from);
if (conv && !conv.muted) {
setNotif({
id: `${Date.now()}-${p.from}`,
partner: p.from ?? 0,
name: conv.partner.name,
body: p.item ? previewOf(p.item) : "پیام جدید",
});
if (notifTimer.current) clearTimeout(notifTimer.current);
notifTimer.current = setTimeout(() => setNotif(null), 5000);
}
}
}
} else if (p.type === "chat_read") {
if (p.from && activeRef.current === p.from) qc.invalidateQueries({ queryKey: ["chat", p.from] });
qc.invalidateQueries({ queryKey: ["conversations"] });
} else if (p.type === "typing") {
if (p.from && activeRef.current === p.from) {
setPartnerTyping(true);
if (typingClearTimer.current) clearTimeout(typingClearTimer.current);
typingClearTimer.current = setTimeout(() => setPartnerTyping(false), 4500);
}
} else if (p.type === "chat_reaction") {
if (p.from && activeRef.current === p.from) qc.invalidateQueries({ queryKey: ["chat"] });
} else if (p.type === "chat_edit") {
const item = p.item;
if (item && p.from && activeRef.current === p.from) {
qc.setQueryData<{ messages: ChatMessage[]; hasMore: boolean; pinned?: ChatMessage[] }>(
["chat", activeRef.current],
(old) => {
if (!old) return old;
return {
...old,
messages: old.messages.map((m) => (m.id === item.id ? item : m)),
pinned: old.pinned?.map((m) => (m.id === item.id ? item : m)),
};
},
);
setOlder((prev) => prev.map((m) => (m.id === item.id ? item : m)));
}
qc.invalidateQueries({ queryKey: ["conversations"] });
} else if (p.type === "chat_delete") {
const deletedId = (p as { id?: number }).id;
if (deletedId) {
setOlder((prev) => prev.filter((m) => m.id !== deletedId));
setPending((prev) => prev.filter((m) => m.id !== deletedId));
}
qc.invalidateQueries({ queryKey: ["conversations"] });
qc.invalidateQueries({ queryKey: ["chat"] });
} else if (p.type === "chat_pin") {
qc.invalidateQueries({ queryKey: ["chat"] });
}
} catch {
/* ignore */
}
};
return () => {
cancelled = true;
wsRef.current = null;
ws.close();
};
}, [qc]);
const signalTyping = () => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN || !active) return;
const now = Date.now();
if (now - typingSentAt.current < 2400) return;
typingSentAt.current = now;
ws.send(JSON.stringify({ type: "typing", to: active }));
};
const partner =
conversations.find((c) => c.partner.id === active)?.partner ??
(loveList?.mentors ?? []).find((m) => m.id === active);
const send = async (text?: string, att?: ChatAttachment | ChatAttachment[] | null) => {
const body = (text ?? draft).trim();
const attachments = pickedAttachments(att, attPref);
if (!active || (!body && attachments.length === 0) || body.length > MAX_LEN || sending) return;
setSending(true);
const attachment = attachments[0];
const quoteId = replyTo?.id;
const uid = useAuth.getState().user;
const temp: ChatMessage = {
id: -Date.now(),
userId: uid?.role === "mentor" ? active : (uid?.id ?? 0),
mentorId: uid?.role === "mentor" ? (uid?.id ?? 0) : active,
senderRole: (uid?.role ?? "student") as ChatMessage["senderRole"],
body,
createdAt: new Date().toISOString(),
...(attachment ? { attachment, attachments } : {}),
...(quoteId ? { replyTo: quoteId } : {}),
};
setDraft("");
setAttPref([]);
setReplyTo(null);
setHistoryAnchor(null);
wasNearBottomRef.current = true;
setPending((p) => [...p, temp]);
try {
const res = await api<{ message: ChatMessage }>(`/api/chats/${active}/messages`, {
method: "POST",
body: {
body,
replyTo: quoteId,
...(attachment ? { attachment, attachments } : {}),
},
});
qc.setQueryData<{ messages: ChatMessage[]; hasMore: boolean }>(["chat", active], (old) => {
const list = old?.messages ?? [];
if (!list.some((x) => x.id === res.message.id)) {
return { ...old, messages: [...list, res.message] } as typeof old;
}
return old;
});
setPending([]);
qc.invalidateQueries({ queryKey: ["conversations"] });
} catch (err) {
setPending([]);
setDraft(body);
setAttPref(attachments);
setReplyTo(replyTo);
toast.error((err as Error).message || "ارسال ممکن نشد");
} finally {
setSending(false);
}
};
const pickFile = () => fileRef.current?.click();
const pickPhotos = () => photoRef.current?.click();
const uploadSelected = async (files: File[]) => {
const room = maxChatAttachments - attPref.length;
if (room <= 0) {
toast.error("در هر پیام حداکثر ۱۰ پیوست میتوان فرستاد");
return;
}
const batch = files.slice(0, room);
if (files.length > room) toast.error("در هر پیام حداکثر ۱۰ پیوست میتوان فرستاد");
const accepted = batch.filter((file) => {
if (file.size > 25 * 1024 * 1024) {
toast.error("فایل باید کمتر از ۲۵ مگابایت باشد");
return false;
}
return true;
});
if (!accepted.length) return;
setUploading(true);
setUploadPct(0);
const added: ChatAttachment[] = [];
try {
for (let i = 0; i < accepted.length; i++) {
const { attachment } = await apiFormProgress<{ attachment: ChatAttachment }>(
"/api/chats/upload",
toForm(accepted[i]),
(ratio) => setUploadPct((i + ratio) / accepted.length),
);
added.push(attachment);
}
setAttPref((prev) => [...prev, ...added].slice(0, maxChatAttachments));
setBroadcastConfirm(false);
} catch (err) {
if (added.length) setAttPref((prev) => [...prev, ...added].slice(0, maxChatAttachments));
toast.error((err as Error).message || "آپلود ممکن نشد");
} finally {
setUploading(false);
}
};
const onFile = async (e: React.ChangeEvent) => {
const files = e.target.files ? Array.from(e.target.files) : [];
e.target.value = "";
if (files.length) await uploadSelected(files);
};
const editAccept = "image/*,video/*,audio/*,.pdf,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.csv,.json";
const beginEditAdd = () => {
if (editingId == null) return;
const current = msgs.find((m) => m.id === editingId);
const original = current ? messageAttachments(current) : [];
const room = maxChatAttachments - (original.length - editRemoved.length + editAdds.length);
if (room <= 0) {
toast.error("در هر پیام حداکثر ۱۰ پیوست میتوان فرستاد");
return;
}
editPick.current = { mode: "add", index: 0 };
const input = editFileRef.current;
if (!input) return;
input.accept = editAccept;
input.multiple = true;
input.click();
};
const beginEditReplace = (index: number) => {
if (editingId == null) return;
const current = msgs.find((m) => m.id === editingId);
const att = current ? messageAttachments(current)[index] : undefined;
if (!att) return;
editPick.current = { mode: "replace", index };
const input = editFileRef.current;
if (!input) return;
input.multiple = false;
input.accept =
att.type === "image" ? "image/*" : att.type === "video" ? "video/*" : att.type === "audio" ? "audio/*" : editAccept;
input.click();
};
const onEditPick = async (e: React.ChangeEvent) => {
const files = e.target.files ? Array.from(e.target.files) : [];
e.target.value = "";
const pick = editPick.current;
if (!files.length || editingId == null) return;
const current = msgs.find((m) => m.id === editingId);
const original = current ? messageAttachments(current) : [];
const room = maxChatAttachments - (original.length - editRemoved.length + editAdds.length);
const batch = pick.mode === "replace" ? files.slice(0, 1) : files.slice(0, Math.max(room, 0));
if (pick.mode === "add" && files.length > batch.length) toast.error("در هر پیام حداکثر ۱۰ پیوست میتوان فرستاد");
const accepted = batch.filter((file) => {
if (file.size > 25 * 1024 * 1024) {
toast.error("فایل باید کمتر از ۲۵ مگابایت باشد");
return false;
}
return true;
});
if (!accepted.length) return;
setUploading(true);
setUploadPct(0);
try {
if (pick.mode === "replace") {
const { attachment } = await apiFormProgress<{ attachment: ChatAttachment }>(
"/api/chats/upload",
toForm(accepted[0]),
setUploadPct,
);
const prev = original[pick.index];
if (!prev || attachment.type !== prev.type) {
toast.error("فایل جدید باید از همان نوع باشد");
return;
}
setEditReplacements((cur) => ({ ...cur, [pick.index]: attachment }));
setEditRemoved((cur) => cur.filter((index) => index !== pick.index));
return;
}
const added: ChatAttachment[] = [];
for (let i = 0; i < accepted.length; i++) {
const { attachment } = await apiFormProgress<{ attachment: ChatAttachment }>(
"/api/chats/upload",
toForm(accepted[i]),
(ratio) => setUploadPct((i + ratio) / accepted.length),
);
added.push(attachment);
}
setEditAdds((prev) => [...prev, ...added].slice(0, maxChatAttachments));
} catch (err) {
toast.error(toUserError(err, "آپلود ممکن نشد"));
} finally {
setUploading(false);
}
};
const voiceRef = useRef<{
recorder: MediaRecorder | null;
stream: MediaStream | null;
chunks: Blob[];
timer: ReturnType;
start: number;
live?: () => void;
} | null>(null);
const [recSec, setRecSec] = useState(0);
const [recLevel, setRecLevel] = useState(0);
const [recUrl, setRecUrl] = useState(null);
const [recBlob, setRecBlob] = useState(null);
const [recMime, setRecMime] = useState(undefined);
const [recDur, setRecDur] = useState(0);
useEffect(() => {
return () => {
const v = voiceRef.current;
if (v?.recorder && v.recorder.state !== "inactive") v.recorder.stop();
v?.stream?.getTracks().forEach((t) => t.stop());
if (v?.timer) clearInterval(v.timer);
};
}, []);
useEffect(() => {
if (!preview) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setPreview(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [preview]);
const cancelVoicePreview = () => {
if (recUrl) URL.revokeObjectURL(recUrl);
setRecUrl(null);
setRecBlob(null);
setRecMime(undefined);
setRecDur(0);
};
const sendVoice = async (opts?: { broadcast?: boolean }) => {
const blob = recBlob;
const mime = recMime;
if (!blob || sending || uploading) return;
if (!opts?.broadcast && !active) return;
cancelVoicePreview();
setUploading(true);
setUploadPct(0);
try {
const { attachment } = await apiFormProgress<{ attachment: ChatAttachment }>("/api/chats/upload", toForm(blob, mime), setUploadPct);
if (opts?.broadcast) await sendBroadcastRef.current({ attachment, skipConfirm: true });
else await send("", attachment);
} catch (err) {
toast.error((err as Error).message || "ارسال صوتی ممکن نشد");
} finally {
setUploading(false);
}
};
const toggleMic = async () => {
const v = voiceRef.current;
if (v?.recorder && v.recorder.state === "recording") {
v.recorder.stop();
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mime = pickMime();
const recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
const chunks: Blob[] = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
recorder.onstop = async () => {
const blob = new Blob(chunks, { type: mime ?? "audio/webm" });
const secs = Math.round((Date.now() - (voiceRef.current?.start ?? Date.now())) / 1000);
clearInterval(voiceRef.current?.timer);
voiceRef.current?.live?.();
stream.getTracks().forEach((t) => t.stop());
setRecSec(0);
setRecLevel(0);
voiceRef.current = null;
if (secs < 1 || blob.size === 0) {
toast.error("پیام صوتی خیلی کوتاه بود");
return;
}
setRecUrl(URL.createObjectURL(blob));
setRecBlob(blob);
setRecMime(mime);
setRecDur(secs);
};
recorder.start(250);
let liveStop: (() => void) | undefined;
try {
const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (Ctx) {
const ctx = new Ctx();
const src = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.6;
src.connect(analyser);
const data = new Uint8Array(analyser.frequencyBinCount);
const liveTick = window.setInterval(() => {
analyser.getByteTimeDomainData(data);
let max = 0;
for (let i = 0; i < data.length; i++) {
const v = Math.abs(data[i] - 128) / 128;
if (v > max) max = v;
}
setRecLevel(Math.max(0.05, Math.min(1, max * 1.8)));
}, 60);
liveStop = () => {
clearInterval(liveTick);
void ctx.close();
};
}
} catch {
/* mic level meter optional */
}
voiceRef.current = {
recorder,
stream,
chunks,
timer: setInterval(() => setRecSec((s) => s + 1), 1000),
start: Date.now(),
live: liveStop,
};
} catch {
toast.error("دسترسی به میکروفون ممکن نشد");
}
};
const cancelVoice = () => {
const v = voiceRef.current;
if (v?.stream) {
v.live?.();
v.stream.getTracks().forEach((t) => t.stop());
clearInterval(v.timer);
setRecSec(0);
setRecLevel(0);
voiceRef.current = null;
}
};
useEffect(() => {
try {
localStorage.removeItem("pargar-chat-pin");
sessionStorage.removeItem("pargar-chat-unlocked");
} catch {
/* storage unavailable */
}
}, []);
const sortedConvs = useMemo(() => {
const q = query.trim().toLowerCase();
const list = [...conversations].sort((a, b) => {
if ((a.partner.id === me?.id) !== (b.partner.id === me?.id)) return a.partner.id === me?.id ? -1 : 1;
const ap = a.pinnedRank;
const bp = b.pinnedRank;
if ((ap != null) !== (bp != null)) return ap != null ? -1 : 1;
if (ap != null && bp != null && ap !== bp) return ap - bp;
const ta = a.lastMessage ? new Date(a.lastMessage.createdAt).getTime() : 0;
const tb = b.lastMessage ? new Date(b.lastMessage.createdAt).getTime() : 0;
return tb - ta;
});
if (!q) return list;
return list.filter(
(c) => c.partner.name.toLowerCase().includes(q) || (c.partner.id === me?.id && "پیامهای ذخیرهشده".includes(q)) || (c.lastMessage?.body ?? "").toLowerCase().includes(q)
);
}, [conversations, query, me?.id]);
const orderedPeople = useMemo(() => {
const list = (loveList?.mentors ?? []).filter((person) => person.id !== me?.id);
return list.slice().sort((a, b) => {
const onlineDelta = Number(!!b.online) - Number(!!a.online);
if (onlineDelta !== 0) return onlineDelta;
return a.name.localeCompare(b.name, "fa");
});
}, [loveList?.mentors, me?.id]);
const pinOrderIds = useMemo(
() => sortedConvs.filter((c) => c.pinnedRank != null && c.partner.id !== me?.id).map((c) => c.partner.id),
[sortedConvs, me?.id],
);
const rowShift = (id: number) => {
if (!pinDragView || pinDragView.phase === "commit") return 0;
const from = pinOrderIds.indexOf(pinDragView.id);
const index = pinOrderIds.indexOf(id);
if (from < 0 || index < 0) return 0;
if (id === pinDragView.id) return pinDragView.dy;
if (from < pinDragView.to && index > from && index <= pinDragView.to) return -pinDragView.rowH;
if (pinDragView.to < from && index >= pinDragView.to && index < from) return pinDragView.rowH;
return 0;
};
const persistPinOrder = async (ids: number[]) => {
const byId = new Map(conversations.map((c) => [c.partner.id, c]));
qc.setQueryData<{ conversations: Conversation[] }>(["conversations", showArchived, me?.id], (old) => {
if (!old) return old;
const rank = new Map(ids.map((id, index) => [id, index]));
return {
...old,
conversations: old.conversations.map((c) => (rank.has(c.partner.id) ? { ...c, pinnedRank: rank.get(c.partner.id)! } : c)),
};
});
try {
await Promise.all(
ids.map((id, index) => {
const row = byId.get(id);
if (!row || row.pinnedRank === index) return Promise.resolve();
return http.put(`/api/chats/${id}/prefs`, { pinnedRank: index, muted: row.muted, archived: row.archived });
}),
);
} catch (err) {
toast.error(toUserError(err, "جابهجایی سنجاق ممکن نشد"));
} finally {
qc.invalidateQueries({ queryKey: ["conversations"] });
}
};
const raisePin = (id: number) => {
const index = pinOrderIds.indexOf(id);
if (index <= 0) return;
const next = [...pinOrderIds];
const previous = next[index - 1];
next[index - 1] = next[index]!;
next[index] = previous!;
void persistPinOrder(next);
};
const onPinGesture = (row: Conversation, phase: "start" | "move" | "end" | "cancel", clientY: number) => {
if (phase === "start") {
if (pinLandTimer.current != null || pendingPinOrder.current) {
if (pinLandTimer.current != null) {
window.clearTimeout(pinLandTimer.current);
pinLandTimer.current = null;
}
pinEpoch.current += 1;
const pending = pendingPinOrder.current;
pendingPinOrder.current = null;
if (pending) void persistPinOrder(pending);
setPinDragView(null);
return;
}
const ids = pinOrderIds;
const from = ids.indexOf(row.partner.id);
if (from < 0) return;
const el = document.querySelector(`[data-conv="${row.partner.id}"]`) as HTMLElement | null;
const rect = el?.getBoundingClientRect();
const shellH = el?.offsetHeight || Math.round(rect?.height ?? 64) || 64;
let rowH = shellH + 2;
if (el && rect) {
const next = el.nextElementSibling as HTMLElement | null;
if (next) rowH = Math.max(shellH, next.getBoundingClientRect().top - rect.top);
else {
const prev = el.previousElementSibling as HTMLElement | null;
if (prev) rowH = Math.max(shellH, rect.top - prev.getBoundingClientRect().top);
}
}
const list = el?.closest(".chat-scroll") as HTMLElement | null;
const listRect = list?.getBoundingClientRect();
const minDy = rect && listRect ? Math.min(0, listRect.top + 2 - rect.top) : -2000;
const maxDy = rect && listRect ? Math.max(0, listRect.bottom - 2 - rect.bottom) : 2000;
pinDragRef.current = { id: row.partner.id, startY: clientY, from, rowH, shellH, to: from, dy: 0, ids, minDy, maxDy };
setPinDragView({ id: row.partner.id, dy: 0, to: from, rowH, shellH, phase: "drag" });
return;
}
const drag = pinDragRef.current;
if (!drag || drag.id !== row.partner.id) return;
if (phase === "move") {
const dy = Math.max(drag.minDy, Math.min(drag.maxDy, clientY - drag.startY));
const slots = Math.round(dy / drag.rowH);
const to = Math.max(0, Math.min(drag.ids.length - 1, drag.from + slots));
drag.to = to;
drag.dy = dy;
setPinDragView({ id: drag.id, dy, to, rowH: drag.rowH, shellH: drag.shellH, phase: "drag" });
return;
}
pinDragRef.current = null;
const shouldCommit = phase === "end" && drag.to !== drag.from;
const epoch = ++pinEpoch.current;
const targetDy = shouldCommit ? (drag.to - drag.from) * drag.rowH : 0;
const targetTo = shouldCommit ? drag.to : drag.from;
const nextOrder = (() => {
if (!shouldCommit) return null;
const next = [...drag.ids];
const [moved] = next.splice(drag.from, 1);
if (moved == null) return null;
next.splice(drag.to, 0, moved);
return next;
})();
const land = () => {
if (pinEpoch.current !== epoch) return;
pinLandTimer.current = null;
pendingPinOrder.current = null;
if (nextOrder) void persistPinOrder(nextOrder);
setPinDragView({ id: drag.id, dy: 0, to: drag.from, rowH: drag.rowH, shellH: drag.shellH, phase: "commit" });
requestAnimationFrame(() => {
if (pinEpoch.current !== epoch) return;
setPinDragView((cur) => (cur?.phase === "commit" ? null : cur));
});
};
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce || Math.abs(drag.dy - targetDy) < 0.5) {
land();
return;
}
pendingPinOrder.current = nextOrder;
setPinDragView({ id: drag.id, dy: drag.dy, to: targetTo, rowH: drag.rowH, shellH: drag.shellH, phase: "settle" });
requestAnimationFrame(() => {
if (pinEpoch.current !== epoch) return;
setPinDragView((cur) => (cur && cur.phase === "settle" && cur.id === drag.id ? { ...cur, dy: targetDy } : cur));
pinLandTimer.current = window.setTimeout(land, 240);
});
};
const CHAT_PAGE = 40;
const view = useMemo(() => {
if (!msgs.length) return { items: msgs, start: 0, end: 0 };
let end = msgs.length;
if (historyAnchor != null) {
const idx = msgs.findIndex((m) => m.id === historyAnchor);
if (idx >= 0) end = idx + 1;
}
const start = Math.max(0, end - CHAT_PAGE);
return { items: msgs.slice(start, end), start, end };
}, [msgs, historyAnchor]);
const thread = useMemo(() => buildThread(view.items, me?.role), [view.items, me?.role]);
useEffect(() => {
if (!msgSearchOpen || !matchCount) return;
const id = matchIds[matchIdx];
if (id == null) return;
const el = threadRef.current?.querySelector(`[data-mid="${id}"]`);
if (el) requestAnimationFrame(() => el.scrollIntoView({ block: "center", behavior: "smooth" }));
}, [matchIdx, msgSearchOpen, matchCount, matchIds, view.items]);
const showEarlier = async () => {
const first = view.items[0];
if (!first || loadingOlder) return;
wasNearBottomRef.current = false;
suppressAutoScrollRef.current = true;
if (view.start > 0) {
setHistoryAnchor(first.id);
requestAnimationFrame(() => {
threadRef.current?.querySelector(`[data-mid="${first.id}"]`)?.scrollIntoView({ block: "start" });
suppressAutoScrollRef.current = false;
});
return;
}
if (!canLoadMore) {
suppressAutoScrollRef.current = false;
return;
}
await loadOlder();
setHistoryAnchor(first.id);
requestAnimationFrame(() => {
threadRef.current?.querySelector(`[data-mid="${first.id}"]`)?.scrollIntoView({ block: "end" });
suppressAutoScrollRef.current = false;
});
};
const emptyThread = !messagesLoading && !messagesQuery.isError && msgs.length === 0;
const openProfile = (id: number) => {
setOpen(false);
router.push(`/profile/${id}`);
};
const applyEdited = (item: ChatMessage) => {
qc.setQueryData<{ messages: ChatMessage[]; hasMore: boolean; pinned?: ChatMessage[] }>(
["chat", activeRef.current],
(old) => {
if (!old) return old;
return {
...old,
messages: old.messages.map((m) => (m.id === item.id ? item : m)),
pinned: old.pinned?.map((m) => (m.id === item.id ? item : m)),
};
},
);
setOlder((prev) => prev.map((m) => (m.id === item.id ? item : m)));
};
const broadcastNames = useMemo(() => {
const people = loveList?.mentors ?? [];
return broadcastIds.flatMap((id) => {
const person = people.find((item) => item.id === id);
return person ? [person.name] : [];
});
}, [broadcastIds, loveList?.mentors]);
const broadcastTargetIds = async () => {
if (!broadcastAll) return broadcastIds.filter((id) => id !== me?.id);
const ids: number[] = [];
let page = 1;
let total = 0;
do {
const res = await http.get<{ mentors: Mentor[]; total: number }>(`/api/mentors?page=${page}&pageSize=50`);
total = res.total ?? 0;
for (const person of res.mentors ?? []) {
if (person.id !== me?.id) ids.push(person.id);
}
if ((res.mentors ?? []).length === 0 || page * 50 >= total) break;
page += 1;
} while (page <= 40);
return ids;
};
const sendBroadcast = async (opts?: { attachment?: ChatAttachment | null; attachments?: ChatAttachment[]; skipConfirm?: boolean }) => {
const body = draft.trim();
const attachments = opts?.attachments ?? pickedAttachments(opts?.attachment, attPref);
const attachment = attachments[0];
if (broadcastBusy) return;
if (!body && attachments.length === 0) return;
if (broadcastAll) {
if (!broadcastConfirm && !opts?.skipConfirm) return;
} else if (broadcastIds.length === 0) {
return;
}
setBroadcastBusy(true);
try {
if (!attachment) {
await http.post("/api/chats/broadcast", { body, all: broadcastAll, userIds: broadcastAll ? [] : broadcastIds });
} else {
const ids = await broadcastTargetIds();
for (let i = 0; i < ids.length; i += 4) {
const chunk = ids.slice(i, i + 4);
await Promise.all(
chunk.map((id) =>
api(`/api/chats/${id}/messages`, { method: "POST", body: { body, attachment, attachments } }),
),
);
}
}
setBroadcastOpen(false);
setBroadcastConfirm(false);
setBroadcastAll(true);
setBroadcastIds([]);
setDraft("");
setAttPref([]);
setEmojiOpen(false);
cancelVoicePreview();
toast.success("ارسال شد");
qc.invalidateQueries({ queryKey: ["conversations"] });
} catch (err) {
toast.error(toUserError(err, "ارسال ممکن نشد"));
} finally {
setBroadcastBusy(false);
}
};
sendBroadcastRef.current = sendBroadcast;
const armBroadcast = () => {
if (broadcastBusy || uploading || sending) return;
const ready = !!draft.trim() || attPref.length > 0 || !!recBlob;
if (!ready) return;
if (!broadcastAll && broadcastIds.length === 0) return;
if (broadcastAll && !broadcastConfirm) {
setBroadcastConfirm(true);
return;
}
if (recBlob) void sendVoice({ broadcast: true });
else void sendBroadcast();
};
const atLimit = draft.length >= MAX_LEN;
const recording = !!voiceRef.current?.recorder && voiceRef.current.recorder.state === "recording";
const firstUnread = useMemo(() => {
if (!me) return null;
for (const m of msgs) {
if (!isMineMsg(m, me.role) && !m.readAt) return m.id;
}
return null;
}, [msgs, me]);
const savedChat = !!partner && partner.id === me?.id;
const disarmPeek = () => {
peekArmed.current = false;
};
const endPeek = () => {
peekToken.current += 1;
setPeek(null);
};
const armPeek = (c: Conversation, rowName: string) => {
const token = ++peekToken.current;
const saved = c.partner.id === me?.id;
const timer = window.setTimeout(() => {
if (peekToken.current !== token) return;
peekArmed.current = true;
setPeek({ name: rowName, saved, partner: c.partner, messages: [], loading: true });
void http
.get<{ messages: ChatMessage[] }>(`/api/chats/${c.partner.id}/peek`)
.then((res) => {
if (peekToken.current !== token) return;
setPeek({ name: rowName, saved, partner: c.partner, messages: res.messages ?? [], loading: false });
})
.catch(() => {
if (peekToken.current !== token) return;
setPeek((cur) => (cur ? { ...cur, loading: false, messages: [] } : null));
});
}, 480);
return () => window.clearTimeout(timer);
};
return (
<>
{open && (
<>
{
setOpen(false);
setActive(null);
}}
className="fixed inset-0 z-[65] bg-background/35 backdrop-blur-[2px]"
/>
{broadcastOpen && !active ? (
<>
>
) : active && partner ? (
<>
{savedChat ? (
پیامهای ذخیرهشده
) : (
<>
>
)}
>
) : (
<>
>
)}
{!active && (
)}
{active && (
)}
{!active && !broadcastOpen && (
<>
>
)}
{peek && (
{peek.saved ? (
) : (
)}
{peek.name}
{!peek.saved && isVerifiedRole(peek.partner.role) && }
)}
{!active && broadcastOpen ? (
{!broadcastAll && (
{orderedPeople.map((person) => {
const picked = broadcastIds.includes(person.id);
return (
);
})}
{orderedPeople.length === 0 && (
کسی برای انتخاب نیست.
)}
)}
{broadcastAudienceLine(broadcastAll, broadcastNames)}
{broadcastAll && broadcastConfirm && (
برای همهٔ فعالها فرستاده شود؟
)}
{recording && (
{clockDur(recSec)}
)}
{
setAttPref((prev) => prev.filter((_, i) => i !== index));
setBroadcastConfirm(false);
}}
onPreview={setPreview}
/>
{emojiOpen && (
{EMOJIS.map((emoji) => (
))}
)}
{recUrl ? (
{
cancelVoicePreview();
setBroadcastConfirm(false);
}}
onSend={armBroadcast}
/>
) : (
{draft.trim() || attPref.length > 0 ? (
) : (
)}
{
setDraft(value.slice(0, MAX_LEN));
setBroadcastConfirm(false);
}}
onSend={armBroadcast}
placeholder={"\u200Fپیام خود را بنویسید…"}
disabled={broadcastBusy || uploading}
/>
)}
) : !active ? (
{composeOpen && (
شروع گفتگوی جدید
{me?.role !== "student" && (
)}
{orderedPeople.map((m) => (
))}
{orderedPeople.length === 0 && (
مخاطب مجازی برای گفتگو وجود ندارد.
)}
)}
{sortedConvs.map((c) => {
const rowName = c.partner.id === me?.id ? "پیامهای ذخیرهشده" : c.partner.name;
const pins = sortedConvs.filter((x) => x.pinnedRank != null);
const pinIndex = pinOrderIds.indexOf(c.partner.id);
const canRaise = c.pinnedRank != null && c.partner.id !== me?.id && pinIndex > 0;
return (
{
setActive(c.partner.id);
setComposeOpen(false);
}}
onRaise={canRaise ? () => raisePin(c.partner.id) : undefined}
onPinGesture={
c.pinnedRank != null && c.partner.id !== me?.id
? (phase, clientY) => onPinGesture(c, phase, clientY)
: undefined
}
onArmPeek={() => armPeek(c, rowName)}
onEndPeek={endPeek}
onDisarmPeek={disarmPeek}
heldRef={peekArmed}
/>
);
})}
{sortedConvs.length === 0 && conversations.length > 0 && (
گفتگویی یافت نشد.
)}
{convLoading && conversations.length === 0 && (
{[0, 1, 2].map((i) => (
))}
)}
{conversations.length === 0 && !composeOpen && !convLoading && (
برای شروع، روی مداد بزنید
{orderedPeople.map((m) => (
))}
)}
) : (
{msgSearchOpen && (
setMsgQuery(e.target.value)}
onKeyDown={(e) => {
if (!matchCount) return;
if (e.key === "Escape") { e.preventDefault(); setMsgSearchOpen(false); setMsgQuery(""); setMatchIdx(0); }
else if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); setMatchIdx((i) => (i + 1) % matchCount); }
else if (e.key === "Enter" && e.shiftKey) { e.preventDefault(); setMatchIdx((i) => (i - 1 + matchCount) % matchCount); }
}}
placeholder="جستجو در این گفتگو…"
dir="rtl"
autoFocus
className="h-8 min-w-0 flex-1 rounded-lg border-2 border-border bg-background/70 px-2.5 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
{msgQuery.trim() !== "" && (
{matchCount ? `${fa(matchIdx + 1)}/${fa(matchCount)}` : "پیدا نشد"}
)}
)}
{activePin && (
{pinned.length > 1 && (
)}
)}
{
const el = threadRef.current;
if (!el) return;
const near = el.scrollHeight - el.scrollTop - el.clientHeight < 140;
wasNearBottomRef.current = near;
setJumpDown(!near);
}}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const files = e.dataTransfer.files ? Array.from(e.dataTransfer.files) : [];
if (files.length) void uploadSelected(files);
}}
dir="ltr"
className="relative z-10 h-full min-h-0 space-y-1 overflow-y-auto overscroll-contain p-3 chat-scroll"
>
{dragOver && (
)}
{messagesLoading &&
}
{messagesQuery.isError && !messagesLoading && (
بارگذاری پیامها ممکن نشد
)}
{emptyThread && !savedChat && (
سلام کنید — کمک بگیرید، مشکلات را بگویید یا موفقیت را جشن بگیرید.
پاسخ کمتر از یک روز
)}
{(view.start > 0 || canLoadMore) && (
)}
{thread.map((seg, i) =>
seg.kind === "date" ? (
{seg.label}
) : (
<>
{firstUnread && seg.msgs.some((mm) => mm.id === firstUnread) && (
)}
{!seg.mine && partner && !savedChat && (
)}
{seg.msgs.map((m, idx) => {
const isLast = idx === seg.msgs.length - 1;
const atts = messageAttachments(m);
const multi = atts.length > 1;
const mediaOnly = atts.length > 0 && !m.body;
const isPending = m.id < 0;
const isGif = !multi && mediaOnly && isGifName(atts[0]);
const onlyEmoji =
!m.replyTo && atts.length === 0 && !!m.body && isSingleEmoji(m.body);
const q0 = msgQuery.trim();
const dimmed = q0 !== "" && matchCount > 0 && !matchSet.has(m.id);
const isCurrent = matchSet.has(m.id) && m.id === matchIds[matchIdx];
return (
like(m)}
onClick={(e) => {
const t = e.target as HTMLElement;
if (t.closest("button, a, input, [role='button']")) return;
setReactId((cur) => (cur === m.id ? null : m.id));
}}
className={cn(
"relative isolate group/msg max-w-full min-w-0",
!isLast && "mb-1",
dimmed && "opacity-30 saturate-50",
isCurrent && "ring-2 ring-accent rounded-xl",
)}
>
{heartMsg === m.id && reactId !== m.id && (
)}
{editingId === m.id ? (
x.id === m.replyTo) ?? null : null}
partnerName={partner?.name}
meRole={me?.role}
onlyEmoji={onlyEmoji}
draft={editDraft}
onDraft={setEditDraft}
adds={editAdds}
removed={editRemoved}
replacements={editReplacements}
busy={editBusy || uploading}
onAdd={beginEditAdd}
onReplace={beginEditReplace}
onClearReplace={(index) =>
setEditReplacements((cur) => {
const next = { ...cur };
delete next[index];
return next;
})
}
onToggleRemove={(index) =>
setEditRemoved((cur) =>
cur.includes(index) ? cur.filter((i) => i !== index) : [...cur, index],
)
}
onRemoveAdd={(index) => setEditAdds((cur) => cur.filter((_, i) => i !== index))}
onCancel={closeEdit}
onSave={() => void saveEdit()}
onPreview={setPreview}
/>
) : multi ? (
<>
{m.replyTo && (
x.id === m.replyTo) ?? null}
mine={seg.mine}
partnerName={partner?.name}
meRole={me?.role}
/>
)}
{m.body ? (
) : null}
{m.body && editingId !== m.id && (
{m.editedAt && (
ویرایششده
)}
{clock(m.createdAt)}
{seg.mine &&
(isPending ? (
) : (
))}
)}
>
) : mediaOnly && isGif ? (
) : mediaOnly ? (
<>
>
) : onlyEmoji ? (
<>
>
) : (
<>
{m.replyTo && (
x.id === m.replyTo) ?? null}
mine={seg.mine}
partnerName={partner?.name}
meRole={me?.role}
/>
)}
{m.attachment && (
)}
{m.body ? (
) : null}
{m.body && editingId !== m.id && (
{m.editedAt && (
ویرایششده
)}
{clock(m.createdAt)}
{seg.mine &&
(isPending ? (
) : (
))}
)}
>
)}
{!!m.reactions?.length && (
void toggleReact(m, em)}
/>
)}
{!!m.buttons?.length && !seg.mine && (
{m.buttons.map((b) => (
))}
)}
{editingId !== m.id && (
setReactId(reactId === m.id ? null : m.id)}
onReactEmoji={(em) => {
setReactId(null);
void toggleReact(m, em);
}}
onReply={() => setReplyTo(m)}
onSave={() => {
void http.post("/api/me/saved", {
body: m.body,
attachmentUrl: m.attachment?.url ?? "",
attachmentType: m.attachment?.type ?? "",
attachmentName: m.attachment?.name ?? "",
}).then(() => {
toast.success("در پیامهای ذخیرهشده قرار گرفت");
qc.invalidateQueries({ queryKey: ["conversations"] });
if (me) qc.invalidateQueries({ queryKey: ["chat", me.id] });
});
}}
onCopy={() => copyText(m)}
onPin={() => void togglePin(m)}
onEdit={
seg.mine && m.id > 0 && (atts.length > 0 || !!m.body)
? () => {
setReactId(null);
setEditingId(m.id);
setEditDraft(m.body ?? "");
setEditAdds([]);
setEditRemoved([]);
setEditReplacements({});
}
: undefined
}
editLabel="ویرایش پیام"
onDelete={() => armDelete(m.id)}
/>
)}
);
})}
>
)
)}
{(jumpDown || (historyAnchor != null && view.end < msgs.length)) && (
{
setHistoryAnchor(null);
wasNearBottomRef.current = true;
setJumpDown(false);
requestAnimationFrame(() => {
const el = threadRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
}}
aria-label="برو به آخرین پیام"
className="absolute bottom-3 end-3 z-30 grid h-9 w-9 place-items-center rounded-full border-2 border-primary/50 bg-card/95 text-primary shadow-soft ring-2 ring-white/40 backdrop-blur-sm transition-colors hover:border-primary hover:bg-primary hover:text-primary-foreground active:scale-90 dark:bg-[#1a1e33]/95 dark:ring-black/30"
>
)}
{partnerTyping && !savedChat && (
{partner?.name} در حال تایپ است…
)}
{emptyThread && !savedChat && (
{(me?.role === "student" ? STUDENT_SUGGESTIONS : MENTOR_SUGGESTIONS).map((q) => (
))}
)}
{recording && (
{Array.from({ length: 9 }).map((_, i) => {
const on = recLevel > (i + 1) / 9;
return (
);
})}
{clockDur(recSec)}
)}
setAttPref((prev) => prev.filter((_, i) => i !== index))}
onPreview={setPreview}
/>
{replyTo && (
در پاسخ به {isMineMsg(replyTo, me?.role) ? "خودتان" : partner?.name ?? "پیام"}
{quotePreview(replyTo)}
)}
{recUrl ? (
void sendVoice()}
/>
) : (
<>
{emojiOpen && (
ایموجی
{EMOJIS.map((e) => (
))}
)}
{draft.trim() || attPref.length > 0 ? (
) : (
)}
{
setDraft(v);
if (v.trim()) signalTyping();
}}
onSend={() => send()}
placeholder={"\u200Fپیام خود را بنویسید…"}
disabled={sending || uploading}
/>
>
)}
)}
>
)}
void onEditPick(e)} />
{preview && (
setPreview(null)}
className="fixed inset-0 z-[80] flex flex-col items-center justify-center gap-3 bg-black/85 p-4 backdrop-blur-2xl"
>
{preview.type === "image" ? (
{preview.name}
) : (
)}
{preview.type === "image" ? (
e.stopPropagation()}>
) : (
e.stopPropagation()}>
)}
{bytes(preview.size)}
)}
{notif && (
{
setActive(notif.partner);
setOpen(true);
setNotif(null);
}}
className="fixed bottom-24 start-5 end-5 z-[64] flex max-w-full items-center gap-2.5 rounded-2xl border-2 border-white/50 bg-card/85 p-2.5 text-right shadow-offset backdrop-blur-2xl sm:end-auto sm:max-w-[300px]"
>
{notif.name}
{notif.body}
)}
{me && !open && (
{unreadTotal > 0 && (
)}
setOpen(true)}
aria-label="گفتگوها"
className="press relative grid h-14 w-14 place-items-center rounded-2xl shadow-offset transition-colors bg-gradient-to-tr from-primary to-accent text-primary-foreground"
>
{unreadTotal > 0 && (
{fa(unreadTotal)}
)}
)}
>
);
}
// ---- attachments -----------------------------------------------------------
const maxChatAttachments = 10;
function messageAttachments(m: ChatMessage): ChatAttachment[] {
if (m.attachments && m.attachments.length > 0) return m.attachments;
return m.attachment ? [m.attachment] : [];
}
function pickedAttachments(
att: ChatAttachment | ChatAttachment[] | null | undefined,
pref: ChatAttachment[],
): ChatAttachment[] {
if (att === undefined) return pref;
if (att === null) return [];
return Array.isArray(att) ? att : [att];
}
function isGifName(att: ChatAttachment) {
return /\.gif($|\?)/i.test(att.name || "") || /\.gif($|\?)/i.test(att.url || "");
}
function ChatEditPane({
message,
mine,
atts,
quote,
partnerName,
meRole,
onlyEmoji,
draft,
onDraft,
adds,
removed,
replacements,
busy,
onAdd,
onReplace,
onClearReplace,
onToggleRemove,
onRemoveAdd,
onCancel,
onSave,
onPreview,
}: {
message: ChatMessage;
mine: boolean;
atts: ChatAttachment[];
quote: ChatMessage | null;
partnerName?: string;
meRole?: string;
onlyEmoji: boolean;
draft: string;
onDraft: (value: string) => void;
adds: ChatAttachment[];
removed: number[];
replacements: Record;
busy: boolean;
onAdd: () => void;
onReplace: (index: number) => void;
onClearReplace: (index: number) => void;
onToggleRemove: (index: number) => void;
onRemoveAdd: (index: number) => void;
onCancel: () => void;
onSave: () => void;
onPreview: (att: ChatAttachment) => void;
}) {
return (
<>
{message.replyTo && (
)}
{onlyEmoji && }
{atts.length > 0 && (
)}
{atts.map((att, index) => {
const gone = removed.includes(index);
const next = replacements[index];
return (
{att.name}
{gone ? (
) : (
<>
{next && (
)}
>
)}
);
})}
{adds.map((att, index) => (
افزوده: {att.name}
))}
>
);
}
function GifSticker({
att,
mine,
time,
pending,
read,
edited,
onPreview,
}: {
att: ChatAttachment;
mine: boolean;
time: string;
pending?: boolean;
read?: boolean;
edited?: boolean;
onPreview?: (att: ChatAttachment) => void;
}) {
return (
{edited && ویرایششده}
{clock(time)}
{mine &&
(pending ? (
) : (
))}
);
}
function CachedImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
const shown = useCachedObjectUrl(src);
if (!shown) {
return ;
}
return
;
}
function FileSaveButton({ att, mine, className }: { att: ChatAttachment; mine: boolean; className?: string }) {
const [pct, setPct] = useState(null);
const save = async (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (pct !== null) return;
setPct(0);
try {
await saveRemoteFile(att.url, att.name, setPct);
} catch (err) {
toast.error((err as Error).message || "دانلود ممکن نشد");
} finally {
setPct(null);
}
};
return (
{att.name}
{pct !== null ? `در حال دانلود… ${fa(Math.round(pct * 100))}٪` : bytes(att.size)}
{pct !== null && (
)}
);
}
function PendingAttachBar({
atts,
uploading,
uploadPct,
captionHint,
onRemove,
onPreview,
largePreview,
}: {
atts: ChatAttachment[];
uploading: boolean;
uploadPct: number;
captionHint: string;
onRemove: (index: number) => void;
onPreview: (att: ChatAttachment) => void;
largePreview?: boolean;
}) {
if (!uploading && atts.length === 0) return null;
const one = atts.length === 1 ? atts[0] : null;
return (
{uploading && (
)}
{uploading && atts.length === 0 ? (
در حال آپلود… {fa(Math.round(uploadPct * 100))}٪
) : one && one.type === "image" && largePreview ? (
{one.name}
{bytes(one.size)}
{captionHint}
) : one ? (
) : (
{atts.map((att, index) => (
{att.type === "image" ? (
) : (
)}
))}
{uploading ? `در حال آپلود… ${fa(Math.round(uploadPct * 100))}٪` : captionHint}
)}
);
}
function DownloadAllButton({
items,
archiveName,
mine,
overlay,
}: {
items: ChatAttachment[];
archiveName: string;
mine: boolean;
overlay?: boolean;
}) {
const [busy, setBusy] = useState(false);
return (
);
}
function IconSaveButton({ url, name, label }: { url: string; name: string; label: string }) {
const [pct, setPct] = useState(null);
return (
);
}
function PhotoSlider({
photos,
time,
edited,
read,
pending,
mine,
showTime,
onPreview,
}: {
photos: ChatAttachment[];
time: string;
edited?: boolean;
read?: boolean;
pending?: boolean;
mine: boolean;
showTime?: boolean;
onPreview?: (att: ChatAttachment) => void;
}) {
const [index, setIndex] = useState(0);
const safe = Math.min(index, Math.max(photos.length - 1, 0));
const photo = photos[safe];
if (!photo) return null;
const step = (dir: number) => (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIndex((i) => (i + dir + photos.length) % photos.length);
};
return (
{photos.length > 1 && (
<>
{fa(safe + 1)} / {fa(photos.length)}
>
)}
{photos.length > 1 && }
{showTime && (
{edited ? `ویرایششده · ${clock(time)}` : clock(time)}
{mine &&
(pending ? (
) : (
))}
)}
);
}
function MultiAttach({
atts,
mine,
time,
edited,
read,
pending,
onPreview,
inset,
showTime,
}: {
atts: ChatAttachment[];
mine: boolean;
time: string;
edited?: boolean;
read?: boolean;
pending?: boolean;
onPreview?: (att: ChatAttachment) => void;
inset?: boolean;
showTime?: boolean;
}) {
const images = atts.filter((a) => a.type === "image");
const files = atts.filter((a) => a.type === "file");
const rest = atts.filter((a) => a.type !== "image" && a.type !== "file");
const photoOnly = images.length > 1 && files.length === 0 && rest.length === 0;
return (
{images.length > 1 && (
)}
{images.length === 1 && (
)}
{(files.length > 0 || rest.length > 0) && (
0 && "px-0")}>
{rest.map((att, index) =>
att.type === "audio" ? (
) : att.type === "video" ? (
onPreview?.(att)} />
) : (
),
)}
{files.map((att, index) => (
))}
{files.length > 1 && }
)}
{showTime && !photoOnly && (
{edited && ویرایششده}
{clock(time)}
{mine &&
(pending ? (
) : (
))}
)}
);
}
function PreviewSaveButton({ url, name, cache = true }: { url: string; name: string; cache?: boolean }) {
const [pct, setPct] = useState(null);
return (
);
}
function MediaOnly({
att,
mine,
time,
edited,
read,
pending,
onPreview,
}: {
att: ChatAttachment;
mine: boolean;
time: string;
edited?: boolean;
read?: boolean;
pending?: boolean;
onPreview?: (att: ChatAttachment) => void;
}) {
const previewTimer = useRef | null>(null);
useEffect(
() => () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
},
[]
);
const openPreview = (a: ChatAttachment) => {
if (previewTimer.current) clearTimeout(previewTimer.current);
previewTimer.current = setTimeout(() => onPreview?.(a), 230);
};
const cancelPreview = () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
previewTimer.current = null;
};
const corner = mine ? "end-1.5 bottom-1.5" : "start-1.5 bottom-1.5";
const receipt = mine ? (
pending ? (
) : (
)
) : null;
const chip = (
{edited ? `ویرایششده · ${clock(time)}` : clock(time)}
{receipt}
);
if (att.type === "image") {
return (
);
}
if (att.type === "video") {
return (
onPreview?.(att)} />
{edited ? `ویرایششده · ${clock(time)}` : clock(time)}
{receipt}
);
}
if (att.type === "audio") {
return (
{edited && ویرایششده}
{clock(time)}
{mine && (pending ? : )}
);
}
return (
{clock(time)}
{mine && (pending ? : )}
);
}
function AttachmentView({
att,
mine,
time,
onPreview,
}: {
att: ChatAttachment;
mine: boolean;
time: string;
onPreview?: (att: ChatAttachment) => void;
}) {
const previewTimer = useRef | null>(null);
useEffect(
() => () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
},
[]
);
const openPreview = (a: ChatAttachment) => {
if (previewTimer.current) clearTimeout(previewTimer.current);
previewTimer.current = setTimeout(() => onPreview?.(a), 230);
};
const cancelPreview = () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
previewTimer.current = null;
};
if (att.type === "image") {
return (
{clock(time)}
);
}
if (att.type === "video") {
return (
onPreview?.(att)} />
{clock(time)}
);
}
if (att.type === "audio") {
return (
);
}
return ;
}
let activeVoice: HTMLAudioElement | null = null;
function VoiceRecPreview({
url,
dur,
sending,
onCancel,
onSend,
}: {
url: string;
dur: number;
sending: boolean;
onCancel: () => void;
onSend: () => void;
}) {
const audioRef = useRef(null);
const canvasRef = useRef(null);
const peaksRef = useRef([]);
const [playing, setPlaying] = useState(false);
const [cur, setCur] = useState(0);
const [ready, setReady] = useState(false);
useEffect(() => {
let ctx: AudioContext | null = null;
let cleanup = false;
const a = new Audio(url);
audioRef.current = a;
const onTime = () => setCur(a.currentTime);
const onEnd = () => {
setPlaying(false);
setCur(0);
};
a.addEventListener("timeupdate", onTime);
a.addEventListener("ended", onEnd);
fetch(url)
.then((r) => r.arrayBuffer())
.then((buf) => {
if (cleanup) return;
const AC =
window.AudioContext ??
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
ctx = new AC();
ctx.decodeAudioData(
buf,
(audio) => {
if (cleanup) return;
peaksRef.current = computePeaks(audio, 80);
setReady(true);
},
() => {
if (!cleanup) setReady(true);
}
);
})
.catch(() => {
if (!cleanup) setReady(true);
});
return () => {
cleanup = true;
a.pause();
a.removeEventListener("timeupdate", onTime);
a.removeEventListener("ended", onEnd);
void ctx?.close().catch(() => undefined);
};
}, [url]);
useEffect(() => {
drawWave(canvasRef.current, peaksRef.current, cur, dur);
}, [cur, dur, ready]);
const toggle = () => {
const a = audioRef.current;
if (!a) return;
if (a.paused) {
void a.play().catch(() => undefined);
setPlaying(true);
} else {
a.pause();
setPlaying(false);
}
};
const seekFromEvent = (e: React.PointerEvent) => {
const a = audioRef.current;
const cv = canvasRef.current;
if (!a || !cv || dur <= 0) return;
const rect = cv.getBoundingClientRect();
if (rect.width === 0) return;
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
a.currentTime = ratio * dur;
setCur(a.currentTime);
};
return (
{playing ? "در حال پخش…" : ""}
{clockDur(cur)} / {clockDur(dur)}
);
}
function isSingleEmoji(text: string): boolean {
const s = text.trim();
if (!s) return false;
// ZWJ sequences (e.g. family) are one grapheme — do not use string.length.
if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
const parts = [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s)];
if (parts.length !== 1) return false;
}
const RE =
/^(?:(?:\p{Regional_Indicator}){2}|\p{Extended_Pictographic}(?:\p{Emoji_Modifier}\uFE0F?|\uFE0F|\uFE0E)?(?:\u200D\p{Extended_Pictographic}(?:\p{Emoji_Modifier}\uFE0F?|\uFE0F|\uFE0E)?)*)$/u;
return RE.test(s);
}
function computePeaks(audio: AudioBuffer, buckets: number): number[] {
const ch = audio.length > 0 ? audio.getChannelData(0) : new Float32Array(0);
const step = Math.max(1, Math.floor(ch.length / buckets));
const out: number[] = [];
for (let i = 0; i < buckets; i++) {
let max = 0;
for (let j = i * step; j < Math.min((i + 1) * step, ch.length); j++) {
const v = Math.abs(ch[j]);
if (v > max) max = v;
}
out.push(Math.min(1, max * 3.6));
}
return out;
}
function drawWave(
cv: HTMLCanvasElement | null,
peaks: number[],
cur: number,
dur: number
) {
if (!cv) return;
const px = window.devicePixelRatio || 1;
const rect = cv.getBoundingClientRect();
if (rect.width < 1) return;
const w = rect.width;
const h = rect.height;
if (cv.width !== Math.round(w * px) || cv.height !== Math.round(h * px)) {
cv.width = Math.round(w * px);
cv.height = Math.round(h * px);
}
const g = cv.getContext("2d");
if (!g) return;
g.clearRect(0, 0, cv.width, cv.height);
g.scale(px, px);
const css = getComputedStyle(document.documentElement);
const playedC = css.getPropertyValue("--color-primary").trim() || "#6d63f0";
const n = Math.max(32, peaks.length || 32);
const playedRatio = dur > 0 ? Math.min(1, Math.max(0, cur / dur)) : 0;
const slot = w / n;
const bw = Math.max(2, slot * 0.62);
for (let i = 0; i < n; i++) {
const pk = peaks[i] ?? 0.22 + 0.42 * Math.abs(Math.sin(i * 1.3 + 0.5));
const bh = Math.max(3, pk * (h - 4));
const x = i * slot + (slot - bw) / 2;
const y = (h - bh) / 2;
g.fillStyle = i / n <= playedRatio ? playedC : "rgba(120,122,150,0.35)";
g.beginPath();
g.roundRect(x, y, bw, bh, 2);
g.fill();
}
g.setTransform(1, 0, 0, 1, 0, 0);
}
function stopActiveVoice() {
if (activeVoice) {
const prev = activeVoice;
activeVoice = null;
prev.pause();
}
}
function ChatVideoPlayer({
att,
onPreview,
}: {
att: ChatAttachment;
onPreview?: () => void;
}) {
const wrapRef = useRef(null);
const videoRef = useRef(null);
const [playing, setPlaying] = useState(false);
const [cur, setCur] = useState(0);
const [dur, setDur] = useState(0);
const [muted, setMuted] = useState(false);
const [buffering, setBuffering] = useState(false);
const [showUI, setShowUI] = useState(false);
const toggle = () => {
const v = videoRef.current;
if (!v) return;
if (v.paused) {
void v.play().catch(() => undefined);
} else {
v.pause();
}
};
const seek = (t: number) => {
const v = videoRef.current;
if (!v || !Number.isFinite(t)) return;
v.currentTime = t;
setCur(t);
};
const fullscreen = () => {
const el = wrapRef.current;
if (!el) return;
if (document.fullscreenElement) {
void document.exitFullscreen().catch(() => undefined);
} else {
void el.requestFullscreen().catch(() => undefined);
}
};
const prog = dur > 0 ? Math.min(cur / dur, 1) : 0;
return (
setShowUI(true)}
onMouseLeave={() => setShowUI(false)}
>
);
}
function VoicePlayer({ att, mine }: { att: ChatAttachment; mine: boolean }) {
const objectKey = mediaObjectKey(att.url);
const audioRef = useRef(null);
const liveRef = useRef<{ ctx: AudioContext; analyser: AnalyserNode } | null>(null);
const rafRef = useRef(0);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const [bars] = useState(() => voiceBars(objectKey || att.name, 44));
const [live, setLive] = useState(null);
const [downloading, setDownloading] = useState(null);
useEffect(() => {
const a = new Audio();
a.preload = "metadata";
a.src = att.url;
audioRef.current = a;
const onMeta = () => setDuration(Number.isFinite(a.duration) ? a.duration : 0);
const onTime = () => setCurrent(a.currentTime);
const onEnd = () => {
setPlaying(false);
setCurrent(0);
setLive(null);
if (activeVoice === a) activeVoice = null;
};
a.addEventListener("loadedmetadata", onMeta);
a.addEventListener("durationchange", onMeta);
a.addEventListener("timeupdate", onTime);
a.addEventListener("ended", onEnd);
return () => {
a.pause();
a.src = "";
if (activeVoice === a) activeVoice = null;
a.removeEventListener("loadedmetadata", onMeta);
a.removeEventListener("durationchange", onMeta);
a.removeEventListener("timeupdate", onTime);
a.removeEventListener("ended", onEnd);
audioRef.current = null;
liveRef.current = null;
};
// The signed query can refresh without a new recording. Playback stays on this object.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [objectKey]);
useEffect(() => {
return () => stopActiveVoice();
}, []);
const startPulse = () => {
const lr = liveRef.current;
if (!lr) return;
const analyser = lr.analyser;
const freq = new Uint8Array(analyser.frequencyBinCount);
const loop = () => {
analyser.getByteFrequencyData(freq);
const vals: number[] = [];
for (let i = 0; i < 44; i++) {
const f = freq[i] / 255;
vals.push(Math.max(0.12, Math.min(1, f * 1.5)));
}
setLive(vals);
rafRef.current = requestAnimationFrame(loop);
};
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(loop);
};
const stopPulse = () => {
cancelAnimationFrame(rafRef.current);
setLive(null);
};
const saveVoice = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (downloading !== null) return;
setDownloading(0);
void saveRemoteFile(att.url, att.name || "voice", setDownloading)
.catch(() => toast.error("دانلود صدا ممکن نشد"))
.finally(() => setDownloading(null));
};
const toggle = () => {
const a = audioRef.current;
if (!a) return;
if (playing) {
stopPulse();
a.pause();
setPlaying(false);
return;
}
stopActiveVoice();
activeVoice = a;
if (!a.src) a.src = att.url;
if (!liveRef.current) {
try {
const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (Ctx) {
const ctx = new Ctx();
const src = ctx.createMediaElementSource(a);
const analyser = ctx.createAnalyser();
analyser.fftSize = 128;
analyser.smoothingTimeConstant = 0.7;
src.connect(analyser);
analyser.connect(ctx.destination);
void ctx.resume();
liveRef.current = { ctx, analyser };
}
} catch {
/* waveform peaks optional */
}
}
void liveRef.current?.ctx.resume();
void a.play().then(
() => {
setPlaying(true);
if (liveRef.current) startPulse();
},
() => {
setPlaying(false);
toast.error("پخش صدا ممکن نشد");
}
);
};
const seek = (v: number) => {
const a = audioRef.current;
if (!a) return;
a.currentTime = v;
setCurrent(v);
};
const scrubRef = useRef(null);
const scrubbingRef = useRef(false);
const seekTo = (clientX: number) => {
if (dur <= 0) return;
const el = scrubRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
let a: HTMLAudioElement | null = null;
try {
a = audioRef.current;
} catch {
/* noop */
}
const v = ratio * dur;
if (a) {
try {
a.currentTime = v;
} catch {
/* noop */
}
}
setCurrent(v);
};
const onScrubDown = (e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
scrubbingRef.current = true;
seekTo(e.clientX);
e.currentTarget.setPointerCapture?.(e.pointerId);
};
const onScrubMove = (e: React.PointerEvent) => {
if (scrubbingRef.current) seekTo(e.clientX);
};
const endScrub = () => {
scrubbingRef.current = false;
};
const dur = Number.isFinite(duration) && duration > 0 ? duration : 0;
const progress = dur > 0 ? Math.min(current / dur, 1) : 0;
const shown = live ?? bars;
return (
{downloading !== null && (
)}
{
e.preventDefault();
e.stopPropagation();
}}
onDoubleClick={(e) => e.stopPropagation()}
className="relative flex h-8 cursor-pointer touch-none select-none items-end gap-[3px]"
>
{shown.map((h, i) => {
const isLit = playing || i / shown.length <= progress;
return (
);
})}
{dur > 0 && (
)}
{fmtDur(current)}
{downloading !== null ? `دانلود ${fa(Math.round(downloading * 100))}٪` : fmtDur(dur)}
);
}
function voiceBars(seed: string, n: number) {
let h = 2166136261;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
const bars: number[] = [];
for (let i = 0; i < n; i++) {
h = Math.imul(h ^ (h >>> 15), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
h ^= h >>> 16;
const r = (h >>> 0) / 4294967296;
const env = Math.exp(-Math.pow((i / (n - 1) - 0.5) * 2.4, 2));
bars.push(Math.max(0.12, Math.min(1, (0.35 + r * 0.65) * (0.35 + env * 0.65))));
}
return bars;
}
function fmtDur(sec: number) {
if (!Number.isFinite(sec) || sec < 0) sec = 0;
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${fa(m)}:${fa(s).padStart(2, "۰")}`;
}
function ZoomableImg({ src, alt }: { src: string; alt?: string }) {
const shown = useCachedObjectUrl(src);
const [scale, setScale] = useState(1);
const [off, setOff] = useState({ x: 0, y: 0 });
const [grabbing, setGrabbing] = useState(false);
const imgRef = useRef(null);
const pointers = useRef(new Map());
const pinchRef = useRef<{ dist: number; scale: number } | null>(null);
const scaleRef = useRef(1);
const clampScale = (s: number) => Math.min(6, Math.max(1, s));
const setSc = (s: number) => {
const v = clampScale(s);
scaleRef.current = v;
setScale(v);
};
useEffect(() => {
setSc(1);
setOff({ x: 0, y: 0 });
pointers.current.clear();
pinchRef.current = null;
setGrabbing(false);
}, [src]);
const clampOff = (x: number, y: number) => {
const el = imgRef.current;
const w = (el?.offsetWidth ?? 0) / 2;
const h = (el?.offsetHeight ?? 0) / 2;
const maxX = Math.max(0, w * (scaleRef.current - 1));
const maxY = Math.max(0, h * (scaleRef.current - 1));
return { x: Math.min(maxX, Math.max(-maxX, x)), y: Math.min(maxY, Math.max(-maxY, y)) };
};
useEffect(() => {
const el = imgRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const s = clampScale(scaleRef.current * (e.deltaY < 0 ? 1.18 : 1 / 1.18));
setSc(s);
if (s === 1) setOff({ x: 0, y: 0 });
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => Math.hypot(a.x - b.x, a.y - b.y);
const onPointerDown = (e: React.PointerEvent) => {
e.stopPropagation();
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
const m = pointers.current;
m.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (m.size === 2) {
const [a, b] = [...m.values()];
pinchRef.current = { dist: dist(a, b), scale: scaleRef.current };
} else if (m.size === 1 && scaleRef.current > 1) {
setGrabbing(true);
}
};
const onPointerMove = (e: React.PointerEvent) => {
const m = pointers.current;
if (!m.has(e.pointerId)) return;
const prev = m.get(e.pointerId)!;
if (m.size === 2) {
const [a, b] = [...m.values()];
const start = pinchRef.current;
if (start) setSc(start.scale * (dist(a, b) / start.dist));
} else if (m.size === 1 && scaleRef.current > 1) {
setOff((o) => clampOff(o.x + (e.clientX - prev.x), o.y + (e.clientY - prev.y)));
}
m.set(e.pointerId, { x: e.clientX, y: e.clientY });
};
const endPointer = (e: React.PointerEvent) => {
pointers.current.delete(e.pointerId);
pinchRef.current = null;
if (pointers.current.size === 0) setGrabbing(false);
};
const toggleZoom = () => {
if (scaleRef.current > 1) {
setSc(1);
setOff({ x: 0, y: 0 });
} else {
setSc(2.2);
}
};
return (
{
e.stopPropagation();
toggleZoom();
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endPointer}
onPointerCancel={endPointer}
style={{ transform: `translate(${off.x}px, ${off.y}px) scale(${scale})` }}
className={cn(
"max-h-[72vh] max-w-[92vw] select-none touch-none rounded-2xl object-contain shadow-offset",
scale > 1 ? (grabbing ? "cursor-grabbing" : "cursor-grab") : "cursor-zoom-in"
)}
/>
);
}
function AttThumb({ att }: { att: ChatAttachment }) {
if (att.type === "image")
return ;
if (att.type === "video")
return (
);
if (att.type === "audio")
return (
);
return (
);
}
// ---- helpers ----------------------------------------------------------------
const STUDENT_SUGGESTIONS = [
"سلام! به کمک نیاز دارم.",
"قفل شدهام — لطفاً حسابم را فعال کنید.",
"در آزمون گیر کردهام و راهنمایی میخواهم.",
];
const MENTOR_SUGGESTIONS = [
"سلام! چطور میتوانم کمکتان کنم؟",
"برای بازگشایی حساب، آزمون را دوباره امتحان کنید.",
"به پیشرفتتان افتخار میکنم — ادامه دهید.",
];
function isMentorOf(role: string) {
return role === "mentor" || role === "admin";
}
function ChatThreadSkeleton() {
return (
{[0, 1, 2, 3].map((i) => (
))}
);
}
function roleLabel(role: string) {
if (role === "mentor") return "منتور";
if (role === "admin") return "ادمین";
return "هنرجو";
}
function presenceLabel(person?: { online?: boolean; lastSeen?: string }) {
if (!person) return "آفلاین";
if (person.online) return "آنلاین";
if (person.lastSeen) return `آخرین بازدید ${formatJalaliStamp(person.lastSeen)}`;
return "آفلاین";
}
function PresenceLine({ person }: { person?: { online?: boolean; lastSeen?: string } }) {
const online = !!person?.online;
return (
{presenceLabel(person)}
);
}
function fa(n: number) {
return n.toLocaleString("fa-IR");
}
function broadcastAudienceLine(all: boolean, names: string[]): string {
if (all) return "به همهٔ فعالها میرسد";
if (names.length === 0) return "گیرندهای انتخاب نشده";
if (names.length === 1) return `به ${names[0]} میرسد`;
if (names.length === 2) return `به ${names[0]} و ${names[1]} میرسد`;
if (names.length === 3) return `به ${names[0]}، ${names[1]} و ${names[2]} میرسد`;
return `به ${fa(names.length)} نفر میرسد`;
}
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) {
const atts = messageAttachments(m);
if (atts.length > 1) {
if (m.body) return m.body;
if (atts.every((a) => a.type === "image")) return `${fa(atts.length)} عکس`;
if (atts.every((a) => a.type === "file")) return `${fa(atts.length)} فایل`;
return `${fa(atts.length)} پیوست`;
}
if (atts[0]) {
const names: Record = { image: "عکس", video: "ویدیو", audio: "پیام صوتی", file: atts[0].name };
const label = names[atts[0].type] ?? atts[0].name;
return m.body ? `${m.body}` : label;
}
return m.body;
}
function PinnedPreview({ m }: { m: ChatMessage }) {
const body = (m.body ?? "").trim();
const att = m.attachment;
const onlyEmoji = !att && !!body && isSingleEmoji(body);
const gif = att && isGifName(att);
if (onlyEmoji) {
return (
);
}
if (att?.type === "image") {
return (
{body ? (
{body}
) : (
{gif ? "GIF" : "عکس"}
)}
);
}
if (att?.type === "video") {
return (
{body || "ویدیو"}
);
}
if (att?.type === "audio") {
return (
{body || "پیام صوتی"}
);
}
if (att?.type === "file") {
return (
{body || att.name || "فایل"}
);
}
if (body) {
return (
{body}
);
}
return پیام پینشده;
}
function toForm(file: File | Blob, mime?: string) {
const fd = new FormData();
const isFile = file instanceof File;
// Prefer .weba / .m4a so audio/webm is never classified as video/webm on the server.
// Important: check webm BEFORE "opus" — MediaRecorder often reports audio/webm;codecs=opus.
let name: string;
let type: string;
if (isFile) {
name = file.name;
type = file.type || mimeForExt(file.name);
} else {
const m = (mime ?? "audio/webm").toLowerCase();
if (m.includes("webm")) {
name = `voice-${Date.now()}.weba`;
type = "audio/webm";
} else if (m.includes("mp4") || m.includes("m4a") || m.includes("aac")) {
name = `voice-${Date.now()}.m4a`;
type = "audio/mp4";
} else if (m.startsWith("audio/ogg") || m === "audio/opus" || m.startsWith("audio/opus")) {
name = `voice-${Date.now()}.ogg`;
type = "audio/ogg";
} else {
name = `voice-${Date.now()}.weba`;
type = "audio/webm";
}
}
fd.append("file", new File([file], name, { type }));
return fd;
}
function mimeForExt(name: string) {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
const map: Record = {
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", avif: "image/avif",
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", m4v: "video/mp4",
mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4", aac: "audio/aac", ogg: "audio/ogg", opus: "audio/ogg",
weba: "audio/webm",
pdf: "application/pdf", zip: "application/zip", txt: "text/plain", md: "text/markdown", csv: "text/csv", json: "application/json",
doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
};
return map[ext] ?? "application/octet-stream";
}
function pickMime() {
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
if (typeof MediaRecorder === "undefined") return "";
for (const c of candidates) {
if (MediaRecorder.isTypeSupported(c)) return c;
}
return "";
}
function SeenTicks({ read }: { read?: boolean }) {
const seen = !!read;
const label = seen ? "دیده شد" : "ارسال شد";
return (
{seen ? (
) : (
)}
{label}
);
}
function TypingDots() {
return (
{[0, 1, 2].map((i) => (
))}
);
}
function AutoTextarea({
value,
onChange,
onSend,
placeholder,
disabled,
inputRef,
}: {
value: string;
onChange: (v: string) => void;
onSend: () => void;
placeholder?: string;
disabled?: boolean;
inputRef?: React.Ref;
}) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [value]);
return (