diff --git a/docs/smoke-test.md b/docs/smoke-test.md new file mode 100644 index 0000000..65259b9 --- /dev/null +++ b/docs/smoke-test.md @@ -0,0 +1,412 @@ +--- +title: Smoke Test Checklist +updated: 2026-04-01 +--- + +A manual walkthrough to verify core functionality after a build. Each section covers a +feature area with steps and expected outcomes. +Test on both iOS and Android unless noted otherwise. + +--- + +## 1. Authentication + +### OAuth Login + +- [ ] Launch app → Login screen appears +- [ ] Enter a valid handle and tap "Sign in with BlueSky" +- [ ] System browser opens → complete OAuth flow +- [ ] Redirected back to app → Home feed loads + +### App Password Login (Debug only) + +- [ ] Toggle to app-password mode +- [ ] Enter handle + app password (xxxx-xxxx-xxxx-xxxx) +- [ ] Tap sign in → Home feed loads + +### Session Restore + +- [ ] Kill and relaunch app → session restores without login prompt +- [ ] Token refresh works silently (wait for expiry or force) + +### Logout + +- [ ] Log out from settings → returns to login screen +- [ ] Relaunching app shows login screen (session cleared) + +--- + +## 2. Home Feed + +### Feed Loading + +- [ ] Following feed loads with posts on launch +- [ ] Pull-to-refresh fetches new posts +- [ ] Scroll down → infinite pagination loads more posts + +### Post Cards + +- [ ] Posts display: avatar, name, handle, relative timestamp +- [ ] Rich text renders correctly (mentions, links, hashtags highlighted) +- [ ] Image embeds display (single + multi-image grids) +- [ ] Video embeds display with thumbnail +- [ ] Link card embeds render (title, description, thumbnail) +- [ ] Quote posts render inline +- [ ] Engagement counts shown (likes, reposts, replies) + +### Post Actions + +- [ ] Tap like → icon fills, count increments (optimistic) +- [ ] Tap like again → unlike, count decrements +- [ ] Tap repost → menu: "Repost" and "Quote Post" +- [ ] Repost → count increments +- [ ] Tap reply → compose modal opens with reply context +- [ ] Tap share → system share sheet +- [ ] Overflow → Save post (bookmark icon fills) +- [ ] Overflow → Copy link + +### Navigation from Feed + +- [ ] Tap post body → thread view opens +- [ ] Tap avatar or name → profile screen opens + +--- + +## 3. Feed Management + +- [ ] Open feed management from home screen +- [ ] Pinned feeds listed (Following + custom generators) +- [ ] Drag to reorder feeds → order persists +- [ ] Toggle pin off → feed removed from home tabs +- [ ] Add a suggested feed → appears in home tabs + +--- + +## 4. Post Composition + +### Basic Compose + +- [ ] Tap compose FAB → compose screen opens +- [ ] Type text → character counter updates (max 300 graphemes) +- [ ] Mentions auto-highlight as typed +- [ ] Links and hashtags auto-highlight +- [ ] Submit → post appears in feed + +### Media + +- [ ] Attach 1–4 images → thumbnails shown +- [ ] Add alt text to an image +- [ ] Remove an image attachment +- [ ] Attach a video → upload progress shown +- [ ] Images and video are mutually exclusive (UI enforces) + +### Reply & Quote + +- [ ] Compose as reply → parent post context shown above input +- [ ] Compose as quote → quoted post shown below input + +### Drafts + +- [ ] Save draft → confirmation shown +- [ ] Open drafts → saved draft listed +- [ ] Load draft → text and media restored +- [ ] Submit loaded draft → posts successfully + +### Scheduled Posts + +- [ ] Schedule a post for a future time → confirmation shown +- [ ] Post publishes at scheduled time (background task) + +## 5. Search + +### Post Search + +- [ ] Type query → results load +- [ ] Sort toggle: "Top" vs "Latest" works +- [ ] Tap result → thread view opens + +### Actor Search + +- [ ] Switch to Actors tab → type query +- [ ] Autocomplete suggestions appear as you type +- [ ] Tap result → profile screen opens + +### Starter Pack Search + +- [ ] Switch to Starter Packs tab → type query +- [ ] Results show pack cards +- [ ] Tap result → starter pack detail opens + +### Search History + +- [ ] Recent searches appear below search bar +- [ ] Tap history item → re-executes search +- [ ] Swipe to delete a single entry +- [ ] "Clear all" removes all history + +## 6. Notifications + +### Notification List + +- [ ] Alerts tab shows notifications (likes, reposts, follows, mentions, replies, quotes) +- [ ] Grouped by day +- [ ] Unread count badge on nav tab +- [ ] Tap notification → navigates to post or profile +- [ ] "Mark all as read" clears unread badge + +### Unread Polling + +- [ ] Leave app open → badge count updates (30s poll interval) + +## 7. Direct Messages + +### Conversation List + +- [ ] Messages sub-tab shows conversations sorted by recency +- [ ] Unread count per conversation displayed +- [ ] Requests sub-tab shows unanswered conversations + +### Message Thread + +- [ ] Tap conversation → message thread opens +- [ ] Messages paginate (scroll up for older) +- [ ] Own messages right-aligned, others left-aligned +- [ ] Type and send a message → appears immediately +- [ ] Tap message → copy option + +### Conversation Actions + +- [ ] Mute conversation from overflow → muted indicator shown +- [ ] Unmute → indicator removed + +## 8. Profile + +### Own Profile + +- [ ] Profile tab shows: banner, avatar, display name, handle, bio +- [ ] Follower/following/post counts displayed +- [ ] Posts tab → author's posts (no replies) +- [ ] Replies tab → posts and threads +- [ ] Media tab → only posts with media +- [ ] Lists tab → lists created by user (Curation | Moderation sub-tabs) +- [ ] Packs tab → starter packs created by user + +### Other User's Profile + +- [ ] Tap user anywhere → their profile loads +- [ ] Follow button shown → tap to follow → button changes +- [ ] Unfollow → button reverts +- [ ] Overflow: Mute, Block, Report, Copy DID, Share + +### Suggested Follows + +- [ ] Overflow → "Suggested Follows" → sheet with suggestions +- [ ] Follow/unfollow buttons in sheet work + +## 9. Profile Context (Constellation) + +- [ ] Open profile context from profile overflow menu +- [ ] **Blocked By** tab: shows count, "Show accounts" expands list +- [ ] Paginated account tiles load +- [ ] **Blocking** tab: shows outgoing blocks (own profile only) +- [ ] Shows "unavailable" message for other profiles +- [ ] **Lists** tab: shows lists user is a member of +- [ ] List cards display: name, owner, purpose badge, description +- [ ] Tap account → navigates to profile +- [ ] Tap list → navigates to list detail + +## 10. Post Thread + +- [ ] Thread loads: parent chain above, replies below +- [ ] Root post visually highlighted +- [ ] Nested reply chains render correctly +- [ ] All post actions work within thread (like, repost, reply, save) +- [ ] Tap profile in thread → profile screen + +## 11. Lists + +### My Lists + +- [ ] Navigate to lists screen +- [ ] Curation and Moderation tabs separate lists correctly +- [ ] Tap list → detail screen + +### Create List + +- [ ] Tap FAB → create dialog +- [ ] Enter name (1–64 graphemes), optional description, pick avatar +- [ ] Select purpose (curation or moderation) +- [ ] Save → list appears in My Lists + +### List Detail + +- [ ] Header: name, avatar, description, creator, member count +- [ ] Feed tab (curation lists): shows posts from members +- [ ] Members tab: lists member profiles +- [ ] Overflow: Edit, Delete, Add/Remove members + +### List Members + +- [ ] Search for actors to add +- [ ] Add member → appears in list +- [ ] Remove member → removed from list + +### Moderation Actions + +- [ ] Mute list → all members muted +- [ ] Block via list → all members blocked + +## 12. Starter Packs + +### Starter Pack Detail + +- [ ] Header: name, description, creator +- [ ] Stats: joined this week, joined all-time +- [ ] Sample members displayed (up to 12) +- [ ] Recommended feeds displayed (up to 3) +- [ ] "See all members" → navigates to list members +- [ ] "Follow all" → follows all members (with confirmation) + +### Create Starter Pack + +- [ ] Tap create from own profile's Packs tab +- [ ] Enter name (max 50 graphemes), optional description +- [ ] Search and add members +- [ ] Pick up to 3 feeds +- [ ] Save → pack appears on profile + +### Edit / Delete + +- [ ] Edit pack → update name, description, feeds +- [ ] Delete pack → removed from profile + +## 13. Media + +### Image Viewer + +- [ ] Tap image → full-screen viewer +- [ ] Pinch to zoom, pan around +- [ ] Multi-image post → swipe between images +- [ ] Alt text shown at bottom (if present) +- [ ] Download → saved to gallery +- [ ] Share → system sheet +- [ ] Swipe down → dismiss viewer + +### Video Player + +- [ ] Tap video → full-screen player +- [ ] Play/pause, seek bar, mute controls work +- [ ] Elapsed/total time displayed +- [ ] Fullscreen toggle works + +### Long-Press Context Menu + +- [ ] Long-press image thumbnail → Save / Share options + +--- + +## 14. Saved Posts + +- [ ] Navigate to saved posts screen +- [ ] Previously saved posts listed +- [ ] Tap post → thread view +- [ ] Unsave → removed from list +- [ ] Empty state shown when no saved posts + +--- + +## 15. Moderation & Labelers + +### Content Filtering + +- [ ] Blurred content shows click-through overlay +- [ ] Alert badge renders on warned content +- [ ] Filtered content hidden from feeds +- [ ] Media blur shows blurred images, text visible + +### Labeler Management + +- [ ] Settings → Moderation → labeler list shown +- [ ] Tap labeler → detail with label definitions +- [ ] Toggle per-label preference (ignore/warn/hide) +- [ ] Subscribe to new labeler +- [ ] Unsubscribe from labeler +- [ ] Adult content toggle gates 18+ labels + +--- + +## 16. Connectivity & Offline + +### Network Loss + +- [ ] Disable network → offline banner appears at top +- [ ] Cached feed data still displays +- [ ] Post actions (like, repost, compose) disabled with tooltip +- [ ] Notifications/DMs show empty or cached state + +### Network Restore + +- [ ] Re-enable network → banner disappears +- [ ] Actions re-enabled +- [ ] Pull-to-refresh loads fresh data + +### Optimistic Updates + +- [ ] Like a post on slow connection → UI updates immediately +- [ ] If API fails → UI rolls back, error snackbar shown + +--- + +## 17. Settings & Preferences + +### Theme + +- [ ] Change theme palette (Oxocarbon, Catppuccin, Nord, Rosé Pine) +- [ ] Toggle Light / Dark / System mode +- [ ] Theme applies immediately across all screens + +### Account Switching + +- [ ] Open account switcher → all accounts listed +- [ ] Switch to another account → feeds/profile reload for new account +- [ ] Add new account → OAuth flow → account added to list + +### Dev Tools + +- [ ] Logs: view logs, filter by level, search text, share log file +- [ ] PDS Explorer: resolve handle, browse collections, view record JSON + +### About + +- [ ] App version and build number displayed +- [ ] Licenses accessible + +--- + +## 18. Cross-Cutting Concerns + +### Navigation + +- [ ] Bottom nav switches between Home, Search, Alerts, Profile +- [ ] Back button / swipe-back navigates correctly through stack +- [ ] Deep link to a post URI opens thread view + +### Performance + +- [ ] Feed scroll is smooth (no jank) +- [ ] Image loading doesn't block UI +- [ ] Search results appear within reasonable time + +### Error States + +- [ ] Invalid handle on login → error message shown +- [ ] Network error on post submit → error snackbar, draft preserved +- [ ] 404 on deleted post → appropriate error state + +### State Persistence + +- [ ] Kill app mid-scroll → relaunch restores session +- [ ] Drafts survive app restart +- [ ] Saved posts survive app restart +- [ ] Search history survives app restart +- [ ] Theme selection survives app restart diff --git a/docs/tasks/phase-5.md b/docs/tasks/phase-5.md index 2fcff62..393fa7c 100644 --- a/docs/tasks/phase-5.md +++ b/docs/tasks/phase-5.md @@ -1,6 +1,6 @@ --- title: Phase 5 Task Breakdown -updated: 2026-03-31 +updated: 2026-04-01 --- # Phase 5 Milestones @@ -106,19 +106,19 @@ updated: 2026-03-31 ### UI -- [ ] Profile screen overflow menu - add "Profile Context" entry (available for all profiles) -- [ ] Route: `/profile-context?did={DID}` in `app_router.dart` -- [ ] `ProfileContextScreen` - `AppBar` (title + handle subtitle), `TabBar` with 3 tabs, `BlocProvider` creating cubit -- [ ] **Blocked By tab** - count header, "Show accounts" expand, paginated profile tiles (avatar, name, handle), tap → profile navigation, contextualizing note text -- [ ] **Blocking tab** - same layout; hidden or explanatory text when viewing other profiles -- [ ] **Lists tab** - list cards (name, owner, purpose badge, member count, description), grouped by purpose, tap → `/list?uri=` -- [ ] Per-tab states: skeleton shimmer (loading), contextual empty state, inline error with retry -- [ ] Pull-to-refresh per tab -- [ ] Infinite scroll pagination per tab +- [x] Profile screen overflow menu - add "Profile Context" entry (available for all profiles) +- [x] Route: `/profile-context?did={DID}` in `app_router.dart` +- [x] `ProfileContextScreen` - `AppBar` (title + handle subtitle), `TabBar` with 3 tabs, `BlocProvider` creating cubit +- [x] **Blocked By tab** - count header, "Show accounts" expand, paginated profile tiles (avatar, name, handle), tap → profile navigation, contextualizing note text +- [x] **Blocking tab** - same layout; hidden or explanatory text when viewing other profiles +- [x] **Lists tab** - list cards (name, owner, purpose badge, member count, description), grouped by purpose, tap → `/list?uri=` +- [x] Per-tab states: skeleton shimmer (loading), contextual empty state, inline error with retry +- [x] Pull-to-refresh per tab +- [x] Infinite scroll pagination per tab ### Tests -- [ ] Unit tests: `ConstellationClient` - each endpoint method, error handling, timeout, URL construction -- [ ] Unit tests: `ProfileContextRepository` - DID hydration batching, list URI derivation, cursor passthrough -- [ ] Unit tests: `ProfileContextCubit` - state transitions for each tab, own-profile vs other-profile logic, pagination appending -- [ ] Widget tests: screen renders 3 tabs, blocked-by count + expand, profile tiles render and navigate, list cards render and navigate, empty states, error + retry, blocking tab hidden for non-own profiles +- [x] Unit tests: `ConstellationClient` - each endpoint method, error handling, timeout, URL construction +- [x] Unit tests: `ProfileContextRepository` - DID hydration batching, list URI derivation, cursor passthrough +- [x] Unit tests: `ProfileContextCubit` - state transitions for each tab, own-profile vs other-profile logic, pagination appending +- [x] Widget tests: screen renders 3 tabs, blocked-by count + expand, profile tiles render and navigate, list cards render and navigate, empty states, error + retry, blocking tab hidden for non-own profiles diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3ac7d46..e08cb05 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -161,7 +161,6 @@ 90899372CAA28BD74B2D49A1 /* Pods-RunnerTests.release.xcconfig */, AA7C2CE85592DCD41249030A /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -490,6 +489,7 @@ CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; + EXCLUDED_ARCHS = ""; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -672,6 +672,7 @@ CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; + EXCLUDED_ARCHS = ""; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -694,6 +695,7 @@ CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; + EXCLUDED_ARCHS = ""; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/lib/core/network/constellation_client.dart b/lib/core/network/constellation_client.dart index cba8835..62cb685 100644 --- a/lib/core/network/constellation_client.dart +++ b/lib/core/network/constellation_client.dart @@ -46,12 +46,23 @@ class ConstellationException implements Exception { class ConstellationClient { ConstellationClient({String? baseUrl, http.Client? httpClient}) - : _baseUrl = baseUrl ?? _defaultBaseUrl, + : _baseUrl = _normalizeBaseUrl(baseUrl), _httpClient = httpClient ?? http.Client(); final String _baseUrl; final http.Client _httpClient; + String get baseUrl => _baseUrl; + + static String _normalizeBaseUrl(String? baseUrl) { + final trimmed = baseUrl?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return _defaultBaseUrl; + } + + return trimmed.replaceFirst(RegExp(r'/+$'), ''); + } + Uri _xrpcUri(String endpoint, Map params) { final filtered = {}; for (final entry in params.entries) { @@ -71,6 +82,21 @@ class ConstellationClient { return jsonDecode(response.body) as Map; } + List _listField(Map data, String key) { + final value = data[key]; + if (value == null) return const []; + return value as List; + } + + List _listFieldAny(Map data, List keys) { + for (final key in keys) { + if (data.containsKey(key) && data[key] != null) { + return data[key] as List; + } + } + return const []; + } + Future getBacklinksCount(String subject, String source) async { final uri = _xrpcUri('blue.microcosm.links.getBacklinksCount', {'subject': subject, 'source': source}); final data = await _get(uri); @@ -92,7 +118,7 @@ class ConstellationClient { final data = await _get(uri); return ( total: data['total'] as int, - dids: (data['dids'] as List).cast(), + dids: _listField(data, 'dids').cast(), cursor: data['cursor'] as String?, ); } @@ -110,9 +136,10 @@ class ConstellationClient { 'cursor': cursor, }); final data = await _get(uri); - final records = (data['linking_records'] as List) - .map((r) => ConstellationLinkRecord.fromJson(r as Map)) - .toList(); + final records = _listFieldAny(data, [ + 'records', + 'linking_records', + ]).map((r) => ConstellationLinkRecord.fromJson(r as Map)).toList(); return (total: data['total'] as int, records: records, cursor: data['cursor'] as String?); } @@ -131,9 +158,7 @@ class ConstellationClient { 'cursor': cursor, }); final data = await _get(uri); - final items = (data['items'] as List) - .map((i) => ManyToManyItem.fromJson(i as Map)) - .toList(); + final items = _listField(data, 'items').map((i) => ManyToManyItem.fromJson(i as Map)).toList(); return (items: items, cursor: data['cursor'] as String?); } } diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 33851c4..d085e38 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -195,6 +195,7 @@ class AppRouter { final constellationUrl = context.read().state.constellationUrl; final repository = ProfileContextRepository( bluesky: context.read(), + publicBluesky: Bluesky.anonymous(service: profileContextPublicAppViewService), constellationClient: ConstellationClient(baseUrl: constellationUrl), ); return BlocProvider( diff --git a/lib/features/profile/bloc/profile_bloc.dart b/lib/features/profile/bloc/profile_bloc.dart index 758c3d5..9921ff3 100644 --- a/lib/features/profile/bloc/profile_bloc.dart +++ b/lib/features/profile/bloc/profile_bloc.dart @@ -36,7 +36,8 @@ class ProfileBloc extends Bloc { emit(state.copyWith(isRefreshing: true)); try { - final profile = await _profileRepository.getProfile(currentProfile.did); + final actor = currentProfile.handle.isNotEmpty ? currentProfile.handle : currentProfile.did; + final profile = await _profileRepository.getProfile(actor); emit(ProfileState.loaded(profile: profile)); } catch (error) { emit(state.copyWith(isRefreshing: false)); diff --git a/lib/features/profile/cubit/profile_context_cubit.dart b/lib/features/profile/cubit/profile_context_cubit.dart index 89c6a37..c78c762 100644 --- a/lib/features/profile/cubit/profile_context_cubit.dart +++ b/lib/features/profile/cubit/profile_context_cubit.dart @@ -14,17 +14,22 @@ class ProfileContextCubit extends Cubit { final ProfileContextRepository _repository; - /// Loads blocked-by and lists-on counts in parallel for tab header badges. + /// Loads tab counts in parallel for the header badges. Future init() async { - try { - final results = await Future.wait([ - _repository.getBlockedByCount(state.did), - _repository.getListsOnCount(state.did), - ]); - emit(state.copyWith(blockedByCount: results[0], listsOnCount: results[1])); - } catch (error) { - log.w('failed to load initial counts: $error'); - } + final futures = >[ + _loadCount(() => _repository.getBlockedByCount(state.did), 'blocked-by'), + _loadCount(() => _repository.getListsOnCount(state.did), 'lists-on'), + if (state.isOwnProfile) _loadCount(() => _repository.getBlockingCount(state.did), 'blocking'), + ]; + final results = await Future.wait(futures); + + emit( + state.copyWith( + blockedByCount: results[0] ?? state.blockedByCount, + listsOnCount: results[1] ?? state.listsOnCount, + blockingCount: state.isOwnProfile ? (results[2] ?? state.blockingCount) : state.blockingCount, + ), + ); } /// Fetches a page of profiles that have blocked the viewed user and appends @@ -39,7 +44,7 @@ class ProfileContextCubit extends Cubit { emit( state.copyWith( blockedByStatus: ProfileContextTabStatus.loaded, - blockedByProfiles: [...state.blockedByProfiles, ...result.profiles], + blockedByEntries: [...state.blockedByEntries, ...result.entries], blockedByCount: result.total, blockedByCursor: result.cursor, blockedByHasMore: result.cursor != null, @@ -70,7 +75,8 @@ class ProfileContextCubit extends Cubit { state.copyWith( blockingStatus: ProfileContextTabStatus.loaded, blockingProfiles: merged, - blockingCount: merged.length, + blockingUnavailable: _mergeUnavailable(state.blockingUnavailable, result.unavailable), + blockingCount: merged.length > state.blockingCount ? merged.length : state.blockingCount, blockingCursor: result.cursor, blockingHasMore: result.cursor != null, ), @@ -112,7 +118,7 @@ class ProfileContextCubit extends Cubit { Future refreshBlockedBy() async { emit( state.copyWith( - blockedByProfiles: [], + blockedByEntries: [], blockedByCursor: null, blockedByHasMore: false, blockedByStatus: ProfileContextTabStatus.initial, @@ -128,14 +134,18 @@ class ProfileContextCubit extends Cubit { emit( state.copyWith( blockingProfiles: [], + blockingUnavailable: [], blockingCursor: null, blockingHasMore: false, blockingStatus: ProfileContextTabStatus.initial, blockingError: null, - blockingCount: 0, ), ); await loadBlocking(); + final refreshedCount = await _loadCount(() => _repository.getBlockingCount(state.did), 'blocking'); + if (refreshedCount != null) { + emit(state.copyWith(blockingCount: refreshedCount)); + } } /// Resets the lists-on list and reloads from the first page. @@ -151,4 +161,24 @@ class ProfileContextCubit extends Cubit { ); await loadListsOn(); } + + Future _loadCount(Future Function() loader, String label) async { + try { + return await loader(); + } catch (error) { + log.w('failed to load $label count: $error'); + return null; + } + } + + List _mergeUnavailable( + List existing, + List incoming, + ) { + final merged = {for (final item in existing) item.did: item}; + for (final item in incoming) { + merged[item.did] = item; + } + return merged.values.toList(); + } } diff --git a/lib/features/profile/cubit/profile_context_state.dart b/lib/features/profile/cubit/profile_context_state.dart index 30f6344..94b5ac7 100644 --- a/lib/features/profile/cubit/profile_context_state.dart +++ b/lib/features/profile/cubit/profile_context_state.dart @@ -12,12 +12,13 @@ class ProfileContextState extends Equatable { this.blockingCount = 0, this.listsOnCount = 0, this.blockedByStatus = ProfileContextTabStatus.initial, - this.blockedByProfiles = const [], + this.blockedByEntries = const [], this.blockedByCursor, this.blockedByHasMore = false, this.blockedByError, this.blockingStatus = ProfileContextTabStatus.initial, this.blockingProfiles = const [], + this.blockingUnavailable = const [], this.blockingCursor, this.blockingHasMore = false, this.blockingError, @@ -39,13 +40,14 @@ class ProfileContextState extends Equatable { final int listsOnCount; final ProfileContextTabStatus blockedByStatus; - final List blockedByProfiles; + final List blockedByEntries; final String? blockedByCursor; final bool blockedByHasMore; final String? blockedByError; final ProfileContextTabStatus blockingStatus; final List blockingProfiles; + final List blockingUnavailable; final String? blockingCursor; final bool blockingHasMore; final String? blockingError; @@ -61,12 +63,13 @@ class ProfileContextState extends Equatable { int? blockingCount, int? listsOnCount, ProfileContextTabStatus? blockedByStatus, - List? blockedByProfiles, + List? blockedByEntries, Object? blockedByCursor = _profileContextNoValue, bool? blockedByHasMore, Object? blockedByError = _profileContextNoValue, ProfileContextTabStatus? blockingStatus, List? blockingProfiles, + List? blockingUnavailable, Object? blockingCursor = _profileContextNoValue, bool? blockingHasMore, Object? blockingError = _profileContextNoValue, @@ -83,7 +86,7 @@ class ProfileContextState extends Equatable { blockingCount: blockingCount ?? this.blockingCount, listsOnCount: listsOnCount ?? this.listsOnCount, blockedByStatus: blockedByStatus ?? this.blockedByStatus, - blockedByProfiles: blockedByProfiles ?? this.blockedByProfiles, + blockedByEntries: blockedByEntries ?? this.blockedByEntries, blockedByCursor: identical(blockedByCursor, _profileContextNoValue) ? this.blockedByCursor : blockedByCursor as String?, @@ -93,6 +96,7 @@ class ProfileContextState extends Equatable { : blockedByError as String?, blockingStatus: blockingStatus ?? this.blockingStatus, blockingProfiles: blockingProfiles ?? this.blockingProfiles, + blockingUnavailable: blockingUnavailable ?? this.blockingUnavailable, blockingCursor: identical(blockingCursor, _profileContextNoValue) ? this.blockingCursor : blockingCursor as String?, @@ -114,12 +118,13 @@ class ProfileContextState extends Equatable { blockingCount, listsOnCount, blockedByStatus, - blockedByProfiles, + blockedByEntries, blockedByCursor, blockedByHasMore, blockedByError, blockingStatus, blockingProfiles, + blockingUnavailable, blockingCursor, blockingHasMore, blockingError, diff --git a/lib/features/profile/data/profile_context_repository.dart b/lib/features/profile/data/profile_context_repository.dart index 3569113..a5202ec 100644 --- a/lib/features/profile/data/profile_context_repository.dart +++ b/lib/features/profile/data/profile_context_repository.dart @@ -1,14 +1,50 @@ import 'package:atproto_core/atproto_core.dart' show AtUri; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_graph_defs.dart'; +import 'package:equatable/equatable.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/constellation_client.dart'; +const profileContextPublicAppViewService = 'public.api.bsky.app'; +const _blockedByPageSize = 16; +const _listsPageSize = 16; + +class UnavailableProfileRef extends Equatable { + const UnavailableProfileRef({required this.did, required this.reason}); + + final String did; + final String reason; + + @override + List get props => [did, reason]; +} + +class BlockedByEntry extends Equatable { + BlockedByEntry.profile({required ProfileView this.profile}) : did = profile.did, unavailableReason = null; + + const BlockedByEntry.unavailable({required this.did, required this.unavailableReason}) : profile = null; + + final String did; + final ProfileView? profile; + final String? unavailableReason; + + bool get isAvailable => profile != null; + + @override + List get props => [did, profile, unavailableReason]; +} + class ProfileContextRepository { - ProfileContextRepository({required dynamic bluesky, required ConstellationClient constellationClient}) - : _bluesky = bluesky, - _constellation = constellationClient; + ProfileContextRepository({ + required dynamic bluesky, + dynamic publicBluesky, + required ConstellationClient constellationClient, + }) : _bluesky = bluesky, + _publicBluesky = publicBluesky ?? bluesky, + _constellation = constellationClient; final dynamic _bluesky; + final dynamic _publicBluesky; final ConstellationClient _constellation; /// Returns the number of accounts that have blocked [did]. @@ -23,26 +59,68 @@ class ProfileContextRepository { /// Returns a page of profiles that have blocked [did], along with the total /// count and a cursor for the next page. - Future<({List profiles, String? cursor, int total})> getBlockedByProfiles( + Future<({List entries, String? cursor, int total})> getBlockedByProfiles( String did, { String? cursor, }) async { - final result = await _constellation.getDistinct( - did, - 'app.bsky.graph.block:subject', - cursor: cursor, + final offset = int.tryParse(cursor ?? '0') ?? 0; + log.i('ProfileContextRepository: blocked-by load start for $did offset=$offset via ${_constellation.baseUrl}'); + final collected = await _collectBlockedByDids(did); + final hydrated = await _hydrateProfiles(collected.dids); + final unavailableByDid = {for (final entry in hydrated.unavailable) entry.did: entry.reason}; + final profileByDid = {for (final profile in hydrated.profiles) profile.did: profile}; + + final pageDids = collected.dids.skip(offset).take(_blockedByPageSize).toList(); + final entries = []; + for (final pageDid in pageDids) { + final profile = profileByDid[pageDid]; + if (profile != null) { + entries.add(BlockedByEntry.profile(profile: profile)); + continue; + } + + final unavailableReason = unavailableByDid[pageDid]; + if (unavailableReason != null) { + entries.add(BlockedByEntry.unavailable(did: pageDid, unavailableReason: unavailableReason)); + } + } + + final nextOffset = offset + _blockedByPageSize; + log.i( + 'ProfileContextRepository: blocked-by load complete for $did total=${collected.total} dids=${collected.dids.length} resolved=${hydrated.profiles.length} unavailable=${hydrated.unavailable.length} returned=${entries.length}', ); - final profiles = await _hydrateProfiles(result.dids); - return (profiles: profiles, cursor: result.cursor, total: result.total); + return ( + entries: entries, + cursor: nextOffset < collected.dids.length ? '$nextOffset' : null, + total: collected.total, + ); + } + + /// Returns the total number of accounts that [did] is blocking. + Future getBlockingCount(String did) async { + var total = 0; + String? cursor; + + do { + final response = await _bluesky.atproto.repo.listRecords( + repo: did, + collection: 'app.bsky.graph.block', + limit: 100, + cursor: cursor, + ); + + total += (response.data.records as List).length; + cursor = response.data.cursor as String?; + } while (cursor != null); + + return total; } /// Returns a page of profiles that [did] is blocking, along with a cursor. /// Uses `com.atproto.repo.listRecords` on the actor's own repo. /// [total] reflects the number of profiles hydrated in this page. - Future<({List profiles, String? cursor, int total})> getBlockingProfiles( - String did, { - String? cursor, - }) async { + Future<({List profiles, List unavailable, String? cursor, int total})> + getBlockingProfiles(String did, {String? cursor}) async { final response = await _bluesky.atproto.repo.listRecords( repo: did, collection: 'app.bsky.graph.block', @@ -50,11 +128,14 @@ class ProfileContextRepository { cursor: cursor, ); - final subjectDids = (response.data.records as List) - .map((r) => r.value['subject'] as String) - .toList(); - final profiles = await _hydrateProfiles(subjectDids); - return (profiles: profiles, cursor: response.data.cursor as String?, total: profiles.length); + final subjectDids = (response.data.records as List).map((r) => r.value['subject'] as String).toList(); + final hydrated = await _hydrateProfiles(subjectDids); + return ( + profiles: hydrated.profiles, + unavailable: hydrated.unavailable, + cursor: response.data.cursor as String?, + total: hydrated.profiles.length, + ); } /// Returns a page of lists that [did] is a member of, along with the total @@ -65,29 +146,230 @@ class ProfileContextRepository { did, 'app.bsky.graph.listitem:subject', 'list', + limit: _listsPageSize, cursor: cursor, ); - final lists = await Future.wait( - result.items.map((item) async { - final uri = AtUri.parse(item.otherSubject); - final response = await _bluesky.graph.getList(list: uri, limit: 1); - return response.data.list as ListView; - }), - ); + final uniqueListUris = {}; + final listUris = []; + for (final item in result.items) { + if (uniqueListUris.add(item.otherSubject)) { + listUris.add(item.otherSubject); + } + } + + final lists = []; + for (final uriString in listUris) { + try { + final uri = AtUri.parse(uriString); + final response = await _publicBluesky.graph.getList(list: uri, limit: 1); + lists.add(response.data.list as ListView); + } catch (error, stackTrace) { + log.w( + 'skipping invalid or unavailable list in profile context: $uriString', + error: error, + stackTrace: stackTrace, + ); + } + } return (lists: lists, cursor: result.cursor, total: total); } /// Hydrates [dids] into [ProfileView] objects in batches of 25. - Future> _hydrateProfiles(List dids) async { - if (dids.isEmpty) return []; + Future<({List profiles, List unavailable})> _hydrateProfiles( + List dids, + ) async { + final normalizedDids = _normalizeDids(dids); + if (normalizedDids.isEmpty) { + return (profiles: [], unavailable: []); + } + final allProfiles = []; - for (var i = 0; i < dids.length; i += 25) { - final batch = dids.sublist(i, (i + 25).clamp(0, dids.length)); - final response = await _bluesky.actor.getProfiles(actors: batch); - allProfiles.addAll(response.data.profiles as List); + final unavailable = []; + for (var i = 0; i < normalizedDids.length; i += 25) { + final batch = normalizedDids.sublist(i, (i + 25).clamp(0, normalizedDids.length)); + final resolvedProfiles = {}; + log.d( + 'ProfileContextRepository: blocked-by public batch ${i ~/ 25 + 1} size=${batch.length} dids=${batch.join(',')}', + ); + try { + final response = await _publicBluesky.actor.getProfiles(actors: batch); + for (final profile in response.data.profiles as List) { + final converted = _asProfileView(profile); + if (converted != null) { + resolvedProfiles[converted.did] = converted; + } + } + } catch (error, stackTrace) { + log.w( + 'ProfileContextRepository: blocked-by public batch failed, falling back to per-DID lookups for ${batch.length} actors', + error: error, + stackTrace: stackTrace, + ); + } + + for (final did in batch) { + final existing = resolvedProfiles[did]; + if (existing != null) { + log.d('ProfileContextRepository: blocked-by public batch resolved $did'); + allProfiles.add(existing); + continue; + } + + try { + final response = await _publicBluesky.actor.getProfile(actor: did); + final converted = _asProfileView(response.data); + if (converted != null) { + log.d('ProfileContextRepository: blocked-by per-DID resolved $did'); + allProfiles.add(converted); + } else { + const reason = 'Public profile lookup failed'; + unavailable.add(UnavailableProfileRef(did: did, reason: reason)); + log.w('ProfileContextRepository: blocked-by per-DID returned an unsupported profile shape for $did'); + } + } catch (error, stackTrace) { + final reason = _publicProfileFailureReason(error); + unavailable.add(UnavailableProfileRef(did: did, reason: reason)); + log.w( + 'ProfileContextRepository: blocked-by per-DID failed for $did: $reason', + error: error, + stackTrace: stackTrace, + ); + } + } } - return allProfiles; + return (profiles: allProfiles, unavailable: unavailable); } + + Future<({List dids, int total})> _collectBlockedByDids(String did) async { + try { + final dids = []; + final seen = {}; + var total = 0; + String? cursor; + log.i('ProfileContextRepository: blocked-by using getDistinct for $did'); + + do { + final result = await _constellation.getDistinct( + did, + 'app.bsky.graph.block:subject', + limit: _blockedByPageSize, + cursor: cursor, + ); + final inputCursor = cursor; + if (total == 0) { + total = result.total; + log.i('ProfileContextRepository: blocked-by count for $did is $total'); + } + for (final rawDid in result.dids) { + final normalizedDid = _normalizeDid(rawDid); + if (normalizedDid != null && seen.add(normalizedDid)) { + dids.add(normalizedDid); + } + } + cursor = result.cursor; + log.d( + 'ProfileContextRepository: blocked-by getDistinct page cursorIn=${inputCursor ?? 'null'} records=${result.dids.length} uniqueTotal=${dids.length} cursorOut=${cursor ?? 'null'}', + ); + } while (cursor != null); + + log.i('ProfileContextRepository: blocked-by collected ${dids.length} unique DIDs from getDistinct for $did'); + return (dids: dids, total: total); + } on ConstellationException catch (error) { + if (!_isNotFound(error)) rethrow; + log.w('ProfileContextRepository: blocked-by getDistinct returned 404 for $did, falling back to getBacklinks'); + + final dids = []; + final seen = {}; + var total = 0; + String? cursor; + + do { + final result = await _constellation.getBacklinks( + did, + 'app.bsky.graph.block:subject', + limit: _blockedByPageSize, + cursor: cursor, + ); + final inputCursor = cursor; + if (total == 0) { + total = result.total; + log.i('ProfileContextRepository: blocked-by count for $did is $total'); + } + for (final record in result.records) { + final normalizedDid = _normalizeDid(record.did); + if (normalizedDid != null && seen.add(normalizedDid)) { + dids.add(normalizedDid); + } + } + cursor = result.cursor; + log.d( + 'ProfileContextRepository: blocked-by getBacklinks page cursorIn=${inputCursor ?? 'null'} records=${result.records.length} uniqueTotal=${dids.length} cursorOut=${cursor ?? 'null'}', + ); + } while (cursor != null); + + log.i('ProfileContextRepository: blocked-by collected ${dids.length} unique DIDs from getBacklinks for $did'); + return (dids: dids, total: total); + } + } + + List _normalizeDids(List dids) { + final normalized = []; + final seen = {}; + for (final did in dids) { + final value = _normalizeDid(did); + if (value != null && seen.add(value)) { + normalized.add(value); + } + } + return normalized; + } + + String? _normalizeDid(String did) { + final trimmed = did.trim(); + if (trimmed.isEmpty) return null; + return trimmed; + } + + String _publicProfileFailureReason(Object error) { + final message = error.toString(); + final lowerMessage = message.toLowerCase(); + if (message.contains('AccountTakedown') || lowerMessage.contains('account has been suspended')) { + return 'Suspended account'; + } + if (message.contains('HTTP 404') || lowerMessage.contains('not found')) { + return 'Profile unavailable'; + } + return 'Public profile lookup failed'; + } + + ProfileView? _asProfileView(dynamic profile) { + if (profile is ProfileView) { + return profile; + } + + if (profile is ProfileViewDetailed) { + return ProfileView( + did: profile.did, + handle: profile.handle, + displayName: profile.displayName, + pronouns: profile.pronouns, + description: profile.description, + avatar: profile.avatar, + associated: profile.associated, + indexedAt: profile.indexedAt, + createdAt: profile.createdAt, + viewer: profile.viewer, + labels: profile.labels, + verification: profile.verification, + status: profile.status, + debug: profile.debug, + ); + } + + return null; + } + + bool _isNotFound(ConstellationException error) => error.message.startsWith('HTTP 404'); } diff --git a/lib/features/profile/presentation/profile_context_screen.dart b/lib/features/profile/presentation/profile_context_screen.dart index 5cf3d9b..3d77066 100644 --- a/lib/features/profile/presentation/profile_context_screen.dart +++ b/lib/features/profile/presentation/profile_context_screen.dart @@ -1,10 +1,11 @@ import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; +import 'package:lazurite/features/profile/data/profile_context_repository.dart'; class ProfileContextScreen extends StatefulWidget { const ProfileContextScreen({super.key, required this.handle}); @@ -113,102 +114,117 @@ class _BlockedByTab extends StatelessWidget { return RefreshIndicator( onRefresh: cubit.refreshBlockedBy, - child: CustomScrollView( - slivers: [ - // Contextualizing note at the top. - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - 'Blocks are a normal part of social media. ' - 'This data is public on the AT Protocol.', - textAlign: TextAlign.center, + child: NotificationListener( + onNotification: (notification) => _maybeLoadMore( + notification: notification, + status: state.blockedByStatus, + hasMore: state.blockedByHasMore, + cursor: state.blockedByCursor, + onLoadMore: (cursor) => cubit.loadBlockedBy(cursor: cursor), + ), + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + // Contextualizing note at the top. + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + 'Blocks are a normal part of social media. ' + 'This data is public on the AT Protocol.', + textAlign: TextAlign.center, + ), ), ), - ), - // Count header + expand button. - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Text( - '${state.blockedByCount} account${state.blockedByCount == 1 ? '' : 's'}', - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), - ), - const Spacer(), - if (state.blockedByStatus == ProfileContextTabStatus.initial) - TextButton.icon( - key: const Key('blocked_by_show_accounts'), - onPressed: () => cubit.loadBlockedBy(), - icon: const Icon(Icons.expand_more), - label: const Text('Show accounts'), + // Count header + expand button. + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Text( + '${state.blockedByCount} account${state.blockedByCount == 1 ? '' : 's'}', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), - ], - ), - ), - ), - // Content based on status. - if (state.blockedByStatus == ProfileContextTabStatus.loading && state.blockedByProfiles.isEmpty) - const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) - else if (state.blockedByStatus == ProfileContextTabStatus.error && state.blockedByProfiles.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: _ErrorRetry( - message: state.blockedByError ?? 'Failed to load accounts', - onRetry: () => cubit.loadBlockedBy(), + const Spacer(), + if (state.blockedByStatus == ProfileContextTabStatus.initial) + TextButton.icon( + key: const Key('blocked_by_show_accounts'), + onPressed: () => cubit.loadBlockedBy(), + icon: const Icon(Icons.expand_more), + label: const Text('Show accounts'), + ), + ], ), ), - ) - else if (state.blockedByStatus == ProfileContextTabStatus.loaded && state.blockedByProfiles.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: Center(child: Text('No accounts have blocked this user')), - ) - else ...[ - SliverList.builder( - itemCount: state.blockedByProfiles.length, - itemBuilder: (context, index) { - final profile = state.blockedByProfiles[index]; - return _ProfileTile( - key: ValueKey('blocked_by_${profile.did}'), - profile: profile, - onTap: () => context.push('/profile/view?actor=${profile.did}'), - ); - }, ), - if (state.blockedByStatus == ProfileContextTabStatus.loading) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), + // Content based on status. + if (state.blockedByStatus == ProfileContextTabStatus.loading && state.blockedByEntries.isEmpty) + const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) + else if (state.blockedByStatus == ProfileContextTabStatus.error && state.blockedByEntries.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: _ErrorRetry( + message: state.blockedByError ?? 'Failed to load accounts', + onRetry: () => cubit.loadBlockedBy(), + ), ), ) - else if (state.blockedByStatus == ProfileContextTabStatus.error) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: _ErrorRetry( - message: state.blockedByError ?? 'Failed to load more', - onRetry: () => cubit.loadBlockedBy(cursor: state.blockedByCursor), + else if (state.blockedByStatus == ProfileContextTabStatus.loaded && state.blockedByEntries.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + state.blockedByCount > 0 + ? 'Found ${state.blockedByCount} blocked-by accounts, but public Bluesky profile details could not be loaded.' + : 'No accounts have blocked this user', + textAlign: TextAlign.center, ), ), ) - else if (state.blockedByHasMore) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: TextButton( - onPressed: () => cubit.loadBlockedBy(cursor: state.blockedByCursor), - child: const Text('Load more'), + else ...[ + SliverList.builder( + itemCount: state.blockedByEntries.length, + itemBuilder: (context, index) { + final entry = state.blockedByEntries[index]; + if (entry.profile != null) { + final profile = entry.profile!; + return _ProfileTile( + key: ValueKey('blocked_by_${profile.did}'), + profile: profile, + onTap: () => + context.push('/profile/view?actor=${Uri.encodeQueryComponent(_profileActor(profile))}'), + ); + } + + return _UnavailableProfileTile( + key: ValueKey('blocked_by_unavailable_${entry.did}'), + did: entry.did, + reason: entry.unavailableReason ?? 'Profile unavailable', + ); + }, + ), + if (state.blockedByStatus == ProfileContextTabStatus.loading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + ) + else if (state.blockedByStatus == ProfileContextTabStatus.error) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: _ErrorRetry( + message: state.blockedByError ?? 'Failed to load more', + onRetry: () => cubit.loadBlockedBy(cursor: state.blockedByCursor), ), ), ), - ), + ], ], - ], + ), ), ); } @@ -241,66 +257,91 @@ class _BlockingTab extends StatelessWidget { return RefreshIndicator( onRefresh: cubit.refreshBlocking, - child: CustomScrollView( - slivers: [ - if (state.blockingStatus == ProfileContextTabStatus.initial || - (state.blockingStatus == ProfileContextTabStatus.loading && state.blockingProfiles.isEmpty)) - const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) - else if (state.blockingStatus == ProfileContextTabStatus.error && state.blockingProfiles.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: _ErrorRetry( - message: state.blockingError ?? 'Failed to load accounts', - onRetry: () => cubit.loadBlocking(), + child: NotificationListener( + onNotification: (notification) => _maybeLoadMore( + notification: notification, + status: state.blockingStatus, + hasMore: state.blockingHasMore, + cursor: state.blockingCursor, + onLoadMore: (cursor) => cubit.loadBlocking(cursor: cursor), + ), + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + '${state.blockingCount} account${state.blockingCount == 1 ? '' : 's'}', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), ), - ) - else if (state.blockingStatus == ProfileContextTabStatus.loaded && state.blockingProfiles.isEmpty) - const SliverFillRemaining(hasScrollBody: false, child: Center(child: Text('Not blocking anyone'))) - else ...[ - SliverList.builder( - itemCount: state.blockingProfiles.length, - itemBuilder: (context, index) { - final profile = state.blockingProfiles[index]; - return _ProfileTile( - key: ValueKey('blocking_${profile.did}'), - profile: profile, - onTap: () => context.push('/profile/view?actor=${profile.did}'), - ); - }, ), - if (state.blockingStatus == ProfileContextTabStatus.loading) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - ) - else if (state.blockingStatus == ProfileContextTabStatus.error) + if (state.blockingUnavailable.isNotEmpty) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: _UnavailableAccountsCard(entries: state.blockingUnavailable), + ), + ), + if (state.blockingStatus == ProfileContextTabStatus.initial || + (state.blockingStatus == ProfileContextTabStatus.loading && state.blockingProfiles.isEmpty)) + const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) + else if (state.blockingStatus == ProfileContextTabStatus.error && state.blockingProfiles.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( child: _ErrorRetry( - message: state.blockingError ?? 'Failed to load more', - onRetry: () => cubit.loadBlocking(cursor: state.blockingCursor), + message: state.blockingError ?? 'Failed to load accounts', + onRetry: () => cubit.loadBlocking(), ), ), ) - else if (state.blockingHasMore) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: TextButton( - onPressed: () => cubit.loadBlocking(cursor: state.blockingCursor), - child: const Text('Load more'), - ), + else if (state.blockingStatus == ProfileContextTabStatus.loaded && state.blockingProfiles.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + state.blockingUnavailable.isNotEmpty + ? 'Some blocked accounts are suspended or unavailable.' + : 'Not blocking anyone', + textAlign: TextAlign.center, ), ), + ) + else ...[ + SliverList.builder( + itemCount: state.blockingProfiles.length, + itemBuilder: (context, index) { + final profile = state.blockingProfiles[index]; + return _ProfileTile( + key: ValueKey('blocking_${profile.did}'), + profile: profile, + onTap: () => + context.push('/profile/view?actor=${Uri.encodeQueryComponent(_profileActor(profile))}'), + ); + }, ), + if (state.blockingStatus == ProfileContextTabStatus.loading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + ) + else if (state.blockingStatus == ProfileContextTabStatus.error) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: _ErrorRetry( + message: state.blockingError ?? 'Failed to load more', + onRetry: () => cubit.loadBlocking(cursor: state.blockingCursor), + ), + ), + ), + ], ], - ], + ), ), ); } @@ -321,66 +362,58 @@ class _ListsOnTab extends StatelessWidget { return RefreshIndicator( onRefresh: cubit.refreshListsOn, - child: CustomScrollView( - slivers: [ - if (state.listsOnStatus == ProfileContextTabStatus.initial || - (state.listsOnStatus == ProfileContextTabStatus.loading && state.listsOn.isEmpty)) - const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) - else if (state.listsOnStatus == ProfileContextTabStatus.error && state.listsOn.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: _ErrorRetry( - message: state.listsOnError ?? 'Failed to load lists', - onRetry: () => cubit.loadListsOn(), - ), - ), - ) - else if (state.listsOnStatus == ProfileContextTabStatus.loaded && state.listsOn.isEmpty) - const SliverFillRemaining(hasScrollBody: false, child: Center(child: Text('Not on any lists'))) - else ...[ - SliverList.builder( - itemCount: state.listsOn.length, - itemBuilder: (context, index) { - final list = state.listsOn[index]; - return ListRowTile( - key: ValueKey('list_on_${list.uri}'), - list: list, - onTap: () => context.push('/list?uri=${Uri.encodeComponent(list.uri.toString())}'), - ); - }, - ), - if (state.listsOnStatus == ProfileContextTabStatus.loading) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - ) - else if (state.listsOnStatus == ProfileContextTabStatus.error) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), + child: NotificationListener( + onNotification: (notification) => _maybeLoadMore( + notification: notification, + status: state.listsOnStatus, + hasMore: state.listsOnHasMore, + cursor: state.listsOnCursor, + onLoadMore: (cursor) => cubit.loadListsOn(cursor: cursor), + ), + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + if (state.listsOnStatus == ProfileContextTabStatus.initial || + (state.listsOnStatus == ProfileContextTabStatus.loading && state.listsOn.isEmpty)) + const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) + else if (state.listsOnStatus == ProfileContextTabStatus.error && state.listsOn.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( child: _ErrorRetry( - message: state.listsOnError ?? 'Failed to load more', - onRetry: () => cubit.loadListsOn(cursor: state.listsOnCursor), + message: state.listsOnError ?? 'Failed to load lists', + onRetry: () => cubit.loadListsOn(), ), ), ) - else if (state.listsOnHasMore) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: TextButton( - onPressed: () => cubit.loadListsOn(cursor: state.listsOnCursor), - child: const Text('Load more'), + else if (state.listsOnStatus == ProfileContextTabStatus.loaded && state.listsOn.isEmpty) + const SliverFillRemaining(hasScrollBody: false, child: Center(child: Text('Not on any lists'))) + else ...[ + ..._buildListSections( + context, + state.listsOn, + (list) => context.push('/list?uri=${Uri.encodeComponent(list.uri.toString())}'), + ), + if (state.listsOnStatus == ProfileContextTabStatus.loading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + ) + else if (state.listsOnStatus == ProfileContextTabStatus.error) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: _ErrorRetry( + message: state.listsOnError ?? 'Failed to load more', + onRetry: () => cubit.loadListsOn(cursor: state.listsOnCursor), ), ), ), - ), + ], ], - ], + ), ), ); } @@ -418,6 +451,271 @@ class _ProfileTile extends StatelessWidget { } } +class _UnavailableAccountsCard extends StatelessWidget { + const _UnavailableAccountsCard({required this.entries}); + + final List entries; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + leading: const Icon(Icons.warning_amber_rounded), + title: Text('Unavailable accounts (${entries.length})'), + subtitle: const Text('These accounts are suspended or their public profile could not be fetched.'), + ), + for (final entry in entries) + ListTile( + dense: true, + leading: Icon(Icons.person_off_outlined, color: colorScheme.onSurfaceVariant), + title: Text(entry.did, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text(entry.reason), + ), + ], + ), + ), + ); + } +} + +class _UnavailableProfileTile extends StatelessWidget { + const _UnavailableProfileTile({super.key, required this.did, required this.reason}); + + final String did; + final String reason; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + leading: Icon(Icons.person_off_outlined, color: colorScheme.onSurfaceVariant), + title: Text(did, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text(reason), + enabled: false, + ); + } +} + +String _profileActor(ProfileView profile) { + final handle = profile.handle.trim(); + return handle.isNotEmpty ? handle : profile.did; +} + +bool _maybeLoadMore({ + required ScrollNotification notification, + required ProfileContextTabStatus status, + required bool hasMore, + required String? cursor, + required ValueChanged onLoadMore, +}) { + if (notification.metrics.extentAfter > 300 || + status == ProfileContextTabStatus.loading || + !hasMore || + cursor == null) { + return false; + } + + onLoadMore(cursor); + return false; +} + +List _buildListSections( + BuildContext context, + List lists, + ValueChanged onTap, +) { + final sections = _groupListsByPurpose(lists); + return [ + for (final section in sections) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + section.title, + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + ), + ), + SliverList.builder( + itemCount: section.lists.length, + itemBuilder: (context, index) { + final list = section.lists[index]; + return _ListContextCard(key: ValueKey('list_on_${list.uri}'), list: list, onTap: () => onTap(list)); + }, + ), + ], + ]; +} + +List<({String title, List lists})> _groupListsByPurpose(List lists) { + final buckets = <_ListPurposeGroup, List>{ + _ListPurposeGroup.curation: [], + _ListPurposeGroup.moderation: [], + _ListPurposeGroup.reference: [], + _ListPurposeGroup.other: [], + }; + + for (final list in lists) { + buckets[_purposeGroupFor(list)]!.add(list); + } + + return [ + (title: 'Curation Lists', lists: buckets[_ListPurposeGroup.curation]!), + (title: 'Moderation Lists', lists: buckets[_ListPurposeGroup.moderation]!), + (title: 'Reference Lists', lists: buckets[_ListPurposeGroup.reference]!), + (title: 'Other Lists', lists: buckets[_ListPurposeGroup.other]!), + ].where((section) => section.lists.isNotEmpty).toList(); +} + +_ListPurposeGroup _purposeGroupFor(bsky_graph.ListView list) { + switch (list.purpose.knownValue) { + case bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist: + return _ListPurposeGroup.curation; + case bsky_graph.KnownListPurpose.appBskyGraphDefsModlist: + return _ListPurposeGroup.moderation; + case bsky_graph.KnownListPurpose.appBskyGraphDefsReferencelist: + return _ListPurposeGroup.reference; + case null: + return _ListPurposeGroup.other; + } +} + +enum _ListPurposeGroup { curation, moderation, reference, other } + +class _ListContextCard extends StatelessWidget { + const _ListContextCard({super.key, required this.list, this.onTap}); + + final bsky_graph.ListView list; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final description = list.description?.trim(); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + backgroundImage: list.avatar != null ? NetworkImage(list.avatar!) : null, + backgroundColor: colorScheme.surfaceContainerHighest, + child: list.avatar == null ? Icon(Icons.list, color: colorScheme.onSurfaceVariant) : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(list.name, maxLines: 1, overflow: TextOverflow.ellipsis), + const SizedBox(height: 4), + Text( + '@${list.creator.handle}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + const SizedBox(width: 12), + _ListPurposeBadge(purpose: list.purpose.knownValue), + ], + ), + const SizedBox(height: 12), + _ListMetaChip( + icon: Icons.group_outlined, + label: '${list.listItemCount ?? 0} member${(list.listItemCount ?? 0) == 1 ? '' : 's'}', + ), + if (description != null && description.isNotEmpty) ...[ + const SizedBox(height: 12), + Text(description, maxLines: 2, overflow: TextOverflow.ellipsis), + ], + ], + ), + ), + ), + ), + ); + } +} + +class _ListPurposeBadge extends StatelessWidget { + const _ListPurposeBadge({required this.purpose}); + + final bsky_graph.KnownListPurpose? purpose; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final (label, color) = switch (purpose) { + bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist => ('CURATE', colorScheme.primary), + bsky_graph.KnownListPurpose.appBskyGraphDefsModlist => ('MOD', colorScheme.error), + bsky_graph.KnownListPurpose.appBskyGraphDefsReferencelist => ('REFERENCE', colorScheme.tertiary), + null => ('LIST', colorScheme.secondary), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.25)), + ), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700, color: color, letterSpacing: 0.6), + ), + ); + } +} + +class _ListMetaChip extends StatelessWidget { + const _ListMetaChip({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return DecoratedBox( + decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(999)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 6), + Text(label, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + ], + ), + ), + ); + } +} + // --------------------------------------------------------------------------- // Shimmer skeleton // --------------------------------------------------------------------------- diff --git a/lib/main.dart b/lib/main.dart index 83bb45c..88eb770 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -66,15 +66,7 @@ Future main() async { log.i('AppLogger: App started'); - runApp( - LazuriteApp( - authBloc: authBloc, - database: database, - settingsCubit: settingsCubit, - connectivityCubit: connectivityCubit, - accountSwitcherCubit: accountSwitcherCubit, - ), - ); + runApp(LazuriteApp.from(authBloc, database, settingsCubit, connectivityCubit, accountSwitcherCubit)); } class LazuriteApp extends StatefulWidget { @@ -93,6 +85,21 @@ class LazuriteApp extends StatefulWidget { final ConnectivityCubit connectivityCubit; final AccountSwitcherCubit accountSwitcherCubit; + /// factory constructor with positional params + static LazuriteApp from( + AuthBloc authBloc, + AppDatabase database, + SettingsCubit settingsCubit, + ConnectivityCubit connectivityCubit, + AccountSwitcherCubit accountSwitcherCubit, + ) => LazuriteApp( + authBloc: authBloc, + database: database, + settingsCubit: settingsCubit, + connectivityCubit: connectivityCubit, + accountSwitcherCubit: accountSwitcherCubit, + ); + @override State createState() => _LazuriteAppState(); } diff --git a/scripts/package.json b/scripts/package.json index 60d0775..8164bdb 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -8,7 +8,8 @@ "scripts": { "start": "bun run index.ts", "build": "bun build index.ts --outfile=dist/splash-gen --target=bun", - "generate": "bun run index.ts" + "generate": "bun run index.ts", + "profile-context": "bun run profile-context.ts" }, "devDependencies": { "@types/bun": "latest" diff --git a/scripts/profile-context.ts b/scripts/profile-context.ts new file mode 100644 index 0000000..0b60b49 --- /dev/null +++ b/scripts/profile-context.ts @@ -0,0 +1,318 @@ +#!/usr/bin/env bun + +import { parseArgs } from "util"; + +const MICROCOSM_XRPC_BASE = "https://constellation.microcosm.blue/xrpc"; +const PUBLIC_BSKY_XRPC_BASE = "https://public.api.bsky.app/xrpc"; +const BLOCK_SOURCE = "app.bsky.graph.block:subject"; +const BACKLINK_LIMIT = 16; +const PROFILE_BATCH_SIZE = 25; + +type JsonMap = Record; + +type FetchResult = { + status: number; + ok: boolean; + text: string; + data: JsonMap | null; +}; + +type OrderedEntry = + | { + did: string; + status: "resolved"; + handle: string; + displayName: string | null; + } + | { + did: string; + status: "unavailable"; + reason: string; + }; + +function buildXrpcUrl(base: string, method: string, params: Record): URL { + const url = new URL(`${base}/${method}`); + for (const [key, value] of Object.entries(params)) { + if (value !== null && value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + return url; +} + +async function fetchJson(url: URL): Promise { + const response = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": "lazurite", + }, + }); + const text = await response.text(); + + let data: JsonMap | null = null; + try { + data = text.length > 0 ? (JSON.parse(text) as JsonMap) : null; + } catch { + data = null; + } + + return { + status: response.status, + ok: response.ok, + text, + data, + }; +} + +function getListFieldAny(data: JsonMap | null, keys: string[]): unknown[] { + if (!data) return []; + + for (const key of keys) { + const value = data[key]; + if (Array.isArray(value)) { + return value; + } + } + + return []; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function classifyProfileFailure(result: FetchResult): string { + const error = asString(result.data?.error); + const message = asString(result.data?.message) ?? result.text; + const combined = `${error ?? ""} ${message}`.toLowerCase(); + + if (combined.includes("accounttakedown") || combined.includes("suspended")) { + return "Suspended account"; + } + if (result.status === 404 || combined.includes("not found")) { + return "Profile unavailable"; + } + return "Public profile lookup failed"; +} + +function chunk(items: T[], size: number): T[][] { + const output: T[][] = []; + for (let index = 0; index < items.length; index += size) { + output.push(items.slice(index, index + size)); + } + return output; +} + +async function fetchBlockedByCount(did: string): Promise { + const url = buildXrpcUrl(MICROCOSM_XRPC_BASE, "blue.microcosm.links.getBacklinksCount", { + subject: did, + source: BLOCK_SOURCE, + }); + const result = await fetchJson(url); + + if (!result.ok || typeof result.data?.total !== "number") { + throw new Error(`getBacklinksCount failed (${result.status}): ${result.text}`); + } + + return result.data.total as number; +} + +async function fetchDistinct(did: string): Promise { + const url = buildXrpcUrl(MICROCOSM_XRPC_BASE, "blue.microcosm.links.getDistinct", { + subject: did, + source: BLOCK_SOURCE, + limit: BACKLINK_LIMIT, + }); + return fetchJson(url); +} + +async function fetchAllBacklinkDids( + did: string, +): Promise<{ orderedDids: string[]; pages: Array<{ page: number; cursorIn: string | null; cursorOut: string | null; recordCount: number }> }> { + const orderedDids: string[] = []; + const seen = new Set(); + const pages: Array<{ page: number; cursorIn: string | null; cursorOut: string | null; recordCount: number }> = []; + + let cursor: string | null = null; + let page = 1; + + do { + const url = buildXrpcUrl(MICROCOSM_XRPC_BASE, "blue.microcosm.links.getBacklinks", { + subject: did, + source: BLOCK_SOURCE, + limit: BACKLINK_LIMIT, + cursor, + }); + const result = await fetchJson(url); + if (!result.ok) { + throw new Error(`getBacklinks failed (${result.status}) on page ${page}: ${result.text}`); + } + + const records = getListFieldAny(result.data, ["records", "linking_records"]); + for (const record of records) { + if (record && typeof record === "object") { + const blockerDid = asString((record as JsonMap).did); + if (blockerDid && !seen.has(blockerDid)) { + seen.add(blockerDid); + orderedDids.push(blockerDid); + } + } + } + + const nextCursor = asString(result.data?.cursor); + pages.push({ + page, + cursorIn: cursor, + cursorOut: nextCursor, + recordCount: records.length, + }); + cursor = nextCursor; + page += 1; + } while (cursor !== null); + + return { orderedDids, pages }; +} + +async function fetchProfilesBatch(dids: string[]): Promise> { + const resolved = new Map(); + + for (const batch of chunk(dids, PROFILE_BATCH_SIZE)) { + const url = new URL(`${PUBLIC_BSKY_XRPC_BASE}/app.bsky.actor.getProfiles`); + for (const did of batch) { + url.searchParams.append("actors", did); + } + + const batchResult = await fetchJson(url); + const batchProfiles = batchResult.ok ? getListFieldAny(batchResult.data, ["profiles"]) : []; + const batchResolved = new Map(); + + for (const profile of batchProfiles) { + if (!profile || typeof profile !== "object") continue; + const record = profile as JsonMap; + const did = asString(record.did); + const handle = asString(record.handle); + if (!did || !handle) continue; + + batchResolved.set(did, { + did, + status: "resolved", + handle, + displayName: asString(record.displayName), + }); + } + + for (const did of batch) { + const existing = batchResolved.get(did); + if (existing) { + resolved.set(did, existing); + continue; + } + + const singleUrl = buildXrpcUrl(PUBLIC_BSKY_XRPC_BASE, "app.bsky.actor.getProfile", { + actor: did, + }); + const singleResult = await fetchJson(singleUrl); + + if (singleResult.ok) { + const handle = asString(singleResult.data?.handle); + if (handle) { + resolved.set(did, { + did, + status: "resolved", + handle, + displayName: asString(singleResult.data?.displayName), + }); + continue; + } + } + + resolved.set(did, { + did, + status: "unavailable", + reason: classifyProfileFailure(singleResult), + }); + } + } + + return resolved; +} + +function printUsage(): void { + console.error("Usage: bun run profile-context.ts --did "); +} + +async function main(): Promise { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + did: { + type: "string", + }, + help: { + type: "boolean", + short: "h", + default: false, + }, + }, + strict: true, + allowPositionals: false, + }); + + if (values.help) { + printUsage(); + process.exit(0); + } + + const did = asString(values.did); + if (!did) { + printUsage(); + process.exit(1); + } + + const count = await fetchBlockedByCount(did); + const distinct = await fetchDistinct(did); + const backlinks = await fetchAllBacklinkDids(did); + const hydrated = await fetchProfilesBatch(backlinks.orderedDids); + const orderedEntries = backlinks.orderedDids.map((blockerDid) => { + return ( + hydrated.get(blockerDid) ?? { + did: blockerDid, + status: "unavailable" as const, + reason: "Public profile lookup failed", + } + ); + }); + + const resolvedCount = orderedEntries.filter((entry) => entry.status === "resolved").length; + const unavailableEntries = orderedEntries.filter((entry) => entry.status === "unavailable"); + + console.log(`Target DID: ${did}`); + console.log(`Microcosm blocked-by count: ${count}`); + console.log( + `Microcosm getDistinct: HTTP ${distinct.status}${distinct.ok ? "" : ` ${distinct.text.trim()}`}`, + ); + console.log(""); + console.log("Backlink pages:"); + for (const page of backlinks.pages) { + console.log( + ` page ${page.page}: cursorIn=${page.cursorIn ?? "null"} records=${page.recordCount} cursorOut=${page.cursorOut ?? "null"}`, + ); + } + console.log(""); + console.log(`Collected blocker DIDs: ${backlinks.orderedDids.length}`); + console.log(`Resolved public profiles: ${resolvedCount}`); + console.log(`Unavailable public profiles: ${unavailableEntries.length}`); + console.log(""); + console.log("Ordered results:"); + orderedEntries.forEach((entry, index) => { + if (entry.status === "resolved") { + const display = entry.displayName ? ` (${entry.displayName})` : ""; + console.log(`${String(index + 1).padStart(2, "0")}. RESOLVED ${entry.did} -> @${entry.handle}${display}`); + return; + } + + console.log(`${String(index + 1).padStart(2, "0")}. UNAVAILABLE ${entry.did} -> ${entry.reason}`); + }); +} + +await main(); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index 7664559..aa8765e 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -17,6 +17,7 @@ "noImplicitOverride": true, "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false + "noPropertyAccessFromIndexSignature": false, + "types": ["bun"] } } diff --git a/test/core/network/constellation_client_test.dart b/test/core/network/constellation_client_test.dart index f261060..6052887 100644 --- a/test/core/network/constellation_client_test.dart +++ b/test/core/network/constellation_client_test.dart @@ -39,6 +39,24 @@ void main() { expect(capturedUri?.host, 'my.instance.example'); }); + test('trims trailing slashes from custom base URL', () async { + Uri? capturedUri; + final client = ConstellationClient( + baseUrl: 'https://my.instance.example///', + httpClient: MockClient((request) async { + capturedUri = request.url; + return http.Response(jsonEncode({'total': 0}), 200); + }), + ); + + await client.getBacklinksCount('did:plc:test', 'app.bsky.graph.block:subject'); + + expect( + capturedUri.toString(), + 'https://my.instance.example/xrpc/blue.microcosm.links.getBacklinksCount?subject=did%3Aplc%3Atest&source=app.bsky.graph.block%3Asubject', + ); + }); + test('builds XRPC path correctly', () async { Uri? capturedUri; final client = ConstellationClient( @@ -142,6 +160,15 @@ void main() { expect(result.cursor, isNull); }); + test('treats null dids as empty list', () async { + final responseBody = jsonEncode({'total': 1, 'dids': null}); + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); + + final result = await client.getDistinct('did:plc:abc', 'source'); + + expect(result.dids, isEmpty); + }); + test('passes limit and cursor as query parameters', () async { Uri? capturedUri; final client = ConstellationClient( @@ -180,6 +207,27 @@ void main() { }); group('getBacklinks', () { + test('prefers records payload when returned by Microcosm', () async { + final responseBody = jsonEncode({ + 'total': 2, + 'records': [ + {'did': 'did:plc:aaa', 'collection': 'app.bsky.graph.block', 'rkey': 'rkey1'}, + {'did': 'did:plc:bbb', 'collection': 'app.bsky.graph.block', 'rkey': 'rkey2'}, + ], + 'linking_records': [ + {'did': 'did:plc:ignored', 'collection': 'app.bsky.graph.block', 'rkey': 'ignored'}, + ], + 'cursor': 'cursor-xyz', + }); + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); + + final result = await client.getBacklinks('did:plc:abc', 'app.bsky.graph.block:subject'); + + expect(result.total, 2); + expect(result.records.map((record) => record.did).toList(), ['did:plc:aaa', 'did:plc:bbb']); + expect(result.cursor, 'cursor-xyz'); + }); + test('returns total, records and cursor from response', () async { final responseBody = jsonEncode({ 'total': 2, @@ -211,6 +259,15 @@ void main() { expect(result.cursor, isNull); }); + test('treats null linking_records as empty list', () async { + final responseBody = jsonEncode({'total': 0, 'linking_records': null}); + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); + + final result = await client.getBacklinks('did:plc:abc', 'source'); + + expect(result.records, isEmpty); + }); + test('passes limit and cursor as query parameters', () async { Uri? capturedUri; final client = ConstellationClient( @@ -278,6 +335,15 @@ void main() { expect(result.cursor, isNull); }); + test('treats null items as empty list', () async { + final responseBody = jsonEncode({'items': null}); + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); + + final result = await client.getManyToMany('did:plc:abc', 'source', 'list'); + + expect(result.items, isEmpty); + }); + test('throws ConstellationException on error response', () async { final client = ConstellationClient(httpClient: MockClient((_) async => http.Response('Bad Request', 400))); diff --git a/test/features/profile/bloc/profile_bloc_test.dart b/test/features/profile/bloc/profile_bloc_test.dart new file mode 100644 index 0000000..13dfeff --- /dev/null +++ b/test/features/profile/bloc/profile_bloc_test.dart @@ -0,0 +1,38 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; +import 'package:lazurite/features/profile/data/profile_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockProfileRepository extends Mock implements ProfileRepository {} + +ProfileViewDetailed _profile({String did = 'did:plc:alice', String handle = 'alice.bsky.social'}) { + return ProfileViewDetailed(did: did, handle: handle, indexedAt: DateTime.utc(2026)); +} + +void main() { + late MockProfileRepository repository; + + setUp(() { + repository = MockProfileRepository(); + }); + + blocTest( + 'refresh uses handle when available', + build: () => ProfileBloc(profileRepository: repository), + seed: () => ProfileState.loaded(profile: _profile()), + setUp: () { + when(() => repository.getProfile('alice.bsky.social')).thenAnswer((_) async => _profile()); + }, + act: (bloc) => bloc.add(const ProfileRefreshRequested()), + expect: () => [ + predicate((state) => state.isRefreshing), + predicate((state) => state.status == ProfileStatus.loaded && !state.isRefreshing), + ], + verify: (_) { + verify(() => repository.getProfile('alice.bsky.social')).called(1); + verifyNever(() => repository.getProfile('did:plc:alice')); + }, + ); +} diff --git a/test/features/profile/cubit/profile_context_cubit_test.dart b/test/features/profile/cubit/profile_context_cubit_test.dart index ddf573a..897632d 100644 --- a/test/features/profile/cubit/profile_context_cubit_test.dart +++ b/test/features/profile/cubit/profile_context_cubit_test.dart @@ -63,14 +63,31 @@ void main() { ); blocTest( - 'emits nothing when counts fail (best-effort)', - build: buildCubit, + 'loads blockingCount too for own profile', + build: () => buildCubit(isOwnProfile: true), + setUp: () { + when(() => mockRepository.getBlockedByCount(_did)).thenAnswer((_) async => 42); + when(() => mockRepository.getListsOnCount(_did)).thenAnswer((_) async => 7); + when(() => mockRepository.getBlockingCount(_did)).thenAnswer((_) async => 3); + }, + act: (cubit) => cubit.init(), + expect: () => [ + predicate((s) => s.blockedByCount == 42 && s.listsOnCount == 7 && s.blockingCount == 3), + ], + ); + + blocTest( + 'preserves successful counts when one loader fails', + build: () => buildCubit(isOwnProfile: true), setUp: () { when(() => mockRepository.getBlockedByCount(any())).thenThrow(Exception('network error')); - when(() => mockRepository.getListsOnCount(any())).thenThrow(Exception('network error')); + when(() => mockRepository.getListsOnCount(_did)).thenAnswer((_) async => 7); + when(() => mockRepository.getBlockingCount(_did)).thenAnswer((_) async => 3); }, act: (cubit) => cubit.init(), - expect: () => [], + expect: () => [ + predicate((s) => s.blockedByCount == 0 && s.listsOnCount == 7 && s.blockingCount == 3), + ], ); }); @@ -82,9 +99,16 @@ void main() { 'emits loading then loaded with profiles on success', build: buildCubit, setUp: () { - when( - () => mockRepository.getBlockedByProfiles(_did, cursor: null), - ).thenAnswer((_) async => (profiles: [profile1, profile2], cursor: 'next', total: 10)); + when(() => mockRepository.getBlockedByProfiles(_did, cursor: null)).thenAnswer( + (_) async => ( + entries: [ + BlockedByEntry.profile(profile: profile1), + BlockedByEntry.profile(profile: profile2), + ], + cursor: 'next', + total: 10, + ), + ); }, act: (cubit) => cubit.loadBlockedBy(), expect: () => [ @@ -92,7 +116,7 @@ void main() { predicate( (s) => s.blockedByStatus == ProfileContextTabStatus.loaded && - s.blockedByProfiles.length == 2 && + s.blockedByEntries.length == 2 && s.blockedByCount == 10 && s.blockedByCursor == 'next' && s.blockedByHasMore, @@ -105,14 +129,14 @@ void main() { build: buildCubit, seed: () => const ProfileContextState.initial(did: _did, isOwnProfile: false).copyWith( blockedByStatus: ProfileContextTabStatus.loaded, - blockedByProfiles: [_profile('did:plc:first')], + blockedByEntries: [BlockedByEntry.profile(profile: _profile('did:plc:first'))], blockedByCursor: 'cursor-1', blockedByHasMore: true, ), setUp: () { when( () => mockRepository.getBlockedByProfiles(_did, cursor: 'cursor-1'), - ).thenAnswer((_) async => (profiles: [profile1], cursor: null, total: 2)); + ).thenAnswer((_) async => (entries: [BlockedByEntry.profile(profile: profile1)], cursor: null, total: 2)); }, act: (cubit) => cubit.loadBlockedBy(cursor: 'cursor-1'), expect: () => [ @@ -120,13 +144,43 @@ void main() { predicate( (s) => s.blockedByStatus == ProfileContextTabStatus.loaded && - s.blockedByProfiles.length == 2 && + s.blockedByEntries.length == 2 && s.blockedByCursor == null && !s.blockedByHasMore, ), ], ); + blocTest( + 'tracks unavailable blocked-by accounts without breaking loaded state', + build: buildCubit, + setUp: () { + when(() => mockRepository.getBlockedByProfiles(_did, cursor: null)).thenAnswer( + (_) async => ( + entries: [ + BlockedByEntry.profile( + profile: const ProfileView(did: 'did:plc:blocker1', handle: 'did:plc:blocker1.bsky.social'), + ), + const BlockedByEntry.unavailable(did: 'did:plc:suspended', unavailableReason: 'Suspended account'), + ], + cursor: null, + total: 2, + ), + ); + }, + act: (cubit) => cubit.loadBlockedBy(), + expect: () => [ + predicate((s) => s.blockedByStatus == ProfileContextTabStatus.loading), + predicate( + (s) => + s.blockedByStatus == ProfileContextTabStatus.loaded && + s.blockedByEntries.length == 2 && + s.blockedByEntries.last.did == 'did:plc:suspended' && + s.blockedByEntries.last.unavailableReason == 'Suspended account', + ), + ], + ); + blocTest( 'emits error state on failure', build: buildCubit, @@ -142,7 +196,7 @@ void main() { (s) => s.blockedByStatus == ProfileContextTabStatus.error && s.blockedByError != null && - s.blockedByProfiles.isEmpty, + s.blockedByEntries.isEmpty, ), ], ); @@ -166,9 +220,9 @@ void main() { 'emits loading then loaded with profiles on success for own profile', build: () => buildCubit(isOwnProfile: true), setUp: () { - when( - () => mockRepository.getBlockingProfiles(_did, cursor: null), - ).thenAnswer((_) async => (profiles: [profile1], cursor: 'next', total: 1)); + when(() => mockRepository.getBlockingProfiles(_did, cursor: null)).thenAnswer( + (_) async => (profiles: [profile1], unavailable: [], cursor: 'next', total: 1), + ); }, act: (cubit) => cubit.loadBlocking(), expect: () => [ @@ -202,9 +256,9 @@ void main() { blockingCount: 1, ), setUp: () { - when( - () => mockRepository.getBlockingProfiles(_did, cursor: 'cursor-1'), - ).thenAnswer((_) async => (profiles: [profile1], cursor: null, total: 1)); + when(() => mockRepository.getBlockingProfiles(_did, cursor: 'cursor-1')).thenAnswer( + (_) async => (profiles: [profile1], unavailable: [], cursor: null, total: 1), + ); }, act: (cubit) => cubit.loadBlocking(cursor: 'cursor-1'), expect: () => [ @@ -213,6 +267,22 @@ void main() { ], ); + blocTest( + 'does not shrink blockingCount when a known total already exists', + build: () => buildCubit(isOwnProfile: true), + seed: () => const ProfileContextState.initial(did: _did, isOwnProfile: true).copyWith(blockingCount: 5), + setUp: () { + when(() => mockRepository.getBlockingProfiles(_did, cursor: null)).thenAnswer( + (_) async => (profiles: [profile1], unavailable: [], cursor: 'next', total: 1), + ); + }, + act: (cubit) => cubit.loadBlocking(), + expect: () => [ + predicate((s) => s.blockingStatus == ProfileContextTabStatus.loading), + predicate((s) => s.blockingProfiles.length == 1 && s.blockingCount == 5), + ], + ); + blocTest( 'emits error state on failure', build: () => buildCubit(isOwnProfile: true), diff --git a/test/features/profile/data/profile_context_repository_test.dart b/test/features/profile/data/profile_context_repository_test.dart index 30f78e9..a1d6c0a 100644 --- a/test/features/profile/data/profile_context_repository_test.dart +++ b/test/features/profile/data/profile_context_repository_test.dart @@ -13,6 +13,16 @@ ProfileView _buildProfileView(String did, String handle) { return ProfileView(did: did, handle: handle, indexedAt: DateTime.utc(2026, 1, 1)); } +ProfileViewDetailed _buildProfileViewDetailed(String did, String handle) { + return ProfileViewDetailed( + did: did, + handle: handle, + displayName: 'Detailed $did', + description: 'Bio for $did', + indexedAt: DateTime.utc(2026, 1, 1), + ); +} + ListView _buildListView(String uriStr, String name) { return ListView( uri: AtUri.parse(uriStr), @@ -48,23 +58,82 @@ class _FakeAtProto { } class _FakeActorService { - _FakeActorService({required this.profiles}); + _FakeActorService({required this.profiles, Map? profileByActor, Map? errorsByActor}) + : _profileByActor = profileByActor ?? const {}, + _errorsByActor = errorsByActor ?? const {}; - final List profiles; + final List profiles; + final Map _profileByActor; + final Map _errorsByActor; Future<_FakeResponse<_FakeProfilesOutput>> getProfiles({ required List actors, + String? $service, Map? $headers, }) async { final matched = profiles.where((p) => actors.contains(p.did) || actors.contains(p.handle)).toList(); return _FakeResponse(_FakeProfilesOutput(matched)); } + + Future<_FakeResponse> getProfile({ + required String actor, + String? $service, + Map? $headers, + }) async { + final error = _errorsByActor[actor]; + if (error != null) throw error; + final profile = _profileByActor[actor]; + if (profile == null) throw Exception('Profile not found: $actor'); + return _FakeResponse(profile); + } +} + +class _ThrowingActorService { + Future<_FakeResponse<_FakeProfilesOutput>> getProfiles({ + required List actors, + String? $service, + Map? $headers, + }) async { + throw Exception('Authenticated actor client should not be used for public profile hydration'); + } + + Future<_FakeResponse> getProfile({ + required String actor, + String? $service, + Map? $headers, + }) async { + throw Exception('Authenticated actor client should not be used for public profile hydration'); + } +} + +class _BatchThrowingActorService { + _BatchThrowingActorService({required Map profileByActor}) : _profileByActor = profileByActor; + + final Map _profileByActor; + + Future<_FakeResponse<_FakeProfilesOutput>> getProfiles({ + required List actors, + String? $service, + Map? $headers, + }) async { + throw Exception('Batch lookup failed'); + } + + Future<_FakeResponse> getProfile({ + required String actor, + String? $service, + Map? $headers, + }) async { + final profile = _profileByActor[actor]; + if (profile == null) throw Exception('Profile not found: $actor'); + return _FakeResponse(profile); + } } class _FakeProfilesOutput { _FakeProfilesOutput(this.profiles); - final List profiles; + final List profiles; } class _FakeGraphService { @@ -75,6 +144,7 @@ class _FakeGraphService { Future<_FakeResponse<_FakeGetListOutput>> getList({ required AtUri list, int limit = 50, + String? $service, Map? $headers, }) async { final listView = lists[list.toString()]; @@ -83,6 +153,17 @@ class _FakeGraphService { } } +class _ThrowingGraphService { + Future<_FakeResponse<_FakeGetListOutput>> getList({ + required AtUri list, + int limit = 50, + String? $service, + Map? $headers, + }) async { + throw Exception('Authenticated graph client should not be used for public list hydration'); + } +} + class _FakeGetListOutput { _FakeGetListOutput(this.list); @@ -174,24 +255,248 @@ void main() { final result = await repo.getBlockedByProfiles('did:plc:target'); expect(result.total, 2); - expect(result.profiles.length, 2); - expect(result.profiles.map((p) => p.did), containsAll(['did:plc:alice', 'did:plc:bob'])); + expect(result.entries, hasLength(2)); + expect(result.entries.map((entry) => entry.did), containsAll(['did:plc:alice', 'did:plc:bob'])); + expect(result.entries.every((entry) => entry.isAvailable), isTrue); expect(result.cursor, isNull); }); - test('passes cursor to getDistinct and returns cursor from response', () async { - String? capturedCursor; + test('converts detailed profiles returned by getProfiles into ProfileView', () async { + final aliceProfile = _buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social'); + + final constellation = _constellationWithResponses((_) { + return { + 'total': 1, + 'dids': ['did:plc:alice'], + }; + }); + + final repo = ProfileContextRepository( + bluesky: _buildBluesky(profiles: [aliceProfile]), + constellationClient: constellation, + ); + + final result = await repo.getBlockedByProfiles('did:plc:target'); + + expect(result.entries, hasLength(1)); + expect(result.entries.first.profile?.did, 'did:plc:alice'); + expect(result.entries.first.profile?.handle, 'alice.bsky.social'); + expect(result.entries.first.profile?.displayName, 'Detailed did:plc:alice'); + }); + + test('falls back to getProfile for DIDs missing from getProfiles', () async { + final repo = ProfileContextRepository( + bluesky: _FakeBluesky( + actor: _ThrowingActorService(), + graph: _FakeGraphService(), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), + publicBluesky: _FakeBluesky( + actor: _FakeActorService( + profiles: const [], + profileByActor: {'did:plc:alice': _buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social')}, + ), + graph: _FakeGraphService(), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), + constellationClient: _constellationWithResponses((_) { + return { + 'total': 1, + 'dids': ['did:plc:alice'], + }; + }), + ); + + final result = await repo.getBlockedByProfiles('did:plc:target'); + + expect(result.entries, hasLength(1)); + expect(result.entries.first.profile?.did, 'did:plc:alice'); + expect(result.entries.first.profile?.handle, 'alice.bsky.social'); + }); + + test('falls back to per-DID getProfile when batch getProfiles fails', () async { + final repo = ProfileContextRepository( + bluesky: _FakeBluesky( + actor: _ThrowingActorService(), + graph: _FakeGraphService(), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), + publicBluesky: _FakeBluesky( + actor: _BatchThrowingActorService( + profileByActor: {'did:plc:alice': _buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social')}, + ), + graph: _FakeGraphService(), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), + constellationClient: _constellationWithResponses((_) { + return { + 'total': 2, + 'dids': ['did:plc:alice', 'did:plc:suspended'], + }; + }), + ); + + final result = await repo.getBlockedByProfiles('did:plc:target'); + + expect(result.total, 2); + expect(result.entries, hasLength(2)); + expect(result.entries.first.profile?.did, 'did:plc:alice'); + expect(result.entries.last.did, 'did:plc:suspended'); + expect(result.entries.last.unavailableReason, 'Profile unavailable'); + }); + + test('uses public bluesky client for batch profile hydration', () async { + final repo = ProfileContextRepository( + bluesky: _FakeBluesky( + actor: _ThrowingActorService(), + graph: _FakeGraphService(), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), + publicBluesky: _FakeBluesky( + actor: _FakeActorService(profiles: [_buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social')]), + graph: _FakeGraphService(), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), + constellationClient: _constellationWithResponses((_) { + return { + 'total': 1, + 'dids': ['did:plc:alice'], + }; + }), + ); + + await repo.getBlockedByProfiles('did:plc:target'); + }); + + test('uses local offset cursor after collecting blocked-by DIDs', () async { + var getDistinctCalls = 0; + String? capturedLimit; final constellation = _constellationWithResponses((uri) { - capturedCursor = uri.queryParameters['cursor']; - return {'total': 10, 'dids': [], 'cursor': 'page2'}; + getDistinctCalls += 1; + capturedLimit = uri.queryParameters['limit']; + if (getDistinctCalls == 1) { + expect(uri.queryParameters['cursor'], isNull); + return { + 'total': 3, + 'dids': ['did:plc:one', 'did:plc:two'], + 'cursor': 'page2', + }; + } + expect(uri.queryParameters['cursor'], 'page2'); + return { + 'total': 3, + 'dids': ['did:plc:three'], + 'cursor': null, + }; }); - final repo = ProfileContextRepository(bluesky: _buildBluesky(), constellationClient: constellation); + final repo = ProfileContextRepository( + bluesky: _buildBluesky( + profiles: [ + _buildProfileView('did:plc:one', 'one.bsky.social'), + _buildProfileView('did:plc:two', 'two.bsky.social'), + _buildProfileView('did:plc:three', 'three.bsky.social'), + ], + ), + constellationClient: constellation, + ); - final result = await repo.getBlockedByProfiles('did:plc:target', cursor: 'page1'); + final result = await repo.getBlockedByProfiles('did:plc:target', cursor: '2'); - expect(capturedCursor, 'page1'); - expect(result.cursor, 'page2'); + expect(getDistinctCalls, 2); + expect(capturedLimit, '16'); + expect(result.entries.map((entry) => entry.did), ['did:plc:three']); + expect(result.cursor, isNull); + }); + + test('falls back to getBacklinks when getDistinct returns 404', () async { + final aliceProfile = _buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social'); + final bobProfile = _buildProfileViewDetailed('did:plc:bob', 'bob.bsky.social'); + + final constellation = ConstellationClient( + httpClient: MockClient((request) async { + if (request.url.path.contains('getDistinct')) { + return http.Response('Not Found', 404); + } + + if (request.url.path.contains('getBacklinks')) { + return http.Response( + jsonEncode({ + 'total': 3, + 'records': [ + {'did': 'did:plc:alice', 'collection': 'app.bsky.graph.block', 'rkey': '1'}, + {'did': 'did:plc:bob', 'collection': 'app.bsky.graph.block', 'rkey': '2'}, + {'did': 'did:plc:alice', 'collection': 'app.bsky.graph.block', 'rkey': '3'}, + ], + }), + 200, + ); + } + + throw Exception('Unexpected endpoint: ${request.url.path}'); + }), + ); + + final repo = ProfileContextRepository( + bluesky: _buildBluesky(profiles: [aliceProfile, bobProfile]), + constellationClient: constellation, + ); + + final result = await repo.getBlockedByProfiles('did:plc:target'); + + expect(result.total, 3); + expect(result.cursor, isNull); + expect(result.entries.map((entry) => entry.did).toList(), ['did:plc:alice', 'did:plc:bob']); + }); + + test('collects blocked-by DIDs across pages before hydrating', () async { + var getDistinctCalls = 0; + final repo = ProfileContextRepository( + bluesky: _FakeBluesky( + actor: _ThrowingActorService(), + graph: _FakeGraphService(), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), + publicBluesky: _FakeBluesky( + actor: _FakeActorService( + profiles: [_buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social')], + profileByActor: const {}, + ), + graph: _FakeGraphService(), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), + constellationClient: _constellationWithResponses((uri) { + if (!uri.path.contains('getDistinct')) { + throw Exception('Unexpected endpoint: ${uri.path}'); + } + + getDistinctCalls += 1; + if (getDistinctCalls == 1) { + expect(uri.queryParameters['cursor'], isNull); + return { + 'total': 23, + 'dids': ['did:plc:suspended'], + 'cursor': 'page-2', + }; + } + + expect(uri.queryParameters['cursor'], 'page-2'); + return { + 'total': 23, + 'dids': ['did:plc:alice'], + 'cursor': null, + }; + }), + ); + + final result = await repo.getBlockedByProfiles('did:plc:target'); + + expect(getDistinctCalls, 2); + expect(result.total, 23); + expect(result.cursor, isNull); + expect(result.entries.map((entry) => entry.did), ['did:plc:suspended', 'did:plc:alice']); + expect(result.entries.first.unavailableReason, 'Profile unavailable'); + expect(result.entries.last.profile?.did, 'did:plc:alice'); }); test('hydrates profiles in batches of 25', () async { @@ -213,7 +518,8 @@ void main() { final result = await repo.getBlockedByProfiles('did:plc:target'); expect(batchSizes, [25, 5]); - expect(result.profiles.length, 30); + expect(result.entries.length, 16); + expect(result.cursor, '16'); }); test('returns empty profiles when no DIDs returned', () async { @@ -222,9 +528,113 @@ void main() { final result = await repo.getBlockedByProfiles('did:plc:target'); - expect(result.profiles, isEmpty); + expect(result.entries, isEmpty); expect(result.total, 0); }); + + test('preserves blocked-by order across two local pages and marks suspended accounts inline', () async { + const unavailableDidOne = 'did:plc:tsy5hmbt5lruc5jjaqglajtp'; + const unavailableDidTwo = 'did:plc:hcton3oag5fbmtx2r55wduei'; + final orderedDids = [ + 'did:plc:blocker00', + 'did:plc:blocker01', + 'did:plc:blocker02', + 'did:plc:blocker03', + unavailableDidOne, + 'did:plc:blocker05', + 'did:plc:blocker06', + 'did:plc:blocker07', + 'did:plc:blocker08', + 'did:plc:blocker09', + 'did:plc:blocker10', + 'did:plc:blocker11', + 'did:plc:blocker12', + 'did:plc:blocker13', + 'did:plc:blocker14', + 'did:plc:blocker15', + 'did:plc:blocker16', + 'did:plc:blocker17', + 'did:plc:blocker18', + unavailableDidTwo, + 'did:plc:blocker20', + 'did:plc:blocker21', + 'did:plc:blocker22', + ]; + final resolvedProfiles = orderedDids + .where((did) => did != unavailableDidOne && did != unavailableDidTwo) + .map((did) => _buildProfileViewDetailed(did, '$did.bsky.social')) + .toList(); + + final constellation = ConstellationClient( + httpClient: MockClient((request) async { + if (request.url.path.contains('getDistinct')) { + return http.Response('Not Found', 404); + } + + if (request.url.path.contains('getBacklinks')) { + final cursor = request.url.queryParameters['cursor']; + final pageDids = cursor == null ? orderedDids.take(16).toList() : orderedDids.skip(16).toList(); + return http.Response( + jsonEncode({ + 'total': 23, + 'records': [ + for (var i = 0; i < pageDids.length; i++) + {'did': pageDids[i], 'collection': 'app.bsky.graph.block', 'rkey': '${cursor ?? 'page-1'}-$i'}, + ], + 'cursor': cursor == null ? '190209' : null, + }), + 200, + ); + } + + throw Exception('Unexpected endpoint: ${request.url.path}'); + }), + ); + + final repo = ProfileContextRepository( + bluesky: _FakeBluesky( + actor: _ThrowingActorService(), + graph: _FakeGraphService(), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), + publicBluesky: _FakeBluesky( + actor: _FakeActorService( + profiles: resolvedProfiles, + errorsByActor: const { + unavailableDidOne: ConstellationException('HTTP 400: AccountTakedown'), + unavailableDidTwo: ConstellationException('HTTP 400: AccountTakedown'), + }, + ), + graph: _FakeGraphService(), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), + constellationClient: constellation, + ); + + final firstPage = await repo.getBlockedByProfiles('did:plc:xg2vq45muivyy3xwatcehspu'); + final secondPage = await repo.getBlockedByProfiles( + 'did:plc:xg2vq45muivyy3xwatcehspu', + cursor: firstPage.cursor, + ); + final allEntries = [...firstPage.entries, ...secondPage.entries]; + + expect(firstPage.total, 23); + expect(firstPage.cursor, '16'); + expect(secondPage.cursor, isNull); + expect(allEntries, hasLength(23)); + expect(allEntries.map((entry) => entry.did).toList(), orderedDids); + expect(allEntries.where((entry) => entry.isAvailable), hasLength(21)); + expect(allEntries.where((entry) => !entry.isAvailable).map((entry) => entry.did).toList(), [ + unavailableDidOne, + unavailableDidTwo, + ]); + expect( + allEntries + .where((entry) => !entry.isAvailable) + .every((entry) => entry.unavailableReason == 'Suspended account'), + isTrue, + ); + }); }); group('getBlockingProfiles', () { @@ -285,6 +695,40 @@ void main() { }); }); + group('getBlockingCount', () { + test('counts records across every listRecords page', () async { + final repo = ProfileContextRepository( + bluesky: _FakeBluesky( + actor: _FakeActorService(profiles: []), + graph: _FakeGraphService(), + atproto: _FakeAtProto( + repo: _PaginatedRepoService( + pages: [ + _FakeListRecordsOutput( + records: [ + _FakeRecord({'subject': 'did:plc:one'}), + _FakeRecord({'subject': 'did:plc:two'}), + ], + cursor: 'page-2', + ), + _FakeListRecordsOutput( + records: [ + _FakeRecord({'subject': 'did:plc:three'}), + ], + ), + ], + ), + ), + ), + constellationClient: _alwaysThrowConstellation(), + ); + + final result = await repo.getBlockingCount('did:plc:actor'); + + expect(result, 3); + }); + }); + group('getListsOn', () { test('returns lists hydrated from getManyToMany results', () async { const listUri = 'at://did:plc:owner/app.bsky.graph.list/listkey'; @@ -307,7 +751,16 @@ void main() { }); final repo = ProfileContextRepository( - bluesky: _buildBluesky(lists: {listUri: listView}), + bluesky: _FakeBluesky( + actor: _FakeActorService(profiles: const []), + graph: _ThrowingGraphService(), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), + publicBluesky: _FakeBluesky( + actor: _FakeActorService(profiles: const []), + graph: _FakeGraphService(lists: {listUri: listView}), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), constellationClient: constellation, ); @@ -339,10 +792,12 @@ void main() { test('passes cursor to getManyToMany and returns response cursor', () async { String? capturedCursor; + String? capturedLimit; final constellation = _constellationWithResponses((uri) { if (uri.path.contains('getBacklinksCount')) return {'total': 0}; if (uri.path.contains('getManyToMany')) { capturedCursor = uri.queryParameters['cursor']; + capturedLimit = uri.queryParameters['limit']; return {'items': [], 'cursor': 'next-page'}; } return {}; @@ -353,6 +808,7 @@ void main() { final result = await repo.getListsOn('did:plc:target', cursor: 'page1'); expect(capturedCursor, 'page1'); + expect(capturedLimit, '16'); expect(result.cursor, 'next-page'); }); @@ -398,18 +854,120 @@ void main() { expect(capturedUri.toString(), listUri); }); + + test('deduplicates list URIs before hydrating metadata', () async { + const listUri = 'at://did:plc:owner/app.bsky.graph.list/abc123'; + final listView = _buildListView(listUri, 'Test List'); + + var getListCalls = 0; + + final repo = ProfileContextRepository( + bluesky: _FakeBluesky( + actor: _FakeActorService(profiles: []), + graph: _CountingGraphService(lists: {listUri: listView}, onGetList: () => getListCalls += 1), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), + constellationClient: _constellationWithResponses((uri) { + if (uri.path.contains('getBacklinksCount')) return {'total': 2}; + return { + 'items': [ + { + 'linkRecord': {'did': 'did:plc:owner', 'collection': 'app.bsky.graph.listitem', 'rkey': 'rk-1'}, + 'otherSubject': listUri, + }, + { + 'linkRecord': {'did': 'did:plc:owner', 'collection': 'app.bsky.graph.listitem', 'rkey': 'rk-2'}, + 'otherSubject': listUri, + }, + ], + }; + }), + ); + + final result = await repo.getListsOn('did:plc:target'); + + expect(getListCalls, 1); + expect(result.lists, [listView]); + }); + + test('skips lists that fail hydration instead of failing the whole page', () async { + const goodListUri = 'at://did:plc:owner/app.bsky.graph.list/good'; + const missingListUri = 'at://did:plc:owner/app.bsky.graph.list/missing'; + final goodListView = _buildListView(goodListUri, 'Good List'); + + final repo = ProfileContextRepository( + bluesky: _buildBluesky(lists: {goodListUri: goodListView}), + constellationClient: _constellationWithResponses((uri) { + if (uri.path.contains('getBacklinksCount')) return {'total': 2}; + return { + 'items': [ + { + 'linkRecord': {'did': 'did:plc:owner', 'collection': 'app.bsky.graph.listitem', 'rkey': 'good'}, + 'otherSubject': goodListUri, + }, + { + 'linkRecord': {'did': 'did:plc:owner', 'collection': 'app.bsky.graph.listitem', 'rkey': 'bad'}, + 'otherSubject': missingListUri, + }, + ], + }; + }), + ); + + final result = await repo.getListsOn('did:plc:target'); + + expect(result.total, 2); + expect(result.lists, [goodListView]); + }); + }); + + group('_hydrateProfiles normalization behavior via public APIs', () { + test('trims and deduplicates DIDs before public hydration', () async { + final capturedActors = >[]; + final repo = ProfileContextRepository( + bluesky: _FakeBluesky( + actor: _ThrowingActorService(), + graph: _FakeGraphService(), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), + publicBluesky: _FakeBluesky( + actor: _BatchTrackingActorService( + profiles: [_buildProfileView('did:plc:alice', 'alice.bsky.social')], + batchSizes: [], + onGetProfiles: (actors) => capturedActors.add(actors), + ), + graph: _FakeGraphService(), + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), + ), + constellationClient: _constellationWithResponses((_) { + return { + 'total': 3, + 'dids': [' did:plc:alice ', 'did:plc:alice', ' '], + }; + }), + ); + + final result = await repo.getBlockedByProfiles('did:plc:target'); + + expect(capturedActors, [ + ['did:plc:alice'], + ]); + expect(result.entries, hasLength(1)); + expect(result.entries.first.profile?.did, 'did:plc:alice'); + }); }); }); } _FakeBluesky _buildBluesky({ - List profiles = const [], + List profiles = const [], + Map profileByActor = const {}, Map lists = const {}, List> blockRecords = const [], String? blockRecordsCursor, }) { return _FakeBluesky( - actor: _FakeActorService(profiles: profiles), + actor: _FakeActorService(profiles: profiles, profileByActor: profileByActor), graph: _FakeGraphService(lists: lists), atproto: _FakeAtProto( repo: _FakeRepoService(records: blockRecords, cursor: blockRecordsCursor), @@ -424,17 +982,20 @@ ConstellationClient _alwaysThrowConstellation() { } class _BatchTrackingActorService { - _BatchTrackingActorService({required this.profiles, required this.batchSizes}); + _BatchTrackingActorService({required this.profiles, required this.batchSizes, this.onGetProfiles}); - final List profiles; + final List profiles; final List batchSizes; + final void Function(List actors)? onGetProfiles; Future<_FakeResponse<_FakeProfilesOutput>> getProfiles({ required List actors, + String? $service, Map? $headers, }) async { batchSizes.add(actors.length); - final matched = profiles.where((p) => actors.contains(p.did)).toList(); + onGetProfiles?.call(actors); + final matched = profiles.where((p) => actors.contains(p.did as String)).toList(); return _FakeResponse(_FakeProfilesOutput(matched)); } } @@ -462,6 +1023,26 @@ class _CursorTrackingRepoService { } } +class _PaginatedRepoService { + _PaginatedRepoService({required this.pages}); + + final List<_FakeListRecordsOutput> pages; + var _pageIndex = 0; + + Future<_FakeResponse<_FakeListRecordsOutput>> listRecords({ + required String repo, + required String collection, + int limit = 50, + String? cursor, + }) async { + final page = pages[_pageIndex]; + if (_pageIndex < pages.length - 1) { + _pageIndex += 1; + } + return _FakeResponse(page); + } +} + class _UriCapturingGraphService { _UriCapturingGraphService({required this.lists, required this.onGetList}); @@ -471,6 +1052,7 @@ class _UriCapturingGraphService { Future<_FakeResponse<_FakeGetListOutput>> getList({ required AtUri list, int limit = 50, + String? $service, Map? $headers, }) async { onGetList(list); @@ -479,3 +1061,22 @@ class _UriCapturingGraphService { return _FakeResponse(_FakeGetListOutput(listView)); } } + +class _CountingGraphService { + _CountingGraphService({required this.lists, required this.onGetList}); + + final Map lists; + final void Function() onGetList; + + Future<_FakeResponse<_FakeGetListOutput>> getList({ + required AtUri list, + int limit = 50, + String? $service, + Map? $headers, + }) async { + onGetList(); + final listView = lists[list.toString()]; + if (listView == null) throw Exception('List not found: $list'); + return _FakeResponse(_FakeGetListOutput(listView)); + } +} diff --git a/test/features/profile/presentation/profile_context_screen_test.dart b/test/features/profile/presentation/profile_context_screen_test.dart index 513f06f..d216508 100644 --- a/test/features/profile/presentation/profile_context_screen_test.dart +++ b/test/features/profile/presentation/profile_context_screen_test.dart @@ -21,12 +21,20 @@ const _handle = 'alice.bsky.social'; ProfileView _profile(String did) => ProfileView(did: did, handle: '$did.bsky.social', displayName: 'User $did', indexedAt: DateTime.utc(2026)); -bsky_graph.ListView _list(String rkey) => bsky_graph.ListView( +bsky_graph.ListView _list( + String rkey, { + bsky_graph.KnownListPurpose purpose = bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist, + String? description, + int? memberCount, + String creatorHandle = 'owner.bsky.social', +}) => bsky_graph.ListView( uri: AtUri.parse('at://did:plc:owner/app.bsky.graph.list/$rkey'), cid: 'cid-$rkey', - creator: const ProfileView(did: 'did:plc:owner', handle: 'owner.bsky.social'), + creator: ProfileView(did: 'did:plc:owner', handle: creatorHandle), name: 'List $rkey', - purpose: const bsky_graph.ListPurpose.knownValue(data: bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist), + purpose: bsky_graph.ListPurpose.knownValue(data: purpose), + description: description, + listItemCount: memberCount, indexedAt: DateTime.utc(2026), ); @@ -51,11 +59,7 @@ void main() { when(() => cubit.refreshListsOn()).thenAnswer((_) async {}); }); - Widget buildSubject({ - required ProfileContextState state, - List observers = const [], - String? Function(BuildContext, GoRouterState)? redirect, - }) { + Widget buildSubject({required ProfileContextState state, List observers = const []}) { when(() => cubit.state).thenReturn(state); whenListen(cubit, const Stream.empty(), initialState: state); @@ -71,7 +75,7 @@ void main() { routes: [ GoRoute( path: 'profile/view', - builder: (context, state) => const Scaffold(body: Text('Profile View')), + builder: (context, state) => Scaffold(body: Text('Profile View ${state.uri.queryParameters['actor']}')), ), GoRoute( path: 'list', @@ -137,7 +141,7 @@ void main() { final profiles = [_profile('did:plc:user1'), _profile('did:plc:user2')]; final state = initialState().copyWith( blockedByStatus: ProfileContextTabStatus.loaded, - blockedByProfiles: profiles, + blockedByEntries: profiles.map((profile) => BlockedByEntry.profile(profile: profile)).toList(), ); await tester.pumpWidget(buildSubject(state: state)); @@ -149,18 +153,30 @@ void main() { final profiles = [_profile('did:plc:user1')]; final state = initialState().copyWith( blockedByStatus: ProfileContextTabStatus.loaded, - blockedByProfiles: profiles, + blockedByEntries: profiles.map((profile) => BlockedByEntry.profile(profile: profile)).toList(), ); - String? pushedRoute; - final observer = _TestNavigatorObserver(onPush: (route, _) => pushedRoute = route.settings.name); - - await tester.pumpWidget(buildSubject(state: state, observers: [observer])); + await tester.pumpWidget(buildSubject(state: state)); await tester.tap(find.text('User did:plc:user1')); await tester.pumpAndSettle(); - expect(pushedRoute, '/profile/view'); - expect(find.text('Profile View'), findsOneWidget); + expect(find.text('Profile View did:plc:user1.bsky.social'), findsOneWidget); + }); + + testWidgets('loads next blocked-by page when scrolled near the end', (tester) async { + final profiles = List.generate(20, (index) => _profile('did:plc:user$index')); + final state = initialState().copyWith( + blockedByStatus: ProfileContextTabStatus.loaded, + blockedByEntries: profiles.map((profile) => BlockedByEntry.profile(profile: profile)).toList(), + blockedByCursor: 'next-page', + blockedByHasMore: true, + ); + await tester.pumpWidget(buildSubject(state: state)); + + await tester.drag(find.byType(CustomScrollView).first, const Offset(0, -4000)); + await tester.pump(); + + verify(() => cubit.loadBlockedBy(cursor: 'next-page')).called(greaterThanOrEqualTo(1)); }); testWidgets('shows contextualizing note', (tester) async { @@ -170,12 +186,58 @@ void main() { }); testWidgets('shows empty state when loaded with no profiles', (tester) async { - final state = initialState().copyWith(blockedByStatus: ProfileContextTabStatus.loaded, blockedByProfiles: []); + final state = initialState().copyWith(blockedByStatus: ProfileContextTabStatus.loaded, blockedByEntries: []); await tester.pumpWidget(buildSubject(state: state)); expect(find.text('No accounts have blocked this user'), findsOneWidget); }); + testWidgets('shows unresolved-details message when count is non-zero but no profiles resolved', (tester) async { + final state = initialState().copyWith( + blockedByStatus: ProfileContextTabStatus.loaded, + blockedByCount: 23, + blockedByEntries: [], + ); + await tester.pumpWidget(buildSubject(state: state)); + + expect(find.textContaining('public Bluesky profile details could not be loaded'), findsOneWidget); + }); + + testWidgets('shows unavailable blocked-by rows inline with resolved profiles', (tester) async { + final state = initialState().copyWith( + blockedByStatus: ProfileContextTabStatus.loaded, + blockedByCount: 23, + blockedByEntries: [ + BlockedByEntry.profile(profile: _profile('did:plc:user1')), + const BlockedByEntry.unavailable(did: 'did:plc:suspended', unavailableReason: 'Suspended account'), + ], + ); + await tester.pumpWidget(buildSubject(state: state)); + + expect(find.byKey(const ValueKey('blocked_by_did:plc:user1')), findsOneWidget); + expect(find.byKey(const ValueKey('blocked_by_unavailable_did:plc:suspended')), findsOneWidget); + expect(find.text('did:plc:suspended'), findsOneWidget); + expect(find.text('Suspended account'), findsOneWidget); + expect(find.text('User did:plc:user1'), findsOneWidget); + expect(find.text('23 accounts'), findsOneWidget); + }); + + testWidgets('unavailable blocked-by rows are non-navigable', (tester) async { + final state = initialState().copyWith( + blockedByStatus: ProfileContextTabStatus.loaded, + blockedByCount: 1, + blockedByEntries: const [ + BlockedByEntry.unavailable(did: 'did:plc:suspended', unavailableReason: 'Suspended account'), + ], + ); + await tester.pumpWidget(buildSubject(state: state)); + + await tester.tap(find.text('did:plc:suspended')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Profile View'), findsNothing); + }); + testWidgets('shows error and retry button on error state', (tester) async { final state = initialState().copyWith( blockedByStatus: ProfileContextTabStatus.error, @@ -221,6 +283,35 @@ void main() { expect(find.text('User did:plc:blocked1'), findsOneWidget); }); + testWidgets('shows count header for own profile', (tester) async { + final state = const ProfileContextState.initial( + did: _did, + isOwnProfile: true, + ).copyWith(blockingCount: 4, blockingStatus: ProfileContextTabStatus.loaded); + await tester.pumpWidget(buildSubject(state: state)); + + await tester.tap(find.text('Blocking (4)')); + await tester.pumpAndSettle(); + + expect(find.text('4 accounts'), findsOneWidget); + }); + + testWidgets('shows unavailable-account card on blocking tab', (tester) async { + final state = const ProfileContextState.initial(did: _did, isOwnProfile: true).copyWith( + blockingStatus: ProfileContextTabStatus.loaded, + blockingUnavailable: const [ + UnavailableProfileRef(did: 'did:plc:takedown', reason: 'Suspended or taken-down account'), + ], + ); + await tester.pumpWidget(buildSubject(state: state)); + + await tester.tap(find.text('Blocking')); + await tester.pumpAndSettle(); + + expect(find.text('Unavailable accounts (1)'), findsOneWidget); + expect(find.text('did:plc:takedown'), findsOneWidget); + }); + testWidgets('shows empty state for own profile when loaded with no profiles', (tester) async { final state = const ProfileContextState.initial( did: _did, @@ -276,7 +367,7 @@ void main() { await tester.tap(find.text('User did:plc:blocked1')); await tester.pumpAndSettle(); - expect(find.text('Profile View'), findsOneWidget); + expect(find.text('Profile View did:plc:blocked1.bsky.social'), findsOneWidget); }); }); @@ -293,6 +384,45 @@ void main() { expect(find.text('List rkey2'), findsOneWidget); }); + testWidgets('groups lists by purpose and shows owner, purpose, and description', (tester) async { + final lists = [ + _list( + 'curate', + purpose: bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist, + description: 'Curated follows', + memberCount: 12, + creatorHandle: 'curator.bsky.social', + ), + _list( + 'mod', + purpose: bsky_graph.KnownListPurpose.appBskyGraphDefsModlist, + description: 'Moderation context', + memberCount: 3, + creatorHandle: 'moderator.bsky.social', + ), + _list( + 'ref', + purpose: bsky_graph.KnownListPurpose.appBskyGraphDefsReferencelist, + memberCount: 8, + creatorHandle: 'reference.bsky.social', + ), + ]; + final state = initialState().copyWith(listsOnStatus: ProfileContextTabStatus.loaded, listsOn: lists); + await tester.pumpWidget(buildSubject(state: state)); + + await tester.tap(find.text('Lists')); + await tester.pumpAndSettle(); + + expect(find.text('Curation Lists'), findsOneWidget); + expect(find.text('Moderation Lists'), findsOneWidget); + expect(find.text('Reference Lists'), findsOneWidget); + expect(find.text('@curator.bsky.social'), findsOneWidget); + expect(find.text('Curated follows'), findsOneWidget); + expect(find.text('CURATE'), findsOneWidget); + expect(find.text('MOD'), findsOneWidget); + expect(find.text('REFERENCE'), findsOneWidget); + }); + testWidgets('list card navigates to /list on tap', (tester) async { final lists = [_list('rkey1')]; final state = initialState().copyWith(listsOnStatus: ProfileContextTabStatus.loaded, listsOn: lists); @@ -344,14 +474,3 @@ void main() { }); }); } - -class _TestNavigatorObserver extends NavigatorObserver { - _TestNavigatorObserver({this.onPush}); - - final void Function(Route, Route?)? onPush; - - @override - void didPush(Route route, Route? previousRoute) { - onPush?.call(route, previousRoute); - } -}