From 474923328bf433def6e28172470422f50bfd28cd Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 8 Aug 2026 17:33:43 +0700 Subject: [PATCH] =?UTF-8?q?=D0=BE=D0=B9=20=D0=B2=20=D0=BF=D0=B8=D0=B7?= =?UTF-8?q?=D0=B4=D1=83=20=D0=BA=D0=BE=D1=80=D0=BE=D1=87=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/calls.dart | 100 ++++---- lib/core/calls/call_controller.dart | 22 +- lib/core/calls/call_link.dart | 16 ++ lib/core/calls/call_session.dart | 54 ++++- lib/core/calls/ws2_signaling.dart | 7 +- .../screens/calls/call_link_sheet.dart | 220 ++++++++++++++++++ lib/frontend/screens/calls/calls_tab.dart | 42 ++-- lib/l10n/app_en.arb | 6 + lib/l10n/app_localizations.dart | 36 +++ lib/l10n/app_localizations_en.dart | 18 ++ lib/l10n/app_localizations_ru.dart | 18 ++ lib/l10n/app_ru.arb | 6 + 12 files changed, 462 insertions(+), 83 deletions(-) create mode 100644 lib/frontend/screens/calls/call_link_sheet.dart diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index ade2ecd..1e33413 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'contacts.dart'; import '../api.dart'; +import '../../core/calls/call_link.dart'; import '../../core/calls/ws2_signaling.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/utils/ids.dart'; @@ -28,6 +29,22 @@ class OutgoingCallParams { }); } +class CreatedCall { + final String conversationId; + final String joinToken; + final String? callName; + final int? chatId; + + const CreatedCall({ + required this.conversationId, + required this.joinToken, + this.callName, + this.chatId, + }); + + String get url => CallLink.url(joinToken); +} + class CallLinkPreview { final String? conferenceId; final String? callName; @@ -84,24 +101,26 @@ class CallsModule { _CallerEndpoint _parseCallerEndpoint( Map payload, - String key, { + List keys, { required String context, }) { - final raw = payload[key]; - final parsed = raw is String - ? jsonDecode(raw) as Map - : const {}; + for (final key in keys) { + final raw = payload[key]; + final parsed = raw is String + ? jsonDecode(raw) as Map + : const {}; - final endpoint = parsed['endpoint'] as String?; - if (endpoint == null) { - throw _CallerEndpointMissingException('$context: no endpoint'); + final endpoint = parsed['endpoint'] as String?; + if (endpoint == null) continue; + + final id = parsed['id']; + final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0; + final external = id is Map ? int.tryParse('${id['external']}') : null; + + return (endpoint: endpoint, callsUserId: callsUserId, external: external); } - final id = parsed['id']; - final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0; - final external = id is Map ? int.tryParse('${id['external']}') : null; - - return (endpoint: endpoint, callsUserId: callsUserId, external: external); + throw _CallerEndpointMissingException('$context: no endpoint'); } Future initiateCall( @@ -121,11 +140,9 @@ class CallsModule { throw Exception('initiateCall: bad response'); } - final parsed = _parseCallerEndpoint( - payload, + final parsed = _parseCallerEndpoint(payload, const [ 'internalCallerParams', - context: 'initiateCall', - ); + ], context: 'initiateCall'); return OutgoingCallParams( conversationId: (payload['conversationId'] as String?) ?? conversationId, @@ -136,33 +153,34 @@ class CallsModule { ); } - Future startGroupCall({bool isVideo = false}) async { + Future createConference() async { final conversationId = uuidV4(); - logger.i('[call] VIDEO_CHAT_START_ACTIVE group conv=$conversationId'); + logger.i('[call] VIDEO_CHAT_START conv=$conversationId'); - final payload = await _api.sendRequestMap(Opcode.videoChatStartActive, { + final payload = await _api.sendRequestMap(Opcode.videoChatStart, { 'conversationId': conversationId, - 'internalParams': _internalParams(), - 'isVideo': isVideo, }); - logger.i('[call] VIDEO_CHAT_START_ACTIVE keys=${payload?.keys.toList()}'); + logger.i('[call] VIDEO_CHAT_START keys=${payload?.keys.toList()}'); if (payload == null) { - throw Exception('startGroupCall: bad response'); + throw Exception('createConference: bad response'); } - final parsed = _parseCallerEndpoint( - payload, - 'internalCallerParams', - context: 'startGroupCall', - ); + final id = (payload['conversationId'] as String?) ?? conversationId; + final rawLink = + (payload['joinLink'] as String?) ?? await createJoinLink(id) ?? ''; + final token = CallLink.normalizeToken(rawLink); + if (token == null) { + throw Exception('createConference: no joinLink'); + } - return OutgoingCallParams( - conversationId: (payload['conversationId'] as String?) ?? conversationId, - endpoint: parsed.endpoint, - callsUserId: parsed.callsUserId, - peerExternalId: 0, - isVideo: isVideo, + final name = (payload['callName'] as String?)?.trim(); + + return CreatedCall( + conversationId: id, + joinToken: token, + callName: (name?.isEmpty ?? true) ? null : name, + chatId: payload['chatId'] is int ? payload['chatId'] as int : null, ); } @@ -189,7 +207,10 @@ class CallsModule { }); Future resolveCallLink(String url) async { - final payload = await _api.sendRequestMap(Opcode.linkInfo, {'link': url}); + final token = CallLink.normalizeToken(url); + final payload = await _api.sendRequestMap(Opcode.linkInfo, { + 'link': token == null ? url : CallLink.path(token), + }); if (payload == null) return null; final vc = payload['videoConference']; @@ -219,11 +240,10 @@ class CallsModule { throw Exception('joinByLink: bad response'); } - final parsed = _parseCallerEndpoint( - payload, + final parsed = _parseCallerEndpoint(payload, const [ 'internalParams', - context: 'joinByLink', - ); + 'internalCallerParams', + ], context: 'joinByLink'); return OutgoingCallParams( conversationId: (payload['conversationId'] as String?) ?? '', diff --git a/lib/core/calls/call_controller.dart b/lib/core/calls/call_controller.dart index 0075abc..8e893b4 100644 --- a/lib/core/calls/call_controller.dart +++ b/lib/core/calls/call_controller.dart @@ -160,27 +160,9 @@ class CallController { return session; } - Future<({CallSession session, String? joinLink})> createGroupCall({ - bool isVideo = false, - }) async { + Future createConference() async { if (_active != null) throw StateError('уже идёт звонок'); - final out = await _calls!.startGroupCall(isVideo: isVideo); - final joinLink = await _calls!.createJoinLink(out.conversationId); - final config = Ws2Config.fromEndpoint( - out.endpoint, - userId: out.callsUserId, - device: _api?.callsDevice, - osVersion: _api?.callsOsVersion, - ); - final session = CallSession( - ws2Config: config, - role: CallRole.caller, - isGroup: true, - ); - _bind(session); - await session.start(); - CallBridge.instance.notifyAccepted(); - return (session: session, joinLink: joinLink); + return _calls!.createConference(); } Future previewCallLink(String url) => diff --git a/lib/core/calls/call_link.dart b/lib/core/calls/call_link.dart index 315fa2a..fe939db 100644 --- a/lib/core/calls/call_link.dart +++ b/lib/core/calls/call_link.dart @@ -1,10 +1,26 @@ class CallLink { + static const String base = 'https://max.ru/joincall/'; + static final RegExp _pattern = RegExp( r'^https?://(?:[^/\s]+\.)?max\.ru/joincall/([A-Za-z0-9_-]+)', caseSensitive: false, ); + static final RegExp _rawPattern = RegExp( + r'^(?:joincall/)?([A-Za-z0-9_-]+)$', + caseSensitive: false, + ); + static bool isCallLink(String url) => token(url) != null; static String? token(String url) => _pattern.firstMatch(url.trim())?.group(1); + + static String? normalizeToken(String raw) { + final value = raw.trim(); + return token(value) ?? _rawPattern.firstMatch(value)?.group(1); + } + + static String url(String token) => '$base$token'; + + static String path(String token) => 'joincall/$token'; } diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart index fe6faf5..dac6913 100644 --- a/lib/core/calls/call_session.dart +++ b/lib/core/calls/call_session.dart @@ -179,7 +179,8 @@ class CallSession { Stream get participantStreamUpdates => _participantStreamUpdates.stream; - MediaStream? streamOf(int participantId) => _participantStreams[participantId]; + MediaStream? streamOf(int participantId) => + _participantStreams[participantId]; int get participantCount => _participants.length; @@ -251,6 +252,8 @@ class CallSession { signaling.done.then((_) => _onSignalingLost()); await signaling.connect(); logger.i('[call] signaling connected to ${ws2Config.uri.host}'); + logger.i('[call] ws2 url ${_maskedUrl()}'); + unawaited(_wakeSignalingIfSilent(signaling)); Timer(const Duration(seconds: 10), () { if (_ended || _gotConnection) return; logger.w( @@ -260,6 +263,55 @@ class CallSession { }); } + String _maskedUrl() { + final token = ws2Config.uri.queryParameters['token']; + if (token == null || token.length < 12) return ws2Config.uri.toString(); + final masked = + '${token.substring(0, 4)}…${token.substring(token.length - 6)}'; + return ws2Config.uri.toString().replaceAll( + Uri.encodeQueryComponent(token), + masked, + ); + } + + /// Кадры ws2 приходят в broadcast-канал Rust-ядра, а подписка на него + /// создаётся уже после того, как сокет открыт: нотификацию `connection`, + /// присланную сразу после хэндшейка, ядро выбрасывает. Если её нет — толкаем + /// сервер командой (ответы идут по sequence и гонке не подвержены). + Future _wakeSignalingIfSilent(Ws2Signaling signaling) async { + await Future.delayed(const Duration(milliseconds: 1200)); + if (_ended || _gotConnection || _signaling != signaling) return; + + logger.w('[call] "connection" не пришла за 1.2 с — бужу ws2'); + try { + final response = await signaling.sendCommand( + 'change-media-settings', + extra: { + 'mediaSettings': { + 'isVideoEnabled': _localVideo, + 'isAudioEnabled': !_muted, + 'isScreenSharingEnabled': _localScreen, + 'isAnimojiEnabled': false, + }, + }, + ); + logger.i('[call] ws2 wake ok: $response'); + } catch (e) { + logger.w('[call] ws2 wake failed: $e'); + return; + } + + await Future.delayed(const Duration(milliseconds: 1200)); + if (_ended || _gotConnection || _signaling != signaling) return; + + logger.w('[call] всё ещё тихо — шлю accept-call вслепую'); + try { + await accept(activate: false); + } catch (e) { + logger.w('[call] accept-call failed: $e'); + } + } + void _onSignalingLost() { if (_ended || _reconnecting) return; logger.w('[call] signaling lost, reconnecting'); diff --git a/lib/core/calls/ws2_signaling.dart b/lib/core/calls/ws2_signaling.dart index 34ac957..baa9ac5 100644 --- a/lib/core/calls/ws2_signaling.dart +++ b/lib/core/calls/ws2_signaling.dart @@ -52,8 +52,10 @@ class Ws2Config { return Ws2Config(uri: uri, userId: userId); } - /// Исходящий звонок: `endpoint` из ответа opcode 78 уже содержит токен и - /// conversationId/userId в query — дописываем клиентские параметры. + /// Исходящий звонок или вход в конференцию: `endpoint` из ответа opcode 78 / + /// 166 уже содержит токен и conversationId/userId в query — дописываем + /// клиентские параметры. Без `tgt=start` медиасервер принимает сокет, но не + /// поднимает разговор и не шлёт нотификацию `connection`. factory Ws2Config.fromEndpoint( String endpoint, { required int userId, @@ -72,6 +74,7 @@ class Ws2Config { 'clientType': 'ONE_ME', 'appVersion': _appVersion, 'osVersion': osVersion ?? defaultOsVersion, + 'tgt': 'start', }, ); return Ws2Config(uri: uri, userId: userId); diff --git a/lib/frontend/screens/calls/call_link_sheet.dart b/lib/frontend/screens/calls/call_link_sheet.dart new file mode 100644 index 0000000..03abeee --- /dev/null +++ b/lib/frontend/screens/calls/call_link_sheet.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/backend/modules/calls.dart'; +import 'package:komet/frontend/screens/chats/chat_list_screen.dart'; +import 'package:komet/frontend/screens/contacts/contact_sheet_common.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; +import 'package:komet/frontend/widgets/small_spinner.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/main.dart' show messagesModule; + +Future showCreatedCallSheet( + BuildContext context, { + required CreatedCall call, +}) async { + final started = await showBlurredCard( + context, + (host) => _CreatedCallCard(call: call, hostContext: host), + ); + return started ?? false; +} + +class _CreatedCallCard extends StatefulWidget { + final CreatedCall call; + final BuildContext hostContext; + + const _CreatedCallCard({required this.call, required this.hostContext}); + + @override + State<_CreatedCallCard> createState() => _CreatedCallCardState(); +} + +class _CreatedCallCardState extends State<_CreatedCallCard> { + bool _sending = false; + + Future _copy() async { + final message = AppLocalizations.of(context)!.sharedLinkCopied; + await Clipboard.setData(ClipboardData(text: widget.call.url)); + if (!mounted) return; + showCustomNotification(context, message); + } + + Future _sendInMax() async { + if (_sending) return; + final target = await openForwardScreen(context: context); + if (target == null || !mounted) return; + + setState(() => _sending = true); + final ok = await messagesModule.sendLinkMessage( + target.chatId, + widget.call.url, + ); + if (!mounted) return; + setState(() => _sending = false); + + final l10n = AppLocalizations.of(context)!; + showCustomNotification( + context, + ok ? l10n.callLinkSent : l10n.callLinkSendFailed, + ); + } + + Widget _action( + ColorScheme cs, { + required IconData icon, + required String label, + required VoidCallback onTap, + bool busy = false, + }) { + return InkWell( + onTap: busy ? null : onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + SizedBox( + width: 24, + height: 24, + child: busy + ? SmallSpinner(size: 24, color: cs.primary) + : Icon(icon, color: cs.primary, size: 24), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: cs.primary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final width = MediaQuery.sizeOf(context).width; + final title = widget.call.callName ?? l10n.callLinkGroupCall; + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Material( + color: Colors.transparent, + child: Container( + width: width > 420 ? 380 : double.infinity, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(22), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + cs.primary, + Color.alphaBlend( + Colors.white.withValues(alpha: 0.25), + cs.primary, + ), + ], + ), + ), + child: Icon( + Symbols.call, + fill: 1, + color: cs.onPrimary, + size: 40, + ), + ), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + widget.call.url, + textAlign: TextAlign.center, + style: TextStyle(color: cs.primary, fontSize: 14), + ), + const SizedBox(height: 20), + Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _action( + cs, + icon: Symbols.content_copy, + label: l10n.sharedCopyLink, + onTap: _copy, + ), + _action( + cs, + icon: Symbols.reply, + label: l10n.callLinkSendInMax, + onTap: _sendInMax, + busy: _sending, + ), + ], + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: Text( + l10n.callLinkStart, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 20ad589..c0e1f7a 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -1,14 +1,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart' show api, accountModule; import '../../../backend/modules/account.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/format.dart'; import '../../../core/calls/call_controller.dart'; -import '../../../core/calls/call_session.dart'; import '../../../backend/modules/calls.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/connection_status.dart'; @@ -19,6 +17,8 @@ import '../../widgets/small_spinner.dart'; import '../../widgets/prompt_dialog.dart'; import '../../widgets/call_link_handler.dart'; import '../../widgets/spectrum_tint.dart'; +import '../../../l10n/app_localizations.dart'; +import 'call_link_sheet.dart'; import 'call_screen.dart'; class CallsTab extends StatefulWidget { @@ -380,34 +380,36 @@ class _CallsTabState extends State return; } - final navigator = Navigator.of(context); - ({CallSession session, String? joinLink}) created; + final l10n = AppLocalizations.of(context)!; + CreatedCall created; try { - created = await controller.createGroupCall(); + created = await controller.createConference(); } catch (e) { if (mounted) { - showCustomNotification(context, 'Не удалось создать звонок: $e'); + showCustomNotification(context, '${l10n.callLinkCreateFailed}: $e'); } return; } if (!mounted) return; - final link = created.joinLink; - if (link != null) { - await Clipboard.setData(ClipboardData(text: link)); - if (!mounted) return; - showCustomNotification(context, 'Ссылка на звонок скопирована'); - } + final start = await showCreatedCallSheet(context, call: created); + if (!start || !mounted) return; - await navigator.push( - MaterialPageRoute( - builder: (_) => CallScreen( - name: 'Групповой звонок', - session: created.session, - isGroup: true, + final navigator = Navigator.of(context); + final name = created.callName ?? l10n.callLinkGroupCall; + try { + final session = await controller.joinByLink(created.joinToken); + if (!mounted) return; + await navigator.push( + MaterialPageRoute( + builder: (_) => + CallScreen(name: name, session: session, isGroup: true), ), - ), - ); + ); + } catch (e) { + if (!mounted) return; + showCustomNotification(context, 'Не удалось начать звонок: $e'); + } } Future _joinGroupCall() async { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e26e514..1796720 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -346,6 +346,12 @@ "callStatusConnecting": "Connecting", "callGroupConnecting": "Connecting…", "callGroupWaitingParticipants": "Waiting for participants…", + "callLinkGroupCall": "Group call", + "callLinkSendInMax": "Send in MAX", + "callLinkStart": "Start call", + "callLinkSent": "Link sent", + "callLinkSendFailed": "Couldn't send the link", + "callLinkCreateFailed": "Couldn't create the call", "callParticipantYou": "You", "callParticipantFallback": "Participant", "callTooltipMinimize": "Minimize", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 725ab88..7f40c4c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1814,6 +1814,42 @@ abstract class AppLocalizations { /// **'Waiting for participants…'** String get callGroupWaitingParticipants; + /// No description provided for @callLinkGroupCall. + /// + /// In en, this message translates to: + /// **'Group call'** + String get callLinkGroupCall; + + /// No description provided for @callLinkSendInMax. + /// + /// In en, this message translates to: + /// **'Send in MAX'** + String get callLinkSendInMax; + + /// No description provided for @callLinkStart. + /// + /// In en, this message translates to: + /// **'Start call'** + String get callLinkStart; + + /// No description provided for @callLinkSent. + /// + /// In en, this message translates to: + /// **'Link sent'** + String get callLinkSent; + + /// No description provided for @callLinkSendFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t send the link'** + String get callLinkSendFailed; + + /// No description provided for @callLinkCreateFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t create the call'** + String get callLinkCreateFailed; + /// No description provided for @callParticipantYou. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6e6d933..0f48a1c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -918,6 +918,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get callGroupWaitingParticipants => 'Waiting for participants…'; + @override + String get callLinkGroupCall => 'Group call'; + + @override + String get callLinkSendInMax => 'Send in MAX'; + + @override + String get callLinkStart => 'Start call'; + + @override + String get callLinkSent => 'Link sent'; + + @override + String get callLinkSendFailed => 'Couldn\'t send the link'; + + @override + String get callLinkCreateFailed => 'Couldn\'t create the call'; + @override String get callParticipantYou => 'You'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 8b12b7a..5984028 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -922,6 +922,24 @@ class AppLocalizationsRu extends AppLocalizations { @override String get callGroupWaitingParticipants => 'Ожидание участников…'; + @override + String get callLinkGroupCall => 'Групповой звонок'; + + @override + String get callLinkSendInMax => 'Отправить в MAX'; + + @override + String get callLinkStart => 'Начать звонок'; + + @override + String get callLinkSent => 'Ссылка отправлена'; + + @override + String get callLinkSendFailed => 'Не удалось отправить ссылку'; + + @override + String get callLinkCreateFailed => 'Не удалось создать звонок'; + @override String get callParticipantYou => 'Вы'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e111099..b88ff1c 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -304,6 +304,12 @@ "callStatusConnecting": "Соединение...", "callGroupConnecting": "Соединение...", "callGroupWaitingParticipants": "Ожидание участников…", + "callLinkGroupCall": "Групповой звонок", + "callLinkSendInMax": "Отправить в MAX", + "callLinkStart": "Начать звонок", + "callLinkSent": "Ссылка отправлена", + "callLinkSendFailed": "Не удалось отправить ссылку", + "callLinkCreateFailed": "Не удалось создать звонок", "callParticipantYou": "Вы", "callParticipantFallback": "Участник", "callTooltipMinimize": "Свернуть",