新增: - BrandCropModal 品牌图裁剪弹窗 + cropImage 工具 - Markdown 行内短代码(inlineShortcodes + remark 插件) - MdDetails 折叠块、MdVideo 视频块组件 - directiveBlocks/inlineShortcodes 测试 其它: - 聊天后端(handler/service chat)调整 - Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
3065 lines
107 KiB
TypeScript
3065 lines
107 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } 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,
|
||
ChevronsDown,
|
||
Smile,
|
||
VolumeX,
|
||
} from "lucide-react";
|
||
import {
|
||
apiListChatMessages,
|
||
apiSendChatMessage,
|
||
apiMarkChatRead,
|
||
apiListChatMembers,
|
||
apiLeaveChatRoom,
|
||
apiDissolveChatRoom,
|
||
apiUpdateChatRoom,
|
||
apiKickChatMember,
|
||
apiMuteChatMember,
|
||
apiSetChatMemberRole,
|
||
apiInviteChatMembers,
|
||
apiRecallChatMessage,
|
||
type ChatMessage,
|
||
type ChatRoomMember,
|
||
type User,
|
||
} from "@/lib/api";
|
||
import {
|
||
realtime,
|
||
RT_CHAT_MESSAGE,
|
||
RT_CHAT_MEMBERSHIP,
|
||
RT_CHAT_RECALLED,
|
||
RT_CHAT_MEMBER_MUTED,
|
||
} from "@/lib/realtime";
|
||
import type {
|
||
RtChatMessageData,
|
||
RtChatMembershipData,
|
||
RtChatRecalledData,
|
||
RtChatMemberMutedData,
|
||
} from "@/lib/realtime";
|
||
import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
|
||
import { toast } from "@/lib/toast";
|
||
import { scrollBehavior } from "@/lib/motion";
|
||
import Avatar from "@/components/Avatar";
|
||
import LevelBadge from "@/components/LevelBadge";
|
||
import BadgeChip from "@/components/BadgeChip";
|
||
import AutolinkText from "@/components/AutolinkText";
|
||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||
import Modal from "@/components/Modal";
|
||
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
||
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
|
||
import EmojiPicker from "@/components/EmojiPicker";
|
||
import EmojiImg from "@/components/EmojiImg";
|
||
import { collectDyEmojiSpans, dyEmojiUrl } from "@/lib/dyEmoji";
|
||
import { userPath } from "@/lib/urlStyle";
|
||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||
|
||
/** 同一房间已读:进行中去重 + 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, opts?: { force?: boolean }): void {
|
||
const now = Date.now();
|
||
const last = markReadLastAt.get(roomId) || 0;
|
||
if (markReadInflight.has(roomId)) return;
|
||
if (!opts?.force && 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[];
|
||
}) {
|
||
const urlStyle = useUrlStyle();
|
||
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={userPath(urlStyle, uid)}
|
||
className="transition-opacity hover:opacity-80"
|
||
style={nameStyle}
|
||
>
|
||
{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" | "admin" | "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;
|
||
/** 站点消息管理 flag */
|
||
canManageMessages?: boolean;
|
||
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,
|
||
users: Map<string, User>,
|
||
onOpenCard: (u: User) => void
|
||
) {
|
||
if (!content) return null;
|
||
// 合并 @提及 与表情短代码 span(互不重叠:短代码字符集内无 @)
|
||
const emojiSpans = collectDyEmojiSpans(content);
|
||
const inEmoji = new Array<boolean>(content.length).fill(false);
|
||
for (const s of emojiSpans) {
|
||
for (let j = s.start; j < s.end; j++) inEmoji[j] = true;
|
||
}
|
||
|
||
// 纯表情消息(去掉短代码后只剩空白)按贴纸大图渲染
|
||
let rest = "";
|
||
let emojiPrev = 0;
|
||
for (const s of emojiSpans) {
|
||
rest += content.slice(emojiPrev, s.start);
|
||
emojiPrev = s.end;
|
||
}
|
||
rest += content.slice(emojiPrev);
|
||
const emojiOnly = emojiSpans.length > 0 && rest.trim() === "";
|
||
|
||
type Span = { start: number; end: number; node: React.ReactNode };
|
||
const spans: Span[] = [];
|
||
const re = /@([A-Za-z0-9_-]{3,32})/g;
|
||
let m: RegExpExecArray | null;
|
||
let i = 0;
|
||
while ((m = re.exec(content)) !== null) {
|
||
if (inEmoji[m.index]) continue;
|
||
const user = users.get(m[1].toLowerCase());
|
||
spans.push({
|
||
start: m.index,
|
||
end: m.index + m[0].length,
|
||
node: user ? (
|
||
<button
|
||
key={`m-${i}`}
|
||
type="button"
|
||
className="j13-mention-token"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onOpenCard(user);
|
||
}}
|
||
onContextMenu={(e) => e.stopPropagation()}
|
||
>
|
||
@{user.nickname || user.username}
|
||
</button>
|
||
) : (
|
||
<span key={`m-${i}`}>@{m[1]}</span>
|
||
),
|
||
});
|
||
i++;
|
||
}
|
||
for (const s of emojiSpans) {
|
||
spans.push({
|
||
start: s.start,
|
||
end: s.end,
|
||
node: (
|
||
<EmojiImg
|
||
key={`e-${s.start}`}
|
||
k={s.key}
|
||
className={`j13-chat-emoji${emojiOnly ? " j13-chat-emoji-lg" : ""}`}
|
||
/>
|
||
),
|
||
});
|
||
}
|
||
spans.sort((a, b) => a.start - b.start);
|
||
|
||
const parts: React.ReactNode[] = [];
|
||
let prev = 0;
|
||
spans.forEach((s) => {
|
||
if (s.start > prev) parts.push(content.slice(prev, s.start));
|
||
parts.push(s.node);
|
||
prev = s.end;
|
||
});
|
||
if (prev < content.length) parts.push(content.slice(prev));
|
||
return parts;
|
||
}
|
||
|
||
/** 消息正文:URL 自动识别 + @提及;外链新开跟随站点策略 */
|
||
function renderChatContent(
|
||
content: string,
|
||
users: Map<string, User>,
|
||
onOpenCard: (u: User) => void
|
||
) {
|
||
return (
|
||
<AutolinkText
|
||
text={content}
|
||
forceNewTab
|
||
renderText={(seg) => <>{highlightMentions(seg, users, onOpenCard)}</>}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 编辑框提及方案:内容直接存「@昵称」(宽度即昵称宽度,光标/选区天然对齐),
|
||
* mentionTokens 记录 昵称→用户名 映射,发送时再翻译回 @用户名(后端按用户名解析提醒)。
|
||
* 昵称可含空格/@ 等任意字符,因此按字面匹配扫描(最长优先 + 边界校验 + 占位去重)。
|
||
*/
|
||
function collectMentionSpans(
|
||
text: string,
|
||
tokens: Map<string, string>
|
||
): { start: number; end: number; key: string }[] {
|
||
const spans: { start: number; end: number; key: string }[] = [];
|
||
if (!text || tokens.size === 0) return spans;
|
||
const keys = [...tokens.keys()].sort((a, b) => b.length - a.length);
|
||
const occupied = new Array<boolean>(text.length).fill(false);
|
||
for (const key of keys) {
|
||
if (!key) continue;
|
||
const needle = `@${key}`;
|
||
let from = 0;
|
||
for (;;) {
|
||
const idx = text.indexOf(needle, from);
|
||
if (idx < 0) break;
|
||
from = idx + 1;
|
||
const end = idx + needle.length;
|
||
// @ 前需为行首/空白,昵称后需为空白/行尾(与插入时的「@昵称 」格式一致)
|
||
const prevCh = idx > 0 ? text[idx - 1] : "";
|
||
if (prevCh && !/\s/.test(prevCh)) continue;
|
||
const nextCh = end < text.length ? text[end] : "";
|
||
if (nextCh && !/\s/.test(nextCh)) continue;
|
||
let clash = false;
|
||
for (let i = idx; i < end; i++) {
|
||
if (occupied[i]) {
|
||
clash = true;
|
||
break;
|
||
}
|
||
}
|
||
if (clash) continue;
|
||
for (let i = idx; i < end; i++) occupied[i] = true;
|
||
spans.push({ start: idx, end, key });
|
||
}
|
||
}
|
||
return spans.sort((a, b) => a.start - b.start);
|
||
}
|
||
|
||
/**
|
||
* 富输入区(contentEditable):编辑框内直接渲染表情图片与 @ 昵称胶囊,
|
||
* content 状态始终保存纯文本(表情为 [:key:] 短代码、@ 为 @昵称),
|
||
* 发送/提醒/草稿等既有字符串逻辑不变。DOM 为临时视图,外部内容变更时整树重建。
|
||
*/
|
||
const ZWSP = "\u200B";
|
||
|
||
/** DOM 子树 → 纯文本长度(表情短代码按 data-sc 长度计,ZWSP 不计) */
|
||
function richNodeLen(node: Node): number {
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
return (node as Text).data.split(ZWSP).join("").length;
|
||
}
|
||
if (node.nodeType !== Node.ELEMENT_NODE) return 0;
|
||
const el = node as HTMLElement;
|
||
if (el.tagName === "IMG" && el.dataset.sc) return el.dataset.sc.length;
|
||
if (el.tagName === "BR") return 1;
|
||
let sum = 0;
|
||
el.childNodes.forEach((c) => {
|
||
sum += richNodeLen(c);
|
||
});
|
||
return sum;
|
||
}
|
||
|
||
/** contentEditable DOM → content 纯文本 */
|
||
function domToText(root: HTMLElement): string {
|
||
let out = "";
|
||
root.childNodes.forEach((node) => {
|
||
out += serializeRichNode(node);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function serializeRichNode(node: Node): string {
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
return (node as Text).data.split(ZWSP).join("");
|
||
}
|
||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||
const el = node as HTMLElement;
|
||
if (el.tagName === "IMG" && el.dataset.sc) return el.dataset.sc;
|
||
if (el.tagName === "BR") return "\n";
|
||
let out = "";
|
||
el.childNodes.forEach((c) => {
|
||
out += serializeRichNode(c);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/** 光标在 content 纯文本中的偏移(表情按短代码长度、@ 胶囊按昵称长度计) */
|
||
function richCaretOffset(root: HTMLElement, container: Node, offsetIdx: number): number {
|
||
let count = 0;
|
||
let node: Node | null = container;
|
||
while (node && node !== root) {
|
||
const parent: HTMLElement | null = node.parentElement;
|
||
if (!parent) break;
|
||
let sib: Node | null = node.previousSibling;
|
||
while (sib) {
|
||
count += richNodeLen(sib);
|
||
sib = sib.previousSibling;
|
||
}
|
||
node = parent;
|
||
}
|
||
if (container.nodeType === Node.TEXT_NODE) {
|
||
count += (container as Text).data.slice(0, offsetIdx).split(ZWSP).join("").length;
|
||
} else if (container.nodeType === Node.ELEMENT_NODE) {
|
||
const kids = (container as Element).childNodes;
|
||
for (let i = 0; i < offsetIdx && i < kids.length; i++) {
|
||
count += richNodeLen(kids[i]);
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
|
||
/** content 纯文本偏移 → DOM 位置(仅用于定位纯文本区间,如 @ 触发串) */
|
||
function richResolveOffset(
|
||
root: HTMLElement,
|
||
target: number
|
||
): { node: Node; offset: number } {
|
||
let acc = 0;
|
||
const kids = root.childNodes;
|
||
for (let i = 0; i < kids.length; i++) {
|
||
const child = kids[i];
|
||
const len = richNodeLen(child);
|
||
if (target < acc + len) {
|
||
if (child.nodeType === Node.TEXT_NODE) {
|
||
// 文本节点可能以零宽字符开头(表情后输入的文本),偏移需按可见字符折算
|
||
const t = child as Text;
|
||
const data = t.data;
|
||
let j = 0;
|
||
while (j < data.length && data[j] === ZWSP) j++;
|
||
let need = target - acc;
|
||
while (j < data.length && need > 0) {
|
||
if (data[j] !== ZWSP) need--;
|
||
j++;
|
||
}
|
||
return { node: t, offset: j };
|
||
}
|
||
return { node: root, offset: i };
|
||
}
|
||
acc += len;
|
||
}
|
||
return { node: root, offset: kids.length };
|
||
}
|
||
|
||
/** content 纯文本 → 富 DOM(表情图 + @ 昵称胶囊 + 文本) */
|
||
function rebuildRichInput(
|
||
el: HTMLElement,
|
||
text: string,
|
||
tokens: Map<string, string>
|
||
) {
|
||
el.textContent = "";
|
||
const frag = document.createDocumentFragment();
|
||
const pieces = [
|
||
...collectMentionSpans(text, tokens).map((s) => ({ ...s, kind: "mention" as const })),
|
||
...collectDyEmojiSpans(text).map((s) => ({ ...s, kind: "emoji" as const })),
|
||
].sort((a, b) => a.start - b.start);
|
||
let last = 0;
|
||
for (const p of pieces) {
|
||
if (p.start < last) continue; // 与 @ 胶囊重叠的表情跳过
|
||
if (p.start > last) {
|
||
frag.appendChild(document.createTextNode(text.slice(last, p.start)));
|
||
}
|
||
if (p.kind === "mention") {
|
||
const span = document.createElement("span");
|
||
span.className = "j13-mention-token";
|
||
span.contentEditable = "false";
|
||
span.textContent = `@${p.key}`;
|
||
frag.appendChild(span);
|
||
} else {
|
||
frag.appendChild(makeEmojiNode(p.key));
|
||
}
|
||
last = p.end;
|
||
}
|
||
if (last < text.length) {
|
||
frag.appendChild(document.createTextNode(text.slice(last)));
|
||
}
|
||
el.appendChild(frag);
|
||
}
|
||
|
||
function makeEmojiNode(key: string): HTMLImageElement {
|
||
const img = document.createElement("img");
|
||
img.src = dyEmojiUrl(key);
|
||
img.dataset.sc = `[:${key}:]`;
|
||
img.alt = "表情";
|
||
img.title = `[:${key}:]`;
|
||
img.draggable = false;
|
||
img.className = "j13-input-emoji";
|
||
return img;
|
||
}
|
||
|
||
function placeCaretEnd(el: HTMLElement) {
|
||
const sel = window.getSelection();
|
||
if (!sel) return;
|
||
const range = document.createRange();
|
||
range.selectNodeContents(el);
|
||
range.collapse(false);
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
}
|
||
|
||
/** 发送前翻译:@昵称 → @用户名(后端按用户名解析提及) */
|
||
function translateMentionsOut(text: string, tokens: Map<string, string>): string {
|
||
if (tokens.size === 0) return text;
|
||
const spans = collectMentionSpans(text, tokens);
|
||
if (spans.length === 0) return text;
|
||
let out = "";
|
||
let last = 0;
|
||
for (const s of spans) {
|
||
out += text.slice(last, s.start) + `@${tokens.get(s.key) || s.key}`;
|
||
last = s.end;
|
||
}
|
||
return out + text.slice(last);
|
||
}
|
||
|
||
/** 服务端文本(@用户名)→ 编辑框文本(@昵称),并为可识别的提及建立 token(重新编辑草稿用) */
|
||
function hydrateMentionsIn(
|
||
text: string,
|
||
userIndex: Map<string, User>
|
||
): { text: string; tokens: Map<string, string> } {
|
||
const re = /@([A-Za-z0-9_-]{3,32})/g;
|
||
const replaces: { start: number; end: number; display: string; username: string }[] = [];
|
||
let m: RegExpExecArray | null;
|
||
while ((m = re.exec(text)) !== null) {
|
||
const u = userIndex.get(m[1].toLowerCase());
|
||
if (!u) continue;
|
||
const display = u.nickname || u.username;
|
||
if (!display || display === m[1]) continue;
|
||
replaces.push({
|
||
start: m.index,
|
||
end: m.index + m[0].length,
|
||
display,
|
||
username: m[1],
|
||
});
|
||
}
|
||
const tokens = new Map<string, string>();
|
||
let out = text;
|
||
for (let i = replaces.length - 1; i >= 0; i--) {
|
||
const r = replaces[i];
|
||
out = out.slice(0, r.start) + `@${r.display}` + out.slice(r.end);
|
||
tokens.set(r.display, r.username);
|
||
}
|
||
return { text: out, tokens };
|
||
}
|
||
|
||
export default function ChatRoomClient({
|
||
roomId,
|
||
roomName,
|
||
roomDescription,
|
||
ownerId,
|
||
memberCount: initialMemberCount,
|
||
myRole,
|
||
joined,
|
||
isPrivate,
|
||
isDefault,
|
||
roomType,
|
||
peer,
|
||
meId,
|
||
meRole,
|
||
canManageMessages = false,
|
||
initialMessages,
|
||
initialHasMore,
|
||
embedded = false,
|
||
}: Props) {
|
||
const router = useRouter();
|
||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
||
const [hasMore, setHasMore] = useState(initialHasMore);
|
||
const [loadingMore, setLoadingMore] = useState(false);
|
||
// 加载历史的在飞锁(同步 ref,滚动事件连发时 state 闭包来不及更新,见 loadMore)
|
||
const loadingMoreRef = useRef(false);
|
||
// 上翻加载完成后恢复 scrollTop:column-reverse 理论上 prepend 不移动视口,
|
||
// 但 React 重渲染期间某些浏览器/场景会重置 scrollTop,强制恢复兜底
|
||
const stabilizeScrollRef = useRef(false);
|
||
const stabilizeScrollValueRef = useRef(0);
|
||
// handleScroll 冷却:一次自动加载后 800ms 内不再触发,防链式冲顶
|
||
const lastAutoLoadRef = useRef(0);
|
||
// 「回到底部」悬浮钮:column-reverse 下 scrollTop≤0(视觉底部为 0,上翻为负),
|
||
// -scrollTop 即距底部距离;newBelowCount 为翻历史期间新到的、尚未“看见”的消息数
|
||
const [showJumpBottom, setShowJumpBottom] = useState(false);
|
||
const [newBelowCount, setNewBelowCount] = useState(0);
|
||
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 [userCtxMenu, setUserCtxMenu] = useState<{
|
||
x: number;
|
||
y: number;
|
||
user: User;
|
||
member?: ChatRoomMember;
|
||
} | null>(null);
|
||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||
/** 禁言弹窗(含时长选择,QQ 风格) */
|
||
const [muteTarget, setMuteTarget] = useState<{
|
||
uid: number;
|
||
nickname: string;
|
||
} | null>(null);
|
||
const [muteBusy, setMuteBusy] = useState(false);
|
||
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
|
||
const [mentionOpen, setMentionOpen] = useState(false);
|
||
const [mentionFilter, setMentionFilter] = useState("");
|
||
const [mentionIndex, setMentionIndex] = useState(0);
|
||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||
/** 从选择器/悬浮确认插入的 @:key=展示昵称,value=用户名(发送时翻译回) */
|
||
const [mentionTokens, setMentionTokens] = useState<Map<string, string>>(
|
||
() => new Map()
|
||
);
|
||
/** 用户资料卡(头像/@昵称点击弹出) */
|
||
const [userCard, setUserCard] = useState<ChatUserCardTarget | null>(null);
|
||
/** 本人撤回后的可重新编辑草稿(仅本端内存,不展示正文) */
|
||
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<HTMLDivElement>(null);
|
||
/** 富输入区最近一次同步到 DOM 的 content 纯文本(区分内部输入与外部内容变更) */
|
||
const lastRichText = useRef<string>("");
|
||
const msgRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||
const composerBoxRef = useRef<HTMLDivElement>(null);
|
||
/** 表情按钮+面板容器:外点(含点击输入框)即关面板 */
|
||
const emojiPanelWrapRef = useRef<HTMLDivElement>(null);
|
||
|
||
// iOS/WebKit 不支持 interactive-widget=resizes-content(仅 Android Chrome 生效):
|
||
// 软键盘弹出时直接盖住锁视口布局的底部输入框,页面 overflow:hidden 又无处可滚。
|
||
// 监听 visualViewport,把 composer 整体上移键盘遮挡高度,并给消息列表加同等
|
||
// 底部内边距让最新消息保持在输入框上方。Android 下遮挡恒为 0,自动空转。
|
||
// 不按聚焦状态门控:键盘未收起期间保持位移,避免点发送按钮时按钮下移丢点击;
|
||
// 用 scale===1 排除双指缩放造成的 visualViewport 变化(键盘不改变 scale)。
|
||
useEffect(() => {
|
||
const vv = window.visualViewport;
|
||
const composerBox = composerBoxRef.current;
|
||
if (!vv || !composerBox) return;
|
||
let raf = 0;
|
||
const apply = () => {
|
||
raf = 0;
|
||
const overlap = Math.round(
|
||
window.innerHeight - vv.offsetTop - vv.height
|
||
);
|
||
const list = listRef.current;
|
||
if (overlap > 80 && vv.scale === 1) {
|
||
composerBox.style.transform = `translateY(-${overlap}px)`;
|
||
if (list) list.style.paddingBottom = `${overlap + 16}px`;
|
||
} else {
|
||
composerBox.style.transform = "";
|
||
if (list) list.style.paddingBottom = "";
|
||
}
|
||
};
|
||
const schedule = () => {
|
||
if (!raf) raf = requestAnimationFrame(apply);
|
||
};
|
||
vv.addEventListener("resize", schedule);
|
||
vv.addEventListener("scroll", schedule);
|
||
return () => {
|
||
vv.removeEventListener("resize", schedule);
|
||
vv.removeEventListener("scroll", schedule);
|
||
if (raf) cancelAnimationFrame(raf);
|
||
composerBox.style.transform = "";
|
||
if (listRef.current) listRef.current.style.paddingBottom = "";
|
||
};
|
||
}, []);
|
||
|
||
const isOwner = myRole === "owner";
|
||
const isRoomAdmin = myRole === "admin";
|
||
const isDirect = roomType === "direct";
|
||
const isRealMember = myRole === "owner" || myRole === "admin" || myRole === "member";
|
||
const canView = joined;
|
||
const oversee = canOverseeChat(meRole);
|
||
const siteOwner = isSiteOwner(meRole);
|
||
const canModerateChat =
|
||
oversee || isOwner || isRoomAdmin || (!!canManageMessages && !isDirect);
|
||
const canManageInvite = !isDirect && (isOwner || isRoomAdmin || oversee);
|
||
const canKickMembers = isOwner || isRoomAdmin || oversee;
|
||
|
||
/** @解析索引:成员表 + 消息发送者(成员未加载/已退群仍能显示昵称) */
|
||
const userIndex = useMemo(() => {
|
||
const idx = new Map<string, User>();
|
||
const add = (u?: User | null) => {
|
||
if (u?.username) idx.set(u.username.toLowerCase(), u);
|
||
};
|
||
members.forEach((m) => add(m.user));
|
||
messages.forEach((m) => add(m.sender));
|
||
return idx;
|
||
}, [members, messages]);
|
||
|
||
const openUserCard = useCallback(
|
||
(u: User) => {
|
||
// 菜单互斥:打开资料卡前关掉两个右键菜单
|
||
setCtxMenu(null);
|
||
setUserCtxMenu(null);
|
||
const mem = members.find((m) => m.user_id === u.id);
|
||
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted, mutedUntil: mem?.muted_until });
|
||
},
|
||
[members]
|
||
);
|
||
|
||
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(
|
||
(opts?: { force?: boolean }) => {
|
||
if (!isRealMember) return;
|
||
requestMarkChatRead(roomId, opts);
|
||
},
|
||
[roomId, isRealMember]
|
||
);
|
||
|
||
// 悬浮钮/新消息引导回底:跳底 + 推进已读 + 清空待看计数
|
||
const jumpToBottom = useCallback(() => {
|
||
setNewBelowCount(0);
|
||
scrollToBottom(true);
|
||
markReadToLatest();
|
||
}, [scrollToBottom, markReadToLatest]);
|
||
|
||
useEffect(() => {
|
||
if (!canView) return;
|
||
realtime.joinRoom(roomId);
|
||
void refreshMembers();
|
||
// 列表用 flex-col-reverse,SSR/无 JS 默认即贴底,无需首屏 scrollToBottom(避免 F5 闪一下)
|
||
markReadToLatest();
|
||
// 换房时重置「回到底部」悬浮钮状态
|
||
setShowJumpBottom(false);
|
||
setNewBelowCount(0);
|
||
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();
|
||
} else if (data.sender_id !== 0) {
|
||
// 正在上方翻历史:不强行跟到底,计数交给「回到底部」按钮提示;
|
||
// 系统提示(禁言/解禁等管理动作)不计入「新消息」提醒
|
||
setNewBelowCount((c) => c + 1);
|
||
}
|
||
});
|
||
});
|
||
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: canModerateChat ? 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, canModerateChat, 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]);
|
||
|
||
// 禁言/解禁实时通知:更新成员列表 + 插入系统提示
|
||
useEffect(() => {
|
||
const off = realtime.on<RtChatMemberMutedData>(
|
||
RT_CHAT_MEMBER_MUTED,
|
||
(data) => {
|
||
if (!data || data.room_id !== roomId) return;
|
||
setMembers((prev) =>
|
||
prev.map((m) =>
|
||
m.user_id === data.user_id
|
||
? { ...m, muted: data.muted, muted_until: data.muted_until }
|
||
: m
|
||
)
|
||
);
|
||
}
|
||
);
|
||
return off;
|
||
}, [roomId, members, meId]);
|
||
|
||
// 禁言/解禁系统提示(居中展示,类似撤回提示)
|
||
const mutedNotice = useMemo(() => {
|
||
const mem = members.find((m) => m.user_id === meId);
|
||
if (!mem?.muted) return null;
|
||
return mem;
|
||
}, [members, meId]);
|
||
|
||
const muteCountdown = useCountdown(mutedNotice?.muted_until, () => {
|
||
// 倒计时到期:前端自动解除禁言状态(惰性清理,无需等待 WS 或发消息)
|
||
setMembers((prev) =>
|
||
prev.map((m) =>
|
||
m.user_id === meId ? { ...m, muted: false, muted_until: null } : m
|
||
)
|
||
);
|
||
});
|
||
|
||
const loadMore = useCallback(async () => {
|
||
// 在飞锁必须用 ref:滚动事件在一次上翻里连发,React 尚未重渲染时
|
||
// loadingMore(state 闭包)仍是 false,会并发连发多页请求。
|
||
if (loadingMoreRef.current) return;
|
||
if (!canView || !hasMore || loadingMore || messages.length === 0) return;
|
||
loadingMoreRef.current = true;
|
||
setLoadingMore(true);
|
||
// 保存当前滚动位置,prepend 完成后恢复(防 React 重渲染导致的意外重置)
|
||
const el = listRef.current;
|
||
const savedScrollTop = el?.scrollTop ?? 0;
|
||
try {
|
||
const before = messages[0].id;
|
||
const res = await apiListChatMessages(roomId, before, 30);
|
||
if ((res.messages || []).length > 0) {
|
||
// 去重兜底:防止旧闭包重复拉同一页导致重复 key
|
||
setMessages((prev) => {
|
||
const seen = new Set(prev.map((p) => p.id));
|
||
const fresh = res.messages.filter((x) => !seen.has(x.id));
|
||
return fresh.length ? [...fresh, ...prev] : prev;
|
||
});
|
||
setHasMore(res.has_more);
|
||
// 标记需要在提交后恢复滚动位置
|
||
stabilizeScrollValueRef.current = savedScrollTop;
|
||
stabilizeScrollRef.current = true;
|
||
} else {
|
||
setHasMore(false);
|
||
}
|
||
} catch {
|
||
toast("加载历史消息失败");
|
||
} finally {
|
||
loadingMoreRef.current = false;
|
||
setLoadingMore(false);
|
||
}
|
||
}, [canView, hasMore, loadingMore, messages, roomId]);
|
||
|
||
// prepend 完成后恢复滚动位置:column-reverse 理论上应稳定,但某些浏览器在
|
||
// React 提交 DOM 时会重置 scrollTop,强制恢复以保证视口不动
|
||
useLayoutEffect(() => {
|
||
if (!stabilizeScrollRef.current) return;
|
||
stabilizeScrollRef.current = false;
|
||
const el = listRef.current;
|
||
if (el) {
|
||
el.scrollTop = stabilizeScrollValueRef.current;
|
||
}
|
||
}, [messages]);
|
||
|
||
const handleScroll = useCallback(() => {
|
||
const el = listRef.current;
|
||
if (!el) return;
|
||
// column-reverse:scrollTop≤0(视觉底部为 0,上翻为负),-scrollTop 即距底部距离
|
||
const fromBottom = -el.scrollTop;
|
||
// 滚向上方历史:距顶部不足 60px 时加载更多;加 800ms 冷却防链式冲顶
|
||
const now = Date.now();
|
||
if (el.scrollHeight - el.clientHeight - fromBottom < 60 && now - lastAutoLoadRef.current > 800) {
|
||
lastAutoLoadRef.current = now;
|
||
loadMore();
|
||
}
|
||
if (fromBottom < 60) {
|
||
markReadToLatest();
|
||
}
|
||
// 「回到底部」:离开底部一段距离即出现,接近底部时消失
|
||
setShowJumpBottom(fromBottom > 160);
|
||
if (fromBottom < 120) {
|
||
setNewBelowCount(0);
|
||
}
|
||
}, [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 Map());
|
||
setReplyTo(null);
|
||
setMentionOpen(false);
|
||
requestAnimationFrame(() => syncComposerHeight());
|
||
try {
|
||
// 编辑框内存「@昵称」,发出前翻译回 @用户名(后端按用户名解析提及)
|
||
const { message } = await apiSendChatMessage(
|
||
roomId,
|
||
translateMentionsOut(prevContent, prevTokens),
|
||
replyId
|
||
);
|
||
setMessages((prev) => {
|
||
if (prev.some((m) => m.id === message.id)) return prev;
|
||
return [...prev, message];
|
||
});
|
||
requestAnimationFrame(() => scrollToBottom(true));
|
||
// 发送成功立刻同步侧栏预览,并强制推进已读(避开进房 MarkRead 的 2s 节流)
|
||
window.dispatchEvent(
|
||
new CustomEvent("j13-chat-sent", { detail: { roomId, message } })
|
||
);
|
||
markReadToLatest({ force: true });
|
||
} 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);
|
||
})();
|
||
|
||
/**
|
||
* 插入 @提及(DOM 级操作):
|
||
* - replaceTrigger:替换光标前未完成的 "@xx" 触发串(选择器路径)
|
||
* - 否则在光标处追加(昵称悬浮 @ 路径),必要时补前导空格
|
||
* 插入后序列化 DOM 回 content 纯文本并记录 昵称→用户名 映射
|
||
*/
|
||
const insertMention = (
|
||
username: string,
|
||
nickname: string,
|
||
opts?: { replaceTrigger?: boolean }
|
||
) => {
|
||
const el = inputRef.current;
|
||
if (!el) return;
|
||
el.focus();
|
||
const sel = window.getSelection();
|
||
let range: Range;
|
||
if (sel && sel.rangeCount > 0 && el.contains(sel.getRangeAt(0).startContainer)) {
|
||
range = sel.getRangeAt(0);
|
||
} else {
|
||
range = document.createRange();
|
||
range.selectNodeContents(el);
|
||
range.collapse(false);
|
||
}
|
||
const caret = richCaretOffset(el, range.startContainer, range.startOffset);
|
||
const text = domToText(el);
|
||
const display = nickname || username;
|
||
let removeStart = caret;
|
||
let leadSpace = false;
|
||
if (opts?.replaceTrigger) {
|
||
const at = text.slice(0, caret).lastIndexOf("@");
|
||
if (at >= 0) removeStart = at;
|
||
} else if (caret > 0 && text[caret - 1] && !/\s/.test(text[caret - 1])) {
|
||
leadSpace = true;
|
||
}
|
||
const span = document.createElement("span");
|
||
span.className = "j13-mention-token";
|
||
span.contentEditable = "false";
|
||
span.textContent = `@${display}`;
|
||
const space = document.createTextNode(" ");
|
||
const p1 = richResolveOffset(el, removeStart);
|
||
const r2 = document.createRange();
|
||
r2.setStart(p1.node, p1.offset);
|
||
const p2 = richResolveOffset(el, caret);
|
||
r2.setEnd(p2.node, p2.offset);
|
||
r2.deleteContents();
|
||
const frag = document.createDocumentFragment();
|
||
if (leadSpace) frag.appendChild(document.createTextNode(" "));
|
||
frag.appendChild(span);
|
||
frag.appendChild(space);
|
||
r2.insertNode(frag);
|
||
const r3 = document.createRange();
|
||
r3.setStartAfter(space);
|
||
r3.collapse(true);
|
||
sel?.removeAllRanges();
|
||
sel?.addRange(r3);
|
||
const next = domToText(el);
|
||
lastRichText.current = next;
|
||
setContent(next);
|
||
setMentionTokens((prev) => new Map(prev).set(display, username));
|
||
setMentionOpen(false);
|
||
setMentionFilter("");
|
||
requestAnimationFrame(syncComposerHeight);
|
||
};
|
||
|
||
const pruneMentionTokens = (text: string, tokens: Map<string, string>) => {
|
||
if (tokens.size === 0) return tokens;
|
||
const present = new Set(
|
||
collectMentionSpans(text, tokens).map((s) => s.key)
|
||
);
|
||
if (present.size === tokens.size) return tokens;
|
||
const next: Map<string, string> = new Map();
|
||
tokens.forEach((username, key) => {
|
||
if (present.has(key)) next.set(key, username);
|
||
});
|
||
return next;
|
||
};
|
||
|
||
/** 表情面板:外点(含输入框)/ Esc 关闭 */
|
||
useEffect(() => {
|
||
if (!emojiOpen) return;
|
||
const onDoc = (e: MouseEvent) => {
|
||
if (
|
||
emojiPanelWrapRef.current &&
|
||
!emojiPanelWrapRef.current.contains(e.target as Node)
|
||
) {
|
||
setEmojiOpen(false);
|
||
}
|
||
};
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key !== "Escape") return;
|
||
e.stopPropagation();
|
||
setEmojiOpen(false);
|
||
inputRef.current?.focus();
|
||
};
|
||
document.addEventListener("mousedown", onDoc);
|
||
document.addEventListener("keydown", onKey, true);
|
||
return () => {
|
||
document.removeEventListener("mousedown", onDoc);
|
||
document.removeEventListener("keydown", onKey, true);
|
||
};
|
||
}, [emojiOpen]);
|
||
|
||
/** 表情面板选表情:光标处插入表情图节点,选完即关面板 */
|
||
const insertEmoji = (key: string) => {
|
||
const el = inputRef.current;
|
||
if (!el) return;
|
||
setEmojiOpen(false);
|
||
el.focus();
|
||
const sel = window.getSelection();
|
||
let range: Range;
|
||
if (sel && sel.rangeCount > 0 && el.contains(sel.getRangeAt(0).startContainer)) {
|
||
range = sel.getRangeAt(0);
|
||
} else {
|
||
range = document.createRange();
|
||
range.selectNodeContents(el);
|
||
range.collapse(false);
|
||
}
|
||
range.deleteContents();
|
||
const img = makeEmojiNode(key);
|
||
range.insertNode(img);
|
||
// 表情后补零宽空格,给光标一个落点(序列化时剔除)
|
||
const zw = document.createTextNode(ZWSP);
|
||
range.setStartAfter(img);
|
||
range.collapse(true);
|
||
range.insertNode(zw);
|
||
range.setStartAfter(zw);
|
||
range.collapse(true);
|
||
sel?.removeAllRanges();
|
||
sel?.addRange(range);
|
||
const next = domToText(el);
|
||
lastRichText.current = next;
|
||
setContent(next);
|
||
setMentionTokens((prev) => pruneMentionTokens(next, prev));
|
||
requestAnimationFrame(syncComposerHeight);
|
||
};
|
||
|
||
/** 外部内容变更(清空/草稿/撤回后恢复等)→ 重建富 DOM;内部输入不触发(DOM 即视图) */
|
||
useEffect(() => {
|
||
const el = inputRef.current;
|
||
if (!el || lastRichText.current === content) return;
|
||
rebuildRichInput(el, content, mentionTokens);
|
||
lastRichText.current = content;
|
||
if (document.activeElement === el) placeCaretEnd(el);
|
||
requestAnimationFrame(syncComposerHeight);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [content, mentionTokens]);
|
||
|
||
const handleRichInput = () => {
|
||
const el = inputRef.current;
|
||
if (!el || mutedNotice) return;
|
||
const text = domToText(el);
|
||
if (text === lastRichText.current) return;
|
||
if (text.length > 2000) {
|
||
// 超 2000 字:回滚到上次合法内容
|
||
rebuildRichInput(el, lastRichText.current, mentionTokens);
|
||
placeCaretEnd(el);
|
||
return;
|
||
}
|
||
lastRichText.current = text;
|
||
setContent(text);
|
||
setMentionTokens((prev) => pruneMentionTokens(text, prev));
|
||
// @ 触发检测(与原 textarea 逻辑一致)
|
||
const sel = window.getSelection();
|
||
let caret = text.length;
|
||
if (sel && sel.rangeCount > 0) {
|
||
const r = sel.getRangeAt(0);
|
||
caret = richCaretOffset(el, r.startContainer, r.startOffset);
|
||
}
|
||
const before = text.slice(0, caret);
|
||
const m = before.match(/@([^\s@]{0,32})$/);
|
||
if (m && isRealMember && !isDirect) {
|
||
setMentionOpen(true);
|
||
setMentionFilter(m[1] || "");
|
||
setMentionIndex(0);
|
||
} else {
|
||
setMentionOpen(false);
|
||
setMentionFilter("");
|
||
}
|
||
};
|
||
|
||
const handleRichKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||
// IME 组合中不拦截按键
|
||
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
||
if (mutedNotice) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
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,
|
||
pick.user.nickname || pick.user.username,
|
||
{ replaceTrigger: true }
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
setMentionOpen(false);
|
||
return;
|
||
}
|
||
}
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
if (e.shiftKey) {
|
||
document.execCommand("insertLineBreak");
|
||
} else {
|
||
send();
|
||
}
|
||
}
|
||
};
|
||
|
||
/** 粘贴一律按纯文本插入(换行转 <br>),避免富文本结构污染序列化 */
|
||
const handleRichPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
|
||
if (mutedNotice) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
const text = e.clipboardData.getData("text/plain");
|
||
if (!text) return;
|
||
const el = inputRef.current;
|
||
if (!el) return;
|
||
const sel = window.getSelection();
|
||
let range: Range;
|
||
if (sel && sel.rangeCount > 0 && el.contains(sel.getRangeAt(0).startContainer)) {
|
||
range = sel.getRangeAt(0);
|
||
} else {
|
||
range = document.createRange();
|
||
range.selectNodeContents(el);
|
||
range.collapse(false);
|
||
}
|
||
range.deleteContents();
|
||
const frag = document.createDocumentFragment();
|
||
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
||
lines.forEach((line, i) => {
|
||
if (i > 0) frag.appendChild(document.createElement("br"));
|
||
if (line) frag.appendChild(document.createTextNode(line));
|
||
});
|
||
range.insertNode(frag);
|
||
range.collapse(false);
|
||
sel?.removeAllRanges();
|
||
sel?.addRange(range);
|
||
handleRichInput();
|
||
};
|
||
|
||
const openMsgMenu = (m: ChatMessage, x: number, y: number) => {
|
||
if (m.recalled_at) return;
|
||
// 菜单互斥:打开消息菜单前关掉头像菜单与资料卡
|
||
setUserCtxMenu(null);
|
||
setUserCard(null);
|
||
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) => {
|
||
if (!canKickMembers) return;
|
||
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 handleMute = (uid: number, nickname: string, muted: boolean) => {
|
||
if (!canKickMembers) return;
|
||
// 入口可能是头像右键菜单或成员列表按钮:先兜底关闭浮层菜单,
|
||
// 避免低概率时序下菜单残留并与弹窗叠层(菜单 z-index 高于弹窗)
|
||
setUserCtxMenu(null);
|
||
setCtxMenu(null);
|
||
if (muted) {
|
||
// 打开时长选择弹窗(QQ 风格)
|
||
setMuteTarget({ uid, nickname });
|
||
return;
|
||
}
|
||
setConfirm({
|
||
title: "解除禁言",
|
||
message: `确定解除 ${nickname} 的禁言?`,
|
||
confirmLabel: "解禁",
|
||
action: async () => {
|
||
await apiMuteChatMember(roomId, uid, false);
|
||
setMembers((prev) =>
|
||
prev.map((m) =>
|
||
m.user_id === uid ? { ...m, muted: false, muted_until: null } : m
|
||
)
|
||
);
|
||
toast("已解禁", "ok");
|
||
},
|
||
});
|
||
};
|
||
|
||
const submitMute = async (durationMinutes: number) => {
|
||
if (!muteTarget) return;
|
||
setMuteBusy(true);
|
||
try {
|
||
await apiMuteChatMember(roomId, muteTarget.uid, true, durationMinutes);
|
||
const until =
|
||
durationMinutes > 0
|
||
? new Date(Date.now() + durationMinutes * 60_000).toISOString()
|
||
: null;
|
||
setMembers((prev) =>
|
||
prev.map((m) =>
|
||
m.user_id === muteTarget.uid
|
||
? { ...m, muted: true, muted_until: until }
|
||
: m
|
||
)
|
||
);
|
||
toast(
|
||
durationMinutes > 0 ? `已禁言 ${formatMuteDuration(durationMinutes)}` : "已永久禁言",
|
||
"ok"
|
||
);
|
||
setMuteTarget(null);
|
||
} catch (e) {
|
||
toast(e instanceof Error ? e.message : "禁言失败");
|
||
} finally {
|
||
setMuteBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleSetAdmin = (uid: number, nickname: string, makeAdmin: boolean) => {
|
||
if (!siteOwner) return;
|
||
setConfirm({
|
||
title: makeAdmin ? "设为群管理员" : "取消群管理员",
|
||
message: makeAdmin
|
||
? `确定将 ${nickname} 设为本群管理员?其可撤回消息、踢人与禁言。`
|
||
: `确定取消 ${nickname} 的群管理员身份?`,
|
||
confirmLabel: makeAdmin ? "任命" : "撤销",
|
||
danger: !makeAdmin,
|
||
action: async () => {
|
||
await apiSetChatMemberRole(roomId, uid, makeAdmin ? "admin" : "member");
|
||
setMembers((prev) =>
|
||
prev.map((m) =>
|
||
m.user_id === uid ? { ...m, role: makeAdmin ? "admin" : "member" } : m
|
||
)
|
||
);
|
||
toast(makeAdmin ? "已设为群管理员" : "已取消群管理员", "ok");
|
||
},
|
||
});
|
||
};
|
||
|
||
const canRecallMsg = (m: ChatMessage) => {
|
||
if (m.recalled_at) return false;
|
||
if (canModerateChat) {
|
||
// 站长(站点 Owner)的消息仅全站监管可撤回,群主/群管/站点消息管无权
|
||
if (!oversee && isSiteOwner(m.sender.role)) return false;
|
||
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: canModerateChat ? 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;
|
||
}
|
||
// 草稿存的是服务端文本(@用户名),回填时翻译为 @昵称 并重建 token
|
||
const { text: display, tokens } = hydrateMentionsIn(text, userIndex);
|
||
setReplyTo(null);
|
||
setContent(display);
|
||
setMentionTokens(tokens);
|
||
requestAnimationFrame(() => {
|
||
const el = inputRef.current;
|
||
if (!el) return;
|
||
el.focus();
|
||
placeCaretEnd(el);
|
||
syncComposerHeight();
|
||
});
|
||
};
|
||
|
||
const scrollToMsg = (id: number) => {
|
||
const el = msgRefs.current.get(id);
|
||
el?.scrollIntoView({ behavior: scrollBehavior(), 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: "profile",
|
||
label: "查看资料卡",
|
||
icon: <Users size={14} />,
|
||
onClick: () => openUserCard(m.sender),
|
||
});
|
||
}
|
||
if (canRecallMsg(m)) {
|
||
items.push({
|
||
key: "recall",
|
||
label: "撤回",
|
||
icon: <Undo2 size={14} />,
|
||
danger: true,
|
||
onClick: () => handleRecall(m.id),
|
||
});
|
||
}
|
||
return items;
|
||
};
|
||
|
||
/** 用户头像右键菜单:按权限展示成员管理项 */
|
||
const userMenuItems = (u: User, mem?: ChatRoomMember): CtxMenuItem[] => {
|
||
const close = () => setUserCtxMenu(null);
|
||
const items: CtxMenuItem[] = [
|
||
{
|
||
key: "profile",
|
||
label: "查看资料卡",
|
||
icon: <Users size={14} />,
|
||
onClick: () => {
|
||
close();
|
||
openUserCard(u);
|
||
},
|
||
},
|
||
];
|
||
if (u.id === meId || isDirect) return items;
|
||
|
||
const isTargetOwner = mem?.role === "owner";
|
||
const isTargetAdmin = mem?.role === "admin";
|
||
|
||
if (canKickMembers && !isTargetOwner) {
|
||
const muted = mem?.muted ?? false;
|
||
items.push({
|
||
key: "mute",
|
||
label: muted ? "解禁" : "禁言",
|
||
icon: <VolumeX size={14} />,
|
||
danger: !muted,
|
||
onClick: () => {
|
||
close();
|
||
handleMute(u.id, u.nickname || u.username, !muted);
|
||
},
|
||
});
|
||
items.push({
|
||
key: "kick",
|
||
label: "移出群聊",
|
||
icon: <UserX size={14} />,
|
||
danger: true,
|
||
onClick: () => {
|
||
close();
|
||
handleKick(u.id, u.nickname || u.username);
|
||
},
|
||
});
|
||
}
|
||
if (siteOwner && !isTargetOwner) {
|
||
items.push({
|
||
key: "setAdmin",
|
||
label: isTargetAdmin ? "取消管理员" : "设为管理员",
|
||
icon: <Crown size={14} />,
|
||
onClick: () => {
|
||
close();
|
||
handleSetAdmin(u.id, u.nickname || u.username, !isTargetAdmin);
|
||
},
|
||
});
|
||
}
|
||
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(--panel-2)]"
|
||
style={{ color: "var(--ink-2)" }}
|
||
aria-label="返回消息列表"
|
||
>
|
||
←
|
||
</Link>
|
||
)}
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{isDirect && peer && (
|
||
<button
|
||
type="button"
|
||
className="rounded-full transition-opacity hover:opacity-85"
|
||
onClick={() =>
|
||
openUserCard({
|
||
...peer,
|
||
role: "",
|
||
})
|
||
}
|
||
aria-label={`查看 ${peer.nickname || peer.username} 的资料卡`}
|
||
>
|
||
<Avatar
|
||
name={peer.nickname || peer.username}
|
||
src={peer.avatar || undefined}
|
||
size={24}
|
||
/>
|
||
</button>
|
||
)}
|
||
<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(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||
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(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||
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(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||
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)] focus-visible:bg-[var(--danger-soft)]"
|
||
style={{ color: "var(--ink-3)" }}
|
||
data-tip={isDirect ? "关闭私聊" : "退出群聊"}
|
||
>
|
||
<LogOut size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative flex-1 flex flex-col" style={{ minHeight: 0 }}>
|
||
<div
|
||
ref={listRef}
|
||
onScroll={handleScroll}
|
||
className="j13-chat-scroll flex-1 overflow-y-auto overflow-x-hidden flex flex-col-reverse px-4 sm:px-5 py-4"
|
||
// overflowAnchor:none:column-reverse 原点在底部,prepend 天然不移动视口;
|
||
// 关掉浏览器原生锚定,避免它对顶部内容变化做多余补偿
|
||
style={{ minHeight: 0, overflowAnchor: "none" }}
|
||
>
|
||
{!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)] focus-visible: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;
|
||
// 发送者的群内角色(群主/管理徽标展示用;私聊不需要)
|
||
const senderRoomRole = !isDirect
|
||
? members.find((x) => x.user_id === m.sender_id)?.role
|
||
: undefined;
|
||
|
||
// 系统消息(如禁言通知):居中灰字;禁言类通知红色突出
|
||
if (m.sender_id === 0) {
|
||
const isMuteNotice = m.content.includes("已被禁言");
|
||
return (
|
||
<div
|
||
key={m.id}
|
||
className="flex justify-center px-2 py-0.5"
|
||
>
|
||
<p
|
||
className="text-[12px] leading-relaxed text-center max-w-[90%]"
|
||
style={{
|
||
color: isMuteNotice
|
||
? "var(--danger)"
|
||
: "var(--ink-3)",
|
||
fontWeight: isMuteNotice ? 600 : undefined,
|
||
}}
|
||
>
|
||
{m.content}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 普通成员:居中系统提示(本人可重新编辑);不展示原文
|
||
if (recalled && !(canModerateChat && 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 && canModerateChat && 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">
|
||
{!grouped && (
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
className="rounded-full transition-opacity hover:opacity-85 cursor-pointer"
|
||
onClick={() => openUserCard(m.sender)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") openUserCard(m.sender);
|
||
}}
|
||
onContextMenu={(e) => {
|
||
if (m.sender_id === meId || isDirect) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const mem = members.find((x) => x.user_id === m.sender_id);
|
||
// 菜单互斥:打开头像菜单前关掉消息菜单与资料卡
|
||
setCtxMenu(null);
|
||
setUserCard(null);
|
||
setUserCtxMenu({
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
user: m.sender,
|
||
member: mem,
|
||
});
|
||
}}
|
||
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
|
||
>
|
||
<Avatar
|
||
name={m.sender.nickname || m.sender.username}
|
||
src={m.sender.avatar || undefined}
|
||
size={32}
|
||
/>
|
||
</div>
|
||
)}
|
||
</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>
|
||
{!isDirect && senderRoomRole === "admin" && (
|
||
<span
|
||
className="text-[10px] font-medium px-1 rounded"
|
||
style={{
|
||
color: "var(--accent)",
|
||
background:
|
||
"color-mix(in srgb, var(--accent) 12%, transparent)",
|
||
}}
|
||
>
|
||
管理
|
||
</span>
|
||
)}
|
||
{!isDirect && (
|
||
<>
|
||
<LevelBadge level={m.sender.level} />
|
||
{(m.sender.badges ?? []).slice(0, 1).map((ub) => (
|
||
<BadgeChip key={ub.id} badge={ub.badge} />
|
||
))}
|
||
</>
|
||
)}
|
||
<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] [overflow-wrap:anywhere] whitespace-pre-wrap select-text w-full"
|
||
style={{
|
||
background:
|
||
"color-mix(in srgb, var(--gold) 7%, var(--panel-2))",
|
||
border:
|
||
"1.5px dashed color-mix(in srgb, var(--gold) 55%, transparent)",
|
||
color: "var(--ink)",
|
||
borderBottomRightRadius: mine ? 4 : undefined,
|
||
borderBottomLeftRadius: mine ? undefined : 4,
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-1.5 mb-1.5">
|
||
<span
|
||
className="inline-flex items-center gap-1 text-[10.5px] font-medium px-1.5 py-px rounded-full"
|
||
style={{
|
||
color: "var(--gold)",
|
||
background:
|
||
"color-mix(in srgb, var(--gold) 14%, transparent)",
|
||
border:
|
||
"1px solid color-mix(in srgb, var(--gold) 35%, transparent)",
|
||
}}
|
||
>
|
||
<Undo2 size={10} strokeWidth={2.4} />
|
||
已撤回
|
||
</span>
|
||
<span
|
||
className="text-[10.5px]"
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
仅管理可见
|
||
</span>
|
||
</div>
|
||
<div className="italic" style={{ color: "var(--ink-2)" }}>
|
||
{renderChatContent(m.content, userIndex, openUserCard)}
|
||
</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 && (
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
className="rounded-full transition-opacity hover:opacity-85 cursor-pointer"
|
||
onClick={() => openUserCard(m.sender)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") openUserCard(m.sender);
|
||
}}
|
||
onContextMenu={(e) => {
|
||
if (m.sender_id === meId || isDirect) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const mem = members.find((x) => x.user_id === m.sender_id);
|
||
// 菜单互斥:打开头像菜单前关掉消息菜单与资料卡
|
||
setCtxMenu(null);
|
||
setUserCard(null);
|
||
setUserCtxMenu({
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
user: m.sender,
|
||
member: mem,
|
||
});
|
||
}}
|
||
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
|
||
>
|
||
<Avatar
|
||
name={m.sender.nickname || m.sender.username}
|
||
src={m.sender.avatar || undefined}
|
||
size={32}
|
||
/>
|
||
</div>
|
||
)}
|
||
</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)" }} />
|
||
)}
|
||
<button
|
||
type="button"
|
||
className={`text-[12px] truncate transition-colors ${
|
||
!isDirect && isRealMember
|
||
? "group/nm cursor-pointer text-[color:var(--ink-2)] hover:text-[color:var(--accent)]"
|
||
: "cursor-default text-[color:var(--ink-2)]"
|
||
}`}
|
||
onClick={() => {
|
||
if (!isDirect && isRealMember) {
|
||
insertMention(
|
||
m.sender.username,
|
||
m.sender.nickname || m.sender.username
|
||
);
|
||
}
|
||
}}
|
||
disabled={isDirect || !isRealMember}
|
||
>
|
||
{/* 悬浮时名字前出现 @,整个名字成为可 @ 点击对象 */}
|
||
{!isDirect && isRealMember && (
|
||
<span
|
||
aria-hidden
|
||
className="hidden group-hover/nm:inline font-semibold text-[color:var(--accent)]"
|
||
>
|
||
@
|
||
</span>
|
||
)}
|
||
{m.sender.nickname}
|
||
</button>
|
||
{!isDirect && senderRoomRole === "admin" && (
|
||
<span
|
||
className="text-[10px] font-medium px-1 rounded"
|
||
style={{
|
||
color: "var(--accent)",
|
||
background:
|
||
"color-mix(in srgb, var(--accent) 12%, transparent)",
|
||
}}
|
||
>
|
||
管理
|
||
</span>
|
||
)}
|
||
{/* 群聊与私聊都展示等级/徽章标识 */}
|
||
<LevelBadge level={m.sender.level} />
|
||
{(m.sender.badges ?? []).slice(0, 1).map((ub) => (
|
||
<BadgeChip key={ub.id} badge={ub.badge} />
|
||
))}
|
||
<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] [overflow-wrap:anywhere] 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 [overflow-wrap:anywhere]"
|
||
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>
|
||
)}
|
||
{renderChatContent(m.content, userIndex, openUserCard)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* 回到底部:飞书式悬浮钮,贴近当前对话框右下角;翻历史期间带新消息计数 */}
|
||
{canView && showJumpBottom && (
|
||
<button
|
||
type="button"
|
||
onClick={jumpToBottom}
|
||
aria-label="回到底部"
|
||
data-tip="回到底部"
|
||
className="absolute right-4 sm:right-5 bottom-3 z-10 w-10 h-10 rounded-full flex items-center justify-center border border-gray-300 hover:border-gray-400 focus-visible:border-gray-400 transition-colors j13-toast-in text-black hover:bg-gray-50"
|
||
style={{
|
||
background: "#fff",
|
||
boxShadow: "var(--shadow)",
|
||
}}
|
||
>
|
||
<ChevronsDown size={18} />
|
||
{newBelowCount > 0 && (
|
||
<span
|
||
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-medium flex items-center justify-center rounded-full"
|
||
style={{
|
||
background: "var(--clay)",
|
||
color: "#fff",
|
||
border: "2px solid var(--bg)",
|
||
}}
|
||
>
|
||
{newBelowCount > 99 ? "99+" : newBelowCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{isRealMember ? (
|
||
<div
|
||
ref={composerBoxRef}
|
||
className="shrink-0 px-3 sm:px-4 py-3"
|
||
style={{
|
||
borderTop: "1px solid var(--line)",
|
||
background: "var(--panel)",
|
||
}}
|
||
>
|
||
{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(--panel-2)]"
|
||
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,
|
||
m.user.nickname || m.user.username,
|
||
{ replaceTrigger: true }
|
||
);
|
||
}}
|
||
>
|
||
<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.closest("[contenteditable='true']")) return;
|
||
e.preventDefault();
|
||
inputRef.current?.focus();
|
||
}}
|
||
>
|
||
<div className="relative shrink-0 flex items-center" ref={emojiPanelWrapRef}>
|
||
<button
|
||
type="button"
|
||
disabled={!!mutedNotice}
|
||
className={`w-9 h-9 flex items-center justify-center rounded-full shrink-0 transition-colors${
|
||
mutedNotice
|
||
? " opacity-40 cursor-not-allowed"
|
||
: emojiOpen
|
||
? " bg-[var(--accent-soft)]"
|
||
: " hover:bg-[var(--panel-2)]"
|
||
}`}
|
||
style={{
|
||
color: mutedNotice
|
||
? "var(--ink-3)"
|
||
: emojiOpen
|
||
? "var(--accent)"
|
||
: "var(--ink-2)",
|
||
}}
|
||
aria-label={mutedNotice ? "禁言中,无法发送表情" : "表情"}
|
||
aria-expanded={emojiOpen}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => {
|
||
if (mutedNotice) return;
|
||
setEmojiOpen((v) => !v);
|
||
}}
|
||
>
|
||
<Smile size={19} />
|
||
</button>
|
||
{emojiOpen && !mutedNotice && (
|
||
<div className="j13-chat-emoji-panel">
|
||
<EmojiPicker onPick={insertEmoji} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div
|
||
ref={inputRef}
|
||
contentEditable={!mutedNotice}
|
||
suppressContentEditableWarning
|
||
role="textbox"
|
||
aria-multiline="true"
|
||
aria-label={mutedNotice ? "禁言中" : "输入消息"}
|
||
data-placeholder={
|
||
mutedNotice
|
||
? `禁言中${muteCountdown ? `,剩余 ${muteCountdown}` : "(永久)"}`
|
||
: isDirect
|
||
? "输入消息…"
|
||
: "输入消息,@ 可提醒成员"
|
||
}
|
||
className="j13-chat-scroll j13-chat-composer-input j13-chat-rich-input relative flex-1 min-w-0 text-[14px] leading-5 py-2"
|
||
style={mutedNotice ? { color: "var(--ink-3)", cursor: "not-allowed" } : undefined}
|
||
onInput={handleRichInput}
|
||
onKeyDown={handleRichKeyDown}
|
||
onPaste={handleRichPaste}
|
||
onFocus={(e) => {
|
||
if (mutedNotice) {
|
||
e.currentTarget.blur();
|
||
return;
|
||
}
|
||
// 键盘弹出兜底:把输入框滚入可视区(个别浏览器
|
||
// interactiveWidget=resizes-content 不生效时仍能聚焦可见)
|
||
const composer = e.currentTarget.closest(".j13-chat-composer");
|
||
composer?.scrollIntoView({ block: "end", behavior: "smooth" });
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={send}
|
||
disabled={sending || !content.trim() || !!mutedNotice}
|
||
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(--panel-2)]"
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
<div className="j13-chat-scroll flex-1 overflow-y-auto min-h-0 p-4">
|
||
{rightPanel === "members" && (
|
||
<MembersPanel
|
||
members={members}
|
||
meId={meId}
|
||
canKick={canKickMembers}
|
||
isChatOwner={isOwner}
|
||
siteOwner={siteOwner}
|
||
onRefresh={refreshMembers}
|
||
onKick={handleKick}
|
||
onMute={handleMute}
|
||
onSetAdmin={handleSetAdmin}
|
||
onOpenCard={(mem) =>
|
||
setUserCard({
|
||
user: mem.user,
|
||
roomRole: mem.role,
|
||
muted: mem.muted,
|
||
mutedUntil: mem.muted_until,
|
||
})
|
||
}
|
||
/>
|
||
)}
|
||
{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="j13-chat-scroll hidden lg:flex flex-col panel p-4 overflow-y-auto min-h-0">
|
||
<MembersPanel
|
||
members={members}
|
||
meId={meId}
|
||
canKick={canKickMembers}
|
||
isChatOwner={isOwner}
|
||
siteOwner={siteOwner}
|
||
onRefresh={refreshMembers}
|
||
onKick={handleKick}
|
||
onMute={handleMute}
|
||
onSetAdmin={handleSetAdmin}
|
||
onOpenCard={(mem) =>
|
||
setUserCard({
|
||
user: mem.user,
|
||
roomRole: mem.role,
|
||
muted: mem.muted,
|
||
mutedUntil: mem.muted_until,
|
||
})
|
||
}
|
||
showTitle
|
||
/>
|
||
</aside>
|
||
)}
|
||
|
||
{/* 弹层打开期间不再渲染右键菜单:渲染级互斥兜底,杜绝菜单悬在弹窗上方 */}
|
||
{ctxMenu && !muteTarget && !confirm && !userCard && (
|
||
<ContextMenu
|
||
x={ctxMenu.x}
|
||
y={ctxMenu.y}
|
||
items={msgMenuItems(ctxMenu.message)}
|
||
onClose={() => setCtxMenu(null)}
|
||
/>
|
||
)}
|
||
|
||
{userCtxMenu && !muteTarget && !confirm && !userCard && (
|
||
<ContextMenu
|
||
x={userCtxMenu.x}
|
||
y={userCtxMenu.y}
|
||
items={userMenuItems(userCtxMenu.user, userCtxMenu.member)}
|
||
onClose={() => setUserCtxMenu(null)}
|
||
/>
|
||
)}
|
||
|
||
{userCard && (
|
||
<ChatUserCard
|
||
target={userCard}
|
||
meId={meId}
|
||
onClose={() => setUserCard(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);
|
||
}}
|
||
/>
|
||
|
||
<MuteModal
|
||
open={!!muteTarget}
|
||
nickname={muteTarget?.nickname ?? ""}
|
||
busy={muteBusy}
|
||
onConfirm={submitMute}
|
||
onClose={() => {
|
||
if (!muteBusy) setMuteTarget(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 禁言时长选项(分钟),0 = 永久,参考 QQ 禁言 */
|
||
const MUTE_DURATIONS: { value: number; label: string }[] = [
|
||
{ value: 10, label: "10 分钟" },
|
||
{ value: 60, label: "1 小时" },
|
||
{ value: 12 * 60, label: "12 小时" },
|
||
{ value: 24 * 60, label: "1 天" },
|
||
{ value: 3 * 24 * 60, label: "3 天" },
|
||
{ value: 7 * 24 * 60, label: "7 天" },
|
||
{ value: 30 * 24 * 60, label: "30 天" },
|
||
{ value: 0, label: "永久" },
|
||
];
|
||
|
||
function formatMuteDuration(minutes: number): string {
|
||
return MUTE_DURATIONS.find((d) => d.value === minutes)?.label ?? `${minutes} 分钟`;
|
||
}
|
||
|
||
/** 禁言系统提示用的时长格式:返回「x天x小时x分钟」 */
|
||
function formatMuteCountdown(untilIso: string): string {
|
||
const until = new Date(untilIso).getTime();
|
||
const diff = until - Date.now();
|
||
if (diff <= 0) return "";
|
||
const days = Math.floor(diff / 86_400_000);
|
||
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
|
||
const minutes = Math.floor((diff % 3_600_000) / 60_000);
|
||
if (days > 0) return `${days} 天 ${hours} 小时`;
|
||
if (hours > 0) return `${hours} 小时 ${minutes} 分钟`;
|
||
return `${minutes} 分钟`;
|
||
}
|
||
|
||
/** 成员列表禁言徽标内的剩余时长,永久/null 返回空串 */
|
||
function formatMuteRemainShort(until?: string | null): string {
|
||
if (!until) return "";
|
||
const diff = new Date(until).getTime() - Date.now();
|
||
if (diff <= 0) return "";
|
||
const days = Math.floor(diff / 86_400_000);
|
||
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
|
||
const minutes = Math.floor((diff % 3_600_000) / 60_000);
|
||
if (days > 0) return ` ${days}d`;
|
||
if (hours > 0) return ` ${hours}h`;
|
||
return ` ${minutes}m`;
|
||
}
|
||
|
||
/** 禁言倒计时:返回「x天x小时x分钟」或 null(永久/未禁言);到期触发 onExpire */
|
||
function useCountdown(
|
||
until?: string | null,
|
||
onExpire?: () => void
|
||
): string | null {
|
||
const [text, setText] = useState<string | null>(null);
|
||
const expiredRef = useRef(false);
|
||
useEffect(() => {
|
||
expiredRef.current = false;
|
||
if (!until) {
|
||
setText(null);
|
||
return;
|
||
}
|
||
const update = () => {
|
||
const diff = new Date(until).getTime() - Date.now();
|
||
if (diff <= 0) {
|
||
if (!expiredRef.current) {
|
||
expiredRef.current = true;
|
||
onExpire?.();
|
||
}
|
||
setText("已到期");
|
||
return;
|
||
}
|
||
const days = Math.floor(diff / 86_400_000);
|
||
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
|
||
const minutes = Math.floor((diff % 3_600_000) / 60_000);
|
||
const seconds = Math.floor((diff % 60_000) / 1000);
|
||
if (days > 0) setText(`${days} 天 ${hours} 小时`);
|
||
else if (hours > 0) setText(`${hours} 小时 ${minutes} 分钟`);
|
||
else if (minutes > 0) setText(`${minutes} 分 ${seconds} 秒`);
|
||
else setText(`${seconds} 秒`);
|
||
};
|
||
update();
|
||
const t = setInterval(update, 1000);
|
||
return () => clearInterval(t);
|
||
}, [until, onExpire]);
|
||
return text;
|
||
}
|
||
|
||
/** 禁言成员弹窗(QQ 风格:选择时长) */
|
||
function MuteModal({
|
||
open,
|
||
nickname,
|
||
busy,
|
||
onConfirm,
|
||
onClose,
|
||
}: {
|
||
open: boolean;
|
||
nickname: string;
|
||
busy: boolean;
|
||
onConfirm: (durationMinutes: number) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const [duration, setDuration] = useState<number>(24 * 60);
|
||
|
||
useEffect(() => {
|
||
if (open) setDuration(24 * 60);
|
||
}, [open]);
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
title="禁言成员"
|
||
onClose={onClose}
|
||
busy={busy}
|
||
maxWidthClass="max-w-sm"
|
||
role="alertdialog"
|
||
footer={
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
disabled={busy}
|
||
className="btn btn-line btn-sm"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onConfirm(duration)}
|
||
disabled={busy}
|
||
className="btn btn-primary btn-sm"
|
||
>
|
||
{busy ? "禁言中..." : "禁言"}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-3">
|
||
<p className="text-[13px]" style={{ color: "var(--ink-2)" }}>
|
||
选择 <span style={{ color: "var(--ink)" }}>{nickname}</span> 的禁言时长,到期自动解除。
|
||
</p>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{MUTE_DURATIONS.map((d) => {
|
||
const active = d.value === duration;
|
||
return (
|
||
<button
|
||
key={d.value}
|
||
type="button"
|
||
onClick={() => setDuration(d.value)}
|
||
className="h-9 rounded-lg text-[13px] transition-colors"
|
||
style={{
|
||
background: active ? "var(--accent-soft)" : "var(--bg)",
|
||
color: active ? "var(--accent)" : "var(--ink-2)",
|
||
border: `1px solid ${active ? "var(--accent)" : "var(--line)"}`,
|
||
}}
|
||
>
|
||
{d.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
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,
|
||
canKick,
|
||
isChatOwner,
|
||
siteOwner,
|
||
onRefresh,
|
||
onKick,
|
||
onMute,
|
||
onSetAdmin,
|
||
onOpenCard,
|
||
showTitle = false,
|
||
}: {
|
||
members: ChatRoomMember[];
|
||
meId: number;
|
||
canKick: boolean;
|
||
isChatOwner: boolean;
|
||
siteOwner: boolean;
|
||
onRefresh: () => void;
|
||
onKick: (uid: number, nickname: string) => void;
|
||
onMute: (uid: number, nickname: string, muted: boolean) => void;
|
||
onSetAdmin: (uid: number, nickname: string, makeAdmin: boolean) => void;
|
||
onOpenCard: (m: ChatRoomMember) => void;
|
||
showTitle?: boolean;
|
||
}) {
|
||
const urlStyle = useUrlStyle();
|
||
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) => {
|
||
const name = m.user.nickname || m.user.username;
|
||
const isTargetOwner = m.role === "owner";
|
||
const isTargetAdmin = m.role === "admin";
|
||
const canActOn =
|
||
canKick &&
|
||
m.user_id !== meId &&
|
||
!isTargetOwner &&
|
||
(isChatOwner || !isTargetAdmin);
|
||
return (
|
||
<li
|
||
key={m.id}
|
||
className="flex items-center gap-2.5 px-2 py-2 rounded-lg group"
|
||
>
|
||
<button
|
||
type="button"
|
||
className="rounded-full transition-opacity hover:opacity-85"
|
||
onClick={() => onOpenCard(m)}
|
||
aria-label={`查看 ${name} 的资料卡`}
|
||
>
|
||
<Avatar name={name} src={m.user.avatar || undefined} size={28} />
|
||
</button>
|
||
<Link
|
||
href={userPath(urlStyle, m.user.id)}
|
||
className="flex-1 min-w-0 truncate text-[13px] hover:text-[var(--accent)] transition-colors"
|
||
style={{ color: "var(--ink)" }}
|
||
>
|
||
{name}
|
||
{m.muted ? (
|
||
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||
· 禁言{formatMuteRemainShort(m.muted_until)}
|
||
</span>
|
||
) : null}
|
||
</Link>
|
||
{isTargetOwner ? (
|
||
<Crown size={12} style={{ color: "var(--gold)" }} aria-label="群主" />
|
||
) : isTargetAdmin ? (
|
||
<span className="text-[10px] font-medium" style={{ color: "var(--accent)" }}>
|
||
管
|
||
</span>
|
||
) : null}
|
||
{siteOwner && !isTargetOwner && m.user_id !== meId && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onSetAdmin(m.user_id, name, !isTargetAdmin)}
|
||
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-visible:opacity-100 lg:group-focus-within:opacity-100 transition-opacity text-[11px] px-1.5 py-0.5 rounded hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||
style={{ color: "var(--accent)", background: "var(--accent-soft)" }}
|
||
>
|
||
{isTargetAdmin ? "取消管" : "设管"}
|
||
</button>
|
||
)}
|
||
{canActOn && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => onMute(m.user_id, name, !m.muted)}
|
||
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-visible:opacity-100 lg:group-focus-within:opacity-100 transition-opacity text-[11px] px-1.5 py-0.5 rounded hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
{m.muted ? "解禁" : "禁言"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onKick(m.user_id, name)}
|
||
aria-label={`移出 ${name}`}
|
||
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-visible:opacity-100 lg:group-focus-within:opacity-100 transition-opacity w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--danger-soft)] focus-visible: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="field"
|
||
/>
|
||
<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="field"
|
||
/>
|
||
</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="field resize-none"
|
||
/>
|
||
</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>
|
||
);
|
||
}
|