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
@@ -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();
});
});
+40 -3
View File
@@ -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 }),
};
}
+24 -3
View File
@@ -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() ?? "";
}