feat(splash): load latest chat message
Docker Image / Build and Push Docker Image (push) Has been cancelled

This commit is contained in:
2026-07-08 17:31:58 +08:00
parent 75aa084095
commit baf0deeb24
7 changed files with 268 additions and 4 deletions
@@ -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("[消息]");
});
});
@@ -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;
}
}
@@ -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 (
<button
type="button"
@@ -33,9 +39,11 @@ export function SplashLatestMessage({
<MessageCircle size={13} aria-hidden="true" />
Latest message
</span>
<span className={styles.message}>
I saved a sweet little moment for you. Come closer?
</span>
{isLoading ? (
<span className={styles.messageLoading} aria-label="Loading latest message" />
) : (
<span className={styles.message}>{message}</span>
)}
</span>
</button>
);
@@ -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<SplashLatestMessageState>({
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 };
}
+13 -1
View File
@@ -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() {
<div className={styles.content}>
<SplashLogo />
<div className={styles.spacer} />
<SplashLatestMessage onOpenChat={handleStartChat} />
<SplashLatestMessage
message={latestMessage.message}
isLoading={latestMessage.isLoading}
onOpenChat={handleStartChat}
/>
<SplashContent />
<div className={styles.buttonArea}>
<SplashButton onStartChat={handleStartChat} />
+16
View File
@@ -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<string | null>
> {
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));
}
@@ -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;
}