fix(chat): 发送后切会话不再误标未读,管理端撤回实时同步到对话页
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,10 +18,11 @@ import {
|
||||
apiCreateChatRoom,
|
||||
apiPinChatRoom,
|
||||
type ChatRoomView,
|
||||
type ChatMessage,
|
||||
type User,
|
||||
} from "@/lib/api";
|
||||
import { realtime, RT_CHAT_UNREAD, RT_CHAT_MEMBERSHIP } from "@/lib/realtime";
|
||||
import type { RtChatUnreadData, RtChatMembershipData } from "@/lib/realtime";
|
||||
import { realtime, RT_CHAT_UNREAD, RT_CHAT_MEMBERSHIP, RT_CHAT_RECALLED } from "@/lib/realtime";
|
||||
import type { RtChatUnreadData, RtChatMembershipData, RtChatRecalledData } from "@/lib/realtime";
|
||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "@/components/Avatar";
|
||||
@@ -111,13 +112,22 @@ export default function ChatShell({
|
||||
room: ChatRoomView;
|
||||
} | null>(null);
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
/** 本人刚发出的消息 ID:拉列表时若水位尚未跟上,避免把这条算成未读 */
|
||||
const lastSentIdByRoom = useRef<Map<number, number>>(new Map());
|
||||
|
||||
/** 拉列表/SSR 回填时,当前打开的会话未读一律置 0(已读请求可能尚未落库) */
|
||||
const applyRooms = useCallback(
|
||||
(list: ChatRoomView[]) => {
|
||||
const next = (list || []).map((r) =>
|
||||
activeRoomId && r.id === activeRoomId ? { ...r, unread_count: 0 } : r
|
||||
);
|
||||
const next = (list || []).map((r) => {
|
||||
if (activeRoomId && r.id === activeRoomId) {
|
||||
return { ...r, unread_count: 0 };
|
||||
}
|
||||
const sentId = lastSentIdByRoom.current.get(r.id);
|
||||
if (sentId && (r.last_message_id || 0) <= sentId && (r.unread_count || 0) > 0) {
|
||||
return { ...r, unread_count: 0 };
|
||||
}
|
||||
return r;
|
||||
});
|
||||
setRooms(sortRooms(next));
|
||||
},
|
||||
[activeRoomId]
|
||||
@@ -152,9 +162,9 @@ export default function ChatShell({
|
||||
[applyRooms]
|
||||
);
|
||||
|
||||
// 进入消息壳 / 切会话:HTTP 拉最新排序(避开预取缓存)
|
||||
// 进入消息壳 / 切会话:HTTP 拉最新排序(避开预取缓存;切房强制拉取以免把刚发出的消息当成未读)
|
||||
useEffect(() => {
|
||||
void refreshRooms();
|
||||
void refreshRooms({ force: true });
|
||||
}, [refreshRooms, activeRoomId]);
|
||||
|
||||
// 从其他标签页切回时对账(10s 限频 + 上方 2s 合并)
|
||||
@@ -198,6 +208,78 @@ export default function ChatShell({
|
||||
return off;
|
||||
}, [activeRoomId, refreshRooms]);
|
||||
|
||||
// 本人刚发出的消息:立刻更新侧栏预览并清角标(发送者收不到 chat:unread)
|
||||
useEffect(() => {
|
||||
const onSent = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent<{ roomId?: number; message?: ChatMessage }>).detail;
|
||||
if (!detail?.roomId || !detail.message) return;
|
||||
const { roomId, message } = detail;
|
||||
lastSentIdByRoom.current.set(roomId, message.id);
|
||||
setRooms((prev) => {
|
||||
const idx = prev.findIndex((r) => r.id === roomId);
|
||||
if (idx < 0) return prev;
|
||||
const next = [...prev];
|
||||
const cur = next[idx];
|
||||
next[idx] = {
|
||||
...cur,
|
||||
unread_count: 0,
|
||||
last_message_id: message.id,
|
||||
last_message: message,
|
||||
updated_at: message.created_at || cur.updated_at,
|
||||
};
|
||||
return sortRooms(next);
|
||||
});
|
||||
};
|
||||
window.addEventListener("j13-chat-sent", onSent);
|
||||
return () => window.removeEventListener("j13-chat-sent", onSent);
|
||||
}, []);
|
||||
|
||||
// 撤回(含管理端):若撤回的是侧栏预览那条,实时换成撤回文案
|
||||
useEffect(() => {
|
||||
const off = realtime.on<RtChatRecalledData>(RT_CHAT_RECALLED, (data) => {
|
||||
if (!data?.room_id || !data.id) return;
|
||||
setRooms((prev) => {
|
||||
const idx = prev.findIndex((r) => r.id === data.room_id);
|
||||
if (idx < 0) return prev;
|
||||
const cur = prev[idx];
|
||||
if (cur.last_message?.id !== data.id && cur.last_message_id !== data.id) {
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev];
|
||||
next[idx] = {
|
||||
...cur,
|
||||
last_message: {
|
||||
id: data.id,
|
||||
room_id: data.room_id,
|
||||
sender_id: data.sender_id || cur.last_message?.sender_id || 0,
|
||||
content: "",
|
||||
created_at: cur.last_message?.created_at || new Date().toISOString(),
|
||||
recalled_at: data.recalled_at || new Date().toISOString(),
|
||||
recalled_by: data.recalled_by,
|
||||
sender: cur.last_message?.sender || ({
|
||||
id: data.sender_id || 0,
|
||||
username: "",
|
||||
nickname: "",
|
||||
avatar: "",
|
||||
role: "user",
|
||||
} as User),
|
||||
recaller: data.recaller
|
||||
? {
|
||||
id: data.recaller.id,
|
||||
username: data.recaller.username,
|
||||
nickname: data.recaller.nickname,
|
||||
avatar: data.recaller.avatar,
|
||||
role: data.recaller.role || "user",
|
||||
}
|
||||
: cur.last_message?.recaller,
|
||||
},
|
||||
};
|
||||
return next;
|
||||
});
|
||||
});
|
||||
return off;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const off = realtime.on<RtChatMembershipData>(RT_CHAT_MEMBERSHIP, (data) => {
|
||||
if (!data) return;
|
||||
|
||||
@@ -48,6 +48,7 @@ import { canOverseeChat } from "@/lib/roles";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { scrollBehavior } from "@/lib/motion";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import AutolinkText from "@/components/AutolinkText";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
||||
|
||||
@@ -56,10 +57,11 @@ const MARK_READ_GAP_MS = 2_000;
|
||||
const markReadInflight = new Set<number>();
|
||||
const markReadLastAt = new Map<number, number>();
|
||||
|
||||
function requestMarkChatRead(roomId: number): void {
|
||||
function requestMarkChatRead(roomId: number, opts?: { force?: boolean }): void {
|
||||
const now = Date.now();
|
||||
const last = markReadLastAt.get(roomId) || 0;
|
||||
if (markReadInflight.has(roomId) || now - last < MARK_READ_GAP_MS) return;
|
||||
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)
|
||||
@@ -235,6 +237,17 @@ function highlightMentions(content: string, members: ChatRoomMember[]) {
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** 消息正文:URL 自动识别 + @提及;外链新开跟随站点策略 */
|
||||
function renderChatContent(content: string, members: ChatRoomMember[]) {
|
||||
return (
|
||||
<AutolinkText
|
||||
text={content}
|
||||
forceNewTab
|
||||
renderText={(seg) => <>{highlightMentions(seg, members)}</>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 输入区镜像层:已确认的 @ 显示为飞书风 token */
|
||||
function renderComposerMirror(content: string, tokens: Set<string>) {
|
||||
if (!content) return null;
|
||||
@@ -405,10 +418,13 @@ export default function ChatRoomClient({
|
||||
}
|
||||
}, [roomId, canView]);
|
||||
|
||||
const markReadToLatest = useCallback(() => {
|
||||
if (!isRealMember) return;
|
||||
requestMarkChatRead(roomId);
|
||||
}, [roomId, isRealMember]);
|
||||
const markReadToLatest = useCallback(
|
||||
(opts?: { force?: boolean }) => {
|
||||
if (!isRealMember) return;
|
||||
requestMarkChatRead(roomId, opts);
|
||||
},
|
||||
[roomId, isRealMember]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canView) return;
|
||||
@@ -563,7 +579,11 @@ export default function ChatRoomClient({
|
||||
return [...prev, message];
|
||||
});
|
||||
requestAnimationFrame(() => scrollToBottom(true));
|
||||
markReadToLatest();
|
||||
// 发送成功立刻同步侧栏预览,并强制推进已读(避开进房 MarkRead 的 2s 节流)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("j13-chat-sent", { detail: { roomId, message } })
|
||||
);
|
||||
markReadToLatest({ force: true });
|
||||
} catch (e) {
|
||||
setContent(prevContent);
|
||||
setMentionTokens(prevTokens);
|
||||
@@ -1196,7 +1216,7 @@ export default function ChatRoomClient({
|
||||
已撤回
|
||||
</div>
|
||||
<div style={{ color: "var(--ink-2)" }}>
|
||||
{highlightMentions(m.content, members)}
|
||||
{renderChatContent(m.content, members)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1291,7 +1311,7 @@ export default function ChatRoomClient({
|
||||
{m.reply_snap}
|
||||
</button>
|
||||
)}
|
||||
{highlightMentions(m.content, members)}
|
||||
{renderChatContent(m.content, members)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
62
frontend/components/AutolinkText.tsx
Normal file
62
frontend/components/AutolinkText.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { usePostLinkNewTab } from "@/components/PostLinkProvider";
|
||||
import { linkifyParts } from "@/lib/linkify";
|
||||
|
||||
function AutolinkAnchor({
|
||||
href,
|
||||
children,
|
||||
openInNewTab,
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
openInNewTab: boolean;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="j13-autolink break-all"
|
||||
style={{ color: "var(--accent)", textDecoration: "underline" }}
|
||||
{...(openInNewTab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯文本自动识别 URL。
|
||||
* forceNewTab:聊天等场景始终新开;否则读 PostLinkProvider(post_link_new_tab)。
|
||||
*/
|
||||
export default function AutolinkText({
|
||||
text,
|
||||
renderText,
|
||||
forceNewTab = false,
|
||||
}: {
|
||||
text: string;
|
||||
renderText?: (segment: string) => ReactNode;
|
||||
forceNewTab?: boolean;
|
||||
}) {
|
||||
const settingNewTab = usePostLinkNewTab();
|
||||
const openInNewTab = forceNewTab || settingNewTab;
|
||||
const parts = linkifyParts(text);
|
||||
return (
|
||||
<>
|
||||
{parts.map((p, i) => {
|
||||
const key = `l-${i}`;
|
||||
if (p.type === "url") {
|
||||
return (
|
||||
<AutolinkAnchor key={key} href={p.href} openInNewTab={openInNewTab}>
|
||||
{p.value}
|
||||
</AutolinkAnchor>
|
||||
);
|
||||
}
|
||||
if (renderText) return <span key={key}>{renderText(p.value)}</span>;
|
||||
return <span key={key}>{p.value}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
frontend/components/PostLinkProvider.tsx
Normal file
20
frontend/components/PostLinkProvider.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const PostLinkCtx = createContext(true);
|
||||
|
||||
/** 帖内 Markdown 外链是否新标签打开(来自公开站点设置) */
|
||||
export default function PostLinkProvider({
|
||||
openInNewTab,
|
||||
children,
|
||||
}: {
|
||||
openInNewTab: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <PostLinkCtx.Provider value={openInNewTab}>{children}</PostLinkCtx.Provider>;
|
||||
}
|
||||
|
||||
export function usePostLinkNewTab() {
|
||||
return useContext(PostLinkCtx);
|
||||
}
|
||||
55
frontend/lib/linkify.ts
Normal file
55
frontend/lib/linkify.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 纯文本 URL 切分(QQ 风格:识别 http(s):// 与 www.)
|
||||
* 仅产出 http/https,拒绝其它协议。
|
||||
*/
|
||||
|
||||
const URL_RE = /(?:https?:\/\/|www\.)[^\s\u4e00-\u9fff<>"'`]+/gi;
|
||||
|
||||
/** 常见尾随标点从 URL 剥离(中英) */
|
||||
const TRAIL_PUNCT = /[.,;:!?。,;:!?、)\]}》〉』」"'`]+$/;
|
||||
|
||||
export type LinkifyPart =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "url"; value: string; href: string };
|
||||
|
||||
function toHref(raw: string): string | null {
|
||||
const href = /^www\./i.test(raw) ? `https://${raw}` : raw;
|
||||
try {
|
||||
const u = new URL(href);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||
return u.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function peel(raw: string): { core: string; trail: string } {
|
||||
const m = raw.match(TRAIL_PUNCT);
|
||||
if (!m) return { core: raw, trail: "" };
|
||||
return { core: raw.slice(0, -m[0].length), trail: m[0] };
|
||||
}
|
||||
|
||||
/** 将纯文本切成文本段与可安全打开的链接段 */
|
||||
export function linkifyParts(text: string): LinkifyPart[] {
|
||||
if (!text) return [];
|
||||
const parts: LinkifyPart[] = [];
|
||||
let last = 0;
|
||||
URL_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = URL_RE.exec(text)) !== null) {
|
||||
if (m.index > last) {
|
||||
parts.push({ type: "text", value: text.slice(last, m.index) });
|
||||
}
|
||||
const { core, trail } = peel(m[0]);
|
||||
const href = core ? toHref(core) : null;
|
||||
if (href && core) {
|
||||
parts.push({ type: "url", value: core, href });
|
||||
if (trail) parts.push({ type: "text", value: trail });
|
||||
} else {
|
||||
parts.push({ type: "text", value: m[0] });
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < text.length) parts.push({ type: "text", value: text.slice(last) });
|
||||
return parts.length ? parts : [{ type: "text", value: text }];
|
||||
}
|
||||
Reference in New Issue
Block a user