mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Clean up
This commit is contained in:
@@ -34,7 +34,7 @@ export interface WorkerOptions {
|
|||||||
* For RTCEncodedVideoFrame/AudioFrame, we use the frame's metadata if available,
|
* For RTCEncodedVideoFrame/AudioFrame, we use the frame's metadata if available,
|
||||||
* otherwise fall back to extracting from RTP header
|
* otherwise fall back to extracting from RTP header
|
||||||
*/
|
*/
|
||||||
function makeIV(encodedFrame: EncodedFrame, frameCount: number, mode: 'encrypt' | 'decrypt'): ArrayBuffer {
|
function makeIV(encodedFrame: EncodedFrame): ArrayBuffer {
|
||||||
// Create IV using ONLY RTP metadata - this ensures sender and receiver use identical IVs
|
// Create IV using ONLY RTP metadata - this ensures sender and receiver use identical IVs
|
||||||
// Frame data can differ between sender/receiver due to encoding differences
|
// Frame data can differ between sender/receiver due to encoding differences
|
||||||
const ivBuffer = new ArrayBuffer(12);
|
const ivBuffer = new ArrayBuffer(12);
|
||||||
@@ -49,14 +49,6 @@ function makeIV(encodedFrame: EncodedFrame, frameCount: number, mode: 'encrypt'
|
|||||||
view.setUint32(4, metadata.synchronizationSource || 0, false); // Middle 4 bytes
|
view.setUint32(4, metadata.synchronizationSource || 0, false); // Middle 4 bytes
|
||||||
view.setUint32(8, 0, false); // Last 4 bytes (padding for 12-byte IV)
|
view.setUint32(8, 0, false); // Last 4 bytes (padding for 12-byte IV)
|
||||||
|
|
||||||
// Debug first few frames only
|
|
||||||
if (frameCount <= 3) {
|
|
||||||
console.log(`${mode.toUpperCase()} IV for frame #${frameCount}:`, {
|
|
||||||
rtpTimestamp: metadata.rtpTimestamp,
|
|
||||||
syncSource: metadata.synchronizationSource,
|
|
||||||
mimeType: metadata.mimeType
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return ivBuffer;
|
return ivBuffer;
|
||||||
}
|
}
|
||||||
@@ -82,8 +74,6 @@ addEventListener("rtctransform", (event) => {
|
|||||||
console.log(`E2EE Worker started in ${mode.toUpperCase()} mode`);
|
console.log(`E2EE Worker started in ${mode.toUpperCase()} mode`);
|
||||||
|
|
||||||
let frameCount = 0;
|
let frameCount = 0;
|
||||||
let lastLogTime = 0;
|
|
||||||
let lastKeyCheck = Date.now();
|
|
||||||
|
|
||||||
async function transform(encodedFrame: EncodedFrame, controller: TransformStreamDefaultController<EncodedFrame>) {
|
async function transform(encodedFrame: EncodedFrame, controller: TransformStreamDefaultController<EncodedFrame>) {
|
||||||
try {
|
try {
|
||||||
@@ -92,35 +82,8 @@ addEventListener("rtctransform", (event) => {
|
|||||||
// Increment frame counter
|
// Increment frame counter
|
||||||
frameCount++;
|
frameCount++;
|
||||||
|
|
||||||
// Log every frame for debugging (only first 3)
|
|
||||||
if (frameCount <= 3) {
|
|
||||||
console.log(`${mode.toUpperCase()} Processing frame #${frameCount}, size: ${data.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create IV using RTP timestamp from metadata (synchronized between peers)
|
// Create IV using RTP timestamp from metadata (synchronized between peers)
|
||||||
const iv = makeIV(encodedFrame, frameCount, mode);
|
const iv = makeIV(encodedFrame);
|
||||||
|
|
||||||
// Log first few frames and periodically for debugging
|
|
||||||
const now = Date.now();
|
|
||||||
if (frameCount <= 5 || now - lastLogTime > 5000) {
|
|
||||||
console.log(`E2EE ${mode} frame #${frameCount}, size: ${data.length} bytes`);
|
|
||||||
lastLogTime = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For screen share, check if we need to request key rotation more frequently
|
|
||||||
// Screen share generates much more data and can benefit from more frequent key rotation
|
|
||||||
if (data.length > 50000 && now - lastKeyCheck > 60000) { // 1 minute for large frames
|
|
||||||
console.log("Large frame detected, suggesting key rotation for screen share");
|
|
||||||
lastKeyCheck = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect potential browser window glitching - frames with specific characteristics
|
|
||||||
if (data.length > 100000 && frameCount > 10) { // Large frames after initial setup
|
|
||||||
const frameType = encodedFrame.type || 'unknown';
|
|
||||||
if (frameType === 'key' && data.length > 200000) {
|
|
||||||
console.log("Large keyframe detected - possible browser window glitch, frame size:", data.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure IV is properly typed
|
// Ensure IV is properly typed
|
||||||
const ivArray = new Uint8Array(iv);
|
const ivArray = new Uint8Array(iv);
|
||||||
@@ -168,16 +131,6 @@ addEventListener("rtctransform", (event) => {
|
|||||||
result.set(encryptedArray, 0);
|
result.set(encryptedArray, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log first few frames for debugging
|
|
||||||
if (frameCount <= 3) {
|
|
||||||
console.log(`E2EE ${mode} frame #${frameCount}: ${data.length} -> ${result.length} bytes (header: ${headerSize})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For video frames, check if we need to force keyframes more frequently
|
|
||||||
// This helps prevent "stuck at first frame" issues with encrypted video
|
|
||||||
if (data.length > 10000 && frameCount > 0 && frameCount % 30 === 0) {
|
|
||||||
console.log(`Large video frame #${frameCount} - suggesting keyframe for stability`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// CRITICAL: Video frames need ArrayBuffer, not Uint8Array
|
// CRITICAL: Video frames need ArrayBuffer, not Uint8Array
|
||||||
encodedFrame.data = result.buffer;
|
encodedFrame.data = result.buffer;
|
||||||
@@ -187,16 +140,7 @@ addEventListener("rtctransform", (event) => {
|
|||||||
// FAIL SECURELY: Never send unencrypted frames
|
// FAIL SECURELY: Never send unencrypted frames
|
||||||
const data = new Uint8Array(encodedFrame.data);
|
const data = new Uint8Array(encodedFrame.data);
|
||||||
console.error(`E2EE ${mode} FAILED - dropping frame #${frameCount}, size: ${data.length}`, e);
|
console.error(`E2EE ${mode} FAILED - dropping frame #${frameCount}, size: ${data.length}`, e);
|
||||||
console.error('Frame type:', encodedFrame.type || 'unknown');
|
return; // Drop the frame completely
|
||||||
|
|
||||||
// For screen share, be more aggressive about dropping corrupted frames
|
|
||||||
// to prevent progressive glitch accumulation
|
|
||||||
if (mode === 'decrypt' && frameCount > 10) {
|
|
||||||
console.warn(`Dropping corrupted frame #${frameCount} to prevent glitch accumulation`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop the frame completely - don't enqueue anything
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ async function getIceServers(): Promise<RTCIceServer[]> {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json() as IceServersResponse;
|
const data = await response.json() as IceServersResponse;
|
||||||
console.log("Received ICE servers:", data.iceServers);
|
|
||||||
return data.iceServers || [];
|
return data.iceServers || [];
|
||||||
} else {
|
} else {
|
||||||
console.warn("Failed to fetch ICE servers:", response.status, response.statusText);
|
console.warn("Failed to fetch ICE servers:", response.status, response.statusText);
|
||||||
@@ -162,8 +161,6 @@ async function createPeerConnection(userId: number): Promise<RTCPeerConnection>
|
|||||||
// Add ICE candidate event listener for debugging and sending
|
// Add ICE candidate event listener for debugging and sending
|
||||||
peerConnection.addEventListener("icecandidate", async (event) => {
|
peerConnection.addEventListener("icecandidate", async (event) => {
|
||||||
if (event.candidate) {
|
if (event.candidate) {
|
||||||
console.log("Local ICE candidate:", event.candidate.candidate);
|
|
||||||
|
|
||||||
// Send ICE candidate to remote peer
|
// Send ICE candidate to remote peer
|
||||||
try {
|
try {
|
||||||
await sendSignalingMessage({
|
await sendSignalingMessage({
|
||||||
@@ -179,8 +176,6 @@ async function createPeerConnection(userId: number): Promise<RTCPeerConnection>
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send ICE candidate:", error);
|
console.error("Failed to send ICE candidate:", error);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log("ICE gathering complete");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,27 +190,22 @@ async function createPeerConnection(userId: number): Promise<RTCPeerConnection>
|
|||||||
// Handle renegotiation when tracks are added/removed
|
// Handle renegotiation when tracks are added/removed
|
||||||
peerConnection.addEventListener("negotiationneeded", async () => {
|
peerConnection.addEventListener("negotiationneeded", async () => {
|
||||||
try {
|
try {
|
||||||
console.log("Negotiation needed for user", userId);
|
|
||||||
const call = calls.get(userId);
|
const call = calls.get(userId);
|
||||||
if (!call) {
|
if (!call) {
|
||||||
console.log("Skipping renegotiation - call not found");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent multiple simultaneous negotiations
|
// Prevent multiple simultaneous negotiations
|
||||||
if (call.isNegotiating) {
|
if (call.isNegotiating) {
|
||||||
console.log("Already negotiating, skipping");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if we're in "stable" state and haven't finished the initial handshake
|
// Skip if we're in "stable" state and haven't finished the initial handshake
|
||||||
if (peerConnection.signalingState !== "stable") {
|
if (peerConnection.signalingState !== "stable") {
|
||||||
console.log("Skipping renegotiation - signaling state is", peerConnection.signalingState);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
call.isNegotiating = true;
|
call.isNegotiating = true;
|
||||||
console.log("Creating new offer for renegotiation (signalingState:", peerConnection.signalingState + ")");
|
|
||||||
|
|
||||||
const offer = await peerConnection.createOffer();
|
const offer = await peerConnection.createOffer();
|
||||||
await peerConnection.setLocalDescription(offer);
|
await peerConnection.setLocalDescription(offer);
|
||||||
|
|||||||
@@ -150,14 +150,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.screen-share-tile {
|
.screen-share-tile {
|
||||||
// min-height removed to allow dynamic sizing based on video content
|
.screen-share-video {
|
||||||
|
object-fit: contain;
|
||||||
.screen-share-video {
|
}
|
||||||
object-fit: contain;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.call-controls {
|
.call-controls {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
import { useAppState } from "@/pages/chat/state";
|
|
||||||
import * as WebRTC from "@/core/calls/webrtc";
|
|
||||||
import { CallSignalingHandler } from "@/core/calls/signaling";
|
|
||||||
import { setCallSignalingHandler } from "@/core/websocket";
|
|
||||||
import { generateCallSessionKey, generateCallEmojis } from "@/core/calls/encryption";
|
|
||||||
import { createRef, useEffect } from "react";
|
|
||||||
|
|
||||||
// Global audio ref shared across all instances
|
|
||||||
let globalRemoteAudioRef = createRef<HTMLAudioElement>();
|
|
||||||
|
|
||||||
export default function useAudioCall() {
|
|
||||||
const { chat, startCall, endCall, setCallStatus, toggleMute, setCallEncryption, setCallSessionKeyHash, user } = useAppState();
|
|
||||||
const remoteAudioRef = globalRemoteAudioRef;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user.authToken) {
|
|
||||||
WebRTC.setAuthToken(user.authToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize call signaling handler
|
|
||||||
const signalingHandler = new CallSignalingHandler(() => ({
|
|
||||||
receiveCall: (userId: number, username: string) => {
|
|
||||||
// Use the receiveCall function from state
|
|
||||||
const state = useAppState.getState();
|
|
||||||
state.receiveCall(userId, username);
|
|
||||||
},
|
|
||||||
endCall,
|
|
||||||
setCallSessionKeyHash,
|
|
||||||
setRemoteVideoEnabled: (enabled: boolean) => {
|
|
||||||
const state = useAppState.getState();
|
|
||||||
state.setRemoteVideoEnabled(enabled);
|
|
||||||
},
|
|
||||||
setRemoteScreenSharing: (enabled: boolean) => {
|
|
||||||
const state = useAppState.getState();
|
|
||||||
state.setRemoteScreenSharing(enabled);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
setCallSignalingHandler(signalingHandler);
|
|
||||||
|
|
||||||
// Set up call state change handler
|
|
||||||
WebRTC.setCallStateChangeHandler((userId: number, state: string) => {
|
|
||||||
const call = chat.call;
|
|
||||||
if (call.remoteUserId === userId) {
|
|
||||||
switch (state) {
|
|
||||||
case "connecting":
|
|
||||||
setCallStatus("connecting");
|
|
||||||
break;
|
|
||||||
case "connected":
|
|
||||||
setCallStatus("active");
|
|
||||||
break;
|
|
||||||
case "disconnected":
|
|
||||||
case "failed":
|
|
||||||
case "closed":
|
|
||||||
endCall();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up remote stream handler
|
|
||||||
WebRTC.setRemoteStreamHandler((_userId: number, stream: MediaStream) => {
|
|
||||||
if (!remoteAudioRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = remoteAudioRef.current;
|
|
||||||
try {
|
|
||||||
el.srcObject = stream;
|
|
||||||
el.muted = false;
|
|
||||||
el.volume = 1.0;
|
|
||||||
el.autoplay = true;
|
|
||||||
|
|
||||||
// Handle audio events
|
|
||||||
el.addEventListener("error", () => {
|
|
||||||
console.warn("[AUDIO] element error", (el.error?.message) || el.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
el.play().catch(() => {
|
|
||||||
// Try to play after user interaction if autoplay is blocked
|
|
||||||
const playAfterInteraction = () => {
|
|
||||||
el.play().catch(() => {});
|
|
||||||
document.removeEventListener('click', playAfterInteraction);
|
|
||||||
document.removeEventListener('touchstart', playAfterInteraction);
|
|
||||||
};
|
|
||||||
document.addEventListener('click', playAfterInteraction);
|
|
||||||
document.addEventListener('touchstart', playAfterInteraction);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("failed to attach remote stream:", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
WebRTC.cleanup();
|
|
||||||
setCallSignalingHandler(null);
|
|
||||||
};
|
|
||||||
}, [user.authToken, chat.call.remoteUserId, setCallStatus, endCall, startCall]);
|
|
||||||
|
|
||||||
// Watch for session key hash changes and generate emojis
|
|
||||||
useEffect(() => {
|
|
||||||
if (chat.call.sessionKeyHash && chat.call.encryptionEmojis.length === 0) {
|
|
||||||
const emojis = generateCallEmojis(chat.call.sessionKeyHash);
|
|
||||||
setCallEncryption(chat.call.sessionKeyHash, emojis);
|
|
||||||
}
|
|
||||||
}, [chat.call.sessionKeyHash, chat.call.encryptionEmojis.length, setCallEncryption]);
|
|
||||||
|
|
||||||
async function requestAudioPermissions(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: true,
|
|
||||||
video: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stop the stream immediately as we just needed permission
|
|
||||||
stream.getTracks().forEach(track => track.stop());
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to get audio permissions:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function initiateCall(userId: number, username: string) {
|
|
||||||
const hasPermission = await requestAudioPermissions();
|
|
||||||
|
|
||||||
if (!hasPermission) {
|
|
||||||
console.log("Audio permission denied");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let sessionKey;
|
|
||||||
try {
|
|
||||||
// Generate call session key and emojis
|
|
||||||
sessionKey = await generateCallSessionKey();
|
|
||||||
const emojis = generateCallEmojis(sessionKey.hash);
|
|
||||||
|
|
||||||
// Start the call in state
|
|
||||||
startCall(userId, username);
|
|
||||||
setCallStatus("calling");
|
|
||||||
setCallEncryption(sessionKey.hash, emojis);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to generate call encryption:", error);
|
|
||||||
endCall();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initiate WebRTC call
|
|
||||||
const success = await WebRTC.initiateCall(userId, username);
|
|
||||||
|
|
||||||
if (success && sessionKey) {
|
|
||||||
// Set the session key for ourselves (initiator)
|
|
||||||
await WebRTC.setSessionKey(userId, sessionKey.key);
|
|
||||||
|
|
||||||
// Send session key hash to the receiver for visual verification
|
|
||||||
await WebRTC.sendCallSessionKey(userId, sessionKey.hash);
|
|
||||||
// Also wrap and send the actual session key for E2EE media
|
|
||||||
await WebRTC.sendWrappedCallSessionKey(userId, sessionKey.key, sessionKey.hash);
|
|
||||||
} else {
|
|
||||||
endCall();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function acceptCall() {
|
|
||||||
if (!chat.call.remoteUserId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCallStatus("connecting");
|
|
||||||
const success = await WebRTC.acceptCall(chat.call.remoteUserId);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
endCall();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function rejectCall() {
|
|
||||||
if (!chat.call.remoteUserId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await WebRTC.rejectCall(chat.call.remoteUserId);
|
|
||||||
endCall();
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handleEndCall() {
|
|
||||||
if (chat.call.remoteUserId) {
|
|
||||||
await WebRTC.endCall(chat.call.remoteUserId);
|
|
||||||
}
|
|
||||||
endCall();
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleToggleMute() {
|
|
||||||
if (chat.call.remoteUserId) {
|
|
||||||
const isMuted = WebRTC.toggleMute(chat.call.remoteUserId);
|
|
||||||
// Update mute state in store
|
|
||||||
if (isMuted !== chat.call.isMuted) {
|
|
||||||
toggleMute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handleIncomingCall(userId: number, username: string) {
|
|
||||||
// Don't generate session key here - wait for it from the caller
|
|
||||||
await WebRTC.handleIncomingCall(userId, username);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handleCallOffer(userId: number, offer: RTCSessionDescriptionInit) {
|
|
||||||
await WebRTC.handleCallOffer(userId, offer);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handleCallAnswer(userId: number, answer: RTCSessionDescriptionInit) {
|
|
||||||
await WebRTC.handleCallAnswer(userId, answer);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handleIceCandidate(userId: number, candidate: RTCIceCandidateInit) {
|
|
||||||
await WebRTC.handleIceCandidate(userId, candidate);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handleCallSessionKey(sessionKeyHash: string) {
|
|
||||||
try {
|
|
||||||
// Just generate and display the emojis from the hash
|
|
||||||
// The actual session key will arrive via the wrapped key mechanism
|
|
||||||
const emojis = generateCallEmojis(sessionKeyHash);
|
|
||||||
setCallEncryption(sessionKeyHash, emojis);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to generate call emojis from hash:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
call: chat.call,
|
|
||||||
initiateCall,
|
|
||||||
acceptCall,
|
|
||||||
rejectCall,
|
|
||||||
endCall: handleEndCall,
|
|
||||||
toggleMute: handleToggleMute,
|
|
||||||
handleIncomingCall,
|
|
||||||
handleCallOffer,
|
|
||||||
handleCallAnswer,
|
|
||||||
handleIceCandidate,
|
|
||||||
handleCallSessionKey,
|
|
||||||
remoteAudioRef
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ import type { Message, WebSocketMessage } from "@/core/types";
|
|||||||
import defaultAvatar from "@/images/default-avatar.png";
|
import defaultAvatar from "@/images/default-avatar.png";
|
||||||
import AnimatedOpacity from "@/core/components/animations/AnimatedOpacity";
|
import AnimatedOpacity from "@/core/components/animations/AnimatedOpacity";
|
||||||
import type { DMPanel } from "./panels/DMPanel";
|
import type { DMPanel } from "./panels/DMPanel";
|
||||||
import useAudioCall from "@/pages/chat/hooks/useAudioCall";
|
import useCall from "@/pages/chat/hooks/useCall";
|
||||||
|
|
||||||
interface MessagePanelRendererProps {
|
interface MessagePanelRendererProps {
|
||||||
panel: MessagePanel | null;
|
panel: MessagePanel | null;
|
||||||
@@ -27,7 +27,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
|||||||
const [editMessage, setEditMessage] = useState<Message | null>(null);
|
const [editMessage, setEditMessage] = useState<Message | null>(null);
|
||||||
const [editVisible, setEditVisible] = useState(Boolean(editMessage));
|
const [editVisible, setEditVisible] = useState(Boolean(editMessage));
|
||||||
const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null);
|
const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null);
|
||||||
const { initiateCall } = useAudioCall();
|
const { initiateCall } = useCall();
|
||||||
|
|
||||||
|
|
||||||
// Drag & drop
|
// Drag & drop
|
||||||
|
|||||||
Reference in New Issue
Block a user