Групповые звонки

This commit is contained in:
Халимов Рустам
2026-03-14 23:27:55 +03:00
parent be0a50b702
commit 3b990f8619
10 changed files with 443 additions and 39 deletions

View File

@@ -60,14 +60,14 @@ public sealed class ChatCreatedDomainEventHandler : INotificationHandler<ChatCre
var payload = new
{
chat.Id,
chat.Type,
chat.Name,
chat.Avatar,
chat.CreatedAt,
Members = members,
Messages = new List<object>(),
UnreadCount = 0
id = chat.Id,
type = chat.Type.ToString().ToLowerInvariant(),
name = chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
avatar = chat.Avatar,
createdAt = chat.CreatedAt,
members = members,
messages = new List<object>(),
unreadCount = 0
};
if (targetUserId != null)

View File

@@ -363,6 +363,7 @@ public sealed class ChatHub : Hub
var userInfo = new ParticipantInfo(userId, username, displayName, avatar);
var participants = _groupCallParticipants.GetOrAdd(chatId, _ => new ConcurrentDictionary<string, ParticipantInfo>());
var isFirst = participants.IsEmpty;
participants.TryAdd(userId, userInfo);
// Notify others
@@ -380,6 +381,17 @@ public sealed class ChatHub : Hub
chatId = chatId,
participants = others
});
if (isFirst)
{
await Clients.Group(chatId).SendAsync("group_call_incoming", new
{
chatId = chatId,
from = userId,
callerInfo = userInfo,
callType = request.CallType
});
}
}
[HubMethodName("group_call_leave")]
@@ -393,6 +405,7 @@ public sealed class ChatHub : Hub
if (participants.IsEmpty)
{
_groupCallParticipants.TryRemove(chatId, out _);
await Clients.Group(chatId).SendAsync("group_call_ended", new { chatId = chatId });
}
}

View File

@@ -0,0 +1,130 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1024.000000pt" height="558.000000pt" viewBox="0 0 1024.000000 558.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,558.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M5030 5120 c-8 -5 -10 -10 -5 -10 6 0 17 5 25 10 8 5 11 10 5 10 -5
0 -17 -5 -25 -10z"/>
<path d="M4990 5100 c-8 -5 -12 -12 -9 -15 4 -3 12 1 19 10 14 17 11 19 -10 5z"/>
<path d="M4910 5051 c-25 -15 -144 -86 -265 -156 -308 -178 -319 -186 -344
-237 -20 -43 -21 -56 -21 -487 0 -485 -2 -468 62 -527 33 -30 170 -116 402
-250 158 -91 157 -91 144 -53 -7 20 -15 28 -25 25 -8 -3 -12 -2 -9 3 3 5 20
14 38 20 31 12 59 23 118 49 14 6 57 20 97 32 40 11 75 25 79 31 4 8 0 8 -14
0 -47 -25 -77 -13 -279 110 -109 66 -226 136 -261 156 -34 19 -69 44 -77 56
-13 18 -15 75 -15 352 0 181 4 336 9 344 5 8 96 67 203 131 106 65 192 123
190 129 -1 7 3 11 10 8 7 -2 26 2 41 10 23 12 26 17 17 29 -14 16 -34 8 -26
-11 3 -8 -2 -11 -15 -8 -23 7 -24 10 -1 31 9 10 13 14 8 10 -16 -12 -46 12
-46 36 0 13 -7 29 -15 36 -18 15 -20 34 -2 27 8 -3 13 3 13 18 0 13 -4 23 -9
22 -14 -3 -28 41 -15 49 7 4 8 3 4 -4 -5 -8 0 -11 16 -8 34 7 35 7 13 16 -18
8 -18 9 5 9 14 1 32 6 40 12 13 9 13 10 -2 6 -9 -3 -18 -2 -20 3 -2 5 -23 -3
-48 -19z m-35 -41 c-3 -5 -12 -10 -18 -10 -7 0 -6 4 3 10 19 12 23 12 15 0z
m-1 -28 c3 -5 -1 -9 -9 -9 -8 0 -12 4 -9 9 3 4 7 8 9 8 2 0 6 -4 9 -8z"/>
<path d="M5050 4570 c-114 -24 -215 -101 -266 -206 -26 -53 -29 -69 -29 -159
0 -89 3 -106 28 -156 26 -54 27 -58 12 -99 -8 -23 -21 -64 -29 -90 l-14 -48
96 33 97 33 46 -18 c176 -70 377 7 460 175 36 73 49 175 30 235 -11 34 -11 34
-7 -15 3 -28 1 -42 -3 -31 -4 11 -18 21 -31 23 -24 4 -49 29 -50 51 0 8 -5 12
-12 10 -6 -2 -19 2 -27 9 -36 30 -57 51 -75 76 -10 15 -24 27 -30 27 -13 0
-86 73 -86 87 0 19 -37 44 -56 37 -18 -5 -18 -5 0 10 11 9 22 12 26 7 3 -6 20
-6 40 -1 19 4 26 8 15 9 -11 1 -36 3 -55 5 -19 3 -55 1 -80 -4z m-73 -350 c10
-20 10 -29 -1 -45 -16 -24 -51 -31 -72 -15 -17 15 -18 62 -2 78 20 20 61 10
75 -18z m181 18 c7 -7 12 -24 12 -38 0 -14 -5 -31 -12 -38 -7 -7 -24 -12 -38
-12 -14 0 -31 5 -38 12 -7 7 -12 24 -12 38 0 14 5 31 12 38 7 7 24 12 38 12
14 0 31 -5 38 -12z m180 0 c7 -7 12 -24 12 -38 0 -34 -17 -50 -52 -50 -37 0
-57 38 -40 75 13 27 58 35 80 13z"/>
<path d="M5395 4440 c10 -11 20 -20 23 -20 3 0 -3 9 -13 20 -10 11 -20 20 -23
20 -3 0 3 -9 13 -20z"/>
<path d="M5457 4321 c-4 -17 -3 -21 5 -13 5 5 8 16 6 23 -3 8 -7 3 -11 -10z"/>
<path d="M3819 3994 c-189 -113 -356 -217 -371 -231 -14 -14 -31 -37 -37 -52
-8 -18 -10 -161 -9 -448 l3 -422 105 63 105 62 3 315 c2 286 4 318 20 331 20
18 504 316 527 324 12 5 15 29 15 135 0 71 -4 129 -8 129 -4 0 -163 -93 -353
-206z"/>
<path d="M5710 3998 c-6 -156 -6 -158 -34 -187 -40 -42 -44 -97 -16 -210 12
-50 24 -91 27 -91 3 0 44 23 92 51 165 99 180 126 181 311 0 113 -9 161 -23
126 -4 -10 -6 -10 -6 -1 -1 7 -8 10 -16 7 -8 -4 -21 -2 -28 4 -7 6 -28 10 -46
10 -18 -1 -36 3 -39 9 -13 18 -42 23 -42 7 0 -12 -3 -12 -19 3 -13 12 -21 35
-23 68 -2 28 -6 -21 -8 -107z"/>
<path d="M3645 2868 c-11 -6 -174 -103 -363 -215 -293 -174 -346 -210 -370
-245 l-27 -42 -3 -443 c-2 -419 -2 -446 17 -487 24 -55 34 -62 346 -253 452
-277 442 -272 519 -249 22 7 142 75 266 151 124 75 272 166 330 200 184 110
193 125 198 320 4 137 -8 395 -16 345 -4 -28 -22 -52 -22 -31 0 11 -3 11 -15
1 -8 -7 -15 -9 -15 -4 0 5 -4 3 -9 -4 -6 -11 -14 -10 -40 6 -17 10 -28 22 -24
25 3 4 -3 7 -15 7 -12 0 -22 5 -22 11 0 6 -9 8 -20 4 -11 -4 -20 -2 -20 4 0
17 -27 13 -34 -5 -3 -9 -6 -98 -6 -198 0 -170 -1 -185 -21 -210 -20 -25 -475
-304 -532 -325 -17 -7 -36 -7 -52 -1 -52 20 -501 293 -527 321 l-28 29 0 329
c0 299 2 331 18 349 16 20 422 273 567 355 39 21 62 36 53 33 -10 -3 -18 -1
-18 4 0 6 6 10 13 10 7 0 5 5 -5 11 -9 5 -15 16 -12 23 3 8 0 33 -6 55 -9 34
-8 48 4 71 13 27 13 31 -2 40 -16 9 -16 9 1 10 9 0 17 -5 17 -11 0 -5 6 -7 13
-3 6 4 3 -3 -8 -17 -11 -13 -13 -19 -5 -13 8 5 27 10 42 9 15 0 24 3 21 8 -2
4 -10 6 -16 4 -6 -2 -19 5 -29 15 -21 21 -110 25 -143 6z"/>
<path d="M3766 2788 c3 -5 10 -6 15 -3 13 9 11 12 -6 12 -8 0 -12 -4 -9 -9z"/>
<path d="M6440 2610 c-8 -5 -10 -10 -5 -10 6 0 17 5 25 10 8 5 11 10 5 10 -5
0 -17 -5 -25 -10z"/>
<path d="M6280 2515 c-7 -8 -16 -15 -21 -15 -6 0 -81 -44 -147 -86 -9 -5 43
-138 70 -180 18 -27 31 -53 30 -57 -3 -9 -1 -8 75 40 29 18 54 33 57 33 11 0
126 73 122 78 -3 3 -13 -1 -23 -9 -15 -12 -16 -12 -10 4 3 9 2 17 -3 17 -6 0
-10 -9 -10 -21 0 -13 -4 -18 -12 -13 -6 4 -8 3 -5 -3 4 -6 0 -14 -9 -17 -23
-9 -26 -8 -18 7 4 7 3 9 -2 4 -5 -5 -9 -13 -9 -19 0 -16 -40 -28 -56 -16 -11
8 -11 9 2 5 9 -3 22 -1 30 4 12 7 11 9 -2 9 -9 0 -16 7 -16 15 0 11 4 12 14 4
9 -8 14 -7 19 4 3 9 3 20 0 25 -8 14 21 35 38 29 10 -4 13 -1 9 9 -3 8 -19 14
-38 14 l-33 0 5 43 c4 23 10 44 15 46 5 1 -6 14 -24 28 -18 14 -25 23 -15 20
9 -4 17 -2 17 3 0 15 -36 12 -50 -5z"/>
<path d="M6493 2333 c9 -2 25 -2 35 0 9 3 1 5 -18 5 -19 0 -27 -2 -17 -5z"/>
<path d="M3579 2286 c-71 -29 -110 -56 -153 -109 -89 -108 -107 -274 -43 -397
l24 -45 -30 -88 c-16 -49 -27 -90 -25 -92 2 -2 46 9 98 25 l95 29 57 -21 c73
-26 199 -22 268 9 66 29 142 103 181 175 32 60 34 68 34 163 0 93 -2 105 -32
165 -18 36 -32 58 -31 50 2 -21 -14 -53 -21 -43 -3 4 -19 16 -36 25 -16 9 -33
28 -37 43 -3 15 -18 37 -33 51 -14 13 -21 24 -15 24 8 0 8 6 0 20 -11 20 -40
28 -40 11 0 -10 -14 -9 -42 4 -10 4 -15 11 -12 16 3 5 -29 9 -73 9 -58 0 -92
-6 -134 -24z m-10 -310 c22 -26 9 -70 -23 -81 -24 -8 -31 -6 -47 14 -23 28
-24 45 -3 65 19 20 58 21 73 2z m189 2 c18 -18 14 -53 -8 -73 -44 -40 -107 25
-69 71 14 17 60 19 77 2z m184 -5 c22 -20 23 -41 1 -65 -22 -25 -59 -23 -77 4
-20 28 -20 43 0 62 20 21 53 20 76 -1z"/>
<path d="M3893 2255 c0 -8 11 -25 24 -37 19 -17 23 -18 17 -5 -8 22 4 22 30 0
21 -18 21 -18 4 2 -9 11 -30 28 -46 37 -24 14 -29 15 -29 3z"/>
<path d="M5792 2223 c-112 -69 -122 -97 -122 -335 l0 -179 130 -63 130 -63 0
223 c0 123 -5 247 -11 276 -14 72 -49 178 -58 178 -4 0 -35 -17 -69 -37z"/>
<path d="M3956 2167 c3 -10 9 -15 12 -12 3 3 0 11 -7 18 -10 9 -11 8 -5 -6z"/>
<path d="M6452 2049 c-114 -15 -225 -96 -279 -207 -25 -51 -28 -68 -28 -158 0
-90 3 -108 28 -160 l28 -59 -30 -85 c-20 -58 -26 -86 -18 -88 7 -1 51 11 100
27 83 28 89 29 124 15 277 -116 574 158 489 450 -10 32 -19 56 -22 53 -2 -2 0
-16 5 -30 8 -21 7 -26 -10 -30 -10 -3 -21 -1 -24 4 -3 4 -12 6 -20 3 -8 -4
-26 7 -42 24 -47 50 -54 62 -47 74 4 7 3 8 -4 4 -7 -4 -18 -4 -24 1 -7 4 -19
8 -27 10 -7 1 -26 13 -40 27 -15 14 -32 26 -38 26 -19 0 -26 20 -13 35 10 12
9 15 -5 15 -16 0 -15 3 5 20 15 13 31 18 44 15 20 -6 20 -5 2 6 -10 6 -36 9
-57 6 -21 -3 -39 -2 -41 2 -2 3 -27 3 -56 0z m-92 -339 c11 -11 20 -23 20 -28
0 -20 -34 -52 -55 -52 -28 0 -56 35 -48 59 17 45 50 54 83 21z m181 10 c22
-12 26 -59 7 -78 -7 -7 -24 -12 -38 -12 -32 0 -50 17 -50 46 0 44 40 65 81 44z
m193 -6 c32 -31 11 -84 -33 -84 -45 0 -69 44 -45 79 18 25 56 27 78 5z"/>
<path d="M7102 1770 c0 -14 2 -19 5 -12 2 6 2 18 0 25 -3 6 -5 1 -5 -13z"/>
<path d="M7098 1645 c-5 -55 -8 -152 -8 -216 0 -77 -4 -121 -12 -131 -21 -27
-541 -329 -568 -330 -14 -1 -61 21 -105 49 -44 27 -159 96 -255 152 -96 56
-185 113 -197 127 -20 22 -23 36 -23 107 l0 82 -78 4 c-103 6 -129 6 -182 -4
-25 -4 -52 -8 -60 -10 -8 -1 -18 3 -22 9 -5 7 -8 6 -8 -4 0 -13 -1 -13 -10 0
-8 13 -11 12 -23 -4 -14 -19 -14 -19 -25 -1 -8 15 -14 17 -32 9 -20 -9 -13
-15 76 -69 l99 -60 5 -75 c6 -91 30 -142 85 -180 51 -36 626 -385 677 -411 52
-26 113 -24 169 6 55 29 632 379 673 408 15 11 39 43 52 71 23 49 24 60 24
273 0 145 -4 224 -11 228 -5 4 -19 -3 -30 -16 -10 -12 -19 -18 -19 -13 0 5
-11 10 -25 10 -14 1 -25 -3 -25 -8 0 -6 -7 -4 -17 3 -13 11 -17 11 -20 2 -5
-15 -33 -17 -33 -3 0 6 -4 10 -10 10 -5 0 -10 -9 -10 -20 0 -11 -5 -20 -11
-20 -7 0 -9 10 -7 25 3 14 1 25 -3 25 -5 0 -10 -12 -11 -27 -1 -16 -4 1 -7 37
l-6 65 -7 -100z"/>
<path d="M5430 1496 c0 -2 7 -7 16 -10 8 -3 12 -2 9 4 -6 10 -25 14 -25 6z"/>
<path d="M4140 1030 c0 -5 5 -10 10 -10 6 0 10 5 10 10 0 6 -4 10 -10 10 -5 0
-10 -4 -10 -10z"/>
<path d="M4360 886 c0 -2 7 -7 16 -10 8 -3 12 -2 9 4 -6 10 -25 14 -25 6z"/>
<path d="M5005 740 c3 -5 8 -10 11 -10 2 0 4 5 4 10 0 6 -5 10 -11 10 -5 0 -7
-4 -4 -10z"/>
<path d="M4837 589 c7 -7 15 -10 18 -7 3 3 -2 9 -12 12 -14 6 -15 5 -6 -5z"/>
<path d="M4874 571 c7 -11 46 -35 46 -28 0 3 -12 12 -26 22 -15 9 -23 12 -20
6z"/>
<path d="M4990 505 c0 -16 70 -55 97 -54 10 0 -1 7 -25 16 -24 8 -41 18 -38
21 7 6 -13 32 -25 32 -5 0 -9 -7 -9 -15z m25 -5 c3 -5 1 -10 -4 -10 -6 0 -11
5 -11 10 0 6 2 10 4 10 3 0 8 -4 11 -10z"/>
<path d="M5238 501 c-20 -10 -40 -16 -44 -12 -4 3 -4 1 -1 -5 4 -7 -6 -14 -26
-19 -20 -5 -26 -9 -15 -12 14 -4 128 52 128 63 0 6 -1 5 -42 -15z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

View File

@@ -268,7 +268,15 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
localStreamRef.current.addTrack(videoTrack);
// Add to all peer connections
for (const [targetUserId, peer] of peersRef.current) {
peer.pc.addTrack(videoTrack, localStreamRef.current);
const transceiver = peer.pc.getTransceivers().find(t => t.receiver?.track?.kind === 'video' || t.sender?.track?.kind === 'video');
if (transceiver) {
await transceiver.sender.replaceTrack(videoTrack).catch(() => {});
if (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive') {
transceiver.direction = 'sendrecv';
}
} else {
peer.pc.addTrack(videoTrack, localStreamRef.current);
}
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
@@ -291,13 +299,17 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
screenStreamRef.current.getTracks().forEach(t => t.stop());
screenStreamRef.current = null;
}
// Replace screen track with null on all peers
// Replace screen track with camera track on all peers
for (const [targetUserId, peer] of peersRef.current) {
const sender = peer.pc.getSenders().find(s => s.track?.kind === 'video');
if (sender) {
await sender.replaceTrack(null);
const transceiver = peer.pc.getTransceivers().find(t => t.sender === sender);
if (transceiver) transceiver.direction = 'recvonly';
const transceiver = peer.pc.getTransceivers().find(t => t.receiver?.track?.kind === 'video' || t.sender?.track?.kind === 'video');
if (transceiver) {
const camTrack = localStreamRef.current?.getVideoTracks().find(t => t.enabled);
await transceiver.sender.replaceTrack(camTrack || null).catch(() => {});
if (!camTrack) {
transceiver.direction = 'recvonly';
} else {
transceiver.direction = 'sendrecv';
}
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
@@ -317,24 +329,19 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
const screenTrack = screenStream.getVideoTracks()[0];
for (const [targetUserId, peer] of peersRef.current) {
const sender = peer.pc.getSenders().find(s => s.track?.kind === 'video');
if (sender) {
await sender.replaceTrack(screenTrack);
const transceiver = peer.pc.getTransceivers().find(t => t.sender === sender);
if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) {
const transceiver = peer.pc.getTransceivers().find(t => t.receiver?.track?.kind === 'video' || t.sender?.track?.kind === 'video');
if (transceiver) {
await transceiver.sender.replaceTrack(screenTrack).catch(() => {});
if (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive') {
transceiver.direction = 'sendrecv';
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
}
} else {
peer.pc.addTrack(screenTrack, localStreamRef.current || screenStream);
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
}
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
}
screenTrack.onended = () => {
@@ -540,6 +547,7 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
if (data.chatId !== chatId) return;
const newMap = new Map<string, ParticipantInfo>();
for (const p of data.participants) {
if (p.id === useAuthStore.getState().user?.id) continue;
newMap.set(p.id, p);
if (p.isSharingScreen && p.id !== useAuthStore.getState().user?.id) {
setSharingUserId(p.id);
@@ -557,7 +565,7 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
};
const onUserJoined = (data: { chatId: string; userId: string; userInfo: ParticipantInfo }) => {
if (data.chatId !== chatId) return;
if (data.chatId !== chatId || data.userId === useAuthStore.getState().user?.id) return;
setParticipants(prev => {
const next = new Map(prev);
next.set(data.userId, data.userInfo);

View File

@@ -162,7 +162,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
}, [storyIndex, userIndex]);
useEffect(() => {
if (paused || !currentStory || isVideo) return;
if (paused || showReactions || showReplyInput || showViewers || !currentStory || isVideo) return;
const duration = STORY_DURATION;
const step = (TICK / duration) * 100;
@@ -180,12 +180,12 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [storyIndex, userIndex, paused, goNext, isVideo, currentStory]);
}, [storyIndex, userIndex, paused, showReactions, showReplyInput, showViewers, goNext, isVideo, currentStory]);
// Handle video progress
useEffect(() => {
const video = videoRef.current;
if (!video || !isVideo || paused) return;
if (!video || !isVideo || paused || showReactions || showReplyInput || showViewers) return;
const interval = setInterval(() => {
if (video.duration) {
@@ -195,7 +195,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
}, TICK);
return () => clearInterval(interval);
}, [isVideo, paused, storyIndex, userIndex]);
}, [isVideo, paused, showReactions, showReplyInput, showViewers, storyIndex, userIndex]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Calendar, AtSign, Edit3, Check, Loader2, Image as ImageIcon, FileText, Link as LinkIcon, Download, ExternalLink, Play, UserPlus, UserMinus, UserCheck, Clock, Search, ChevronLeft, Eye, Users, Video, Camera, Trash2 } from 'lucide-react';
import { X, Calendar, AtSign, Edit3, Check, Loader2, Image as ImageIcon, FileText, Link as LinkIcon, Download, ExternalLink, Play, UserPlus, UserMinus, UserCheck, Clock, Search, ChevronLeft, Eye, Users, Video, Camera, Trash2, MessageSquare, Phone, Bell, BellOff, MoreHorizontal } from 'lucide-react';
import Cropper from 'react-easy-crop';
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
@@ -14,6 +14,8 @@ import { useStoryStore } from '../stores/useStoryStore';
import { getMediaUrl } from '../lib/utils';
import { getCroppedImg } from '../lib/imageCrop';
import DatePicker from './DatePicker';
import { useChatStore } from '../stores/chatStore';
import { toggleMuteChat, isChatMuted } from '../lib/sounds';
interface UserProfileProps {
userId: string;
@@ -31,6 +33,48 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
const [profile, setProfile] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<MediaTab>('publications');
const { chats, addChat, setActiveChat, loadMessages } = useChatStore();
const personalChat = chats.find(c => c.type === 'personal' && c.members.some(m => m.user.id === userId));
const commonGroups = chats.filter(c => c.type === 'group' && c.members.some(m => m.user.id === userId));
const [isMutedLocally, setIsMutedLocally] = useState(() => personalChat ? isChatMuted(personalChat.id) : false);
useEffect(() => {
if (personalChat) setIsMutedLocally(isChatMuted(personalChat.id));
}, [personalChat?.id]);
const handleOpenChat = async () => {
try {
let targetChatId = personalChat?.id;
if (!targetChatId) {
const chat = await api.createPersonalChat(userId);
addChat(chat);
const socket = getSocket();
if (socket) socket.emit('join_chat', chat.id);
targetChatId = chat.id;
}
setActiveChat(targetChatId);
loadMessages(targetChatId);
onClose();
} catch (e) {
console.error(e);
}
};
const handleToggleMute = () => {
if (!personalChat) return;
const muted = toggleMuteChat(personalChat.id);
setIsMutedLocally(muted);
};
const handleStartCall = (type: 'voice' | 'video') => {
if (profile) {
window.dispatchEvent(new CustomEvent('START_CALL', { detail: { targetUser: profile, type } }));
onClose();
}
};
// Shared media state
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
@@ -507,6 +551,48 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
)}
</div>
)}
{/* Быстрые действия (только для других пользователей) */}
{!isSelf && (
<div className="flex w-full items-center justify-center gap-2 mt-6 px-4">
<button
onClick={handleOpenChat}
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 hover:bg-white/10 transition-colors border border-white/5"
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
<MessageSquare size={16} className="text-white" />
</div>
<span className="text-[11px] font-medium text-white">{t('chat')}</span>
</button>
<button
onClick={handleToggleMute}
disabled={!personalChat}
className={`flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl transition-colors border ${!personalChat ? 'opacity-50 cursor-not-allowed bg-white/5 border-white/5' : 'bg-white/5 hover:bg-white/10 border-white/5'}`}
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
{isMutedLocally ? <BellOff size={16} className="text-white" /> : <Bell size={16} className="text-white" />}
</div>
<span className="text-[11px] font-medium text-white shadow-sm">{isMutedLocally ? t('enableSound') : t('disableSound')}</span>
</button>
<button
onClick={() => handleStartCall('voice')}
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 hover:bg-white/10 transition-colors border border-white/5"
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
<Phone size={16} className="text-white" />
</div>
<span className="text-[11px] font-medium text-white">{t('call')}</span>
</button>
<button
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 border border-white/5 opacity-50 cursor-not-allowed"
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
<MoreHorizontal size={16} className="text-white" />
</div>
<span className="text-[11px] font-medium text-white">{t('more')}</span>
</button>
</div>
)}
</div>
{/* Информация */}
@@ -585,6 +671,35 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
})}
</p>
</div>
{/* Общие группы */}
{!isSelf && commonGroups.length > 0 && (
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10 group mt-2 text-left">
<div className="flex items-center gap-2 mb-3">
<div className="w-6 h-6 rounded-full bg-purple-500/20 flex items-center justify-center border border-purple-500/30">
<Users size={12} className="text-purple-400" />
</div>
<label className="text-xs font-semibold text-purple-200/50 uppercase tracking-widest">
{t('commonGroups')}
</label>
</div>
<div className="flex flex-col gap-1.5">
{commonGroups.map(cg => (
<button key={cg.id} onClick={() => { setActiveChat(cg.id); loadMessages(cg.id); onClose(); }} className="flex items-center gap-3 w-full bg-white/5 hover:bg-white/10 p-2 rounded-xl transition-colors border border-white/5">
{cg.avatar ? (
<img src={getMediaUrl(cg.avatar)} className="w-10 h-10 rounded-full object-cover" />
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex flex-col items-center justify-center text-white font-bold">{(cg.name || 'G').charAt(0)}</div>
)}
<div className="flex-1 text-left min-w-0">
<p className="text-sm font-medium text-white truncate">{cg.name || 'Group'}</p>
<p className="text-xs text-zinc-500 truncate">{cg.members.length} {t('participants')}</p>
</div>
</button>
))}
</div>
</div>
)}
</div>
{/* Медиа / Файлы / Ссылки */}

View File

@@ -186,6 +186,8 @@ const translations = {
noStories: 'Публикаций пока нет',
goToMessage: 'Перейти к сообщению',
story: 'История',
commonGroups: 'Общие группы',
more: 'Ещё',
// Typing
typingText: 'печатает',
// Emoji
@@ -451,6 +453,8 @@ const translations = {
noStories: 'No stories yet',
goToMessage: 'Go to message',
story: 'Story',
commonGroups: 'Common groups',
more: 'More',
typingText: 'typing',
emojiFrequent: 'Frequent',
emojiGestures: 'Gestures',

View File

@@ -4,10 +4,10 @@ import { useChatStore } from '../stores/chatStore';
import { useAuthStore } from '../stores/authStore';
import { getSocket, disconnectSocket } from '../lib/socket';
import { api } from '../lib/api';
import { playNotificationSound, isChatMuted } from '../lib/sounds';
import { playNotificationSound, isChatMuted, playCallRingtone, stopCallRingtone } from '../lib/sounds';
import { useLang } from '../lib/i18n';
import type { Message, UserBasic, CallInfo } from '../lib/types';
import { Send, Check } from 'lucide-react';
import { Send, Check, Phone, PhoneOff } from 'lucide-react';
import Sidebar from '../components/Sidebar';
import ChatView from '../components/ChatView';
import CallModal from '../components/CallModal';
@@ -51,8 +51,18 @@ export default function ChatPage() {
const [groupCallType, setGroupCallType] = useState<'voice' | 'video'>('voice');
const [groupCallSessionId, setGroupCallSessionId] = useState(0);
const [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null);
const groupCallOpenRef = useRef(false);
const groupCallChatIdRef = useRef('');
const { t } = useLang();
useEffect(() => {
groupCallOpenRef.current = groupCallOpen;
groupCallChatIdRef.current = groupCallChatId;
}, [groupCallOpen, groupCallChatId]);
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
@@ -244,6 +254,45 @@ export default function ChatPage() {
}
});
socket.on('group_call_incoming', (data: { chatId: string; from: string; callerInfo: any; callType: string }) => {
if (data.from === user?.id) return;
if (groupCallOpenRef.current && groupCallChatIdRef.current === data.chatId) return;
const { chats } = useChatStore.getState();
const chat = chats.find(c => c.id === data.chatId);
if (!chat) return;
playCallRingtone();
setIncomingGroupCall({
chatId: data.chatId,
from: data.from,
callerInfo: data.callerInfo,
callType: data.callType,
chatName: chat.name || 'Group',
});
// Auto-dismiss after 15 seconds if ignored
setTimeout(() => {
setIncomingGroupCall(prev => {
if (prev?.chatId === data.chatId) {
stopCallRingtone();
return null;
}
return prev;
});
}, 15000);
});
socket.on('group_call_ended', (data: { chatId: string }) => {
setIncomingGroupCall(prev => {
if (prev?.chatId === data.chatId) {
stopCallRingtone();
return null;
}
return prev;
});
});
return () => {
socket.off('new_message');
socket.off('scheduled_delivered');
@@ -265,6 +314,8 @@ export default function ChatPage() {
socket.off('story_viewed');
socket.off('story_reply');
socket.off('story_reaction');
socket.off('group_call_incoming');
socket.off('group_call_ended');
};
}, [user?.id]);
@@ -276,6 +327,16 @@ export default function ChatPage() {
setCallOpen(true);
};
useEffect(() => {
const handleCustomCallEvent = ((e: CustomEvent) => {
if (e.detail?.targetUser && e.detail?.type) {
handleStartCall(e.detail.targetUser, e.detail.type);
}
}) as EventListener;
window.addEventListener('START_CALL', handleCustomCallEvent);
return () => window.removeEventListener('START_CALL', handleCustomCallEvent);
}, []);
const handleStartGroupCall = (chatId: string, chatName: string, type: 'voice' | 'video') => {
setGroupCallChatId(chatId);
setGroupCallChatName(chatName);
@@ -342,6 +403,67 @@ export default function ChatPage() {
</motion.div>
)}
</AnimatePresence>
{/* Incoming Group Call Overlay */}
<AnimatePresence>
{incomingGroupCall && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm"
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="bg-zinc-900 border border-white/10 p-8 rounded-3xl w-full max-w-sm flex flex-col items-center shadow-2xl"
>
<div className="relative mb-6">
<div className="absolute inset-0 rounded-full bg-emerald-500/20 animate-call-wave" />
<div className="relative w-24 h-24 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-4xl font-bold text-white uppercase overflow-hidden">
{incomingGroupCall.callerInfo?.avatar ? (
<img src={incomingGroupCall.callerInfo.avatar} className="w-full h-full object-cover" />
) : (
<>{incomingGroupCall.chatName.charAt(0)}</>
)}
</div>
</div>
<h2 className="text-2xl text-white font-semibold mb-2 text-center break-words w-full max-w-full">
{incomingGroupCall.chatName}
</h2>
<p className="text-emerald-400 font-medium mb-1 truncate w-full text-center">
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('calling' as any) || 'звонит...'}
</p>
<p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5">
{t('group' as any) || 'Групповой звонок'}
</p>
<div className="flex items-center gap-8 w-full justify-center">
<button
onClick={() => {
stopCallRingtone();
setIncomingGroupCall(null);
}}
className="w-16 h-16 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white transition-colors shadow-lg shadow-red-500/20"
>
<PhoneOff size={28} />
</button>
<button
onClick={() => {
stopCallRingtone();
handleStartGroupCall(incomingGroupCall.chatId, incomingGroupCall.chatName, incomingGroupCall.callType as any);
setIncomingGroupCall(null);
}}
className="w-16 h-16 rounded-full bg-emerald-500 hover:bg-emerald-600 flex items-center justify-center text-white transition-colors animate-pulse shadow-lg shadow-emerald-500/20"
>
<Phone size={28} className="animate-wiggle" />
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}

View File

@@ -152,7 +152,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
return {
...chat,
messages: [message],
unreadCount: (chat.id === state.activeChat || message.senderId === userId || message.storyId) ? chat.unreadCount : chat.unreadCount + 1,
unreadCount: (chat.id === state.activeChat || message.senderId === userId) ? chat.unreadCount : chat.unreadCount + 1,
};
}
return chat;
@@ -446,12 +446,24 @@ export const useChatStore = create<ChatState>((set, get) => ({
addChat: (chat) => {
set((state) => {
const existing = state.chats.find((c) => c.id === chat.id);
const messagesFromState = state.messages[chat.id] || [];
const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []);
let unreadCount = chat.unreadCount || 0;
if (!existing && messagesFromState.length > 0) {
const userId = useAuthStore.getState().user?.id;
unreadCount = messagesFromState.filter((m) => m.senderId !== userId && !m.readBy?.some(r => r.userId === userId)).length;
}
const updatedChat = { ...chat, messages: messagesToUse.length > 0 ? [messagesToUse[messagesToUse.length - 1]] : [], unreadCount };
if (existing) {
return {
chats: state.chats.map((c) => (c.id === chat.id ? { ...c, ...chat } : c)),
chats: state.chats.map((c) => (c.id === chat.id ? { ...c, ...updatedChat } : c)),
};
}
return { chats: [chat, ...state.chats] };
return { chats: [updatedChat, ...state.chats] };
});
},