diff --git a/lib/src/app/routing/deep_link.dart b/lib/src/app/routing/deep_link.dart index 67441fd53..0cde329bd 100644 --- a/lib/src/app/routing/deep_link.dart +++ b/lib/src/app/routing/deep_link.dart @@ -128,7 +128,7 @@ Future _initializeLemmyClient(BuildContext context) async { } // Validate connection by making a simple request - await InstanceRepositoryImpl(account: account).getSiteInfo(); + await InstanceRepositoryImpl(account: account).info(); return; } catch (e) { attempts++; diff --git a/lib/src/app/utils/navigation.dart b/lib/src/app/utils/navigation.dart index b14c2a0dc..2808c982f 100644 --- a/lib/src/app/utils/navigation.dart +++ b/lib/src/app/utils/navigation.dart @@ -7,9 +7,11 @@ import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/app/routing/swipeable_page_route.dart'; +import 'package:thunder/src/app/utils/global_context.dart'; import 'package:thunder/src/core/enums/comment_sort_type.dart'; +import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/core/models/models.dart'; import 'package:thunder/src/core/models/thunder_private_message.dart'; -import 'package:thunder/src/core/models/thunder_site_response.dart'; import 'package:thunder/src/features/instance/instance.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/account/account.dart'; @@ -52,38 +54,25 @@ Future navigateToInstancePage( required String instanceHost, required int? instanceId, }) async { - assert(instanceHost.isNotEmpty); - showLoadingPage(context); - final l10n = AppLocalizations.of(context)!; - - final profileBloc = context.read(); - final thunderBloc = context.read(); - final gestureCubit = context.read(); - final themeCubit = context.read(); - - final reduceAnimations = themeCubit.state.reduceAnimations; - final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; - - ThunderSiteResponse? getSiteResponse; - bool? isBlocked; + final reduceAnimations = context.read().state.reduceAnimations; + final enableFullScreenSwipeNavigationGesture = context.read().state.enableFullScreenSwipeNavigationGesture; final platformInfo = await detectPlatformFromNodeInfo(instanceHost); - final platform = platformInfo?['platform']; + final platform = platformInfo?['platform'] ?? ThreadiversePlatform.lemmy; // Fallback to Lemmy if we can't detect the platform + + ThunderSiteResponse? site; try { // Get the site information by connecting to the given instance final account = Account(id: '', index: -1, instance: instanceHost, platform: platform); - getSiteResponse = await InstanceRepositoryImpl(account: account).getSiteInfo().timeout(const Duration(seconds: 5)); - - // Check whether this instance is blocked (we have to get our user from our current site first). - isBlocked = profileBloc.state.siteResponse?.myUser?.instanceBlocks.any((i) => i.instance['domain'] == instanceHost); + site = await InstanceRepositoryImpl(account: account).info().timeout(const Duration(seconds: 5)); } catch (e) { // Continue if we can't get the site } - final SwipeablePageRoute route = SwipeablePageRoute( + final route = SwipeablePageRoute( transitionDuration: isLoadingPageShown ? Duration.zero : reduceAnimations @@ -93,24 +82,35 @@ Future navigateToInstancePage( canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, canOnlySwipeFromEdge: true, builder: (context) => BlocProvider.value( - value: thunderBloc, + value: context.read(), child: InstancePage( - platform: platform!, - site: getSiteResponse!, - isBlocked: isBlocked, - instanceId: instanceId, + instance: ThunderInstanceInfo( + id: instanceId, + domain: site!.site.actorId, + name: site.site.name, + description: site.site.description, + sidebar: site.site.sidebar, + icon: site.site.icon, + users: site.site.users, + version: site.version, + platform: platform, + contentWarning: site.site.contentWarning, + ), ), ), ); - if (getSiteResponse != null) { + if (site != null) { pushOnTopOfLoadingPage(context, route); } else { + final l10n = GlobalContext.l10n; + showSnackbar( l10n.unableToNavigateToInstance(instanceHost), trailingAction: () => handleLink(context, url: "https://$instanceHost", forceOpenInBrowser: true), trailingIcon: Icons.open_in_browser_rounded, ); + hideLoadingPage(context); } } diff --git a/lib/src/core/enums/threadiverse_platform.dart b/lib/src/core/enums/threadiverse_platform.dart index 004435e85..7cf0e84f1 100644 --- a/lib/src/core/enums/threadiverse_platform.dart +++ b/lib/src/core/enums/threadiverse_platform.dart @@ -18,6 +18,12 @@ enum ThreadiversePlatform { } } + /// The name of the platform, formatted for display + String get displayName => switch (this) { + ThreadiversePlatform.lemmy => 'Lemmy', + ThreadiversePlatform.piefed => 'PieFed', + }; + /// Converts ThreadiversePlatform enum to string String? toStringValue() => name; } diff --git a/lib/src/core/models/thunder_instance_info.dart b/lib/src/core/models/thunder_instance_info.dart index 1f016f848..6f2ba4d13 100644 --- a/lib/src/core/models/thunder_instance_info.dart +++ b/lib/src/core/models/thunder_instance_info.dart @@ -1,11 +1,14 @@ import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +/// A class that holds metadata about an instance. class ThunderInstanceInfo { const ThunderInstanceInfo({ this.id, - this.domain, + required this.domain, this.version, - this.name, + required this.name, + this.description, + this.sidebar, this.icon, this.users, this.success = false, @@ -13,32 +16,38 @@ class ThunderInstanceInfo { this.contentWarning, }); - bool isMetadataPopulated() => icon != null || version != null || name != null || users != null; + bool isMetadataPopulated() => icon != null || version != null || users != null; /// The ID of the instance. final int? id; /// The domain of the instance. - final String? domain; + final String domain; - /// The Lemmy version of the instance. + /// The platform of the instance. + final ThreadiversePlatform? platform; + + /// The version of the instance. final String? version; /// The name of the instance. - final String? name; + final String name; /// The icon of the instance. final String? icon; + /// The description of the instance. + final String? description; + + /// The sidebar of the instance. + final String? sidebar; + + /// The content warning of the instance. + final String? contentWarning; + /// The number of users on the instance. final int? users; /// Whether the instance was successfully fetched. final bool success; - - /// The platform of the instance. - final ThreadiversePlatform? platform; - - /// The content warning of the instance. - final String? contentWarning; } diff --git a/lib/src/core/network/lemmy_api.dart b/lib/src/core/network/lemmy_api.dart index 2a45031bf..00f27a1d0 100644 --- a/lib/src/core/network/lemmy_api.dart +++ b/lib/src/core/network/lemmy_api.dart @@ -539,7 +539,7 @@ class LemmyApi { 'community': json['community'] != null ? ThunderCommunity.fromLemmyCommunityView(json['community']) : null, 'post': json['post'] != null ? ThunderPost.fromLemmyPostView(json['post']) : null, 'comment': json['comment'] != null ? ThunderComment.fromLemmyCommentView(json['comment']) : null, - 'user': json['user'] != null ? ThunderUser.fromLemmyUserView(json['user']) : null, + 'user': json['person'] != null ? ThunderUser.fromLemmyUserView(json['person']) : null, }; } diff --git a/lib/src/features/account/presentation/bloc/profile_bloc.dart b/lib/src/features/account/presentation/bloc/profile_bloc.dart index 47a490b9e..717fa0b9a 100644 --- a/lib/src/features/account/presentation/bloc/profile_bloc.dart +++ b/lib/src/features/account/presentation/bloc/profile_bloc.dart @@ -88,7 +88,7 @@ class ProfileBloc extends Bloc { ThunderSiteResponse? siteResponse; try { - siteResponse = await instanceRepository!.getSiteInfo().timeout(const Duration(seconds: 15)); + siteResponse = await instanceRepository!.info().timeout(const Duration(seconds: 15)); downvotesEnabled = siteResponse.site.enableDownvotes ?? true; } catch (e) { return emit(state.copyWith(status: ProfileStatus.failureCheckingInstance, error: () => getExceptionErrorMessage(e))); @@ -134,7 +134,7 @@ class ProfileBloc extends Bloc { // Create a temporary instance repository to use for the site information tempAccount = Account(id: '', index: -1, jwt: jwt, instance: tempAccount.instance, platform: platform); - final siteResponse = await InstanceRepositoryImpl(account: tempAccount).getSiteInfo(); + final siteResponse = await InstanceRepositoryImpl(account: tempAccount).info(); if (event.showContentWarning && siteResponse.site.contentWarning?.isNotEmpty == true) { return emit(state.copyWith(status: ProfileStatus.contentWarning, contentWarning: () => siteResponse.site.contentWarning!)); @@ -252,7 +252,7 @@ class ProfileBloc extends Bloc { emit(state.copyWith(status: ProfileStatus.loading)); // Refresh the site information, which includes the user's settings - final response = await instanceRepository!.getSiteInfo(); + final response = await instanceRepository!.info(); return emit(state.copyWith(status: ProfileStatus.success, siteResponse: () => response)); } catch (e) { diff --git a/lib/src/features/account/presentation/widgets/profile_modal_body.dart b/lib/src/features/account/presentation/widgets/profile_modal_body.dart index e03858aec..e0f96e0c1 100644 --- a/lib/src/features/account/presentation/widgets/profile_modal_body.dart +++ b/lib/src/features/account/presentation/widgets/profile_modal_body.dart @@ -746,7 +746,11 @@ class _ProfileSelectState extends State { for (final account in accountsExtended) { final instanceInfo = await getInstanceInfo(account.instance).timeout( const Duration(seconds: 5), - onTimeout: () => const ThunderInstanceInfo(success: false), + onTimeout: () => ThunderInstanceInfo( + domain: account.instance!, + name: fetchInstanceNameFromUrl(account.instance!)!, + success: false, + ), ); if (mounted) { @@ -802,7 +806,11 @@ class _ProfileSelectState extends State { for (final anonymousInstanceExtended in anonymousInstancesExtended) { final instanceInfo = await getInstanceInfo(anonymousInstanceExtended.anonymousInstance.instance).timeout( const Duration(seconds: 5), - onTimeout: () => const ThunderInstanceInfo(success: false), + onTimeout: () => ThunderInstanceInfo( + domain: anonymousInstanceExtended.anonymousInstance.instance, + name: fetchInstanceNameFromUrl(anonymousInstanceExtended.anonymousInstance.instance)!, + success: false, + ), ); if (mounted) { diff --git a/lib/src/features/community/presentation/widgets/community_list_entry.dart b/lib/src/features/community/presentation/widgets/community_list_entry.dart index 9b1166bab..515c1ba60 100644 --- a/lib/src/features/community/presentation/widgets/community_list_entry.dart +++ b/lib/src/features/community/presentation/widgets/community_list_entry.dart @@ -25,14 +25,14 @@ class CommunityListEntry extends StatefulWidget { /// Whether to indicate that the community is a favorite. final bool indicateFavorites; - /// Whether the community should be resolved to a different instance - final String? resolutionInstance; + /// The account to use for resolving the community to a different instance + final Account? resolutionAccount; const CommunityListEntry({ super.key, required this.community, this.indicateFavorites = true, - this.resolutionInstance, + this.resolutionAccount, }); @override @@ -120,7 +120,7 @@ class _CommunityListEntryState extends State { ] ], ), - trailing: widget.resolutionInstance == null + trailing: widget.resolutionAccount == null ? IconButton( onPressed: () { onSubscribe(community.subscribed != SubscriptionStatus.notSubscribed, isUserLoggedIn); @@ -144,11 +144,9 @@ class _CommunityListEntryState extends State { onTap: () async { int? communityId = widget.community.id; - if (widget.resolutionInstance != null) { + if (widget.resolutionAccount != null) { try { - // Create a temporary Account - final account = Account(instance: widget.resolutionInstance!, id: '', index: -1); - final response = await SearchRepositoryImpl(account: account).resolve(query: widget.community.actorId); + final response = await SearchRepositoryImpl(account: widget.resolutionAccount!).resolve(query: widget.community.actorId); communityId = response['community']?.id; } catch (e) { diff --git a/lib/src/features/instance/data/repositories/instance_repository.dart b/lib/src/features/instance/data/repositories/instance_repository.dart index b85b273c5..54c78a8ff 100644 --- a/lib/src/features/instance/data/repositories/instance_repository.dart +++ b/lib/src/features/instance/data/repositories/instance_repository.dart @@ -13,7 +13,7 @@ import 'package:thunder/src/app/utils/global_context.dart'; /// Interface for a instance repository abstract class InstanceRepository { /// Fetches the site info - Future getSiteInfo(); + Future info(); /// Blocks a given instance Future block(int instanceId, bool block); @@ -49,7 +49,7 @@ class InstanceRepositoryImpl implements InstanceRepository { } @override - Future getSiteInfo() async { + Future info() async { switch (account.platform) { case ThreadiversePlatform.lemmy: return await client.site(); diff --git a/lib/src/features/instance/domain/enums/instance_action.dart b/lib/src/features/instance/domain/enums/instance_action.dart deleted file mode 100644 index 4c02bff13..000000000 --- a/lib/src/features/instance/domain/enums/instance_action.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:thunder/src/features/post/post.dart'; - -enum InstanceAction { - /// User level instance actions - block(permissionType: PermissionType.user); - - const InstanceAction({ - required this.permissionType, - }); - - final PermissionType permissionType; -} diff --git a/lib/src/features/instance/instance.dart b/lib/src/features/instance/instance.dart index 6cd7cbf62..71cf12745 100644 --- a/lib/src/features/instance/instance.dart +++ b/lib/src/features/instance/instance.dart @@ -1,7 +1,5 @@ -export 'domain/enums/instance_action.dart'; export 'data/repositories/instance_repository.dart'; -export 'presentation/bloc/instance_page_cubit.dart'; export 'presentation/pages/instance_page.dart'; -export 'presentation/widgets/instance_view.dart'; +export 'presentation/widgets/instance_information.dart'; export 'presentation/widgets/instance_list_entry.dart'; export 'presentation/widgets/instance_action_bottom_sheet.dart'; diff --git a/lib/src/features/instance/presentation/bloc/instance_page_bloc.dart b/lib/src/features/instance/presentation/bloc/instance_page_bloc.dart new file mode 100644 index 000000000..cc7bc889d --- /dev/null +++ b/lib/src/features/instance/presentation/bloc/instance_page_bloc.dart @@ -0,0 +1,347 @@ +import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:equatable/equatable.dart'; + +import 'package:thunder/src/core/models/thunder_instance_info.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/core/enums/enums.dart'; +import 'package:thunder/src/core/enums/meta_search_type.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/search/search.dart'; +import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/shared/utils/error_messages.dart'; +import 'package:thunder/src/features/instance/presentation/bloc/instance_page_event.dart'; + +part 'instance_page_state.dart'; + +class InstancePageBloc extends Bloc { + /// The account that should be used to resolve data (communities, users, posts, comments) + Account account; + + /// The instance info to use for fetching data + ThunderInstanceInfo instanceInfo; + + /// The search repository to use for fetching data + late SearchRepository repository; + + /// The limit of items to fetch per page + static const int _pageLimit = 30; + + /// The number of items to resolve in parallel at a time + static const int _resolveBatchSize = 6; + + /// The repository to use for resolving items on the user's instance + late SearchRepository localRepository; + + InstancePageBloc({required this.account, required this.instanceInfo}) : super(const InstancePageState()) { + final uri = Uri.parse(instanceInfo.domain); + final tempAccount = Account(instance: uri.host, id: '', index: -1, platform: instanceInfo.platform); + repository = SearchRepositoryImpl(account: tempAccount); + localRepository = SearchRepositoryImpl(account: account); + + on(_onGetInstanceCommunities, transformer: restartable()); + on(_onGetInstanceUsers, transformer: restartable()); + on(_onGetInstancePosts, transformer: restartable()); + on(_onGetInstanceComments, transformer: restartable()); + on(_onResetInstanceTabs); + } + + Future _onGetInstanceCommunities(GetInstanceCommunities event, Emitter emit) async { + final currentPage = event.page ?? state.communities.page; + if (state.communities.status == InstancePageStatus.loading && currentPage != 1) return; + + emit( + state.copyWith( + communities: state.communities.copyWith( + status: InstancePageStatus.loading, + items: currentPage == 1 ? [] : state.communities.items, + ), + ), + ); + + try { + final response = await repository.search( + query: event.query ?? '', + type: MetaSearchType.communities, + sort: event.sortType, + listingType: FeedListType.local, + limit: _pageLimit, + page: currentPage, + ); + + final List communities = List.from(response['communities']); + final status = communities.isEmpty || communities.length < _pageLimit ? InstancePageStatus.done : InstancePageStatus.success; + + emit( + state.copyWith( + communities: state.communities.copyWith( + status: status, + items: [...(currentPage == 1 ? [] : state.communities.items), ...communities], + page: currentPage, + ), + ), + ); + } catch (e) { + emit( + state.copyWith( + communities: state.communities.copyWith( + status: InstancePageStatus.failure, + message: getExceptionErrorMessage(e), + ), + ), + ); + } + } + + Future _onGetInstanceUsers(GetInstanceUsers event, Emitter emit) async { + final currentPage = event.page ?? state.users.page; + if (state.users.status == InstancePageStatus.loading && currentPage != 1) return; + + emit( + state.copyWith( + users: state.users.copyWith( + status: InstancePageStatus.loading, + items: currentPage == 1 ? [] : state.users.items, + ), + ), + ); + + try { + final response = await repository.search( + query: event.query ?? '', + type: MetaSearchType.users, + sort: event.sortType, + listingType: FeedListType.local, + limit: _pageLimit, + page: currentPage, + ); + + final List users = List.from(response['users']); + final status = users.isEmpty || users.length < _pageLimit ? InstancePageStatus.done : InstancePageStatus.success; + + emit( + state.copyWith( + users: state.users.copyWith( + status: status, + items: [...(currentPage == 1 ? [] : state.users.items), ...users], + page: currentPage, + ), + ), + ); + } catch (e) { + emit( + state.copyWith( + users: state.users.copyWith( + status: InstancePageStatus.failure, + message: getExceptionErrorMessage(e), + ), + ), + ); + } + } + + Future _onGetInstancePosts(GetInstancePosts event, Emitter emit) async { + final currentPage = event.page ?? state.posts.page; + if (state.posts.status == InstancePageStatus.loading && currentPage != 1) return; + + emit( + state.copyWith( + posts: state.posts.copyWith( + status: InstancePageStatus.loading, + items: currentPage == 1 ? [] : state.posts.items, + ), + ), + ); + + try { + final response = await repository.search( + query: event.query ?? '', + type: MetaSearchType.posts, + sort: event.sortType, + listingType: FeedListType.local, + limit: _pageLimit, + page: currentPage, + ); + + final List posts = response['posts']; + final status = posts.isEmpty || posts.length < _pageLimit ? InstancePageStatus.done : InstancePageStatus.success; + + final List previousItems = currentPage == 1 ? [] : state.posts.items; + List allResolvedPosts = []; + + if (posts.isEmpty) { + emit( + state.copyWith( + posts: state.posts.copyWith( + status: InstancePageStatus.done, + items: previousItems, + page: currentPage, + ), + ), + ); + return; + } + + for (int i = 0; i < posts.length; i += _resolveBatchSize) { + final end = (i + _resolveBatchSize < posts.length) ? i + _resolveBatchSize : posts.length; + final batch = posts.sublist(i, end); + + final resolvedBatch = await Future.wait( + batch.map( + (post) async { + try { + final response = await localRepository.resolve(query: post.apId); + return response['post'] as ThunderPost?; + } catch (e) { + return null; + } + }, + ), + ); + + final nonNullResolved = resolvedBatch.whereType().toList(); + allResolvedPosts.addAll(nonNullResolved); + + emit( + state.copyWith( + posts: state.posts.copyWith( + status: InstancePageStatus.loading, + items: [...previousItems, ...allResolvedPosts], + page: currentPage, + ), + ), + ); + } + + emit( + state.copyWith( + posts: state.posts.copyWith( + status: status, + items: [...previousItems, ...allResolvedPosts], + page: currentPage, + ), + ), + ); + } catch (e) { + emit( + state.copyWith( + posts: state.posts.copyWith( + status: InstancePageStatus.failure, + message: getExceptionErrorMessage(e), + ), + ), + ); + } + } + + Future _onGetInstanceComments(GetInstanceComments event, Emitter emit) async { + final currentPage = event.page ?? state.comments.page; + if (state.comments.status == InstancePageStatus.loading && currentPage != 1) return; + + emit( + state.copyWith( + comments: state.comments.copyWith( + status: InstancePageStatus.loading, + items: currentPage == 1 ? [] : state.comments.items, + ), + ), + ); + + try { + final response = await repository.search( + query: event.query ?? '', + type: MetaSearchType.comments, + sort: event.sortType, + listingType: FeedListType.local, + limit: _pageLimit, + page: currentPage, + ); + + final List comments = response['comments']; + final status = comments.isEmpty || comments.length < _pageLimit ? InstancePageStatus.done : InstancePageStatus.success; + + final List previousItems = currentPage == 1 ? [] : state.comments.items; + List allResolvedComments = []; + + if (comments.isEmpty) { + emit( + state.copyWith( + comments: state.comments.copyWith( + status: InstancePageStatus.done, + items: previousItems, + page: currentPage, + ), + ), + ); + return; + } + + for (var i = 0; i < comments.length; i += _resolveBatchSize) { + final end = (i + _resolveBatchSize < comments.length) ? i + _resolveBatchSize : comments.length; + final batch = comments.sublist(i, end); + + final resolvedBatch = await Future.wait( + batch.map( + (comment) async { + try { + final response = await localRepository.resolve(query: comment.apId); + return response['comment'] as ThunderComment?; + } catch (e) { + return null; + } + }, + ), + ); + + final nonNullResolved = resolvedBatch.whereType().toList(); + allResolvedComments.addAll(nonNullResolved); + + emit( + state.copyWith( + comments: state.comments.copyWith( + status: InstancePageStatus.loading, + items: [...previousItems, ...allResolvedComments], + page: currentPage, + ), + ), + ); + } + + emit( + state.copyWith( + comments: state.comments.copyWith( + status: status, + items: [...previousItems, ...allResolvedComments], + page: currentPage, + ), + ), + ); + } catch (e) { + emit( + state.copyWith( + comments: state.comments.copyWith( + status: InstancePageStatus.failure, + message: getExceptionErrorMessage(e), + ), + ), + ); + } + } + + void _onResetInstanceTabs(ResetInstanceTabs event, Emitter emit) { + if (event.excludeType != MetaSearchType.communities) { + emit(state.copyWith(communities: const InstanceTypeState())); + } + if (event.excludeType != MetaSearchType.users) { + emit(state.copyWith(users: const InstanceTypeState())); + } + if (event.excludeType != MetaSearchType.posts) { + emit(state.copyWith(posts: const InstanceTypeState())); + } + if (event.excludeType != MetaSearchType.comments) { + emit(state.copyWith(comments: const InstanceTypeState())); + } + } +} diff --git a/lib/src/features/instance/presentation/bloc/instance_page_cubit.dart b/lib/src/features/instance/presentation/bloc/instance_page_cubit.dart deleted file mode 100644 index 03596e711..000000000 --- a/lib/src/features/instance/presentation/bloc/instance_page_cubit.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; - -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/error_messages.dart'; - -part 'instance_page_state.dart'; - -class InstancePageCubit extends Cubit { - static const int _pageLimit = 15; - - Account account; - - late SearchRepository searchRepository; - final String instance; - - InstancePageCubit({required this.instance, required String resolutionInstance, required this.account}) - : super(InstancePageState(status: InstancePageStatus.success, resolutionInstance: resolutionInstance)) { - searchRepository = SearchRepositoryImpl(account: account); - } - - Future loadCommunities({int? page, required PostSortType postSortType}) async { - if (page == 1) emit(state.copyWith(status: InstancePageStatus.loading)); - - try { - final response = await searchRepository.search( - query: '', - type: MetaSearchType.communities, - sort: postSortType, - listingType: FeedListType.local, - limit: _pageLimit, - page: page ?? 1, - ); - - final List communities = response['communities']; - final status = communities.isEmpty || communities.length < _pageLimit ? InstancePageStatus.done : InstancePageStatus.success; - - emit( - state.copyWith( - status: status, - communities: [...(state.communities ?? []), ...communities], - page: page ?? 1, - ), - ); - } catch (e) { - emit(state.copyWith(status: InstancePageStatus.failure, errorMessage: getExceptionErrorMessage(e))); - } - } - - Future loadUsers({int? page, required PostSortType postSortType}) async { - if (page == 1) emit(state.copyWith(status: InstancePageStatus.loading)); - - try { - final response = await searchRepository.search( - query: '', - type: MetaSearchType.users, - sort: postSortType, - listingType: FeedListType.local, - limit: _pageLimit, - page: page ?? 1, - ); - - final List users = response['users']; - final status = users.isEmpty || users.length < _pageLimit ? InstancePageStatus.done : InstancePageStatus.success; - - emit( - state.copyWith( - status: status, - users: [...(state.users ?? []), ...users], - page: page ?? 1, - ), - ); - } catch (e) { - emit(state.copyWith(status: InstancePageStatus.failure, errorMessage: getExceptionErrorMessage(e))); - } - } - - Future loadPosts({int? page, required PostSortType postSortType}) async { - if (page == 1) emit(state.copyWith(status: InstancePageStatus.loading)); - - try { - final response = await searchRepository.search( - query: '', - type: MetaSearchType.posts, - sort: postSortType, - listingType: FeedListType.local, - limit: _pageLimit, - page: page ?? 1, - ); - - final List posts = response['posts']; - final status = posts.isEmpty || posts.length < _pageLimit ? InstancePageStatus.done : InstancePageStatus.success; - - emit( - state.copyWith( - status: status, - posts: [...(state.posts ?? []), ...(await parsePosts(posts, resolutionInstance: state.resolutionInstance))], - page: page ?? 1, - ), - ); - } catch (e) { - emit(state.copyWith(status: InstancePageStatus.failure, errorMessage: getExceptionErrorMessage(e))); - } - } - - Future loadComments({int? page, required PostSortType postSortType}) async { - if (page == 1) emit(state.copyWith(status: InstancePageStatus.loading)); - - try { - final response = await searchRepository.search( - query: '', - type: MetaSearchType.comments, - sort: postSortType, - listingType: FeedListType.local, - limit: _pageLimit, - page: page ?? 1, - ); - - final List comments = response['comments']; - final status = comments.isEmpty || comments.length < _pageLimit ? InstancePageStatus.done : InstancePageStatus.success; - - emit( - state.copyWith( - status: status, - comments: [...(state.comments ?? []), ...comments], - page: page ?? 1, - ), - ); - } catch (e) { - emit(state.copyWith(status: InstancePageStatus.failure, errorMessage: getExceptionErrorMessage(e))); - } - } -} diff --git a/lib/src/features/instance/presentation/bloc/instance_page_event.dart b/lib/src/features/instance/presentation/bloc/instance_page_event.dart new file mode 100644 index 000000000..7820b6a71 --- /dev/null +++ b/lib/src/features/instance/presentation/bloc/instance_page_event.dart @@ -0,0 +1,64 @@ +import 'package:equatable/equatable.dart'; + +import 'package:thunder/src/core/enums/meta_search_type.dart'; +import 'package:thunder/src/core/enums/post_sort_type.dart'; + +abstract class InstancePageEvent extends Equatable { + const InstancePageEvent(); + + @override + List get props => []; +} + +class GetInstanceCommunities extends InstancePageEvent { + final int? page; + final PostSortType sortType; + final String? query; + + const GetInstanceCommunities({this.page, required this.sortType, this.query}); + + @override + List get props => [page, sortType, query]; +} + +class GetInstanceUsers extends InstancePageEvent { + final int? page; + final PostSortType sortType; + final String? query; + + const GetInstanceUsers({this.page, required this.sortType, this.query}); + + @override + List get props => [page, sortType, query]; +} + +class GetInstancePosts extends InstancePageEvent { + final int? page; + final PostSortType sortType; + final String? query; + + const GetInstancePosts({this.page, required this.sortType, this.query}); + + @override + List get props => [page, sortType, query]; +} + +class GetInstanceComments extends InstancePageEvent { + final int? page; + final PostSortType sortType; + final String? query; + + const GetInstanceComments({this.page, required this.sortType, this.query}); + + @override + List get props => [page, sortType, query]; +} + +class ResetInstanceTabs extends InstancePageEvent { + final MetaSearchType? excludeType; + + const ResetInstanceTabs({this.excludeType}); + + @override + List get props => [excludeType]; +} diff --git a/lib/src/features/instance/presentation/bloc/instance_page_state.dart b/lib/src/features/instance/presentation/bloc/instance_page_state.dart index 39d418320..c0a9ed42e 100644 --- a/lib/src/features/instance/presentation/bloc/instance_page_state.dart +++ b/lib/src/features/instance/presentation/bloc/instance_page_state.dart @@ -1,50 +1,91 @@ -part of 'instance_page_cubit.dart'; +part of 'instance_page_bloc.dart'; enum InstancePageStatus { none, loading, success, failure, done } +class InstanceTypeState extends Equatable { + /// The status of the instance type + final InstancePageStatus status; + + /// The error message if the instance type failed to load + final String? message; + + /// The current page of the instance type + final int page; + + /// The list of items for the instance type + final List items; + + const InstanceTypeState({ + this.status = InstancePageStatus.none, + this.message, + this.page = 1, + this.items = const [], + }); + + InstanceTypeState copyWith({ + InstancePageStatus? status, + String? message, + int? page, + List? items, + }) { + return InstanceTypeState( + status: status ?? this.status, + message: message ?? this.message, + page: page ?? this.page, + items: items ?? this.items, + ); + } + + @override + List get props => [status, message, page, items]; +} + class InstancePageState extends Equatable { + /// The status of the instance page final InstancePageStatus status; - final String? errorMessage; - final int? page; - final String resolutionInstance; - final List? communities; - final List? posts; - final List? users; - final List? comments; + /// The error message if the instance page failed to load + final String? message; + + /// The communities for the instance page + final InstanceTypeState communities; + + /// The posts for the instance page + final InstanceTypeState posts; + + /// The users for the instance page + final InstanceTypeState users; + + /// The comments for the instance page + final InstanceTypeState comments; const InstancePageState({ - this.status = InstancePageStatus.none, - this.errorMessage, - this.communities, - this.posts, - this.users, - this.comments, - this.page, - required this.resolutionInstance, + this.status = InstancePageStatus.success, + this.message, + this.communities = const InstanceTypeState(), + this.posts = const InstanceTypeState(), + this.users = const InstanceTypeState(), + this.comments = const InstanceTypeState(), }); InstancePageState copyWith({ - required InstancePageStatus status, - String? errorMessage, - List? communities, - List? posts, - List? users, - List? comments, - int? page, + InstancePageStatus? status, + String? message, + InstanceTypeState? communities, + InstanceTypeState? posts, + InstanceTypeState? users, + InstanceTypeState? comments, }) { return InstancePageState( - status: status, - errorMessage: errorMessage, - communities: communities, - posts: posts, - users: users, - comments: comments, - page: page, - resolutionInstance: resolutionInstance, + status: status ?? this.status, + message: message ?? this.message, + communities: communities ?? this.communities, + posts: posts ?? this.posts, + users: users ?? this.users, + comments: comments ?? this.comments, ); } @override - List get props => [status, errorMessage, communities, posts, users, comments, page, resolutionInstance]; + List get props => [status, message, communities, posts, users, comments]; } diff --git a/lib/src/features/instance/presentation/pages/instance_page.dart b/lib/src/features/instance/presentation/pages/instance_page.dart index fd55641c2..2bfc885ea 100644 --- a/lib/src/features/instance/presentation/pages/instance_page.dart +++ b/lib/src/features/instance/presentation/pages/instance_page.dart @@ -1,365 +1,239 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/core/enums/meta_search_type.dart'; import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; import 'package:thunder/src/core/models/models.dart'; +import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/instance/instance.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/shared/widgets/chips/thunder_action_chip.dart'; -import 'package:thunder/src/shared/error_message.dart'; -import 'package:thunder/src/shared/persistent_header.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/shared/sort_picker.dart'; -import 'package:thunder/src/shared/widgets/thunder_popup_menu_item.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/shared/utils/links.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/shared/utils/numbers.dart'; - +import 'package:thunder/src/features/instance/presentation/bloc/instance_page_bloc.dart'; +import 'package:thunder/src/features/instance/presentation/bloc/instance_page_event.dart'; +import 'package:thunder/src/features/instance/presentation/widgets/instance_page_app_bar.dart'; +import 'package:thunder/src/features/instance/presentation/widgets/instance_tabs.dart'; + +/// A widget that displays the instance page. +/// +/// The page contains information about a given instance, with the ability to explore its content. class InstancePage extends StatefulWidget { - /// The platform of the instance. - final ThreadiversePlatform platform; - - /// The site information for the instance. - final ThunderSiteResponse site; - - /// Whether the instance is blocked. - final bool? isBlocked; - - // This is needed (in addition to Site) specifically for blocking. - // Since site is requested directly from the target instance, its ID is only right on its own server - // But it's wrong on the server we're connected to. - final int? instanceId; + /// The instance to display. + final ThunderInstanceInfo instance; const InstancePage({ super.key, - required this.platform, - required this.site, - required this.isBlocked, - required this.instanceId, + required this.instance, }); @override State createState() => _InstancePageState(); } -class _InstancePageState extends State { - final ScrollController _scrollController = ScrollController(initialScrollOffset: 0); - bool _isLoading = false; - - bool? isBlocked; - bool currentlyTogglingBlock = false; +class _InstancePageState extends State with SingleTickerProviderStateMixin { + /// The tab controller + late final TabController _tabController; - // Use the existing SearchType enum to represent what we're showing in the instance page - // with SearchType.all representing the about page - MetaSearchType viewType = MetaSearchType.all; + /// The post sort type to use PostSortType postSortType = PostSortType.topAll; - /// Context for [_onScroll] to use + /// Context for [_onScroll] to use to find the proper cubit BuildContext? buildContext; + /// The query to use for search + String? query; + @override void initState() { - _scrollController.addListener(_onScroll); + _tabController = TabController(length: 5, vsync: this); + _tabController.addListener(() { + if (!_tabController.indexIsChanging) _handleTabChange(); + }); + super.initState(); } @override - Widget build(BuildContext context) { - final AppLocalizations l10n = AppLocalizations.of(context)!; - final ThemeData theme = Theme.of(context); + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + void _onRefresh() { + final context = buildContext; + if (context == null || !context.mounted) return; + + // Refresh specific tab and reset others + final bloc = context.read(); + + switch (_tabController.index) { + case 1: + bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.communities)); + bloc.add(GetInstanceCommunities(page: 1, sortType: postSortType, query: query)); + break; + case 2: + bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.users)); + bloc.add(GetInstanceUsers(page: 1, sortType: postSortType, query: query)); + break; + case 3: + bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.posts)); + bloc.add(GetInstancePosts(page: 1, sortType: postSortType, query: query)); + break; + case 4: + bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.comments)); + bloc.add(GetInstanceComments(page: 1, sortType: postSortType, query: query)); + break; + default: + bloc.add(const ResetInstanceTabs(excludeType: null)); + break; + } + } - isBlocked ??= widget.isBlocked ?? false; - final bool tabletMode = context.read().state.tabletMode; + void _handleTabChange() { + final context = buildContext; + if (context == null || !context.mounted) return; - final String accountInstance = context.read().state.account.instance; + final bloc = context.read(); - final chipColor = theme.colorScheme.primaryContainer.withValues(alpha: 0.25); + switch (_tabController.index) { + case 1: + if (bloc.state.communities.items.isEmpty) bloc.add(GetInstanceCommunities(sortType: postSortType, query: query)); + break; + case 2: + if (bloc.state.users.items.isEmpty) bloc.add(GetInstanceUsers(sortType: postSortType, query: query)); + break; + case 3: + if (bloc.state.posts.items.isEmpty) bloc.add(GetInstancePosts(sortType: postSortType, query: query)); + break; + case 4: + if (bloc.state.comments.items.isEmpty) bloc.add(GetInstanceComments(sortType: postSortType, query: query)); + break; + } + } + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; - final account = context.select((bloc) => bloc.state.account); + final account = context.read().state.account; return MultiBlocProvider( providers: [ - BlocProvider.value( - value: InstancePageCubit( - instance: fetchInstanceNameFromUrl(widget.site.site.actorId)!, - resolutionInstance: accountInstance, - account: account, - ), - ), - BlocProvider.value( - value: FeedBloc( - account: Account( - id: '', - instance: fetchInstanceNameFromUrl(widget.site.site.actorId)!, - index: -1, - platform: widget.platform, - ), - ), - ), + BlocProvider(create: (context) => InstancePageBloc(account: account, instanceInfo: widget.instance)), + BlocProvider(create: (context) => FeedBloc(account: account)), ], - child: BlocConsumer( + child: BlocConsumer( listener: (context, state) { - context.read().add(PopulatePostsEvent(state.posts ?? [])); + context.read().add(PopulatePostsEvent(state.posts.items)); }, builder: (context, state) { buildContext = context; - return Scaffold( - body: Container( - color: theme.colorScheme.surface, - child: SafeArea( - top: false, - child: CustomScrollView( - controller: _scrollController, - slivers: [ - SliverAppBar( - pinned: true, - toolbarHeight: APP_BAR_HEIGHT, - title: ListTile( - title: Text( - fetchInstanceNameFromUrl(widget.site.site.actorId) ?? '', - overflow: TextOverflow.fade, - maxLines: 1, - softWrap: false, - style: theme.textTheme.titleLarge, - ), - subtitle: Text("v${widget.site.version} · ${l10n.countUsers(formatLongNumber(widget.site.site.users ?? 0))}"), - contentPadding: const EdgeInsets.symmetric(horizontal: 0), - ), - actions: [ - if (widget.instanceId != null) - IconButton( - tooltip: isBlocked! ? l10n.unblockInstance : l10n.blockInstance, - onPressed: () async { - currentlyTogglingBlock = true; - - final repository = InstanceRepositoryImpl(account: account); - final blocked = await repository.block(widget.instanceId!, !isBlocked!); - - if (blocked) { - showSnackbar(l10n.successfullyBlockedCommunity(fetchInstanceNameFromUrl(widget.site.site.actorId) ?? '')); - } else { - showSnackbar(l10n.successfullyUnblockedCommunity(fetchInstanceNameFromUrl(widget.site.site.actorId) ?? '')); - } - currentlyTogglingBlock = false; - setState(() => isBlocked = !isBlocked!); - }, - icon: Icon( - isBlocked! ? Icons.undo_rounded : Icons.block, - semanticLabel: isBlocked! ? l10n.unblockInstance : l10n.blockInstance, + return Scaffold( + body: SafeArea( + top: false, + child: NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: InstancePageAppBar( + instance: widget.instance, + postSortType: postSortType, + account: account, + onSortSelected: (sortType) { + setState(() => postSortType = sortType); + _onRefresh(); + }, + onQueryChanged: (query) { + setState(() => this.query = query); + _onRefresh(); + }, + bottom: TabBar( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + tabs: [ + Tab( + child: Row( + spacing: 6.0, + children: [const Icon(Icons.info_outline_rounded, size: 20.0), Text(l10n.about)], + ), ), - ), - if (viewType == MetaSearchType.all) - IconButton( - tooltip: l10n.openInBrowser, - onPressed: () => handleLink(context, url: widget.site.site.actorId), - icon: Icon( - Icons.open_in_browser_rounded, - semanticLabel: l10n.openInBrowser, + Tab( + child: Row( + spacing: 6.0, + children: [const Icon(Icons.groups_outlined, size: 20.0), Text(l10n.communities)], + ), ), - ), - if (viewType != MetaSearchType.all) - IconButton( - icon: Icon(Icons.sort, semanticLabel: l10n.sortBy), - onPressed: () { - final feedBloc = context.read(); - HapticFeedback.mediumImpact(); - - showModalBottomSheet( - showDragHandle: true, - context: context, - isScrollControlled: true, - builder: (builderContext) => SortPicker( - account: feedBloc.account, - title: l10n.sortOptions, - onSelect: (selected) async { - postSortType = selected.payload; - _doLoad(context); - }, - previouslySelected: postSortType, - ), - ); - }, - ), - Semantics( - label: l10n.menu, - child: PopupMenuButton( - itemBuilder: (context) => [ - ThunderPopupMenuItem( - onTap: () async { - HapticFeedback.mediumImpact(); - navigateToModlogPage( - context, - subtitle: fetchInstanceNameFromUrl(widget.site.site.actorId) ?? '', - ); - }, - icon: Icons.shield_rounded, - title: l10n.modlog, + Tab( + child: Row( + spacing: 6.0, + children: [const Icon(Icons.people_outlined, size: 20.0), Text(l10n.users)], ), - if (viewType != MetaSearchType.all) - ThunderPopupMenuItem( - onTap: () => handleLink(context, url: widget.site.site.actorId), - icon: Icons.open_in_browser_rounded, - title: l10n.openInBrowser, - ), - ], - ), - ), - ], - ), - SliverPersistentHeader( - pinned: true, - delegate: PersistentHeader( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Padding( - padding: const EdgeInsets.only(left: 15, right: 15), - child: Row( - spacing: 10.0, - children: [ - ThunderActionChip( - backgroundColor: viewType == MetaSearchType.all ? chipColor : null, - icon: Icons.info_rounded, - onPressed: () => setState(() => viewType = MetaSearchType.all), - label: l10n.about, - ), - ThunderActionChip( - backgroundColor: viewType == MetaSearchType.communities ? chipColor : null, - icon: Icons.people_rounded, - onPressed: () async { - viewType = MetaSearchType.communities; - await context.read().loadCommunities(page: 1, postSortType: postSortType); - WidgetsBinding.instance.addPostFrameCallback((_) => _scrollController.jumpTo(0)); - }, - label: l10n.communities, - ), - ThunderActionChip( - backgroundColor: viewType == MetaSearchType.users ? chipColor : null, - icon: Icons.person_rounded, - onPressed: () async { - viewType = MetaSearchType.users; - await context.read().loadUsers(page: 1, postSortType: postSortType); - WidgetsBinding.instance.addPostFrameCallback((_) => _scrollController.jumpTo(0)); - }, - label: l10n.users, - ), - ThunderActionChip( - backgroundColor: viewType == MetaSearchType.posts ? chipColor : null, - icon: Icons.article_rounded, - onPressed: () async { - viewType = MetaSearchType.posts; - await context.read().loadPosts(page: 1, postSortType: postSortType); - WidgetsBinding.instance.addPostFrameCallback((_) => _scrollController.jumpTo(0)); - }, - label: l10n.posts, - ), - ThunderActionChip( - backgroundColor: viewType == MetaSearchType.comments ? chipColor : null, - icon: Icons.comment_rounded, - onPressed: () async { - viewType = MetaSearchType.comments; - await context.read().loadComments(page: 1, postSortType: postSortType); - WidgetsBinding.instance.addPostFrameCallback((_) => _scrollController.jumpTo(0)); - }, - label: l10n.comments, - ), - ], ), - ), - ), - ), - ), - if (state.status == InstancePageStatus.loading) - const SliverFillRemaining( - child: Center( - child: CircularProgressIndicator(), - ), - ), - if (state.status == InstancePageStatus.failure) - SliverFillRemaining( - child: ErrorMessage( - message: state.errorMessage, - actions: [ - ( - text: l10n.refreshContent, - action: () async => await _doLoad(context), - loading: false, + Tab( + child: Row( + spacing: 6.0, + children: [const Icon(Icons.splitscreen_rounded, size: 20.0), Text(l10n.posts)], + ), + ), + Tab( + child: Row( + spacing: 6.0, + children: [const Icon(Icons.comment_outlined, size: 20.0), Text(l10n.comments)], + ), ), ], ), ), - if (state.status == InstancePageStatus.success || state.status == InstancePageStatus.done) ...[ - if (viewType == MetaSearchType.all) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(20), - child: Material( - child: InstanceView(site: widget.site.site), + ), + ]; + }, + body: TabBarView( + controller: _tabController, + children: [ + // About Tab + Builder(builder: (context) { + return CustomScrollView( + key: const PageStorageKey('about'), + slivers: [ + SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(20), + child: Material( + child: InstanceInformation(instance: widget.instance), + ), ), ), - ), - if (viewType == MetaSearchType.communities) - SliverList.builder( - itemCount: state.communities?.length, - itemBuilder: (context, index) { - final community = state.communities?[index]; - - return Material( - child: community != null ? CommunityListEntry(community: community, resolutionInstance: state.resolutionInstance) : Container(), - ); - }, - ), - if (viewType == MetaSearchType.users) - SliverList.builder( - itemCount: state.users?.length, - itemBuilder: (context, index) { - final user = state.users?[index]; - return Material(child: user != null ? UserListEntry(user: user, resolutionInstance: state.resolutionInstance) : Container()); - }, - ), - if (viewType == MetaSearchType.posts) - FeedPostCardList( - markPostReadOnScroll: false, - posts: state.posts ?? [], - tabletMode: tabletMode, - ), - if (viewType == MetaSearchType.comments) - SliverList.builder( - itemCount: state.comments?.length, - itemBuilder: (context, index) { - final comment = state.comments?[index]; - return Material( - child: comment != null ? CommentListEntry(comment: comment) : Container(), - ); - }, - ), - ], - if (state.status == InstancePageStatus.success && viewType != MetaSearchType.all) ...[ - const SliverToBoxAdapter( - child: SizedBox(height: 10), - ), - const SliverToBoxAdapter( - child: Center( - child: CircularProgressIndicator(), - ), - ), - ], - if (viewType != MetaSearchType.all) - const SliverToBoxAdapter( - child: SizedBox(height: 10), - ), + ], + ); + }), + InstanceCommunityTab( + account: account, + query: query, + postSortType: postSortType, + onRetry: () => context.read().add(GetInstanceCommunities(sortType: postSortType, query: query)), + ), + InstanceUserTab( + account: account, + query: query, + postSortType: postSortType, + onRetry: () => context.read().add(GetInstanceUsers(sortType: postSortType, query: query)), + ), + InstancePostTab( + account: account, + query: query, + postSortType: postSortType, + onRetry: () => context.read().add(GetInstancePosts(sortType: postSortType, query: query)), + ), + InstanceCommentTab( + account: account, + query: query, + postSortType: postSortType, + onRetry: () => context.read().add(GetInstanceComments(sortType: postSortType, query: query)), + ), ], ), ), @@ -369,36 +243,4 @@ class _InstancePageState extends State { ), ); } - - Future _doLoad(BuildContext context, {int? page}) async { - final InstancePageCubit instancePageCubit = context.read(); - - switch (viewType) { - case MetaSearchType.communities: - await instancePageCubit.loadCommunities(page: page ?? 1, postSortType: postSortType); - break; - case MetaSearchType.users: - await instancePageCubit.loadUsers(page: page ?? 1, postSortType: postSortType); - break; - case MetaSearchType.posts: - await instancePageCubit.loadPosts(page: page ?? 1, postSortType: postSortType); - break; - case MetaSearchType.comments: - await instancePageCubit.loadComments(page: page ?? 1, postSortType: postSortType); - break; - default: - break; - } - } - - Future _onScroll() async { - if (!_isLoading && _scrollController.position.pixels >= _scrollController.position.maxScrollExtent * 0.8) { - _isLoading = true; - InstancePageState? instancePageState = buildContext?.read().state; - if (instancePageState != null && instancePageState.status != InstancePageStatus.done) { - await _doLoad(buildContext!, page: (instancePageState.page ?? 0) + 1); - } - _isLoading = false; - } - } } diff --git a/lib/src/features/instance/presentation/widgets/instance_information.dart b/lib/src/features/instance/presentation/widgets/instance_information.dart new file mode 100644 index 000000000..5b41056ae --- /dev/null +++ b/lib/src/features/instance/presentation/widgets/instance_information.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; + +import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/core/models/models.dart'; +import 'package:thunder/src/shared/divider.dart'; +import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; +import 'package:thunder/src/shared/widgets/avatars/instance_avatar.dart'; + +/// A widget that displays information about a given instance. +class InstanceInformation extends StatelessWidget { + /// Information about the instance. + final ThunderInstanceInfo instance; + + const InstanceInformation({super.key, required this.instance}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = GlobalContext.l10n; + + return Column( + children: [ + Row( + spacing: 16.0, + children: [ + InstanceAvatar( + radius: 24.0, + instance: instance, + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + instance.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), + ), + Flexible( + child: Text( + instance.description ?? '-', + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ), + ], + ), + if (instance.sidebar?.isNotEmpty == true) ...[ + const ThunderDivider(sliver: false, padding: false), + const SizedBox(height: 8.0), + Row( + spacing: 6.0, + children: [ + Badge( + label: Text(instance.platform?.displayName ?? '-'), + backgroundColor: theme.colorScheme.primary, + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + ), + Badge( + label: Text('v${instance.version ?? '-'}'), + backgroundColor: theme.colorScheme.secondary, + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + ), + Badge( + label: Text(l10n.countUsers(NumberFormat.decimalPattern(l10n.localeName).format(instance.users ?? 0))), + backgroundColor: theme.colorScheme.tertiary, + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + ), + ], + ), + const SizedBox(height: 16.0), + CommonMarkdownBody(body: instance.sidebar ?? '-'), + ], + ], + ); + } +} diff --git a/lib/src/features/instance/presentation/widgets/instance_list_entry.dart b/lib/src/features/instance/presentation/widgets/instance_list_entry.dart index 5e4221ed5..9d133b9ab 100644 --- a/lib/src/features/instance/presentation/widgets/instance_list_entry.dart +++ b/lib/src/features/instance/presentation/widgets/instance_list_entry.dart @@ -1,18 +1,17 @@ import 'package:flutter/material.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/app/utils/global_context.dart'; import 'package:thunder/src/core/models/models.dart'; - import 'package:thunder/src/app/utils/navigation.dart'; import 'package:thunder/src/shared/widgets/avatars/instance_avatar.dart'; import 'package:thunder/src/shared/utils/numbers.dart'; /// Creates a widget which can display a summary of an instance for a list. -/// Note that this is only Stateful so that it can be useful within an AnimatedContainer. class InstanceListEntry extends StatefulWidget { - final ThunderInstanceInfo instanceInfo; + /// The instance to display. + final ThunderInstanceInfo instance; - const InstanceListEntry({super.key, required this.instanceInfo}); + const InstanceListEntry({super.key, required this.instance}); @override State createState() => _InstanceListEntryState(); @@ -21,37 +20,38 @@ class InstanceListEntry extends StatefulWidget { class _InstanceListEntryState extends State { @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final instanceInfo = widget.instanceInfo; + final l10n = GlobalContext.l10n; - final name = instanceInfo.name; - final domain = instanceInfo.domain ?? ''; - final users = instanceInfo.users ?? 0; - final version = instanceInfo.version; + final name = widget.instance.name; + final domain = widget.instance.domain; + final users = widget.instance.users ?? 0; + final version = widget.instance.version; - if (!instanceInfo.success) { + if (!widget.instance.success) { return ListTile( - leading: InstanceAvatar(instance: instanceInfo), - title: Text(name ?? domain, overflow: TextOverflow.ellipsis), - subtitle: Wrap(children: [ - Text(domain, overflow: TextOverflow.ellipsis), - Text(' · ${l10n.unreachable}'), - ]), + leading: InstanceAvatar(instance: widget.instance), + title: Text(name.isNotEmpty ? name : domain, overflow: TextOverflow.ellipsis), + subtitle: Wrap( + children: [ + Text(domain, overflow: TextOverflow.ellipsis), + Text(' · ${l10n.unreachable}'), + ], + ), onTap: null, ); } return ListTile( - leading: InstanceAvatar(instance: instanceInfo), - title: Text(name ?? domain, overflow: TextOverflow.ellipsis), + leading: InstanceAvatar(instance: widget.instance), + title: Text(name.isNotEmpty ? name : domain, overflow: TextOverflow.ellipsis), subtitle: Wrap( children: [ Text(domain, overflow: TextOverflow.ellipsis), - if (instanceInfo.users != null) Text(' · ${l10n.countUsers(formatLongNumber(users))}', semanticsLabel: l10n.countUsers(users)), + if (widget.instance.users != null) Text(' · ${l10n.countUsers(formatLongNumber(users))}', semanticsLabel: l10n.countUsers(users)), if (version?.isNotEmpty == true) Text(' · v$version', semanticsLabel: 'v$version'), ], ), - onTap: () => navigateToInstancePage(context, instanceHost: domain, instanceId: instanceInfo.id), + onTap: () => navigateToInstancePage(context, instanceHost: domain, instanceId: widget.instance.id), ); } } diff --git a/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart b/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart new file mode 100644 index 000000000..314c3838f --- /dev/null +++ b/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart @@ -0,0 +1,166 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/core/models/models.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/instance/instance.dart'; +import 'package:thunder/src/shared/snackbar.dart'; +import 'package:thunder/src/shared/sort_picker.dart'; +import 'package:thunder/src/shared/utils/constants.dart'; +import 'package:thunder/src/shared/utils/links.dart'; +import 'package:thunder/src/shared/widgets/thunder_popup_menu_item.dart'; + +class InstancePageAppBar extends StatefulWidget { + /// The instance being displayed. + final ThunderInstanceInfo instance; + + /// The sort type for the instance's data. + final PostSortType postSortType; + + /// The account being used. + final Account account; + + /// Callback for when the sort type is changed. + final Function(PostSortType sortType) onSortSelected; + + /// Widget to be displayed at the bottom of the app bar. + final PreferredSizeWidget? bottom; + + /// Callback for when the query is changed. + final Function(String query) onQueryChanged; + + const InstancePageAppBar({ + super.key, + required this.instance, + required this.postSortType, + required this.account, + required this.onSortSelected, + required this.onQueryChanged, + this.bottom, + }); + + @override + State createState() => _InstancePageAppBarState(); +} + +class _InstancePageAppBarState extends State { + /// The timer for debouncing the search bar. + Timer? _debounceTimer; + + /// The controller for the search bar. + TextEditingController queryController = TextEditingController(); + + @override + void dispose() { + _debounceTimer?.cancel(); + queryController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + final account = context.read().state.account; + + final instanceHost = Uri.parse(widget.instance.domain).host; + + final blockedInstances = context.watch().state.siteResponse?.myUser?.instanceBlocks; + final blocked = blockedInstances?.any((i) => instanceHost.contains(i.instance['domain'])) ?? false; + + return SliverAppBar( + pinned: true, + toolbarHeight: APP_BAR_HEIGHT, + bottom: widget.bottom, + automaticallyImplyLeading: false, + title: SearchBar( + controller: queryController, + hintText: l10n.searchInstance(instanceHost), + elevation: WidgetStateProperty.all(0), + onChanged: (query) { + if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 500), () { + widget.onQueryChanged(query); + }); + }, + leading: IconButton( + icon: Icon(Icons.arrow_back, semanticLabel: l10n.back), + onPressed: () { + HapticFeedback.mediumImpact(); + Navigator.pop(context); + }, + ), + trailing: [ + IconButton( + icon: Icon(Icons.sort, semanticLabel: l10n.sortBy), + onPressed: () { + HapticFeedback.mediumImpact(); + + showModalBottomSheet( + showDragHandle: true, + context: context, + isScrollControlled: true, + builder: (builderContext) => SortPicker( + account: account, + title: l10n.sortOptions, + onSelect: (selected) async { + widget.onSortSelected(selected.payload); + }, + previouslySelected: widget.postSortType, + ), + ); + }, + ), + Semantics( + label: l10n.menu, + child: PopupMenuButton( + itemBuilder: (context) => [ + if (widget.instance.id != null && !widget.account.anonymous && !instanceHost.contains(account.instance)) + ThunderPopupMenuItem( + title: blocked ? l10n.unblockInstance : l10n.blockInstance, + icon: blocked ? Icons.undo_rounded : Icons.block, + onTap: () async { + final repository = InstanceRepositoryImpl(account: widget.account); + final success = await repository.block(widget.instance.id!, !blocked); + + // Update the profile bloc state. + context.read().add(FetchProfileSettings()); + + if (context.mounted) { + if (success) { + showSnackbar(l10n.successfullyBlockedCommunity(widget.instance.name)); + } else { + showSnackbar(l10n.successfullyUnblockedCommunity(widget.instance.name)); + } + } + }, + ), + if (widget.instance.platform == ThreadiversePlatform.lemmy) + ThunderPopupMenuItem( + title: l10n.modlog, + icon: Icons.shield_rounded, + onTap: () async { + HapticFeedback.mediumImpact(); + navigateToModlogPage(context, subtitle: widget.instance.name); + }, + ), + ThunderPopupMenuItem( + title: l10n.openInBrowser, + icon: Icons.open_in_browser_rounded, + onTap: () => handleLink(context, url: widget.instance.domain), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/instance/presentation/widgets/instance_tabs.dart b/lib/src/features/instance/presentation/widgets/instance_tabs.dart new file mode 100644 index 000000000..beb8b724a --- /dev/null +++ b/lib/src/features/instance/presentation/widgets/instance_tabs.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/app/thunder.dart'; +import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/instance/presentation/bloc/instance_page_bloc.dart'; +import 'package:thunder/src/features/instance/presentation/bloc/instance_page_event.dart'; +import 'package:thunder/src/shared/error_message.dart'; + +/// A scaffold for instance tabs. Handles loading, retry and loading more. +class _InstanceTabScaffold extends StatefulWidget { + /// The state of the instance tab. + final InstanceUserTabState state; + + /// Callback to load more items. + final VoidCallback onLoadMore; + + /// Callback to retry loading items. + final VoidCallback onRetry; + + /// The builder for the items. + final Widget Function(BuildContext context, T item) itemBuilder; + + /// The storage key for the tab. + final String storageKey; + + /// The loading widget to show when loading. + final Widget? loadingWidget; + + const _InstanceTabScaffold({ + required this.state, + required this.onLoadMore, + required this.onRetry, + required this.itemBuilder, + required this.storageKey, + this.loadingWidget, + }); + + @override + State<_InstanceTabScaffold> createState() => _InstanceTabScaffoldState(); +} + +class _InstanceTabScaffoldState extends State<_InstanceTabScaffold> with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + + final l10n = GlobalContext.l10n; + final state = widget.state; + + if (state.status == InstancePageStatus.loading && state.items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.status == InstancePageStatus.failure && state.items.isEmpty) { + return ErrorMessage( + message: state.message, + actions: [ + (text: l10n.refreshContent, action: widget.onRetry, loading: false), + ], + ); + } + + if (state.status == InstancePageStatus.done && state.items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.search_off_rounded, size: 48.0), + const SizedBox(height: 16.0), + Text(l10n.noResultsFound), + ], + ), + ); + } + + return NotificationListener( + onNotification: (ScrollNotification scrollInfo) { + if (state.status != InstancePageStatus.loading && state.status != InstancePageStatus.done && scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent * 0.8) { + // 0.8 as threshold + widget.onLoadMore(); + } + return false; + }, + child: CustomScrollView( + key: PageStorageKey(widget.storageKey), + slivers: [ + SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), + if (widget.loadingWidget != null) + widget.loadingWidget! + else + SliverList.builder( + itemCount: state.items.length + (state.status == InstancePageStatus.loading ? 1 : 0), + itemBuilder: (context, index) { + if (index == state.items.length) { + return const Center(child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator())); + } + return widget.itemBuilder(context, state.items[index]); + }, + ), + ], + ), + ); + } +} + +typedef InstanceUserTabState = InstanceTypeState; + +class InstanceCommunityTab extends StatelessWidget { + /// The account to use for the tab. + final Account account; + + /// The post sort type to use for the tab. + final PostSortType postSortType; + + /// Callback to retry loading items. + final VoidCallback onRetry; + + /// The query to use for the tab. + final String? query; + + const InstanceCommunityTab({super.key, required this.account, required this.postSortType, required this.onRetry, this.query}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => previous.communities != current.communities, + builder: (context, state) { + return _InstanceTabScaffold( + state: state.communities, + storageKey: 'communities', + onRetry: onRetry, + onLoadMore: () => context.read().add(GetInstanceCommunities(page: state.communities.page + 1, sortType: postSortType, query: query)), + itemBuilder: (context, item) => CommunityListEntry(community: item, resolutionAccount: account), + ); + }, + ); + } +} + +class InstanceUserTab extends StatelessWidget { + /// The account to use for the tab. + final Account account; + + /// The post sort type to use for the tab. + final PostSortType postSortType; + + /// Callback to retry loading items. + final VoidCallback onRetry; + + /// The query to use for the tab. + final String? query; + + const InstanceUserTab({super.key, required this.account, required this.postSortType, required this.onRetry, this.query}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => previous.users != current.users, + builder: (context, state) { + return _InstanceTabScaffold( + state: state.users, + storageKey: 'users', + onRetry: onRetry, + onLoadMore: () => context.read().add(GetInstanceUsers(page: state.users.page + 1, sortType: postSortType, query: query)), + itemBuilder: (context, item) => UserListEntry(user: item, resolutionAccount: account), + ); + }, + ); + } +} + +class InstancePostTab extends StatelessWidget { + /// The account to use for the tab. + final Account account; + + /// The post sort type to use for the tab. + final PostSortType postSortType; + + /// Callback to retry loading items. + final VoidCallback onRetry; + + /// The query to use for the tab. + final String? query; + + const InstancePostTab({super.key, required this.account, required this.postSortType, required this.onRetry, this.query}); + + @override + Widget build(BuildContext context) { + final tabletMode = context.read().state.tabletMode; + + return BlocBuilder( + buildWhen: (previous, current) => previous.posts != current.posts, + builder: (context, state) { + return _InstanceTabScaffold( + state: state.posts, + storageKey: 'posts', + onRetry: onRetry, + onLoadMore: () => context.read().add(GetInstancePosts(page: state.posts.page + 1, sortType: postSortType, query: query)), + loadingWidget: SliverMainAxisGroup( + slivers: [ + FeedPostCardList( + markPostReadOnScroll: false, + posts: state.posts.items, + tabletMode: tabletMode, + ), + if (state.posts.status == InstancePageStatus.loading) const SliverToBoxAdapter(child: Center(child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()))), + ], + ), + itemBuilder: (context, item) => const SizedBox.shrink(), // Not used when loadingWidget is provided + ); + }, + ); + } +} + +class InstanceCommentTab extends StatelessWidget { + /// The account to use for the tab. + final Account account; + + /// The post sort type to use for the tab. + final PostSortType postSortType; + + /// Callback to retry loading items. + final VoidCallback onRetry; + + /// The query to use for the tab. + final String? query; + + const InstanceCommentTab({super.key, required this.account, required this.postSortType, required this.onRetry, this.query}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => previous.comments != current.comments, + builder: (context, state) { + return _InstanceTabScaffold( + state: state.comments, + storageKey: 'comments', + onRetry: onRetry, + onLoadMore: () => context.read().add(GetInstanceComments(page: state.comments.page + 1, sortType: postSortType, query: query)), + itemBuilder: (context, item) => CommentListEntry(comment: item), + ); + }, + ); + } +} diff --git a/lib/src/features/instance/presentation/widgets/instance_view.dart b/lib/src/features/instance/presentation/widgets/instance_view.dart deleted file mode 100644 index ca6d9750b..000000000 --- a/lib/src/features/instance/presentation/widgets/instance_view.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:cached_network_image/cached_network_image.dart'; - -import 'package:thunder/src/core/models/models.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; - -class InstanceView extends StatelessWidget { - final ThunderSite site; - - const InstanceView({super.key, required this.site}); - - @override - Widget build(BuildContext context) { - final ThemeData theme = Theme.of(context); - - return Column( - children: [ - Row( - children: [ - SizedBox( - width: 48, - child: site.icon == null - ? CircleAvatar( - backgroundColor: theme.colorScheme.secondaryContainer, - maxRadius: 24, - child: Text( - site.name[0], - semanticsLabel: '', - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), - ) - : CachedNetworkImage( - imageUrl: site.icon!, - imageBuilder: (context, imageProvider) => CircleAvatar( - backgroundColor: Colors.transparent, - foregroundImage: imageProvider, - maxRadius: 24, - ), - ), - ), - const SizedBox(width: 16.0), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - site.name, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), - ), - Row( - children: [ - Flexible( - child: Text( - site.description ?? '', - style: theme.textTheme.bodyMedium, - ), - ), - ], - ), - ], - ), - ), - ], - ), - const Divider(), - CommonMarkdownBody( - body: site.sidebar ?? '', - ), - ], - ); - } -} diff --git a/lib/src/features/search/presentation/bloc/search_bloc.dart b/lib/src/features/search/presentation/bloc/search_bloc.dart index 2d4e35983..498c94333 100644 --- a/lib/src/features/search/presentation/bloc/search_bloc.dart +++ b/lib/src/features/search/presentation/bloc/search_bloc.dart @@ -111,7 +111,15 @@ class SearchBloc extends Bloc { final lastSuccessfulPublishedTime = DateTime.parse(instance['federation_state']['last_successful_published_time']); if (lastSuccessfulPublishedTime.isAfter(DateTime.now().subtract(const Duration(days: 1))) == true) { - instances.add(ThunderInstanceInfo(success: true, domain: instance['domain'], id: instance['id'], version: instance['version'])); + instances.add( + ThunderInstanceInfo( + id: instance['id'], + domain: instance['domain'], + name: fetchInstanceNameFromUrl(instance['domain'])!, + version: instance['version'], + success: true, + ), + ); } } } diff --git a/lib/src/features/search/presentation/pages/search_page.dart b/lib/src/features/search/presentation/pages/search_page.dart index 7e5216c3b..e86441bd0 100644 --- a/lib/src/features/search/presentation/pages/search_page.dart +++ b/lib/src/features/search/presentation/pages/search_page.dart @@ -747,8 +747,15 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi final instanceInfo = state.instances![index]; return AnimatedCrossFade( duration: const Duration(milliseconds: 250), - firstChild: InstanceListEntry(instanceInfo: ThunderInstanceInfo(success: instanceInfo.success, domain: instanceInfo.domain, id: instanceInfo.id)), - secondChild: InstanceListEntry(instanceInfo: instanceInfo), + firstChild: InstanceListEntry( + instance: ThunderInstanceInfo( + id: instanceInfo.id, + domain: instanceInfo.domain, + name: fetchInstanceNameFromUrl(instanceInfo.domain)!, + success: instanceInfo.success, + ), + ), + secondChild: InstanceListEntry(instance: instanceInfo), // If the instance metadata is not fully populated, show one widget, otherwise show the other. // This should allow the metadata to essentially "fade in". crossFadeState: instanceInfo.isMetadataPopulated() ? CrossFadeState.showSecond : CrossFadeState.showFirst, diff --git a/lib/src/features/user/presentation/bloc/user_settings_bloc.dart b/lib/src/features/user/presentation/bloc/user_settings_bloc.dart index d62ae3921..56d03dfe3 100644 --- a/lib/src/features/user/presentation/bloc/user_settings_bloc.dart +++ b/lib/src/features/user/presentation/bloc/user_settings_bloc.dart @@ -97,7 +97,7 @@ class UserSettingsBloc extends Bloc { if (account.anonymous) throw Exception(l10n.userNotLoggedIn); try { - final getSiteResponse = await instanceRepository.getSiteInfo(); + final getSiteResponse = await instanceRepository.info(); return emit( state.copyWith( @@ -182,7 +182,7 @@ class UserSettingsBloc extends Bloc { if (account.anonymous) throw Exception(l10n.userNotLoggedIn); try { - final getSiteResponse = await instanceRepository.getSiteInfo(); + final getSiteResponse = await instanceRepository.info(); final personBlocks = getSiteResponse.myUser!.personBlocks..sort((a, b) => a.name.compareTo(b.name)); final communityBlocks = getSiteResponse.myUser!.communityBlocks..sort((a, b) => a.name.compareTo(b.name)); diff --git a/lib/src/features/user/presentation/widgets/user_list_entry.dart b/lib/src/features/user/presentation/widgets/user_list_entry.dart index 0f947d7f9..5ae21421d 100644 --- a/lib/src/features/user/presentation/widgets/user_list_entry.dart +++ b/lib/src/features/user/presentation/widgets/user_list_entry.dart @@ -15,10 +15,10 @@ class UserListEntry extends StatelessWidget { /// The user to display. final ThunderUser user; - /// The instance to resolve the user on, if different from the current instance. - final String? resolutionInstance; + /// The account to use for resolving the user, if different from the current instance. + final Account? resolutionAccount; - const UserListEntry({super.key, required this.user, this.resolutionInstance}); + const UserListEntry({super.key, required this.user, this.resolutionAccount}); @override Widget build(BuildContext context) { @@ -51,11 +51,9 @@ class UserListEntry extends StatelessWidget { onTap: () async { int? userId = user.id; - if (resolutionInstance != null) { + if (resolutionAccount != null) { try { - // Create a temporary Account for the request - final account = Account(instance: resolutionInstance!, id: '', index: -1); - final response = await SearchRepositoryImpl(account: account).resolve(query: user.actorId); + final response = await SearchRepositoryImpl(account: resolutionAccount!).resolve(query: user.actorId); userId = response['user']?.id; } catch (e) { diff --git a/lib/src/shared/profile_site_info_cache.dart b/lib/src/shared/profile_site_info_cache.dart index 0da85627d..df6e36f4f 100644 --- a/lib/src/shared/profile_site_info_cache.dart +++ b/lib/src/shared/profile_site_info_cache.dart @@ -35,7 +35,7 @@ class ProfileSiteInfoCache { } final repository = InstanceRepositoryImpl(account: account); - final response = await repository.getSiteInfo(); + final response = await repository.info(); _cacheByAccountKey[key] = _CacheEntry(response: response, fetchedAt: now, isDirty: false); debugPrint('ProfileSiteInfoCache: Cached site info for $key'); diff --git a/lib/src/shared/utils/instance.dart b/lib/src/shared/utils/instance.dart index fac9d1bc1..24cd4fc60 100644 --- a/lib/src/shared/utils/instance.dart +++ b/lib/src/shared/utils/instance.dart @@ -182,7 +182,13 @@ Future getLemmyCommentId(BuildContext context, String text) async { /// This includes the instance name, version, icon, and user count. /// If the URL is invalid or the instance is unreachable, it returns a default [ThunderInstanceInfo] with success set to false. Future getInstanceInfo(String? url, {int? id, Duration? timeout}) async { - if (url?.isEmpty ?? true) return const ThunderInstanceInfo(success: false); + if (url?.isEmpty ?? true) { + return ThunderInstanceInfo( + domain: '', + name: '', + success: false, + ); + } try { final platformInfo = await detectPlatformFromNodeInfo(url!); @@ -191,12 +197,12 @@ Future getInstanceInfo(String? url, {int? id, Duration? tim // Create a temporary Account for the request final account = Account(instance: url, id: '', index: -1, platform: platform); - final site = await InstanceRepositoryImpl(account: account).getSiteInfo().timeout(timeout ?? const Duration(seconds: 5)); + final site = await InstanceRepositoryImpl(account: account).info().timeout(timeout ?? const Duration(seconds: 5)); final instance = site.site; return ThunderInstanceInfo( id: id, - domain: fetchInstanceNameFromUrl(instance.actorId), + domain: fetchInstanceNameFromUrl(instance.actorId)!, version: site.version, name: instance.name, icon: instance.icon, @@ -207,8 +213,13 @@ Future getInstanceInfo(String? url, {int? id, Duration? tim ); } catch (e) { debugPrint('Error getting instance info: $e'); + // Bad instances will throw an exception, so no icon - return const ThunderInstanceInfo(success: false); + return ThunderInstanceInfo( + domain: '', + name: '', + success: false, + ); } }