diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index a673a483ee..27a90b2812 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -3497,6 +3497,9 @@ "unblockUserToSendMessages": "Unblock user to send messages", "supportChat": "Support Chat", "supportChatDescription": "I’ve got your back! If you encounter bugs or need any help using Twake Workplace, send a message, I will reply to you swiftly", + "inviteFriend": "Invite friend", + "linkCopiedToClipboard": "Link copied to clipboard", + "cannotCreateChatWithSelf": "You cannot start a conversation with yourself", "yourPersonalQr": "Your personal QR", "personalQrDescription": "Start chatting with your contacts. Send invitation link to Twake", "shareQrCode": "Share QR code", diff --git a/lib/pages/chat_draft/draft_chat.dart b/lib/pages/chat_draft/draft_chat.dart index f8cb764ec3..37b46aef8c 100644 --- a/lib/pages/chat_draft/draft_chat.dart +++ b/lib/pages/chat_draft/draft_chat.dart @@ -259,10 +259,6 @@ class DraftChatController extends State TextEditingController sendController = TextEditingController(); - void setActiveClient(Client c) => setState(() { - Matrix.of(context).setActiveClient(c); - }); - Future _triggerTagGreetingMessage() async { if (_userProfile.value == null) { return sendController.value.text; diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index ae48db8618..c72dfac241 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -466,20 +466,6 @@ class ChatListController extends State }); } - void setActiveBundle(String bundle) { - context.go('/rooms'); - setState(() { - conversationSelectionNotifier.value.clear(); - Matrix.of(context).activeBundle = bundle; - if (!Matrix.of(context) - .currentBundle! - .any((client) => client == client)) { - Matrix.of(context) - .setActiveClient(Matrix.of(context).currentBundle!.first); - } - }); - } - void editBundlesForAccount(String? userId, String? activeBundle) async { final l10n = L10n.of(context)!; final client = Matrix.of(context) diff --git a/lib/pages/contacts_tab/contacts_appbar.dart b/lib/pages/contacts_tab/contacts_appbar.dart index 679f37e13d..253f5acd6b 100644 --- a/lib/pages/contacts_tab/contacts_appbar.dart +++ b/lib/pages/contacts_tab/contacts_appbar.dart @@ -27,6 +27,7 @@ class ContactsAppBar extends StatelessWidget { @override Widget build(BuildContext context) { return Column( + mainAxisSize: MainAxisSize.min, children: [ TwakeAppBar( title: L10n.of(context)!.contacts, @@ -61,20 +62,11 @@ class ContactsAppBar extends StatelessWidget { : LinagoraSysColors.material().onPrimary, ), height: ContactsAppbarStyle.textFieldHeight, - child: Padding( - padding: ContactsAppbarStyle.searchFieldPadding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: SearchTextField( - textEditingController: textEditingController, - autofocus: false, - focusNode: searchFocusNode, - ), - ), - ], - ), + padding: ContactsAppbarStyle.searchFieldPadding, + child: SearchTextField( + textEditingController: textEditingController, + autofocus: false, + focusNode: searchFocusNode, ), ); }, diff --git a/lib/pages/contacts_tab/contacts_tab_body_view.dart b/lib/pages/contacts_tab/contacts_tab_body_view.dart index f79596b66a..c8db5e2962 100644 --- a/lib/pages/contacts_tab/contacts_tab_body_view.dart +++ b/lib/pages/contacts_tab/contacts_tab_body_view.dart @@ -2,6 +2,7 @@ import 'package:fluffychat/domain/app_state/contact/get_contacts_state.dart'; import 'package:fluffychat/pages/contacts_tab/contacts_tab.dart'; import 'package:fluffychat/pages/contacts_tab/contacts_tab_view_style.dart'; import 'package:fluffychat/pages/contacts_tab/empty_contacts_body.dart'; +import 'package:fluffychat/pages/contacts_tab/widgets/sliver_invite_friend_button.dart'; import 'package:fluffychat/pages/new_private_chat/widget/expansion_contact_list_tile.dart'; import 'package:fluffychat/pages/new_private_chat/widget/expansion_phonebook_contact_list_tile.dart'; import 'package:fluffychat/pages/new_private_chat/widget/loading_contact_widget.dart'; @@ -31,6 +32,8 @@ class ContactsTabBodyView extends StatelessWidget { Widget build(BuildContext context) { return CustomScrollView( slivers: [ + if (controller.client.userID != null) + SliverInviteFriendButton(userId: controller.client.userID!), _SliverWarningBanner(controller: controller), _SliverPhonebookLoading(controller: controller), _SliverRecentContacts(controller: controller), diff --git a/lib/pages/contacts_tab/widgets/sliver_invite_friend_button.dart b/lib/pages/contacts_tab/widgets/sliver_invite_friend_button.dart new file mode 100644 index 0000000000..5dc7d6bf9a --- /dev/null +++ b/lib/pages/contacts_tab/widgets/sliver_invite_friend_button.dart @@ -0,0 +1,111 @@ +import 'package:fluffychat/generated/l10n/app_localizations.dart'; +import 'package:fluffychat/presentation/extensions/client_extension.dart'; +import 'package:fluffychat/resource/image_paths.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/utils/string_extension.dart'; +import 'package:fluffychat/utils/twake_snackbar.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:linagora_design_flutter/colors/linagora_sys_colors.dart'; +import 'package:matrix/matrix.dart'; +import 'package:share_plus/share_plus.dart'; + +class SliverInviteFriendButton extends StatelessWidget { + const SliverInviteFriendButton({super.key, required this.userId}); + + final String userId; + + @override + Widget build(BuildContext context) { + return SliverPersistentHeader( + floating: true, + delegate: _InviteFriendButtonDelegate(userId), + ); + } +} + +class _InviteFriendButtonDelegate extends SliverPersistentHeaderDelegate { + const _InviteFriendButtonDelegate(this.userId); + + final String userId; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + final sysColor = LinagoraSysColors.material(); + final textTheme = Theme.of(context).textTheme; + final client = Matrix.of(context).client; + return Material( + color: sysColor.onPrimary, + child: InkWell( + onTap: () async { + final url = client.personalInviteUrl; + try { + if (PlatformInfos.isMobile) { + await Share.share(url); + } else if (PlatformInfos.isWeb) { + await Clipboard.setData(ClipboardData(text: url)); + TwakeSnackBar.show( + context, + L10n.of(context)!.linkCopiedToClipboard, + ); + } + } catch (e) { + Logs().e('InviteFriendButtonDelegate::onTap():', e); + } + }, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.symmetric( + horizontal: BorderSide( + color: sysColor.surfaceTint.withValues(alpha: 0.16), + ), + ), + ), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: SvgPicture.asset( + ImagePaths.icPersonCheck, + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + sysColor.primary, + BlendMode.srcIn, + ), + ), + ), + Flexible( + child: Text( + L10n.of(context)!.inviteFriend.capitalize(context), + style: textTheme.bodyLarge?.copyWith( + fontSize: 17, + height: 24 / 17, + color: sysColor.primary, + ), + ), + ), + ], + ), + ), + ), + ); + } + + @override + double get maxExtent => 64; + + @override + double get minExtent => 0; + + @override + bool shouldRebuild(covariant _InviteFriendButtonDelegate oldDelegate) => + oldDelegate.userId != userId; +} diff --git a/lib/pages/personal_qr/personal_qr_view.dart b/lib/pages/personal_qr/personal_qr_view.dart index cb94172ae6..ab1f4f6ec6 100644 --- a/lib/pages/personal_qr/personal_qr_view.dart +++ b/lib/pages/personal_qr/personal_qr_view.dart @@ -1,6 +1,5 @@ import 'dart:math'; -import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/generated/l10n/app_localizations.dart'; import 'package:fluffychat/pages/personal_qr/personal_qr.dart'; import 'package:fluffychat/pages/settings_dashboard/settings_profile/settings_profile_view_mobile_style.dart'; @@ -31,7 +30,7 @@ class PersonalQrView extends StatelessWidget { final sysColor = LinagoraSysColors.material(); final client = Matrix.of(context).client; final userId = client.userID ?? ''; - final qrData = '${AppConfig.inviteLinkPrefix}$userId'; + final qrData = client.personalInviteUrl; return Scaffold( appBar: TwakeAppBar( diff --git a/lib/presentation/extensions/client_extension.dart b/lib/presentation/extensions/client_extension.dart index 6ea192c182..cadca66a4a 100644 --- a/lib/presentation/extensions/client_extension.dart +++ b/lib/presentation/extensions/client_extension.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; +import 'package:fluffychat/config/app_constants.dart'; import 'package:fluffychat/presentation/enum/chat_list/chat_list_enum.dart'; import 'package:fluffychat/utils/string_extension.dart'; import 'package:flutter/material.dart'; @@ -37,4 +38,12 @@ extension ClientExtension on Client { false, ); } + + String get personalInviteUrl { + const domain = AppConstants.appLinkUniversalLinkDomain; + const fallbackUrl = 'https://sign-up.twake.app'; + return userID == null + ? 'https://$domain/chat?fallback=$fallbackUrl' + : 'https://$domain/chat/${Uri.encodeComponent(userID!)}?fallback=$fallbackUrl'; + } } diff --git a/lib/presentation/mixins/go_to_direct_chat_mixin.dart b/lib/presentation/mixins/go_to_direct_chat_mixin.dart index c3420dcf6d..f10a99637a 100644 --- a/lib/presentation/mixins/go_to_direct_chat_mixin.dart +++ b/lib/presentation/mixins/go_to_direct_chat_mixin.dart @@ -1,6 +1,8 @@ +import 'package:fluffychat/generated/l10n/app_localizations.dart'; import 'package:fluffychat/presentation/model/contact/presentation_contact_constant.dart'; import 'package:fluffychat/presentation/model/search/presentation_search.dart'; import 'package:fluffychat/utils/dialog/twake_dialog.dart'; +import 'package:fluffychat/utils/twake_snackbar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; @@ -75,15 +77,20 @@ mixin GoToDraftChatMixin { required String path, required ContactPresentationSearch contactPresentationSearch, }) { - if (contactPresentationSearch.matrixId != + if (contactPresentationSearch.matrixId == Matrix.of(context).client.userID) { + TwakeSnackBar.show(context, L10n.of(context)!.cannotCreateChatWithSelf); + return; + } + + if (contactPresentationSearch.matrixId != null) { Router.neglect( context, () => context.push( '/$path/draftChat', extra: { PresentationContactConstant.receiverId: - contactPresentationSearch.matrixId ?? '', + contactPresentationSearch.matrixId!, PresentationContactConstant.displayName: contactPresentationSearch.displayName ?? '', PresentationContactConstant.status: '', diff --git a/lib/utils/url_launcher.dart b/lib/utils/url_launcher.dart index 1d9bcf180e..c9c5f2a6f7 100644 --- a/lib/utils/url_launcher.dart +++ b/lib/utils/url_launcher.dart @@ -44,9 +44,12 @@ class UrlLauncher with GoToDraftChatMixin { return; } if (uri.host == AppConstants.appLinkUniversalLinkDomain) { - final pathWithoutChatPrefix = uri.path.replaceFirst('/chat', ''); - context.go(pathWithoutChatPrefix); - return; + final inviteUri = Uri.parse(AppConfig.inviteLinkPrefix); + final matrixToUri = uri.replace( + host: inviteUri.host, + path: uri.path.replaceFirst('/chat', ''), + ); + return openMatrixToUrl(matrixToUri.toString()); } if (!{'https', 'http'}.contains(uri.scheme)) { // just launch non-https / non-http uris directly @@ -104,12 +107,12 @@ class UrlLauncher with GoToDraftChatMixin { ); } - void openMatrixToUrl() async { + void openMatrixToUrl([String? customUrl]) async { final matrix = Matrix.of(context); - final url = this.url!.replaceFirst( - AppConfig.deepLinkPrefix, - AppConfig.inviteLinkPrefix, - ); + final url = (customUrl ?? this.url!).replaceFirst( + AppConfig.deepLinkPrefix, + AppConfig.inviteLinkPrefix, + ); // The identifier might be a matrix.to url and needs escaping. Or, it might have multiple // identifiers (room id & event id), or it might also have a query part. diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 17b858dc57..32622d2ef5 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -320,6 +320,7 @@ class MatrixState extends State StreamSubscription? onFocusSub; StreamSubscription? onBlurSub; final initSettingsCompleter = Completer(); + bool _hasInitializedSharingIntent = false; String? _cachedPassword; Timer? _cachedPasswordClearTimer; @@ -360,7 +361,6 @@ class MatrixState extends State initMatrix(); final emojiRawData = await EmojiData.builtIn(); emojiData = emojiRawData.filterByVersion(13.5); - initReceiveSharingIntent(); await tryToGetFederationConfigurations(); if (PlatformInfos.isWeb) { initConfigWeb().then((_) => initSettings()); @@ -377,6 +377,29 @@ class MatrixState extends State }); } + /// Initializes sharing intent once when user is logged in + void _initializeSharingIntentOnce() { + if (_hasInitializedSharingIntent) return; + _hasInitializedSharingIntent = true; + initReceiveSharingIntent(); + } + + /// Initializes sharing intent after first sync completes + void _initializeSharingIntentAfterFirstSync(Client client) { + if (_hasInitializedSharingIntent) return; + client.onSync.stream.first.then((_) { + Logs().d( + 'MatrixState::_initializeSharingIntentAfterFirstSync: First sync completed, initializing sharing intent', + ); + _initializeSharingIntentOnce(); + }).catchError((error) { + Logs().e( + 'MatrixState::_initializeSharingIntentAfterFirstSync: Error waiting for first sync: $error', + ); + _initializeSharingIntentOnce(); + }); + } + void _registerSubs(String name) async { final currentClient = getClientByName(name); if (currentClient == null) { @@ -520,6 +543,7 @@ class MatrixState extends State await _getUserInfoWithActiveClient(newActiveClient); await _getHomeserverInformation(newActiveClient); matrixState.reSyncContacts(); + _initializeSharingIntentAfterFirstSync(newActiveClient); onClientLoginStateChanged.add( ClientLoginStateEvent( client: client, @@ -647,6 +671,11 @@ class MatrixState extends State } createVoipPlugin(); + + // Initialize sharing intent if there are already logged-in clients + if (widget.clients.isNotEmpty && widget.clients.any((c) => c.isLogged())) { + _initializeSharingIntentOnce(); + } } void createVoipPlugin() async {