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 { 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) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
/** 图片 URL(base64 data URL 或 http URL) */
|
/** 图片 URL(base64 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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() ?? "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
<SplashLatestMessageProvider>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<UserAuthSync />
|
<UserAuthSync />
|
||||||
{children}
|
{children}
|
||||||
<div id="toast-portal" />
|
<div id="toast-portal" />
|
||||||
</UserProvider>
|
</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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user