Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 180 additions & 22 deletions src/components/collaboration/VideoConference.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import { Mic, Video, Monitor, Phone, VideoOff, MicOff } from 'lucide-react';
import { Mic, Video, Monitor, Phone, VideoOff, MicOff, ShieldAlert, AlertTriangle } from 'lucide-react';
import { io, type Socket } from 'socket.io-client';
import type { CollaborationUser } from '../../hooks/useCollaboration';
import { useFraudDetection } from '../../hooks/useFraudDetection';
import { fraudDetectionService, FraudDetectionService } from '../../services/fraud-detection';
import type { FraudDetectionResult } from '../../services/fraud-detection';

interface VideoConferenceProps {
roomId: string;
user: CollaborationUser;
websocketUrl?: string;
fraudService?: FraudDetectionService;
isHost?: boolean;
hostUserId?: string;
}

type SignalingOffer = {
Expand All @@ -29,7 +35,14 @@ type IceCandidatePayload = {
userId: string;
};

export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceProps) {
export function VideoConference({
roomId,
user,
websocketUrl,
fraudService = fraudDetectionService,
isHost = false,
hostUserId,
}: VideoConferenceProps) {
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
Expand All @@ -42,30 +55,54 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP
const [microphoneEnabled, setMicrophoneEnabled] = useState(true);
const [sharingScreen, setSharingScreen] = useState(false);
const [status, setStatus] = useState('Idle');
const [fraudWarning, setFraudWarning] = useState<string | null>(null);

const signalingUrl = websocketUrl || process.env.NEXT_PUBLIC_WEBSOCKET_URL || 'http://localhost:3001';
const fraud = useFraudDetection(fraudService, {
user,
roomId,
isHost,
hostUserId,
});

const signalingUrl =
websocketUrl ||
process.env.NEXT_PUBLIC_WEBSOCKET_URL ||
'http://localhost:3001';

useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}

const check = fraud.checkJoin();
if (check.blocked) {
setStatus('Access blocked by fraud detection');
setFraudWarning('Multiple active connections detected');
return undefined;
}

const socket = io(signalingUrl, {
autoConnect: true,
transports: ['websocket'],
});
socketRef.current = socket;

socket.on('connect', () => {
socket.emit('join-room', { roomId, userId: user.id, userName: user.name });
socket.emit('join-room', {
roomId,
userId: user.id,
userName: user.name,
});
setStatus('Connected to signaling');
});

socket.on('webrtc-offer', async ({ offer, userId }: SignalingOffer) => {
if (userId === user.id) return;
await createPeerConnection();
if (!pcRef.current) return;
await pcRef.current.setRemoteDescription(new RTCSessionDescription(offer));
await pcRef.current.setRemoteDescription(
new RTCSessionDescription(offer),
);
const answer = await pcRef.current.createAnswer();
await pcRef.current.setLocalDescription(answer);
socket.emit('webrtc-answer', { roomId, answer, userId: user.id });
Expand All @@ -76,7 +113,9 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP
socket.on('webrtc-answer', async ({ answer, userId }: SignalingAnswer) => {
if (userId === user.id) return;
if (!pcRef.current) return;
await pcRef.current.setRemoteDescription(new RTCSessionDescription(answer));
await pcRef.current.setRemoteDescription(
new RTCSessionDescription(answer),
);
setCallActive(true);
setStatus('Call established');
});
Expand All @@ -91,6 +130,7 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP
});

return () => {
fraud.checkLeave();
socket.disconnect();
socketRef.current = null;
};
Expand Down Expand Up @@ -132,7 +172,10 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP
};

peerConnection.onconnectionstatechange = () => {
if (peerConnection.connectionState === 'disconnected' || peerConnection.connectionState === 'failed') {
if (
peerConnection.connectionState === 'disconnected' ||
peerConnection.connectionState === 'failed'
) {
setStatus('Remote connection lost');
}
};
Expand All @@ -143,7 +186,10 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP

const startLocalStream = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
setLocalStream(stream);
stream.getAudioTracks().forEach((track) => {
track.enabled = microphoneEnabled;
Expand All @@ -166,11 +212,30 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP
return;
}

const check: FraudDetectionResult = fraud.checkStartCall();
if (check.blocked) {
setStatus('Call blocked by fraud detection');
setFraudWarning('Suspicious activity detected. Please try again later.');
return;
}
if (check.isSuspicious) {
setFraudWarning('Unusual activity detected. Your actions are being monitored.');
}

const bombingCheck: FraudDetectionResult = fraud.checkMeetingBombing();
if (bombingCheck.blocked) {
setStatus('Meeting bombing attempt detected');
setFraudWarning('Too many rapid join attempts detected');
return;
}

const stream = await startLocalStream();
if (!stream) return;

const peerConnection = await createPeerConnection();
stream.getTracks().forEach((track) => peerConnection.addTrack(track, stream));
stream
.getTracks()
.forEach((track) => peerConnection.addTrack(track, stream));

const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
Expand Down Expand Up @@ -215,12 +280,24 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP
});
setLocalStream(stream);
setSharingScreen(false);
socketRef.current?.emit('screen-share', { roomId, userId: user.id, sharing: false });
fraud.checkScreenShare(false);
socketRef.current?.emit('screen-share', {
roomId,
userId: user.id,
sharing: false,
});
return;
}

const check = fraud.checkScreenShare(true);
if (check.isSuspicious) {
setFraudWarning('Screen share abuse detected. Rate limited.');
}

try {
const displayStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const displayStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
});
const screenTrack = displayStream.getVideoTracks()[0];
const peerConnection = await createPeerConnection();
peerConnection.getSenders().forEach((sender) => {
Expand All @@ -229,7 +306,11 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP
}
});
setSharingScreen(true);
socketRef.current?.emit('screen-share', { roomId, userId: user.id, sharing: true });
socketRef.current?.emit('screen-share', {
roomId,
userId: user.id,
sharing: true,
});
screenTrack.onended = () => {
setSharingScreen(false);
if (localStream) {
Expand Down Expand Up @@ -257,30 +338,78 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP
<div className="rounded-3xl border border-gray-200 bg-white/90 p-5 shadow-sm dark:border-slate-800 dark:bg-slate-950/90">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">Video conference</h2>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">Peer-to-peer WebRTC audio/video with screen sharing and signaling.</p>
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
Video conference
</h2>
{fraud.fraudScore > 0 && (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
fraud.fraudScore >= 50
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400'
: fraud.fraudScore >= 20
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400'
}`}
title={`Fraud score: ${fraud.fraudScore}`}
>
<ShieldAlert size={12} aria-hidden="true" />
{fraud.fraudScore}
</span>
)}
</div>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Peer-to-peer WebRTC audio/video with screen sharing and signaling.
</p>
</div>
<div className="flex items-center gap-2">
{fraudWarning && (
<div
className="flex items-center gap-1.5 rounded-full bg-amber-50 px-3 py-1 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
role="alert"
>
<AlertTriangle size={12} aria-hidden="true" />
{fraudWarning}
</div>
)}
<div className="rounded-full bg-slate-100 px-3 py-1 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-200">
{status}
</div>
</div>
<div className="rounded-full bg-slate-100 px-3 py-1 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-200">{status}</div>
</div>

<div className="mt-5 grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900">
<div className="grid gap-4 sm:grid-cols-2">
<div className="overflow-hidden rounded-3xl bg-slate-900">
<video ref={localVideoRef} autoPlay muted playsInline className="h-72 w-full object-cover" />
<video
ref={localVideoRef}
autoPlay
muted
playsInline
className="h-72 w-full object-cover"
/>
<div className="p-3 text-sm text-slate-200">Your camera</div>
</div>
<div className="overflow-hidden rounded-3xl bg-slate-900">
<video ref={remoteVideoRef} autoPlay playsInline className="h-72 w-full object-cover" />
<div className="p-3 text-sm text-slate-200">Remote participant</div>
<video
ref={remoteVideoRef}
autoPlay
playsInline
className="h-72 w-full object-cover"
/>
<div className="p-3 text-sm text-slate-200">
Remote participant
</div>
</div>
</div>

<div className="mt-5 grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={startCall}
className="inline-flex items-center justify-center gap-2 rounded-3xl bg-blue-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-blue-700"
disabled={fraud.isBlocked}
className="inline-flex items-center justify-center gap-2 rounded-3xl bg-blue-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Phone size={16} /> Start call
</button>
Expand All @@ -292,10 +421,28 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP
<VideoOff size={16} /> End call
</button>
</div>

{fraud.accessCheck && !fraud.accessCheck.allowed && (
<div
className="mt-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/40 dark:bg-red-950/40 dark:text-red-400"
role="alert"
>
<div className="flex items-center gap-2 font-medium">
<ShieldAlert size={14} aria-hidden="true" />
Access restricted
</div>
<p className="mt-1 text-xs">
{fraud.accessCheck.reason ||
'Access to this conference is restricted.'}
</p>
</div>
)}
</div>

<div className="space-y-4 rounded-3xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900">
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">Controls</div>
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Controls
</div>
<button
type="button"
onClick={toggleCamera}
Expand All @@ -315,18 +462,29 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP
onClick={toggleScreenShare}
className="inline-flex w-full items-center justify-center gap-2 rounded-3xl bg-slate-100 px-4 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
>
<Monitor size={16} /> {sharingScreen ? 'Stop screen share' : 'Share screen'}
<Monitor size={16} />{' '}
{sharingScreen ? 'Stop screen share' : 'Share screen'}
</button>

<div className="rounded-3xl border border-slate-200 bg-white p-4 text-sm text-slate-700 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-200">
<div className="flex items-center gap-2">
<Mic size={14} />
<span>{microphoneEnabled ? 'Microphone active' : 'Microphone muted'}</span>
<span>
{microphoneEnabled ? 'Microphone active' : 'Microphone muted'}
</span>
</div>
<div className="mt-3 flex items-center gap-2">
<Video size={14} />
<span>{cameraEnabled ? 'Camera active' : 'Camera off'}</span>
</div>
<div className="mt-3 flex items-center gap-2">
<ShieldAlert size={14} />
<span>
{fraud.fraudScore === 0
? 'No suspicious activity'
: `Fraud score: ${fraud.fraudScore}`}
</span>
</div>
</div>
</div>
</div>
Expand Down
Loading