From ad3546a209b45695c9e283bb9d8b9b182364ffa6 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Thu, 10 Jul 2025 09:42:44 -0400 Subject: [PATCH] 3028 easier to invite users to a chat (#3387) * chore: inital work for invite page updates * open user dialog on click user in invite list --- lib/config/routes.dart | 17 +- lib/l10n/intl_en.arb | 23 + .../invitation_selection.dart | 149 +----- .../invitation_selection_view.dart | 305 +++-------- .../pages/pangea_chat_details.dart | 10 +- .../pages/pangea_invitation_selection.dart | 437 ++++++++++++++++ .../pangea_invitation_selection_view.dart | 477 ++++++++++++++++++ 7 files changed, 1040 insertions(+), 378 deletions(-) create mode 100644 lib/pangea/chat_settings/pages/pangea_invitation_selection.dart create mode 100644 lib/pangea/chat_settings/pages/pangea_invitation_selection_view.dart diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 5cd585a4b..de4176268 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -15,7 +15,6 @@ import 'package:fluffychat/pages/chat_members/chat_members.dart'; import 'package:fluffychat/pages/chat_permissions_settings/chat_permissions_settings.dart'; import 'package:fluffychat/pages/chat_search/chat_search_page.dart'; import 'package:fluffychat/pages/device_settings/device_settings.dart'; -import 'package:fluffychat/pages/invitation_selection/invitation_selection.dart'; import 'package:fluffychat/pages/login/login.dart'; import 'package:fluffychat/pages/new_group/new_group.dart'; import 'package:fluffychat/pages/new_private_chat/new_private_chat.dart'; @@ -34,6 +33,7 @@ import 'package:fluffychat/pangea/activity_generator/activity_generator.dart'; import 'package:fluffychat/pangea/activity_planner/activity_planner_page.dart'; import 'package:fluffychat/pangea/analytics_page/analytics_page.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; +import 'package:fluffychat/pangea/chat_settings/pages/pangea_invitation_selection.dart'; import 'package:fluffychat/pangea/common/widgets/pangea_side_view.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/find_your_people/find_your_people.dart'; @@ -576,8 +576,13 @@ abstract class AppRoutes { pageBuilder: (context, state) => defaultPageBuilder( context, state, - InvitationSelection( + PangeaInvitationSelection( roomId: state.pathParameters['roomid']!, + initialFilter: state.uri.queryParameters['filter'] != null + ? InvitationFilter.fromString( + state.uri.queryParameters['filter']!, + ) + : null, ), ), redirect: loggedOutRedirect, @@ -657,8 +662,14 @@ abstract class AppRoutes { pageBuilder: (context, state) => defaultPageBuilder( context, state, - InvitationSelection( + PangeaInvitationSelection( roomId: state.pathParameters['roomid']!, + initialFilter: + state.uri.queryParameters['filter'] != null + ? InvitationFilter.fromString( + state.uri.queryParameters['filter']!, + ) + : null, ), ), redirect: loggedOutRedirect, diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index bcb795ed5..7997c6615 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5038,5 +5038,28 @@ "saveActivity": "Save this activity", "yourSavedActivities": "Saved Activities", "failedToPlayVideo": "Failed to play video", + "done": "Done", + "inThisSpace": "In this space", + "myContacts": "My contacts", + "inviteAllInSpace": "Invite all in this space", + "spaceParticipantsHaveBeenInvitedToTheChat": "All space participants has been invited to the chat", + "numKnocking": "{count} knocking", + "@numKnocking": { + "type": "String", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "numInvited": "{count} invited", + "@numInvited": { + "type": "String", + "placeholders": { + "count": { + "type": "int" + } + } + }, "saved": "Saved" } diff --git a/lib/pages/invitation_selection/invitation_selection.dart b/lib/pages/invitation_selection/invitation_selection.dart index 4929e27a9..0c0d27fe5 100644 --- a/lib/pages/invitation_selection/invitation_selection.dart +++ b/lib/pages/invitation_selection/invitation_selection.dart @@ -2,13 +2,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:collection/collection.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/invitation_selection/invitation_selection_view.dart'; -import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; -import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../utils/localized_exception_extension.dart'; @@ -34,122 +31,26 @@ class InvitationSelectionController extends State { String? get roomId => widget.roomId; - // #Pangea - - List? get participants { - final room = Matrix.of(context).client.getRoomById(roomId!); - return room?.getParticipants(); - } - - List get _membershipOrder => [ - Membership.join, - Membership.invite, - Membership.knock, - Membership.leave, - Membership.ban, - ]; - - String? membershipCopy(Membership? membership) => switch (membership) { - Membership.ban => L10n.of(context).banned, - Membership.invite => L10n.of(context).invited, - Membership.join => null, - Membership.knock => L10n.of(context).knocking, - Membership.leave => L10n.of(context).leftTheChat, - null => null, - }; - - int _sortUsers(User a, User b) { - // sort yourself to the top - final client = Matrix.of(context).client; - if (a.id == client.userID) return -1; - if (b.id == client.userID) return 1; - - // sort the bot to the bottom - if (a.id == BotName.byEnvironment) return 1; - if (b.id == BotName.byEnvironment) return -1; - - if (participants != null) { - final participantA = participants!.firstWhereOrNull((u) => u.id == a.id); - final participantB = participants!.firstWhereOrNull((u) => u.id == b.id); - // sort all participants first, with admins first, then moderators, then the rest - if (participantA?.membership == null && - participantB?.membership != null) { - return 1; - } - if (participantA?.membership != null && - participantB?.membership == null) { - return -1; - } - if (participantA?.membership != null && - participantB?.membership != null) { - final aIndex = _membershipOrder.indexOf(participantA!.membership); - final bIndex = _membershipOrder.indexOf(participantB!.membership); - if (aIndex != bIndex) { - return aIndex.compareTo(bIndex); - } - } - } - - // finally, sort by displayname - final aName = a.calcDisplayname().toLowerCase(); - final bName = b.calcDisplayname().toLowerCase(); - return aName.compareTo(bName); - } - // Pangea# - Future> getContacts(BuildContext context) async { final client = Matrix.of(context).client; final room = client.getRoomById(roomId!)!; final participants = (room.summary.mJoinedMemberCount ?? 0) > 100 ? room.getParticipants() - // #Pangea - // : await room.requestParticipants(); - : await room.requestParticipants( - [Membership.join, Membership.invite, Membership.knock], - false, - true, - ); - // Pangea# + : await room.requestParticipants(); participants.removeWhere( (u) => ![Membership.join, Membership.invite].contains(u.membership), ); final contacts = client.rooms .where((r) => r.isDirectChat) - // #Pangea - // .map((r) => r.unsafeGetUserFromMemoryOrFallback(r.directChatMatrixID!)) - .map( - (r) => r - .getParticipants() - .firstWhereOrNull((u) => u.id != client.userID), - ) - .where((u) => u != null) - .cast() - // Pangea# + .map((r) => r.unsafeGetUserFromMemoryOrFallback(r.directChatMatrixID!)) .toList(); - // #Pangea - final mutuals = client.rooms - .where((r) => r.isSpace) - .map((r) => r.getParticipants()) - .expand((element) => element) - .toList(); - - for (final user in mutuals) { - final index = contacts.indexWhere((u) => u.id == user.id); - if (index == -1) { - contacts.add(user); - } - } - - contacts.sort(_sortUsers); + contacts.sort( + (a, b) => a.calcDisplayname().toLowerCase().compareTo( + b.calcDisplayname().toLowerCase(), + ), + ); return contacts; - // contacts.sort( - // (a, b) => a.calcDisplayname().toLowerCase().compareTo( - // b.calcDisplayname().toLowerCase(), - // ), - // ); - // return contacts; - //Pangea# } void inviteAction(BuildContext context, String id, String displayname) async { @@ -162,10 +63,7 @@ class InvitationSelectionController extends State { if (success.error == null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - // #Pangea - // content: Text(L10n.of(context).contactHasBeenInvitedToTheGroup), - content: Text(L10n.of(context).contactHasBeenInvitedToTheChat), - // Pangea# + content: Text(L10n.of(context).contactHasBeenInvitedToTheGroup), ), ); } @@ -186,25 +84,12 @@ class InvitationSelectionController extends State { } currentSearchTerm = text; if (currentSearchTerm.isEmpty) return; - //#Pangea - String pangeaSearchText = text; - if (!pangeaSearchText.startsWith("@")) { - pangeaSearchText = "@$pangeaSearchText"; - } - if (!pangeaSearchText.contains(":")) { - pangeaSearchText = "$pangeaSearchText:${Environment.homeServer}"; - } - //#Pangea if (loading) return; setState(() => loading = true); final matrix = Matrix.of(context); SearchUserDirectoryResponse response; try { - // response = await matrix.client.searchUserDirectory(text, limit: 10); - //#Pangea - response = - await matrix.client.searchUserDirectory(pangeaSearchText, limit: 10); - //#Pangea + response = await matrix.client.searchUserDirectory(text, limit: 10); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text((e).toLocalizedString(context))), @@ -223,22 +108,6 @@ class InvitationSelectionController extends State { ], ); } - //#Pangea - final participants = Matrix.of(context) - .client - .getRoomById(roomId!) - ?.getParticipants() - .where( - (user) => - [Membership.join, Membership.invite].contains(user.membership), - ) - .toList(); - foundProfiles.removeWhere( - (profile) => - participants?.indexWhere((u) => u.id == profile.userId) != -1 && - BotName.byEnvironment != profile.userId, - ); - //Pangea# }); } diff --git a/lib/pages/invitation_selection/invitation_selection_view.dart b/lib/pages/invitation_selection/invitation_selection_view.dart index 2df3b6a56..f4a84d5c8 100644 --- a/lib/pages/invitation_selection/invitation_selection_view.dart +++ b/lib/pages/invitation_selection/invitation_selection_view.dart @@ -1,21 +1,9 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; -import 'package:universal_html/html.dart' as html; -import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/invitation_selection/invitation_selection.dart'; -import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart'; -import 'package:fluffychat/pangea/chat_settings/constants/room_settings_constants.dart'; -import 'package:fluffychat/pangea/common/config/environment.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/spaces/constants/space_constants.dart'; -import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -41,81 +29,20 @@ class InvitationSelectionView extends StatelessWidget { ); } - // #Pangea - // final groupName = room.name.isEmpty ? L10n.of(context).group : room.name; - // Pangea# + final groupName = room.name.isEmpty ? L10n.of(context).group : room.name; final theme = Theme.of(context); return Scaffold( appBar: AppBar( leading: const Center(child: BackButton()), titleSpacing: 0, title: Text(L10n.of(context).inviteContact), - // #Pangea - actions: [ - if (room.isSpace && room.classCode != null) - PopupMenuButton( - icon: const Icon(Icons.share_outlined), - onSelected: (value) async { - final spaceCode = room.classCode!; - String toCopy = spaceCode; - if (value == 0) { - final String initialUrl = - kIsWeb ? html.window.origin! : Environment.frontendURL; - toCopy = - "$initialUrl/#/join_with_link?${SpaceConstants.classCode}=${room.classCode}"; - } - - await Clipboard.setData(ClipboardData(text: toCopy)); - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text( - L10n.of(context).copiedToClipboard, - ), - ), - ); - }, - itemBuilder: (BuildContext context) => >[ - PopupMenuItem( - value: 0, - child: ListTile( - leading: const Icon(Icons.share_outlined), - title: Text(L10n.of(context).shareSpaceLink), - contentPadding: const EdgeInsets.all(0), - ), - ), - PopupMenuItem( - value: 1, - child: ListTile( - leading: const Icon(Icons.share_outlined), - title: Text( - L10n.of(context).shareInviteCode(room.classCode!), - ), - contentPadding: const EdgeInsets.all(0), - ), - ), - ], - ), - ], - // Pangea# ), body: MaxWidthBody( innerPadding: const EdgeInsets.symmetric(vertical: 8), - // #Pangea - withScrolling: false, - // Pangea# child: Column( children: [ Padding( - // #Pangea - // padding: const EdgeInsets.all(16.0), - padding: const EdgeInsets.only( - bottom: 16.0, - left: 16.0, - right: 16.0, - ), - // Pangea# + padding: const EdgeInsets.all(16.0), child: TextField( textInputAction: TextInputAction.search, decoration: InputDecoration( @@ -129,10 +56,7 @@ class InvitationSelectionView extends StatelessWidget { color: theme.colorScheme.onPrimaryContainer, fontWeight: FontWeight.normal, ), - // #Pangea - hintText: L10n.of(context).inviteStudentByUserName, - // hintText: L10n.of(context).inviteContactToGroup(groupName), - // Pangea# + hintText: L10n.of(context).inviteContactToGroup(groupName), prefixIcon: controller.loading ? const Padding( padding: EdgeInsets.symmetric( @@ -151,134 +75,69 @@ class InvitationSelectionView extends StatelessWidget { onChanged: controller.searchUserWithCoolDown, ), ), - // #Pangea - // StreamBuilder( - Expanded( - child: StreamBuilder( - // stream: room.client.onRoomState.stream - // .where((update) => update.roomId == room.id), - stream: room.client.onRoomState.stream - .where((update) => update.roomId == room.id) - .rateLimit(const Duration(seconds: 1)), - // Pangea# - builder: (context, snapshot) { - final participants = - room.getParticipants().map((user) => user.id).toSet(); - return controller.foundProfiles.isNotEmpty - ? ListView.builder( - // #Pangea - // physics: const NeverScrollableScrollPhysics(), - // shrinkWrap: true, - // Pangea# - itemCount: controller.foundProfiles.length, - itemBuilder: (BuildContext context, int i) => - _InviteContactListTile( - profile: controller.foundProfiles[i], - isMember: participants.contains( - controller.foundProfiles[i].userId, - ), - onTap: () => controller.inviteAction( - context, - controller.foundProfiles[i].userId, - controller.foundProfiles[i].displayName ?? - controller - .foundProfiles[i].userId.localpart ?? - L10n.of(context).user, - ), + StreamBuilder( + stream: room.client.onRoomState.stream + .where((update) => update.roomId == room.id), + builder: (context, snapshot) { + final participants = + room.getParticipants().map((user) => user.id).toSet(); + return controller.foundProfiles.isNotEmpty + ? ListView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: controller.foundProfiles.length, + itemBuilder: (BuildContext context, int i) => + _InviteContactListTile( + profile: controller.foundProfiles[i], + isMember: participants + .contains(controller.foundProfiles[i].userId), + onTap: () => controller.inviteAction( + context, + controller.foundProfiles[i].userId, + controller.foundProfiles[i].displayName ?? + controller.foundProfiles[i].userId.localpart ?? + L10n.of(context).user, ), - ) - : FutureBuilder>( - future: controller.getContacts(context), - builder: (BuildContext context, snapshot) { - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - ); - } - final contacts = snapshot.data!; - return ListView.builder( - // #Pangea - // physics: const NeverScrollableScrollPhysics(), - // shrinkWrap: true, - // itemCount: contacts.length, - // itemBuilder: (BuildContext context, int i) => - // _InviteContactListTile( - itemCount: contacts.length + 1, - itemBuilder: (BuildContext context, int i) { - if (i == contacts.length) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: SizedBox( - width: 450, - child: CachedNetworkImage( - imageUrl: - "${AppConfig.assetsBaseURL}/${RoomSettingsConstants.referFriendAsset}", - errorWidget: (context, url, error) => - const SizedBox(), - placeholder: (context, url) => - const Center( - child: CircularProgressIndicator - .adaptive(), - ), - ), - ), - ); - } - return _InviteContactListTile( - // Pangea# - user: contacts[i], - profile: Profile( - avatarUrl: contacts[i].avatarUrl, - displayName: contacts[i].displayName ?? - contacts[i].id.localpart ?? - L10n.of(context).user, - userId: contacts[i].id, - ), - isMember: - participants.contains(contacts[i].id), - onTap: () => controller.inviteAction( - context, - contacts[i].id, - contacts[i].displayName ?? - contacts[i].id.localpart ?? - L10n.of(context).user, - ), - ); - }, + ), + ) + : FutureBuilder>( + future: controller.getContacts(context), + builder: (BuildContext context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), ); - }, - ); - }, - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primaryContainer, - elevation: 5.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - room.isSpace - ? L10n.of(context).goToSpaceButton - : L10n.of(context).goToChat, - style: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - ), - ), - ], - ), - onPressed: () => context.go( - room.isSpace - ? "/rooms?spaceId=${room.id}" - : "/rooms/${room.id}", - ), - ), + } + final contacts = snapshot.data!; + return ListView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: contacts.length, + itemBuilder: (BuildContext context, int i) => + _InviteContactListTile( + user: contacts[i], + profile: Profile( + avatarUrl: contacts[i].avatarUrl, + displayName: contacts[i].displayName ?? + contacts[i].id.localpart ?? + L10n.of(context).user, + userId: contacts[i].id, + ), + isMember: participants.contains(contacts[i].id), + onTap: () => controller.inviteAction( + context, + contacts[i].id, + contacts[i].displayName ?? + contacts[i].id.localpart ?? + L10n.of(context).user, + ), + ), + ); + }, + ); + }, ), ], ), @@ -302,9 +161,7 @@ class _InviteContactListTile extends StatelessWidget { @override Widget build(BuildContext context) { - // #Pangea - // final theme = Theme.of(context); - // Pangea# + final theme = Theme.of(context); final l10n = L10n.of(context); return ListTile( @@ -322,32 +179,14 @@ class _InviteContactListTile extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - // #Pangea - // subtitle: Text( - // profile.userId, - // maxLines: 1, - // overflow: TextOverflow.ellipsis, - // style: TextStyle( - // color: theme.colorScheme.secondary, - // ), - // ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // https://github.com/pangeachat/client/issues/3047 - const SizedBox(height: 2.0), - Text( - profile.userId, - style: const TextStyle( - fontSize: 12.0, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - LevelDisplayName(userId: profile.userId), - ], + subtitle: Text( + profile.userId, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: theme.colorScheme.secondary, + ), ), - // Pangea# trailing: TextButton.icon( onPressed: isMember ? null : onTap, label: Text(isMember ? l10n.participant : l10n.invite), diff --git a/lib/pangea/chat_settings/pages/pangea_chat_details.dart b/lib/pangea/chat_settings/pages/pangea_chat_details.dart index 6aef36342..db8dc64b7 100644 --- a/lib/pangea/chat_settings/pages/pangea_chat_details.dart +++ b/lib/pangea/chat_settings/pages/pangea_chat_details.dart @@ -175,7 +175,7 @@ class PangeaChatDetailsView extends StatelessWidget { onPressed: room.isDirectChat ? null : () => context.push( - '/rooms/${controller.roomId}/details/members', + '/rooms/${controller.roomId}/details/invite?filter=participants', ), icon: const Icon( Icons.group_outlined, @@ -359,7 +359,13 @@ class RoomDetailsButtonRowState extends State { ButtonDetails( title: l10n.invite, icon: const Icon(Icons.person_add_outlined, size: 30.0), - onPressed: () => context.go('/rooms/${room.id}/details/invite'), + onPressed: () { + String filter = 'knocking'; + if (room.getParticipants([Membership.knock]).isEmpty) { + filter = room.pangeaSpaceParents.isNotEmpty ? 'space' : 'contacts'; + } + context.go('/rooms/${room.id}/details/invite?filter=$filter'); + }, visible: (room.canInvite && !room.isDirectChat) || room.isSpace, enabled: room.canInvite && !room.isDirectChat, ), diff --git a/lib/pangea/chat_settings/pages/pangea_invitation_selection.dart b/lib/pangea/chat_settings/pages/pangea_invitation_selection.dart new file mode 100644 index 000000000..b285300d2 --- /dev/null +++ b/lib/pangea/chat_settings/pages/pangea_invitation_selection.dart @@ -0,0 +1,437 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; +import 'package:fluffychat/pangea/chat_settings/pages/pangea_invitation_selection_view.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +enum InvitationFilter { + space, + contacts, + knocking, + invited, + participants, + public; + + static InvitationFilter? fromString(String value) { + switch (value) { + case 'space': + return InvitationFilter.space; + case 'contacts': + return InvitationFilter.contacts; + case 'invited': + return InvitationFilter.invited; + case 'knocking': + return InvitationFilter.knocking; + case 'public': + return InvitationFilter.public; + case 'participants': + return InvitationFilter.participants; + default: + return null; + } + } + + String get string { + switch (this) { + case InvitationFilter.space: + return 'space'; + case InvitationFilter.contacts: + return 'contacts'; + case InvitationFilter.invited: + return 'invited'; + case InvitationFilter.knocking: + return 'knocking'; + case InvitationFilter.public: + return 'public'; + case InvitationFilter.participants: + return 'participants'; + } + } +} + +class PangeaInvitationSelection extends StatefulWidget { + final String roomId; + final InvitationFilter? initialFilter; + const PangeaInvitationSelection({ + super.key, + required this.roomId, + this.initialFilter, + }); + + @override + PangeaInvitationSelectionController createState() => + PangeaInvitationSelectionController(); +} + +class PangeaInvitationSelectionController + extends State { + TextEditingController controller = TextEditingController(); + + bool loading = true; + + List foundProfiles = []; + Timer? coolDown; + + InvitationFilter filter = InvitationFilter.knocking; + + @override + void initState() { + super.initState(); + + if (widget.initialFilter != null && + availableFilters.contains(widget.initialFilter)) { + filter = widget.initialFilter!; + } else if (spaceParent != null) { + filter = InvitationFilter.space; + } else if (_room?.getParticipants([Membership.knock]).isEmpty ?? true) { + filter = InvitationFilter.contacts; + } + + if (filter == InvitationFilter.public) { + searchUser(context, ''); + } + + _room?.requestParticipants( + [ + Membership.join, + Membership.invite, + Membership.knock, + ], + false, + true, + ).then((_) { + if (mounted) { + setState(() { + loading = false; + }); + } + }); + + spaceParent?.requestParticipants( + [ + Membership.join, + Membership.invite, + Membership.knock, + ], + false, + true, + ).then((_) { + if (mounted) setState(() {}); + }); + + controller.addListener(() { + setState(() {}); + }); + } + + String filterLabel(InvitationFilter filter) { + final l10n = L10n.of(context); + switch (filter) { + case InvitationFilter.space: + return l10n.inThisSpace; + case InvitationFilter.contacts: + return l10n.myContacts; + case InvitationFilter.invited: + return l10n.numInvited(_room?.summary.mInvitedMemberCount ?? 0); + case InvitationFilter.knocking: + return l10n.numKnocking( + participants?.where((u) => u.membership == Membership.knock).length ?? + 0, + ); + case InvitationFilter.public: + return l10n.public; + case InvitationFilter.participants: + return l10n.classRoster; + } + } + + Room? get _room => Matrix.of(context).client.getRoomById(widget.roomId); + + Room? get spaceParent { + final spaceParents = _room?.spaceParents; + if (spaceParents == null || spaceParents.isEmpty) { + return null; + } + + final client = Matrix.of(context).client; + for (final parent in spaceParents) { + if (parent.roomId == null) continue; + final space = client.getRoomById(parent.roomId!); + if (space != null) return space; + } + return null; + } + + List get availableFilters => InvitationFilter.values + .where( + (f) => switch (f) { + InvitationFilter.space => spaceParent != null, + InvitationFilter.contacts => true, + InvitationFilter.invited => participants?.any( + (u) => u.membership == Membership.invite, + ) ?? + false, + InvitationFilter.knocking => participants?.any( + (u) => u.membership == Membership.knock, + ) ?? + false, + InvitationFilter.public => true, + InvitationFilter.participants => true, + }, + ) + .toList(); + + List? get participants { + return _room?.getParticipants(); + } + + List get _membershipOrder => [ + Membership.join, + Membership.invite, + Membership.knock, + Membership.leave, + Membership.ban, + ]; + + String? membershipCopy(Membership? membership) => switch (membership) { + Membership.ban => L10n.of(context).banned, + Membership.invite => L10n.of(context).invited, + Membership.join => null, + Membership.knock => L10n.of(context).knocking, + Membership.leave => L10n.of(context).leftTheChat, + null => null, + }; + + int _sortUsers(User a, User b) { + // sort yourself to the top + final client = Matrix.of(context).client; + if (a.id == client.userID) return -1; + if (b.id == client.userID) return 1; + + // sort the bot to the bottom + if (a.id == BotName.byEnvironment) return 1; + if (b.id == BotName.byEnvironment) return -1; + + if (participants != null) { + final participantA = participants!.firstWhereOrNull((u) => u.id == a.id); + final participantB = participants!.firstWhereOrNull((u) => u.id == b.id); + // sort all participants first, with admins first, then moderators, then the rest + if (participantA?.membership == null && + participantB?.membership != null) { + return 1; + } + if (participantA?.membership != null && + participantB?.membership == null) { + return -1; + } + if (participantA?.membership != null && + participantB?.membership != null) { + final aIndex = _membershipOrder.indexOf(participantA!.membership); + final bIndex = _membershipOrder.indexOf(participantB!.membership); + if (aIndex != bIndex) { + return aIndex.compareTo(bIndex); + } + } + } + + // finally, sort by displayname + final aName = a.calcDisplayname().toLowerCase(); + final bName = b.calcDisplayname().toLowerCase(); + return aName.compareTo(bName); + } + + void setFilter(InvitationFilter newFilter) { + if (filter == newFilter) return; + if (newFilter == InvitationFilter.public) { + searchUser(context, ''); + } + setState(() => filter = newFilter); + } + + List filteredContacts() { + List contacts = []; + switch (filter) { + case InvitationFilter.space: + contacts = spaceParent?.getParticipants() ?? []; + case InvitationFilter.contacts: + contacts = getContacts(context); + case InvitationFilter.invited: + contacts = participants + ?.where( + (u) => u.membership == Membership.invite, + ) + .toList() ?? + []; + case InvitationFilter.knocking: + contacts = participants + ?.where( + (u) => u.membership == Membership.knock, + ) + .toList() ?? + []; + default: + contacts = participants ?? []; + } + + final search = controller.text.toLowerCase(); + contacts = contacts + .where( + (u) => + u.calcDisplayname().toLowerCase().contains(search) || + u.id.toLowerCase().contains(search), + ) + .toList(); + + contacts.sort(_sortUsers); + return contacts; + } + + List getContacts(BuildContext context) { + final client = Matrix.of(context).client; + participants!.removeWhere( + (u) => ![Membership.join, Membership.invite].contains(u.membership), + ); + final contacts = client.rooms + .where((r) => r.isDirectChat) + .map((r) => r.unsafeGetUserFromMemoryOrFallback(r.directChatMatrixID!)) + .toList(); + contacts.sort( + (a, b) => a.calcDisplayname().toLowerCase().compareTo( + b.calcDisplayname().toLowerCase(), + ), + ); + return contacts; + } + + void searchUserWithCoolDown(String text) async { + if (filter != InvitationFilter.public) return; + coolDown?.cancel(); + coolDown = Timer( + const Duration(milliseconds: 500), + () => searchUser(context, text), + ); + } + + Future searchUser(BuildContext context, String text) async { + coolDown?.cancel(); + if (text.isEmpty) { + setState(() => foundProfiles = []); + } + + String pangeaSearchText = text; + if (!pangeaSearchText.startsWith("@")) { + pangeaSearchText = "@$pangeaSearchText"; + } + if (!pangeaSearchText.contains(":")) { + pangeaSearchText = "$pangeaSearchText:${Environment.homeServer}"; + } + + setState(() => loading = true); + final matrix = Matrix.of(context); + SearchUserDirectoryResponse response; + try { + response = + await matrix.client.searchUserDirectory(pangeaSearchText, limit: 100); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text((e).toLocalizedString(context))), + ); + return; + } finally { + setState(() => loading = false); + } + setState(() { + foundProfiles = List.from(response.results); + if (text.isValidMatrixId && + foundProfiles.indexWhere((profile) => text == profile.userId) == -1) { + setState( + () => foundProfiles = [ + Profile.fromJson({'user_id': text}), + ], + ); + } + + final participants = this + .participants + ?.where( + (user) => + [Membership.join, Membership.invite].contains(user.membership), + ) + .toList(); + foundProfiles.removeWhere( + (profile) => + participants?.indexWhere((u) => u.id == profile.userId) != -1 && + BotName.byEnvironment != profile.userId, + ); + }); + } + + void inviteAction(BuildContext context, String id, String displayname) async { + final room = Matrix.of(context).client.getRoomById(widget.roomId)!; + + final success = await showFutureLoadingDialog( + context: context, + future: () => room.invite(id), + ); + if (success.error == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(L10n.of(context).contactHasBeenInvitedToTheChat), + ), + ); + } + } + + Future inviteAllInSpace() async { + if (_room == null) return; + final spaceParticipants = + spaceParent?.getParticipants([Membership.join]) ?? []; + + if (spaceParticipants.isEmpty) return; + + final List futures = []; + for (final user in spaceParticipants) { + if (participants?.any((u) => u.id == user.id) ?? false) { + // User is already in the room + continue; + } + + if (user.id == Matrix.of(context).client.userID) continue; + futures.add(_room!.invite(user.id)); + } + + await showFutureLoadingDialog( + context: context, + future: () async { + await Future.wait(futures); + return null; // No error + }, + ).then((result) { + if (result.error == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + L10n.of(context).spaceParticipantsHaveBeenInvitedToTheChat, + ), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.error.toString())), + ); + } + }); + } + + @override + Widget build(BuildContext context) => PangeaInvitationSelectionView(this); +} diff --git a/lib/pangea/chat_settings/pages/pangea_invitation_selection_view.dart b/lib/pangea/chat_settings/pages/pangea_invitation_selection_view.dart new file mode 100644 index 000000000..62985a972 --- /dev/null +++ b/lib/pangea/chat_settings/pages/pangea_invitation_selection_view.dart @@ -0,0 +1,477 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collection/collection.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; +import 'package:universal_html/html.dart' as html; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart'; +import 'package:fluffychat/pangea/chat_settings/constants/room_settings_constants.dart'; +import 'package:fluffychat/pangea/chat_settings/pages/pangea_invitation_selection.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/spaces/constants/space_constants.dart'; +import 'package:fluffychat/utils/fluffy_share.dart'; +import 'package:fluffychat/utils/stream_extension.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/user_dialog.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/layouts/max_width_body.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/member_actions_popup_menu_button.dart'; + +class PangeaInvitationSelectionView extends StatelessWidget { + final PangeaInvitationSelectionController controller; + + const PangeaInvitationSelectionView(this.controller, {super.key}); + + @override + Widget build(BuildContext context) { + final room = + Matrix.of(context).client.getRoomById(controller.widget.roomId); + if (room == null) { + return Scaffold( + appBar: AppBar( + title: Text(L10n.of(context).oopsSomethingWentWrong), + ), + body: Center( + child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat), + ), + ); + } + + final theme = Theme.of(context); + final contacts = controller.filteredContacts(); + + final alias = room.canonicalAlias.isEmpty + ? controller.widget.roomId + : room.canonicalAlias; + + return Scaffold( + appBar: AppBar( + leading: const Center(child: BackButton()), + titleSpacing: 0, + title: Text(L10n.of(context).inviteContact), + ), + body: MaxWidthBody( + maxWidth: 800.0, + withScrolling: false, + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + children: [ + TextField( + controller: controller.controller, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + filled: true, + fillColor: theme.colorScheme.secondaryContainer, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(99), + ), + hintStyle: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.normal, + ), + hintText: L10n.of(context).search, + prefixIcon: controller.loading + ? const Padding( + padding: EdgeInsets.symmetric( + vertical: 10.0, + horizontal: 12, + ), + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ), + ) + : const Icon(Icons.search_outlined), + ), + onChanged: controller.searchUserWithCoolDown, + ), + const SizedBox(height: 12.0), + Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + spacing: 12.0, + children: controller.availableFilters.map((filter) { + return FilterChip( + label: Text(controller.filterLabel(filter)), + onSelected: (_) => controller.setFilter(filter), + selected: controller.filter == filter, + ); + }).toList(), + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 40.0), + child: StreamBuilder( + stream: room.client.onRoomState.stream + .where((update) => update.roomId == room.id) + .rateLimit(const Duration(seconds: 1)), + builder: (context, snapshot) { + final participants = + room.getParticipants().map((user) => user.id).toSet(); + return controller.filter == InvitationFilter.public + ? ListView.builder( + itemCount: controller.foundProfiles.length, + itemBuilder: (BuildContext context, int i) => + _InviteContactListTile( + profile: controller.foundProfiles[i], + isMember: participants.contains( + controller.foundProfiles[i].userId, + ), + onTap: () => controller.inviteAction( + context, + controller.foundProfiles[i].userId, + controller.foundProfiles[i].displayName ?? + controller + .foundProfiles[i].userId.localpart ?? + L10n.of(context).user, + ), + controller: controller, + ), + ) + : ListView.builder( + itemCount: contacts.length + 2, + itemBuilder: (BuildContext context, int i) { + if (i == 0) { + return controller.filter == + InvitationFilter.space && + controller.spaceParent != null + ? ListTile( + leading: Avatar( + mxContent: + controller.spaceParent!.avatar, + name: controller.spaceParent! + .getLocalizedDisplayname(), + borderRadius: BorderRadius.circular( + AppConfig.borderRadius / 4, + ), + ), + title: Text( + controller.spaceParent! + .getLocalizedDisplayname(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + L10n.of(context).countParticipants( + controller.spaceParent!.summary + .mJoinedMemberCount ?? + 1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: TextButton.icon( + onPressed: + controller.inviteAllInSpace, + label: Text( + L10n.of(context).inviteAllInSpace, + ), + icon: const Icon(Icons.add), + ), + ) + : const SizedBox(); + } + + i--; + + if (i == contacts.length) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: 450, + child: CachedNetworkImage( + imageUrl: + "${AppConfig.assetsBaseURL}/${RoomSettingsConstants.referFriendAsset}", + errorWidget: (context, url, error) => + const SizedBox(), + placeholder: (context, url) => + const Center( + child: CircularProgressIndicator + .adaptive(), + ), + ), + ), + ); + } + return _InviteContactListTile( + user: contacts[i], + profile: Profile( + avatarUrl: contacts[i].avatarUrl, + displayName: contacts[i].displayName ?? + contacts[i].id.localpart ?? + L10n.of(context).user, + userId: contacts[i].id, + ), + isMember: + participants.contains(contacts[i].id), + onTap: () => controller.inviteAction( + context, + contacts[i].id, + contacts[i].displayName ?? + contacts[i].id.localpart ?? + L10n.of(context).user, + ), + controller: controller, + ); + }, + ); + }, + ), + ), + ), + Row( + spacing: 12.0, + children: [ + if (room.isSpace && room.classCode != null) + Expanded( + child: PopupMenuButton( + borderRadius: BorderRadius.circular(32.0), + child: IgnorePointer( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + theme.colorScheme.primaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + ), + child: Row( + spacing: 34.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.share_outlined, + color: theme.colorScheme.onPrimaryContainer, + ), + Text( + L10n.of(context).share, + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + onPressed: () {}, + ), + ), + onSelected: (value) async { + final spaceCode = room.classCode!; + String toCopy = spaceCode; + if (value == 0) { + final String initialUrl = kIsWeb + ? html.window.origin! + : Environment.frontendURL; + toCopy = + "$initialUrl/#/join_with_link?${SpaceConstants.classCode}=${room.classCode}"; + } + + await Clipboard.setData(ClipboardData(text: toCopy)); + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + L10n.of(context).copiedToClipboard, + ), + ), + ); + }, + itemBuilder: (BuildContext context) => + >[ + PopupMenuItem( + value: 0, + child: ListTile( + leading: const Icon(Icons.share_outlined), + title: Text(L10n.of(context).shareSpaceLink), + contentPadding: const EdgeInsets.all(0), + ), + ), + PopupMenuItem( + value: 1, + child: ListTile( + leading: const Icon(Icons.share_outlined), + title: Text( + L10n.of(context) + .shareInviteCode(room.classCode!), + ), + contentPadding: const EdgeInsets.all(0), + ), + ), + ], + ), + ) + else + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + ), + child: Row( + spacing: 34.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.share_outlined, + color: theme.colorScheme.onPrimaryContainer, + ), + Text( + L10n.of(context).share, + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + onPressed: () => FluffyShare.share( + "${Environment.frontendURL}/#/join_with_alias?alias=$alias", + context, + ), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + ), + child: Row( + spacing: 34.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.check, + color: theme.colorScheme.onPrimaryContainer, + ), + Text( + L10n.of(context).done, + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + onPressed: () => context.go( + room.isSpace + ? "/rooms?spaceId=${room.id}" + : "/rooms/${room.id}", + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _InviteContactListTile extends StatelessWidget { + final Profile profile; + final User? user; + final bool isMember; + final void Function() onTap; + final PangeaInvitationSelectionController controller; + + const _InviteContactListTile({ + required this.profile, + this.user, + required this.isMember, + required this.onTap, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context); + + final participant = controller.participants?.firstWhereOrNull( + (p) => p.id == profile.userId, + ); + final membership = participant?.membership; + + return ListTile( + onTap: participant != null + ? () => showMemberActionsPopupMenu( + context: context, + user: participant, + ) + : null, + leading: Avatar( + mxContent: profile.avatarUrl, + name: profile.displayName, + presenceUserId: profile.userId, + onTap: () => UserDialog.show( + context: context, + profile: profile, + ), + ), + title: Text( + profile.displayName ?? profile.userId.localpart ?? l10n.user, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // https://github.com/pangeachat/client/issues/3047 + const SizedBox(height: 2.0), + Text( + profile.userId, + style: const TextStyle( + fontSize: 12.0, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + LevelDisplayName(userId: profile.userId), + ], + ), + trailing: [Membership.invite, Membership.knock].contains(membership) + ? Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + margin: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + controller.membershipCopy(membership)!, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + ) + : TextButton.icon( + onPressed: isMember ? null : onTap, + label: Text(isMember ? l10n.participant : l10n.invite), + icon: Icon(isMember ? Icons.check : Icons.add), + ), + ); + } +}