diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 9c8242dd2e..e9e8796734 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -3059,5 +3059,7 @@ "placeholders": { "user": {} } - } + }, + "viewRoom": "View chat", + "joinRoomFailed": "Failed to join the room" } diff --git a/lib/pages/search/public_room/empty_search_public_room_widget.dart b/lib/pages/search/public_room/empty_search_public_room_widget.dart new file mode 100644 index 0000000000..a00ef581e1 --- /dev/null +++ b/lib/pages/search/public_room/empty_search_public_room_widget.dart @@ -0,0 +1,61 @@ +import 'package:fluffychat/pages/search/public_room/search_public_room_view_style.dart'; +import 'package:fluffychat/widgets/avatar/avatar.dart'; +import 'package:fluffychat/widgets/twake_components/twake_text_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +class EmptySearchPublicRoomWidget extends StatelessWidget { + final String genericSearchTerm; + final VoidCallback? onTapJoin; + + const EmptySearchPublicRoomWidget({ + super.key, + required this.genericSearchTerm, + this.onTapJoin, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: SearchPublicRoomViewStyle.paddingListItem, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: SearchPublicRoomViewStyle.paddingAvatar, + child: Avatar( + name: genericSearchTerm, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + genericSearchTerm, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: SearchPublicRoomViewStyle.roomNameTextStyle, + ), + const SizedBox( + height: SearchPublicRoomViewStyle.nameToButtonSpace, + ), + TwakeTextButton( + message: L10n.of(context)!.joinRoom, + styleMessage: + SearchPublicRoomViewStyle.joinButtonLabelStyle(context), + paddingAll: SearchPublicRoomViewStyle.paddingButton, + onTap: onTapJoin, + buttonDecoration: + SearchPublicRoomViewStyle.actionButtonDecoration( + context, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/search/public_room/public_room_actions.dart b/lib/pages/search/public_room/public_room_actions.dart new file mode 100644 index 0000000000..674c348e4f --- /dev/null +++ b/lib/pages/search/public_room/public_room_actions.dart @@ -0,0 +1,26 @@ +import 'package:fluffychat/pages/search/public_room/search_public_room_view_style.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter/material.dart'; + +enum PublicRoomActions { + join, + view; + + String getLabel(BuildContext context) { + switch (this) { + case PublicRoomActions.join: + return L10n.of(context)!.joinRoom; + case PublicRoomActions.view: + return L10n.of(context)!.viewRoom; + } + } + + TextStyle? getLabelStyle(BuildContext context) { + switch (this) { + case PublicRoomActions.join: + return SearchPublicRoomViewStyle.joinButtonLabelStyle(context); + case PublicRoomActions.view: + return SearchPublicRoomViewStyle.viewButtonLabelStyle(context); + } + } +} diff --git a/lib/pages/search/public_room/search_public_room_controller.dart b/lib/pages/search/public_room/search_public_room_controller.dart new file mode 100644 index 0000000000..d2d7bc6b19 --- /dev/null +++ b/lib/pages/search/public_room/search_public_room_controller.dart @@ -0,0 +1,211 @@ +import 'dart:async'; +import 'package:dartz/dartz.dart'; +import 'package:fluffychat/app_state/failure.dart'; +import 'package:fluffychat/app_state/success.dart'; +import 'package:fluffychat/di/global/get_it_initializer.dart'; +import 'package:fluffychat/domain/app_state/search/public_room_state.dart'; +import 'package:fluffychat/domain/usecase/search/public_room_interactor.dart'; +import 'package:fluffychat/pages/search/public_room/public_room_actions.dart'; +import 'package:fluffychat/pages/search/search_debouncer_mixin.dart'; +import 'package:fluffychat/presentation/model/search/public_room/presentation_search_public_room.dart'; +import 'package:fluffychat/presentation/model/search/public_room/presentation_search_public_room_empty.dart'; +import 'package:fluffychat/presentation/model/search/public_room/presentation_search_public_room_state.dart'; +import 'package:fluffychat/utils/dialog/twake_dialog.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:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +class SearchPublicRoomController with SearchDebouncerMixin { + SearchPublicRoomController(); + + final PublicRoomInteractor _publicRoomInteractor = + getIt.get(); + + final searchResultsNotifier = + ValueNotifier( + PresentationSearchPublicRoomInitial(), + ); + + static const int _limitPublicRoomSearchFilter = 20; + + PublicRoomQueryFilter? _filter; + + StreamSubscription? _searchSubscription; + + bool get searchTermIsNotEmpty => + _filter?.genericSearchTerm?.isNotEmpty == true; + + String? get genericSearchTerm => _filter?.genericSearchTerm; + + void init() { + initializeDebouncer((keyword) { + _updateFilter(keyword); + + if (keyword.isEmpty) return; + + if (keyword.isRoomAlias()) { + _resetSearchResults(); + _searchPublicRoom(); + } else if (keyword.isRoomId()) { + _handleKeywordIsRoomId(); + } + }); + } + + void _updateFilter(String keyword) { + _filter = PublicRoomQueryFilter( + genericSearchTerm: keyword, + ); + } + + void _resetSearchResults() { + searchResultsNotifier.value = PresentationSearchPublicRoom( + searchResults: [], + ); + } + + void _searchPublicRoom() { + _searchSubscription = _publicRoomInteractor + .execute( + filter: _filter, + limit: _limitPublicRoomSearchFilter, + server: getServerName(_filter?.genericSearchTerm), + ) + .listen( + (searchResult) => _handleListenSearchPublicRoom(searchResult), + ); + } + + void _handleListenSearchPublicRoom(Either searchResult) { + searchResult.fold( + (failure) { + if (searchResultsNotifier.value.props.isNotEmpty) { + return; + } else { + _resetSearchResults(); + searchResultsNotifier.value = PresentationSearchPublicRoomEmpty(); + } + }, + (success) { + if (!searchTermIsNotEmpty) { + return; + } + + if (success is PublicRoomSuccess) { + if (success.publicRoomsChunk == null || + success.publicRoomsChunk!.isEmpty) { + searchResultsNotifier.value = PresentationSearchPublicRoomEmpty(); + } else { + searchResultsNotifier.value = PresentationSearchPublicRoom( + searchResults: success.publicRoomsChunk!, + ); + } + } + }, + ); + } + + void _handleKeywordIsRoomId() { + searchResultsNotifier.value = PresentationSearchPublicRoomEmpty(); + } + + void _viewRoom(BuildContext context, String roomId) { + context.go('/rooms/$roomId'); + if (!PlatformInfos.isMobile) { + Navigator.of(context, rootNavigator: false).pop(); + } + } + + String? getServerName(String? roomIdOrAlias) { + if (roomIdOrAlias != null) { + return roomIdOrAlias.getServerNameFromRoomIdOrAlias(); + } + return null; + } + + void joinRoom( + BuildContext context, + String roomIdOrAlias, + String? server, + ) async { + final client = Matrix.of(context).client; + final result = await TwakeDialog.showFutureLoadingDialogFullScreen( + future: () => client.joinRoom( + roomIdOrAlias, + serverName: server != null ? [server] : null, + ), + ); + if (result.error == null) { + if (client.getRoomById(result.result!) == null) { + await TwakeDialog.showFutureLoadingDialogFullScreen( + future: () => client.onSync.stream.firstWhere( + (sync) => sync.rooms?.join?.containsKey(result.result) ?? false, + ), + ); + } + if (!client.getRoomById(result.result!)!.isSpace) { + context.go('/rooms/${result.result!}'); + } + } else { + TwakeSnackBar.show(context, L10n.of(context)!.joinRoomFailed); + } + if (!PlatformInfos.isMobile) { + Navigator.of(context, rootNavigator: false).pop(); + } + return; + } + + void handlePublicRoomActions( + BuildContext context, + PublicRoomsChunk room, + PublicRoomActions action, + ) { + switch (action) { + case PublicRoomActions.join: + joinRoom( + context, + room.roomId, + getServerName(room.roomId), + ); + break; + case PublicRoomActions.view: + _viewRoom(context, room.roomId); + break; + } + } + + PublicRoomActions? getAction( + BuildContext context, + PublicRoomsChunk room, + ) { + final client = Matrix.of(context).client; + if (client.getRoomById(room.roomId) != null) { + return PublicRoomActions.view; + } else if (room.joinRule == 'public') { + return PublicRoomActions.join; + } + return null; + } + + void onSearchBarChanged(String keyword) { + if (keyword.isRoomAlias() || keyword.isRoomId()) { + setDebouncerValue(keyword); + } else { + setDebouncerValue(''); + _resetSearchResults(); + } + } + + void dispose() { + super.disposeDebouncer(); + _searchSubscription?.cancel(); + searchResultsNotifier.dispose(); + _resetSearchResults(); + _filter = null; + } +} diff --git a/lib/pages/search/public_room/search_public_room_view.dart b/lib/pages/search/public_room/search_public_room_view.dart new file mode 100644 index 0000000000..6d790f6ea5 --- /dev/null +++ b/lib/pages/search/public_room/search_public_room_view.dart @@ -0,0 +1,114 @@ +import 'package:fluffychat/pages/search/public_room/empty_search_public_room_widget.dart'; +import 'package:fluffychat/pages/search/public_room/search_public_room_controller.dart'; +import 'package:fluffychat/pages/search/public_room/search_public_room_view_style.dart'; +import 'package:fluffychat/presentation/model/search/public_room/presentation_search_public_room.dart'; +import 'package:fluffychat/presentation/model/search/public_room/presentation_search_public_room_empty.dart'; +import 'package:fluffychat/widgets/avatar/avatar.dart'; +import 'package:fluffychat/widgets/twake_components/twake_text_button.dart'; +import 'package:flutter/material.dart' hide SearchController; + +class SearchPublicRoomList extends StatelessWidget { + final SearchPublicRoomController searchController; + + const SearchPublicRoomList({ + super.key, + required this.searchController, + }); + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: ValueListenableBuilder( + valueListenable: searchController.searchResultsNotifier, + builder: (context, searchPublicRoomNotifier, child) { + if (searchPublicRoomNotifier is PresentationSearchPublicRoomEmpty) { + final genericSearchTerm = searchController.genericSearchTerm; + if (genericSearchTerm != null && genericSearchTerm.isNotEmpty) { + return EmptySearchPublicRoomWidget( + genericSearchTerm: genericSearchTerm, + onTapJoin: () => searchController.joinRoom( + context, + genericSearchTerm, + searchController.getServerName(genericSearchTerm), + ), + ); + } + return child!; + } + + if (searchPublicRoomNotifier is PresentationSearchPublicRoom) { + return ListView.builder( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + itemCount: searchPublicRoomNotifier.searchResults.length, + padding: SearchPublicRoomViewStyle.paddingListItem, + itemBuilder: ((context, index) { + final room = searchPublicRoomNotifier.searchResults[index]; + final action = searchController.getAction(context, room); + return Padding( + padding: SearchPublicRoomViewStyle.paddingListItem, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: SearchPublicRoomViewStyle.paddingAvatar, + child: Avatar( + mxContent: room.avatarUrl, + name: room.name, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + room.name ?? room.roomId, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: + SearchPublicRoomViewStyle.roomNameTextStyle, + ), + const SizedBox( + height: + SearchPublicRoomViewStyle.nameToButtonSpace, + ), + Text( + room.canonicalAlias ?? room.roomId, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: + SearchPublicRoomViewStyle.roomAliasTextStyle, + ), + ], + ), + ), + if (action != null) + TwakeTextButton( + message: action.getLabel(context), + styleMessage: action.getLabelStyle(context), + paddingAll: SearchPublicRoomViewStyle.paddingButton, + onTap: () => searchController.handlePublicRoomActions( + context, + room, + action, + ), + hoverColor: Colors.transparent, + buttonDecoration: + SearchPublicRoomViewStyle.actionButtonDecoration( + context, + ), + ), + ], + ), + ); + }), + ); + } + + return child!; + }, + child: const SizedBox(), + ), + ); + } +} diff --git a/lib/pages/search/public_room/search_public_room_view_style.dart b/lib/pages/search/public_room/search_public_room_view_style.dart new file mode 100644 index 0000000000..5bc2b59344 --- /dev/null +++ b/lib/pages/search/public_room/search_public_room_view_style.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:linagora_design_flutter/colors/linagora_sys_colors.dart'; +import 'package:linagora_design_flutter/style/linagora_text_style.dart'; + +class SearchPublicRoomViewStyle { + static const double nameToButtonSpace = 8.0; + + static const double paddingButton = 12.0; + + static const EdgeInsetsGeometry paddingListItem = EdgeInsets.all(16.0); + + static const EdgeInsetsGeometry paddingAvatar = EdgeInsets.only(right: 12.0); + + static TextStyle roomNameTextStyle = + LinagoraTextStyle.material().bodyMedium2.copyWith( + color: LinagoraSysColors.material().onSurface, + fontFamily: GoogleFonts.inter().fontFamily, + ); + + static TextStyle roomAliasTextStyle = + LinagoraTextStyle.material().bodyMedium3.copyWith( + color: LinagoraSysColors.material().onSurface, + fontFamily: GoogleFonts.inter().fontFamily, + ); + + static TextStyle? joinButtonLabelStyle(BuildContext context) { + return Theme.of(context).textTheme.labelLarge?.copyWith( + color: LinagoraSysColors.material().primary, + ); + } + + static TextStyle? viewButtonLabelStyle(BuildContext context) { + return Theme.of(context).textTheme.labelLarge?.copyWith( + color: LinagoraSysColors.material().onSurface, + ); + } + + static BoxDecoration actionButtonDecoration(BuildContext context) { + return BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: const BorderRadius.all( + Radius.circular(28.0), + ), + ); + } +} diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index 7eedc6978a..13ad19c4d5 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -4,6 +4,7 @@ import 'package:fluffychat/app_state/success.dart'; import 'package:fluffychat/di/global/get_it_initializer.dart'; import 'package:fluffychat/domain/app_state/search/search_state.dart'; import 'package:fluffychat/domain/usecase/search/pre_search_recent_contacts_interactor.dart'; +import 'package:fluffychat/pages/search/public_room/search_public_room_controller.dart'; import 'package:fluffychat/pages/search/search_contacts_and_chats_controller.dart'; import 'package:fluffychat/pages/search/search_view.dart'; import 'package:fluffychat/pages/search/server_search_controller.dart'; @@ -37,6 +38,7 @@ class SearchController extends State { SearchContactsAndChatsController? searchContactAndRecentChatController; final serverSearchController = ServerSearchController(); + final searchPublicRoomController = SearchPublicRoomController(); final _preSearchRecentContactsInteractor = getIt.get(); @@ -167,6 +169,7 @@ class SearchController extends State { SchedulerBinding.instance.addPostFrameCallback((_) async { if (mounted) { searchContactAndRecentChatController?.init(); + searchPublicRoomController.init(); serverSearchController.initSearch(); fetchPreSearchRecentContacts(); textEditingController.addListener(() { @@ -179,6 +182,7 @@ class SearchController extends State { void onSearchBarChanged(String keyword) { searchContactAndRecentChatController?.onSearchBarChanged(keyword); + searchPublicRoomController.onSearchBarChanged(keyword); serverSearchController.onSearchBarChanged(keyword); } @@ -197,6 +201,7 @@ class SearchController extends State { @override void dispose() { searchContactAndRecentChatController?.dispose(); + searchPublicRoomController.dispose(); serverSearchController.dispose(); preSearchRecentContactsNotifier.dispose(); textEditingController.dispose(); diff --git a/lib/pages/search/search_view.dart b/lib/pages/search/search_view.dart index 1e747f7bd3..3ce364721a 100644 --- a/lib/pages/search/search_view.dart +++ b/lib/pages/search/search_view.dart @@ -1,4 +1,5 @@ import 'package:fluffychat/domain/app_state/search/pre_search_state.dart'; +import 'package:fluffychat/pages/search/public_room/search_public_room_view.dart'; import 'package:fluffychat/pages/search/recent_contacts_banner_widget.dart'; import 'package:fluffychat/pages/search/recent_item_widget.dart'; import 'package:fluffychat/pages/search/search.dart'; @@ -7,6 +8,8 @@ import 'package:fluffychat/pages/search/search_view_style.dart'; import 'package:fluffychat/pages/search/server_search_view.dart'; import 'package:fluffychat/presentation/model/search/presentation_server_side_empty_search.dart'; import 'package:fluffychat/presentation/model/search/presentation_server_side_search.dart'; +import 'package:fluffychat/presentation/model/search/public_room/presentation_search_public_room.dart'; +import 'package:fluffychat/presentation/model/search/public_room/presentation_search_public_room_empty.dart'; import 'package:fluffychat/widgets/twake_components/twake_icon_button.dart'; import 'package:fluffychat/widgets/twake_components/twake_loading/center_loading_indicator.dart'; import 'package:flutter/material.dart' hide SearchController; @@ -66,6 +69,31 @@ class SearchView extends StatelessWidget { ), _RecentChatAndContactsHeader(searchController: searchController), _recentChatsWidget(), + ValueListenableBuilder( + valueListenable: searchController + .searchPublicRoomController.searchResultsNotifier, + builder: ((context, searchResults, child) { + if (searchResults is PresentationSearchPublicRoomEmpty) { + return child!; + } + + if (searchResults is PresentationSearchPublicRoom) { + if (searchResults.searchResults.isEmpty) { + return child!; + } + return _SearchHeader( + header: L10n.of(context)!.publicRooms, + searchController: searchController, + needShowMore: false, + ); + } + return child!; + }), + child: _EmptySliverBox(), + ), + SearchPublicRoomList( + searchController: searchController.searchPublicRoomController, + ), ValueListenableBuilder( valueListenable: searchController.serverSearchController.searchResultsNotifier, diff --git a/lib/presentation/model/search/public_room/presentation_search_public_room.dart b/lib/presentation/model/search/public_room/presentation_search_public_room.dart new file mode 100644 index 0000000000..23061c563b --- /dev/null +++ b/lib/presentation/model/search/public_room/presentation_search_public_room.dart @@ -0,0 +1,13 @@ +import 'package:fluffychat/presentation/model/search/public_room/presentation_search_public_room_state.dart'; +import 'package:matrix/matrix.dart'; + +class PresentationSearchPublicRoom extends PresentationSearchPublicRoomUIState { + final List searchResults; + + PresentationSearchPublicRoom({ + required this.searchResults, + }); + + @override + List get props => [searchResults]; +} diff --git a/lib/presentation/model/search/public_room/presentation_search_public_room_empty.dart b/lib/presentation/model/search/public_room/presentation_search_public_room_empty.dart new file mode 100644 index 0000000000..2291d6ec54 --- /dev/null +++ b/lib/presentation/model/search/public_room/presentation_search_public_room_empty.dart @@ -0,0 +1,7 @@ +import 'package:fluffychat/presentation/model/search/public_room/presentation_search_public_room_state.dart'; + +class PresentationSearchPublicRoomEmpty + extends PresentationSearchPublicRoomUIState { + @override + List get props => []; +} diff --git a/lib/presentation/model/search/public_room/presentation_search_public_room_state.dart b/lib/presentation/model/search/public_room/presentation_search_public_room_state.dart new file mode 100644 index 0000000000..4f3c0d3769 --- /dev/null +++ b/lib/presentation/model/search/public_room/presentation_search_public_room_state.dart @@ -0,0 +1,9 @@ +import 'package:equatable/equatable.dart'; + +abstract class PresentationSearchPublicRoomUIState with EquatableMixin { + @override + List get props => []; +} + +class PresentationSearchPublicRoomInitial + extends PresentationSearchPublicRoomUIState {} diff --git a/lib/utils/string_extension.dart b/lib/utils/string_extension.dart index c688a6d20e..248aad2468 100644 --- a/lib/utils/string_extension.dart +++ b/lib/utils/string_extension.dart @@ -373,4 +373,28 @@ extension StringCasingExtension on String { final match = regex.firstMatch(this); return match?.group(1); } + + bool isRoomAlias() { + final regExp = RegExp( + r'^#[^:]+:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[0-9A-Fa-f:.]{2,45}\]|[0-9A-Za-z-.]{1,255})(:\d{1,5})?$', + ); + final result = regExp.hasMatch(trim()); + return result; + } + + String? getServerNameFromRoomIdOrAlias() { + final parts = split(':'); + if (parts.length > 1) { + return parts[1]; + } + return null; + } + + bool isRoomId() { + final regExp = RegExp( + r'^![^:]+:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[0-9A-Fa-f:.]{2,45}\]|[0-9A-Za-z-.]{1,255})(:\d{1,5})?$', + ); + final result = regExp.hasMatch(trim()); + return result; + } } diff --git a/test/utils/string_extension_test.dart b/test/utils/string_extension_test.dart index d37b6f8164..719d9a9305 100644 --- a/test/utils/string_extension_test.dart +++ b/test/utils/string_extension_test.dart @@ -377,4 +377,57 @@ void main() { expect(''.extractInnerText(), isEmpty); }); }); + + group('[isRoomAlias TEST]', () { + final testMap = { + '#room:server.com': true, + '#room:192.168.1.1': true, + '#room:[2001:db8:85a3:8d3:1319:8a2e:370:7348]': true, + '#room:example.com:8080': true, + '!room:server.com': false, + 'room:server.com': false, + '': false, + }; + + for (final entry in testMap.entries) { + test('Testing: ${entry.key} => Expected: ${entry.value}', () { + final result = entry.key.isRoomAlias(); + expect(result, entry.value); + }); + } + }); + + group('[getServerNameFromRoomAlias] TEST', () { + final testMap = { + '#room:server.com': 'server.com', + '#room:domain.com:': 'domain.com', + '': null, + }; + + for (final entry in testMap.entries) { + test('Testing: ${entry.key} => Expected: ${entry.value}', () { + final result = entry.key.getServerNameFromRoomIdOrAlias(); + expect(result, equals(entry.value)); + }); + } + }); + + group('[isRoomId TEST]', () { + final testMap = { + '!room:server.com': true, + '!room:192.168.1.1': true, + '!room:[2001:db8:85a3:8d3:1319:8a2e:370:7348]': true, + '!room:example.com:8080': true, + '#room:server.com': false, + 'room:server.com': false, + '': false, + }; + + for (final entry in testMap.entries) { + test('Testing: ${entry.key} => Expected: ${entry.value}', () { + final result = entry.key.isRoomId(); + expect(result, entry.value); + }); + } + }); }