From e988a34b76b39f0279f24df04fdac8ff9df9accb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Sat, 21 Mar 2026 09:26:06 +0100 Subject: [PATCH] feat: Implement experimental jitsi group calls behind a feature flag --- lib/config/setting_keys.dart | 2 + lib/l10n/intl_en.arb | 11 +- lib/pages/chat/chat_view.dart | 9 +- lib/pages/chat/jitsi_popup_button.dart | 177 +++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 lib/pages/chat/jitsi_popup_button.dart diff --git a/lib/config/setting_keys.dart b/lib/config/setting_keys.dart index 6cbc1127..88b3a485 100644 --- a/lib/config/setting_keys.dart +++ b/lib/config/setting_keys.dart @@ -40,6 +40,8 @@ enum AppSettings { showPresences('chat.fluffy.show_presences', true), displayNavigationRail('chat.fluffy.display_navigation_rail', false), experimentalVoip('chat.fluffy.experimental_voip', false), + jitsiFeature('chat.fluffy.enable_jitsi', false), + jitsiDomain('chat.fluffy.jitsi_domain', 'meet.jit.si'), shareKeysWith('chat.fluffy.share_keys_with_2', 'all'), noEncryptionWarningShown( 'chat.fluffy.no_encryption_warning_shown', diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 1ce086ee..7321c5b7 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -2789,5 +2789,14 @@ "iDoNotWantToSupport": "I do not want to support", "iAlreadySupportFluffyChat": "I already support FluffyChat", "setLowPriority": "Set low priority", - "unsetLowPriority": "Unset low priority" + "unsetLowPriority": "Unset low priority", + "jitsiGroupCalls": "Jitsi group calls", + "removeCallFromChat": "Remove call from chat", + "removeCallFromChatDescription": "Do you want to remove the call from the chat for all members?", + "removeCallForEveryone": "Remove call for everyone", + "startVoiceCall": "Start voice call", + "startVideoCall": "Start video call", + "joinVoiceCall": "Join voice call", + "joinVideoCall": "Join video call", + "live": "Live" } diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index 2c19524b..e9f71834 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -14,6 +14,7 @@ import 'package:fluffychat/pages/chat/chat_app_bar_list_tile.dart'; import 'package:fluffychat/pages/chat/chat_app_bar_title.dart'; import 'package:fluffychat/pages/chat/chat_event_list.dart'; import 'package:fluffychat/pages/chat/encryption_button.dart'; +import 'package:fluffychat/pages/chat/jitsi_popup_button.dart'; import 'package:fluffychat/pages/chat/pinned_events.dart'; import 'package:fluffychat/pages/chat/reply_display.dart'; import 'package:fluffychat/utils/account_config.dart'; @@ -224,14 +225,16 @@ class ChatView extends StatelessWidget { ], ), ] else if (!controller.room.isArchived) ...[ - if (AppSettings.experimentalVoip.value && + if ((AppSettings.experimentalVoip.value && Matrix.of(context).voipPlugin != null && - controller.room.isDirectChat) + controller.room.isDirectChat)) IconButton( onPressed: controller.onPhoneButtonTap, icon: const Icon(Icons.call_outlined), tooltip: L10n.of(context).placeCall, - ), + ) + else if (AppSettings.jitsiFeature.value) + JitsiPopupButton(controller.room), EncryptionButton(controller.room), ChatSettingsPopupMenu(controller.room, true), ], diff --git a/lib/pages/chat/jitsi_popup_button.dart b/lib/pages/chat/jitsi_popup_button.dart new file mode 100644 index 00000000..aa25046c --- /dev/null +++ b/lib/pages/chat/jitsi_popup_button.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:fluffychat/config/setting_keys.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; + +class JitsiPopupButton extends StatelessWidget { + final Room room; + const JitsiPopupButton(this.room, {super.key}); + + Future _startCall(BuildContext context, bool isAudioOnly) async { + final l10n = L10n.of(context); + final urlResult = await showFutureLoadingDialog( + context: context, + future: () async { + final conferenceId = room.client.generateUniqueTransactionId(); + final domain = AppSettings.jitsiDomain.value; + final uri = Uri( + scheme: 'https', + host: domain, + path: conferenceId, + fragment: isAudioOnly ? 'config.startWithVideoMuted=true' : null, + ); + await room.addWidget( + MatrixWidget( + room: room, + name: 'Jitsi Meet', + type: 'jitsi', + url: uri.toString(), + data: { + 'domain': domain, + 'isAudioOnly': isAudioOnly, + 'conferenceId': conferenceId, + 'roomName': room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)), + ), + }, + ), + ); + return uri; + }, + ); + final url = urlResult.result; + if (url == null) return; + await launchUrl(url); + + if (!context.mounted) return; + final consent = await showOkCancelAlertDialog( + context: context, + title: l10n.removeCallFromChat, + message: l10n.removeCallFromChatDescription, + okLabel: l10n.remove, + ); + + if (consent != OkCancelResult.ok) return; + if (!context.mounted) return; + + await _endAllCalls(context); + } + + Future _endAllCalls(BuildContext context) => showFutureLoadingDialog( + context: context, + future: () async { + final activeJitsiCalls = room.states['im.vector.modular.widgets']?.values + .where((state) => state.content['type'] == 'jitsi'); + if (activeJitsiCalls == null) return; + for (final call in activeJitsiCalls) { + await room.deleteWidget(call.stateKey!); + } + }, + ); + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context); + final activeJitsiCalls = room.widgets + .where((widget) => widget.type == 'jitsi') + .map((widget) { + final isAudioOnly = widget.data?.tryGet('isAudioOnly') ?? false; + final domain = widget.data?.tryGet('domain'); + final conferenceId = widget.data?.tryGet('conferenceId'); + return ( + isAudioOnly: isAudioOnly, + domain: domain, + conferenceId: conferenceId, + ); + }) + .toList(); + final canEditCalls = room.canChangeStateEvent('im.vector.modular.widgets'); + if (activeJitsiCalls.isEmpty && !canEditCalls) { + return const SizedBox.shrink(); + } + return PopupMenuButton( + itemBuilder: (context) => [ + ...activeJitsiCalls.map( + (call) => PopupMenuItem( + onTap: () => launchUrl( + Uri( + scheme: 'https', + host: call.domain, + path: call.conferenceId, + fragment: call.isAudioOnly + ? 'config.startWithVideoMuted=true' + : null, + ), + ), + child: Row( + mainAxisSize: .min, + children: [ + Icon(call.isAudioOnly ? Icons.add_call : Icons.video_call), + const SizedBox(width: 12), + Text( + call.isAudioOnly ? l10n.joinVoiceCall : l10n.joinVideoCall, + ), + ], + ), + ), + ), + if (canEditCalls) ...[ + if (activeJitsiCalls.isEmpty) ...[ + PopupMenuItem( + onTap: () => _startCall(context, true), + child: Row( + mainAxisSize: .min, + children: [ + const Icon(Icons.add_call), + const SizedBox(width: 12), + Text(l10n.startVoiceCall), + ], + ), + ), + PopupMenuItem( + onTap: () => _startCall(context, false), + child: Row( + mainAxisSize: .min, + children: [ + const Icon(Icons.video_call), + const SizedBox(width: 12), + Text(l10n.startVideoCall), + ], + ), + ), + ] else + PopupMenuItem( + onTap: () => _endAllCalls(context), + child: Row( + mainAxisSize: .min, + children: [ + Icon( + Icons.call_end_outlined, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 12), + Text( + l10n.removeCallForEveryone, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ), + ), + ], + ], + icon: Badge( + label: Text(l10n.live), + isLabelVisible: activeJitsiCalls.isNotEmpty, + child: Icon(Icons.video_call_outlined), + ), + ); + } +}