diff --git a/.changes/2176-show-suggested.md b/.changes/2176-show-suggested.md new file mode 100644 index 000000000000..c5e150e623b4 --- /dev/null +++ b/.changes/2176-show-suggested.md @@ -0,0 +1 @@ +- Suggested spaces and chats are preferably now shown on the overview of a space, if configured \ No newline at end of file diff --git a/app/lib/common/providers/space_providers.dart b/app/lib/common/providers/space_providers.dart index 2e39c19e02ed..c8ba14df7360 100644 --- a/app/lib/common/providers/space_providers.dart +++ b/app/lib/common/providers/space_providers.dart @@ -359,6 +359,64 @@ final remoteChatRelationsProvider = } }); +typedef RoomsAndRoomInfos = (List, List); + +final suggestedChatsProvider = + FutureProvider.family((ref, spaceId) async { + try { + final relatedSpaces = + await ref.watch(spaceRelationsOverviewProvider(spaceId).future); + final suggestedRooms = relatedSpaces.suggestedIds; + if (suggestedRooms.isEmpty) { + return (List.empty(), List.empty()); + } + final toIgnore = relatedSpaces.knownChats.toList(); + final localRooms = relatedSpaces.knownChats + .where((r) => suggestedRooms.contains(r)) + .toList(); + final roomHierarchy = + await ref.watch(spaceRemoteRelationsProvider(spaceId).future); + // filter out the known rooms + final remoteRooms = roomHierarchy + .where((r) => + !r.isSpace() && + suggestedRooms.contains(r.roomIdStr()) && + !toIgnore.contains(r.roomIdStr()),) + .toList(); + return (localRooms, remoteRooms); + } on SpaceNotFound { + return (List.empty(), List.empty()); + } +}); + +final suggestedSpacesProvider = + FutureProvider.family((ref, spaceId) async { + try { + final relatedSpaces = + await ref.watch(spaceRelationsOverviewProvider(spaceId).future); + final suggestedRooms = relatedSpaces.suggestedIds; + if (suggestedRooms.isEmpty) { + return (List.empty(), List.empty()); + } + final toIgnore = relatedSpaces.knownSubspaces.toList(); + final localRooms = relatedSpaces.knownSubspaces + .where((r) => suggestedRooms.contains(r)) + .toList(); + final roomHierarchy = + await ref.watch(spaceRemoteRelationsProvider(spaceId).future); + // filter out the known rooms + final remoteRooms = roomHierarchy + .where((r) => + r.isSpace() && + suggestedRooms.contains(r.roomIdStr()) && + !toIgnore.contains(r.roomIdStr()),) + .toList(); + return (localRooms, remoteRooms); + } on SpaceNotFound { + return (List.empty(), List.empty()); + } +}); + final remoteSubspaceRelationsProvider = FutureProvider.family, String>( (ref, spaceId) async { diff --git a/app/lib/common/widgets/room/room_hierarchy_card.dart b/app/lib/common/widgets/room/room_hierarchy_card.dart index 247401636515..eb62ede3884e 100644 --- a/app/lib/common/widgets/room/room_hierarchy_card.dart +++ b/app/lib/common/widgets/room/room_hierarchy_card.dart @@ -1,9 +1,7 @@ import 'package:acter/common/providers/room_providers.dart'; import 'package:acter/common/widgets/room/room_with_profile_card.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; -import 'package:expandable_text/expandable_text.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class RoomHierarchyCard extends ConsumerWidget { @@ -77,8 +75,8 @@ class RoomHierarchyCard extends ConsumerWidget { /// Custom trailing widget. final Widget? trailing; - /// Whether to show the suggested icon if this is a suggested space - final bool showIconIfSuggested; + /// Whether to show the suggested info if this is a suggested room + final bool indicateIfSuggested; const RoomHierarchyCard({ super.key, @@ -94,7 +92,7 @@ class RoomHierarchyCard extends ConsumerWidget { this.contentPadding = const EdgeInsets.all(15), this.shape, this.withBorder = true, - this.showIconIfSuggested = false, + this.indicateIfSuggested = false, this.trailing, }); @@ -102,22 +100,11 @@ class RoomHierarchyCard extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final roomId = roomInfo.roomIdStr(); final avatarInfo = ref.watch(roomHierarchyAvatarInfoProvider(roomInfo)); - final topic = roomInfo.topic(); - bool showSuggested = showIconIfSuggested && roomInfo.suggested(); - final Widget? subtitle = topic?.isNotEmpty == true - ? ExpandableText( - topic!, - maxLines: 2, - expandText: L10n.of(context).showMore, - collapseText: L10n.of(context).showLess, - linkColor: Theme.of(context).colorScheme.primary, - ) - : null; + bool showSuggested = indicateIfSuggested && roomInfo.suggested(); return RoomWithAvatarInfoCard( roomId: roomId, avatarInfo: avatarInfo, - subtitle: subtitle, onTap: onTap ?? () {}, onFocusChange: onFocusChange, onLongPress: onLongPress, diff --git a/app/lib/features/space/dialogs/suggested_rooms.dart b/app/lib/features/space/dialogs/suggested_rooms.dart index bd70f55273df..671d04ab6379 100644 --- a/app/lib/features/space/dialogs/suggested_rooms.dart +++ b/app/lib/features/space/dialogs/suggested_rooms.dart @@ -34,7 +34,7 @@ class __SuggestedRoomsState extends ConsumerState<_SuggestedRooms> { super.initState(); ref.listenManual( - suggestedRoomsProvider(widget.spaceId), + roomsToSuggestProvider(widget.spaceId), (prev, next) { if (next.hasValue) { setState(() { diff --git a/app/lib/features/space/pages/chats_page.dart b/app/lib/features/space/pages/chats_page.dart index 91d4a3110a40..ea7a46f4454d 100644 --- a/app/lib/features/space/pages/chats_page.dart +++ b/app/lib/features/space/pages/chats_page.dart @@ -21,6 +21,7 @@ class SpaceChatsPage extends ConsumerWidget { Widget _renderLoading(BuildContext context) { return ListView.builder( + shrinkWrap: true, itemCount: 3, itemBuilder: (context, idx) => const LoadingConvoCard(roomId: 'fake'), ); diff --git a/app/lib/features/space/providers/suggested_provider.dart b/app/lib/features/space/providers/suggested_provider.dart index ee29413b3e7a..d8436593f4e5 100644 --- a/app/lib/features/space/providers/suggested_provider.dart +++ b/app/lib/features/space/providers/suggested_provider.dart @@ -19,7 +19,7 @@ final shouldShowSuggestedProvider = } final suggestedRooms = - await ref.watch(suggestedRoomsProvider(spaceId).future); + await ref.watch(roomsToSuggestProvider(spaceId).future); // only if we really have some remote rooms that the user is suggested and not yet in return suggestedRooms.chats.isNotEmpty || suggestedRooms.spaces.isNotEmpty; } catch (e, s) { @@ -33,11 +33,14 @@ typedef SuggestedRooms = ({ List chats }); -final suggestedRoomsProvider = +// Will show the room _to_ suggest to the user, ergo excludes rooms they are +// already in +final roomsToSuggestProvider = FutureProvider.family((ref, roomId) async { final chats = await ref.watch(remoteChatRelationsProvider(roomId).future); final spaces = await ref.watch(remoteSubspaceRelationsProvider(roomId).future); + return ( chats: chats.where((r) => r.suggested()).toList(), spaces: spaces.where((r) => r.suggested()).toList() diff --git a/app/lib/features/space/widgets/related/chats_helpers.dart b/app/lib/features/space/widgets/related/chats_helpers.dart index 2cb8bcbb4a56..6633e8942a20 100644 --- a/app/lib/features/space/widgets/related/chats_helpers.dart +++ b/app/lib/features/space/widgets/related/chats_helpers.dart @@ -5,6 +5,7 @@ import 'package:acter/common/widgets/room/room_hierarchy_card.dart'; import 'package:acter/common/widgets/room/room_hierarchy_join_button.dart'; import 'package:acter/common/widgets/room/room_hierarchy_options_menu.dart'; import 'package:acter/router/utils.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -19,6 +20,7 @@ Widget chatsListUI( List chats, int chatsLimit, { bool showOptions = false, + bool showSuggestedMarkIfGiven = true, }) { final suggestedId = ref.watch(suggestedIdsProvider(parentId)).valueOrNull ?? []; @@ -32,7 +34,8 @@ Widget chatsListUI( return RoomCard( roomId: roomId, showParents: false, - showSuggestedMark: suggestedId.contains(roomId), + showSuggestedMark: + showSuggestedMarkIfGiven && suggestedId.contains(roomId), onTap: () => goToChat(context, roomId), trailing: showOptions ? RoomHierarchyOptionsMenu( @@ -46,6 +49,55 @@ Widget chatsListUI( ); } +Widget renderRemoteChats( + BuildContext context, + WidgetRef ref, + String parentId, + List chats, + int? maxItems, { + bool showSuggestedMarkIfGiven = true, + bool renderMenu = true, +}) { + if (chats.isEmpty) return const SizedBox.shrink(); + return ListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + itemCount: maxItems ?? chats.length, + itemBuilder: (context, index) { + final roomInfo = chats[index]; + final roomId = roomInfo.roomIdStr(); + return RoomHierarchyCard( + indicateIfSuggested: showSuggestedMarkIfGiven, + parentId: parentId, + roomInfo: roomInfo, + trailing: Wrap( + children: [ + RoomHierarchyJoinButton( + joinRule: roomInfo.joinRuleStr().toLowerCase(), + roomId: roomId, + roomName: roomInfo.name() ?? roomId, + viaServerName: roomInfo.viaServerName(), + forward: (roomId) { + goToChat(context, roomId); + // make sure the UI refreshes when the user comes back here + ref.invalidate(spaceRelationsProvider(parentId)); + ref.invalidate(spaceRemoteRelationsProvider(parentId)); + }, + ), + if (renderMenu) + RoomHierarchyOptionsMenu( + isSuggested: roomInfo.suggested(), + childId: roomId, + parentId: parentId, + ), + ], + ), + ); + }, + ); +} + Widget renderFurther( BuildContext context, WidgetRef ref, @@ -54,46 +106,7 @@ Widget renderFurther( ) { final relatedChatsLoader = ref.watch(remoteChatRelationsProvider(spaceId)); return relatedChatsLoader.when( - data: (chats) { - if (chats.isEmpty) return const SizedBox.shrink(); - return ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - physics: const NeverScrollableScrollPhysics(), - itemCount: maxItems ?? chats.length, - itemBuilder: (context, index) { - final roomInfo = chats[index]; - final roomId = roomInfo.roomIdStr(); - final parentId = spaceId; - return RoomHierarchyCard( - showIconIfSuggested: true, - parentId: parentId, - roomInfo: roomInfo, - trailing: Wrap( - children: [ - RoomHierarchyJoinButton( - joinRule: roomInfo.joinRuleStr().toLowerCase(), - roomId: roomId, - roomName: roomInfo.name() ?? roomId, - viaServerName: roomInfo.viaServerName(), - forward: (roomId) { - goToChat(context, roomId); - // make sure the UI refreshes when the user comes back here - ref.invalidate(spaceRelationsProvider(parentId)); - ref.invalidate(spaceRemoteRelationsProvider(parentId)); - }, - ), - RoomHierarchyOptionsMenu( - isSuggested: roomInfo.suggested(), - childId: roomId, - parentId: parentId, - ), - ], - ), - ); - }, - ); - }, + data: (chats) => renderRemoteChats(context, ref, spaceId, chats, maxItems), error: (e, s) { _log.severe('Failed to load the related chats', e, s); return Card( diff --git a/app/lib/features/space/widgets/related/spaces_helpers.dart b/app/lib/features/space/widgets/related/spaces_helpers.dart index c721b2cd8aea..0cb0cc520800 100644 --- a/app/lib/features/space/widgets/related/spaces_helpers.dart +++ b/app/lib/features/space/widgets/related/spaces_helpers.dart @@ -5,6 +5,7 @@ import 'package:acter/common/widgets/room/room_hierarchy_options_menu.dart'; import 'package:acter/common/widgets/room/room_card.dart'; import 'package:acter/common/widgets/room/room_hierarchy_card.dart'; import 'package:acter/router/utils.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -53,69 +54,85 @@ List? _renderKnownSubspaces( ]; } -Widget renderMoreSubspaces( +Widget renderRemoteSubspaces( BuildContext context, WidgetRef ref, - String spaceIdOrAlias, { + String spaceIdOrAlias, + List spaces, { int? maxLength, EdgeInsetsGeometry? padding, }) { - final relatedSpacesLoader = - ref.watch(remoteSubspaceRelationsProvider(spaceIdOrAlias)); - return relatedSpacesLoader.when( - data: (spaces) { - if (spaces.isEmpty) { - return const SizedBox.shrink(); - } + if (spaces.isEmpty) { + return const SizedBox.shrink(); + } - int itemCount = spaces.length; - if (maxLength != null && maxLength < itemCount) { - itemCount = maxLength; - } + int itemCount = spaces.length; + if (maxLength != null && maxLength < itemCount) { + itemCount = maxLength; + } - return GridView.builder( - padding: padding, - itemCount: itemCount, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 1, - childAspectRatio: 4.0, - mainAxisExtent: 100, - ), - shrinkWrap: true, - itemBuilder: (context, index) { - final roomInfo = spaces[index]; - final parentId = spaceIdOrAlias; - final roomId = roomInfo.roomIdStr(); - return RoomHierarchyCard( - key: Key('subspace-list-item-$roomId'), - roomInfo: roomInfo, - parentId: parentId, - showIconIfSuggested: true, - trailing: Wrap( - children: [ - RoomHierarchyJoinButton( - joinRule: roomInfo.joinRuleStr().toLowerCase(), - roomId: roomId, - roomName: roomInfo.name() ?? roomId, - viaServerName: roomInfo.viaServerName(), - forward: (spaceId) { - goToSpace(context, spaceId); - ref.invalidate(spaceRelationsProvider(parentId)); - ref.invalidate(spaceRemoteRelationsProvider(parentId)); - }, - ), - RoomHierarchyOptionsMenu( - isSuggested: roomInfo.suggested(), - childId: roomId, - parentId: parentId, - ), - ], + return GridView.builder( + padding: padding, + itemCount: itemCount, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 1, + childAspectRatio: 4.0, + mainAxisExtent: 100, + ), + shrinkWrap: true, + itemBuilder: (context, index) { + final roomInfo = spaces[index]; + final parentId = spaceIdOrAlias; + final roomId = roomInfo.roomIdStr(); + return RoomHierarchyCard( + key: Key('subspace-list-item-$roomId'), + roomInfo: roomInfo, + parentId: parentId, + indicateIfSuggested: true, + trailing: Wrap( + children: [ + RoomHierarchyJoinButton( + joinRule: roomInfo.joinRuleStr().toLowerCase(), + roomId: roomId, + roomName: roomInfo.name() ?? roomId, + viaServerName: roomInfo.viaServerName(), + forward: (spaceId) { + goToSpace(context, spaceId); + ref.invalidate(spaceRelationsProvider(parentId)); + ref.invalidate(spaceRemoteRelationsProvider(parentId)); + }, ), - ); - }, + RoomHierarchyOptionsMenu( + isSuggested: roomInfo.suggested(), + childId: roomId, + parentId: parentId, + ), + ], + ), ); }, + ); +} + +Widget renderMoreSubspaces( + BuildContext context, + WidgetRef ref, + String spaceIdOrAlias, { + int? maxLength, + EdgeInsetsGeometry? padding, +}) { + final relatedSpacesLoader = + ref.watch(remoteSubspaceRelationsProvider(spaceIdOrAlias)); + return relatedSpacesLoader.when( + data: (spaces) => renderRemoteSubspaces( + context, + ref, + spaceIdOrAlias, + spaces, + maxLength: maxLength, + padding: padding, + ), error: (e, s) { _log.severe('Failed to load the related subspaces', e, s); return Card( diff --git a/app/lib/features/space/widgets/space_sections/chats_section.dart b/app/lib/features/space/widgets/space_sections/chats_section.dart index 1392a1b053be..8fab8a725857 100644 --- a/app/lib/features/space/widgets/space_sections/chats_section.dart +++ b/app/lib/features/space/widgets/space_sections/chats_section.dart @@ -3,6 +3,7 @@ import 'package:acter/common/utils/routes.dart'; import 'package:acter/features/space/widgets/related/chats_helpers.dart'; import 'package:acter/features/space/widgets/related/util.dart'; import 'package:acter/features/space/widgets/space_sections/section_header.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -24,6 +25,17 @@ class ChatsSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final suggestedChats = + ref.watch(suggestedChatsProvider(spaceId)).valueOrNull; + if (suggestedChats != null && + (suggestedChats.$1.isNotEmpty || suggestedChats.$2.isNotEmpty)) { + return buildSuggestedChatsSectionUI( + context, + ref, + suggestedChats.$1, + suggestedChats.$2, + ); + } final overviewLoader = ref.watch(spaceRelationsOverviewProvider(spaceId)); return overviewLoader.when( data: (overview) => buildChatsSectionUI( @@ -45,6 +57,51 @@ class ChatsSection extends ConsumerWidget { ); } + Widget buildSuggestedChatsSectionUI( + BuildContext context, + WidgetRef ref, + List suggestedLocalChats, + List suggestedRemoteChats, + ) { + final config = calculateSectionConfig( + localListLen: suggestedLocalChats.length, + limit: limit, + remoteListLen: suggestedRemoteChats.length, + ); + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader( + title: L10n.of(context).suggestedChats, + isShowSeeAllButton: true, + onTapSeeAll: () => context.pushNamed( + Routes.spaceChats.name, + pathParameters: {'spaceId': spaceId}, + ), + ), + chatsListUI( + ref, + spaceId, + suggestedLocalChats, + config.listingLimit, + showOptions: false, + showSuggestedMarkIfGiven: false, + ), + if (config.renderRemote) + renderRemoteChats( + context, + ref, + spaceId, + suggestedRemoteChats, + config.remoteCount, + showSuggestedMarkIfGiven: false, + renderMenu: false, + ), + ], + ); + } + Widget buildChatsSectionUI( BuildContext context, WidgetRef ref, diff --git a/app/lib/features/space/widgets/space_sections/spaces_section.dart b/app/lib/features/space/widgets/space_sections/spaces_section.dart index 266f33fdd4e7..a6d217fdaaf8 100644 --- a/app/lib/features/space/widgets/space_sections/spaces_section.dart +++ b/app/lib/features/space/widgets/space_sections/spaces_section.dart @@ -4,6 +4,7 @@ import 'package:acter/common/widgets/room/room_card.dart'; import 'package:acter/features/space/widgets/related/spaces_helpers.dart'; import 'package:acter/features/space/widgets/related/util.dart'; import 'package:acter/features/space/widgets/space_sections/section_header.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -24,6 +25,14 @@ class SpacesSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final suggestedSpaces = + ref.watch(suggestedSpacesProvider(spaceId)).valueOrNull; + if (suggestedSpaces != null && + (suggestedSpaces.$1.isNotEmpty || suggestedSpaces.$2.isNotEmpty)) { + return buildSuggestedSpacesSectionUI( + context, ref, suggestedSpaces.$1, suggestedSpaces.$2,); + } + final overviewLoader = ref.watch(spaceRelationsOverviewProvider(spaceId)); return overviewLoader.when( data: (overview) => buildSpacesSectionUI( @@ -43,6 +52,47 @@ class SpacesSection extends ConsumerWidget { ); } + Widget buildSuggestedSpacesSectionUI( + BuildContext context, + WidgetRef ref, + List suggestedLocalSpaces, + List suggestedRemoteSpaces, + ) { + final config = calculateSectionConfig( + localListLen: suggestedLocalSpaces.length, + limit: limit, + remoteListLen: suggestedRemoteSpaces.length, + ); + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader( + title: L10n.of(context).suggestedSpaces, + isShowSeeAllButton: true, + onTapSeeAll: () => context.pushNamed( + Routes.spaceRelatedSpaces.name, + pathParameters: {'spaceId': spaceId}, + ), + ), + spacesListUI( + suggestedLocalSpaces, + config.listingLimit, + // showOptions: false, + // showSuggestedMarkIfGiven: false, + ), + if (config.renderRemote) + renderRemoteSubspaces( + context, + ref, + spaceId, + suggestedRemoteSpaces, + maxLength: config.remoteCount, + ), + ], + ); + } + Widget buildSpacesSectionUI( BuildContext context, WidgetRef ref, diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 13922bc158a2..f495b9f7a458 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1137,6 +1137,10 @@ "@subspaces": {}, "suggested": "Suggested", "@suggested": {}, + "suggestedChats": "Suggested Chats", + "@suggestedChats": {}, + "suggestedSpaces": "Suggested Spaces", + "@suggestedSpaces": {}, "suggestedUsers": "Suggested Users", "@suggestedUsers": {}, "joiningSuggested": "Joining suggested",