From baf0deeb24348431eb6828b9a9051de8dc12df17 Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 8 Jul 2026 17:31:58 +0800 Subject: [PATCH] feat(splash): load latest chat message --- .../splash-latest-message.helpers.test.ts | 67 ++++++++++++++++ .../splash-latest-message.module.css | 26 ++++++ .../components/splash-latest-message.tsx | 14 +++- .../splash/hooks/use-splash-latest-message.ts | 79 +++++++++++++++++++ src/app/splash/splash-screen.tsx | 14 +++- src/lib/chat/splash_latest_message.ts | 16 ++++ src/lib/chat/splash_latest_message_preview.ts | 56 +++++++++++++ 7 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 src/app/splash/__tests__/splash-latest-message.helpers.test.ts create mode 100644 src/app/splash/hooks/use-splash-latest-message.ts create mode 100644 src/lib/chat/splash_latest_message.ts create mode 100644 src/lib/chat/splash_latest_message_preview.ts diff --git a/src/app/splash/__tests__/splash-latest-message.helpers.test.ts b/src/app/splash/__tests__/splash-latest-message.helpers.test.ts new file mode 100644 index 00000000..2d92d792 --- /dev/null +++ b/src/app/splash/__tests__/splash-latest-message.helpers.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { getSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message_preview"; + +describe("getSplashLatestMessagePreview", () => { + it("uses text content for text messages", () => { + expect( + getSplashLatestMessagePreview({ + type: "text", + content: " Come closer? ", + audioUrl: null, + image: { type: null, url: null }, + }), + ).toBe("Come closer?"); + }); + + it("uses image placeholder for image messages", () => { + expect( + getSplashLatestMessagePreview({ + type: "image", + content: "", + image: { type: "elio_schedule", url: null }, + }), + ).toBe("[图片]"); + }); + + it("uses image placeholder when the image url exists", () => { + expect( + getSplashLatestMessagePreview({ + type: "text", + content: "", + image: { type: null, url: "https://example.com/image.jpg" }, + }), + ).toBe("[图片]"); + }); + + it("uses voice placeholder for voice messages", () => { + expect( + getSplashLatestMessagePreview({ + type: "audio", + content: "", + audioUrl: "https://example.com/voice.mp3", + }), + ).toBe("[语音]"); + }); + + it("uses voice placeholder for locked voice messages", () => { + expect( + getSplashLatestMessagePreview({ + type: "text", + content: "", + lockDetail: { reason: "voice_message" }, + }), + ).toBe("[语音]"); + }); + + it("falls back when the latest message has no displayable content", () => { + expect( + getSplashLatestMessagePreview({ + type: "text", + content: " ", + image: { type: null, url: null }, + audioUrl: null, + }), + ).toBe("[消息]"); + }); +}); diff --git a/src/app/splash/components/splash-latest-message.module.css b/src/app/splash/components/splash-latest-message.module.css index 80e13dd7..492d0a65 100644 --- a/src/app/splash/components/splash-latest-message.module.css +++ b/src/app/splash/components/splash-latest-message.module.css @@ -86,6 +86,22 @@ line-height: 1.35; } +.messageLoading { + display: block; + width: min(220px, 76%); + height: 18px; + border-radius: 999px; + background: + linear-gradient( + 90deg, + rgba(255, 255, 255, 0.16), + rgba(255, 255, 255, 0.34), + rgba(255, 255, 255, 0.16) + ); + background-size: 180% 100%; + animation: messageLoading 1.1s ease-in-out infinite; +} + @keyframes messageFloatIn { from { opacity: 0; @@ -97,3 +113,13 @@ transform: translateY(0) scale(1); } } + +@keyframes messageLoading { + from { + background-position: 100% 0; + } + + to { + background-position: 0 0; + } +} diff --git a/src/app/splash/components/splash-latest-message.tsx b/src/app/splash/components/splash-latest-message.tsx index c8ab6de8..61525b5f 100644 --- a/src/app/splash/components/splash-latest-message.tsx +++ b/src/app/splash/components/splash-latest-message.tsx @@ -6,12 +6,18 @@ import { MessageCircle } from "lucide-react"; import styles from "./splash-latest-message.module.css"; export interface SplashLatestMessageProps { + message: string | null; + isLoading?: boolean; onOpenChat: () => void; } export function SplashLatestMessage({ + message, + isLoading = false, onOpenChat, }: SplashLatestMessageProps) { + if (!isLoading && !message) return null; + return ( ); diff --git a/src/app/splash/hooks/use-splash-latest-message.ts b/src/app/splash/hooks/use-splash-latest-message.ts new file mode 100644 index 00000000..81cce74c --- /dev/null +++ b/src/app/splash/hooks/use-splash-latest-message.ts @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import type { LoginStatus } from "@/data/dto/auth"; +import { fetchSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message"; +import { Logger, Result } from "@/utils"; + +const log = new Logger("SplashLatestMessage"); + +export interface UseSplashLatestMessageInput { + hasInitialized: boolean; + isAuthLoading: boolean; + loginStatus: LoginStatus; +} + +export interface UseSplashLatestMessageOutput { + message: string | null; + isLoading: boolean; +} + +interface SplashLatestMessageState { + key: string; + loaded: boolean; + message: string | null; +} + +export function useSplashLatestMessage({ + hasInitialized, + isAuthLoading, + loginStatus, +}: UseSplashLatestMessageInput): UseSplashLatestMessageOutput { + const [state, setState] = useState({ + key: "", + loaded: false, + message: null, + }); + const shouldLoad = + hasInitialized && !isAuthLoading && loginStatus !== "notLoggedIn"; + const requestKey = shouldLoad ? loginStatus : ""; + + useEffect(() => { + if (!shouldLoad) return; + + let cancelled = false; + + const loadLatestMessage = async () => { + const result = await fetchSplashLatestMessagePreview(); + if (cancelled) return; + + if (Result.isErr(result)) { + log.warn( + { err: result.error.message }, + "[splash] latest message history request failed", + ); + setState({ key: requestKey, loaded: true, message: null }); + return; + } + + setState({ + key: requestKey, + loaded: true, + message: result.data, + }); + }; + + void loadLatestMessage(); + + return () => { + cancelled = true; + }; + }, [requestKey, shouldLoad]); + + if (!shouldLoad) return { message: null, isLoading: false }; + if (state.key !== requestKey || !state.loaded) { + return { message: null, isLoading: true }; + } + return { message: state.message, isLoading: false }; +} diff --git a/src/app/splash/splash-screen.tsx b/src/app/splash/splash-screen.tsx index 1ef76305..ec9ee425 100644 --- a/src/app/splash/splash-screen.tsx +++ b/src/app/splash/splash-screen.tsx @@ -5,6 +5,7 @@ import { useEffect } from "react"; import { AppBottomNav, MobileShell } from "@/app/_components/core"; import { ROUTES } from "@/router/routes"; import { useAppNavigator } from "@/router/use-app-navigator"; +import { useAuthState } from "@/stores/auth/auth-context"; import { pwaUtil } from "@/utils"; import { @@ -14,6 +15,7 @@ import { SplashLatestMessage, SplashLogo, } from "./components"; +import { useSplashLatestMessage } from "./hooks/use-splash-latest-message"; import styles from "./components/splash-screen.module.css"; // 尽量早地缓存 beforeinstallprompt,避免用户停留 splash 时浏览器事件已触发并丢失。 @@ -23,6 +25,12 @@ if (typeof window !== "undefined") { export function SplashScreen() { const navigator = useAppNavigator(); + const authState = useAuthState(); + const latestMessage = useSplashLatestMessage({ + hasInitialized: authState.hasInitialized, + isAuthLoading: authState.isLoading, + loginStatus: authState.loginStatus, + }); const handleStartChat = () => { navigator.openChat({ replace: true }); @@ -45,7 +53,11 @@ export function SplashScreen() {
- +
diff --git a/src/lib/chat/splash_latest_message.ts b/src/lib/chat/splash_latest_message.ts new file mode 100644 index 00000000..db1d2e98 --- /dev/null +++ b/src/lib/chat/splash_latest_message.ts @@ -0,0 +1,16 @@ +"use client"; + +import { getChatRepository } from "@/data/repositories/chat_repository"; +import { Result, type Result as ResultT } from "@/utils"; + +import { getSplashLatestMessagePreview } from "./splash_latest_message_preview"; + +export async function fetchSplashLatestMessagePreview(): Promise< + ResultT +> { + const result = await getChatRepository().getHistory(1, 0); + if (Result.isErr(result)) return Result.err(result.error); + + const latestMessage = result.data.messages[0] ?? null; + return Result.ok(getSplashLatestMessagePreview(latestMessage)); +} diff --git a/src/lib/chat/splash_latest_message_preview.ts b/src/lib/chat/splash_latest_message_preview.ts new file mode 100644 index 00000000..ca145bec --- /dev/null +++ b/src/lib/chat/splash_latest_message_preview.ts @@ -0,0 +1,56 @@ +export interface SplashLatestMessageSource { + type?: string | null; + content?: string | null; + audioUrl?: string | null; + image?: { + type?: string | null; + url?: string | null; + } | null; + lockDetail?: { + reason?: string | null; + } | null; +} + +const IMAGE_PREVIEW_TEXT = "[图片]"; +const VOICE_PREVIEW_TEXT = "[语音]"; +const FALLBACK_PREVIEW_TEXT = "[消息]"; + +export function getSplashLatestMessagePreview( + message: SplashLatestMessageSource | null | undefined, +): string | null { + if (!message) return null; + + if (isVoiceMessage(message)) return VOICE_PREVIEW_TEXT; + if (isImageMessage(message)) return IMAGE_PREVIEW_TEXT; + + const content = message.content?.trim() ?? ""; + return content.length > 0 ? content : FALLBACK_PREVIEW_TEXT; +} + +function isVoiceMessage(message: SplashLatestMessageSource): boolean { + const type = normalizeMessageType(message.type); + return ( + type === "voice" || + type === "audio" || + hasValue(message.audioUrl) || + message.lockDetail?.reason === "voice_message" + ); +} + +function isImageMessage(message: SplashLatestMessageSource): boolean { + const type = normalizeMessageType(message.type); + return ( + type === "image" || + hasValue(message.image?.url) || + hasValue(message.image?.type) || + message.lockDetail?.reason === "image" + ); +} + +function normalizeMessageType(value: string | null | undefined): string { + return value?.trim().toLowerCase() ?? ""; +} + +function hasValue(value: string | null | undefined): boolean { + return value != null && value.trim().length > 0; +}