feat(splash): load latest chat message
Docker Image / Build and Push Docker Image (push) Has been cancelled
Docker Image / Build and Push Docker Image (push) Has been cancelled
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user