This commit is contained in:
2025-10-19 20:31:29 +03:00
Unverified
parent 66f0b756a0
commit f5527ca14e
67 changed files with 891 additions and 902 deletions
+8 -8
View File
@@ -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!
};
}
+9 -9
View File
@@ -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();
+2 -2
View File
@@ -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);
+16 -16
View File
@@ -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);
+16 -16
View File
@@ -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);
}
+8 -8
View File
@@ -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);
+57 -57
View File
@@ -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) {
+3 -3
View File
@@ -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 }}
+2 -2
View File
@@ -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 -6
View File
@@ -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",
+10 -10
View File
@@ -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
+5 -5
View File
@@ -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);
}
+4 -4
View File
@@ -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);