fix(splash): reuse latest chat message preview
This commit is contained in:
@@ -35,6 +35,7 @@ import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-
|
||||
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
|
||||
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
|
||||
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
|
||||
import { useSplashLatestMessageSync } from "./hooks/use-splash-latest-message-sync";
|
||||
|
||||
const chatShellStyle = {
|
||||
"--chat-message-font-size": "clamp(16px, 3.333vw, 18px)",
|
||||
@@ -47,6 +48,11 @@ export function ChatScreen() {
|
||||
const state = useChatState();
|
||||
const authState = useAuthState();
|
||||
const authDispatch = useAuthDispatch();
|
||||
useSplashLatestMessageSync({
|
||||
historyLoaded: state.historyLoaded,
|
||||
loginStatus: authState.loginStatus,
|
||||
messages: state.historyMessages,
|
||||
});
|
||||
const isPromotionBootstrapReady = useChatPromotionBootstrap();
|
||||
const visibleMessages = isPromotionBootstrapReady
|
||||
? 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 { getSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message_preview";
|
||||
import {
|
||||
getLatestSplashMessagePreview,
|
||||
getSplashLatestMessagePreview,
|
||||
} from "@/lib/chat/splash_latest_message_preview";
|
||||
|
||||
describe("getSplashLatestMessagePreview", () => {
|
||||
it("uses text content for text messages", () => {
|
||||
@@ -34,6 +37,15 @@ describe("getSplashLatestMessagePreview", () => {
|
||||
).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", () => {
|
||||
expect(
|
||||
getSplashLatestMessagePreview({
|
||||
@@ -54,6 +66,35 @@ describe("getSplashLatestMessagePreview", () => {
|
||||
).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", () => {
|
||||
expect(
|
||||
getSplashLatestMessagePreview({
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
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";
|
||||
|
||||
const log = new Logger("SplashLatestMessage");
|
||||
@@ -30,6 +31,7 @@ export function useSplashLatestMessage({
|
||||
isAuthLoading,
|
||||
loginStatus,
|
||||
}: UseSplashLatestMessageInput): UseSplashLatestMessageOutput {
|
||||
const cache = useSplashLatestMessageCache();
|
||||
const [state, setState] = useState<SplashLatestMessageState>({
|
||||
key: "",
|
||||
loaded: false,
|
||||
@@ -45,7 +47,7 @@ export function useSplashLatestMessage({
|
||||
let cancelled = false;
|
||||
|
||||
const loadLatestMessage = async () => {
|
||||
const result = await fetchSplashLatestMessagePreview();
|
||||
const result = await loadSplashLatestMessagePreview({ cache });
|
||||
if (cancelled) return;
|
||||
|
||||
if (Result.isErr(result)) {
|
||||
@@ -69,7 +71,7 @@ export function useSplashLatestMessage({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [requestKey, shouldLoad]);
|
||||
}, [cache, requestKey, shouldLoad]);
|
||||
|
||||
if (!shouldLoad) return { message: null, isLoading: false };
|
||||
if (state.key !== requestKey || !state.loaded) {
|
||||
|
||||
@@ -13,6 +13,8 @@ export const UiMessageSchema = z.object({
|
||||
isFromAI: z.boolean(),
|
||||
/** 日期分隔条使用的本地日期(YYYY-MM-DD) */
|
||||
date: z.string(),
|
||||
/** 前端生成的辅助消息,不应作为服务端聊天预览。 */
|
||||
isSynthetic: z.boolean().optional(),
|
||||
/** 图片 URL(base64 data URL 或 http URL) */
|
||||
imageUrl: z.string().optional(),
|
||||
/** true = 图片可在列表展示,但全屏查看时需要会员解锁 */
|
||||
|
||||
@@ -8,7 +8,11 @@ vi.mock("@/data/repositories/chat_repository", () => ({
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
@@ -39,3 +43,87 @@ describe("fetchSplashLatestMessagePreview", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,47 @@
|
||||
"use client";
|
||||
|
||||
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 { 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<
|
||||
ResultT<string | null>
|
||||
@@ -11,6 +49,5 @@ export async function fetchSplashLatestMessagePreview(): Promise<
|
||||
const result = await getChatRepository().getHistory(1, 0);
|
||||
if (Result.isErr(result)) return Result.err(result.error);
|
||||
|
||||
const latestMessage = result.data.messages.at(-1) ?? null;
|
||||
return Result.ok(getSplashLatestMessagePreview(latestMessage));
|
||||
return Result.ok(getLatestSplashMessagePreview(result.data.messages));
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,9 @@ export interface SplashLatestMessageSource {
|
||||
type?: string | null;
|
||||
content?: string | null;
|
||||
audioUrl?: string | null;
|
||||
imageUrl?: string | null;
|
||||
isSynthetic?: boolean;
|
||||
lockReason?: string | null;
|
||||
image?: {
|
||||
type?: string | null;
|
||||
url?: string | null;
|
||||
@@ -18,7 +21,7 @@ const FALLBACK_PREVIEW_TEXT = "[消息]";
|
||||
export function getSplashLatestMessagePreview(
|
||||
message: SplashLatestMessageSource | null | undefined,
|
||||
): string | null {
|
||||
if (!message) return null;
|
||||
if (!message || message.isSynthetic) return null;
|
||||
|
||||
if (isVoiceMessage(message)) return VOICE_PREVIEW_TEXT;
|
||||
if (isImageMessage(message)) return IMAGE_PREVIEW_TEXT;
|
||||
@@ -27,13 +30,25 @@ export function getSplashLatestMessagePreview(
|
||||
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 {
|
||||
const type = normalizeMessageType(message.type);
|
||||
return (
|
||||
type === "voice" ||
|
||||
type === "audio" ||
|
||||
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);
|
||||
return (
|
||||
type === "image" ||
|
||||
hasValue(message.imageUrl) ||
|
||||
hasValue(message.image?.url) ||
|
||||
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 {
|
||||
return value?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
/**
|
||||
* 根级 Client Providers 包装
|
||||
*
|
||||
* 这里只保留跨路由的 Auth、User 和 viewport Provider。Chat、Payment、
|
||||
* PrivateRoom 状态机由对应路由 layout 按需挂载。
|
||||
* 这里只保留跨路由的 Auth、User、消息预览和 viewport Provider。Chat、
|
||||
* Payment、PrivateRoom 状态机由对应路由 layout 按需挂载。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SplashLatestMessageProvider } from "@/providers/splash-latest-message-provider";
|
||||
import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider";
|
||||
import { AppNavigationGuard } from "@/router/app-navigation-guard";
|
||||
import { AuthProvider } from "@/stores/auth/auth-context";
|
||||
@@ -28,11 +29,13 @@ export function RootProviders({ children }: RootProvidersProps) {
|
||||
<AuthStatusChecker />
|
||||
<OAuthSessionSync />
|
||||
<AppNavigationGuard />
|
||||
<SplashLatestMessageProvider>
|
||||
<UserProvider>
|
||||
<UserAuthSync />
|
||||
{children}
|
||||
<div id="toast-portal" />
|
||||
</UserProvider>
|
||||
</SplashLatestMessageProvider>
|
||||
</AuthProvider>
|
||||
</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;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export function createGreetingMessage(): UiMessage {
|
||||
"Finally I can relax. How was your day out?",
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
isSynthetic: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user