mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 03:25:07 +03:00
Clean up
This commit is contained in:
@@ -35,8 +35,8 @@ async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
|
||||
}
|
||||
|
||||
async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
||||
const payload: UploadPublicKeyRequest = {
|
||||
publicKey: b64(publicKey)
|
||||
const payload: UploadPublicKeyRequest = {
|
||||
publicKey: b64(publicKey)
|
||||
}
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
@@ -49,9 +49,9 @@ async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<vo
|
||||
|
||||
async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "GET",
|
||||
headers
|
||||
headers
|
||||
});
|
||||
if (res.ok) {
|
||||
const response: BackupBlob = await res.json();
|
||||
@@ -83,7 +83,7 @@ export function getCurrentKeys(): UserKeyPairMemory | null {
|
||||
}
|
||||
|
||||
function saveKeys(
|
||||
publicKey: Uint8Array<ArrayBufferLike>,
|
||||
publicKey: Uint8Array<ArrayBufferLike>,
|
||||
privateKey: Uint8Array<ArrayBufferLike>
|
||||
) {
|
||||
const encodedPublicKey = b64(publicKey);
|
||||
@@ -117,9 +117,9 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis
|
||||
|
||||
saveKeys(currentPublicKey!, currentPrivateKey!);
|
||||
|
||||
return {
|
||||
publicKey: currentPublicKey!,
|
||||
privateKey: currentPrivateKey!
|
||||
return {
|
||||
publicKey: currentPublicKey!,
|
||||
privateKey: currentPrivateKey!
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ export async function fetchUserPublicKey(userId: number, token: string): Promise
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
@@ -73,8 +73,8 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey
|
||||
|
||||
await request({
|
||||
type: "dmSend",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
data: payload
|
||||
@@ -177,8 +177,8 @@ export interface DMConversationResponse {
|
||||
}
|
||||
|
||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
@@ -187,9 +187,9 @@ export async function fetchDMConversations(token: string): Promise<DMConversatio
|
||||
|
||||
export async function searchUsers(query: string, token: string): Promise<User[]> {
|
||||
if (query.length < 2) return [];
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function loadProfile(token: string): Promise<ProfileData | null> {
|
||||
description: data.bio
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
@@ -119,7 +119,7 @@ export async function fetchUserProfile(token: string, username: string): Promise
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
|
||||
@@ -39,7 +39,7 @@ function makeIV(encodedFrame: EncodedFrame): ArrayBuffer {
|
||||
// Frame data can differ between sender/receiver due to encoding differences
|
||||
const ivBuffer = new ArrayBuffer(12);
|
||||
const view = new DataView(ivBuffer);
|
||||
|
||||
|
||||
if (encodedFrame.getMetadata) {
|
||||
try {
|
||||
const metadata = encodedFrame.getMetadata();
|
||||
@@ -48,14 +48,14 @@ function makeIV(encodedFrame: EncodedFrame): ArrayBuffer {
|
||||
view.setUint32(0, metadata.rtpTimestamp, false); // First 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)
|
||||
|
||||
|
||||
return ivBuffer;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to get metadata:", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Fallback: use timestamp only (no random to avoid desync)
|
||||
view.setUint32(0, Date.now() & 0xFFFFFFFF, false);
|
||||
view.setUint32(4, 0, false);
|
||||
@@ -67,30 +67,30 @@ addEventListener("rtctransform", (event) => {
|
||||
const { transformer } = event;
|
||||
const { readable, writable } = transformer;
|
||||
const { key, mode } = transformer.options as WorkerOptions;
|
||||
|
||||
|
||||
const isEncrypting = mode === 'encrypt';
|
||||
|
||||
|
||||
let frameCount = 0;
|
||||
|
||||
|
||||
async function transform(encodedFrame: EncodedFrame, controller: TransformStreamDefaultController<EncodedFrame>) {
|
||||
try {
|
||||
const data = new Uint8Array(encodedFrame.data);
|
||||
|
||||
|
||||
// Increment frame counter
|
||||
frameCount++;
|
||||
|
||||
|
||||
// Create IV using RTP timestamp from metadata (synchronized between peers)
|
||||
const iv = makeIV(encodedFrame);
|
||||
|
||||
|
||||
// Ensure IV is properly typed
|
||||
const ivArray = new Uint8Array(iv);
|
||||
const params: AesGcmParams = { name: 'AES-GCM', iv: ivArray };
|
||||
|
||||
|
||||
// COMPROMISE: Encrypt most of the frame while preserving minimal codec compatibility
|
||||
// This prevents most visual leakage while maintaining decodability
|
||||
let headerSize = 0;
|
||||
let payloadData: Uint8Array;
|
||||
|
||||
|
||||
if (data.length > 20) {
|
||||
// For video frames, preserve first 8 bytes for better codec compatibility
|
||||
// This includes frame type, keyframe info, and basic header structure
|
||||
@@ -100,11 +100,11 @@ addEventListener("rtctransform", (event) => {
|
||||
// For small frames (likely audio), encrypt everything
|
||||
payloadData = data;
|
||||
}
|
||||
|
||||
|
||||
// Encrypt the payload data
|
||||
const payloadBuffer = new ArrayBuffer(payloadData.byteLength);
|
||||
new Uint8Array(payloadBuffer).set(payloadData);
|
||||
|
||||
|
||||
let encryptedPayload: ArrayBuffer;
|
||||
if (isEncrypting) {
|
||||
encryptedPayload = await crypto.subtle.encrypt(params, key, payloadBuffer);
|
||||
@@ -116,18 +116,18 @@ addEventListener("rtctransform", (event) => {
|
||||
return; // Drop the frame
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Reconstruct frame: minimal headers + encrypted payload
|
||||
const encryptedArray = new Uint8Array(encryptedPayload);
|
||||
const result = new Uint8Array(headerSize + encryptedArray.length);
|
||||
|
||||
|
||||
if (headerSize > 0) {
|
||||
result.set(data.slice(0, headerSize), 0); // Copy minimal headers
|
||||
result.set(encryptedArray, headerSize); // Add encrypted payload
|
||||
} else {
|
||||
result.set(encryptedArray, 0);
|
||||
}
|
||||
|
||||
|
||||
// CRITICAL: Video frames need ArrayBuffer, not Uint8Array
|
||||
encodedFrame.data = result.buffer;
|
||||
controller.enqueue(encodedFrame);
|
||||
|
||||
@@ -31,11 +31,11 @@ export interface EncryptedCallMessage {
|
||||
export async function generateCallSessionKey(): Promise<CallSessionKey> {
|
||||
// Generate session key material
|
||||
const sessionKeyMaterial = randomBytes(32);
|
||||
|
||||
|
||||
// Generate hash for emoji display (first 4 bytes of SHA-256 hash)
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", sessionKeyMaterial.buffer as ArrayBuffer);
|
||||
const hash = b64(new Uint8Array(hashBuffer.slice(0, 4)));
|
||||
|
||||
|
||||
return {
|
||||
key: sessionKeyMaterial,
|
||||
hash
|
||||
@@ -49,11 +49,11 @@ export async function generateCallSessionKey(): Promise<CallSessionKey> {
|
||||
export async function rotateCallSessionKey(): Promise<CallSessionKey> {
|
||||
// Generate new session key material (completely independent of current key)
|
||||
const newSessionKeyMaterial = randomBytes(32);
|
||||
|
||||
|
||||
// Generate new hash for emoji display
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", newSessionKeyMaterial.buffer as ArrayBuffer);
|
||||
const newHash = b64(new Uint8Array(hashBuffer.slice(0, 4)));
|
||||
|
||||
|
||||
return {
|
||||
key: newSessionKeyMaterial,
|
||||
hash: newHash
|
||||
@@ -68,12 +68,12 @@ export async function createCallSessionKeyFromHash(hash: string): Promise<CallSe
|
||||
// For backward compatibility, generate a deterministic key from the hash
|
||||
const hashBytes = ub64(hash);
|
||||
const sessionKey = new Uint8Array(32);
|
||||
|
||||
|
||||
// Repeat the hash bytes to fill 32 bytes
|
||||
for (let i = 0; i < 32; i++) {
|
||||
sessionKey[i] = hashBytes[i % hashBytes.length];
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
key: sessionKey,
|
||||
hash
|
||||
@@ -85,7 +85,7 @@ export async function createCallSessionKeyFromHash(hash: string): Promise<CallSe
|
||||
* This creates a deterministic but cryptographically secure key
|
||||
*/
|
||||
export async function deriveCallSessionKeyFromSharedSecret(
|
||||
sharedSecret: Uint8Array,
|
||||
sharedSecret: Uint8Array,
|
||||
sessionKeyHash: string,
|
||||
isInitiator: boolean
|
||||
): Promise<CallSessionKey> {
|
||||
@@ -93,7 +93,7 @@ export async function deriveCallSessionKeyFromSharedSecret(
|
||||
// Include the session key hash and role to ensure uniqueness
|
||||
const info = new TextEncoder().encode(`call-session-${sessionKeyHash}-${isInitiator ? 'initiator' : 'receiver'}`);
|
||||
const salt = new Uint8Array(32); // Zero salt for deterministic derivation
|
||||
|
||||
|
||||
// Import the shared secret as a raw key for HKDF
|
||||
const sharedKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -102,7 +102,7 @@ export async function deriveCallSessionKeyFromSharedSecret(
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
|
||||
// Derive the session key using HKDF
|
||||
const sessionKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
@@ -116,10 +116,10 @@ export async function deriveCallSessionKeyFromSharedSecret(
|
||||
true, // Make the key extractable so we can export it
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
|
||||
// Export the raw key material
|
||||
const sessionKeyMaterial = await crypto.subtle.exportKey('raw', sessionKey);
|
||||
|
||||
|
||||
return {
|
||||
key: new Uint8Array(sessionKeyMaterial),
|
||||
hash: sessionKeyHash
|
||||
@@ -132,7 +132,7 @@ export async function deriveCallSessionKeyFromSharedSecret(
|
||||
export async function encryptCallMessage(message: Record<string, unknown>, sessionKey: Uint8Array): Promise<EncryptedCallMessage> {
|
||||
const messageKey = await importAesGcmKey(sessionKey);
|
||||
const encrypted = await aesGcmEncrypt(messageKey, new TextEncoder().encode(JSON.stringify(message)));
|
||||
|
||||
|
||||
return {
|
||||
iv: b64(encrypted.iv),
|
||||
ciphertext: b64(encrypted.ciphertext),
|
||||
@@ -158,7 +158,7 @@ export function generateCallEmojis(sessionKeyHash: string): string[] {
|
||||
// Convert hash to numbers and map to emoji ranges
|
||||
const hashBytes = new Uint8Array(ub64(sessionKeyHash));
|
||||
const emojis: string[] = [];
|
||||
|
||||
|
||||
// Different emoji categories for variety
|
||||
const emojiSets = [
|
||||
["🎵", "🎶", "🎤", "🎧", "🎼", "🎹", "🥁", "🎺", "🎸", "🎻"], // Music
|
||||
@@ -166,13 +166,13 @@ export function generateCallEmojis(sessionKeyHash: string): string[] {
|
||||
["🚀", "🛸", "🛰️", "🌌", "🔭", "⚙️", "🔧", "⚡", "💡", "🔬"], // Tech/Space
|
||||
["🎭", "🎪", "🎨", "🎬", "📷", "🎥", "📺", "🎮", "🕹️", "🎯"] // Entertainment
|
||||
];
|
||||
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const set = emojiSets[i % emojiSets.length];
|
||||
const index = hashBytes[i % hashBytes.length] % set.length;
|
||||
emojis.push(set[index]);
|
||||
}
|
||||
|
||||
|
||||
return emojis;
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ export async function createSharedSecretAndDeriveSessionKey(
|
||||
|
||||
// Create shared secret using ECDH
|
||||
const sharedSecret = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
|
||||
|
||||
// Derive the session key from the shared secret
|
||||
return await deriveCallSessionKeyFromSharedSecret(sharedSecret, sessionKeyHash, isInitiator);
|
||||
}
|
||||
|
||||
@@ -69,10 +69,10 @@ export class CallSignalingHandler {
|
||||
const { fromUsername } = data;
|
||||
const fromUserId = message.fromUserId;
|
||||
const state = this.getState();
|
||||
|
||||
|
||||
// First, create the peer connection in WebRTC service
|
||||
await WebRTC.handleIncomingCall(fromUserId, fromUsername);
|
||||
|
||||
|
||||
// Then show incoming call UI
|
||||
state.receiveCall(fromUserId, fromUsername);
|
||||
}
|
||||
@@ -96,12 +96,12 @@ export class CallSignalingHandler {
|
||||
private handleCallReject(data: CallRejectData) {
|
||||
const state = this.getState();
|
||||
const { fromUserId } = data;
|
||||
|
||||
|
||||
// Clean up WebRTC connection first
|
||||
if (fromUserId) {
|
||||
WebRTC.cleanupCall(fromUserId);
|
||||
}
|
||||
|
||||
|
||||
// End the call
|
||||
state.endCall();
|
||||
}
|
||||
@@ -121,12 +121,12 @@ export class CallSignalingHandler {
|
||||
private handleCallEnd(data: CallEndData) {
|
||||
const state = this.getState();
|
||||
const { fromUserId } = data;
|
||||
|
||||
|
||||
// Clean up WebRTC connection first
|
||||
if (fromUserId) {
|
||||
WebRTC.cleanupCall(fromUserId);
|
||||
}
|
||||
|
||||
|
||||
// End the call
|
||||
state.endCall();
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export class CallSignalingHandler {
|
||||
|
||||
private handleVideoToggle(message: CallSignalingMessage, data: CallVideoToggleData) {
|
||||
const state = this.getState();
|
||||
|
||||
|
||||
if (data && typeof data.enabled === "boolean" && message.fromUserId) {
|
||||
// Update Zustand state (for UI)
|
||||
state.setRemoteVideoEnabled(data.enabled);
|
||||
@@ -157,7 +157,7 @@ export class CallSignalingHandler {
|
||||
|
||||
private handleScreenShareToggle(message: CallSignalingMessage, data: CallScreenShareToggleData) {
|
||||
const state = this.getState();
|
||||
|
||||
|
||||
if (data && typeof data.enabled === "boolean" && message.fromUserId) {
|
||||
// Update Zustand state (for UI)
|
||||
state.setRemoteScreenSharing(data.enabled);
|
||||
|
||||
@@ -69,7 +69,7 @@ export class WebRTCCall {
|
||||
private set sessionKey(value: Uint8Array | null) {
|
||||
this._sessionKey = value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// -------------------
|
||||
// Core initialization
|
||||
@@ -85,12 +85,12 @@ export class WebRTCCall {
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
const iceServers = await this.getIceServers();
|
||||
|
||||
|
||||
// Create peer connection with proper ICE servers
|
||||
this.peerConnection = new RTCPeerConnection({
|
||||
iceServers
|
||||
});
|
||||
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export class WebRTCCall {
|
||||
const response = await fetch("/api/webrtc/ice", {
|
||||
headers: getAuthHeaders(getAuthToken()!)
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as IceServersResponse;
|
||||
return data.iceServers || [];
|
||||
@@ -112,7 +112,7 @@ export class WebRTCCall {
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch ICE servers:", error);
|
||||
}
|
||||
|
||||
|
||||
// Fallback to STUN only if backend fails
|
||||
return DEFAULT_ICE_SERVERS;
|
||||
}
|
||||
@@ -161,7 +161,7 @@ export class WebRTCCall {
|
||||
}
|
||||
|
||||
this.isNegotiating = true;
|
||||
|
||||
|
||||
const offer = await this.peerConnection.createOffer();
|
||||
await this.peerConnection.setLocalDescription(offer);
|
||||
|
||||
@@ -184,7 +184,7 @@ export class WebRTCCall {
|
||||
const [remoteStream] = event.streams;
|
||||
if (remoteStream) {
|
||||
const track = event.track;
|
||||
|
||||
|
||||
// Apply E2EE transform to all tracks - video now uses header-preserving encryption
|
||||
if (this.sessionKey && window.RTCRtpScriptTransform) {
|
||||
try {
|
||||
@@ -198,12 +198,12 @@ export class WebRTCCall {
|
||||
console.error("Failed to apply E2EE to received track:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Determine stream type based on track kind and signaling state
|
||||
if (track.kind === "video") {
|
||||
let isScreenShare = false;
|
||||
let isVideo = false;
|
||||
|
||||
|
||||
if (this.isRemoteScreenSharing && this.isRemoteVideoEnabled) {
|
||||
// Both active - route based on which one we haven't received yet
|
||||
// Simple logic: if we haven't received video yet, this is video
|
||||
@@ -225,7 +225,7 @@ export class WebRTCCall {
|
||||
isVideo = true;
|
||||
this.receivedVideoTrackCount++;
|
||||
}
|
||||
|
||||
|
||||
if (isScreenShare) {
|
||||
if (callbacks.onRemoteScreenShare) {
|
||||
callbacks.onRemoteScreenShare(this.remoteUserId, remoteStream);
|
||||
@@ -253,7 +253,7 @@ export class WebRTCCall {
|
||||
|
||||
// Clean up only on permanent failures
|
||||
// Don't end on "disconnected" - ICE can recover from temporary disconnections
|
||||
if (this.peerConnection.connectionState === "failed" ||
|
||||
if (this.peerConnection.connectionState === "failed" ||
|
||||
this.peerConnection.connectionState === "closed") {
|
||||
// Only send end call message if we're not already cleaning up
|
||||
if (!this.isEnding) {
|
||||
@@ -284,32 +284,32 @@ export class WebRTCCall {
|
||||
audioTrack.stop();
|
||||
this.localStream.removeTrack(audioTrack);
|
||||
}
|
||||
|
||||
|
||||
// Create a silent audio track using Web Audio API
|
||||
const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
const audioContext = new AudioContextClass();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
|
||||
// Set gain to 0 (silent)
|
||||
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
|
||||
|
||||
|
||||
// Connect nodes
|
||||
oscillator.connect(gainNode);
|
||||
|
||||
|
||||
// Create a MediaStreamDestination to get a MediaStream
|
||||
const destination = audioContext.createMediaStreamDestination();
|
||||
gainNode.connect(destination);
|
||||
|
||||
|
||||
// Start the oscillator (but it's silent due to gain = 0)
|
||||
oscillator.start();
|
||||
|
||||
|
||||
// Add the silent track to maintain WebRTC connection
|
||||
const silentTrack = destination.stream.getAudioTracks()[0];
|
||||
if (silentTrack) {
|
||||
this.localStream.addTrack(silentTrack);
|
||||
}
|
||||
|
||||
|
||||
this.isMuted = true;
|
||||
return true; // Muted
|
||||
} else {
|
||||
@@ -318,15 +318,15 @@ export class WebRTCCall {
|
||||
.then(newStream => {
|
||||
// Remove any existing audio tracks from the stream
|
||||
this.localStream!.getAudioTracks().forEach(track => track.stop());
|
||||
|
||||
|
||||
// Get the new active track
|
||||
const newAudioTrack = newStream.getAudioTracks()[0];
|
||||
|
||||
|
||||
// Replace the track in the peer connection
|
||||
const sender = this.peerConnection.getSenders().find(s =>
|
||||
const sender = this.peerConnection.getSenders().find(s =>
|
||||
s.track && s.track.kind === 'audio'
|
||||
);
|
||||
|
||||
|
||||
if (sender) {
|
||||
// Replace the track in the existing sender
|
||||
sender.replaceTrack(newAudioTrack);
|
||||
@@ -334,10 +334,10 @@ export class WebRTCCall {
|
||||
// Add the track to the peer connection if no sender exists
|
||||
this.peerConnection.addTrack(newAudioTrack, this.localStream!);
|
||||
}
|
||||
|
||||
|
||||
// Add the track to the local stream
|
||||
this.localStream!.addTrack(newAudioTrack);
|
||||
|
||||
|
||||
this.isMuted = false;
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -471,10 +471,10 @@ export class WebRTCCall {
|
||||
|
||||
// Add screen share track to peer connection
|
||||
const videoTrack = screenStream.getVideoTracks()[0];
|
||||
|
||||
|
||||
// Handle when user stops sharing via browser UI
|
||||
videoTrack.addEventListener("ended", async () => {
|
||||
|
||||
|
||||
// Clean up screen share state
|
||||
if (this.screenShareStream) {
|
||||
this.screenShareStream.getTracks().forEach(t => t.stop());
|
||||
@@ -484,12 +484,12 @@ export class WebRTCCall {
|
||||
|
||||
// Remove screen share track from peer connection
|
||||
const senders = this.peerConnection.getSenders();
|
||||
const screenSender = senders.find(sender =>
|
||||
sender.track && sender.track.kind === 'video' &&
|
||||
const screenSender = senders.find(sender =>
|
||||
sender.track && sender.track.kind === 'video' &&
|
||||
sender.track.readyState === 'ended' &&
|
||||
this.transformedSenders.has(sender)
|
||||
);
|
||||
|
||||
|
||||
if (screenSender) {
|
||||
await this.peerConnection.removeTrack(screenSender);
|
||||
this.transformedSenders.delete(screenSender);
|
||||
@@ -534,7 +534,7 @@ export class WebRTCCall {
|
||||
try {
|
||||
const key = await importAesGcmKey(this.sessionKey);
|
||||
const sender = this.peerConnection.getSenders().find(s => s.track === videoTrack);
|
||||
|
||||
|
||||
if (sender && !this.transformedSenders.has(sender)) {
|
||||
sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: "encrypt", sessionId: this.sessionId });
|
||||
this.transformedSenders.add(sender);
|
||||
@@ -604,14 +604,14 @@ export class WebRTCCall {
|
||||
*/
|
||||
async setSessionKey(keyBytes: Uint8Array): Promise<void> {
|
||||
this.sessionKey = keyBytes;
|
||||
|
||||
|
||||
await this.applyE2EETransforms();
|
||||
|
||||
|
||||
// Start key rotation timer (rotate every 10 minutes for long calls)
|
||||
if (this.keyRotationTimer) {
|
||||
clearInterval(this.keyRotationTimer);
|
||||
}
|
||||
|
||||
|
||||
this.keyRotationTimer = setInterval(async () => {
|
||||
await this.rotateSessionKey();
|
||||
}, KEY_ROTATION_INTERVAL);
|
||||
@@ -622,14 +622,14 @@ export class WebRTCCall {
|
||||
*/
|
||||
private async rotateSessionKey(): Promise<void> {
|
||||
if (!this.sessionKey) return;
|
||||
|
||||
|
||||
try {
|
||||
// Generate new session key
|
||||
const newSessionKey = await rotateCallSessionKey();
|
||||
|
||||
|
||||
// Update the call with new session key
|
||||
this.sessionKey = newSessionKey.key;
|
||||
|
||||
|
||||
// Reapply E2EE transforms with new key
|
||||
await this.applyE2EETransforms();
|
||||
} catch (error) {
|
||||
@@ -645,9 +645,9 @@ export class WebRTCCall {
|
||||
if (!this.sessionKey || !window.RTCRtpScriptTransform) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const key = await importAesGcmKey(this.sessionKey);
|
||||
|
||||
|
||||
// Apply to receivers that don't already have transforms
|
||||
const receivers = this.peerConnection.getReceivers();
|
||||
for (const receiver of receivers) {
|
||||
@@ -656,7 +656,7 @@ export class WebRTCCall {
|
||||
this.transformedReceivers.add(receiver);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Apply to senders that don't already have transforms
|
||||
const senders = this.peerConnection.getSenders();
|
||||
for (const sender of senders) {
|
||||
@@ -678,9 +678,9 @@ export class WebRTCCall {
|
||||
if (!sessionKey || !window.RTCRtpScriptTransform) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const key = await importAesGcmKey(sessionKey);
|
||||
|
||||
|
||||
// Apply to receivers that don't already have transforms
|
||||
const receivers = this.peerConnection.getReceivers();
|
||||
for (const receiver of receivers) {
|
||||
@@ -689,7 +689,7 @@ export class WebRTCCall {
|
||||
this.transformedReceivers.add(receiver);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Apply to senders that don't already have transforms
|
||||
const senders = this.peerConnection.getSenders();
|
||||
for (const sender of senders) {
|
||||
@@ -735,7 +735,7 @@ export class WebRTCCall {
|
||||
if (this.keyRotationTimer) {
|
||||
clearInterval(this.keyRotationTimer);
|
||||
}
|
||||
|
||||
|
||||
// Close peer connection
|
||||
if (this.peerConnection) {
|
||||
this.peerConnection.close();
|
||||
@@ -833,8 +833,8 @@ export async function initiateCall(userId: number, username: string): Promise<bo
|
||||
type: "call_invite",
|
||||
fromUserId: 0, // Will be set by server
|
||||
toUserId: userId,
|
||||
data: {
|
||||
fromUsername: username
|
||||
data: {
|
||||
fromUsername: username
|
||||
}
|
||||
});
|
||||
|
||||
@@ -886,14 +886,14 @@ export async function setSessionKey(userId: number, keyBytes: Uint8Array): Promi
|
||||
console.error("setSessionKey: No call found for user", userId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await call.setSessionKey(keyBytes);
|
||||
}
|
||||
|
||||
|
||||
export async function receiveWrappedSessionKey(
|
||||
fromUserId: number,
|
||||
wrappedPayload: WrappedSessionKeyPayload,
|
||||
fromUserId: number,
|
||||
wrappedPayload: WrappedSessionKeyPayload,
|
||||
sessionKeyHash?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
@@ -906,14 +906,14 @@ export async function receiveWrappedSessionKey(
|
||||
console.error("Missing wrapped payload or session key hash");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Unwrap the session key from the encrypted payload
|
||||
const unwrappedSessionKey = await unwrapCallSessionKeyFromSender(senderPublicKey, {
|
||||
salt: wrappedPayload.salt,
|
||||
iv2: wrappedPayload.iv2,
|
||||
wrapped: wrappedPayload.wrapped
|
||||
});
|
||||
|
||||
|
||||
// Use the unwrapped session key directly (both sides should have the same key)
|
||||
await setSessionKey(fromUserId, unwrappedSessionKey);
|
||||
} catch (e) {
|
||||
@@ -973,7 +973,7 @@ export async function endCall(userId: number): Promise<void> {
|
||||
const call = calls.get(userId);
|
||||
if (call && !call.isEnding) {
|
||||
call.isEnding = true;
|
||||
|
||||
|
||||
// Send call end message
|
||||
await sendSignalingMessage({
|
||||
type: "call_end",
|
||||
@@ -1009,7 +1009,7 @@ export async function onRemoteAccepted(userId: number): Promise<void> {
|
||||
// Small delay to ensure remote peer finishes processing the accept
|
||||
// This prevents race conditions where our offer arrives before they're ready
|
||||
await delay(NEGOTIATION_DELAY);
|
||||
|
||||
|
||||
// Create offer
|
||||
const offer = await call.peerConnection.createOffer();
|
||||
await call.peerConnection.setLocalDescription(offer);
|
||||
@@ -1029,7 +1029,7 @@ export async function onRemoteAccepted(userId: number): Promise<void> {
|
||||
|
||||
export async function handleCallOffer(userId: number, offer: RTCSessionDescriptionInit): Promise<void> {
|
||||
let call = calls.get(userId);
|
||||
|
||||
|
||||
// Handle race condition - offer might arrive before peer connection is created
|
||||
if (!call) {
|
||||
call = await createPeerConnection(userId);
|
||||
@@ -1085,23 +1085,23 @@ export async function handleCallAnswer(userId: number, answer: RTCSessionDescrip
|
||||
|
||||
try {
|
||||
await call.peerConnection.setRemoteDescription(answer);
|
||||
|
||||
|
||||
// Reset negotiating flag
|
||||
call.isNegotiating = false;
|
||||
|
||||
|
||||
// Attach transforms on initiator side if session key is available
|
||||
// If not available yet, setSessionKey will apply them when it arrives
|
||||
if (call.sessionKey) {
|
||||
await call.createE2EETransform(call.sessionKey, call.sessionId);
|
||||
}
|
||||
|
||||
|
||||
// Check if there are new receivers with tracks that haven't been notified yet
|
||||
// This handles the case where tracks exist but the track event hasn't fired yet
|
||||
const receivers = call.peerConnection.getReceivers();
|
||||
for (const receiver of receivers) {
|
||||
if (receiver.track) {
|
||||
const track = receiver.track;
|
||||
|
||||
|
||||
// Find the stream for this track
|
||||
const transceiver = call.peerConnection.getTransceivers().find(t => t.receiver === receiver);
|
||||
if (transceiver && transceiver.receiver.track) {
|
||||
|
||||
@@ -12,7 +12,7 @@ interface SearchBarProps {
|
||||
rightIcon?: string | React.ReactNode;
|
||||
}
|
||||
|
||||
export default function SearchBar({
|
||||
export default function SearchBar({
|
||||
placeholder,
|
||||
children,
|
||||
searchQuery,
|
||||
@@ -66,11 +66,11 @@ export default function SearchBar({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
ref={parentContainerRef}
|
||||
className="search-parent"
|
||||
>
|
||||
<div
|
||||
<div
|
||||
ref={searchContainerRef}
|
||||
className={`search-bar-container ${isExpanded ? "expanded" : "collapsed"}`}
|
||||
style={{ height: dynamicHeight }}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { TextField } from "mdui/components/text-field";
|
||||
type TextFieldProps = React.ComponentPropsWithoutRef<"mdui-text-field">
|
||||
|
||||
export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref<TextField> }) {
|
||||
return <mdui-text-field
|
||||
autocomplete="off"
|
||||
return <mdui-text-field
|
||||
autocomplete="off"
|
||||
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} />
|
||||
}
|
||||
@@ -14,16 +14,16 @@ $font-size: 16px;
|
||||
position: absolute;
|
||||
z-index: 1001;
|
||||
overflow: hidden;
|
||||
|
||||
|
||||
// All properties animate together simultaneously
|
||||
transition:
|
||||
transition:
|
||||
height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
top 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
left 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
right 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-radius 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
|
||||
// Initial background color for smooth transition
|
||||
background-color: $color-dark-surface-container-high;
|
||||
|
||||
@@ -143,7 +143,7 @@ $font-size: 16px;
|
||||
mdui-list-item {
|
||||
img[slot="icon"] {
|
||||
$size: 48px;
|
||||
|
||||
|
||||
width: $size;
|
||||
height: $size;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
*/
|
||||
|
||||
import { request } from "./websocket";
|
||||
import type {
|
||||
StatusUpdateWebSocketMessage,
|
||||
SubscribeStatusWebSocketMessage,
|
||||
UnsubscribeStatusWebSocketMessage
|
||||
import type {
|
||||
StatusUpdateWebSocketMessage,
|
||||
SubscribeStatusWebSocketMessage,
|
||||
UnsubscribeStatusWebSocketMessage
|
||||
} from "./types";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
|
||||
@@ -94,7 +94,7 @@ export class OnlineStatusManager {
|
||||
handleStatusUpdate(message: StatusUpdateWebSocketMessage): void {
|
||||
const { userId, online, lastSeen } = message.data;
|
||||
this.statusCache.set(userId, { online, lastSeen });
|
||||
|
||||
|
||||
// Update the global state
|
||||
const { updateOnlineStatus } = useAppState.getState();
|
||||
updateOnlineStatus(userId, online, lastSeen);
|
||||
@@ -132,7 +132,7 @@ export class OnlineStatusManager {
|
||||
* Unsubscribe from all users and clear cache
|
||||
*/
|
||||
async unsubscribeAll(): Promise<void> {
|
||||
const unsubscribePromises = Array.from(this.subscribedUsers).map(userId =>
|
||||
const unsubscribePromises = Array.from(this.subscribedUsers).map(userId =>
|
||||
this.unsubscribe(userId)
|
||||
);
|
||||
await Promise.all(unsubscribePromises);
|
||||
|
||||
@@ -108,8 +108,8 @@ async function showMessageNotification(message: any): Promise<void> {
|
||||
try {
|
||||
await showNotification({
|
||||
title: `New message from ${message.username}`,
|
||||
body: message.content.length > 100
|
||||
? message.content.substring(0, 100) + "..."
|
||||
body: message.content.length > 100
|
||||
? message.content.substring(0, 100) + "..."
|
||||
: message.content,
|
||||
icon: message.profile_picture || "/logo.png",
|
||||
tag: `message_${message.id}`,
|
||||
@@ -155,7 +155,7 @@ export async function initialize(): Promise<boolean> {
|
||||
try {
|
||||
registration = await navigator.serviceWorker.register(serviceWorker, { type: "module" });
|
||||
console.log("Service Worker registered successfully");
|
||||
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === "granted") {
|
||||
await subscribeToWebPush();
|
||||
@@ -200,7 +200,7 @@ export async function showNotification(payload: NotificationPayload): Promise<bo
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For web browsers, notifications are handled by the service worker
|
||||
// when push messages are received from the server
|
||||
return false;
|
||||
@@ -240,7 +240,7 @@ export async function startElectronReceiver(): Promise<void> {
|
||||
}
|
||||
|
||||
isElectronReceiverRunning = true;
|
||||
|
||||
|
||||
// Add our own message listener to the existing WebSocket
|
||||
messageListener = (event: MessageEvent) => {
|
||||
try {
|
||||
@@ -250,7 +250,7 @@ export async function startElectronReceiver(): Promise<void> {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
websocket.addEventListener('message', messageListener);
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ export function stopElectronReceiver(): void {
|
||||
}
|
||||
|
||||
isElectronReceiverRunning = false;
|
||||
|
||||
|
||||
// Remove our message listener
|
||||
if (messageListener) {
|
||||
websocket.removeEventListener('message', messageListener);
|
||||
|
||||
@@ -33,7 +33,7 @@ self.addEventListener("push", function(event: ExtendableEvent) {
|
||||
const pushEvent = event as PushEvent;
|
||||
if (pushEvent.data) {
|
||||
const data: NotificationPayload = pushEvent.data.json();
|
||||
|
||||
|
||||
const options: NotificationOptions = {
|
||||
body: data.body,
|
||||
icon: data.icon || "/logo.png",
|
||||
|
||||
Vendored
+10
-10
@@ -221,17 +221,17 @@ export interface DmFile {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface DmEditedPayload {
|
||||
id: number;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
timestamp: string
|
||||
export interface DmEditedPayload {
|
||||
id: number;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface DmDeletedPayload {
|
||||
id: number;
|
||||
senderId: number;
|
||||
recipientId: number
|
||||
export interface DmDeletedPayload {
|
||||
id: number;
|
||||
senderId: number;
|
||||
recipientId: number
|
||||
}
|
||||
|
||||
export interface FetchDMResponse {
|
||||
@@ -461,7 +461,7 @@ export interface CallSignalingMessage extends WebSocketMessage {
|
||||
data: CallSignalingMessageData;
|
||||
}
|
||||
|
||||
export type CallSignalingMessageData =
|
||||
export type CallSignalingMessageData =
|
||||
| CallInviteMessageData
|
||||
| CallAcceptData
|
||||
| CallRejectData
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { request } from "./websocket";
|
||||
import type {
|
||||
import type {
|
||||
TypingWebSocketMessage,
|
||||
StopTypingWebSocketMessage,
|
||||
DmTypingWebSocketMessage,
|
||||
@@ -166,14 +166,14 @@ export class TypingManager {
|
||||
*/
|
||||
private scheduleStopTyping(context: string): void {
|
||||
this.clearStopTypingTimeout(context);
|
||||
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
if (context === "public") {
|
||||
await this.sendStopTyping();
|
||||
}
|
||||
this.typingTimeouts.delete(context);
|
||||
}, this.TYPING_TIMEOUT);
|
||||
|
||||
|
||||
this.typingTimeouts.set(context, timeout);
|
||||
}
|
||||
|
||||
@@ -183,12 +183,12 @@ export class TypingManager {
|
||||
private scheduleStopDmTyping(recipientId: number): void {
|
||||
const context = `dm_${recipientId}`;
|
||||
this.clearStopTypingTimeout(context);
|
||||
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
await this.sendStopDmTyping(recipientId);
|
||||
this.typingTimeouts.delete(context);
|
||||
}, this.TYPING_TIMEOUT);
|
||||
|
||||
|
||||
this.typingTimeouts.set(context, timeout);
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export function request<Request, Response = any>(payload: WebSocketMessage<Reque
|
||||
* This function will wait 3 seconds and them attempts to reconnect the WebSocket.
|
||||
* If it fails, tries again in an endless loop until the connection is established
|
||||
* again.
|
||||
*
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async function onError() {
|
||||
@@ -112,12 +112,12 @@ async function onError() {
|
||||
websocket.addEventListener("message", (e) => {
|
||||
try {
|
||||
const response: WebSocketMessage<any> = JSON.parse(e.data);
|
||||
|
||||
|
||||
// Handle call signaling messages
|
||||
if (callSignalingHandler && response.type === "call_signaling" && response.data) {
|
||||
callSignalingHandler.handleWebSocketMessage(response.data);
|
||||
}
|
||||
|
||||
|
||||
// Handle status and typing messages
|
||||
if (response.type === "statusUpdate") {
|
||||
onlineStatusManager.handleStatusUpdate(response as any);
|
||||
@@ -130,7 +130,7 @@ websocket.addEventListener("message", (e) => {
|
||||
} else if (response.type === "stopDmTyping") {
|
||||
typingManager.handleStopDmTyping(response as any);
|
||||
}
|
||||
|
||||
|
||||
// Route message to global handler if set
|
||||
if (globalMessageHandler) {
|
||||
globalMessageHandler(response);
|
||||
|
||||
Reference in New Issue
Block a user