feat(matrix-web): add VoIP/video call support

- Add VoIP types: CallState, CallType, CallDirection, SimpleCall, CallCallbacks
- Add VoIP state management to Matrix store with place/answer/reject/hangup methods
- Add mic/camera mute toggle functionality
- Create CallView component for active calls with video streams
- Create IncomingCallDialog component for incoming call notifications
- Enable call buttons in RoomHeader (DMs only)
- Integrate call components into chat page
This commit is contained in:
Till-JS 2026-01-29 21:28:35 +01:00
parent 22a0feeacd
commit c64b4d6ac9
7 changed files with 782 additions and 10 deletions

View file

@ -0,0 +1,203 @@
<script lang="ts">
import { matrixStore, type SimpleCall } from '$lib/matrix';
import {
PhoneDisconnect,
Microphone,
MicrophoneSlash,
VideoCamera,
VideoCameraSlash,
User,
} from '@manacore/shared-icons';
import { onDestroy } from 'svelte';
interface Props {
call: SimpleCall;
onHangup?: () => void;
}
let { call, onHangup }: Props = $props();
// Video refs need to work with bind:this - not reactive
let localVideoRef: HTMLVideoElement | undefined = $state();
let remoteVideoRef: HTMLVideoElement | undefined = $state();
let callDuration = $state(0);
let durationInterval: ReturnType<typeof setInterval> | null = null;
// Start duration timer when call connects
$effect(() => {
if (call.state === 'connected' && !durationInterval) {
durationInterval = setInterval(() => {
callDuration++;
}, 1000);
}
});
// Attach local stream to video element
$effect(() => {
if (localVideoRef && call.localStream) {
localVideoRef.srcObject = call.localStream;
}
});
// Attach remote stream to video element
$effect(() => {
if (remoteVideoRef && call.remoteStream) {
remoteVideoRef.srcObject = call.remoteStream;
}
});
onDestroy(() => {
if (durationInterval) {
clearInterval(durationInterval);
}
});
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 handleMicToggle() {
matrixStore.toggleMicMute();
}
function handleCameraToggle() {
matrixStore.toggleCameraMute();
}
function handleHangup() {
matrixStore.hangupCall();
onHangup?.();
}
function getStateText(state: string): string {
switch (state) {
case 'invite_sent':
return 'Anrufen...';
case 'ringing':
return 'Klingelt...';
case 'connecting':
return 'Verbinden...';
case 'connected':
return formatDuration(callDuration);
case 'ended':
return 'Beendet';
default:
return 'Verbinden...';
}
}
</script>
<div class="fixed inset-0 z-[100] bg-zinc-900 flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between p-4 bg-black/30">
<div class="flex items-center gap-3">
{#if call.opponentAvatar}
<img
src={call.opponentAvatar}
alt={call.opponentName}
class="w-10 h-10 rounded-full object-cover"
/>
{:else}
<div
class="w-10 h-10 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center"
>
<User class="w-5 h-5 text-white" />
</div>
{/if}
<div>
<p class="font-medium text-white">{call.opponentName || 'Unbekannt'}</p>
<p class="text-sm text-white/70">
{call.type === 'video' ? 'Videoanruf' : 'Sprachanruf'} · {getStateText(call.state)}
</p>
</div>
</div>
</div>
<!-- Video area -->
<div class="flex-1 relative">
{#if call.type === 'video'}
<!-- Remote video (full screen) -->
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={remoteVideoRef} autoplay playsinline class="w-full h-full object-cover"
></video>
<!-- Local video (picture-in-picture) -->
<div
class="absolute bottom-24 right-4 w-32 h-48 rounded-xl overflow-hidden shadow-xl border-2 border-white/20"
>
<video
bind:this={localVideoRef}
autoplay
playsinline
muted
class="w-full h-full object-cover"
></video>
</div>
{:else}
<!-- Voice call - show avatar -->
<div class="flex flex-col items-center justify-center h-full">
{#if call.opponentAvatar}
<img
src={call.opponentAvatar}
alt={call.opponentName}
class="w-32 h-32 rounded-full object-cover mb-6 ring-4 ring-white/20"
/>
{:else}
<div
class="w-32 h-32 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center mb-6 ring-4 ring-white/20"
>
<User class="w-16 h-16 text-white" />
</div>
{/if}
<p class="text-2xl font-semibold text-white">{call.opponentName || 'Unbekannt'}</p>
<p class="text-lg text-white/70 mt-2">{getStateText(call.state)}</p>
</div>
{/if}
</div>
<!-- Controls -->
<div
class="flex items-center justify-center gap-6 p-8 bg-gradient-to-t from-black/50 to-transparent"
>
<!-- Mute mic -->
<button
class="w-14 h-14 rounded-full flex items-center justify-center transition-colors
{call.isMicMuted ? 'bg-red-500 hover:bg-red-600' : 'bg-white/20 hover:bg-white/30'}"
onclick={handleMicToggle}
title={call.isMicMuted ? 'Mikrofon aktivieren' : 'Mikrofon stumm'}
>
{#if call.isMicMuted}
<MicrophoneSlash class="w-6 h-6 text-white" />
{:else}
<Microphone class="w-6 h-6 text-white" />
{/if}
</button>
<!-- Mute camera (video calls only) -->
{#if call.type === 'video'}
<button
class="w-14 h-14 rounded-full flex items-center justify-center transition-colors
{call.isCameraMuted ? 'bg-red-500 hover:bg-red-600' : 'bg-white/20 hover:bg-white/30'}"
onclick={handleCameraToggle}
title={call.isCameraMuted ? 'Kamera aktivieren' : 'Kamera aus'}
>
{#if call.isCameraMuted}
<VideoCameraSlash class="w-6 h-6 text-white" />
{:else}
<VideoCamera class="w-6 h-6 text-white" />
{/if}
</button>
{/if}
<!-- Hang up -->
<button
class="w-16 h-16 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center transition-colors"
onclick={handleHangup}
title="Auflegen"
>
<PhoneDisconnect class="w-7 h-7 text-white" />
</button>
</div>
</div>

View file

@ -0,0 +1,123 @@
<script lang="ts">
import { matrixStore, type SimpleCall } from '$lib/matrix';
import { Phone, PhoneDisconnect, VideoCamera, User } from '@manacore/shared-icons';
interface Props {
call: SimpleCall;
onAnswer?: () => void;
onReject?: () => void;
}
let { call, onAnswer, onReject }: Props = $props();
function handleAnswer() {
matrixStore.answerCall();
onAnswer?.();
}
function handleReject() {
matrixStore.rejectCall();
onReject?.();
}
</script>
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-zinc-900 rounded-3xl p-8 shadow-2xl max-w-sm w-full mx-4 animate-bounce-in">
<!-- Caller info -->
<div class="flex flex-col items-center text-center mb-8">
{#if call.opponentAvatar}
<img
src={call.opponentAvatar}
alt={call.opponentName}
class="w-24 h-24 rounded-full object-cover mb-4 ring-4 ring-violet-500/50 animate-pulse"
/>
{:else}
<div
class="w-24 h-24 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center mb-4 ring-4 ring-violet-500/50 animate-pulse"
>
<User class="w-12 h-12 text-white" />
</div>
{/if}
<p class="text-xl font-semibold text-white">{call.opponentName || 'Unbekannt'}</p>
<p class="text-white/70 mt-1">
{call.type === 'video' ? 'Eingehender Videoanruf' : 'Eingehender Sprachanruf'}
</p>
</div>
<!-- Call type indicator -->
<div class="flex items-center justify-center gap-2 mb-8">
{#if call.type === 'video'}
<VideoCamera class="w-5 h-5 text-violet-400" />
<span class="text-violet-400 text-sm">Video</span>
{:else}
<Phone class="w-5 h-5 text-green-400" />
<span class="text-green-400 text-sm">Audio</span>
{/if}
</div>
<!-- Action buttons -->
<div class="flex items-center justify-center gap-8">
<!-- Reject -->
<button
class="w-16 h-16 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center transition-all hover:scale-110 shadow-lg shadow-red-500/30"
onclick={handleReject}
title="Ablehnen"
>
<PhoneDisconnect class="w-7 h-7 text-white" />
</button>
<!-- Answer -->
<button
class="w-16 h-16 rounded-full bg-green-500 hover:bg-green-600 flex items-center justify-center transition-all hover:scale-110 shadow-lg shadow-green-500/30 animate-ring"
onclick={handleAnswer}
title="Annehmen"
>
<Phone class="w-7 h-7 text-white" />
</button>
</div>
</div>
</div>
<style>
@keyframes bounce-in {
0% {
opacity: 0;
transform: scale(0.8);
}
50% {
transform: scale(1.05);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes ring {
0%,
100% {
transform: scale(1);
}
10%,
30%,
50%,
70%,
90% {
transform: scale(1.1);
}
20%,
40%,
60%,
80% {
transform: scale(0.95);
}
}
.animate-bounce-in {
animation: bounce-in 0.4s ease-out;
}
.animate-ring {
animation: ring 2s ease-in-out infinite;
}
</style>

View file

@ -0,0 +1,2 @@
export { default as CallView } from './CallView.svelte';
export { default as IncomingCallDialog } from './IncomingCallDialog.svelte';

View file

@ -16,9 +16,14 @@
onMenuClick?: () => void;
onInfoClick?: () => void;
onSearchClick?: () => void;
onVoiceCall?: () => void;
onVideoCall?: () => void;
}
let { onMenuClick, onInfoClick, onSearchClick }: Props = $props();
let { onMenuClick, onInfoClick, onSearchClick, onVoiceCall, onVideoCall }: Props = $props();
// Check if calls are possible (DMs only for now)
let canCall = $derived(matrixStore.currentSimpleRoom?.isDirect ?? false);
let room = $derived(matrixStore.currentSimpleRoom);
let cryptoReady = $derived(matrixStore.cryptoReady);
@ -111,18 +116,22 @@
<MagnifyingGlass class="h-5 w-5 text-muted-foreground" />
</button>
<button
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm disabled:opacity-40"
title="Sprachanruf"
disabled
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm transition-colors
{canCall ? 'hover:bg-green-500/10 hover:text-green-500' : 'opacity-40 cursor-not-allowed'}"
title={canCall ? 'Sprachanruf' : 'Anrufe nur in Direktnachrichten verfügbar'}
disabled={!canCall}
onclick={onVoiceCall}
>
<Phone class="h-5 w-5 text-muted-foreground" />
<Phone class="h-5 w-5" />
</button>
<button
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm disabled:opacity-40"
title="Videoanruf"
disabled
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm transition-colors
{canCall ? 'hover:bg-violet-500/10 hover:text-violet-500' : 'opacity-40 cursor-not-allowed'}"
title={canCall ? 'Videoanruf' : 'Anrufe nur in Direktnachrichten verfügbar'}
disabled={!canCall}
onclick={onVideoCall}
>
<VideoCamera class="h-5 w-5 text-muted-foreground" />
<VideoCamera class="h-5 w-5" />
</button>
<button
class="p-2.5 rounded-xl glass-button shadow-sm"

View file

@ -1,6 +1,10 @@
import { browser } from '$app/environment';
import type { MatrixClient, Room, MatrixEvent, RoomMember as SDKRoomMember } from 'matrix-js-sdk';
import { showMessageNotification, canShowNotifications, isDocumentFocused } from '$lib/notifications';
import {
showMessageNotification,
canShowNotifications,
isDocumentFocused,
} from '$lib/notifications';
import type {
SyncState,
MatrixCredentials,
@ -17,6 +21,11 @@ import type {
CrossSigningStatus,
PresenceState,
UserPresence,
SimpleCall,
CallCallbacks,
CallState as CallStateType,
CallType,
CallDirection,
} from './types';
const STORAGE_KEY = 'matrix_credentials';
@ -46,6 +55,13 @@ class MatrixStore {
private _crossSigningReady = $state(false);
private _cryptoCallbacks: CryptoCallbacks = {};
// VoIP / Call State
private _activeCall = $state<SimpleCall | null>(null);
private _incomingCall = $state<SimpleCall | null>(null);
private _callCallbacks: CallCallbacks = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private _matrixCall: any = null; // The actual MatrixCall object
// ─────────────────────────────────────────────────────────
// Public Getters
// ─────────────────────────────────────────────────────────
@ -82,6 +98,20 @@ class MatrixStore {
return this._crossSigningReady;
}
// VoIP Getters
get activeCall() {
return this._activeCall;
}
get incomingCall() {
return this._incomingCall;
}
get hasActiveCall() {
return this._activeCall !== null;
}
get hasIncomingCall() {
return this._incomingCall !== null;
}
/**
* Get presence for a specific user
*/
@ -331,6 +361,19 @@ class MatrixStore {
this._timeline = [...(room.getLiveTimeline().getEvents() || [])];
}
});
// Incoming calls
// CallEvent is exported from matrix-js-sdk, but CallState needs dynamic import from webrtc module
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this._client.on('Call.incoming' as any, async (call: any) => {
console.log('Incoming call:', call.callId);
try {
const webrtc = await import('matrix-js-sdk/lib/webrtc/call');
this.handleIncomingCall(call, webrtc.CallEvent, webrtc.CallState);
} catch (err) {
console.error('Error handling incoming call:', err);
}
});
}
/**
@ -1270,6 +1313,283 @@ class MatrixStore {
}
}
// ─────────────────────────────────────────────────────────
// VoIP / Call Actions
// ─────────────────────────────────────────────────────────
/**
* Set call callbacks for UI notifications
*/
setCallCallbacks(callbacks: CallCallbacks) {
this._callCallbacks = callbacks;
}
/**
* Place a voice call to the current room
*/
async placeVoiceCall(roomId?: string): Promise<boolean> {
const targetRoomId = roomId || this._currentRoomId;
if (!this._client || !targetRoomId) return false;
try {
// Import WebRTC types from the submodule
const webrtc = await import('matrix-js-sdk/lib/webrtc/call');
const { CallEvent, CallState } = webrtc;
// Create the call
const call = this._client.createCall(targetRoomId);
if (!call) {
console.error('Failed to create call');
return false;
}
this._matrixCall = call;
// Set up event handlers
this.setupCallEventHandlers(call, CallEvent, CallState);
// Place the voice call
await call.placeVoiceCall();
// Update active call state
this._activeCall = this.matrixCallToSimpleCall(call, 'voice', 'outbound');
return true;
} catch (err) {
console.error('Error placing voice call:', err);
this._error = 'Failed to start voice call';
return false;
}
}
/**
* Place a video call to the current room
*/
async placeVideoCall(roomId?: string): Promise<boolean> {
const targetRoomId = roomId || this._currentRoomId;
if (!this._client || !targetRoomId) return false;
try {
// Import WebRTC types from the submodule
const webrtc = await import('matrix-js-sdk/lib/webrtc/call');
const { CallEvent, CallState } = webrtc;
// Create the call
const call = this._client.createCall(targetRoomId);
if (!call) {
console.error('Failed to create call');
return false;
}
this._matrixCall = call;
// Set up event handlers
this.setupCallEventHandlers(call, CallEvent, CallState);
// Place the video call
await call.placeVideoCall();
// Update active call state
this._activeCall = this.matrixCallToSimpleCall(call, 'video', 'outbound');
return true;
} catch (err) {
console.error('Error placing video call:', err);
this._error = 'Failed to start video call';
return false;
}
}
/**
* Answer an incoming call
*/
async answerCall(): Promise<boolean> {
if (!this._matrixCall || !this._incomingCall) return false;
try {
await this._matrixCall.answer();
this._activeCall = { ...this._incomingCall };
this._incomingCall = null;
return true;
} catch (err) {
console.error('Error answering call:', err);
return false;
}
}
/**
* Reject an incoming call
*/
rejectCall(): boolean {
if (!this._matrixCall || !this._incomingCall) return false;
try {
this._matrixCall.reject();
this._incomingCall = null;
this._matrixCall = null;
return true;
} catch (err) {
console.error('Error rejecting call:', err);
return false;
}
}
/**
* Hang up the current call
*/
hangupCall(): boolean {
if (!this._matrixCall) return false;
try {
this._matrixCall.hangup('user_hangup', false);
this._activeCall = null;
this._matrixCall = null;
return true;
} catch (err) {
console.error('Error hanging up call:', err);
return false;
}
}
/**
* Toggle microphone mute
*/
toggleMicMute(): boolean {
if (!this._matrixCall || !this._activeCall) return false;
try {
const muted = this._matrixCall.isMicrophoneMuted();
this._matrixCall.setMicrophoneMuted(!muted);
this._activeCall = { ...this._activeCall, isMicMuted: !muted };
return true;
} catch (err) {
console.error('Error toggling mic mute:', err);
return false;
}
}
/**
* Toggle camera mute (for video calls)
*/
toggleCameraMute(): boolean {
if (!this._matrixCall || !this._activeCall) return false;
try {
const muted = this._matrixCall.isLocalVideoMuted();
this._matrixCall.setLocalVideoMuted(!muted);
this._activeCall = { ...this._activeCall, isCameraMuted: !muted };
return true;
} catch (err) {
console.error('Error toggling camera mute:', err);
return false;
}
}
/**
* Set up call event handlers
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private setupCallEventHandlers(call: any, CallEvent: any, CallState: any) {
// State changes
call.on(CallEvent.State, (state: string, oldState: string) => {
console.log(`Call state: ${oldState} -> ${state}`);
if (this._activeCall) {
this._activeCall = {
...this._activeCall,
state: state as CallStateType,
};
this._callCallbacks.onCallStateChange?.(this._activeCall);
}
// Handle call ending
if (state === CallState.Ended) {
const reason = call.hangupReason;
const endedCall = this._activeCall;
this._activeCall = null;
this._matrixCall = null;
if (endedCall) {
this._callCallbacks.onCallEnded?.(endedCall, reason);
}
}
});
// Feeds changed (audio/video streams)
call.on(CallEvent.FeedsChanged, (feeds: any[]) => {
if (this._activeCall) {
const localFeed = feeds.find((f) => f.isLocal());
const remoteFeed = feeds.find((f) => !f.isLocal());
this._activeCall = {
...this._activeCall,
localStream: localFeed?.stream,
remoteStream: remoteFeed?.stream,
};
}
});
// Error handling
call.on(CallEvent.Error, (error: any) => {
console.error('Call error:', error);
this._error = `Call error: ${error.message || 'Unknown error'}`;
});
// Hangup
call.on(CallEvent.Hangup, () => {
const endedCall = this._activeCall;
this._activeCall = null;
this._matrixCall = null;
if (endedCall) {
this._callCallbacks.onCallEnded?.(endedCall, call.hangupReason);
}
});
}
/**
* Handle incoming call from the SDK
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private handleIncomingCall(call: any, CallEvent: any, CallState: any) {
this._matrixCall = call;
// Determine call type from the call object
const callType: CallType = call.type === 'video' ? 'video' : 'voice';
// Create simple call representation
const simpleCall = this.matrixCallToSimpleCall(call, callType, 'inbound');
this._incomingCall = simpleCall;
// Set up event handlers
this.setupCallEventHandlers(call, CallEvent, CallState);
// Notify UI
this._callCallbacks.onIncomingCall?.(simpleCall);
}
/**
* Convert MatrixCall to SimpleCall
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private matrixCallToSimpleCall(call: any, type: CallType, direction: CallDirection): SimpleCall {
const opponent = call.getOpponentMember?.();
const room = this._client?.getRoom(call.roomId);
return {
callId: call.callId,
roomId: call.roomId,
state: (call.state || 'fledgling') as CallStateType,
type,
direction,
opponentUserId: opponent?.userId,
opponentName: opponent?.name || room?.name || 'Unbekannt',
opponentAvatar: opponent?.getAvatarUrl?.(this._client?.baseUrl || '', 48, 48, 'scale'),
isMicMuted: call.isMicrophoneMuted?.() || false,
isCameraMuted: call.isLocalVideoMuted?.() || false,
isScreenSharing: false,
isRemoteOnHold: call.isRemoteOnHold?.() || false,
};
}
// ─────────────────────────────────────────────────────────
// Cleanup
// ─────────────────────────────────────────────────────────
@ -1278,6 +1598,15 @@ class MatrixStore {
* Stop the client and clean up
*/
destroy() {
// Hang up any active call
if (this._matrixCall) {
try {
this._matrixCall.hangup('user_hangup', false);
} catch {
// Ignore errors during cleanup
}
}
this._client?.stopClient();
this._client = null;
this._syncState = 'STOPPED';
@ -1293,6 +1622,11 @@ class MatrixStore {
this._keyBackupEnabled = false;
this._crossSigningReady = false;
this._cryptoCallbacks = {};
// Reset call state
this._activeCall = null;
this._incomingCall = null;
this._matrixCall = null;
this._callCallbacks = {};
}
/**

View file

@ -247,3 +247,62 @@ export interface CrossSigningStatus {
privateKeysInSecretStorage: boolean;
privateKeysCachedLocally: boolean;
}
// ─────────────────────────────────────────────────────────
// VoIP / Call Types
// ─────────────────────────────────────────────────────────
/**
* Call state
*/
export type CallState =
| 'fledgling'
| 'invite_sent'
| 'wait_local_media'
| 'create_offer'
| 'create_answer'
| 'connecting'
| 'connected'
| 'ringing'
| 'ended';
/**
* Call type (voice or video)
*/
export type CallType = 'voice' | 'video';
/**
* Call direction
*/
export type CallDirection = 'inbound' | 'outbound';
/**
* Simplified call info for UI
*/
export interface SimpleCall {
callId: string;
roomId: string;
state: CallState;
type: CallType;
direction: CallDirection;
opponentUserId?: string;
opponentName?: string;
opponentAvatar?: string;
startTime?: number;
duration?: number;
isMicMuted: boolean;
isCameraMuted: boolean;
isScreenSharing: boolean;
isRemoteOnHold: boolean;
localStream?: MediaStream;
remoteStream?: MediaStream;
}
/**
* Call event callbacks for UI handling
*/
export interface CallCallbacks {
onIncomingCall?: (call: SimpleCall) => void;
onCallStateChange?: (call: SimpleCall) => void;
onCallEnded?: (call: SimpleCall, reason?: string) => void;
}

View file

@ -4,9 +4,14 @@
import CreateRoomDialog from '$lib/components/chat/CreateRoomDialog.svelte';
import RoomSettingsPanel from '$lib/components/chat/RoomSettingsPanel.svelte';
import SearchDialog from '$lib/components/chat/SearchDialog.svelte';
import { CallView, IncomingCallDialog } from '$lib/components/call';
import { ChatCircle, Plus, Gear } from '@manacore/shared-icons';
import { browser } from '$app/environment';
// Call state
let activeCall = $derived(matrixStore.activeCall);
let incomingCall = $derived(matrixStore.incomingCall);
// Start with sidebar closed on mobile
let sidebarOpen = $state(browser ? window.innerWidth >= 1024 : true);
let showCreateRoom = $state(false);
@ -69,6 +74,31 @@
function handleRoomCreated(roomId: string) {
matrixStore.selectRoom(roomId);
}
// Call handlers
async function handleVoiceCall() {
if (matrixStore.currentRoom) {
await matrixStore.placeVoiceCall(matrixStore.currentRoom.roomId);
}
}
async function handleVideoCall() {
if (matrixStore.currentRoom) {
await matrixStore.placeVideoCall(matrixStore.currentRoom.roomId);
}
}
function handleCallHangup() {
// Call ended - UI will update automatically
}
function handleCallAnswer() {
// Call answered - UI will update automatically
}
function handleCallReject() {
// Call rejected - UI will update automatically
}
</script>
<div class="chat-layout flex h-full min-h-0 overflow-hidden bg-background relative">
@ -139,6 +169,8 @@
onMenuClick={toggleSidebar}
onInfoClick={() => (showRoomSettings = true)}
onSearchClick={() => (showSearch = true)}
onVoiceCall={handleVoiceCall}
onVideoCall={handleVideoCall}
/>
<!-- Timeline -->
@ -202,3 +234,13 @@
<!-- Search Dialog -->
<SearchDialog open={showSearch} onClose={() => (showSearch = false)} />
<!-- Active Call View -->
{#if activeCall}
<CallView call={activeCall} onHangup={handleCallHangup} />
{/if}
<!-- Incoming Call Dialog -->
{#if incomingCall && !activeCall}
<IncomingCallDialog call={incomingCall} onAnswer={handleCallAnswer} onReject={handleCallReject} />
{/if}