fix(splash): reuse latest chat message preview
This commit is contained in:
@@ -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() ?? "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user