- Leaderboard: rank-based percentile tiers (champion/gold/silver/bronze),
deterministic tie-breaking (xp DESC, user_id ASC)
- Mentor chat: md: breakpoints, safe-area-inset-bottom padding
- Learning progress: server-side progressPct, curriculum expansion safety
- Physical orders: hard-disabled with 410 Gone
- Certificate approval: pending/issued flow, admin issue/revoke endpoints,
certificate_requires_approval setting (migration 0031)
- Draft/publish: is_published on lessons and chapters, admin publish UI
(migration 0030)
- Video panel: static thumbnails (no preload), client-side pagination
- Rich editor: TipTap + tiptap-markdown for lesson descriptions,
marked + DOMPurify for SafeMarkdown rendering
5623 lines
231 KiB
TypeScript
5623 lines
231 KiB
TypeScript
"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 (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
data-conv={c.partner.id}
|
||
className="relative"
|
||
style={lifting ? { height: shellH } : undefined}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"rounded-[22px]",
|
||
lifting &&
|
||
"absolute inset-x-0 top-0 z-30 bg-white shadow-[0_16px_36px_-14px_rgba(42,39,69,0.42)] ring-1 ring-black/10 dark:bg-[#1c1b33] dark:ring-white/10",
|
||
!lifting && shiftY !== 0 && "bg-white dark:bg-[#1c1b33]",
|
||
)}
|
||
style={{
|
||
transform: `translateY(${shiftY}px)`,
|
||
transition: yTransition,
|
||
}}
|
||
>
|
||
<div className="overflow-x-hidden rounded-[22px]">
|
||
<div
|
||
className="flex"
|
||
style={{
|
||
width: `calc(100% + ${actionPx}px)`,
|
||
transform: `translateX(${rtlRef.current ? reveal : -reveal}px)`,
|
||
transition: dragging ? "none" : ROW_EASE,
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
draggable={false}
|
||
onContextMenu={(e) => e.preventDefault()}
|
||
onClick={() => {
|
||
if (skipClick.current || heldRef.current) {
|
||
skipClick.current = false;
|
||
heldRef.current = false;
|
||
return;
|
||
}
|
||
if (revealRef.current > 0) {
|
||
setShift(0);
|
||
return;
|
||
}
|
||
onOpen();
|
||
}}
|
||
onPointerDown={(e) => {
|
||
if (e.button !== 0) return;
|
||
onDisarmPeek();
|
||
const pointerId = e.pointerId;
|
||
const startX = e.clientX;
|
||
const startY = e.clientY;
|
||
const origin = revealRef.current;
|
||
const handle = e.currentTarget;
|
||
const reorderable = !!pinGestureRef.current;
|
||
let axis: "x" | "y" | null = null;
|
||
let moved = false;
|
||
let reordering = false;
|
||
const cancelTimer = onArmPeek();
|
||
setDragging(true);
|
||
let reorderArmed = e.pointerType !== "touch";
|
||
const armTimer =
|
||
reorderable && !reorderArmed
|
||
? window.setTimeout(() => {
|
||
reorderArmed = true;
|
||
}, 170)
|
||
: 0;
|
||
|
||
const finishListeners = () => {
|
||
window.removeEventListener("pointermove", move);
|
||
window.removeEventListener("pointerup", up);
|
||
window.removeEventListener("pointercancel", up);
|
||
window.removeEventListener("keydown", onKey);
|
||
if (armTimer) window.clearTimeout(armTimer);
|
||
};
|
||
const onKey = (ev: KeyboardEvent) => {
|
||
if (ev.key !== "Escape" || !reordering) return;
|
||
ev.preventDefault();
|
||
finishListeners();
|
||
cancelTimer();
|
||
onEndPeek();
|
||
setDragging(false);
|
||
pinGestureRef.current?.("cancel", startY);
|
||
skipClick.current = true;
|
||
reordering = false;
|
||
};
|
||
const move = (ev: PointerEvent) => {
|
||
if (ev.pointerId !== pointerId) return;
|
||
const dx = ev.clientX - startX;
|
||
const dy = ev.clientY - startY;
|
||
if (!axis) {
|
||
if (Math.hypot(dx, dy) < 8) return;
|
||
axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
|
||
moved = true;
|
||
if (armTimer) window.clearTimeout(armTimer);
|
||
cancelTimer();
|
||
onEndPeek();
|
||
onDisarmPeek();
|
||
if (axis === "y" && reorderable && reorderArmed && !heldRef.current) {
|
||
reordering = true;
|
||
setShift(0);
|
||
try {
|
||
handle.setPointerCapture(pointerId);
|
||
} catch {
|
||
/* window listeners still track the gesture */
|
||
}
|
||
pinGestureRef.current?.("start", startY);
|
||
}
|
||
}
|
||
if (axis === "x") {
|
||
const dir = rtlRef.current ? 1 : -1;
|
||
setShift(origin + dx * dir);
|
||
return;
|
||
}
|
||
if (!reordering) return;
|
||
ev.preventDefault();
|
||
pinGestureRef.current?.("move", ev.clientY);
|
||
};
|
||
const up = (ev: PointerEvent) => {
|
||
if (ev.pointerId !== pointerId && ev.type !== "pointercancel") return;
|
||
const abort = ev.type === "pointercancel";
|
||
const didReorder = reordering;
|
||
finishListeners();
|
||
cancelTimer();
|
||
const held = heldRef.current;
|
||
onEndPeek();
|
||
setDragging(false);
|
||
if (didReorder) pinGestureRef.current?.(abort ? "cancel" : "end", ev.clientY);
|
||
else if (axis === "x") setShift(revealRef.current > actionPx * 0.35 ? actionPx : 0);
|
||
if (moved || held || didReorder) {
|
||
skipClick.current = true;
|
||
heldRef.current = false;
|
||
const stop = (click: globalThis.MouseEvent) => {
|
||
click.preventDefault();
|
||
click.stopPropagation();
|
||
window.removeEventListener("click", stop, true);
|
||
};
|
||
window.addEventListener("click", stop, true);
|
||
window.setTimeout(() => window.removeEventListener("click", stop, true), 500);
|
||
}
|
||
};
|
||
window.addEventListener("pointermove", move, { passive: false });
|
||
window.addEventListener("pointerup", up);
|
||
window.addEventListener("pointercancel", up);
|
||
window.addEventListener("keydown", onKey);
|
||
}}
|
||
style={{ width: `calc(100% - ${actionPx}px)`, touchAction: lifting ? "none" : undefined }}
|
||
className={cn(
|
||
"flex min-w-0 touch-pan-y select-none items-center gap-3 rounded-[22px] py-1.5 pe-3 ps-2 text-start text-sm transition-colors hover:bg-white/45 dark:hover:bg-white/[0.06]",
|
||
lifting && "cursor-grabbing hover:bg-transparent dark:hover:bg-transparent",
|
||
)}
|
||
>
|
||
{saved ? (
|
||
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-primary/10 text-primary" aria-hidden>
|
||
<Bookmark className="h-5 w-5" />
|
||
</span>
|
||
) : (
|
||
<div className="relative shrink-0">
|
||
<UserAvatar name={c.partner.name} className="h-11 w-11" {...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-baseline justify-between gap-3">
|
||
<p className="flex min-w-0 items-center gap-1 font-bold">
|
||
<span className="truncate">{rowName}</span>
|
||
{!saved && isVerifiedRole(c.partner.role) && <VerifiedBadge role={c.partner.role} />}
|
||
</p>
|
||
<span className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground">
|
||
{showPin && c.pinnedRank != null && <Pin className="h-3 w-3" aria-hidden />}
|
||
{showMute && c.muted && <BellOff className="h-3 w-3" aria-hidden />}
|
||
{c.lastMessage ? formatJalaliStamp(c.lastMessage.createdAt) : ""}
|
||
</span>
|
||
</div>
|
||
<div className="mt-0.5 flex items-center gap-2">
|
||
<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>
|
||
{actionCount > 0 && (
|
||
<div className="flex shrink-0" style={{ width: actionPx }}>
|
||
{showPin && (
|
||
<button
|
||
type="button"
|
||
aria-label={c.pinnedRank != null ? "برداشتن سنجاق" : "سنجاق"}
|
||
onClick={() => savePrefs({ pinnedRank: c.pinnedRank == null ? nextPinRank : null, muted: c.muted, archived: c.archived })}
|
||
style={{ width: SWIPE_ACTION_PX }}
|
||
className="flex shrink-0 flex-col items-center justify-center gap-1 bg-gold/20 px-1 text-center text-[10px] font-extrabold leading-tight text-foreground"
|
||
>
|
||
{c.pinnedRank != null ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
|
||
{c.pinnedRank != null ? "برداشتن" : "سنجاق"}
|
||
</button>
|
||
)}
|
||
{canRaise && onRaise && (
|
||
<button
|
||
type="button"
|
||
aria-label="بالا بردن"
|
||
onClick={() => {
|
||
onRaise();
|
||
setShift(0);
|
||
}}
|
||
style={{ width: SWIPE_ACTION_PX }}
|
||
className="flex shrink-0 flex-col items-center justify-center gap-1 bg-primary/15 px-1 text-center text-[10px] font-extrabold leading-tight text-foreground"
|
||
>
|
||
<ChevronUp className="h-4 w-4" />
|
||
بالا
|
||
</button>
|
||
)}
|
||
{showMute && (
|
||
<button
|
||
type="button"
|
||
aria-label={c.muted ? "باصدا" : "بیصدا"}
|
||
onClick={() => savePrefs({ pinnedRank: c.pinnedRank, muted: !c.muted, archived: c.archived })}
|
||
style={{ width: SWIPE_ACTION_PX }}
|
||
className="flex shrink-0 flex-col items-center justify-center gap-1 bg-muted px-1 text-center text-[10px] font-extrabold leading-tight text-foreground"
|
||
>
|
||
{c.muted ? <Bell className="h-4 w-4" /> : <BellOff className="h-4 w-4" />}
|
||
{c.muted ? "باصدا" : "بیصدا"}
|
||
</button>
|
||
)}
|
||
{showArchive && (
|
||
<button
|
||
type="button"
|
||
aria-label={c.archived ? "بازگرداندن" : "آرشیو"}
|
||
onClick={() => savePrefs({ pinnedRank: c.pinnedRank, muted: c.muted, archived: !c.archived })}
|
||
style={{ width: SWIPE_ACTION_PX }}
|
||
className="flex shrink-0 flex-col items-center justify-center gap-1 bg-accent/25 px-1 text-center text-[10px] font-extrabold leading-tight text-foreground"
|
||
>
|
||
{c.archived ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
|
||
{c.archived ? "بازگرداندن" : "آرشیو"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div dir="ltr" className="flex h-full flex-col justify-end gap-1">
|
||
{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>
|
||
) : (
|
||
<div key={`s-${i}`} className="flex w-full items-end gap-1.5">
|
||
{!seg.mine && !saved && (
|
||
<UserAvatar name={partner.name} className="mb-0.5 h-7 w-7 shrink-0" {...avatarPropsOf(partner)} />
|
||
)}
|
||
<div className={cn("flex w-full min-w-0 max-w-[min(100%,20rem)] flex-col", seg.mine ? "ml-auto items-end" : "mr-auto items-start")}>
|
||
{seg.msgs.map((m, idx) => (
|
||
<PeekBubble
|
||
key={m.id}
|
||
m={m}
|
||
mine={seg.mine}
|
||
last={idx === seg.msgs.length - 1}
|
||
messages={shown}
|
||
partnerName={saved ? "پیامهای ذخیرهشده" : partner.name}
|
||
role={role}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
),
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className={cn("relative max-w-full min-w-0", !last && "mb-1")}>
|
||
<div
|
||
className={cn(
|
||
mediaOnly && !isGif
|
||
? "overflow-hidden"
|
||
: onlyEmoji || isGif
|
||
? "bg-transparent px-1.5 py-1"
|
||
: "w-fit min-w-0 max-w-full px-3.5 py-2 text-sm leading-relaxed",
|
||
!onlyEmoji &&
|
||
!isGif &&
|
||
(mine
|
||
? cn("bg-primary text-primary-foreground shadow-[0_1px_1px_rgba(0,0,0,0.12)]", last ? "rounded-[18px] rounded-tr-[6px]" : "rounded-[14px]")
|
||
: cn(
|
||
"border border-border/60 bg-card shadow-[0_1px_1px_rgba(0,0,0,0.06)]",
|
||
last ? "rounded-[18px] rounded-tl-[6px]" : "rounded-[14px]",
|
||
)),
|
||
)}
|
||
>
|
||
{multi ? (
|
||
<>
|
||
<MultiAttach atts={atts} mine={mine} time={m.createdAt} edited={!!m.editedAt} read={!!m.readAt} inset={!!m.body} showTime={!m.body} />
|
||
{m.body ? (
|
||
<p dir="auto" className="chat-bubble-text mt-1">
|
||
<HighlightBody text={m.body} q="" />
|
||
</p>
|
||
) : null}
|
||
{m.body && (
|
||
<span className={cn("mt-1 flex items-center gap-1 text-[10px] leading-none", mine ? "justify-end" : "justify-start")}>
|
||
{m.editedAt && <span className={cn("opacity-75", mine ? "text-primary-foreground" : "text-muted-foreground")}>ویرایششده</span>}
|
||
<span className={cn("opacity-75", mine ? "text-primary-foreground" : "text-muted-foreground")}>{clock(m.createdAt)}</span>
|
||
{mine && <SeenTicks read={!!m.readAt} />}
|
||
</span>
|
||
)}
|
||
</>
|
||
) : mediaOnly && isGif ? (
|
||
<GifSticker att={atts[0]} mine={mine} time={m.createdAt} read={!!m.readAt} edited={!!m.editedAt} />
|
||
) : mediaOnly ? (
|
||
<MediaOnly att={atts[0]} mine={mine} time={m.createdAt} edited={!!m.editedAt} read={!!m.readAt} />
|
||
) : 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 != null && (
|
||
<QuoteBox q={messages.find((x) => x.id === m.replyTo) ?? null} mine={mine} partnerName={partnerName} meRole={role} />
|
||
)}
|
||
{m.attachment && <AttachmentView att={m.attachment} mine={mine} time={m.createdAt} />}
|
||
{m.body ? (
|
||
<p dir="auto" className={cn("chat-bubble-text", m.attachment && "mt-1")}>
|
||
<HighlightBody text={m.body} q="" />
|
||
</p>
|
||
) : null}
|
||
{m.body && (
|
||
<span className={cn("mt-1 flex items-center gap-1 text-[10px] leading-none", mine ? "justify-end" : "justify-start")}>
|
||
{m.editedAt && <span className={cn("opacity-75", mine ? "text-primary-foreground" : "text-muted-foreground")}>ویرایششده</span>}
|
||
<span className={cn("opacity-75", mine ? "text-primary-foreground" : "text-muted-foreground")}>{clock(m.createdAt)}</span>
|
||
{mine && <SeenTicks read={!!m.readAt} />}
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
{!!m.reactions?.length && (
|
||
<div className={cn("relative z-20 -mt-1.5 w-fit", mine ? "ms-auto -me-1.5" : "me-auto -ms-1.5")}>
|
||
<ReactChips reactions={m.reactions} mine={mine} onReact={() => undefined} />
|
||
</div>
|
||
)}
|
||
</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 [showArchived, setShowArchived] = useState(false);
|
||
const [peek, setPeek] = useState<PeekPreview | null>(null);
|
||
const [broadcastOpen, setBroadcastOpen] = useState(false);
|
||
const [broadcastAll, setBroadcastAll] = useState(true);
|
||
const [broadcastIds, setBroadcastIds] = useState<number[]>([]);
|
||
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<number | null>(null);
|
||
const pendingPinOrder = useRef<number[] | null>(null);
|
||
const sendBroadcastRef = useRef<(opts?: { attachment?: ChatAttachment | null; skipConfirm?: boolean }) => Promise<void>>(async () => {});
|
||
const peekToken = useRef(0);
|
||
const peekArmed = useRef(false);
|
||
const [sending, setSending] = useState(false);
|
||
const [pending, setPending] = useState<ChatMessage[]>([]);
|
||
const [partnerTyping, setPartnerTyping] = useState(false);
|
||
const [attPref, setAttPref] = useState<ChatAttachment[]>([]);
|
||
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 [editAdds, setEditAdds] = useState<ChatAttachment[]>([]);
|
||
const [editRemoved, setEditRemoved] = useState<number[]>([]);
|
||
const [editReplacements, setEditReplacements] = useState<Record<number, ChatAttachment>>({});
|
||
const [editBusy, setEditBusy] = useState(false);
|
||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
const photoRef = useRef<HTMLInputElement>(null);
|
||
const editFileRef = useRef<HTMLInputElement>(null);
|
||
const editPick = useRef<{ mode: "add" | "replace"; index: number }>({ mode: "add", index: 0 });
|
||
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", showArchived, me?.id],
|
||
queryFn: async () => {
|
||
type ConvPayload = { conversations: Conversation[]; mutedAll?: boolean };
|
||
const data = await http.get<ConvPayload>(
|
||
`/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<ConvPayload>("/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<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)) ||
|
||
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<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("");
|
||
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<HTMLInputElement>) => {
|
||
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<HTMLInputElement>) => {
|
||
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<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 (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 (
|
||
<>
|
||
<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] md:w-[440px] dark:border-white/15 dark:bg-[#14172b]/60 dark:ring-white/10 xl:start-8 xl:w-[460px]"
|
||
>
|
||
<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">
|
||
{broadcastOpen && !active ? (
|
||
<>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-lg"
|
||
aria-label="بازگشت"
|
||
onClick={() => {
|
||
setBroadcastOpen(false);
|
||
setBroadcastConfirm(false);
|
||
}}
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-sm font-extrabold">پیام همگانی</p>
|
||
</div>
|
||
</>
|
||
) : active && partner ? (
|
||
<>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-lg" aria-label="بازگشت" onClick={() => setActive(null)}>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
{savedChat ? (
|
||
<p className="min-w-0 flex-1 truncate text-sm font-extrabold">پیامهای ذخیرهشده</p>
|
||
) : (
|
||
<>
|
||
<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="flex min-w-0 items-center gap-1 text-sm font-extrabold">
|
||
<span className="truncate">{partner.name}</span>
|
||
{isVerifiedRole(partner.role) && <VerifiedBadge role={partner.role} />}
|
||
</p>
|
||
{partnerTyping ? (
|
||
<p className="flex items-center gap-1 text-[11px] font-bold text-primary">
|
||
<TypingDots /> در حال تایپ است
|
||
</p>
|
||
) : (
|
||
<p className={cn("truncate text-[11px]", partner.online ? "font-bold text-success" : "text-muted-foreground")}>
|
||
{presenceLabel(partner)}
|
||
</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>
|
||
</>
|
||
)}
|
||
{!active && (
|
||
<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 && !broadcastOpen && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowArchived((value) => !value)}
|
||
aria-label={showArchived ? "بازگشت به گفتگوها" : "آرشیو"}
|
||
aria-pressed={showArchived}
|
||
title={showArchived ? "بازگشت به گفتگوها" : "آرشیو"}
|
||
className={cn(
|
||
"grid h-8 w-8 place-items-center rounded-lg bg-muted/60 transition-colors",
|
||
showArchived ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||
)}
|
||
>
|
||
<Archive className="h-4 w-4" />
|
||
</button>
|
||
<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>
|
||
|
||
{peek && (
|
||
<div className="pointer-events-none absolute inset-x-3 top-16 bottom-4 z-30 flex flex-col overflow-hidden rounded-[26px] border border-white/70 bg-white/75 shadow-[0_24px_70px_-24px_rgba(42,39,69,0.45)] ring-1 ring-black/5 backdrop-blur-2xl dark:border-white/15 dark:bg-[#14172b]/90 dark:ring-white/10">
|
||
<div className="flex items-center gap-2 border-b border-white/50 px-3 py-2.5 dark:border-white/10">
|
||
{peek.saved ? (
|
||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-primary/10 text-primary" aria-hidden>
|
||
<Bookmark className="h-4 w-4" />
|
||
</span>
|
||
) : (
|
||
<span className="relative shrink-0">
|
||
<UserAvatar name={peek.partner.name} className="h-8 w-8" {...avatarPropsOf(peek.partner)} />
|
||
<span
|
||
className={cn(
|
||
"absolute -bottom-0.5 -end-0.5 h-2.5 w-2.5 rounded-full border-2 border-card",
|
||
peek.partner.online ? "bg-success shadow-[0_0_6px_rgba(34,197,94,0.8)]" : "bg-muted-foreground/40",
|
||
)}
|
||
/>
|
||
</span>
|
||
)}
|
||
<p className="flex min-w-0 items-center gap-1 truncate text-sm font-extrabold">
|
||
<span className="truncate">{peek.name}</span>
|
||
{!peek.saved && isVerifiedRole(peek.partner.role) && <VerifiedBadge role={peek.partner.role} />}
|
||
</p>
|
||
</div>
|
||
<div className="chat-scroll min-h-0 flex-1 overflow-hidden p-3">
|
||
{peek.loading ? <ChatThreadSkeleton /> : <PeekThread messages={peek.messages} role={me?.role} saved={peek.saved} partner={peek.partner} />}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||
{!active && broadcastOpen ? (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
<div className="chat-scroll min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-4 pb-2 pt-4">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<button
|
||
type="button"
|
||
aria-pressed={broadcastAll}
|
||
onClick={() => {
|
||
setBroadcastAll(true);
|
||
setBroadcastConfirm(false);
|
||
}}
|
||
className={cn(
|
||
"flex min-h-[4.5rem] items-center justify-between gap-2 rounded-[22px] px-4 py-4 text-start text-sm font-extrabold transition-colors",
|
||
broadcastAll
|
||
? "bg-primary/15 text-foreground ring-2 ring-primary/45"
|
||
: "bg-white/40 text-foreground hover:bg-white/60 dark:bg-white/5 dark:hover:bg-white/10"
|
||
)}
|
||
>
|
||
همهٔ فعالها
|
||
{broadcastAll && <Check className="h-4 w-4 shrink-0 text-primary" />}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-pressed={!broadcastAll}
|
||
onClick={() => {
|
||
setBroadcastAll(false);
|
||
setBroadcastConfirm(false);
|
||
}}
|
||
className={cn(
|
||
"flex min-h-[4.5rem] items-center justify-between gap-2 rounded-[22px] px-4 py-4 text-start text-sm font-extrabold transition-colors",
|
||
!broadcastAll
|
||
? "bg-primary/15 text-foreground ring-2 ring-primary/45"
|
||
: "bg-white/40 text-foreground hover:bg-white/60 dark:bg-white/5 dark:hover:bg-white/10"
|
||
)}
|
||
>
|
||
چند نفر
|
||
{!broadcastAll && <Check className="h-4 w-4 shrink-0 text-primary" />}
|
||
</button>
|
||
</div>
|
||
{!broadcastAll && (
|
||
<div className="space-y-1">
|
||
{orderedPeople.map((person) => {
|
||
const picked = broadcastIds.includes(person.id);
|
||
return (
|
||
<button
|
||
key={person.id}
|
||
type="button"
|
||
aria-pressed={picked}
|
||
onClick={() => {
|
||
setBroadcastIds((ids) => (picked ? ids.filter((id) => id !== person.id) : [...ids, person.id]));
|
||
setBroadcastConfirm(false);
|
||
}}
|
||
className={cn(
|
||
"flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-start transition-colors",
|
||
picked ? "bg-primary/10" : "hover:bg-white/45 dark:hover:bg-white/5"
|
||
)}
|
||
>
|
||
<span
|
||
className={cn(
|
||
"grid h-5 w-5 shrink-0 place-items-center rounded-md border",
|
||
picked ? "border-primary bg-primary text-primary-foreground" : "border-foreground/20 bg-white/50 dark:bg-white/5"
|
||
)}
|
||
>
|
||
{picked && <Check className="h-3.5 w-3.5" />}
|
||
</span>
|
||
<UserAvatar name={person.name} className="h-9 w-9" {...avatarPropsOf(person)} />
|
||
<span className="min-w-0 flex-1">
|
||
<span className="flex min-w-0 items-center gap-1 text-sm font-medium">
|
||
<span className="truncate">{person.name}</span>
|
||
{isVerifiedRole(person.role) && <VerifiedBadge role={person.role} />}
|
||
</span>
|
||
<PresenceLine person={person} />
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
{orderedPeople.length === 0 && (
|
||
<p className="px-2 py-3 text-sm text-muted-foreground">کسی برای انتخاب نیست.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
<p className="truncate text-center text-xs text-muted-foreground">{broadcastAudienceLine(broadcastAll, broadcastNames)}</p>
|
||
{broadcastAll && broadcastConfirm && (
|
||
<div className="space-y-3 rounded-[22px] bg-white/45 p-4 dark:bg-white/5">
|
||
<p className="text-center text-sm font-bold">برای همهٔ فعالها فرستاده شود؟</p>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button type="button" variant="outline" size="lg" className="px-2" onClick={() => setBroadcastConfirm(false)}>
|
||
انصراف
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="lg"
|
||
className="px-2"
|
||
disabled={broadcastBusy || uploading || (!draft.trim() && attPref.length === 0 && !recBlob)}
|
||
onClick={armBroadcast}
|
||
>
|
||
{broadcastBusy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
برای همه بفرست
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="shrink-0 px-2 pb-2">
|
||
{recording && (
|
||
<div className="mb-2 flex items-center gap-2 rounded-xl bg-destructive/10 px-3 py-2">
|
||
<span className="flex-1 text-xs font-bold text-destructive tabular-nums">{clockDur(recSec)}</span>
|
||
<button type="button" onClick={cancelVoice} aria-label="انصراف از ضبط" className="text-destructive/70 hover:text-destructive">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
<button type="button" onClick={toggleMic} aria-label="پایان ضبط" className="text-destructive">
|
||
<Square className="h-4 w-4 fill-current" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
<PendingAttachBar
|
||
atts={attPref}
|
||
uploading={uploading}
|
||
uploadPct={uploadPct}
|
||
captionHint={draft.trim() ? "با همین کپشن ارسال میشود." : "بدون کپشن هم میتوانید بفرستید."}
|
||
onRemove={(index) => {
|
||
setAttPref((prev) => prev.filter((_, i) => i !== index));
|
||
setBroadcastConfirm(false);
|
||
}}
|
||
onPreview={setPreview}
|
||
/>
|
||
{emojiOpen && (
|
||
<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">
|
||
{EMOJIS.map((emoji) => (
|
||
<button
|
||
key={emoji}
|
||
type="button"
|
||
onClick={() => {
|
||
if (draft.length + emoji.length > MAX_LEN) return;
|
||
setDraft((value) => value + emoji);
|
||
setBroadcastConfirm(false);
|
||
}}
|
||
className="grid h-8 w-8 place-items-center rounded-lg transition-colors hover:bg-primary/10"
|
||
aria-label={emoji}
|
||
>
|
||
<AppleEmoji emoji={emoji} size={22} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{recUrl ? (
|
||
<VoiceRecPreview
|
||
url={recUrl}
|
||
dur={recDur}
|
||
sending={broadcastBusy || uploading}
|
||
onCancel={() => {
|
||
cancelVoicePreview();
|
||
setBroadcastConfirm(false);
|
||
}}
|
||
onSend={armBroadcast}
|
||
/>
|
||
) : (
|
||
<div className="flex items-end gap-1.5 rounded-xl border-2 border-border/70 bg-card/85 p-1.5 shadow-soft">
|
||
{draft.trim() || attPref.length > 0 ? (
|
||
<Button
|
||
onClick={armBroadcast}
|
||
disabled={broadcastBusy || uploading || sending || recording || (!broadcastAll && broadcastIds.length === 0)}
|
||
className="h-10 w-10 shrink-0 rounded-xl bg-primary text-primary-foreground"
|
||
aria-label="ارسال"
|
||
>
|
||
{broadcastBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowRight className="h-4 w-4" />}
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-10 w-10 shrink-0 rounded-xl text-muted-foreground"
|
||
onClick={toggleMic}
|
||
disabled={uploading || broadcastBusy}
|
||
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={pickPhotos} disabled={uploading || broadcastBusy || recording} aria-label="پیوست عکس">
|
||
<ImageIcon className="h-4 w-4" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" className="h-10 w-10 shrink-0 rounded-xl text-muted-foreground" onClick={pickFile} disabled={uploading || broadcastBusy || 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
|
||
value={draft}
|
||
onChange={(value) => {
|
||
setDraft(value.slice(0, MAX_LEN));
|
||
setBroadcastConfirm(false);
|
||
}}
|
||
onSend={armBroadcast}
|
||
placeholder={"\u200Fپیام خود را بنویسید…"}
|
||
disabled={broadcastBusy || 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((open) => !open)}
|
||
disabled={broadcastBusy || recording}
|
||
aria-label="ایموجی"
|
||
>
|
||
<Smile className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : !active ? (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
<div className="shrink-0 px-4 pb-1 pt-4">
|
||
<div className="relative">
|
||
<Search className="absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="جستجو در گفتگوها…"
|
||
dir="rtl"
|
||
className="h-11 w-full rounded-2xl border border-white/70 bg-white/45 ps-10 pe-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/75 focus-visible:ring-2 focus-visible:ring-ring dark:border-white/10 dark:bg-white/5"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="chat-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto overscroll-contain px-2 pb-3 pt-2">
|
||
{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>
|
||
{me?.role !== "student" && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setComposeOpen(false);
|
||
setBroadcastConfirm(false);
|
||
setBroadcastAll(true);
|
||
setBroadcastIds([]);
|
||
setDraft("");
|
||
setAttPref([]);
|
||
setBroadcastOpen(true);
|
||
}}
|
||
className="mb-1 flex w-full items-center gap-2 rounded-xl px-2 py-2 text-sm font-bold transition-colors hover:bg-primary/10"
|
||
>
|
||
<Megaphone className="h-4 w-4 text-primary" />
|
||
پیام همگانی
|
||
</button>
|
||
)}
|
||
{orderedPeople.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-start text-sm transition-colors hover:bg-primary/10"
|
||
>
|
||
<UserAvatar name={m.name} className="h-8 w-8" {...avatarPropsOf(m)} />
|
||
<span className="min-w-0 flex-1">
|
||
<span className="flex min-w-0 items-center gap-1 font-medium">
|
||
<span className="truncate">{m.name}</span>
|
||
{isVerifiedRole(m.role) && <VerifiedBadge role={m.role} />}
|
||
</span>
|
||
<PresenceLine person={m} />
|
||
</span>
|
||
</button>
|
||
))}
|
||
{orderedPeople.length === 0 && (
|
||
<p className="text-xs text-muted-foreground">مخاطب مجازی برای گفتگو وجود ندارد.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
{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 (
|
||
<ConversationRow
|
||
key={c.partner.id}
|
||
c={c}
|
||
rowName={rowName}
|
||
saved={c.partner.id === me?.id}
|
||
canRaise={canRaise}
|
||
nextPinRank={pins.length}
|
||
shiftY={rowShift(c.partner.id)}
|
||
lifting={pinDragView?.phase !== "commit" && pinDragView?.id === c.partner.id}
|
||
settling={pinDragView?.phase === "settle" && pinDragView.id === c.partner.id}
|
||
freezeMotion={pinDragView?.phase === "commit"}
|
||
shellH={pinDragView?.phase !== "commit" && pinDragView?.id === c.partner.id ? pinDragView.shellH : 0}
|
||
onOpen={() => {
|
||
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 && (
|
||
<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>
|
||
{orderedPeople.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-start text-sm transition-colors hover:bg-primary/10"
|
||
>
|
||
<UserAvatar name={m.name} className="h-7 w-7" {...avatarPropsOf(m)} />
|
||
<span className="min-w-0 flex-1">
|
||
<span className="flex min-w-0 items-center gap-1 font-medium">
|
||
<span className="truncate">{m.name}</span>
|
||
{isVerifiedRole(m.role) && <VerifiedBadge role={m.role} />}
|
||
</span>
|
||
<PresenceLine person={m} />
|
||
</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 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 && (
|
||
<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 && !savedChat && (
|
||
<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 && !savedChat && (
|
||
<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 w-full 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 w-full 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 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 (
|
||
<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 && editingId !== m.id && !isGif
|
||
? "overflow-hidden"
|
||
: onlyEmoji || isGif
|
||
? "bg-transparent px-1.5 py-1"
|
||
: "w-fit min-w-0 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",
|
||
)}
|
||
>
|
||
{editingId === m.id ? (
|
||
<ChatEditPane
|
||
message={m}
|
||
mine={seg.mine}
|
||
atts={atts}
|
||
quote={m.replyTo ? msgs.find((x) => 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 && (
|
||
<QuoteBox
|
||
q={msgs.find((x) => x.id === m.replyTo) ?? null}
|
||
mine={seg.mine}
|
||
partnerName={partner?.name}
|
||
meRole={me?.role}
|
||
/>
|
||
)}
|
||
<MultiAttach
|
||
atts={atts}
|
||
mine={seg.mine}
|
||
time={m.createdAt}
|
||
edited={!!m.editedAt}
|
||
read={!!m.readAt}
|
||
pending={isPending}
|
||
onPreview={setPreview}
|
||
inset={!!m.body}
|
||
showTime={!m.body}
|
||
/>
|
||
{m.body ? (
|
||
<p dir="auto" className="chat-bubble-text 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>
|
||
)}
|
||
</>
|
||
) : mediaOnly && isGif ? (
|
||
<GifSticker
|
||
att={atts[0]}
|
||
mine={seg.mine}
|
||
time={m.createdAt}
|
||
pending={isPending}
|
||
read={!!m.readAt}
|
||
edited={!!m.editedAt}
|
||
onPreview={setPreview}
|
||
/>
|
||
) : mediaOnly ? (
|
||
<>
|
||
<MediaOnly att={atts[0]} 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}
|
||
/>
|
||
)}
|
||
{m.body ? (
|
||
<p
|
||
dir="auto"
|
||
className={cn("chat-bubble-text", 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>
|
||
)}
|
||
{!!m.buttons?.length && !seg.mine && (
|
||
<div className="mt-1 flex flex-col gap-1">
|
||
{m.buttons.map((b) => (
|
||
<button
|
||
key={b.text}
|
||
type="button"
|
||
className="rounded-lg border px-2 py-1 text-center text-xs font-bold"
|
||
onClick={() => {
|
||
if (!active) return;
|
||
void http
|
||
.post(`/api/chats/${active}/messages`, { body: b.text })
|
||
.then(() => qc.invalidateQueries({ queryKey: ["chat", active] }));
|
||
}}
|
||
>
|
||
{b.text}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{editingId !== m.id && (
|
||
<MsgActions
|
||
mine={seg.mine}
|
||
voice={atts.length === 1 && atts[0].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)}
|
||
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)}
|
||
/>
|
||
)}
|
||
</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 && !savedChat && (
|
||
<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 && !savedChat && (
|
||
<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 pb-[env(safe-area-inset-bottom)] 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>
|
||
)}
|
||
|
||
<PendingAttachBar
|
||
atts={attPref}
|
||
uploading={uploading}
|
||
uploadPct={uploadPct}
|
||
largePreview
|
||
captionHint={draft.trim() ? "با همین کپشن ارسال میشود." : "بدون کپشن هم میتوانید بفرستید، یا پایین متن بنویسید."}
|
||
onRemove={(index) => setAttPref((prev) => prev.filter((_, i) => i !== index))}
|
||
onPreview={setPreview}
|
||
/>
|
||
|
||
<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-end 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.length > 0 ? (
|
||
<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={pickPhotos} disabled={uploading || sending || recording} aria-label="پیوست عکس">
|
||
<ImageIcon className="h-4 w-4" />
|
||
</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={"\u200Fپیام خود را بنویسید…"}
|
||
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={photoRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={onFile}
|
||
/>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="image/*,video/*,audio/*,.pdf,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.csv,.json"
|
||
multiple
|
||
className="hidden"
|
||
onChange={onFile}
|
||
/>
|
||
<input ref={editFileRef} type="file" className="hidden" onChange={(e) => void onEditPick(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 -----------------------------------------------------------
|
||
|
||
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<number, ChatAttachment>;
|
||
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 && (
|
||
<QuoteBox q={quote} mine={mine} partnerName={partnerName} meRole={meRole} />
|
||
)}
|
||
{onlyEmoji && <AppleEmoji emoji={draft || message.body || "🙂"} size={40} />}
|
||
{atts.length > 0 && (
|
||
<MultiAttach
|
||
atts={atts}
|
||
mine={mine}
|
||
time={message.createdAt}
|
||
edited={!!message.editedAt}
|
||
read={!!message.readAt}
|
||
onPreview={onPreview}
|
||
inset
|
||
showTime={false}
|
||
/>
|
||
)}
|
||
<div className="mt-2 space-y-2">
|
||
{atts.map((att, index) => {
|
||
const gone = removed.includes(index);
|
||
const next = replacements[index];
|
||
return (
|
||
<div key={`${att.url}-${index}`} className="flex items-center gap-1 text-[10px]">
|
||
<span dir="auto" className={cn("min-w-0 flex-1 truncate font-bold", gone && "line-through opacity-60")}>
|
||
{att.name}
|
||
</span>
|
||
{gone ? (
|
||
<button type="button" onClick={() => onToggleRemove(index)} className="shrink-0 rounded-full px-2 py-1 font-bold text-foreground">
|
||
برگرداندن
|
||
</button>
|
||
) : (
|
||
<>
|
||
{next && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onClearReplace(index)}
|
||
className="max-w-[7rem] truncate rounded-full px-2 py-1 font-bold text-foreground"
|
||
title="انصراف از جایگزینی"
|
||
>
|
||
با {next.name}
|
||
</button>
|
||
)}
|
||
<button type="button" onClick={() => onReplace(index)} className="shrink-0 rounded-full px-2 py-1 font-bold text-foreground">
|
||
جایگزینی
|
||
</button>
|
||
<button type="button" onClick={() => onToggleRemove(index)} className="shrink-0 rounded-full px-2 py-1 font-bold text-foreground">
|
||
حذف
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{adds.map((att, index) => (
|
||
<div key={`${att.url}-${index}`} className="flex items-center gap-1 text-[10px]">
|
||
<span dir="auto" className="min-w-0 flex-1 truncate font-bold">
|
||
افزوده: {att.name}
|
||
</span>
|
||
<button type="button" onClick={() => onRemoveAdd(index)} className="shrink-0 rounded-full px-2 py-1 font-bold text-foreground">
|
||
حذف
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button type="button" onClick={onAdd} disabled={busy} className="rounded-full bg-foreground/10 px-2 py-1 text-[10px] font-bold text-foreground disabled:opacity-50">
|
||
افزودن پیوست
|
||
</button>
|
||
<textarea
|
||
value={draft}
|
||
onChange={(e) => onDraft(e.target.value)}
|
||
dir="auto"
|
||
rows={3}
|
||
maxLength={MAX_LEN}
|
||
placeholder="کپشن یا متن…"
|
||
className="chat-composer 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={onCancel} className="rounded-full px-2 py-1 text-[10px] font-bold text-foreground">
|
||
انصراف
|
||
</button>
|
||
<button type="button" disabled={busy} onClick={onSave} className="rounded-full bg-foreground/10 px-2 py-1 text-[10px] font-bold text-foreground disabled:opacity-50">
|
||
{busy ? "…" : "ذخیره"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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 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 (
|
||
<div className="relative mb-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 && atts.length === 0 ? (
|
||
<div className="flex items-center gap-2">
|
||
<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>
|
||
</div>
|
||
) : one && one.type === "image" && largePreview ? (
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => onPreview(one)}
|
||
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={one.url} alt={one.name} className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" />
|
||
</button>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-xs font-bold">{one.name}</p>
|
||
<p className="text-[10px] text-muted-foreground">{bytes(one.size)}</p>
|
||
<p className="text-[10px] text-muted-foreground">{captionHint}</p>
|
||
</div>
|
||
<button type="button" onClick={() => onRemove(0)} aria-label="حذف ضمیمه" className="text-muted-foreground hover:text-destructive">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
) : one ? (
|
||
<div className="flex items-center gap-2">
|
||
<AttThumb att={one} />
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-xs font-bold">{one.name}</p>
|
||
<p className="text-[10px] text-muted-foreground">{captionHint}</p>
|
||
</div>
|
||
<button type="button" onClick={() => onRemove(0)} aria-label="حذف ضمیمه" className="text-muted-foreground hover:text-destructive">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<div className="flex gap-2 overflow-x-auto">
|
||
{atts.map((att, index) => (
|
||
<div key={`${att.url}-${index}`} className="relative shrink-0">
|
||
{att.type === "image" ? (
|
||
<button type="button" onClick={() => onPreview(att)} aria-label={att.name} className="block h-16 w-16 overflow-hidden rounded-lg">
|
||
<CachedImg src={att.url} alt={att.name} className="h-full w-full object-cover" />
|
||
</button>
|
||
) : (
|
||
<div className="flex h-16 w-28 items-center gap-1 rounded-lg bg-background/70 px-2">
|
||
<AttThumb att={att} />
|
||
<span className="min-w-0 truncate text-[10px] font-bold" dir="auto">{att.name}</span>
|
||
</div>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemove(index)}
|
||
aria-label={`حذف ${att.name}`}
|
||
className="absolute -start-1 -top-1 grid h-5 w-5 place-items-center rounded-full bg-background text-muted-foreground shadow-soft hover:text-destructive"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
{uploading ? `در حال آپلود… ${fa(Math.round(uploadPct * 100))}٪` : captionHint}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DownloadAllButton({
|
||
items,
|
||
archiveName,
|
||
mine,
|
||
overlay,
|
||
}: {
|
||
items: ChatAttachment[];
|
||
archiveName: string;
|
||
mine: boolean;
|
||
overlay?: boolean;
|
||
}) {
|
||
const [busy, setBusy] = useState(false);
|
||
return (
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
aria-label="دانلود همه"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (busy) return;
|
||
setBusy(true);
|
||
void downloadAllAsZip(
|
||
items.map((item) => ({ url: item.url, name: item.name })),
|
||
archiveName,
|
||
)
|
||
.catch((err) => toast.error((err as Error).message || "دانلود ممکن نشد"))
|
||
.finally(() => setBusy(false));
|
||
}}
|
||
className={cn(
|
||
"inline-flex items-center justify-center gap-1 font-bold disabled:opacity-70",
|
||
overlay
|
||
? "rounded-full bg-black/55 px-2 py-1 text-[10px] text-white backdrop-blur-sm"
|
||
: cn(
|
||
"w-full rounded-xl px-3 py-1.5 text-[11px]",
|
||
mine ? "bg-primary-foreground/15 text-primary-foreground" : "border border-border/50 bg-background/50",
|
||
),
|
||
)}
|
||
>
|
||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
|
||
دانلود همه
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function IconSaveButton({ url, name, label }: { url: string; name: string; label: string }) {
|
||
const [pct, setPct] = useState<number | null>(null);
|
||
return (
|
||
<button
|
||
type="button"
|
||
aria-label={label}
|
||
title={label}
|
||
disabled={pct !== null}
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (pct !== null) return;
|
||
setPct(0);
|
||
void saveRemoteFile(url, name, setPct)
|
||
.catch((err) => toast.error((err as Error).message || "دانلود ممکن نشد"))
|
||
.finally(() => setPct(null));
|
||
}}
|
||
className="grid h-7 w-7 place-items-center rounded-full bg-black/55 text-white backdrop-blur-sm disabled:opacity-70"
|
||
>
|
||
{pct !== null ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => onPreview?.(photo)}
|
||
className="block w-full cursor-zoom-in"
|
||
aria-label={`باز کردن ${photo.name}`}
|
||
>
|
||
<CachedImg src={photo.url} alt={photo.name} className="max-h-72 w-full object-cover" />
|
||
</button>
|
||
{photos.length > 1 && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={step(-1)}
|
||
aria-label="عکس قبلی"
|
||
className="absolute start-1.5 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center rounded-full bg-black/55 text-white backdrop-blur-sm"
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={step(1)}
|
||
aria-label="عکس بعدی"
|
||
className="absolute end-1.5 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center rounded-full bg-black/55 text-white backdrop-blur-sm"
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</button>
|
||
<span className="absolute start-1/2 top-1.5 -translate-x-1/2 rounded-full bg-black/55 px-2 py-0.5 text-[10px] font-bold text-white backdrop-blur-sm">
|
||
{fa(safe + 1)} / {fa(photos.length)}
|
||
</span>
|
||
</>
|
||
)}
|
||
<span className="absolute inset-x-1.5 bottom-1.5 flex items-end justify-between gap-1">
|
||
<span className="flex items-center gap-1">
|
||
<IconSaveButton url={photo.url} name={photo.name} label="دانلود این عکس" />
|
||
{photos.length > 1 && <DownloadAllButton items={photos} archiveName="pargar-photos.zip" mine={mine} overlay />}
|
||
</span>
|
||
{showTime && (
|
||
<span className="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">
|
||
<span>{edited ? `ویرایششده · ${clock(time)}` : clock(time)}</span>
|
||
{mine &&
|
||
(pending ? (
|
||
<Clock className="h-3 w-3 animate-spin text-white/80 [animation-duration:1.6s]" />
|
||
) : (
|
||
<SeenTicks read={read} />
|
||
))}
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div>
|
||
{images.length > 1 && (
|
||
<div className={cn(inset && "-mx-3.5 -mt-3.5 mb-1.5 overflow-hidden rounded-t-2xl", inset && (mine ? "rounded-l-2xl" : "rounded-r-2xl"))}>
|
||
<PhotoSlider
|
||
photos={images}
|
||
mine={mine}
|
||
time={time}
|
||
edited={edited}
|
||
read={read}
|
||
pending={pending}
|
||
showTime={showTime && photoOnly}
|
||
onPreview={onPreview}
|
||
/>
|
||
</div>
|
||
)}
|
||
{images.length === 1 && (
|
||
<div className={cn("relative", inset && "-mx-3.5 -mt-3.5 mb-1.5 overflow-hidden rounded-t-2xl", inset && (mine ? "rounded-l-2xl" : "rounded-r-2xl"))}>
|
||
<button type="button" onClick={() => onPreview?.(images[0])} className="block w-full" aria-label={`باز کردن ${images[0].name}`}>
|
||
<CachedImg src={images[0].url} alt={images[0].name} className="max-h-72 w-full object-cover" />
|
||
</button>
|
||
<span className="absolute bottom-1.5 start-1.5">
|
||
<IconSaveButton url={images[0].url} name={images[0].name} label="دانلود این عکس" />
|
||
</span>
|
||
</div>
|
||
)}
|
||
{(files.length > 0 || rest.length > 0) && (
|
||
<div className={cn("space-y-1.5", !inset && "px-3 py-2", inset && images.length > 0 && "px-0")}>
|
||
{rest.map((att, index) =>
|
||
att.type === "audio" ? (
|
||
<VoicePlayer key={`${att.url}-${index}`} att={att} mine={mine} />
|
||
) : att.type === "video" ? (
|
||
<ChatVideoPlayer key={`${att.url}-${index}`} att={att} onPreview={() => onPreview?.(att)} />
|
||
) : (
|
||
<FileSaveButton key={`${att.url}-${index}`} att={att} mine={mine} />
|
||
),
|
||
)}
|
||
{files.map((att, index) => (
|
||
<FileSaveButton key={`${att.url}-${index}`} att={att} mine={mine} />
|
||
))}
|
||
{files.length > 1 && <DownloadAllButton items={files} archiveName="pargar-files.zip" mine={mine} />}
|
||
</div>
|
||
)}
|
||
{showTime && !photoOnly && (
|
||
<span className={cn("mt-1 flex items-center gap-1 px-3 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>
|
||
);
|
||
}
|
||
|
||
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 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 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 (
|
||
<span className={cn("block h-4 truncate text-[10px] leading-4", online ? "font-bold text-success" : "text-muted-foreground")}>
|
||
{presenceLabel(person)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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<string, string> = { 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 (
|
||
<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 = `${el.scrollHeight}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="chat-composer my-1 min-h-10 w-full min-w-0 resize-none bg-transparent px-1.5 py-2 text-sm leading-7 text-foreground outline-none 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;
|
||
const atts = messageAttachments(m);
|
||
if (atts.length > 1) {
|
||
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]) return "پیام";
|
||
switch (atts[0].type) {
|
||
case "image":
|
||
return "📷 تصویر";
|
||
case "video":
|
||
return "🎬 ویدیو";
|
||
case "audio":
|
||
return "🎙️ پیام صوتی";
|
||
default:
|
||
return `📄 ${atts[0].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,
|
||
onSave,
|
||
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;
|
||
onSave?: () => 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>
|
||
{onSave && (
|
||
<button
|
||
onClick={onSave}
|
||
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"
|
||
>
|
||
<Bookmark className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
<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 };
|
||
} |