Групповые звонки
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
130
apps/server-net/uploads/3dfe695e-fbab-4483-a6eb-13998f128320.svg
Normal file
130
apps/server-net/uploads/3dfe695e-fbab-4483-a6eb-13998f128320.svg
Normal 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 |
BIN
apps/server-net/uploads/73e12c4e-de85-4712-8d2c-8f200d5783a4.png
Normal file
BIN
apps/server-net/uploads/73e12c4e-de85-4712-8d2c-8f200d5783a4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 MiB |
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* Медиа / Файлы / Ссылки */}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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] };
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user