"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Send, Users, Crown, LogOut, Settings, Trash2, UserX, UserPlus, X, Undo2, Reply, Copy, AtSign, } from "lucide-react"; import { apiListChatMessages, apiSendChatMessage, apiMarkChatRead, apiListChatMembers, apiLeaveChatRoom, apiDissolveChatRoom, apiUpdateChatRoom, apiKickChatMember, apiInviteChatMembers, apiRecallChatMessage, type ChatMessage, type ChatRoomMember, type User, } from "@/lib/api"; import { realtime, RT_CHAT_MESSAGE, RT_CHAT_MEMBERSHIP, RT_CHAT_RECALLED, } from "@/lib/realtime"; import type { RtChatMessageData, RtChatMembershipData, RtChatRecalledData, } from "@/lib/realtime"; import { canOverseeChat } from "@/lib/roles"; import { toast } from "@/lib/toast"; import Avatar from "@/components/Avatar"; import ConfirmDialog from "@/components/ConfirmDialog"; import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu"; /** 同一房间已读:进行中去重 + 2s 内合并(滚动/新消息/Strict Mode 连打) */ const MARK_READ_GAP_MS = 2_000; const markReadInflight = new Set(); const markReadLastAt = new Map(); function requestMarkChatRead(roomId: number): void { const now = Date.now(); const last = markReadLastAt.get(roomId) || 0; if (markReadInflight.has(roomId) || now - last < MARK_READ_GAP_MS) return; markReadInflight.add(roomId); markReadLastAt.set(roomId, now); apiMarkChatRead(roomId, 0) .then(() => { window.dispatchEvent(new Event("j13-chat-unread-sync")); }) .catch(() => {}) .finally(() => { markReadInflight.delete(roomId); }); } /** 成员列表:并发去重(进房 effect 与面板刷新) */ const membersInflight = new Set(); type RightPanel = null | "members" | "settings" | "invite"; type ConfirmState = { title: string; message: string; confirmLabel?: string; danger?: boolean; action: () => Promise | void; } | null; /** 撤回提示:本人 / 他人撤回 / 管理员代撤 */ function RecallNotice({ message, meId, members, }: { message: ChatMessage; meId: number; members: ChatRoomMember[]; }) { if (!message.recalled_at) return null; const by = message.recalled_by || message.recaller?.id || message.sender_id; // 操作者不是原发送者 → 监管代撤 const adminRecall = !!(by && by !== message.sender_id); if (by === meId) { return ( {adminRecall ? "你撤回了一条成员消息" : "你撤回了一条消息"} ); } let recaller: User | null | undefined = message.recaller && message.recaller.id ? message.recaller : by === message.sender_id ? message.sender : null; if ((!recaller || !recaller.id) && by > 0) { const hit = members.find((x) => x.user_id === by); if (hit) recaller = hit.user; } const name = (recaller?.nickname || recaller?.username || "").trim() || "有人"; const uid = recaller?.id || by; const nameStyle = { color: "color-mix(in srgb, var(--accent) 72%, var(--ink))", } as const; const nameNode = uid > 0 ? ( {name} ) : ( {name} ); if (adminRecall) { return ( 管理员 {nameNode} 撤回了一条成员消息 ); } return ( {nameNode} 撤回了一条消息 ); } /** 撤回后「重新编辑」有效窗口(接近微信体验) */ const RE_EDIT_MS = 2 * 60 * 1000; interface Props { roomId: number; roomName: string; roomDescription: string; ownerId: number; memberCount: number; myRole: "" | "owner" | "member" | "overseer"; joined: boolean; isPrivate: boolean; isDefault: boolean; roomType: "group" | "direct"; peer: { id: number; username: string; nickname: string; avatar: string } | null; meId: number; meRole: string; meName: string; meAvatar: string; initialMessages: ChatMessage[]; initialHasMore: boolean; /** 嵌入 IM 壳时铺满右栏,不自带外层卡片高度 */ embedded?: boolean; } function formatTime(dateStr: string) { const d = new Date(dateStr); const now = new Date(); const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); const hm = `${String(d.getHours()).padStart(2, "0")}:${String( d.getMinutes() ).padStart(2, "0")}`; if (sameDay) return hm; const md = `${d.getMonth() + 1}/${d.getDate()}`; return `${md} ${hm}`; } function highlightMentions(content: string, members: ChatRoomMember[]) { const byUsername = new Map( members.map((m) => [m.user.username.toLowerCase(), m.user]) ); const parts: React.ReactNode[] = []; const re = /@([A-Za-z0-9_-]{3,32})/g; let last = 0; let m: RegExpExecArray | null; let i = 0; while ((m = re.exec(content)) !== null) { if (m.index > last) parts.push(content.slice(last, m.index)); const user = byUsername.get(m[1].toLowerCase()); parts.push( user ? ( e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()} > @{m[1]} ) : ( @{m[1]} ) ); last = m.index + m[0].length; i++; } if (last < content.length) parts.push(content.slice(last)); return parts; } /** 输入区镜像层:已确认的 @ 显示为飞书风 token */ function renderComposerMirror(content: string, tokens: Set) { if (!content) return null; const parts: React.ReactNode[] = []; const re = /@([A-Za-z0-9_-]{3,32})/g; let last = 0; let m: RegExpExecArray | null; let i = 0; while ((m = re.exec(content)) !== null) { if (m.index > last) parts.push(content.slice(last, m.index)); const key = m[1].toLowerCase(); parts.push( tokens.has(key) ? ( @{m[1]} ) : ( @{m[1]} ) ); last = m.index + m[0].length; i++; } if (last < content.length) parts.push(content.slice(last)); // 末尾换行在 pre-wrap 镜像里需要占位,否则高度会少一行 if (content.endsWith("\n")) parts.push("\n"); return parts; } /** 光标前是否紧贴一个已确认的 @token(含可选尾随空格) */ function mentionTokenBefore( text: string, cursor: number, tokens: Set ): { start: number; end: number } | null { const before = text.slice(0, cursor); const m = before.match(/@([A-Za-z0-9_-]{3,32})( ?)$/); if (!m || !tokens.has(m[1].toLowerCase())) return null; return { start: cursor - m[0].length, end: cursor }; } /** 光标后是否紧跟一个已确认的 @token(含可选尾随空格) */ function mentionTokenAfter( text: string, cursor: number, tokens: Set ): { start: number; end: number } | null { const after = text.slice(cursor); const m = after.match(/^@([A-Za-z0-9_-]{3,32})( ?)/); if (!m || !tokens.has(m[1].toLowerCase())) return null; return { start: cursor, end: cursor + m[0].length }; } export default function ChatRoomClient({ roomId, roomName, roomDescription, ownerId, memberCount: initialMemberCount, myRole, joined, isPrivate, isDefault, roomType, peer, meId, meRole, initialMessages, initialHasMore, embedded = false, }: Props) { const router = useRouter(); const [messages, setMessages] = useState(initialMessages); const [hasMore, setHasMore] = useState(initialHasMore); const [loadingMore, setLoadingMore] = useState(false); const [content, setContent] = useState(""); const [sending, setSending] = useState(false); const [members, setMembers] = useState([]); const [memberCount, setMemberCount] = useState(initialMemberCount); const [rightPanel, setRightPanel] = useState(null); const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; message: ChatMessage; } | null>(null); const [confirm, setConfirm] = useState(null); const [confirmBusy, setConfirmBusy] = useState(false); const [replyTo, setReplyTo] = useState(null); const [mentionOpen, setMentionOpen] = useState(false); const [mentionFilter, setMentionFilter] = useState(""); const [mentionIndex, setMentionIndex] = useState(0); /** 从选择器确认插入的 @,在输入区以 token 整体展示/删除 */ const [mentionTokens, setMentionTokens] = useState>(() => new Set()); /** 本人撤回后的可重新编辑草稿(仅本端内存,不展示正文) */ const [reEditDrafts, setReEditDrafts] = useState< Record >({}); const longPressTimer = useRef | null>(null); const listRef = useRef(null); const inputRef = useRef(null); const mirrorRef = useRef(null); const msgRefs = useRef>(new Map()); const isOwner = myRole === "owner"; const isDirect = roomType === "direct"; const isRealMember = myRole === "owner" || myRole === "member"; const canView = joined; const oversee = canOverseeChat(meRole); const canManageInvite = !isDirect && (isOwner || oversee); const rememberReEdit = useCallback((id: number, text: string) => { const t = text.trim(); if (!t) return; setReEditDrafts((prev) => ({ ...prev, [id]: { text: t, until: Date.now() + RE_EDIT_MS }, })); }, []); const takeReEditDraft = useCallback((id: number): string | null => { const d = reEditDrafts[id]; if (!d || Date.now() > d.until) return null; return d.text; }, [reEditDrafts]); // 到期后清掉「重新编辑」,避免按钮残留 useEffect(() => { const ids = Object.keys(reEditDrafts); if (ids.length === 0) return; const nearest = Math.min(...Object.values(reEditDrafts).map((d) => d.until)); const wait = Math.max(0, nearest - Date.now()) + 50; const t = setTimeout(() => { setReEditDrafts((prev) => { const now = Date.now(); const next: typeof prev = {}; for (const [k, v] of Object.entries(prev)) { if (v.until > now) next[Number(k)] = v; } return next; }); }, wait); return () => clearTimeout(t); }, [reEditDrafts]); const scrollToBottom = useCallback((smooth = false) => { const el = listRef.current; if (!el) return; el.scrollTo({ top: el.scrollHeight, behavior: smooth ? "smooth" : "auto", }); }, []); const refreshMembers = useCallback(async () => { if (!canView) return; if (membersInflight.has(roomId)) return; membersInflight.add(roomId); try { const res = await apiListChatMembers(roomId); setMembers(res.members || []); if (res.members) setMemberCount(res.members.length); } catch { // 静默:@ 功能降级为纯文本 } finally { membersInflight.delete(roomId); } }, [roomId, canView]); const markReadToLatest = useCallback(() => { if (!isRealMember) return; requestMarkChatRead(roomId); }, [roomId, isRealMember]); useEffect(() => { if (!canView) return; realtime.joinRoom(roomId); void refreshMembers(); requestAnimationFrame(() => scrollToBottom(false)); markReadToLatest(); return () => { realtime.leaveRoom(roomId); }; // 只随房间切换订阅/首拉;避免回调身份变化重复打 members/read // eslint-disable-next-line react-hooks/exhaustive-deps }, [roomId, canView]); useEffect(() => { const off = realtime.on(RT_CHAT_MESSAGE, (data) => { if (!data || data.room_id !== roomId) return; setMessages((prev) => { if (prev.some((m) => m.id === data.id)) return prev; return [...prev, data as unknown as ChatMessage]; }); requestAnimationFrame(() => { const el = listRef.current; if (!el) return; const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120; if (nearBottom) { scrollToBottom(true); markReadToLatest(); } }); }); return off; }, [roomId, scrollToBottom, markReadToLatest]); useEffect(() => { const off = realtime.on(RT_CHAT_RECALLED, (data) => { if (!data || data.room_id !== roomId) return; setMessages((prev) => prev.map((m) => { if (m.id !== data.id) return m; // 本人撤回:草稿留给「重新编辑」,气泡不展示正文 if (m.sender_id === meId && m.content) { rememberReEdit(m.id, m.content); } return { ...m, // 仅监管界面保留正文;普通成员(含撤回者自己)气泡不展示 content: oversee ? m.content : "", recalled_at: data.recalled_at || new Date().toISOString(), recalled_by: data.recalled_by, sender_id: data.sender_id || m.sender_id, recaller: data.recaller ? { id: data.recaller.id, username: data.recaller.username, nickname: data.recaller.nickname, avatar: data.recaller.avatar, role: data.recaller.role || "user", } : m.recaller, }; }) ); }); return off; }, [roomId, oversee, meId, rememberReEdit]); useEffect(() => { const off = realtime.on( RT_CHAT_MEMBERSHIP, (data) => { if (!data || data.room_id !== roomId) return; if (data.status === "invited") { toast("你已被邀请加入本群", "ok"); router.refresh(); return; } if (data.status === "kicked") { toast("你已被移出该群"); } else { toast("该群已被解散"); } setTimeout(() => router.push("/chat"), 1200); } ); return off; }, [roomId, router]); const loadMore = useCallback(async () => { if (!canView || !hasMore || loadingMore || messages.length === 0) return; setLoadingMore(true); const prevHeight = listRef.current?.scrollHeight ?? 0; try { const before = messages[0].id; const res = await apiListChatMessages(roomId, before, 30); if ((res.messages || []).length > 0) { setMessages((prev) => [...res.messages, ...prev]); setHasMore(res.has_more); requestAnimationFrame(() => { const el = listRef.current; if (!el) return; el.scrollTop = el.scrollHeight - prevHeight; }); } else { setHasMore(false); } } catch { toast("加载历史消息失败"); } finally { setLoadingMore(false); } }, [canView, hasMore, loadingMore, messages, roomId]); const handleScroll = useCallback(() => { const el = listRef.current; if (!el) return; if (el.scrollTop < 60) { loadMore(); } if (el.scrollHeight - el.scrollTop - el.clientHeight < 60) { markReadToLatest(); } }, [loadMore, markReadToLatest]); const syncComposerHeight = () => { const t = inputRef.current; if (!t) return; t.style.height = "auto"; t.style.height = Math.min(t.scrollHeight, 128) + "px"; }; const send = async () => { const trimmed = content.trim(); if (!trimmed || sending || !isRealMember) return; setSending(true); const prevContent = trimmed; const prevTokens = mentionTokens; const replyId = replyTo?.id || 0; setContent(""); setMentionTokens(new Set()); setReplyTo(null); setMentionOpen(false); requestAnimationFrame(() => syncComposerHeight()); try { const { message } = await apiSendChatMessage(roomId, prevContent, replyId); setMessages((prev) => { if (prev.some((m) => m.id === message.id)) return prev; return [...prev, message]; }); requestAnimationFrame(() => scrollToBottom(true)); markReadToLatest(); } catch (e) { setContent(prevContent); setMentionTokens(prevTokens); if (replyId) { const found = messages.find((m) => m.id === replyId); if (found) setReplyTo(found); } toast(e instanceof Error ? e.message : "发送失败"); requestAnimationFrame(() => syncComposerHeight()); } finally { setSending(false); inputRef.current?.focus(); } }; const mentionCandidates = (() => { if (!mentionOpen) return []; const q = mentionFilter.toLowerCase(); return members .filter((m) => m.user_id !== meId) .filter((m) => { if (!q) return true; return ( m.user.username.toLowerCase().includes(q) || (m.user.nickname || "").toLowerCase().includes(q) ); }) .slice(0, 8); })(); const insertMention = (username: string) => { const el = inputRef.current; const val = content; const cursor = el?.selectionStart ?? val.length; const before = val.slice(0, cursor); const after = val.slice(cursor); const at = before.lastIndexOf("@"); const head = at >= 0 ? before.slice(0, at) : before; const next = `${head}@${username} ${after}`; setContent(next); setMentionTokens((prev) => { const n = new Set(prev); n.add(username.toLowerCase()); return n; }); setMentionOpen(false); setMentionFilter(""); requestAnimationFrame(() => { const pos = head.length + username.length + 2; el?.focus(); el?.setSelectionRange(pos, pos); syncComposerHeight(); }); }; const pruneMentionTokens = (text: string, tokens: Set) => { if (tokens.size === 0) return tokens; const present = new Set(); const re = /@([A-Za-z0-9_-]{3,32})/g; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { const key = m[1].toLowerCase(); if (tokens.has(key)) present.add(key); } if (present.size === tokens.size) return tokens; return present; }; const onComposerChange = (raw: string) => { setContent(raw); setMentionTokens((prev) => pruneMentionTokens(raw, prev)); const el = inputRef.current; const cursor = el?.selectionStart ?? raw.length; const before = raw.slice(0, cursor); const m = before.match(/@([A-Za-z0-9_-]{0,32})$/); if (m && isRealMember && !isDirect) { setMentionOpen(true); setMentionFilter(m[1] || ""); setMentionIndex(0); } else { setMentionOpen(false); setMentionFilter(""); } }; const onKeyDown = (e: React.KeyboardEvent) => { if (mentionOpen && mentionCandidates.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); setMentionIndex((i) => (i + 1) % mentionCandidates.length); return; } if (e.key === "ArrowUp") { e.preventDefault(); setMentionIndex( (i) => (i - 1 + mentionCandidates.length) % mentionCandidates.length ); return; } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const pick = mentionCandidates[mentionIndex]; if (pick) insertMention(pick.user.username); return; } if (e.key === "Escape") { e.preventDefault(); setMentionOpen(false); return; } } const el = e.currentTarget; const start = el.selectionStart; const end = el.selectionEnd; // 已确认 @token 按整体跳过 / 删除(飞书体验) if (start === end && mentionTokens.size > 0) { if (e.key === "Backspace") { const range = mentionTokenBefore(content, start, mentionTokens); if (range) { e.preventDefault(); const next = content.slice(0, range.start) + content.slice(range.end); onComposerChange(next); requestAnimationFrame(() => { el.setSelectionRange(range.start, range.start); syncComposerHeight(); }); return; } } if (e.key === "Delete") { const range = mentionTokenAfter(content, start, mentionTokens); if (range) { e.preventDefault(); const next = content.slice(0, range.start) + content.slice(range.end); onComposerChange(next); requestAnimationFrame(() => { el.setSelectionRange(range.start, range.start); syncComposerHeight(); }); return; } } if (e.key === "ArrowLeft" && !e.shiftKey) { const range = mentionTokenBefore(content, start, mentionTokens); if (range) { e.preventDefault(); el.setSelectionRange(range.start, range.start); return; } } if (e.key === "ArrowRight" && !e.shiftKey) { const range = mentionTokenAfter(content, start, mentionTokens); if (range) { e.preventDefault(); el.setSelectionRange(range.end, range.end); return; } } } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }; const openMsgMenu = (m: ChatMessage, x: number, y: number) => { if (m.recalled_at) return; setCtxMenu({ x, y, message: m }); }; const clearLongPress = () => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; } }; const togglePanel = (panel: Exclude) => { setRightPanel((cur) => (cur === panel ? null : panel)); }; const runConfirm = async () => { if (!confirm) return; setConfirmBusy(true); try { await confirm.action(); setConfirm(null); } catch (e) { toast(e instanceof Error ? e.message : "操作失败"); } finally { setConfirmBusy(false); } }; const handleLeave = () => { if (isDefault) { toast("默认大厅不可退出"); return; } setConfirm({ title: isDirect ? "关闭私聊" : "退出群聊", message: isDirect ? "确定关闭该私聊?之后可从对方主页再次打开。" : "确定退出该群?退出后可重新加入。", confirmLabel: isDirect ? "关闭" : "退出", danger: true, action: async () => { await apiLeaveChatRoom(roomId); toast(isDirect ? "已关闭私聊" : "已退出群聊", "ok"); router.push("/chat"); }, }); }; const handleDissolve = () => { if (isDefault) { toast("默认大厅不可解散"); return; } setConfirm({ title: "解散群聊", message: "确定解散该群?所有成员将被移出,消息历史保留但不再可达。", confirmLabel: "解散", danger: true, action: async () => { await apiDissolveChatRoom(roomId); toast("群已解散", "ok"); router.push("/chat"); }, }); }; const handleKick = (uid: number, nickname: string) => { setConfirm({ title: "移出成员", message: `确定将 ${nickname} 移出该群?`, confirmLabel: "移出", danger: true, action: async () => { await apiKickChatMember(roomId, uid); setMembers((prev) => prev.filter((m) => m.user_id !== uid)); setMemberCount((c) => Math.max(0, c - 1)); toast("已移出", "ok"); }, }); }; const canRecallMsg = (m: ChatMessage) => { if (m.recalled_at) return false; if (oversee) return true; return m.sender_id === meId && isRealMember; }; const handleRecall = (mid: number) => { const snapshot = messages.find((m) => m.id === mid); setCtxMenu(null); setConfirm({ title: "撤回消息", message: "确定撤回这条消息?", confirmLabel: "撤回", danger: true, action: async () => { const { message } = await apiRecallChatMessage(roomId, mid); const body = (message.content || snapshot?.content || "").trim(); const mine = (snapshot?.sender_id ?? message.sender_id) === meId; if (mine && body) rememberReEdit(mid, body); setMessages((prev) => prev.map((m) => m.id === mid ? { ...m, content: oversee ? body || m.content : "", recalled_at: message.recalled_at || new Date().toISOString(), recalled_by: message.recalled_by, recaller: message.recaller || m.recaller, } : m ) ); toast("已撤回", "ok"); }, }); }; const handleReEdit = (mid: number) => { const text = takeReEditDraft(mid); if (!text) { toast("重新编辑已过期"); return; } setReplyTo(null); setContent(text); requestAnimationFrame(() => { const el = inputRef.current; if (!el) return; el.focus(); el.setSelectionRange(text.length, text.length); el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 120)}px`; }); }; const scrollToMsg = (id: number) => { const el = msgRefs.current.get(id); el?.scrollIntoView({ behavior: "smooth", block: "center" }); }; const msgMenuItems = (m: ChatMessage): CtxMenuItem[] => { const items: CtxMenuItem[] = [ { key: "reply", label: "回复", icon: , onClick: () => { setReplyTo(m); inputRef.current?.focus(); }, }, { key: "copy", label: "复制", icon: , onClick: async () => { try { await navigator.clipboard.writeText(m.content); toast("已复制", "ok"); } catch { toast("复制失败"); } }, }, ]; if (!isDirect && isRealMember && m.sender_id !== meId) { items.push({ key: "at", label: `提及 @${m.sender.username}`, icon: , onClick: () => { setContent((c) => `${c}${c && !c.endsWith(" ") ? " " : ""}@${m.sender.username} `); inputRef.current?.focus(); }, }); } if (canRecallMsg(m)) { items.push({ key: "recall", label: "撤回", icon: , danger: true, onClick: () => handleRecall(m.id), }); } return items; }; // 点击空白关闭右键菜单 — ContextMenu 自带 const shellClass = embedded ? "relative flex flex-row h-full min-h-0" : "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_260px] gap-4"; return (
{embedded && ( ← )}

{roomName}

{isDefault && ( 大厅 )} {isPrivate && !isDirect && ( 私密 )}

{isDirect ? peer ? `@${peer.username}` : "私聊" : `${memberCount} 位成员`} {!isDirect && roomDescription && ` · ${roomDescription}`}

{canView && !isDirect && ( )} {canManageInvite && ( )} {isOwner && !isDirect && ( )} {isRealMember && !isDefault && (isDirect || !isOwner) && ( )}
{!canView ? (

{isPrivate ? "此群为私密群" : "预览群信息"}

{isPrivate ? "需群主邀请后才能查看消息" : "加入后可查看历史与发言"}

) : ( <> {hasMore && ( )}
{messages.length === 0 ? (

还没有消息,发第一条吧

) : ( messages.map((m, i) => { const recalled = !!m.recalled_at; const mine = m.sender_id === meId; const prev = messages[i - 1]; const grouped = !recalled && prev && !prev.recalled_at && prev.sender_id === m.sender_id && new Date(m.created_at).getTime() - new Date(prev.created_at).getTime() < 5 * 60_000; const reEditText = mine ? takeReEditDraft(m.id) : null; // 普通成员:居中系统提示(本人可重新编辑);不展示原文 if (recalled && !(oversee && m.content)) { return (
{ if (node) msgRefs.current.set(m.id, node); else msgRefs.current.delete(m.id); }} className="flex justify-center px-2 py-0.5" >

{reEditText ? ( <> {" "} ) : null}

); } // 站长/超管:单气泡呈现撤回原文(可读,非双气泡) if (recalled && oversee && m.content) { return (
{ if (node) msgRefs.current.set(m.id, node); else msgRefs.current.delete(m.id); }} className={`flex gap-2.5 ${mine ? "flex-row-reverse" : ""}`} >
{!isDirect && m.sender_id === ownerId && ( )} {m.sender.nickname} {formatTime(m.created_at)}
已撤回
{highlightMentions(m.content, members)}
); } return (
{ if (node) msgRefs.current.set(m.id, node); else msgRefs.current.delete(m.id); }} className={`flex gap-2.5 ${mine ? "flex-row-reverse" : ""}`} >
{!grouped && ( )}
{!grouped && (
{!isDirect && m.sender_id === ownerId && ( )} {m.sender.nickname} {formatTime(m.created_at)}
)}
{ e.preventDefault(); e.stopPropagation(); openMsgMenu(m, e.clientX, e.clientY); }} onTouchStart={(e) => { const t = e.touches[0]; if (!t) return; clearLongPress(); longPressTimer.current = setTimeout(() => { openMsgMenu(m, t.clientX, t.clientY); }, 480); }} onTouchEnd={clearLongPress} onTouchMove={clearLongPress} style={ mine ? { background: "color-mix(in srgb, var(--accent) 14%, var(--panel))", color: "var(--ink)", borderBottomRightRadius: 4, } : { background: "var(--tint)", color: "var(--ink)", borderBottomLeftRadius: 4, } } > {!!m.reply_to_id && m.reply_snap && ( )} {highlightMentions(m.content, members)}
); }) )}
)}
{isRealMember ? (
{replyTo && (

回复 {replyTo.sender.nickname || replyTo.sender.username}

{replyTo.recalled_at ? "(已撤回的消息)" : replyTo.content}

)}
{mentionOpen && mentionCandidates.length > 0 && (
{mentionCandidates.map((m, i) => ( ))}
)}
{ // 点空白/内边距时聚焦输入,避免抢走发送按钮的点击 const t = e.target as HTMLElement; if (t.closest("button, a, [role='button']")) return; if (t.tagName === "TEXTAREA") return; e.preventDefault(); inputRef.current?.focus(); }} >
{content ? (
{renderComposerMirror(content, mentionTokens)}
) : null}