From e6bd66cbdf6bdabc9b7d78083d653adeee628417 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Mon, 18 May 2026 10:32:51 -0500 Subject: [PATCH] feat: known followers tab in connections screen * link to known followers --- lib/core/l10n/app_localizations.dart | 12 +++ lib/core/l10n/app_localizations_en.dart | 9 ++ lib/core/l10n/intl_en.arb | 14 +++ lib/core/network/app_bsky_routing_policy.dart | 1 + lib/core/network/poptart_client_adapter.dart | 1 + .../services/bluesky_graph_service.dart | 15 +++ .../cubit/profile_connections_cubit.dart | 20 +++- .../profile/data/profile_repository.dart | 18 +++- .../profile_connections_screen.dart | 5 + .../profile/presentation/profile_screen.dart | 11 ++- .../network/app_bsky_routing_policy_test.dart | 1 + .../cubit/profile_connections_cubit_test.dart | 27 +++++ .../profile/data/profile_repository_test.dart | 75 +++++++++++++- .../profile_connections_screen_test.dart | 18 +++- .../presentation/profile_screen_test.dart | 98 ++++++++++++++++++- 15 files changed, 309 insertions(+), 16 deletions(-) diff --git a/lib/core/l10n/app_localizations.dart b/lib/core/l10n/app_localizations.dart index ecbebee..c52248b 100644 --- a/lib/core/l10n/app_localizations.dart +++ b/lib/core/l10n/app_localizations.dart @@ -2623,6 +2623,12 @@ abstract class AppLocalizations { /// **'Search stopped after {count} accounts'** String formatConnectionsSearchStopped(int count); + /// Connections link text for a profile viewer's known followers count + /// + /// In en, this message translates to: + /// **'You know {count, plural, =1{1 follower} other{{count} followers}}'** + String formatKnownFollowersLink(int count); + /// Follow audit classifying progress label /// /// In en, this message translates to: @@ -2881,6 +2887,12 @@ abstract class AppLocalizations { /// **'Followers'** String get labelFollowers; + /// Short connections tab label for followers that are also followed by the viewer + /// + /// In en, this message translates to: + /// **'Known'** + String get labelKnownFollowers; + /// Starter pack statistic label for joins this week /// /// In en, this message translates to: diff --git a/lib/core/l10n/app_localizations_en.dart b/lib/core/l10n/app_localizations_en.dart index 6e2dba6..2037ab3 100644 --- a/lib/core/l10n/app_localizations_en.dart +++ b/lib/core/l10n/app_localizations_en.dart @@ -1399,6 +1399,12 @@ class AppLocalizationsEn extends AppLocalizations { return 'Search stopped after $count accounts'; } + @override + String formatKnownFollowersLink(int count) { + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count followers', one: '1 follower'); + return 'You know $_temp0'; + } + @override String formatClassifyingProgress(int progress, int total) { return 'Classifying: $progress/$total'; @@ -1583,6 +1589,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get labelFollowers => 'Followers'; + @override + String get labelKnownFollowers => 'Known'; + @override String get labelJoinedThisWeek => 'joined this week'; diff --git a/lib/core/l10n/intl_en.arb b/lib/core/l10n/intl_en.arb index 9684bed..63543c8 100644 --- a/lib/core/l10n/intl_en.arb +++ b/lib/core/l10n/intl_en.arb @@ -1941,6 +1941,16 @@ } } }, + "formatKnownFollowersLink": "You know {count, plural, =1{1 follower} other{{count} followers}}", + "@formatKnownFollowersLink": { + "description": "Connections link text for a profile viewer's known followers count", + "placeholders": { + "count": { + "type": "int", + "example": "3" + } + } + }, "formatClassifyingProgress": "Classifying: {progress}/{total}", "@formatClassifyingProgress": { "description": "Follow audit classifying progress label", @@ -2253,6 +2263,10 @@ "@labelFollowers": { "description": "Followers label" }, + "labelKnownFollowers": "Known", + "@labelKnownFollowers": { + "description": "Short connections tab label for followers that are also followed by the viewer" + }, "labelJoinedThisWeek": "joined this week", "@labelJoinedThisWeek": { "description": "Starter pack statistic label for joins this week" diff --git a/lib/core/network/app_bsky_routing_policy.dart b/lib/core/network/app_bsky_routing_policy.dart index 2fabc75..43b0ba5 100644 --- a/lib/core/network/app_bsky_routing_policy.dart +++ b/lib/core/network/app_bsky_routing_policy.dart @@ -65,6 +65,7 @@ abstract final class AppBskyRoutingPolicy { 'app.bsky.graph.getSuggestedFollowsByActor': AppBskyProxyMode.bypassProxy, 'app.bsky.graph.getFollows': AppBskyProxyMode.bypassProxy, 'app.bsky.graph.getFollowers': AppBskyProxyMode.bypassProxy, + 'app.bsky.graph.getKnownFollowers': AppBskyProxyMode.bypassProxy, 'app.bsky.graph.getLists': AppBskyProxyMode.bypassProxy, 'app.bsky.graph.getList': AppBskyProxyMode.bypassProxy, 'app.bsky.graph.getListsWithMembership': AppBskyProxyMode.bypassProxy, diff --git a/lib/core/network/poptart_client_adapter.dart b/lib/core/network/poptart_client_adapter.dart index 2122c93..98af99d 100644 --- a/lib/core/network/poptart_client_adapter.dart +++ b/lib/core/network/poptart_client_adapter.dart @@ -36,6 +36,7 @@ import 'package:bluesky_poptart/app/bsky/graph/follow.dart'; import 'package:bluesky_poptart/app/bsky/graph/get_actor_starter_packs.dart'; import 'package:bluesky_poptart/app/bsky/graph/get_followers.dart'; import 'package:bluesky_poptart/app/bsky/graph/get_follows.dart'; +import 'package:bluesky_poptart/app/bsky/graph/get_known_followers.dart'; import 'package:bluesky_poptart/app/bsky/graph/get_list.dart'; import 'package:bluesky_poptart/app/bsky/graph/get_lists.dart'; import 'package:bluesky_poptart/app/bsky/graph/get_lists_with_membership.dart'; diff --git a/lib/core/network/services/bluesky_graph_service.dart b/lib/core/network/services/bluesky_graph_service.dart index 52fb51c..1dbb782 100644 --- a/lib/core/network/services/bluesky_graph_service.dart +++ b/lib/core/network/services/bluesky_graph_service.dart @@ -42,6 +42,21 @@ class BlueskyGraphService { ); } + Future> getKnownFollowers({ + required String actor, + int limit = 50, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyGraphGetKnownFollowers, + headers: $headers, + service: $service, + parameters: GraphGetKnownFollowersInput(actor: actor, limit: limit, cursor: cursor), + ); + } + Future> getLists({ required String actor, int limit = 50, diff --git a/lib/features/profile/cubit/profile_connections_cubit.dart b/lib/features/profile/cubit/profile_connections_cubit.dart index 14968f7..596afc2 100644 --- a/lib/features/profile/cubit/profile_connections_cubit.dart +++ b/lib/features/profile/cubit/profile_connections_cubit.dart @@ -7,24 +7,27 @@ import 'package:fuzzywuzzy/fuzzywuzzy.dart' as fuzzywuzzy; import 'package:lazurite/core/network/constellation_client.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; -enum ProfileConnectionsTab { following, followers, mutuals } +enum ProfileConnectionsTab { following, followers, knownFollowers, mutuals } extension ProfileConnectionsTabX on ProfileConnectionsTab { String get routeValue => switch (this) { ProfileConnectionsTab.following => 'following', ProfileConnectionsTab.followers => 'followers', + ProfileConnectionsTab.knownFollowers => 'known-followers', ProfileConnectionsTab.mutuals => 'mutuals', }; String get title => switch (this) { ProfileConnectionsTab.following => 'Following', ProfileConnectionsTab.followers => 'Followers', + ProfileConnectionsTab.knownFollowers => 'Known', ProfileConnectionsTab.mutuals => 'Mutuals', }; static ProfileConnectionsTab fromRouteValue(String? value) { return switch (value) { 'followers' => ProfileConnectionsTab.followers, + 'known-followers' => ProfileConnectionsTab.knownFollowers, 'mutuals' => ProfileConnectionsTab.mutuals, _ => ProfileConnectionsTab.following, }; @@ -53,6 +56,7 @@ class ProfileConnectionsCubit extends Cubit { final Map _searchGenerations = { ProfileConnectionsTab.following: 0, ProfileConnectionsTab.followers: 0, + ProfileConnectionsTab.knownFollowers: 0, ProfileConnectionsTab.mutuals: 0, }; Timer? _searchDebounce; @@ -162,6 +166,7 @@ class ProfileConnectionsCubit extends Cubit { searchQuery: normalizedQuery, following: state.following.clearSearch(), followers: state.followers.clearSearch(), + knownFollowers: state.knownFollowers.clearSearch(), mutuals: state.mutuals.clearSearch(), ), ); @@ -197,6 +202,11 @@ class ProfileConnectionsCubit extends Cubit { return switch (tab) { ProfileConnectionsTab.following => _repository.getFollowing(actor: _actor, cursor: cursor, limit: _pageLimit), ProfileConnectionsTab.followers => _repository.getFollowers(actor: _actor, cursor: cursor, limit: _pageLimit), + ProfileConnectionsTab.knownFollowers => _repository.getKnownFollowers( + actor: _actor, + cursor: cursor, + limit: _pageLimit, + ), ProfileConnectionsTab.mutuals => _getMutuals(cursor: cursor), }; } @@ -420,18 +430,21 @@ class ProfileConnectionsState extends Equatable { const ProfileConnectionsState({ this.following = const ProfileConnectionsTabData(), this.followers = const ProfileConnectionsTabData(), + this.knownFollowers = const ProfileConnectionsTabData(), this.mutuals = const ProfileConnectionsTabData(), this.searchQuery = '', }); final ProfileConnectionsTabData following; final ProfileConnectionsTabData followers; + final ProfileConnectionsTabData knownFollowers; final ProfileConnectionsTabData mutuals; final String searchQuery; ProfileConnectionsTabData dataFor(ProfileConnectionsTab tab) => switch (tab) { ProfileConnectionsTab.following => following, ProfileConnectionsTab.followers => followers, + ProfileConnectionsTab.knownFollowers => knownFollowers, ProfileConnectionsTab.mutuals => mutuals, }; @@ -450,12 +463,14 @@ class ProfileConnectionsState extends Equatable { ProfileConnectionsState copyWith({ ProfileConnectionsTabData? following, ProfileConnectionsTabData? followers, + ProfileConnectionsTabData? knownFollowers, ProfileConnectionsTabData? mutuals, String? searchQuery, }) { return ProfileConnectionsState( following: following ?? this.following, followers: followers ?? this.followers, + knownFollowers: knownFollowers ?? this.knownFollowers, mutuals: mutuals ?? this.mutuals, searchQuery: searchQuery ?? this.searchQuery, ); @@ -465,6 +480,7 @@ class ProfileConnectionsState extends Equatable { return switch (tab) { ProfileConnectionsTab.following => copyWith(following: data), ProfileConnectionsTab.followers => copyWith(followers: data), + ProfileConnectionsTab.knownFollowers => copyWith(knownFollowers: data), ProfileConnectionsTab.mutuals => copyWith(mutuals: data), }; } @@ -478,7 +494,7 @@ class ProfileConnectionsState extends Equatable { } @override - List get props => [following, followers, mutuals, searchQuery]; + List get props => [following, followers, knownFollowers, mutuals, searchQuery]; } class ProfileConnectionsTabData extends Equatable { diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/profile_repository.dart index 6c10ab7..84e4eda 100644 --- a/lib/features/profile/data/profile_repository.dart +++ b/lib/features/profile/data/profile_repository.dart @@ -2,24 +2,24 @@ import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; -import 'package:poptart_core/poptart_core.dart' as atp_core; import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; import 'package:bluesky_poptart/app/bsky/actor/profile.dart'; import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; import 'package:bluesky_poptart/app/bsky/feed/like.dart'; -import 'package:poptart_lex/com/atproto/repo/list_records.dart'; import 'package:characters/characters.dart'; import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; -import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/actor_repository_service_resolver.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; +import 'package:poptart_core/poptart_core.dart' as atp_core; +import 'package:poptart_lex/com/atproto/repo/list_records.dart'; class ProfileRepository { ProfileRepository({ @@ -175,6 +175,18 @@ class ProfileRepository { return ProfileConnectionsPage(subject: response.data.subject, profiles: profiles, cursor: response.data.cursor); } + Future getKnownFollowers({required String actor, String? cursor, int limit = 50}) async { + final headers = _appViewContext.appBskyHeadersForEndpoint( + 'app.bsky.graph.getKnownFollowers', + await _moderationService?.headersForRequest(), + ); + final response = await _authRecovery.run( + (client) => client.graph.getKnownFollowers(actor: actor, cursor: cursor, limit: limit, $headers: headers), + ); + final profiles = _filterProfileList(response.data.followers); + return ProfileConnectionsPage(subject: response.data.subject, profiles: profiles, cursor: response.data.cursor); + } + /// Likes transport matrix: /// - Self liked tab: app.bsky.feed.getActorLikes via viewer-auth context /// (PDS-routed, read-after-write behavior for the current account). diff --git a/lib/features/profile/presentation/profile_connections_screen.dart b/lib/features/profile/presentation/profile_connections_screen.dart index aa36008..5deb7c3 100644 --- a/lib/features/profile/presentation/profile_connections_screen.dart +++ b/lib/features/profile/presentation/profile_connections_screen.dart @@ -71,6 +71,8 @@ class _ProfileConnectionsScreenState extends State wit title: Text(subtitle == null || subtitle.isEmpty ? context.l10n.labelConnections : '@$subtitle'), bottom: TabBar( controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, onTap: (index) { final tab = ProfileConnectionsTab.values[index]; final cubit = context.read(); @@ -80,6 +82,7 @@ class _ProfileConnectionsScreenState extends State wit tabs: [ Tab(text: context.l10n.labelFollowing), Tab(text: context.l10n.labelFollowers), + Tab(text: context.l10n.labelKnownFollowers), Tab(text: context.l10n.labelMutuals), ], ), @@ -93,6 +96,7 @@ class _ProfileConnectionsScreenState extends State wit children: const [ _ConnectionsTabView(tab: ProfileConnectionsTab.following), _ConnectionsTabView(tab: ProfileConnectionsTab.followers), + _ConnectionsTabView(tab: ProfileConnectionsTab.knownFollowers), _ConnectionsTabView(tab: ProfileConnectionsTab.mutuals), ], ), @@ -504,6 +508,7 @@ String _localizedTabTitle(BuildContext context, ProfileConnectionsTab tab, {bool final title = switch (tab) { ProfileConnectionsTab.following => context.l10n.labelFollowing, ProfileConnectionsTab.followers => context.l10n.labelFollowers, + ProfileConnectionsTab.knownFollowers => context.l10n.labelKnownFollowers, ProfileConnectionsTab.mutuals => context.l10n.labelMutuals, }; return lowercase ? title.toLowerCase() : title; diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 03a2170..442a8f2 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -1,7 +1,6 @@ import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; import 'package:bluesky_poptart/app/bsky/graph/defs.dart' as bsky_graph; -import 'package:lazurite/features/moderation/domain/moderation_models.dart' as bsky_moderation; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -28,6 +27,7 @@ import 'package:lazurite/features/lists/cubit/add_to_list_cubit.dart'; import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart'; import 'package:lazurite/features/lists/data/list_repository.dart'; import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart'; +import 'package:lazurite/features/moderation/domain/moderation_models.dart' as bsky_moderation; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; @@ -843,6 +843,15 @@ class _ProfileScreenState extends State with TickerProviderStateM const SizedBox(height: 16), Wrap(spacing: 8, runSpacing: 8, children: metaChildren), ], + if (!isOwnProfile && (profile.viewer?.knownFollowers?.count ?? 0) > 0) ...[ + const SizedBox(height: 12), + TextButton.icon( + key: const ValueKey('profile_known_followers_link'), + onPressed: () => _openConnections(context, profile, ProfileConnectionsTab.knownFollowers), + icon: const Icon(Icons.group_outlined, size: 18), + label: Text(context.l10n.formatKnownFollowersLink(profile.viewer!.knownFollowers!.count)), + ), + ], const SizedBox(height: 16), Container( key: const ValueKey('profile_stats_row'), diff --git a/test/core/network/app_bsky_routing_policy_test.dart b/test/core/network/app_bsky_routing_policy_test.dart index e493a80..9cb80e1 100644 --- a/test/core/network/app_bsky_routing_policy_test.dart +++ b/test/core/network/app_bsky_routing_policy_test.dart @@ -9,6 +9,7 @@ void main() { 'app.bsky.actor.getProfiles', 'app.bsky.actor.searchActorsTypeahead', 'app.bsky.graph.getFollowers', + 'app.bsky.graph.getKnownFollowers', 'app.bsky.graph.getFollows', 'app.bsky.graph.getList', 'app.bsky.graph.getLists', diff --git a/test/features/profile/cubit/profile_connections_cubit_test.dart b/test/features/profile/cubit/profile_connections_cubit_test.dart index 274a514..fa328fb 100644 --- a/test/features/profile/cubit/profile_connections_cubit_test.dart +++ b/test/features/profile/cubit/profile_connections_cubit_test.dart @@ -81,6 +81,28 @@ void main() { ], ); + blocTest( + 'loads known followers through the repository', + build: () { + when( + () => repository.getKnownFollowers(actor: 'did:plc:alice', cursor: null, limit: 100), + ).thenAnswer((_) async => const ProfileConnectionsPage(subject: subject, profiles: [gardener], cursor: 'next')); + return ProfileConnectionsCubit(repository: repository, actor: 'did:plc:alice'); + }, + act: (cubit) => cubit.loadTab(ProfileConnectionsTab.knownFollowers), + expect: () => [ + isA().having( + (state) => state.knownFollowers.status, + 'knownFollowers.status', + ProfileConnectionsStatus.loading, + ), + isA() + .having((state) => state.knownFollowers.status, 'knownFollowers.status', ProfileConnectionsStatus.loaded) + .having((state) => state.knownFollowers.profiles, 'knownFollowers.profiles', [gardener]) + .having((state) => state.knownFollowers.cursor, 'knownFollowers.cursor', 'next'), + ], + ); + blocTest( 'stores load-more failures separately while keeping loaded profiles', build: () { @@ -125,6 +147,11 @@ void main() { expect(state.visibleProfilesFor(ProfileConnectionsTab.following), [astronaut]); }); + test('route value parses known followers tab', () { + expect(ProfileConnectionsTabX.fromRouteValue('known-followers'), ProfileConnectionsTab.knownFollowers); + expect(ProfileConnectionsTab.knownFollowers.routeValue, 'known-followers'); + }); + blocTest( 'progressively searches every API page using limit 100', build: () { diff --git a/test/features/profile/data/profile_repository_test.dart b/test/features/profile/data/profile_repository_test.dart index 2d8f52a..2ce51e4 100644 --- a/test/features/profile/data/profile_repository_test.dart +++ b/test/features/profile/data/profile_repository_test.dart @@ -1,15 +1,12 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:poptart_core/poptart_core.dart' as atp_core; import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; import 'package:bluesky_poptart/app/bsky/actor/get_profiles.dart'; import 'package:bluesky_poptart/app/bsky/graph/get_followers.dart'; import 'package:bluesky_poptart/app/bsky/graph/get_follows.dart'; +import 'package:bluesky_poptart/app/bsky/graph/get_known_followers.dart'; import 'package:bluesky_poptart/app/bsky/graph/get_suggested_follows_by_actor.dart'; -import 'package:poptart_lex/com/atproto/repo/get_record.dart'; -import 'package:poptart_lex/com/atproto/repo/put_record.dart'; -import 'package:poptart_lex/com/atproto/repo/upload_blob.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -17,6 +14,10 @@ import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/network/poptart_client_adapter.dart' show Bluesky; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; +import 'package:poptart_core/poptart_core.dart' as atp_core; +import 'package:poptart_lex/com/atproto/repo/get_record.dart'; +import 'package:poptart_lex/com/atproto/repo/put_record.dart'; +import 'package:poptart_lex/com/atproto/repo/upload_blob.dart'; import '../../../helpers/test_bluesky_client.dart'; @@ -118,6 +119,28 @@ void main() { expect(result.profiles, followers); expect(result.cursor, isNull); }); + + test('returns known followers page from graph service', () async { + const subject = ProfileView(did: 'did:plc:alice', handle: 'alice.bsky.social'); + const followers = [ProfileView(did: 'did:plc:erin', handle: 'erin.bsky.social')]; + final repository = ProfileRepository( + database: database, + bluesky: _testBlueskyClient( + actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), + graph: _FakeGraphService( + knownFollowers: followers, + knownFollowersSubject: subject, + knownFollowersCursor: 'more', + ), + ), + ); + + final result = await repository.getKnownFollowers(actor: subject.did, cursor: 'cursor'); + + expect(result.subject, subject); + expect(result.profiles, followers); + expect(result.cursor, 'more'); + }); }); test('loads and caches a profile after a successful xrpc response', () async { @@ -431,6 +454,22 @@ class _FakeProfileTransport { cursor: response.data.cursor, ).toJson(), ); + case 'app.bsky.graph.getKnownFollowers': + final response = await graph.getKnownFollowers( + actor: query['actor']!, + cursor: query['cursor'], + limit: int.tryParse(query['limit'] ?? ''), + $headers: headers, + ); + return jsonResponse( + url, + 'GET', + GraphGetKnownFollowersOutput( + subject: response.data.subject, + followers: response.data.followers, + cursor: response.data.cursor, + ).toJson(), + ); case 'com.atproto.repo.getRecord': final response = await atproto.repo.getRecord( repo: query['repo']!, @@ -640,6 +679,9 @@ class _FakeGraphService { List? followers, ProfileView? followersSubject, String? followersCursor, + List? knownFollowers, + ProfileView? knownFollowersSubject, + String? knownFollowersCursor, }) : _suggestions = suggestions ?? [], _onGetSuggested = onGetSuggested, _follows = follows ?? [], @@ -647,7 +689,10 @@ class _FakeGraphService { _followsCursor = followsCursor, _followers = followers ?? [], _followersSubject = followersSubject, - _followersCursor = followersCursor; + _followersCursor = followersCursor, + _knownFollowers = knownFollowers ?? [], + _knownFollowersSubject = knownFollowersSubject, + _knownFollowersCursor = knownFollowersCursor; final List _suggestions; final Future<_FakeSuggestedResponse> Function(String actor)? _onGetSuggested; @@ -657,6 +702,9 @@ class _FakeGraphService { final List _followers; final ProfileView? _followersSubject; final String? _followersCursor; + final List _knownFollowers; + final ProfileView? _knownFollowersSubject; + final String? _knownFollowersCursor; Future<_FakeSuggestedResponse> getSuggestedFollowsByActor({required String actor, Map? $headers}) { final handler = _onGetSuggested; @@ -689,6 +737,23 @@ class _FakeGraphService { ), ); } + + Future<_FakeFollowersResponse> getKnownFollowers({ + required String actor, + String? cursor, + int? limit, + Map? $headers, + }) { + return Future.value( + _FakeFollowersResponse( + _FakeFollowersData( + _knownFollowersSubject ?? ProfileView(did: actor, handle: actor), + _knownFollowers, + _knownFollowersCursor, + ), + ), + ); + } } class _FakeSuggestedResponse { diff --git a/test/features/profile/presentation/profile_connections_screen_test.dart b/test/features/profile/presentation/profile_connections_screen_test.dart index 20e89ec..ed43c23 100644 --- a/test/features/profile/presentation/profile_connections_screen_test.dart +++ b/test/features/profile/presentation/profile_connections_screen_test.dart @@ -1,4 +1,3 @@ -import 'package:poptart_core/poptart_core.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; import 'package:flutter/material.dart'; @@ -10,6 +9,7 @@ import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; import 'package:lazurite/features/profile/presentation/profile_connections_screen.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:poptart_core/poptart_core.dart'; class MockProfileRepository extends Mock implements ProfileRepository {} @@ -83,7 +83,11 @@ void main() { await tester.pumpAndSettle(); expect(find.text('@alice.bsky.social'), findsOneWidget); + expect(find.text('Known'), findsOneWidget); expect(find.text('Mutuals'), findsOneWidget); + final tabBar = tester.widget(find.byType(TabBar)); + expect(tabBar.isScrollable, isTrue); + expect(tabBar.tabAlignment, TabAlignment.start); expect(find.text('Lina Orbit'), findsWidgets); expect(find.text('Space systems engineer'), findsWidgets); expect(find.textContaining('Joined'), findsWidgets); @@ -161,4 +165,16 @@ void main() { verify(() => profileRepository.getFollowers(actor: subject.did, cursor: null, limit: 100)).called(1); expect(find.text('Moss Vale'), findsOneWidget); }); + + testWidgets('loads the requested initial known followers tab', (tester) async { + when( + () => profileRepository.getKnownFollowers(actor: subject.did, cursor: null, limit: 100), + ).thenAnswer((_) async => const ProfileConnectionsPage(subject: subject, profiles: [gardener])); + + await tester.pumpWidget(buildSubject(initialTab: ProfileConnectionsTab.knownFollowers)); + await tester.pumpAndSettle(); + + verify(() => profileRepository.getKnownFollowers(actor: subject.did, cursor: null, limit: 100)).called(1); + expect(find.text('Moss Vale'), findsOneWidget); + }); } diff --git a/test/features/profile/presentation/profile_screen_test.dart b/test/features/profile/presentation/profile_screen_test.dart index b65af5d..d5caba0 100644 --- a/test/features/profile/presentation/profile_screen_test.dart +++ b/test/features/profile/presentation/profile_screen_test.dart @@ -1,9 +1,8 @@ import 'dart:async'; -import 'package:poptart_core/poptart_core.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; -import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:bluesky_poptart/app/bsky/feed/defs.dart' hide ViewerState; import 'package:bluesky_poptart/app/bsky/feed/post.dart' hide ReplyRef; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -28,6 +27,7 @@ import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:poptart_core/poptart_core.dart'; class MockAuthBloc extends MockBloc implements AuthBloc {} @@ -219,7 +219,8 @@ void main() { ); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); await tester.tap(find.byKey(const Key('profile_edit_header_button'))); await tester.pumpAndSettle(); @@ -255,7 +256,8 @@ void main() { ); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); await tester.tap(find.byKey(const ValueKey('profile_following_stat'))); await tester.pumpAndSettle(); @@ -265,6 +267,94 @@ void main() { router.dispose(); }); + testWidgets('known followers link opens known followers connections tab', (tester) async { + useLargeScreen(tester); + const otherProfile = ProfileViewDetailed( + did: 'did:plc:other', + handle: 'other.bsky.social', + displayName: 'Other User', + viewer: ViewerState(knownFollowers: KnownFollowers(count: 2, followers: [])), + ); + when(() => profileBloc.state).thenReturn(const ProfileState.loaded(profile: otherProfile)); + whenListen( + profileBloc, + const Stream.empty(), + initialState: const ProfileState.loaded(profile: otherProfile), + ); + when(() => feedBloc.state).thenReturn( + const FeedState.loaded(actor: 'did:plc:other', posts: [], filter: FeedFilter.postsNoReplies, hasMore: false), + ); + whenListen( + feedBloc, + const Stream.empty(), + initialState: const FeedState.loaded( + actor: 'did:plc:other', + posts: [], + filter: FeedFilter.postsNoReplies, + hasMore: false, + ), + ); + final mockProfileActionRepository = MockProfileActionRepository(); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => RepositoryProvider.value( + value: mockProfileActionRepository, + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: profileBloc), + BlocProvider.value(value: feedBloc), + BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: connectivityCubit), + ], + child: const ProfileScreen(actor: 'did:plc:other', showBackButton: true), + ), + ), + ), + GoRoute( + path: '/profile/:actor/connections', + builder: (context, state) => + Scaffold(body: Text([state.uri.queryParameters['tab'], state.pathParameters['actor']].join(' '))), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('You know 2 followers'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('profile_known_followers_link'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('known-followers other.bsky.social'), findsOneWidget); + + router.dispose(); + }); + + testWidgets('known followers link is hidden on own profile', (tester) async { + useLargeScreen(tester); + final ownProfileWithKnownFollowers = profile.copyWith( + viewer: const ViewerState(knownFollowers: KnownFollowers(count: 2, followers: [])), + ); + when(() => profileBloc.state).thenReturn(ProfileState.loaded(profile: ownProfileWithKnownFollowers)); + whenListen( + profileBloc, + const Stream.empty(), + initialState: ProfileState.loaded(profile: ownProfileWithKnownFollowers), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('You know 2 followers'), findsNothing); + expect(find.byKey(const ValueKey('profile_known_followers_link')), findsNothing); + }); + testWidgets('does not show Bookmarks/Liked buttons on other profiles', (tester) async { useLargeScreen(tester); const otherProfile = ProfileViewDetailed( -- 2.51.2