Enhance MobileCallPage with chat transcript feature

- Introduce a new `MobileCallTranscript` component to display chat messages during calls, improving user interaction.
- Implement message timestamp formatting and automatic scrolling for better usability.
- Add a toggle button to switch between camera view and chat view, enhancing the user experience during calls.
- Refactor the layout of the `MobileCallPage` to accommodate the new chat functionality while maintaining existing video call features.
This commit is contained in:
Xin Wang
2026-07-10 14:56:34 +08:00
parent 4a57b290d3
commit 2a002697aa

View File

@@ -1,7 +1,16 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { Camera, Headphones, Loader2, Mic, Phone, PhoneOff, Video } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Camera,
Headphones,
Loader2,
MessageSquareText,
Mic,
Phone,
PhoneOff,
Video,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -20,7 +29,81 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useCameraPreview } from "@/hooks/use-camera-preview";
import { useVoicePreview } from "@/hooks/use-voice-preview";
import {
useVoicePreview,
type ChatMessage,
} from "@/hooks/use-voice-preview";
type CallView = "camera" | "chat";
function messageTime(timestamp: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
function MobileCallTranscript({ messages }: { messages: ChatMessage[] }) {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = scrollRef.current;
if (container) container.scrollTop = container.scrollHeight;
}, [messages]);
return (
<div className="absolute inset-0 z-[1] bg-[#07101a]/95 backdrop-blur-sm">
<div
ref={scrollRef}
className="scrollbar-subtle h-full overflow-y-auto px-4 pb-28 pt-20"
>
{messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 pb-16 text-center">
<MessageSquareText className="size-7 text-white/40" />
<p className="text-sm font-medium text-white/80"></p>
<p className="max-w-64 text-xs leading-5 text-white/45">
</p>
</div>
) : (
<div className="flex flex-col gap-4">
{messages.map((message) => {
const time = messageTime(message.timestamp);
const isAssistant = message.role === "assistant";
return (
<div
key={message.id}
className={[
"flex max-w-[88%] flex-col gap-1",
isAssistant ? "self-start" : "self-end items-end",
].join(" ")}
>
<span className="px-1 text-[11px] text-white/40">
{isAssistant ? "助手" : "我"}
{time ? ` · ${time}` : ""}
</span>
<div
className={[
"whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-sm leading-6 shadow-sm",
isAssistant
? "rounded-tl-sm bg-white/10 text-white/90"
: "rounded-tr-sm bg-white text-[#07101a]",
].join(" ")}
>
{message.content || (message.streaming ? "…" : "")}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
function CallAudioDeviceSelect({
micValue,
@@ -163,6 +246,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
selectDevice,
selectOutputDevice,
supportsOutputSelection,
messages,
connect,
replaceVideoStream,
disconnect,
@@ -179,6 +263,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
selectCamera: changeCamera,
} = camera;
const videoRef = useRef<HTMLVideoElement>(null);
const [view, setView] = useState<CallView>("camera");
const connecting = status === "connecting";
const inCall = connecting || status === "connected";
@@ -219,22 +304,28 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
const error = previewError || cameraError;
return (
<main className="relative isolate h-dvh min-h-80 w-full overflow-hidden bg-[#07101a] text-white">
<main className="flex h-dvh w-full items-center justify-center overflow-hidden bg-black lg:p-4">
<audio ref={audioRef} autoPlay playsInline className="hidden" />
<div
data-testid="mobile-call-viewport"
className="relative isolate h-dvh min-h-80 w-full overflow-hidden bg-[#07101a] text-white lg:h-[calc(100dvh-2rem)] lg:min-h-0 lg:w-auto lg:aspect-[9/16] lg:rounded-[2rem] lg:ring-1 lg:ring-white/15 lg:shadow-2xl"
>
<video
ref={videoRef}
autoPlay
playsInline
muted
className={[
"absolute inset-0 h-full w-full -scale-x-100 object-cover transition-opacity duration-300",
cameraStream ? "opacity-100" : "opacity-0",
"absolute inset-0 h-full w-full -scale-x-100 object-cover transition-opacity duration-300 lg:object-contain",
cameraStream && view === "camera" ? "opacity-100" : "opacity-0",
].join(" ")}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/35 via-transparent to-black/65" />
<div className="absolute left-1/2 top-[max(1rem,env(safe-area-inset-top))] flex -translate-x-1/2 items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1.5 text-xs text-white/85 backdrop-blur-md">
{view === "chat" && <MobileCallTranscript messages={messages} />}
<div className="absolute left-1/2 top-[max(1rem,env(safe-area-inset-top))] z-10 flex -translate-x-1/2 items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1.5 text-xs text-white/85 backdrop-blur-md">
<span
className={[
"h-2 w-2 rounded-full",
@@ -252,8 +343,22 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
: "通话已结束"}
</div>
{!cameraStream && inCall && (
<div className="absolute inset-0 flex items-center justify-center px-8 text-center">
<button
type="button"
onClick={() => setView((current) => current === "camera" ? "chat" : "camera")}
aria-label={view === "camera" ? "显示聊天记录" : "显示摄像头画面"}
title={view === "camera" ? "聊天记录" : "摄像头画面"}
className="absolute right-4 top-[max(1rem,env(safe-area-inset-top))] z-10 flex size-9 items-center justify-center rounded-full border border-white/15 bg-black/40 text-white shadow-lg backdrop-blur-md transition-colors hover:bg-black/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
>
{view === "camera" ? (
<MessageSquareText className="size-[18px]" />
) : (
<Video className="size-[18px]" />
)}
</button>
{!cameraStream && inCall && view === "camera" && (
<div className="absolute inset-0 z-[2] flex items-center justify-center px-8 text-center">
<div className="flex max-w-sm flex-col items-center gap-3">
{cameraStarting ? (
<Loader2 size={34} className="animate-spin text-white/70" />
@@ -270,7 +375,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
)}
{!inCall && (
<div className="absolute inset-0 flex items-center justify-center px-8">
<div className="absolute inset-0 z-[2] flex items-center justify-center px-8">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<Button
type="button"
@@ -286,7 +391,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
)}
{inCall && (
<div className="absolute bottom-[max(1.25rem,env(safe-area-inset-bottom))] left-1/2 flex -translate-x-1/2 items-center gap-3 min-[380px]:gap-5 landscape:bottom-[max(1rem,env(safe-area-inset-bottom))]">
<div className="absolute bottom-[max(1.25rem,env(safe-area-inset-bottom))] left-1/2 z-10 flex -translate-x-1/2 items-center gap-3 min-[380px]:gap-5 landscape:bottom-[max(1rem,env(safe-area-inset-bottom))]">
<CallAudioDeviceSelect
micValue={selectedDeviceId}
micDevices={audioInputs}
@@ -314,6 +419,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
/>
</div>
)}
</div>
</main>
);
}