fix(splash): reuse latest chat message preview

This commit is contained in:
2026-07-14 11:28:23 +08:00
parent b4904e738b
commit f9c15bd91f
13 changed files with 389 additions and 18 deletions
+6
View File
@@ -35,6 +35,7 @@ import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-
import { useChatGuestLogin } from "./hooks/use-chat-guest-login"; import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner"; import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap"; import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
import { useSplashLatestMessageSync } from "./hooks/use-splash-latest-message-sync";
const chatShellStyle = { const chatShellStyle = {
"--chat-message-font-size": "clamp(16px, 3.333vw, 18px)", "--chat-message-font-size": "clamp(16px, 3.333vw, 18px)",
@@ -47,6 +48,11 @@ export function ChatScreen() {
const state = useChatState(); const state = useChatState();
const authState = useAuthState(); const authState = useAuthState();
const authDispatch = useAuthDispatch(); const authDispatch = useAuthDispatch();
useSplashLatestMessageSync({
historyLoaded: state.historyLoaded,
loginStatus: authState.loginStatus,
messages: state.historyMessages,
});
const isPromotionBootstrapReady = useChatPromotionBootstrap(); const isPromotionBootstrapReady = useChatPromotionBootstrap();
const visibleMessages = isPromotionBootstrapReady const visibleMessages = isPromotionBootstrapReady
? state.messages ? state.messages
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef } from "react";
import type { LoginStatus } from "@/data/dto/auth";
import type { UiMessage } from "@/data/dto/chat";
import { resolveSplashLatestMessageCacheIdentity } from "@/lib/chat/splash_latest_message";
import { getLatestSplashMessagePreview } from "@/lib/chat/splash_latest_message_preview";
import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider";
export interface UseSplashLatestMessageSyncInput {
historyLoaded: boolean;
loginStatus: LoginStatus;
messages: readonly UiMessage[];
}
export function useSplashLatestMessageSync({
historyLoaded,
loginStatus,
messages,
}: UseSplashLatestMessageSyncInput): void {
const cache = useSplashLatestMessageCache();
const hasObservedInitialSnapshotRef = useRef(false);
const identityPromiseRef = useRef<Promise<string | null> | null>(null);
useEffect(() => {
hasObservedInitialSnapshotRef.current = false;
identityPromiseRef.current = null;
}, [loginStatus]);
useEffect(() => {
if (!historyLoaded) return;
if (!hasObservedInitialSnapshotRef.current) {
hasObservedInitialSnapshotRef.current = true;
return;
}
const preview = getLatestSplashMessagePreview(messages);
let cancelled = false;
identityPromiseRef.current ??= resolveSplashLatestMessageCacheIdentity();
void identityPromiseRef.current.then((identity) => {
if (!cancelled && identity) {
cache.set(identity, preview);
}
});
return () => {
cancelled = true;
};
}, [cache, historyLoaded, messages]);
}
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message_preview"; import {
getLatestSplashMessagePreview,
getSplashLatestMessagePreview,
} from "@/lib/chat/splash_latest_message_preview";
describe("getSplashLatestMessagePreview", () => { describe("getSplashLatestMessagePreview", () => {
it("uses text content for text messages", () => { it("uses text content for text messages", () => {
@@ -34,6 +37,15 @@ describe("getSplashLatestMessagePreview", () => {
).toBe("[图片]"); ).toBe("[图片]");
}); });
it("uses image placeholder for a mapped UI image message", () => {
expect(
getSplashLatestMessagePreview({
content: "",
imageUrl: "https://example.com/image.jpg",
}),
).toBe("[图片]");
});
it("uses voice placeholder for voice messages", () => { it("uses voice placeholder for voice messages", () => {
expect( expect(
getSplashLatestMessagePreview({ getSplashLatestMessagePreview({
@@ -54,6 +66,35 @@ describe("getSplashLatestMessagePreview", () => {
).toBe("[语音]"); ).toBe("[语音]");
}); });
it("uses media placeholders for mapped UI lock reasons", () => {
expect(
getSplashLatestMessagePreview({
content: "",
lockReason: "voice_message",
}),
).toBe("[语音]");
expect(
getSplashLatestMessagePreview({
content: "",
lockReason: "image_paywall",
}),
).toBe("[图片]");
});
it("skips synthetic messages when selecting the latest preview", () => {
expect(
getLatestSplashMessagePreview([
{ content: "Real message" },
{ content: "Greeting", isSynthetic: true },
]),
).toBe("Real message");
expect(
getLatestSplashMessagePreview([
{ content: "Greeting", isSynthetic: true },
]),
).toBeNull();
});
it("falls back when the latest message has no displayable content", () => { it("falls back when the latest message has no displayable content", () => {
expect( expect(
getSplashLatestMessagePreview({ getSplashLatestMessagePreview({
@@ -3,7 +3,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { LoginStatus } from "@/data/dto/auth"; import type { LoginStatus } from "@/data/dto/auth";
import { fetchSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message"; import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message";
import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider";
import { Logger, Result } from "@/utils"; import { Logger, Result } from "@/utils";
const log = new Logger("SplashLatestMessage"); const log = new Logger("SplashLatestMessage");
@@ -30,6 +31,7 @@ export function useSplashLatestMessage({
isAuthLoading, isAuthLoading,
loginStatus, loginStatus,
}: UseSplashLatestMessageInput): UseSplashLatestMessageOutput { }: UseSplashLatestMessageInput): UseSplashLatestMessageOutput {
const cache = useSplashLatestMessageCache();
const [state, setState] = useState<SplashLatestMessageState>({ const [state, setState] = useState<SplashLatestMessageState>({
key: "", key: "",
loaded: false, loaded: false,
@@ -45,7 +47,7 @@ export function useSplashLatestMessage({
let cancelled = false; let cancelled = false;
const loadLatestMessage = async () => { const loadLatestMessage = async () => {
const result = await fetchSplashLatestMessagePreview(); const result = await loadSplashLatestMessagePreview({ cache });
if (cancelled) return; if (cancelled) return;
if (Result.isErr(result)) { if (Result.isErr(result)) {
@@ -69,7 +71,7 @@ export function useSplashLatestMessage({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [requestKey, shouldLoad]); }, [cache, requestKey, shouldLoad]);
if (!shouldLoad) return { message: null, isLoading: false }; if (!shouldLoad) return { message: null, isLoading: false };
if (state.key !== requestKey || !state.loaded) { if (state.key !== requestKey || !state.loaded) {
+2
View File
@@ -13,6 +13,8 @@ export const UiMessageSchema = z.object({
isFromAI: z.boolean(), isFromAI: z.boolean(),
/** 日期分隔条使用的本地日期(YYYY-MM-DD */ /** 日期分隔条使用的本地日期(YYYY-MM-DD */
date: z.string(), date: z.string(),
/** 前端生成的辅助消息,不应作为服务端聊天预览。 */
isSynthetic: z.boolean().optional(),
/** 图片 URLbase64 data URL 或 http URL */ /** 图片 URLbase64 data URL 或 http URL */
imageUrl: z.string().optional(), imageUrl: z.string().optional(),
/** true = 图片可在列表展示,但全屏查看时需要会员解锁 */ /** true = 图片可在列表展示,但全屏查看时需要会员解锁 */
@@ -8,7 +8,11 @@ vi.mock("@/data/repositories/chat_repository", () => ({
getChatRepository: () => ({ getHistory: getHistoryMock }), getChatRepository: () => ({ getHistory: getHistoryMock }),
})); }));
import { fetchSplashLatestMessagePreview } from "../splash_latest_message"; import {
fetchSplashLatestMessagePreview,
loadSplashLatestMessagePreview,
} from "../splash_latest_message";
import { createSplashLatestMessageCache } from "../splash_latest_message_cache";
describe("fetchSplashLatestMessagePreview", () => { describe("fetchSplashLatestMessagePreview", () => {
beforeEach(() => { beforeEach(() => {
@@ -39,3 +43,87 @@ describe("fetchSplashLatestMessagePreview", () => {
expect(Result.isOk(result) && result.data).toBeNull(); expect(Result.isOk(result) && result.data).toBeNull();
}); });
}); });
describe("loadSplashLatestMessagePreview", () => {
it("reuses a successful result for the same identity", async () => {
const cache = createSplashLatestMessageCache();
const fetchPreview = vi.fn().mockResolvedValue(Result.ok("Hello again"));
const resolveIdentity = vi.fn().mockResolvedValue(Result.ok("user:1"));
const first = await loadSplashLatestMessagePreview({
cache,
fetchPreview,
resolveIdentity,
});
const second = await loadSplashLatestMessagePreview({
cache,
fetchPreview,
resolveIdentity,
});
expect(Result.isOk(first) && first.data).toBe("Hello again");
expect(Result.isOk(second) && second.data).toBe("Hello again");
expect(fetchPreview).toHaveBeenCalledTimes(1);
});
it("treats a cached empty history as a cache hit", async () => {
const cache = createSplashLatestMessageCache();
cache.set("user:empty", null);
const fetchPreview = vi.fn();
const result = await loadSplashLatestMessagePreview({
cache,
fetchPreview,
resolveIdentity: vi
.fn()
.mockResolvedValue(Result.ok("user:empty")),
});
expect(Result.isOk(result) && result.data).toBeNull();
expect(fetchPreview).not.toHaveBeenCalled();
});
it("does not cache failed requests", async () => {
const cache = createSplashLatestMessageCache();
const fetchPreview = vi
.fn()
.mockResolvedValueOnce(Result.err(new Error("offline")))
.mockResolvedValueOnce(Result.ok("Recovered"));
const resolveIdentity = vi.fn().mockResolvedValue(Result.ok("user:1"));
await loadSplashLatestMessagePreview({
cache,
fetchPreview,
resolveIdentity,
});
const retry = await loadSplashLatestMessagePreview({
cache,
fetchPreview,
resolveIdentity,
});
expect(Result.isOk(retry) && retry.data).toBe("Recovered");
expect(fetchPreview).toHaveBeenCalledTimes(2);
});
it("bypasses the cache when the identity is unavailable", async () => {
const cache = createSplashLatestMessageCache();
const fetchPreview = vi.fn().mockResolvedValue(Result.ok("Network"));
const resolveIdentity = vi
.fn()
.mockResolvedValue(Result.err(new Error("missing identity")));
await loadSplashLatestMessagePreview({
cache,
fetchPreview,
resolveIdentity,
});
await loadSplashLatestMessagePreview({
cache,
fetchPreview,
resolveIdentity,
});
expect(fetchPreview).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { createSplashLatestMessageCache } from "../splash_latest_message_cache";
describe("splash latest message cache", () => {
it("isolates previews by chat identity", () => {
const cache = createSplashLatestMessageCache();
cache.set("user:1", "First user");
cache.set("user:2", "Second user");
expect(cache.get("user:1")?.message).toBe("First user");
expect(cache.get("user:2")?.message).toBe("Second user");
expect(cache.get("user:3")).toBeUndefined();
});
it("clears all session previews", () => {
const cache = createSplashLatestMessageCache();
cache.set("user:1", null);
cache.clear();
expect(cache.get("user:1")).toBeUndefined();
});
});
+40 -3
View File
@@ -1,9 +1,47 @@
"use client"; "use client";
import { getChatRepository } from "@/data/repositories/chat_repository"; import { getChatRepository } from "@/data/repositories/chat_repository";
import {
resolveChatCacheOwnerKey,
type ChatCacheIdentityResolver,
} from "@/data/repositories/chat_cache_identity";
import { Result, type Result as ResultT } from "@/utils"; import { Result, type Result as ResultT } from "@/utils";
import { getSplashLatestMessagePreview } from "./splash_latest_message_preview"; import type { SplashLatestMessageCache } from "./splash_latest_message_cache";
import { getLatestSplashMessagePreview } from "./splash_latest_message_preview";
export interface LoadSplashLatestMessagePreviewInput {
cache: SplashLatestMessageCache;
fetchPreview?: () => Promise<ResultT<string | null>>;
resolveIdentity?: ChatCacheIdentityResolver;
}
export async function resolveSplashLatestMessageCacheIdentity(): Promise<
string | null
> {
const result = await resolveChatCacheOwnerKey();
return Result.isOk(result) ? result.data : null;
}
export async function loadSplashLatestMessagePreview({
cache,
fetchPreview = fetchSplashLatestMessagePreview,
resolveIdentity = resolveChatCacheOwnerKey,
}: LoadSplashLatestMessagePreviewInput): Promise<ResultT<string | null>> {
const identityResult = await resolveIdentity();
const identity = Result.isOk(identityResult) ? identityResult.data : null;
if (identity) {
const cached = cache.get(identity);
if (cached) return Result.ok(cached.message);
}
const result = await fetchPreview();
if (identity && Result.isOk(result)) {
cache.set(identity, result.data);
}
return result;
}
export async function fetchSplashLatestMessagePreview(): Promise< export async function fetchSplashLatestMessagePreview(): Promise<
ResultT<string | null> ResultT<string | null>
@@ -11,6 +49,5 @@ export async function fetchSplashLatestMessagePreview(): Promise<
const result = await getChatRepository().getHistory(1, 0); const result = await getChatRepository().getHistory(1, 0);
if (Result.isErr(result)) return Result.err(result.error); if (Result.isErr(result)) return Result.err(result.error);
const latestMessage = result.data.messages.at(-1) ?? null; return Result.ok(getLatestSplashMessagePreview(result.data.messages));
return Result.ok(getSplashLatestMessagePreview(latestMessage));
} }
@@ -0,0 +1,19 @@
export interface SplashLatestMessageCacheEntry {
message: string | null;
}
export interface SplashLatestMessageCache {
clear(): void;
get(identity: string): SplashLatestMessageCacheEntry | undefined;
set(identity: string, message: string | null): void;
}
export function createSplashLatestMessageCache(): SplashLatestMessageCache {
const entries = new Map<string, SplashLatestMessageCacheEntry>();
return {
clear: () => entries.clear(),
get: (identity) => entries.get(identity),
set: (identity, message) => entries.set(identity, { message }),
};
}
+24 -3
View File
@@ -2,6 +2,9 @@ export interface SplashLatestMessageSource {
type?: string | null; type?: string | null;
content?: string | null; content?: string | null;
audioUrl?: string | null; audioUrl?: string | null;
imageUrl?: string | null;
isSynthetic?: boolean;
lockReason?: string | null;
image?: { image?: {
type?: string | null; type?: string | null;
url?: string | null; url?: string | null;
@@ -18,7 +21,7 @@ const FALLBACK_PREVIEW_TEXT = "[消息]";
export function getSplashLatestMessagePreview( export function getSplashLatestMessagePreview(
message: SplashLatestMessageSource | null | undefined, message: SplashLatestMessageSource | null | undefined,
): string | null { ): string | null {
if (!message) return null; if (!message || message.isSynthetic) return null;
if (isVoiceMessage(message)) return VOICE_PREVIEW_TEXT; if (isVoiceMessage(message)) return VOICE_PREVIEW_TEXT;
if (isImageMessage(message)) return IMAGE_PREVIEW_TEXT; if (isImageMessage(message)) return IMAGE_PREVIEW_TEXT;
@@ -27,13 +30,25 @@ export function getSplashLatestMessagePreview(
return content.length > 0 ? content : FALLBACK_PREVIEW_TEXT; return content.length > 0 ? content : FALLBACK_PREVIEW_TEXT;
} }
export function getLatestSplashMessagePreview(
messages: readonly SplashLatestMessageSource[],
): string | null {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message && !message.isSynthetic) {
return getSplashLatestMessagePreview(message);
}
}
return null;
}
function isVoiceMessage(message: SplashLatestMessageSource): boolean { function isVoiceMessage(message: SplashLatestMessageSource): boolean {
const type = normalizeMessageType(message.type); const type = normalizeMessageType(message.type);
return ( return (
type === "voice" || type === "voice" ||
type === "audio" || type === "audio" ||
hasValue(message.audioUrl) || hasValue(message.audioUrl) ||
message.lockDetail?.reason === "voice_message" getLockReason(message) === "voice_message"
); );
} }
@@ -41,12 +56,18 @@ function isImageMessage(message: SplashLatestMessageSource): boolean {
const type = normalizeMessageType(message.type); const type = normalizeMessageType(message.type);
return ( return (
type === "image" || type === "image" ||
hasValue(message.imageUrl) ||
hasValue(message.image?.url) || hasValue(message.image?.url) ||
hasValue(message.image?.type) || hasValue(message.image?.type) ||
message.lockDetail?.reason === "image" getLockReason(message) === "image" ||
getLockReason(message) === "image_paywall"
); );
} }
function getLockReason(message: SplashLatestMessageSource): string | null {
return message.lockReason ?? message.lockDetail?.reason ?? null;
}
function normalizeMessageType(value: string | null | undefined): string { function normalizeMessageType(value: string | null | undefined): string {
return value?.trim().toLowerCase() ?? ""; return value?.trim().toLowerCase() ?? "";
} }
+10 -7
View File
@@ -3,12 +3,13 @@
/** /**
* 根级 Client Providers 包装 * 根级 Client Providers 包装
* *
* 这里只保留跨路由的 Auth、User 和 viewport Provider。Chat、Payment、 * 这里只保留跨路由的 Auth、User、消息预览和 viewport Provider。Chat、
* PrivateRoom 状态机由对应路由 layout 按需挂载。 * Payment、PrivateRoom 状态机由对应路由 layout 按需挂载。
*/ */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { SplashLatestMessageProvider } from "@/providers/splash-latest-message-provider";
import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider"; import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider";
import { AppNavigationGuard } from "@/router/app-navigation-guard"; import { AppNavigationGuard } from "@/router/app-navigation-guard";
import { AuthProvider } from "@/stores/auth/auth-context"; import { AuthProvider } from "@/stores/auth/auth-context";
@@ -28,11 +29,13 @@ export function RootProviders({ children }: RootProvidersProps) {
<AuthStatusChecker /> <AuthStatusChecker />
<OAuthSessionSync /> <OAuthSessionSync />
<AppNavigationGuard /> <AppNavigationGuard />
<UserProvider> <SplashLatestMessageProvider>
<UserAuthSync /> <UserProvider>
{children} <UserAuthSync />
<div id="toast-portal" /> {children}
</UserProvider> <div id="toast-portal" />
</UserProvider>
</SplashLatestMessageProvider>
</AuthProvider> </AuthProvider>
</ViewportCssVarsProvider> </ViewportCssVarsProvider>
); );
@@ -0,0 +1,73 @@
"use client";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useRef,
useState,
} from "react";
import {
createSplashLatestMessageCache,
type SplashLatestMessageCache,
} from "@/lib/chat/splash_latest_message_cache";
import { useAuthState } from "@/stores/auth/auth-context";
const SplashLatestMessageCacheContext =
createContext<SplashLatestMessageCache | null>(null);
export function SplashLatestMessageProvider({
children,
}: {
children: ReactNode;
}) {
const [cache] = useState(createSplashLatestMessageCache);
return (
<SplashLatestMessageCacheContext.Provider value={cache}>
<SplashLatestMessageAuthSync cache={cache} />
{children}
</SplashLatestMessageCacheContext.Provider>
);
}
export function useSplashLatestMessageCache(): SplashLatestMessageCache {
const cache = useContext(SplashLatestMessageCacheContext);
if (!cache) {
throw new Error(
"useSplashLatestMessageCache must be used within SplashLatestMessageProvider.",
);
}
return cache;
}
function SplashLatestMessageAuthSync({
cache,
}: {
cache: SplashLatestMessageCache;
}) {
const authState = useAuthState();
const previousLoginStatusRef = useRef<string | null>(null);
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
const previousLoginStatus = previousLoginStatusRef.current;
if (
previousLoginStatus !== null &&
previousLoginStatus !== authState.loginStatus
) {
cache.clear();
}
previousLoginStatusRef.current = authState.loginStatus;
}, [
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
cache,
]);
return null;
}
+1
View File
@@ -35,6 +35,7 @@ export function createGreetingMessage(): UiMessage {
"Finally I can relax. How was your day out?", "Finally I can relax. How was your day out?",
isFromAI: true, isFromAI: true,
date: todayString(), date: todayString(),
isSynthetic: true,
}; };
} }