фикс багов
This commit is contained in:
+54
-14
@@ -1,34 +1,74 @@
|
|||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
apps/server/dist/
|
apps/server/dist/
|
||||||
apps/web/dist/
|
apps/web/dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
apps/server/.env
|
apps/server/.env
|
||||||
|
apps/web/.env
|
||||||
|
|
||||||
# Uploads (user data)
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
eslint-debug.log*
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Editor/IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
.project
|
||||||
|
.settings/
|
||||||
|
.classpath
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.docker-compose.override.yml
|
||||||
|
.docker-compose.local.yml
|
||||||
|
|
||||||
|
# Uploads and User Data
|
||||||
|
apps/server/uploads/*
|
||||||
|
!apps/server/uploads/.gitkeep
|
||||||
apps/server/uploads/avatars/*
|
apps/server/uploads/avatars/*
|
||||||
!apps/server/uploads/avatars/.gitkeep
|
!apps/server/uploads/avatars/.gitkeep
|
||||||
|
|
||||||
# Deploy artifacts
|
# Deploy artifacts
|
||||||
vortex-deploy/
|
vortex-deploy/
|
||||||
vortex-deploy.tar.gz
|
vortex-deploy.tar.gz
|
||||||
|
vortex-deploy.zip
|
||||||
# Credentials
|
|
||||||
CREDENTIALS.txt
|
CREDENTIALS.txt
|
||||||
|
|
||||||
# Security audit (personal)
|
|
||||||
SECURITY_AUDIT.md
|
SECURITY_AUDIT.md
|
||||||
|
|
||||||
# OS files
|
# Misc
|
||||||
Thumbs.db
|
.cache/
|
||||||
.DS_Store
|
.eslintcache
|
||||||
|
.npm/
|
||||||
# IDE
|
.tmp/
|
||||||
.idea/
|
tmp/
|
||||||
.vscode/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|||||||
@@ -59,8 +59,11 @@ app.use('/uploads', (req, res, next) => {
|
|||||||
const contentType = mime.lookup(filePath) || 'application/octet-stream';
|
const contentType = mime.lookup(filePath) || 'application/octet-stream';
|
||||||
res.setHeader('Content-Type', contentType);
|
res.setHeader('Content-Type', contentType);
|
||||||
|
|
||||||
// If encryption is enabled, try to decrypt
|
// Check if we should skip decryption (videos/audio always skipped for range support)
|
||||||
if (isEncryptionEnabled()) {
|
const isMedia = contentType.startsWith('video/') || contentType.startsWith('audio/');
|
||||||
|
|
||||||
|
// If encryption is enabled and NOT video/audio, try to decrypt
|
||||||
|
if (isEncryptionEnabled() && !isMedia) {
|
||||||
const decrypted = decryptFileToBuffer(filePath);
|
const decrypted = decryptFileToBuffer(filePath);
|
||||||
if (decrypted) {
|
if (decrypted) {
|
||||||
res.setHeader('Content-Length', decrypted.length);
|
res.setHeader('Content-Length', decrypted.length);
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export const uploadFile = multer({
|
|||||||
cb(null, `${uuidv4()}${ext}`);
|
cb(null, `${uuidv4()}${ext}`);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 50 * 1024 * 1024 },
|
limits: { fileSize: 200 * 1024 * 1024 },
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
const ext = path.extname(file.originalname).toLowerCase();
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
if (BLOCKED_EXTENSIONS.has(ext)) {
|
if (BLOCKED_EXTENSIONS.has(ext)) {
|
||||||
@@ -158,15 +158,20 @@ export function encryptUploadedFile(req: Request, _res: Response, next: NextFunc
|
|||||||
try {
|
try {
|
||||||
// Single file upload (req.file)
|
// Single file upload (req.file)
|
||||||
if (req.file) {
|
if (req.file) {
|
||||||
encryptFileInPlace(req.file.path);
|
// Don't encrypt videos and audio as they need range support and are large
|
||||||
|
if (!req.file.mimetype.startsWith('video/') && !req.file.mimetype.startsWith('audio/')) {
|
||||||
|
encryptFileInPlace(req.file.path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Multiple files (req.files) — handle both array and field-keyed forms
|
// Multiple files (req.files) — handle both array and field-keyed forms
|
||||||
if (req.files) {
|
if (req.files) {
|
||||||
const files = Array.isArray(req.files)
|
const files = Array.isArray(req.files)
|
||||||
? req.files
|
? req.files
|
||||||
: Object.values(req.files).flat();
|
: Object.values(req.files).flat() as Express.Multer.File[];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
encryptFileInPlace(file.path);
|
if (!file.mimetype.startsWith('video/') && !file.mimetype.startsWith('audio/')) {
|
||||||
|
encryptFileInPlace(file.path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -779,6 +779,15 @@ export function setupSocket(io: Server) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('call_type_changed', (data: { targetUserId: string; callType: 'voice' | 'video' }) => {
|
||||||
|
const targetSockets = onlineUsers.get(data.targetUserId);
|
||||||
|
if (targetSockets) {
|
||||||
|
for (const sid of targetSockets) {
|
||||||
|
io.to(sid).emit('call_type_changed', { from: userId, callType: data.callType });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ======= Group Conference Calls =======
|
// ======= Group Conference Calls =======
|
||||||
|
|
||||||
// Query active group call status for a chat
|
// Query active group call status for a chat
|
||||||
|
|||||||
@@ -220,20 +220,26 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
remoteStreamRef.current = stream;
|
remoteStreamRef.current = stream;
|
||||||
|
|
||||||
// Check if remote has video tracks
|
// Check if remote has video tracks
|
||||||
// Include !t.muted so we detect when the remote stops sending
|
|
||||||
// (replaceTrack(null) or direction change causes muted=true)
|
|
||||||
const checkVideo = () => {
|
const checkVideo = () => {
|
||||||
const videoTracks = stream.getVideoTracks();
|
const videoTracks = stream.getVideoTracks();
|
||||||
|
// hasRemoteVideo stays true if we have ANY live video track.
|
||||||
const hasVideo = videoTracks.length > 0 && videoTracks.some(
|
const hasVideo = videoTracks.length > 0 && videoTracks.some(
|
||||||
t => t.readyState === 'live' && t.enabled && !t.muted
|
t => t.readyState === 'live' && t.enabled && !t.muted
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('[checkVideo] videoTracks:', videoTracks.map(t =>
|
console.log('[checkVideo] videoTracks:', videoTracks.map(t =>
|
||||||
`${t.label} state=${t.readyState} enabled=${t.enabled} muted=${t.muted}`
|
`${t.label} state=${t.readyState} enabled=${t.enabled} muted=${t.muted}`
|
||||||
), '→ hasRemoteVideo:', hasVideo);
|
), '→ hasRemoteVideo:', hasVideo);
|
||||||
|
|
||||||
setHasRemoteVideo(hasVideo);
|
setHasRemoteVideo(hasVideo);
|
||||||
|
// Fallback: if we detect a live video track but signalling hasn't updated us, switch to video mode
|
||||||
|
if (hasVideo && callType !== 'video') {
|
||||||
|
console.log('[checkVideo] Auto-switching to video mode');
|
||||||
|
setCallType('video');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Listen for unmute/mute on every video track (muted→unmuted when data starts flowing)
|
// Listen for unmute/mute/ended on video tracks
|
||||||
const attachTrackListeners = (track: MediaStreamTrack) => {
|
const attachTrackListeners = (track: MediaStreamTrack) => {
|
||||||
if (track.kind !== 'video') return;
|
if (track.kind !== 'video') return;
|
||||||
track.onunmute = checkVideo;
|
track.onunmute = checkVideo;
|
||||||
@@ -245,6 +251,8 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
checkVideo();
|
checkVideo();
|
||||||
|
|
||||||
if (remoteVideoRef.current) {
|
if (remoteVideoRef.current) {
|
||||||
|
// Force re-assignment to ensure browser picks up new track
|
||||||
|
remoteVideoRef.current.srcObject = null;
|
||||||
remoteVideoRef.current.srcObject = stream;
|
remoteVideoRef.current.srcObject = stream;
|
||||||
}
|
}
|
||||||
if (remoteAudioRef.current) {
|
if (remoteAudioRef.current) {
|
||||||
@@ -336,9 +344,11 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
// Add all tracks from the stream
|
// Add all tracks from the stream
|
||||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||||
|
|
||||||
// If this is a video call but we only have audio, add a recvonly video transceiver
|
// Always add a video transceiver (even for voice calls)
|
||||||
// so we can receive video from the other side and potentially add video later
|
// This ensures we have a video m-line for future camera/screen share,
|
||||||
if (effectiveCallType === 'video' && stream.getVideoTracks().length === 0) {
|
// making renegotiation much more stable across browsers.
|
||||||
|
const hasVideoTransceiver = pc.getTransceivers().some(t => t.receiver?.track?.kind === 'video');
|
||||||
|
if (!hasVideoTransceiver) {
|
||||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,17 +439,13 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
pc.addTrack(track, stream);
|
pc.addTrack(track, stream);
|
||||||
});
|
});
|
||||||
|
|
||||||
// If this is a video call but we only have audio, we need a recvonly video transceiver.
|
// Always ensure we have a video transceiver.
|
||||||
// After setRemoteDescription, one already exists from the offer — but only if the offer
|
// After setRemoteDescription, one usually exists from the offer if it was a video call or if the caller added one.
|
||||||
// contained a video m-line. If it did, the transceiver is already recvonly.
|
const hasVideoTransceiver = pc.getTransceivers().some(
|
||||||
// If there's no video transceiver at all and we want to receive video, add one.
|
t => t.receiver?.track?.kind === 'video'
|
||||||
if (incoming.callType === 'video' && !hasVideo) {
|
);
|
||||||
const hasVideoTransceiver = pc.getTransceivers().some(
|
if (!hasVideoTransceiver) {
|
||||||
t => t.receiver?.track?.kind === 'video'
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||||
);
|
|
||||||
if (!hasVideoTransceiver) {
|
|
||||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[acceptCall] Transceivers after setup:',
|
console.log('[acceptCall] Transceivers after setup:',
|
||||||
@@ -888,6 +894,8 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
}
|
}
|
||||||
setIsVideoOff(true);
|
setIsVideoOff(true);
|
||||||
setCallType('voice');
|
setCallType('voice');
|
||||||
|
const socket = getSocket();
|
||||||
|
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice' });
|
||||||
} else {
|
} else {
|
||||||
// Turn on: re-enable existing track or get new camera
|
// Turn on: re-enable existing track or get new camera
|
||||||
const sender = findVideoSender(pc);
|
const sender = findVideoSender(pc);
|
||||||
@@ -937,6 +945,8 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
|
|
||||||
setIsVideoOff(false);
|
setIsVideoOff(false);
|
||||||
setCallType('video');
|
setCallType('video');
|
||||||
|
const socket = getSocket();
|
||||||
|
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Could not start camera:', err);
|
console.error('Could not start camera:', err);
|
||||||
}
|
}
|
||||||
@@ -1038,6 +1048,8 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
}
|
}
|
||||||
setCallType('voice');
|
setCallType('voice');
|
||||||
setIsVideoOff(false);
|
setIsVideoOff(false);
|
||||||
|
const socket = getSocket();
|
||||||
|
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice' });
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsScreenSharing(false);
|
setIsScreenSharing(false);
|
||||||
@@ -1165,11 +1177,15 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setCallType('voice');
|
setCallType('voice');
|
||||||
|
const socket = getSocket();
|
||||||
|
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
setIsScreenSharing(true);
|
setIsScreenSharing(true);
|
||||||
setCallType('video');
|
setCallType('video');
|
||||||
|
const socket = getSocket();
|
||||||
|
socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error starting screen share:', err);
|
console.error('Error starting screen share:', err);
|
||||||
}
|
}
|
||||||
@@ -1287,6 +1303,12 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onCallTypeChanged = (data: { from: string; callType: 'voice' | 'video' }) => {
|
||||||
|
if (data.from !== targetUserIdRef.current) return;
|
||||||
|
console.log('[onCallTypeChanged] New call type:', data.callType);
|
||||||
|
setCallType(data.callType);
|
||||||
|
};
|
||||||
|
|
||||||
socket.on('call_answered', onCallAnswered);
|
socket.on('call_answered', onCallAnswered);
|
||||||
socket.on('ice_candidate', onIceCandidate);
|
socket.on('ice_candidate', onIceCandidate);
|
||||||
socket.on('call_ended', onCallEnded);
|
socket.on('call_ended', onCallEnded);
|
||||||
@@ -1294,6 +1316,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
socket.on('call_unavailable', onCallUnavailable);
|
socket.on('call_unavailable', onCallUnavailable);
|
||||||
socket.on('renegotiate', onRenegotiate);
|
socket.on('renegotiate', onRenegotiate);
|
||||||
socket.on('renegotiate_answer', onRenegotiateAnswer);
|
socket.on('renegotiate_answer', onRenegotiateAnswer);
|
||||||
|
socket.on('call_type_changed', onCallTypeChanged);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off('call_answered', onCallAnswered);
|
socket.off('call_answered', onCallAnswered);
|
||||||
@@ -1303,6 +1326,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
socket.off('call_unavailable', onCallUnavailable);
|
socket.off('call_unavailable', onCallUnavailable);
|
||||||
socket.off('renegotiate', onRenegotiate);
|
socket.off('renegotiate', onRenegotiate);
|
||||||
socket.off('renegotiate_answer', onRenegotiateAnswer);
|
socket.off('renegotiate_answer', onRenegotiateAnswer);
|
||||||
|
socket.off('call_type_changed', onCallTypeChanged);
|
||||||
};
|
};
|
||||||
}, [cleanup, scheduleClose]);
|
}, [cleanup, scheduleClose]);
|
||||||
|
|
||||||
@@ -1332,11 +1356,13 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
|
|
||||||
// Sync remote video ref with remote stream (only when srcObject actually changes)
|
// Sync remote video ref with remote stream (only when srcObject actually changes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!remoteVideoRef.current) return;
|
if (!remoteVideoRef.current || !remoteStreamRef.current) return;
|
||||||
if (remoteStreamRef.current && remoteVideoRef.current.srcObject !== remoteStreamRef.current) {
|
if (remoteVideoRef.current.srcObject !== remoteStreamRef.current) {
|
||||||
|
console.log('[useEffect] Syncing remote video srcObject');
|
||||||
remoteVideoRef.current.srcObject = remoteStreamRef.current;
|
remoteVideoRef.current.srcObject = remoteStreamRef.current;
|
||||||
|
remoteVideoRef.current.play().catch(() => { });
|
||||||
}
|
}
|
||||||
});
|
}, [hasRemoteVideo, callType]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1523,24 +1549,25 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
className="relative bg-black w-full"
|
className="relative bg-black w-full"
|
||||||
style={{ aspectRatio: '16 / 9' }}
|
style={{ aspectRatio: '16 / 9' }}
|
||||||
>
|
>
|
||||||
{/* Remote video — fills container, preserves natural aspect ratio */}
|
<div className="absolute inset-0 bg-black flex items-center justify-center">
|
||||||
{hasRemoteVideo ? (
|
{/* Unified Remote video — always rendered if area is shown to keep stream alive & prevent ref-swapping issues */}
|
||||||
<video
|
<video
|
||||||
ref={remoteVideoRef}
|
ref={remoteVideoRef}
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
muted
|
muted
|
||||||
className="absolute inset-0 w-full h-full object-contain bg-black"
|
className={`absolute inset-0 w-full h-full object-contain bg-black transition-opacity duration-300 ${hasRemoteVideo ? 'opacity-100' : 'opacity-0'}`}
|
||||||
onContextMenu={(e) => { e.preventDefault(); setShowVolumeSlider(true); }}
|
onContextMenu={(e) => { e.preventDefault(); setShowVolumeSlider(true); }}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
{/* Placeholder shown when video track is muted or not yet arrived */}
|
||||||
<VideoOff size={48} className="text-zinc-500 mb-2" />
|
{!hasRemoteVideo && (
|
||||||
<span className="text-sm text-zinc-500">{displayName}</span>
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-900/50 backdrop-blur-sm z-10">
|
||||||
{/* Hidden video to keep stream alive */}
|
<VideoOff size={48} className="text-zinc-500 mb-2" />
|
||||||
<video ref={remoteVideoRef} autoPlay playsInline muted className="hidden" />
|
<span className="text-sm text-zinc-500">{displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Local video PIP (bottom-right) */}
|
{/* Local video PIP (bottom-right) */}
|
||||||
{hasLocalVideo && (
|
{hasLocalVideo && (
|
||||||
@@ -1691,26 +1718,22 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
<ChevronUp size={10} />
|
<ChevronUp size={10} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Camera toggle — only for video calls */}
|
{/* Camera toggle */}
|
||||||
{initialCallType === 'video' && (
|
<button
|
||||||
<button
|
onClick={toggleVideo}
|
||||||
onClick={toggleVideo}
|
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isVideoOff ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'
|
||||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isVideoOff ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
|
||||||
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
|
</button>
|
||||||
</button>
|
{/* Camera selector */}
|
||||||
)}
|
<button
|
||||||
{/* Camera selector — only for video calls */}
|
onClick={openCameraMenu}
|
||||||
{initialCallType === 'video' && (
|
className="w-11 h-11 rounded-full flex items-center justify-center transition-colors bg-white/10 text-white hover:bg-white/20"
|
||||||
<button
|
title={t('switchCamera')}
|
||||||
onClick={openCameraMenu}
|
>
|
||||||
className="w-11 h-11 rounded-full flex items-center justify-center transition-colors bg-white/10 text-white hover:bg-white/20"
|
<SwitchCamera size={18} />
|
||||||
title={t('switchCamera')}
|
</button>
|
||||||
>
|
|
||||||
<SwitchCamera size={18} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleScreenShare}
|
onClick={toggleScreenShare}
|
||||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isScreenSharing ? 'bg-vortex-500/30 text-vortex-400' : 'bg-white/10 text-white hover:bg-white/20'
|
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isScreenSharing ? 'bg-vortex-500/30 text-vortex-400' : 'bg-white/10 text-white hover:bg-white/20'
|
||||||
|
|||||||
@@ -186,33 +186,83 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
}
|
}
|
||||||
}, [chatMessages.length, user?.id, scrollToBottom]);
|
}, [chatMessages.length, user?.id, scrollToBottom]);
|
||||||
|
|
||||||
// Read receipts — debounced via ref to avoid excessive emits
|
// Read receipts using IntersectionObserver
|
||||||
const sentReadIdsRef = useRef<Set<string>>(new Set());
|
const sentReadIdsRef = useRef<Set<string>>(new Set());
|
||||||
useEffect(() => {
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
if (!activeChat || !user?.id) return;
|
|
||||||
// Reset tracked IDs when switching chats
|
|
||||||
sentReadIdsRef.current.clear();
|
|
||||||
}, [activeChat, user?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeChat || !user?.id) return;
|
if (!activeChat || !user?.id) return;
|
||||||
const unread = chatMessages.filter(
|
|
||||||
(m) => m.senderId !== user.id && !m.readBy?.some((r) => r.userId === user.id) && !sentReadIdsRef.current.has(m.id)
|
// Cleanup previous observer
|
||||||
);
|
if (observerRef.current) observerRef.current.disconnect();
|
||||||
if (unread.length > 0) {
|
sentReadIdsRef.current.clear();
|
||||||
const ids = unread.map((m) => m.id);
|
|
||||||
ids.forEach((id) => sentReadIdsRef.current.add(id));
|
const socket = getSocket();
|
||||||
const socket = getSocket();
|
if (!socket) return;
|
||||||
if (socket) {
|
|
||||||
socket.emit('read_messages', {
|
const observer = new IntersectionObserver(
|
||||||
chatId: activeChat,
|
(entries) => {
|
||||||
messageIds: ids,
|
const newlyReadIds: string[] = [];
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const msgId = entry.target.getAttribute('data-message-id');
|
||||||
|
if (msgId && !sentReadIdsRef.current.has(msgId)) {
|
||||||
|
newlyReadIds.push(msgId);
|
||||||
|
sentReadIdsRef.current.add(msgId);
|
||||||
|
// Stop observing once read
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (newlyReadIds.length > 0) {
|
||||||
|
console.log('[IntersectionObserver] Marking as read:', newlyReadIds);
|
||||||
|
socket.emit('read_messages', {
|
||||||
|
chatId: activeChat,
|
||||||
|
messageIds: newlyReadIds,
|
||||||
|
});
|
||||||
|
// Update local store immediately for current user
|
||||||
|
useChatStore.getState().markRead(activeChat, user.id, newlyReadIds);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: messagesContainerRef.current,
|
||||||
|
threshold: 0.1, // Message must be 10% visible
|
||||||
}
|
}
|
||||||
// Update local store immediately for current user
|
);
|
||||||
useChatStore.getState().markRead(activeChat, user.id, ids);
|
|
||||||
|
observerRef.current = observer;
|
||||||
|
|
||||||
|
// Initial observation of unread messages
|
||||||
|
const observeUnread = () => {
|
||||||
|
if (!messagesContainerRef.current) return;
|
||||||
|
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
|
||||||
|
unreadElements.forEach((el) => {
|
||||||
|
const id = el.getAttribute('data-message-id');
|
||||||
|
if (id && !sentReadIdsRef.current.has(id)) {
|
||||||
|
observer.observe(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
observeUnread();
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [activeChat, user?.id]);
|
||||||
|
|
||||||
|
// Re-run observation when messages change (to catch new arrivals)
|
||||||
|
useEffect(() => {
|
||||||
|
if (observerRef.current && !isLoadingMessages) {
|
||||||
|
if (!messagesContainerRef.current) return;
|
||||||
|
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
|
||||||
|
unreadElements.forEach((el) => {
|
||||||
|
const id = el.getAttribute('data-message-id');
|
||||||
|
if (id && !sentReadIdsRef.current.has(id)) {
|
||||||
|
observerRef.current?.observe(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [chatMessages.length, activeChat, user?.id]);
|
}, [chatMessages.length, isLoadingMessages]);
|
||||||
|
|
||||||
// Scroll detection
|
// Scroll detection
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
@@ -769,7 +819,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
new Date(msg.createdAt).toDateString() !== new Date(prevMsg.createdAt).toDateString();
|
new Date(msg.createdAt).toDateString() !== new Date(prevMsg.createdAt).toDateString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} id={`msg-${msg.id}`} className="transition-colors duration-500">
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
id={`msg-${msg.id}`}
|
||||||
|
data-message-id={msg.id}
|
||||||
|
className={`transition-colors duration-500 ${msg.senderId !== user?.id && !msg.readBy?.some(r => r.userId === user?.id) ? 'unread-detector' : ''}`}
|
||||||
|
>
|
||||||
{showDate && (
|
{showDate && (
|
||||||
<div className="flex justify-center my-4">
|
<div className="flex justify-center my-4">
|
||||||
<span className="px-3 py-1 rounded-full text-xs text-zinc-400 glass">
|
<span className="px-3 py-1 rounded-full text-xs text-zinc-400 glass">
|
||||||
|
|||||||
@@ -126,6 +126,12 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
|||||||
localStreamRef.current.getTracks().forEach(track => pc.addTrack(track, localStreamRef.current!));
|
localStreamRef.current.getTracks().forEach(track => pc.addTrack(track, localStreamRef.current!));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always ensure a video m-line exists for future camera/screen share stability
|
||||||
|
const hasVideoTransceiver = pc.getTransceivers().some(t => t.receiver?.track?.kind === 'video');
|
||||||
|
if (!hasVideoTransceiver) {
|
||||||
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||||
|
}
|
||||||
|
|
||||||
pc.onicecandidate = (e) => {
|
pc.onicecandidate = (e) => {
|
||||||
if (e.candidate) {
|
if (e.candidate) {
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
@@ -137,15 +143,17 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
|||||||
if (!remoteStream.getTracks().includes(e.track)) {
|
if (!remoteStream.getTracks().includes(e.track)) {
|
||||||
remoteStream.addTrack(e.track);
|
remoteStream.addTrack(e.track);
|
||||||
}
|
}
|
||||||
const hasVid = remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted);
|
const checkHasVideo = () => {
|
||||||
peerState.hasVideo = hasVid;
|
return remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted);
|
||||||
|
};
|
||||||
|
peerState.hasVideo = checkHasVideo();
|
||||||
|
|
||||||
e.track.onunmute = () => {
|
e.track.onunmute = () => {
|
||||||
peerState.hasVideo = remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted);
|
peerState.hasVideo = checkHasVideo();
|
||||||
forceUpdate(n => n + 1);
|
forceUpdate(n => n + 1);
|
||||||
};
|
};
|
||||||
e.track.onmute = () => {
|
e.track.onmute = () => {
|
||||||
peerState.hasVideo = remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted);
|
peerState.hasVideo = checkHasVideo();
|
||||||
forceUpdate(n => n + 1);
|
forceUpdate(n => n + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,10 +254,12 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
|||||||
if (videoTrack && localStreamRef.current) {
|
if (videoTrack && localStreamRef.current) {
|
||||||
localStreamRef.current.addTrack(videoTrack);
|
localStreamRef.current.addTrack(videoTrack);
|
||||||
// Add to all peer connections
|
// Add to all peer connections
|
||||||
for (const [, peer] of peersRef.current) {
|
for (const [targetUserId, peer] of peersRef.current) {
|
||||||
peer.pc.addTrack(videoTrack, localStreamRef.current);
|
peer.pc.addTrack(videoTrack, localStreamRef.current);
|
||||||
const offer = await peer.pc.createOffer();
|
const offer = await peer.pc.createOffer();
|
||||||
await peer.pc.setLocalDescription(offer);
|
await peer.pc.setLocalDescription(offer);
|
||||||
|
const socket = getSocket();
|
||||||
|
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
|
||||||
}
|
}
|
||||||
setIsVideoOff(false);
|
setIsVideoOff(false);
|
||||||
}
|
}
|
||||||
@@ -864,15 +874,13 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
|||||||
<ChevronUp size={10} />
|
<ChevronUp size={10} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Camera — only for video calls */}
|
{/* Camera toggle */}
|
||||||
{initialCallType === 'video' && (
|
<button
|
||||||
<button
|
onClick={toggleVideo}
|
||||||
onClick={toggleVideo}
|
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isVideoOff ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
|
||||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isVideoOff ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
|
>
|
||||||
>
|
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
|
||||||
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleScreenShare}
|
onClick={toggleScreenShare}
|
||||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isScreenSharing ? 'bg-vortex-500/30 text-vortex-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
|
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isScreenSharing ? 'bg-vortex-500/30 text-vortex-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
clearAttachment();
|
clearAttachment();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Ошибка загрузки файла:', e);
|
console.error('Ошибка загрузки файла:', e);
|
||||||
|
alert(t('uploadError'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,9 +204,10 @@ const translations = {
|
|||||||
registerNow: 'Зарегистрироваться',
|
registerNow: 'Зарегистрироваться',
|
||||||
loginNow: 'Войти',
|
loginNow: 'Войти',
|
||||||
// File/upload
|
// File/upload
|
||||||
fileTooLarge: 'Файл слишком большой (макс. 50МБ)',
|
fileTooLarge: 'Файл слишком большой (макс. 200МБ)',
|
||||||
dropFileHere: 'Отпустите файл здесь',
|
dropFileHere: 'Отпустите файл здесь',
|
||||||
uploading: 'Загрузка...',
|
uploading: 'Загрузка...',
|
||||||
|
uploadError: 'Ошибка загрузки файла',
|
||||||
changePhoto: 'Сменить фото',
|
changePhoto: 'Сменить фото',
|
||||||
remove: 'Удалить',
|
remove: 'Удалить',
|
||||||
// Formatting
|
// Formatting
|
||||||
@@ -449,9 +450,10 @@ const translations = {
|
|||||||
passwordRequirements: 'At least 8 characters, letters and numbers',
|
passwordRequirements: 'At least 8 characters, letters and numbers',
|
||||||
registerNow: 'Register',
|
registerNow: 'Register',
|
||||||
loginNow: 'Login',
|
loginNow: 'Login',
|
||||||
fileTooLarge: 'File too large (max 50MB)',
|
fileTooLarge: 'File too large (max 200MB)',
|
||||||
dropFileHere: 'Drop file here',
|
dropFileHere: 'Drop file here',
|
||||||
uploading: 'Uploading...',
|
uploading: 'Uploading...',
|
||||||
|
uploadError: 'File upload error',
|
||||||
changePhoto: 'Change photo',
|
changePhoto: 'Change photo',
|
||||||
remove: 'Remove',
|
remove: 'Remove',
|
||||||
formatBold: 'Bold',
|
formatBold: 'Bold',
|
||||||
|
|||||||
@@ -168,8 +168,8 @@ export interface FriendshipStatus {
|
|||||||
/** Audio file extensions recognized by the app. */
|
/** Audio file extensions recognized by the app. */
|
||||||
export const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.wma'] as const;
|
export const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.wma'] as const;
|
||||||
|
|
||||||
/** Max file size for uploads (50MB). */
|
/** Max file size for uploads (200MB). */
|
||||||
export const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
export const MAX_FILE_SIZE = 200 * 1024 * 1024;
|
||||||
|
|
||||||
/** Max avatar size (5MB). */
|
/** Max avatar size (5MB). */
|
||||||
export const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
|
export const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
|
||||||
|
|||||||
@@ -170,11 +170,24 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
updateMessage: (message) => {
|
updateMessage: (message) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const chatMessages = state.messages[message.chatId] || [];
|
const chatMessages = state.messages[message.chatId] || [];
|
||||||
|
const updatedMessages = chatMessages.map((m) => (m.id === message.id ? message : m));
|
||||||
|
|
||||||
|
const updatedChats = state.chats.map((chat) => {
|
||||||
|
if (chat.id === message.chatId) {
|
||||||
|
return {
|
||||||
|
...chat,
|
||||||
|
messages: chat.messages?.map((m) => (m.id === message.id ? message : m)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return chat;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: {
|
messages: {
|
||||||
...state.messages,
|
...state.messages,
|
||||||
[message.chatId]: chatMessages.map((m) => (m.id === message.id ? message : m)),
|
[message.chatId]: updatedMessages,
|
||||||
},
|
},
|
||||||
|
chats: updatedChats,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -280,24 +293,38 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
addReaction: (messageId, chatId, userId, username, emoji) => {
|
addReaction: (messageId, chatId, userId, username, emoji) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const chatMessages = state.messages[chatId] || [];
|
const chatMessages = state.messages[chatId] || [];
|
||||||
|
const updateMsg = (m: Message) => {
|
||||||
|
if (m.id === messageId) {
|
||||||
|
const exists = m.reactions.some((r) => r.userId === userId && r.emoji === emoji);
|
||||||
|
if (exists) return m;
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
reactions: [
|
||||||
|
...m.reactions,
|
||||||
|
{ id: `${messageId}-${userId}-${emoji}`, emoji, userId, user: { id: userId, username, displayName: username } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedMessages = chatMessages.map(updateMsg);
|
||||||
|
const updatedChats = state.chats.map((chat) => {
|
||||||
|
if (chat.id === chatId) {
|
||||||
|
return {
|
||||||
|
...chat,
|
||||||
|
messages: chat.messages?.map(updateMsg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return chat;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: {
|
messages: {
|
||||||
...state.messages,
|
...state.messages,
|
||||||
[chatId]: chatMessages.map((m) => {
|
[chatId]: updatedMessages,
|
||||||
if (m.id === messageId) {
|
|
||||||
const exists = m.reactions.some((r) => r.userId === userId && r.emoji === emoji);
|
|
||||||
if (exists) return m;
|
|
||||||
return {
|
|
||||||
...m,
|
|
||||||
reactions: [
|
|
||||||
...m.reactions,
|
|
||||||
{ id: `${messageId}-${userId}-${emoji}`, emoji, userId, user: { id: userId, username, displayName: username } },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
|
chats: updatedChats,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -305,19 +332,33 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
removeReaction: (messageId, chatId, userId, emoji) => {
|
removeReaction: (messageId, chatId, userId, emoji) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const chatMessages = state.messages[chatId] || [];
|
const chatMessages = state.messages[chatId] || [];
|
||||||
|
const updateMsg = (m: Message) => {
|
||||||
|
if (m.id === messageId) {
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
reactions: m.reactions.filter((r) => !(r.userId === userId && r.emoji === emoji)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedMessages = chatMessages.map(updateMsg);
|
||||||
|
const updatedChats = state.chats.map((chat) => {
|
||||||
|
if (chat.id === chatId) {
|
||||||
|
return {
|
||||||
|
...chat,
|
||||||
|
messages: chat.messages?.map(updateMsg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return chat;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: {
|
messages: {
|
||||||
...state.messages,
|
...state.messages,
|
||||||
[chatId]: chatMessages.map((m) => {
|
[chatId]: updatedMessages,
|
||||||
if (m.id === messageId) {
|
|
||||||
return {
|
|
||||||
...m,
|
|
||||||
reactions: m.reactions.filter((r) => !(r.userId === userId && r.emoji === emoji)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
|
chats: updatedChats,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -326,24 +367,34 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const currentUserId = useAuthStore.getState().user?.id;
|
const currentUserId = useAuthStore.getState().user?.id;
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const chatMessages = state.messages[chatId] || [];
|
const chatMessages = state.messages[chatId] || [];
|
||||||
|
const updateMsg = (m: Message) => {
|
||||||
|
if (messageIds.includes(m.id)) {
|
||||||
|
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
|
||||||
|
if (alreadyRead) return m;
|
||||||
|
return { ...m, readBy: [...(m.readBy || []), { userId }] };
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedMessages = chatMessages.map(updateMsg);
|
||||||
|
|
||||||
|
const updatedChats = state.chats.map((chat) => {
|
||||||
|
if (chat.id === chatId) {
|
||||||
|
const updatedLastMessages = chat.messages?.map(updateMsg);
|
||||||
|
if (userId === currentUserId) {
|
||||||
|
return { ...chat, messages: updatedLastMessages, unreadCount: 0 };
|
||||||
|
}
|
||||||
|
return { ...chat, messages: updatedLastMessages };
|
||||||
|
}
|
||||||
|
return chat;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: {
|
messages: {
|
||||||
...state.messages,
|
...state.messages,
|
||||||
[chatId]: chatMessages.map((m) => {
|
[chatId]: updatedMessages,
|
||||||
if (messageIds.includes(m.id)) {
|
|
||||||
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
|
|
||||||
if (alreadyRead) return m;
|
|
||||||
return { ...m, readBy: [...(m.readBy || []), { userId }] };
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
chats: state.chats.map((chat) => {
|
chats: updatedChats,
|
||||||
if (chat.id === chatId && userId === currentUserId) {
|
|
||||||
return { ...chat, unreadCount: 0 };
|
|
||||||
}
|
|
||||||
return chat;
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
client_max_body_size 200M;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|||||||
Reference in New Issue
Block a user