Video Messaging
Asynchronous video messaging with browser-based recording (screen + camera + audio), MUX-hosted playback, automatic transcription, AI-generated summaries, and threaded conversations. Think Loom-style video messages with rich post-processing.
Prerequisites
- Next.js app with
src/directory and App Router stream-muxskill applied (MUX upload + asset management)video-playerskill applied (MUX Player React components)dbskill applied (Drizzle ORM + Postgres)authskill applied (better-auth for user sessions)queueskill applied (Inngest for background transcription + AI summary jobs)storageskill applied (for supplementary file storage)
Installation
No additional packages required. This skill uses:
MediaRecorderbrowser API (built into all modern browsers)navigator.mediaDevices.getDisplayMediafor screen capturenavigator.mediaDevices.getUserMediafor camera + audio@mux/mux-node(fromstream-muxdependency)@mux/mux-player-react(fromvideo-playerdependency)
What Gets Created
src/
├── lib/
│ ├── video/
│ │ ├── video-message.ts # Server: CRUD for video messages
│ │ ├── use-recorder.ts # Client hook: screen + camera recording
│ │ └── types-messaging.ts # VideoMessage, VideoThread types
│ └── db/
│ └── schema/
│ └── video-messages.ts # Drizzle schema: video_messages table
├── components/
│ └── video/
│ ├── video-recorder.tsx # Record UI with camera preview + controls
│ ├── video-message-card.tsx # Message card with summary + transcript
│ └── video-thread.tsx # Threaded conversation of video messages
└── app/
└── api/
└── video/
└── messages/
├── route.ts # POST create, GET list
└── [id]/
└── route.ts # GET message with details, GET replies
Setup Steps
Step 1: Create src/lib/video/types-messaging.ts
export type VideoMessageStatus =
| "recording"
| "uploading"
| "processing"
| "transcribing"
| "ready"
| "errored";
export type VideoMessage = {
id: string;
userId: string;
title: string;
muxAssetId: string | null;
muxPlaybackId: string | null;
transcriptId: string | null;
meetingNoteId: string | null;
parentId: string | null;
duration: number | null;
status: VideoMessageStatus;
createdAt: Date;
};
export type VideoMessageWithDetails = VideoMessage & {
transcript: string | null;
summary: string | null;
actionItems: string[] | null;
senderName: string | null;
senderEmail: string | null;
senderImage: string | null;
replyCount: number;
};
export type VideoThread = {
message: VideoMessageWithDetails;
replies: VideoMessageWithDetails[];
};
export type RecorderOptions = {
screen: boolean;
camera: boolean;
audio: boolean;
};
export type RecorderState = {
isRecording: boolean;
isPaused: boolean;
duration: number;
previewStream: MediaStream | null;
cameraStream: MediaStream | null;
recordedBlob: Blob | null;
error: string | null;
};
Step 2: Create src/lib/video/use-recorder.ts
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import type { RecorderOptions, RecorderState } from "@/lib/video/types-messaging";
type UseRecorderReturn = RecorderState & {
startRecording: (options: RecorderOptions) => Promise<void>;
stopRecording: () => void;
pauseRecording: () => void;
resumeRecording: () => void;
resetRecording: () => void;
};
function useRecorder(): UseRecorderReturn {
const [isRecording, setIsRecording] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [duration, setDuration] = useState(0);
const [previewStream, setPreviewStream] = useState<MediaStream | null>(null);
const [cameraStream, setCameraStream] = useState<MediaStream | null>(null);
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTimeRef = useRef<number>(0);
const pausedDurationRef = useRef<number>(0);
// Clean up timer on unmount
useEffect(() => {
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, []);
const stopAllTracks = useCallback((stream: MediaStream | null) => {
if (stream) {
for (const track of stream.getTracks()) {
track.stop();
}
}
}, []);
const startTimer = useCallback(() => {
startTimeRef.current = Date.now() - pausedDurationRef.current * 1000;
timerRef.current = setInterval(() => {
const elapsed = (Date.now() - startTimeRef.current) / 1000;
setDuration(Math.floor(elapsed));
}, 100);
}, []);
const stopTimer = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const startRecording = useCallback(
async (options: RecorderOptions) => {
setError(null);
setRecordedBlob(null);
chunksRef.current = [];
pausedDurationRef.current = 0;
try {
const tracks: MediaStreamTrack[] = [];
let screenStream: MediaStream | null = null;
let camStream: MediaStream | null = null;
// Get screen capture if requested
if (options.screen) {
screenStream = await navigator.mediaDevices.getDisplayMedia({
video: {
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30 },
},
audio: options.audio,
});
for (const track of screenStream.getVideoTracks()) {
tracks.push(track);
}
// Use screen audio if available
if (options.audio) {
for (const track of screenStream.getAudioTracks()) {
tracks.push(track);
}
}
}
// Get camera + microphone if requested
if (options.camera || (options.audio && !options.screen)) {
const constraints: MediaStreamConstraints = {};
if (options.camera) {
constraints.video = {
width: { ideal: 640 },
height: { ideal: 480 },
facingMode: "user",
};
}
if (options.audio && !screenStream?.getAudioTracks().length) {
constraints.audio = {
echoCancellation: true,
noiseSuppression: true,
};
}
camStream = await navigator.mediaDevices.getUserMedia(constraints);
setCameraStream(camStream);
// If we have screen capture, only add audio from camera (not video)
if (options.screen) {
if (options.audio) {
for (const track of camStream.getAudioTracks()) {
tracks.push(track);
}
}
} else {
for (const track of camStream.getTracks()) {
tracks.push(track);
}
}
}
if (tracks.length === 0) {
throw new Error("No media tracks available. Enable screen, camera, or audio.");
}
const combinedStream = new MediaStream(tracks);
setPreviewStream(combinedStream);
// Determine best MIME type
const mimeType = MediaRecorder.isTypeSupported("video/webm;codecs=vp9,opus")
? "video/webm;codecs=vp9,opus"
: MediaRecorder.isTypeSupported("video/webm;codecs=vp8,opus")
? "video/webm;codecs=vp8,opus"
: "video/webm";
const recorder = new MediaRecorder(combinedStream, {
mimeType,
videoBitsPerSecond: 2_500_000,
});
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: mimeType });
setRecordedBlob(blob);
setIsRecording(false);
setIsPaused(false);
stopTimer();
// Stop all tracks
stopAllTracks(combinedStream);
stopAllTracks(camStream);
setPreviewStream(null);
setCameraStream(null);
};
recorder.onerror = () => {
setError("Recording failed unexpectedly");
setIsRecording(false);
stopTimer();
stopAllTracks(combinedStream);
stopAllTracks(camStream);
};
// Listen for screen share stop
if (screenStream) {
for (const track of screenStream.getVideoTracks()) {
track.addEventListener("ended", () => {
if (mediaRecorderRef.current?.state === "recording") {
mediaRecorderRef.current.stop();
}
});
}
}
mediaRecorderRef.current = recorder;
recorder.start(1000); // Collect data every second
setIsRecording(true);
startTimer();
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to start recording";
setError(message);
setIsRecording(false);
}
},
[startTimer, stopTimer, stopAllTracks]
);
const stopRecording = useCallback(() => {
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state !== "inactive"
) {
mediaRecorderRef.current.stop();
}
}, []);
const pauseRecording = useCallback(() => {
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state === "recording"
) {
mediaRecorderRef.current.pause();
setIsPaused(true);
pausedDurationRef.current = duration;
stopTimer();
}
}, [duration, stopTimer]);
const resumeRecording = useCallback(() => {
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state === "paused"
) {
mediaRecorderRef.current.resume();
setIsPaused(false);
startTimer();
}
}, [startTimer]);
const resetRecording = useCallback(() => {
stopRecording();
setRecordedBlob(null);
setDuration(0);
setError(null);
chunksRef.current = [];
pausedDurationRef.current = 0;
}, [stopRecording]);
return {
isRecording,
isPaused,
duration,
previewStream,
cameraStream,
recordedBlob,
error,
startRecording,
stopRecording,
pauseRecording,
resumeRecording,
resetRecording,
};
}
export { useRecorder };
export type { UseRecorderReturn };
Step 3: Create src/lib/video/video-message.ts
import { db } from "@/lib/db";
import { videoMessages } from "@/lib/db/schema";
import { eq, desc, and, isNull, sql } from "drizzle-orm";
import { createUploadUrl } from "@/lib/video/mux-vod";
import { inngest } from "@/lib/queue/client";
import type { VideoMessage, VideoMessageWithDetails } from "@/lib/video/types-messaging";
/**
* Create a new video message, get a MUX upload URL, and return both.
*/
export async function createVideoMessage(
userId: string,
title: string,
parentId?: string
): Promise<{ message: VideoMessage; uploadUrl: string }> {
// Get a MUX direct upload URL
const upload = await createUploadUrl();
// Create the video message record
const [record] = await db
.insert(videoMessages)
.values({
userId,
title,
muxUploadId: upload.id,
parentId: parentId ?? null,
status: "uploading",
})
.returning();
const message: VideoMessage = {
id: record.id,
userId: record.userId,
title: record.title,
muxAssetId: record.muxAssetId,
muxPlaybackId: record.muxPlaybackId,
transcriptId: record.transcriptId,
meetingNoteId: record.meetingNoteId,
parentId: record.parentId,
duration: record.duration ? Number(record.duration) : null,
status: record.status as VideoMessage["status"],
createdAt: record.createdAt,
};
return { message, uploadUrl: upload.url };
}
/**
* Called after MUX webhook confirms the asset is ready.
* Triggers background transcription + AI summary generation.
*/
export async function onVideoMessageReady(
muxAssetId: string,
muxPlaybackId: string,
duration: number | null
): Promise<void> {
// Find the video message by MUX asset ID or upload ID
const [record] = await db
.select()
.from(videoMessages)
.where(eq(videoMessages.muxAssetId, muxAssetId))
.limit(1);
if (!record) return;
// Update the record with playback info
await db
.update(videoMessages)
.set({
muxPlaybackId: muxPlaybackId,
duration: duration ? String(duration) : null,
status: "transcribing",
updatedAt: new Date(),
})
.where(eq(videoMessages.id, record.id));
// Trigger background transcription + AI summary via Inngest
await inngest.send({
name: "job/video-message-process",
data: {
jobId: record.id,
userId: record.userId,
videoMessageId: record.id,
muxAssetId,
muxPlaybackId,
},
});
}
/**
* Get all top-level video messages for a user (no parent).
*/
export async function getVideoMessages(
userId: string
): Promise<VideoMessageWithDetails[]> {
const records = await db
.select()
.from(videoMessages)
.where(
and(
eq(videoMessages.userId, userId),
isNull(videoMessages.parentId)
)
)
.orderBy(desc(videoMessages.createdAt));
return records.map(mapToVideoMessageWithDetails);
}
/**
* Get a single video message with its details.
*/
export async function getVideoMessage(
messageId: string
): Promise<VideoMessageWithDetails | null> {
const [record] = await db
.select()
.from(videoMessages)
.where(eq(videoMessages.id, messageId))
.limit(1);
if (!record) return null;
return mapToVideoMessageWithDetails(record);
}
/**
* Get replies to a video message.
*/
export async function getVideoMessageReplies(
parentId: string
): Promise<VideoMessageWithDetails[]> {
const records = await db
.select()
.from(videoMessages)
.where(eq(videoMessages.parentId, parentId))
.orderBy(videoMessages.createdAt);
return records.map(mapToVideoMessageWithDetails);
}
type VideoMessageRecord = typeof videoMessages.$inferSelect;
function mapToVideoMessageWithDetails(
record: VideoMessageRecord
): VideoMessageWithDetails {
const transcript =
record.transcript && typeof record.transcript === "string"
? record.transcript
: null;
const summaryData = record.summary as {
text?: string;
actionItems?: string[];
} | null;
return {
id: record.id,
userId: record.userId,
title: record.title,
muxAssetId: record.muxAssetId,
muxPlaybackId: record.muxPlaybackId,
transcriptId: record.transcriptId,
meetingNoteId: record.meetingNoteId,
parentId: record.parentId,
duration: record.duration ? Number(record.duration) : null,
status: record.status as VideoMessage["status"],
createdAt: record.createdAt,
transcript,
summary: summaryData?.text ?? null,
actionItems: summaryData?.actionItems ?? null,
senderName: null, // Populated by API route via join
senderEmail: null,
senderImage: null,
replyCount: 0, // Populated by API route via subquery
};
}
Step 4: Create src/lib/db/schema/video-messages.ts
import {
pgTable,
text,
timestamp,
uuid,
jsonb,
pgEnum,
} from "drizzle-orm/pg-core";
export const videoMessageStatusEnum = pgEnum("video_message_status", [
"recording",
"uploading",
"processing",
"transcribing",
"ready",
"errored",
]);
export const videoMessages = pgTable("video_messages", {
id: uuid("id").primaryKey().defaultRandom(),
userId: text("user_id").notNull(),
title: text("title").notNull().default("Untitled"),
muxAssetId: text("mux_asset_id"),
muxPlaybackId: text("mux_playback_id"),
muxUploadId: text("mux_upload_id"),
transcriptId: text("transcript_id"),
meetingNoteId: text("meeting_note_id"),
parentId: uuid("parent_id"),
status: videoMessageStatusEnum("status").notNull().default("recording"),
duration: text("duration"),
transcript: text("transcript"),
summary: jsonb("summary"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export type VideoMessageRecord = typeof videoMessages.$inferSelect;
export type NewVideoMessageRecord = typeof videoMessages.$inferInsert;
Step 5: Add export to src/lib/db/schema/index.ts
Find the existing exports and add:
export * from "./video-messages";
Step 6: Create src/components/video/video-recorder.tsx
"use client";
import { useState, useCallback, useRef, useEffect, useId } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useRecorder } from "@/lib/video/use-recorder";
type VideoRecorderProps = {
onSend: (blob: Blob, duration: number, title: string) => Promise<void>;
onCancel?: () => void;
defaultTitle?: string;
className?: string;
};
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
function RecordIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="8" />
</svg>
);
}
function StopIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
);
}
function PauseIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
);
}
function PlayIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<polygon points="6,4 20,12 6,20" />
</svg>
);
}
function VideoRecorder({
onSend,
onCancel,
defaultTitle = "Video Message",
className,
}: VideoRecorderProps) {
const recorderId = useId();
const screenPreviewRef = useRef<HTMLVideoElement>(null);
const cameraPreviewRef = useRef<HTMLVideoElement>(null);
const recordedPreviewRef = useRef<HTMLVideoElement>(null);
const [isSending, setIsSending] = useState(false);
const [title, setTitle] = useState(defaultTitle);
const [recordMode, setRecordMode] = useState<"screen" | "camera" | "both">(
"screen"
);
const {
isRecording,
isPaused,
duration,
previewStream,
cameraStream,
recordedBlob,
error,
startRecording,
stopRecording,
pauseRecording,
resumeRecording,
resetRecording,
} = useRecorder();
// Attach preview streams to video elements
useEffect(() => {
if (screenPreviewRef.current && previewStream) {
screenPreviewRef.current.srcObject = previewStream;
}
}, [previewStream]);
useEffect(() => {
if (cameraPreviewRef.current && cameraStream) {
cameraPreviewRef.current.srcObject = cameraStream;
}
}, [cameraStream]);
// Show recorded blob in preview
useEffect(() => {
if (recordedPreviewRef.current && recordedBlob) {
recordedPreviewRef.current.src = URL.createObjectURL(recordedBlob);
}
return () => {
if (recordedPreviewRef.current?.src) {
URL.revokeObjectURL(recordedPreviewRef.current.src);
}
};
}, [recordedBlob]);
const handleStartRecording = useCallback(async () => {
const options = {
screen: recordMode === "screen" || recordMode === "both",
camera: recordMode === "camera" || recordMode === "both",
audio: true,
};
await startRecording(options);
}, [recordMode, startRecording]);
const handleSend = useCallback(async () => {
if (!recordedBlob) return;
setIsSending(true);
try {
await onSend(recordedBlob, duration, title);
resetRecording();
} catch (err) {
console.error("Failed to send video message:", err);
} finally {
setIsSending(false);
}
}, [recordedBlob, duration, title, onSend, resetRecording]);
const handleReRecord = useCallback(() => {
resetRecording();
}, [resetRecording]);
// State: recorded blob available — show preview + send/re-record
if (recordedBlob) {
return (
<Card className={cn("overflow-hidden", className)}>
<CardContent className="p-4 space-y-4">
{/* Recorded video preview */}
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black">
<video
ref={recordedPreviewRef}
controls
className="h-full w-full"
playsInline
/>
<Badge
variant="secondary"
className="absolute top-2 right-2 bg-black/70 text-white font-mono"
>
{formatDuration(duration)}
</Badge>
</div>
{/* Title input */}
<input
type="text"
value={title}
=> setTitle(e.target.value)}
placeholder="Add a title..."
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
aria-label="Video message title"
/>
{/* Action buttons */}
<div className="flex gap-2">
<Button
variant="outline"
disabled={isSending}
className="flex-1"
>
Re-record
</Button>
<Button
disabled={isSending}
className="flex-1"
>
{isSending ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Sending...
</span>
) : (
"Send"
)}
</Button>
</div>
</CardContent>
</Card>
);
}
// State: recording or idle
return (
<Card className={cn("overflow-hidden", className)}>
<CardContent className="p-4 space-y-4">
{/* Preview area */}
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-muted">
{previewStream ? (
<>
<video
ref={screenPreviewRef}
autoPlay
muted
playsInline
className="h-full w-full object-contain"
/>
{/* Camera PiP overlay */}
{cameraStream && (
<div className="absolute bottom-3 right-3 h-24 w-32 overflow-hidden rounded-full border-2 border-white shadow-lg">
<video
ref={cameraPreviewRef}
autoPlay
muted
playsInline
className="h-full w-full object-cover"
/>
</div>
)}
{/* Recording indicator */}
<div className="absolute top-3 left-3 flex items-center gap-2">
<div
className={cn(
"h-3 w-3 rounded-full",
isPaused
? "bg-yellow-500"
: "bg-red-500 animate-pulse"
)}
/>
<Badge
variant="secondary"
className="bg-black/70 text-white font-mono"
>
{formatDuration(duration)}
</Badge>
</div>
</>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 text-muted-foreground">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14" />
<rect x="1" y="6" width="14" height="12" rx="2" />
</svg>
<span className="text-sm">Ready to record</span>
</div>
)}
</div>
{/* Record mode selector (only shown when not recording) */}
{!isRecording && (
<div className="flex gap-2">
{(["screen", "camera", "both"] as const).map((mode) => (
<Button
key={`${recorderId}-${mode}`}
variant={recordMode === mode ? "default" : "outline"}
size="sm"
=> setRecordMode(mode)}
className="flex-1 capitalize"
>
{mode === "both" ? "Screen + Camera" : mode}
</Button>
))}
</div>
)}
{/* Controls */}
<div className="flex items-center justify-center gap-3">
{!isRecording ? (
<Button
size="lg"
variant="destructive"
className="gap-2 rounded-full px-6"
>
<RecordIcon />
Record
</Button>
) : (
<>
{/* Pause/Resume */}
<Button
size="icon"
variant="outline"
? resumeRecording : pauseRecording}
aria-label={isPaused ? "Resume" : "Pause"}
>
{isPaused ? <PlayIcon /> : <PauseIcon />}
</Button>
{/* Stop */}
<Button
size="lg"
variant="destructive"
className="gap-2 rounded-full px-6"
>
<StopIcon />
Stop
</Button>
</>
)}
{/* Cancel */}
{onCancel && !isRecording && (
<Button variant="ghost"
Cancel
</Button>
)}
</div>
{/* Error display */}
{error && (
<p className="text-sm text-destructive text-center">{error}</p>
)}
</CardContent>
</Card>
);
}
export { VideoRecorder };
export type { VideoRecorderProps };
Step 7: Create src/components/video/video-message-card.tsx
"use client";
import { useState, useCallback, useId } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { VideoMessageWithDetails } from "@/lib/video/types-messaging";
type VideoMessageCardProps = {
message: VideoMessageWithDetails;
onPlay?: (playbackId: string) => void;
onReply?: (messageId: string) => void;
className?: string;
};
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
function VideoMessageCard({
message,
onPlay,
onReply,
className,
}: VideoMessageCardProps) {
const cardId = useId();
const [isExpanded, setIsExpanded] = useState(false);
const thumbnailUrl = message.muxPlaybackId
? `https://image.mux.com/${message.muxPlaybackId}/thumbnail.jpg?width=320&height=180&time=2`
: null;
const handlePlay = useCallback(() => {
if (onPlay && message.muxPlaybackId) {
onPlay(message.muxPlaybackId);
}
}, [onPlay, message.muxPlaybackId]);
const handleReply = useCallback(() => {
if (onReply) {
onReply(message.id);
}
}, [onReply, message.id]);
const toggleExpanded = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
const isPlayable = message.status === "ready" && message.muxPlaybackId;
return (
<Card className={cn("overflow-hidden", className)}>
<CardContent className="p-0">
<div className="flex gap-3 p-3">
{/* Thumbnail */}
<div className="relative shrink-0 w-40 overflow-hidden rounded-md bg-muted">
<div className="aspect-video">
{thumbnailUrl ? (
<img
src={thumbnailUrl}
alt={message.title}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
className="text-muted-foreground"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<polygon points="10,8 16,12 10,16" fill="currentColor" />
</svg>
</div>
)}
{/* Play overlay */}
{isPlayable && (
<button
type="button"
className="absolute inset-0 flex items-center justify-center text-white opacity-0 transition-opacity hover:opacity-100 bg-black/30"
aria-label={`Play ${message.title}`}
>
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="currentColor"
>
<circle cx="16" cy="16" r="16" fillOpacity="0.7" />
<polygon points="12,9 24,16 12,23" fill="white" />
</svg>
</button>
)}
{/* Duration badge */}
{message.duration !== null && message.duration > 0 && (
<Badge
variant="secondary"
className="absolute bottom-1 right-1 bg-black/70 text-white text-xs font-mono"
>
{formatDuration(message.duration)}
</Badge>
)}
</div>
</div>
{/* Content */}
<div className="flex min-w-0 flex-1 flex-col justify-between">
<div>
{/* Header: sender + time */}
<div className="flex items-center gap-2 mb-1">
{message.senderImage ? (
<img
src={message.senderImage}
alt={message.senderName ?? ""}
className="h-5 w-5 rounded-full"
/>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs">
{(message.senderName ?? "?")[0].toUpperCase()}
</div>
)}
<span className="text-xs font-medium truncate">
{message.senderName ?? message.senderEmail ?? "Unknown"}
</span>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(message.createdAt)}
</span>
</div>
{/* Title */}
<h4 className="text-sm font-medium leading-tight mb-1 line-clamp-1">
{message.title}
</h4>
{/* AI Summary */}
{message.summary && (
<p className="text-xs text-muted-foreground line-clamp-2">
{message.summary}
</p>
)}
{/* Status badge for non-ready messages */}
{message.status !== "ready" && (
<Badge
variant={
message.status === "errored" ? "destructive" : "secondary"
}
className="mt-1"
>
{message.status === "transcribing"
? "Transcribing..."
: message.status === "processing"
? "Processing..."
: message.status === "uploading"
? "Uploading..."
: message.status}
</Badge>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 mt-2">
{message.summary && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
>
{isExpanded ? "Hide details" : "Show details"}
</Button>
)}
{onReply && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
>
Reply
{message.replyCount > 0 && ` (${message.replyCount})`}
</Button>
)}
</div>
</div>
</div>
{/* Expanded details: transcript + action items */}
{isExpanded && (
<div className="border-t px-3 py-3 space-y-3">
{/* Action items */}
{message.actionItems && message.actionItems.length > 0 && (
<div>
<h5 className="text-xs font-semibold text-muted-foreground mb-1">
Action Items
</h5>
<ul className="space-y-1">
{message.actionItems.map((item, idx) => (
<li
key={`${cardId}-action-${item.slice(0, 20)}`}
className="flex items-start gap-2 text-xs"
>
<span className="mt-0.5 h-3 w-3 shrink-0 rounded border" />
<span>{item}</span>
</li>
))}
</ul>
</div>
)}
{/* Transcript */}
{message.transcript && (
<div>
<h5 className="text-xs font-semibold text-muted-foreground mb-1">
Transcript
</h5>
<p className="text-xs text-muted-foreground whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto">
{message.transcript}
</p>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}
export { VideoMessageCard };
export type { VideoMessageCardProps };
Step 8: Create src/components/video/video-thread.tsx
"use client";
import { useState, useCallback, useId } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { VideoMessageCard } from "@/components/video/video-message-card";
import { VideoRecorder } from "@/components/video/video-recorder";
import type { VideoMessageWithDetails } from "@/lib/video/types-messaging";
type VideoThreadProps = {
message: VideoMessageWithDetails;
replies: VideoMessageWithDetails[];
onPlay?: (playbackId: string) => void;
onSendReply?: (
parentId: string,
blob: Blob,
duration: number,
title: string
) => Promise<void>;
className?: string;
};
function VideoThread({
message,
replies,
onPlay,
onSendReply,
className,
}: VideoThreadProps) {
const threadId = useId();
const [isReplying, setIsReplying] = useState(false);
const handleReply = useCallback(() => {
setIsReplying(true);
}, []);
const handleCancelReply = useCallback(() => {
setIsReplying(false);
}, []);
const handleSendReply = useCallback(
async (blob: Blob, duration: number, title: string) => {
if (onSendReply) {
await onSendReply(message.id, blob, duration, title);
}
setIsReplying(false);
},
[message.id, onSendReply]
);
return (
<div className={cn("space-y-3", className)}>
{/* Original message */}
<VideoMessageCard
message={message}
? handleReply : undefined}
/>
{/* Replies */}
{replies.length > 0 && (
<div className="ml-6 space-y-2 border-l-2 border-muted pl-4">
{replies.map((reply) => (
<VideoMessageCard
key={`${threadId}-reply-${reply.id}`}
message={reply}
/>
))}
</div>
)}
{/* Reply recorder */}
{isReplying && (
<div className="ml-6 border-l-2 border-primary pl-4">
<VideoRecorder
defaultTitle={`Re: ${message.title}`}
/>
</div>
)}
{/* Reply button (when not actively replying) */}
{!isReplying && onSendReply && (
<div className="ml-6">
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground"
>
Reply with video
</Button>
</div>
)}
</div>
);
}
export { VideoThread };
export type { VideoThreadProps };
Step 9: Create src/app/api/video/messages/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import {
createVideoMessage,
getVideoMessages,
} from "@/lib/video/video-message";
import type { VideoMessage, VideoMessageWithDetails } from "@/lib/video/types-messaging";
type CreateResponse = {
message: VideoMessage;
uploadUrl: string;
};
type ListResponse = {
messages: VideoMessageWithDetails[];
};
/** POST /api/video/messages — Create a video message and get upload URL */
export async function POST(
request: Request
): Promise<NextResponse<CreateResponse | { error: string }>> {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await request.json().catch(() => ({}));
const { title, parentId } = body as {
title?: string;
parentId?: string;
};
const result = await createVideoMessage(
session.user.id,
title ?? "Video Message",
parentId
);
return NextResponse.json(result, { status: 201 });
} catch (err) {
const message =
err instanceof Error ? e
…(truncated)