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;
|
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 {
|
@keyframes messageFloatIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -97,3 +113,13 @@
|
|||||||
transform: translateY(0) scale(1);
|
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";
|
import styles from "./splash-latest-message.module.css";
|
||||||
|
|
||||||
export interface SplashLatestMessageProps {
|
export interface SplashLatestMessageProps {
|
||||||
|
message: string | null;
|
||||||
|
isLoading?: boolean;
|
||||||
onOpenChat: () => void;
|
onOpenChat: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SplashLatestMessage({
|
export function SplashLatestMessage({
|
||||||
|
message,
|
||||||
|
isLoading = false,
|
||||||
onOpenChat,
|
onOpenChat,
|
||||||
}: SplashLatestMessageProps) {
|
}: SplashLatestMessageProps) {
|
||||||
|
if (!isLoading && !message) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -33,9 +39,11 @@ export function SplashLatestMessage({
|
|||||||
<MessageCircle size={13} aria-hidden="true" />
|
<MessageCircle size={13} aria-hidden="true" />
|
||||||
Latest message
|
Latest message
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.message}>
|
{isLoading ? (
|
||||||
I saved a sweet little moment for you. Come closer?
|
<span className={styles.messageLoading} aria-label="Loading latest message" />
|
||||||
</span>
|
) : (
|
||||||
|
<span className={styles.message}>{message}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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 { AppBottomNav, MobileShell } from "@/app/_components/core";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { pwaUtil } from "@/utils";
|
import { pwaUtil } from "@/utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
SplashLatestMessage,
|
SplashLatestMessage,
|
||||||
SplashLogo,
|
SplashLogo,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
|
import { useSplashLatestMessage } from "./hooks/use-splash-latest-message";
|
||||||
import styles from "./components/splash-screen.module.css";
|
import styles from "./components/splash-screen.module.css";
|
||||||
|
|
||||||
// 尽量早地缓存 beforeinstallprompt,避免用户停留 splash 时浏览器事件已触发并丢失。
|
// 尽量早地缓存 beforeinstallprompt,避免用户停留 splash 时浏览器事件已触发并丢失。
|
||||||
@@ -23,6 +25,12 @@ if (typeof window !== "undefined") {
|
|||||||
|
|
||||||
export function SplashScreen() {
|
export function SplashScreen() {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
|
const authState = useAuthState();
|
||||||
|
const latestMessage = useSplashLatestMessage({
|
||||||
|
hasInitialized: authState.hasInitialized,
|
||||||
|
isAuthLoading: authState.isLoading,
|
||||||
|
loginStatus: authState.loginStatus,
|
||||||
|
});
|
||||||
|
|
||||||
const handleStartChat = () => {
|
const handleStartChat = () => {
|
||||||
navigator.openChat({ replace: true });
|
navigator.openChat({ replace: true });
|
||||||
@@ -45,7 +53,11 @@ export function SplashScreen() {
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<SplashLogo />
|
<SplashLogo />
|
||||||
<div className={styles.spacer} />
|
<div className={styles.spacer} />
|
||||||
<SplashLatestMessage onOpenChat={handleStartChat} />
|
<SplashLatestMessage
|
||||||
|
message={latestMessage.message}
|
||||||
|
isLoading={latestMessage.isLoading}
|
||||||
|
onOpenChat={handleStartChat}
|
||||||
|
/>
|
||||||
<SplashContent />
|
<SplashContent />
|
||||||
<div className={styles.buttonArea}>
|
<div className={styles.buttonArea}>
|
||||||
<SplashButton onStartChat={handleStartChat} />
|
<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