- SessionMeta.unreadCount(可选,兼容旧后端)+ api/sessions.markSessionRead - lib/sessionRead.ts:会话历史落地后再推游标(best-effort,失败静默,下次 GET /sessions 收敛)+ 帧归属判定(ws.ts 因此留在 500 行内) - store.clearUnread 无变化时返回同一 state,避免多余重渲染;Nav 徽标沿用 Miuix token(mu-radius-pill/mu-err/mu-rise-in) - 逻辑测试 35 → 54 项,含变异校验(改成 clearUnread(activeSessionId) 即 FAIL)
46 lines
2.2 KiB
TypeScript
46 lines
2.2 KiB
TypeScript
import { useAppStore } from "../store/useAppStore";
|
|
import { markSessionRead } from "./api";
|
|
|
|
/**
|
|
* Session read cursor (Android 协同 B3/D4) — the consumer side of the per-session
|
|
* unread count. Two write paths, both landing on the same single cursor:
|
|
*
|
|
* - opening a session (this end) → POST /sessions/{id}/read
|
|
* - the `session_read` frame (other ends) → clears the badge locally only
|
|
*
|
|
* The store's `clearUnread` zeroes the badge; the network cannot fail the badge,
|
|
* because `GET /sessions` is the eventual truth (a failed POST simply means the
|
|
* dot comes back on the next list load).
|
|
*/
|
|
|
|
/** Which session does a `session_read` frame address? The payload's own
|
|
* `sessionId` wins when present (a frame may scope itself in the body, like the
|
|
* streaming frames), else the envelope's. "" means unattributable: the caller
|
|
* must then leave every badge alone rather than guess, since clearing the wrong
|
|
* session's badge would hide messages the user has not seen. */
|
|
export function sessionReadTarget(
|
|
envelopeSessionId: string | undefined,
|
|
r: Record<string, unknown> | null
|
|
): string {
|
|
const payload = typeof r?.sessionId === "string" ? r.sessionId : "";
|
|
return payload || envelopeSessionId || "";
|
|
}
|
|
|
|
/** Mark a session read because the user just opened it (called once its first
|
|
* page of history has landed, so the cursor never passes messages that were
|
|
* never rendered). Zeroes the badge immediately — the list must not show a stale
|
|
* dot for the conversation on screen — and pushes the cursor so every other end
|
|
* converges. A session this end already reads as read is skipped entirely: the
|
|
* WS frame (or this very call) may have cleared it first, and switching back and
|
|
* forth must not fire a request per render. */
|
|
export function markSessionOpenedRead(sessionId: string): void {
|
|
if (!sessionId) return;
|
|
const { sessions, clearUnread } = useAppStore.getState();
|
|
if (!(sessions[sessionId]?.unreadCount ?? 0)) return;
|
|
clearUnread(sessionId);
|
|
void markSessionRead(sessionId).catch(() => {
|
|
// Best effort: the server cursor stays where it was until the next attempt,
|
|
// and the next GET /sessions reports it as truth.
|
|
});
|
|
}
|