降权全站字重;优化右栏公告/数据块;类型状态徽章用固定浅色区分;TipHost 仅在文案不一致、非文字或省略时显示。 Co-authored-by: Cursor <cursoragent@cursor.com>
1855 lines
60 KiB
TypeScript
1855 lines
60 KiB
TypeScript
"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<number>();
|
||
const markReadLastAt = new Map<number, number>();
|
||
|
||
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<number>();
|
||
|
||
type RightPanel = null | "members" | "settings" | "invite";
|
||
|
||
type ConfirmState = {
|
||
title: string;
|
||
message: string;
|
||
confirmLabel?: string;
|
||
danger?: boolean;
|
||
action: () => Promise<void> | 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 (
|
||
<span>
|
||
{adminRecall ? "你撤回了一条成员消息" : "你撤回了一条消息"}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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 ? (
|
||
<Link
|
||
href={`/u/${uid}`}
|
||
className="transition-opacity hover:opacity-80"
|
||
style={nameStyle}
|
||
data-tip={`查看 ${name} 的主页`}
|
||
>
|
||
{name}
|
||
</Link>
|
||
) : (
|
||
<span style={nameStyle}>
|
||
{name}
|
||
</span>
|
||
);
|
||
|
||
if (adminRecall) {
|
||
return (
|
||
<span>
|
||
管理员
|
||
{nameNode}
|
||
撤回了一条成员消息
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span>
|
||
{nameNode}
|
||
撤回了一条消息
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** 撤回后「重新编辑」有效窗口(接近微信体验) */
|
||
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 ? (
|
||
<Link
|
||
key={`m-${i}`}
|
||
href={`/u/${user.id}`}
|
||
className="j13-mention-token"
|
||
data-tip={`查看 ${user.nickname || user.username} 的主页`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onContextMenu={(e) => e.stopPropagation()}
|
||
>
|
||
@{m[1]}
|
||
</Link>
|
||
) : (
|
||
<span key={`m-${i}`}>@{m[1]}</span>
|
||
)
|
||
);
|
||
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<string>) {
|
||
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) ? (
|
||
<span key={`t-${i}`} className="j13-mention-token">
|
||
@{m[1]}
|
||
</span>
|
||
) : (
|
||
<span key={`t-${i}`}>@{m[1]}</span>
|
||
)
|
||
);
|
||
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<string>
|
||
): { 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<string>
|
||
): { 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<ChatMessage[]>(initialMessages);
|
||
const [hasMore, setHasMore] = useState(initialHasMore);
|
||
const [loadingMore, setLoadingMore] = useState(false);
|
||
const [content, setContent] = useState("");
|
||
const [sending, setSending] = useState(false);
|
||
const [members, setMembers] = useState<ChatRoomMember[]>([]);
|
||
const [memberCount, setMemberCount] = useState(initialMemberCount);
|
||
const [rightPanel, setRightPanel] = useState<RightPanel>(null);
|
||
const [ctxMenu, setCtxMenu] = useState<{
|
||
x: number;
|
||
y: number;
|
||
message: ChatMessage;
|
||
} | null>(null);
|
||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
|
||
const [mentionOpen, setMentionOpen] = useState(false);
|
||
const [mentionFilter, setMentionFilter] = useState("");
|
||
const [mentionIndex, setMentionIndex] = useState(0);
|
||
/** 从选择器确认插入的 @,在输入区以 token 整体展示/删除 */
|
||
const [mentionTokens, setMentionTokens] = useState<Set<string>>(() => new Set());
|
||
/** 本人撤回后的可重新编辑草稿(仅本端内存,不展示正文) */
|
||
const [reEditDrafts, setReEditDrafts] = useState<
|
||
Record<number, { text: string; until: number }>
|
||
>({});
|
||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const listRef = useRef<HTMLDivElement>(null);
|
||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||
const mirrorRef = useRef<HTMLDivElement>(null);
|
||
const msgRefs = useRef<Map<number, HTMLDivElement>>(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;
|
||
// column-reverse:scrollTop=0 即视觉底部(最新消息)
|
||
el.scrollTo({
|
||
top: 0,
|
||
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();
|
||
// 列表用 flex-col-reverse,SSR/无 JS 默认即贴底,无需首屏 scrollToBottom(避免 F5 闪一下)
|
||
markReadToLatest();
|
||
return () => {
|
||
realtime.leaveRoom(roomId);
|
||
};
|
||
// 只随房间切换订阅/首拉;避免回调身份变化重复打 members/read
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [roomId, canView]);
|
||
|
||
useEffect(() => {
|
||
const off = realtime.on<RtChatMessageData>(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;
|
||
// column-reverse:靠近 scrollTop=0 即贴底
|
||
if (el.scrollTop < 120) {
|
||
scrollToBottom(true);
|
||
markReadToLatest();
|
||
}
|
||
});
|
||
});
|
||
return off;
|
||
}, [roomId, scrollToBottom, markReadToLatest]);
|
||
|
||
useEffect(() => {
|
||
const off = realtime.on<RtChatRecalledData>(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<RtChatMembershipData>(
|
||
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 el0 = listRef.current;
|
||
const prevHeight = el0?.scrollHeight ?? 0;
|
||
const prevTop = el0?.scrollTop ?? 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;
|
||
// column-reverse:历史插在上方,用高度差补偿保持视口锚点
|
||
el.scrollTop = prevTop + (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;
|
||
// column-reverse:滚向上方历史 ≈ 接近 max scrollTop
|
||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 60) {
|
||
loadMore();
|
||
}
|
||
if (el.scrollTop < 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<string>) => {
|
||
if (tokens.size === 0) return tokens;
|
||
const present = new Set<string>();
|
||
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<HTMLTextAreaElement>) => {
|
||
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<RightPanel, null>) => {
|
||
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: <Reply size={14} />,
|
||
onClick: () => {
|
||
setReplyTo(m);
|
||
inputRef.current?.focus();
|
||
},
|
||
},
|
||
{
|
||
key: "copy",
|
||
label: "复制",
|
||
icon: <Copy size={14} />,
|
||
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: <AtSign size={14} />,
|
||
onClick: () => {
|
||
setContent((c) => `${c}${c && !c.endsWith(" ") ? " " : ""}@${m.sender.username} `);
|
||
inputRef.current?.focus();
|
||
},
|
||
});
|
||
}
|
||
if (canRecallMsg(m)) {
|
||
items.push({
|
||
key: "recall",
|
||
label: "撤回",
|
||
icon: <Undo2 size={14} />,
|
||
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 (
|
||
<div className={shellClass}>
|
||
<div
|
||
className={
|
||
embedded
|
||
? "flex flex-col h-full min-h-0 min-w-0 flex-1"
|
||
: "panel flex flex-col"
|
||
}
|
||
style={
|
||
embedded
|
||
? undefined
|
||
: { height: "100%", minHeight: 0 }
|
||
}
|
||
>
|
||
<div
|
||
className="flex items-center justify-between gap-3 px-4 sm:px-5 py-3 shrink-0"
|
||
style={{ borderBottom: "1px solid var(--line)" }}
|
||
>
|
||
<div className="min-w-0 flex items-center gap-2">
|
||
{embedded && (
|
||
<Link
|
||
href="/chat"
|
||
className="lg:hidden w-8 h-8 flex items-center justify-center rounded-full hover:bg-[var(--accent-soft)]"
|
||
style={{ color: "var(--ink-2)" }}
|
||
aria-label="返回消息列表"
|
||
>
|
||
←
|
||
</Link>
|
||
)}
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<h1
|
||
className="font-extrabold text-[16px] truncate"
|
||
style={{ color: "var(--ink)" }}
|
||
>
|
||
{roomName}
|
||
</h1>
|
||
{isDefault && (
|
||
<span
|
||
className="text-[10.5px] px-2 py-0.5 rounded-full"
|
||
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
||
>
|
||
大厅
|
||
</span>
|
||
)}
|
||
{isPrivate && !isDirect && (
|
||
<span
|
||
className="text-[10.5px] px-2 py-0.5 rounded-full"
|
||
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
|
||
>
|
||
私密
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="meta text-[11.5px] mt-0.5 truncate">
|
||
{isDirect
|
||
? peer
|
||
? `@${peer.username}`
|
||
: "私聊"
|
||
: `${memberCount} 位成员`}
|
||
{!isDirect && roomDescription && ` · ${roomDescription}`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
{canView && !isDirect && (
|
||
<button
|
||
onClick={() => togglePanel("members")}
|
||
aria-label="成员列表"
|
||
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)]"
|
||
style={{
|
||
color: rightPanel === "members" ? "var(--accent)" : "var(--ink-2)",
|
||
background: rightPanel === "members" ? "var(--accent-soft)" : undefined,
|
||
}}
|
||
data-tip="成员"
|
||
>
|
||
<Users size={16} />
|
||
</button>
|
||
)}
|
||
{canManageInvite && (
|
||
<button
|
||
onClick={() => togglePanel("invite")}
|
||
aria-label="邀请成员"
|
||
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)]"
|
||
style={{
|
||
color: rightPanel === "invite" ? "var(--accent)" : "var(--ink-2)",
|
||
background: rightPanel === "invite" ? "var(--accent-soft)" : undefined,
|
||
}}
|
||
data-tip="邀请成员"
|
||
>
|
||
<UserPlus size={16} />
|
||
</button>
|
||
)}
|
||
{isOwner && !isDirect && (
|
||
<button
|
||
onClick={() => togglePanel("settings")}
|
||
aria-label="群设置"
|
||
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)]"
|
||
style={{
|
||
color: rightPanel === "settings" ? "var(--accent)" : "var(--ink-2)",
|
||
background: rightPanel === "settings" ? "var(--accent-soft)" : undefined,
|
||
}}
|
||
data-tip="群设置"
|
||
>
|
||
<Settings size={16} />
|
||
</button>
|
||
)}
|
||
{isRealMember && !isDefault && (isDirect || !isOwner) && (
|
||
<button
|
||
onClick={handleLeave}
|
||
aria-label={isDirect ? "关闭私聊" : "退出群聊"}
|
||
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)]"
|
||
style={{ color: "var(--ink-3)" }}
|
||
data-tip={isDirect ? "关闭私聊" : "退出群聊"}
|
||
>
|
||
<LogOut size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
ref={listRef}
|
||
onScroll={handleScroll}
|
||
className="flex-1 overflow-y-auto flex flex-col-reverse px-4 sm:px-5 py-4"
|
||
style={{ minHeight: 0 }}
|
||
>
|
||
{!canView ? (
|
||
<div className="text-center py-12 px-4">
|
||
<p style={{ color: "var(--ink)" }}>
|
||
{isPrivate ? "此群为私密群" : "预览群信息"}
|
||
</p>
|
||
<p className="meta mt-1.5">
|
||
{isPrivate
|
||
? "需群主邀请后才能查看消息"
|
||
: "加入后可查看历史与发言"}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col gap-3">
|
||
{hasMore && (
|
||
<button
|
||
onClick={loadMore}
|
||
disabled={loadingMore}
|
||
className="mx-auto block text-[12px] meta hover:text-[var(--accent)] transition-colors py-2"
|
||
>
|
||
{loadingMore ? "加载中..." : "加载更早消息"}
|
||
</button>
|
||
)}
|
||
{messages.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
<p className="meta" style={{ color: "var(--ink-3)" }}>
|
||
还没有消息,发第一条吧
|
||
</p>
|
||
</div>
|
||
) : (
|
||
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 (
|
||
<div
|
||
key={m.id}
|
||
ref={(node) => {
|
||
if (node) msgRefs.current.set(m.id, node);
|
||
else msgRefs.current.delete(m.id);
|
||
}}
|
||
className="flex justify-center px-2 py-0.5"
|
||
>
|
||
<p
|
||
className="text-[12px] leading-relaxed text-center max-w-[90%]"
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
<RecallNotice
|
||
message={m}
|
||
meId={meId}
|
||
members={members}
|
||
/>
|
||
{reEditText ? (
|
||
<>
|
||
{" "}
|
||
<button
|
||
type="button"
|
||
onClick={() => handleReEdit(m.id)}
|
||
className="transition-opacity hover:opacity-80"
|
||
style={{
|
||
color:
|
||
"color-mix(in srgb, var(--accent) 72%, var(--ink))",
|
||
}}
|
||
>
|
||
重新编辑
|
||
</button>
|
||
</>
|
||
) : null}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 站长/超管:单气泡呈现撤回原文(可读,非双气泡)
|
||
if (recalled && oversee && m.content) {
|
||
return (
|
||
<div
|
||
key={m.id}
|
||
ref={(node) => {
|
||
if (node) msgRefs.current.set(m.id, node);
|
||
else msgRefs.current.delete(m.id);
|
||
}}
|
||
className={`flex gap-2.5 ${mine ? "flex-row-reverse" : ""}`}
|
||
>
|
||
<div className="w-8 shrink-0">
|
||
<Avatar
|
||
name={m.sender.nickname || m.sender.username}
|
||
src={m.sender.avatar || undefined}
|
||
size={32}
|
||
/>
|
||
</div>
|
||
<div
|
||
className={`flex flex-col min-w-0 max-w-[78%] ${
|
||
mine ? "items-end" : "items-start"
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
{!isDirect && m.sender_id === ownerId && (
|
||
<Crown size={11} style={{ color: "var(--gold)" }} />
|
||
)}
|
||
<span
|
||
className="text-[12px] truncate"
|
||
style={{ color: "var(--ink-2)" }}
|
||
>
|
||
{m.sender.nickname}
|
||
</span>
|
||
<span className="meta text-[10.5px]">
|
||
{formatTime(m.created_at)}
|
||
</span>
|
||
</div>
|
||
<div
|
||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] break-words whitespace-pre-wrap select-text w-full"
|
||
style={{
|
||
background: "var(--panel-2)",
|
||
border:
|
||
"1px solid color-mix(in srgb, var(--line) 80%, var(--ink-3))",
|
||
color: "var(--ink)",
|
||
borderBottomRightRadius: mine ? 4 : undefined,
|
||
borderBottomLeftRadius: mine ? undefined : 4,
|
||
opacity: 0.92,
|
||
}}
|
||
>
|
||
<div
|
||
className="inline-flex items-center gap-1 mb-1.5 text-[11px]"
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
<Undo2 size={11} strokeWidth={2.4} />
|
||
已撤回
|
||
</div>
|
||
<div style={{ color: "var(--ink-2)" }}>
|
||
{highlightMentions(m.content, members)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
key={m.id}
|
||
ref={(node) => {
|
||
if (node) msgRefs.current.set(m.id, node);
|
||
else msgRefs.current.delete(m.id);
|
||
}}
|
||
className={`flex gap-2.5 ${mine ? "flex-row-reverse" : ""}`}
|
||
>
|
||
<div className="w-8 shrink-0">
|
||
{!grouped && (
|
||
<Avatar
|
||
name={m.sender.nickname || m.sender.username}
|
||
src={m.sender.avatar || undefined}
|
||
size={32}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div
|
||
className={`flex flex-col min-w-0 max-w-[78%] ${
|
||
mine ? "items-end" : "items-start"
|
||
}`}
|
||
>
|
||
{!grouped && (
|
||
<div className="flex items-center gap-2 mb-1">
|
||
{!isDirect && m.sender_id === ownerId && (
|
||
<Crown size={11} style={{ color: "var(--gold)" }} />
|
||
)}
|
||
<span
|
||
className="text-[12px] truncate"
|
||
style={{ color: "var(--ink-2)" }}
|
||
>
|
||
{m.sender.nickname}
|
||
</span>
|
||
<span className="meta text-[10.5px]">
|
||
{formatTime(m.created_at)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div
|
||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] break-words whitespace-pre-wrap select-text"
|
||
onContextMenu={(e) => {
|
||
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 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => scrollToMsg(m.reply_to_id!)}
|
||
className="block w-full text-left mb-1.5 px-2 py-1.5 rounded-lg text-[12px] leading-snug"
|
||
style={{
|
||
background: mine
|
||
? "color-mix(in srgb, var(--accent) 10%, transparent)"
|
||
: "var(--bg)",
|
||
borderLeft: "3px solid var(--accent)",
|
||
color: "var(--ink-2)",
|
||
}}
|
||
>
|
||
{m.reply_snap}
|
||
</button>
|
||
)}
|
||
{highlightMentions(m.content, members)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isRealMember ? (
|
||
<div
|
||
className="shrink-0 px-3 sm:px-4 py-3"
|
||
style={{ borderTop: "1px solid var(--line)" }}
|
||
>
|
||
{replyTo && (
|
||
<div
|
||
className="flex items-start gap-2 mb-2 px-3 py-2 rounded-xl"
|
||
style={{
|
||
background: "var(--bg)",
|
||
border: "1px solid var(--line)",
|
||
}}
|
||
>
|
||
<div
|
||
className="w-1 self-stretch rounded-full shrink-0"
|
||
style={{ background: "var(--accent)" }}
|
||
/>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-[11px]" style={{ color: "var(--accent)" }}>
|
||
回复 {replyTo.sender.nickname || replyTo.sender.username}
|
||
</p>
|
||
<p className="text-[12px] truncate meta mt-0.5">
|
||
{replyTo.recalled_at ? "(已撤回的消息)" : replyTo.content}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setReplyTo(null)}
|
||
aria-label="取消回复"
|
||
className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--accent-soft)]"
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
<X size={13} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="relative">
|
||
{mentionOpen && mentionCandidates.length > 0 && (
|
||
<div
|
||
className="absolute bottom-full left-0 right-0 mb-2 rounded-xl overflow-hidden shadow-lg z-10"
|
||
style={{
|
||
background: "var(--panel)",
|
||
border: "1px solid var(--line)",
|
||
}}
|
||
>
|
||
{mentionCandidates.map((m, i) => (
|
||
<button
|
||
key={m.id}
|
||
type="button"
|
||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left"
|
||
style={{
|
||
background: i === mentionIndex ? "var(--accent-soft)" : "transparent",
|
||
}}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
insertMention(m.user.username);
|
||
}}
|
||
>
|
||
<Avatar
|
||
name={m.user.nickname || m.user.username}
|
||
src={m.user.avatar || undefined}
|
||
size={28}
|
||
/>
|
||
<div className="min-w-0">
|
||
<p className="text-[13px] truncate" style={{ color: "var(--ink)" }}>
|
||
{m.user.nickname || m.user.username}
|
||
</p>
|
||
<p className="text-[11px] meta truncate">@{m.user.username}</p>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div
|
||
className="j13-chat-composer flex items-center gap-2 rounded-2xl pl-3.5 pr-2 py-1.5 min-h-[48px] cursor-text transition-[border-color,box-shadow] duration-150"
|
||
style={{
|
||
background: "var(--bg)",
|
||
border: "1px solid var(--line)",
|
||
}}
|
||
onMouseDown={(e) => {
|
||
// 点空白/内边距时聚焦输入,避免抢走发送按钮的点击
|
||
const t = e.target as HTMLElement;
|
||
if (t.closest("button, a, [role='button']")) return;
|
||
if (t.tagName === "TEXTAREA") return;
|
||
e.preventDefault();
|
||
inputRef.current?.focus();
|
||
}}
|
||
>
|
||
<div className="relative flex-1 min-w-0 self-stretch flex items-center">
|
||
{content ? (
|
||
<div
|
||
ref={mirrorRef}
|
||
className="j13-chat-composer-mirror text-[14px] leading-5 py-2"
|
||
aria-hidden
|
||
>
|
||
{renderComposerMirror(content, mentionTokens)}
|
||
</div>
|
||
) : null}
|
||
<textarea
|
||
ref={inputRef}
|
||
value={content}
|
||
onChange={(e) => onComposerChange(e.target.value)}
|
||
onKeyDown={onKeyDown}
|
||
onScroll={(e) => {
|
||
if (mirrorRef.current) {
|
||
mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
|
||
mirrorRef.current.scrollLeft = e.currentTarget.scrollLeft;
|
||
}
|
||
}}
|
||
rows={1}
|
||
placeholder={
|
||
isDirect
|
||
? "输入消息…"
|
||
: "输入消息,@ 可提醒成员"
|
||
}
|
||
maxLength={2000}
|
||
className={`j13-chat-composer-input relative w-full bg-transparent text-[14px] leading-5 py-2 max-h-32 min-h-[36px]${
|
||
content ? " has-value" : ""
|
||
}`}
|
||
style={{ color: content ? undefined : "var(--ink)" }}
|
||
onInput={(e) => {
|
||
const t = e.currentTarget;
|
||
t.style.height = "auto";
|
||
t.style.height = Math.min(t.scrollHeight, 128) + "px";
|
||
}}
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={send}
|
||
disabled={sending || !content.trim()}
|
||
className="btn btn-accent btn-sm gap-1.5 h-9 px-3.5 shrink-0 rounded-full"
|
||
aria-label="发送"
|
||
>
|
||
<Send size={14} strokeWidth={2.25} />
|
||
发送
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="px-4 sm:px-5 py-4 shrink-0 text-center"
|
||
style={{ borderTop: "1px solid var(--line)" }}
|
||
>
|
||
{oversee && !isRealMember ? (
|
||
<p className="text-[13px] meta" style={{ color: "var(--ink-3)" }}>
|
||
监管旁路查看中(未正式入群,不可发言)
|
||
</p>
|
||
) : isPrivate ? (
|
||
<p className="text-[13px] meta" style={{ color: "var(--ink-3)" }}>
|
||
私密群需邀请才能加入
|
||
</p>
|
||
) : (
|
||
<>
|
||
<p
|
||
className="text-[13px] meta mb-2.5"
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
你还不是该群成员
|
||
</p>
|
||
<JoinButton roomId={roomId} onJoined={() => router.refresh()} />
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 飞书式右栏:成员 / 邀请 / 设置 */}
|
||
{canView && !isDirect && rightPanel && (
|
||
<aside
|
||
className={
|
||
embedded
|
||
? "absolute inset-0 z-30 flex flex-col sm:static sm:inset-auto sm:w-[280px] sm:shrink-0 h-full min-h-0"
|
||
: "hidden lg:flex flex-col w-[260px] panel min-h-0"
|
||
}
|
||
style={{
|
||
background: "var(--panel)",
|
||
borderLeft: "1px solid var(--line)",
|
||
}}
|
||
>
|
||
<div
|
||
className="flex items-center justify-between px-4 py-3 shrink-0"
|
||
style={{ borderBottom: "1px solid var(--line)" }}
|
||
>
|
||
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||
{rightPanel === "members"
|
||
? `成员 · ${memberCount}`
|
||
: rightPanel === "invite"
|
||
? "邀请成员"
|
||
: "群设置"}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => setRightPanel(null)}
|
||
aria-label="关闭面板"
|
||
className="w-7 h-7 flex items-center justify-center rounded-full hover:bg-[var(--accent-soft)]"
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto min-h-0 p-4">
|
||
{rightPanel === "members" && (
|
||
<MembersPanel
|
||
members={members}
|
||
meId={meId}
|
||
isOwner={isOwner}
|
||
onRefresh={refreshMembers}
|
||
onKick={handleKick}
|
||
/>
|
||
)}
|
||
{rightPanel === "invite" && (
|
||
<InvitePanel
|
||
roomId={roomId}
|
||
onInvited={() => {
|
||
refreshMembers();
|
||
setRightPanel("members");
|
||
}}
|
||
/>
|
||
)}
|
||
{rightPanel === "settings" && (
|
||
<SettingsPanel
|
||
roomId={roomId}
|
||
initialName={roomName}
|
||
initialDescription={roomDescription}
|
||
canDissolve={!isDefault}
|
||
onSaved={() => {
|
||
setRightPanel(null);
|
||
router.refresh();
|
||
}}
|
||
onDissolve={handleDissolve}
|
||
/>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
)}
|
||
|
||
{/* 非嵌入模式:未开右栏时桌面仍显示成员常驻栏 */}
|
||
{!embedded && canView && !isDirect && !rightPanel && (
|
||
<aside className="hidden lg:flex flex-col panel p-4 overflow-y-auto min-h-0">
|
||
<MembersPanel
|
||
members={members}
|
||
meId={meId}
|
||
isOwner={isOwner}
|
||
onRefresh={refreshMembers}
|
||
onKick={handleKick}
|
||
showTitle
|
||
/>
|
||
</aside>
|
||
)}
|
||
|
||
{ctxMenu && (
|
||
<ContextMenu
|
||
x={ctxMenu.x}
|
||
y={ctxMenu.y}
|
||
items={msgMenuItems(ctxMenu.message)}
|
||
onClose={() => setCtxMenu(null)}
|
||
/>
|
||
)}
|
||
|
||
<ConfirmDialog
|
||
open={!!confirm}
|
||
title={confirm?.title || ""}
|
||
message={confirm?.message || ""}
|
||
confirmLabel={confirm?.confirmLabel}
|
||
danger={confirm?.danger}
|
||
busy={confirmBusy}
|
||
onConfirm={runConfirm}
|
||
onCancel={() => {
|
||
if (!confirmBusy) setConfirm(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function JoinButton({
|
||
roomId,
|
||
onJoined,
|
||
}: {
|
||
roomId: number;
|
||
onJoined: () => void;
|
||
}) {
|
||
const [joining, setJoining] = useState(false);
|
||
const join = async () => {
|
||
setJoining(true);
|
||
try {
|
||
const { apiJoinChatRoom } = await import("@/lib/api");
|
||
await apiJoinChatRoom(roomId);
|
||
toast("已加入群聊", "ok");
|
||
onJoined();
|
||
} catch (e) {
|
||
toast(e instanceof Error ? e.message : "加入失败");
|
||
} finally {
|
||
setJoining(false);
|
||
}
|
||
};
|
||
return (
|
||
<button
|
||
onClick={join}
|
||
disabled={joining}
|
||
className="btn btn-accent btn-sm"
|
||
>
|
||
{joining ? "加入中..." : "加入群聊"}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function MembersPanel({
|
||
members,
|
||
meId,
|
||
isOwner,
|
||
onRefresh,
|
||
onKick,
|
||
showTitle = false,
|
||
}: {
|
||
members: ChatRoomMember[];
|
||
meId: number;
|
||
isOwner: boolean;
|
||
onRefresh: () => void;
|
||
onKick: (uid: number, nickname: string) => void;
|
||
showTitle?: boolean;
|
||
}) {
|
||
return (
|
||
<div>
|
||
{showTitle && (
|
||
<div className="flex items-center justify-between mb-3">
|
||
<p
|
||
className="text-[12px] tracking-[0.08em] uppercase"
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
成员 · {members.length}
|
||
</p>
|
||
<button
|
||
onClick={onRefresh}
|
||
className="meta hover:text-[var(--accent)] transition-colors text-[11px]"
|
||
>
|
||
刷新
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!showTitle && (
|
||
<div className="flex justify-end mb-2">
|
||
<button
|
||
onClick={onRefresh}
|
||
className="meta hover:text-[var(--accent)] transition-colors text-[11px]"
|
||
>
|
||
刷新
|
||
</button>
|
||
</div>
|
||
)}
|
||
<ul className="flex flex-col gap-1.5">
|
||
{members.map((m) => (
|
||
<li
|
||
key={m.id}
|
||
className="flex items-center gap-2.5 px-2 py-2 rounded-lg group"
|
||
>
|
||
<Avatar
|
||
name={m.user.nickname || m.user.username}
|
||
src={m.user.avatar || undefined}
|
||
size={28}
|
||
/>
|
||
<Link
|
||
href={`/u/${m.user.id}`}
|
||
className="flex-1 min-w-0 truncate text-[13px] hover:text-[var(--accent)] transition-colors"
|
||
style={{ color: "var(--ink)" }}
|
||
>
|
||
{m.user.nickname}
|
||
</Link>
|
||
{m.role === "owner" ? (
|
||
<Crown size={12} style={{ color: "var(--gold)" }} />
|
||
) : null}
|
||
{isOwner && m.user_id !== meId && (
|
||
<button
|
||
onClick={() => onKick(m.user_id, m.user.nickname)}
|
||
aria-label={`移出 ${m.user.nickname}`}
|
||
className="opacity-0 group-hover:opacity-100 transition-opacity w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--danger-soft)]"
|
||
style={{ color: "var(--danger)" }}
|
||
>
|
||
<UserX size={12} />
|
||
</button>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function InvitePanel({
|
||
roomId,
|
||
onInvited,
|
||
}: {
|
||
roomId: number;
|
||
onInvited: () => void;
|
||
}) {
|
||
const [input, setInput] = useState("");
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
const submit = async () => {
|
||
const usernames = input
|
||
.split(/[\s,,]+/)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
if (usernames.length === 0) {
|
||
toast("请输入要邀请的用户名");
|
||
return;
|
||
}
|
||
setSubmitting(true);
|
||
try {
|
||
const { added } = await apiInviteChatMembers(roomId, usernames);
|
||
toast(added.length > 0 ? `已邀请 ${added.length} 人` : "没有新成员加入", "ok");
|
||
setInput("");
|
||
onInvited();
|
||
} catch (e) {
|
||
toast(e instanceof Error ? e.message : "邀请失败");
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<p className="text-[12.5px] meta">输入用户名,多个用空格或逗号分隔</p>
|
||
<input
|
||
autoFocus
|
||
type="text"
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
placeholder="username1, username2"
|
||
className="w-full px-3.5 h-10 rounded-lg outline-none"
|
||
style={{
|
||
background: "var(--bg)",
|
||
border: "1px solid var(--line)",
|
||
color: "var(--ink)",
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={submit}
|
||
disabled={submitting}
|
||
className="btn btn-accent btn-sm w-full justify-center"
|
||
>
|
||
{submitting ? "邀请中..." : "发送邀请"}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SettingsPanel({
|
||
roomId,
|
||
initialName,
|
||
initialDescription,
|
||
canDissolve,
|
||
onSaved,
|
||
onDissolve,
|
||
}: {
|
||
roomId: number;
|
||
initialName: string;
|
||
initialDescription: string;
|
||
canDissolve: boolean;
|
||
onSaved: () => void;
|
||
onDissolve: () => void;
|
||
}) {
|
||
const [name, setName] = useState(initialName);
|
||
const [description, setDescription] = useState(initialDescription);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
const save = async () => {
|
||
const trimmed = name.trim();
|
||
if (trimmed.length < 2 || trimmed.length > 32) {
|
||
toast("群名称需为 2-32 个字符");
|
||
return;
|
||
}
|
||
if (description.length > 200) {
|
||
toast("群简介不能超过 200 个字符");
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
try {
|
||
await apiUpdateChatRoom(roomId, trimmed, description.trim());
|
||
toast("已保存", "ok");
|
||
onSaved();
|
||
} catch (e) {
|
||
toast(e instanceof Error ? e.message : "保存失败");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label
|
||
className="block text-[12px] mb-1.5"
|
||
style={{ color: "var(--ink-2)" }}
|
||
>
|
||
群名称
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
maxLength={32}
|
||
className="w-full px-3.5 h-10 rounded-lg outline-none"
|
||
style={{
|
||
background: "var(--bg)",
|
||
border: "1px solid var(--line)",
|
||
color: "var(--ink)",
|
||
}}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label
|
||
className="block text-[12px] mb-1.5"
|
||
style={{ color: "var(--ink-2)" }}
|
||
>
|
||
群简介
|
||
</label>
|
||
<textarea
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
maxLength={200}
|
||
rows={4}
|
||
className="w-full px-3.5 py-2.5 rounded-lg outline-none resize-none"
|
||
style={{
|
||
background: "var(--bg)",
|
||
border: "1px solid var(--line)",
|
||
color: "var(--ink)",
|
||
}}
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={save}
|
||
disabled={saving}
|
||
className="btn btn-accent btn-sm w-full justify-center"
|
||
>
|
||
{saving ? "保存中..." : "保存修改"}
|
||
</button>
|
||
{canDissolve && (
|
||
<button
|
||
onClick={onDissolve}
|
||
disabled={saving}
|
||
className="btn btn-danger btn-sm w-full justify-center gap-1.5"
|
||
>
|
||
<Trash2 size={13} /> 解散群
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|