mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: polish context feature and use anon client

+2614 -337
+412
docs/smoke-test.md
··· 1 + --- 2 + title: Smoke Test Checklist 3 + updated: 2026-04-01 4 + --- 5 + 6 + A manual walkthrough to verify core functionality after a build. Each section covers a 7 + feature area with steps and expected outcomes. 8 + Test on both iOS and Android unless noted otherwise. 9 + 10 + --- 11 + 12 + ## 1. Authentication 13 + 14 + ### OAuth Login 15 + 16 + - [ ] Launch app → Login screen appears 17 + - [ ] Enter a valid handle and tap "Sign in with BlueSky" 18 + - [ ] System browser opens → complete OAuth flow 19 + - [ ] Redirected back to app → Home feed loads 20 + 21 + ### App Password Login (Debug only) 22 + 23 + - [ ] Toggle to app-password mode 24 + - [ ] Enter handle + app password (xxxx-xxxx-xxxx-xxxx) 25 + - [ ] Tap sign in → Home feed loads 26 + 27 + ### Session Restore 28 + 29 + - [ ] Kill and relaunch app → session restores without login prompt 30 + - [ ] Token refresh works silently (wait for expiry or force) 31 + 32 + ### Logout 33 + 34 + - [ ] Log out from settings → returns to login screen 35 + - [ ] Relaunching app shows login screen (session cleared) 36 + 37 + --- 38 + 39 + ## 2. Home Feed 40 + 41 + ### Feed Loading 42 + 43 + - [ ] Following feed loads with posts on launch 44 + - [ ] Pull-to-refresh fetches new posts 45 + - [ ] Scroll down → infinite pagination loads more posts 46 + 47 + ### Post Cards 48 + 49 + - [ ] Posts display: avatar, name, handle, relative timestamp 50 + - [ ] Rich text renders correctly (mentions, links, hashtags highlighted) 51 + - [ ] Image embeds display (single + multi-image grids) 52 + - [ ] Video embeds display with thumbnail 53 + - [ ] Link card embeds render (title, description, thumbnail) 54 + - [ ] Quote posts render inline 55 + - [ ] Engagement counts shown (likes, reposts, replies) 56 + 57 + ### Post Actions 58 + 59 + - [ ] Tap like → icon fills, count increments (optimistic) 60 + - [ ] Tap like again → unlike, count decrements 61 + - [ ] Tap repost → menu: "Repost" and "Quote Post" 62 + - [ ] Repost → count increments 63 + - [ ] Tap reply → compose modal opens with reply context 64 + - [ ] Tap share → system share sheet 65 + - [ ] Overflow → Save post (bookmark icon fills) 66 + - [ ] Overflow → Copy link 67 + 68 + ### Navigation from Feed 69 + 70 + - [ ] Tap post body → thread view opens 71 + - [ ] Tap avatar or name → profile screen opens 72 + 73 + --- 74 + 75 + ## 3. Feed Management 76 + 77 + - [ ] Open feed management from home screen 78 + - [ ] Pinned feeds listed (Following + custom generators) 79 + - [ ] Drag to reorder feeds → order persists 80 + - [ ] Toggle pin off → feed removed from home tabs 81 + - [ ] Add a suggested feed → appears in home tabs 82 + 83 + --- 84 + 85 + ## 4. Post Composition 86 + 87 + ### Basic Compose 88 + 89 + - [ ] Tap compose FAB → compose screen opens 90 + - [ ] Type text → character counter updates (max 300 graphemes) 91 + - [ ] Mentions auto-highlight as typed 92 + - [ ] Links and hashtags auto-highlight 93 + - [ ] Submit → post appears in feed 94 + 95 + ### Media 96 + 97 + - [ ] Attach 1–4 images → thumbnails shown 98 + - [ ] Add alt text to an image 99 + - [ ] Remove an image attachment 100 + - [ ] Attach a video → upload progress shown 101 + - [ ] Images and video are mutually exclusive (UI enforces) 102 + 103 + ### Reply & Quote 104 + 105 + - [ ] Compose as reply → parent post context shown above input 106 + - [ ] Compose as quote → quoted post shown below input 107 + 108 + ### Drafts 109 + 110 + - [ ] Save draft → confirmation shown 111 + - [ ] Open drafts → saved draft listed 112 + - [ ] Load draft → text and media restored 113 + - [ ] Submit loaded draft → posts successfully 114 + 115 + ### Scheduled Posts 116 + 117 + - [ ] Schedule a post for a future time → confirmation shown 118 + - [ ] Post publishes at scheduled time (background task) 119 + 120 + ## 5. Search 121 + 122 + ### Post Search 123 + 124 + - [ ] Type query → results load 125 + - [ ] Sort toggle: "Top" vs "Latest" works 126 + - [ ] Tap result → thread view opens 127 + 128 + ### Actor Search 129 + 130 + - [ ] Switch to Actors tab → type query 131 + - [ ] Autocomplete suggestions appear as you type 132 + - [ ] Tap result → profile screen opens 133 + 134 + ### Starter Pack Search 135 + 136 + - [ ] Switch to Starter Packs tab → type query 137 + - [ ] Results show pack cards 138 + - [ ] Tap result → starter pack detail opens 139 + 140 + ### Search History 141 + 142 + - [ ] Recent searches appear below search bar 143 + - [ ] Tap history item → re-executes search 144 + - [ ] Swipe to delete a single entry 145 + - [ ] "Clear all" removes all history 146 + 147 + ## 6. Notifications 148 + 149 + ### Notification List 150 + 151 + - [ ] Alerts tab shows notifications (likes, reposts, follows, mentions, replies, quotes) 152 + - [ ] Grouped by day 153 + - [ ] Unread count badge on nav tab 154 + - [ ] Tap notification → navigates to post or profile 155 + - [ ] "Mark all as read" clears unread badge 156 + 157 + ### Unread Polling 158 + 159 + - [ ] Leave app open → badge count updates (30s poll interval) 160 + 161 + ## 7. Direct Messages 162 + 163 + ### Conversation List 164 + 165 + - [ ] Messages sub-tab shows conversations sorted by recency 166 + - [ ] Unread count per conversation displayed 167 + - [ ] Requests sub-tab shows unanswered conversations 168 + 169 + ### Message Thread 170 + 171 + - [ ] Tap conversation → message thread opens 172 + - [ ] Messages paginate (scroll up for older) 173 + - [ ] Own messages right-aligned, others left-aligned 174 + - [ ] Type and send a message → appears immediately 175 + - [ ] Tap message → copy option 176 + 177 + ### Conversation Actions 178 + 179 + - [ ] Mute conversation from overflow → muted indicator shown 180 + - [ ] Unmute → indicator removed 181 + 182 + ## 8. Profile 183 + 184 + ### Own Profile 185 + 186 + - [ ] Profile tab shows: banner, avatar, display name, handle, bio 187 + - [ ] Follower/following/post counts displayed 188 + - [ ] Posts tab → author's posts (no replies) 189 + - [ ] Replies tab → posts and threads 190 + - [ ] Media tab → only posts with media 191 + - [ ] Lists tab → lists created by user (Curation | Moderation sub-tabs) 192 + - [ ] Packs tab → starter packs created by user 193 + 194 + ### Other User's Profile 195 + 196 + - [ ] Tap user anywhere → their profile loads 197 + - [ ] Follow button shown → tap to follow → button changes 198 + - [ ] Unfollow → button reverts 199 + - [ ] Overflow: Mute, Block, Report, Copy DID, Share 200 + 201 + ### Suggested Follows 202 + 203 + - [ ] Overflow → "Suggested Follows" → sheet with suggestions 204 + - [ ] Follow/unfollow buttons in sheet work 205 + 206 + ## 9. Profile Context (Constellation) 207 + 208 + - [ ] Open profile context from profile overflow menu 209 + - [ ] **Blocked By** tab: shows count, "Show accounts" expands list 210 + - [ ] Paginated account tiles load 211 + - [ ] **Blocking** tab: shows outgoing blocks (own profile only) 212 + - [ ] Shows "unavailable" message for other profiles 213 + - [ ] **Lists** tab: shows lists user is a member of 214 + - [ ] List cards display: name, owner, purpose badge, description 215 + - [ ] Tap account → navigates to profile 216 + - [ ] Tap list → navigates to list detail 217 + 218 + ## 10. Post Thread 219 + 220 + - [ ] Thread loads: parent chain above, replies below 221 + - [ ] Root post visually highlighted 222 + - [ ] Nested reply chains render correctly 223 + - [ ] All post actions work within thread (like, repost, reply, save) 224 + - [ ] Tap profile in thread → profile screen 225 + 226 + ## 11. Lists 227 + 228 + ### My Lists 229 + 230 + - [ ] Navigate to lists screen 231 + - [ ] Curation and Moderation tabs separate lists correctly 232 + - [ ] Tap list → detail screen 233 + 234 + ### Create List 235 + 236 + - [ ] Tap FAB → create dialog 237 + - [ ] Enter name (1–64 graphemes), optional description, pick avatar 238 + - [ ] Select purpose (curation or moderation) 239 + - [ ] Save → list appears in My Lists 240 + 241 + ### List Detail 242 + 243 + - [ ] Header: name, avatar, description, creator, member count 244 + - [ ] Feed tab (curation lists): shows posts from members 245 + - [ ] Members tab: lists member profiles 246 + - [ ] Overflow: Edit, Delete, Add/Remove members 247 + 248 + ### List Members 249 + 250 + - [ ] Search for actors to add 251 + - [ ] Add member → appears in list 252 + - [ ] Remove member → removed from list 253 + 254 + ### Moderation Actions 255 + 256 + - [ ] Mute list → all members muted 257 + - [ ] Block via list → all members blocked 258 + 259 + ## 12. Starter Packs 260 + 261 + ### Starter Pack Detail 262 + 263 + - [ ] Header: name, description, creator 264 + - [ ] Stats: joined this week, joined all-time 265 + - [ ] Sample members displayed (up to 12) 266 + - [ ] Recommended feeds displayed (up to 3) 267 + - [ ] "See all members" → navigates to list members 268 + - [ ] "Follow all" → follows all members (with confirmation) 269 + 270 + ### Create Starter Pack 271 + 272 + - [ ] Tap create from own profile's Packs tab 273 + - [ ] Enter name (max 50 graphemes), optional description 274 + - [ ] Search and add members 275 + - [ ] Pick up to 3 feeds 276 + - [ ] Save → pack appears on profile 277 + 278 + ### Edit / Delete 279 + 280 + - [ ] Edit pack → update name, description, feeds 281 + - [ ] Delete pack → removed from profile 282 + 283 + ## 13. Media 284 + 285 + ### Image Viewer 286 + 287 + - [ ] Tap image → full-screen viewer 288 + - [ ] Pinch to zoom, pan around 289 + - [ ] Multi-image post → swipe between images 290 + - [ ] Alt text shown at bottom (if present) 291 + - [ ] Download → saved to gallery 292 + - [ ] Share → system sheet 293 + - [ ] Swipe down → dismiss viewer 294 + 295 + ### Video Player 296 + 297 + - [ ] Tap video → full-screen player 298 + - [ ] Play/pause, seek bar, mute controls work 299 + - [ ] Elapsed/total time displayed 300 + - [ ] Fullscreen toggle works 301 + 302 + ### Long-Press Context Menu 303 + 304 + - [ ] Long-press image thumbnail → Save / Share options 305 + 306 + --- 307 + 308 + ## 14. Saved Posts 309 + 310 + - [ ] Navigate to saved posts screen 311 + - [ ] Previously saved posts listed 312 + - [ ] Tap post → thread view 313 + - [ ] Unsave → removed from list 314 + - [ ] Empty state shown when no saved posts 315 + 316 + --- 317 + 318 + ## 15. Moderation & Labelers 319 + 320 + ### Content Filtering 321 + 322 + - [ ] Blurred content shows click-through overlay 323 + - [ ] Alert badge renders on warned content 324 + - [ ] Filtered content hidden from feeds 325 + - [ ] Media blur shows blurred images, text visible 326 + 327 + ### Labeler Management 328 + 329 + - [ ] Settings → Moderation → labeler list shown 330 + - [ ] Tap labeler → detail with label definitions 331 + - [ ] Toggle per-label preference (ignore/warn/hide) 332 + - [ ] Subscribe to new labeler 333 + - [ ] Unsubscribe from labeler 334 + - [ ] Adult content toggle gates 18+ labels 335 + 336 + --- 337 + 338 + ## 16. Connectivity & Offline 339 + 340 + ### Network Loss 341 + 342 + - [ ] Disable network → offline banner appears at top 343 + - [ ] Cached feed data still displays 344 + - [ ] Post actions (like, repost, compose) disabled with tooltip 345 + - [ ] Notifications/DMs show empty or cached state 346 + 347 + ### Network Restore 348 + 349 + - [ ] Re-enable network → banner disappears 350 + - [ ] Actions re-enabled 351 + - [ ] Pull-to-refresh loads fresh data 352 + 353 + ### Optimistic Updates 354 + 355 + - [ ] Like a post on slow connection → UI updates immediately 356 + - [ ] If API fails → UI rolls back, error snackbar shown 357 + 358 + --- 359 + 360 + ## 17. Settings & Preferences 361 + 362 + ### Theme 363 + 364 + - [ ] Change theme palette (Oxocarbon, Catppuccin, Nord, Rosé Pine) 365 + - [ ] Toggle Light / Dark / System mode 366 + - [ ] Theme applies immediately across all screens 367 + 368 + ### Account Switching 369 + 370 + - [ ] Open account switcher → all accounts listed 371 + - [ ] Switch to another account → feeds/profile reload for new account 372 + - [ ] Add new account → OAuth flow → account added to list 373 + 374 + ### Dev Tools 375 + 376 + - [ ] Logs: view logs, filter by level, search text, share log file 377 + - [ ] PDS Explorer: resolve handle, browse collections, view record JSON 378 + 379 + ### About 380 + 381 + - [ ] App version and build number displayed 382 + - [ ] Licenses accessible 383 + 384 + --- 385 + 386 + ## 18. Cross-Cutting Concerns 387 + 388 + ### Navigation 389 + 390 + - [ ] Bottom nav switches between Home, Search, Alerts, Profile 391 + - [ ] Back button / swipe-back navigates correctly through stack 392 + - [ ] Deep link to a post URI opens thread view 393 + 394 + ### Performance 395 + 396 + - [ ] Feed scroll is smooth (no jank) 397 + - [ ] Image loading doesn't block UI 398 + - [ ] Search results appear within reasonable time 399 + 400 + ### Error States 401 + 402 + - [ ] Invalid handle on login → error message shown 403 + - [ ] Network error on post submit → error snackbar, draft preserved 404 + - [ ] 404 on deleted post → appropriate error state 405 + 406 + ### State Persistence 407 + 408 + - [ ] Kill app mid-scroll → relaunch restores session 409 + - [ ] Drafts survive app restart 410 + - [ ] Saved posts survive app restart 411 + - [ ] Search history survives app restart 412 + - [ ] Theme selection survives app restart
+14 -14
docs/tasks/phase-5.md
··· 1 1 --- 2 2 title: Phase 5 Task Breakdown 3 - updated: 2026-03-31 3 + updated: 2026-04-01 4 4 --- 5 5 6 6 # Phase 5 Milestones ··· 106 106 107 107 ### UI 108 108 109 - - [ ] Profile screen overflow menu - add "Profile Context" entry (available for all profiles) 110 - - [ ] Route: `/profile-context?did={DID}` in `app_router.dart` 111 - - [ ] `ProfileContextScreen` - `AppBar` (title + handle subtitle), `TabBar` with 3 tabs, `BlocProvider` creating cubit 112 - - [ ] **Blocked By tab** - count header, "Show accounts" expand, paginated profile tiles (avatar, name, handle), tap → profile navigation, contextualizing note text 113 - - [ ] **Blocking tab** - same layout; hidden or explanatory text when viewing other profiles 114 - - [ ] **Lists tab** - list cards (name, owner, purpose badge, member count, description), grouped by purpose, tap → `/list?uri=` 115 - - [ ] Per-tab states: skeleton shimmer (loading), contextual empty state, inline error with retry 116 - - [ ] Pull-to-refresh per tab 117 - - [ ] Infinite scroll pagination per tab 109 + - [x] Profile screen overflow menu - add "Profile Context" entry (available for all profiles) 110 + - [x] Route: `/profile-context?did={DID}` in `app_router.dart` 111 + - [x] `ProfileContextScreen` - `AppBar` (title + handle subtitle), `TabBar` with 3 tabs, `BlocProvider` creating cubit 112 + - [x] **Blocked By tab** - count header, "Show accounts" expand, paginated profile tiles (avatar, name, handle), tap → profile navigation, contextualizing note text 113 + - [x] **Blocking tab** - same layout; hidden or explanatory text when viewing other profiles 114 + - [x] **Lists tab** - list cards (name, owner, purpose badge, member count, description), grouped by purpose, tap → `/list?uri=` 115 + - [x] Per-tab states: skeleton shimmer (loading), contextual empty state, inline error with retry 116 + - [x] Pull-to-refresh per tab 117 + - [x] Infinite scroll pagination per tab 118 118 119 119 ### Tests 120 120 121 - - [ ] Unit tests: `ConstellationClient` - each endpoint method, error handling, timeout, URL construction 122 - - [ ] Unit tests: `ProfileContextRepository` - DID hydration batching, list URI derivation, cursor passthrough 123 - - [ ] Unit tests: `ProfileContextCubit` - state transitions for each tab, own-profile vs other-profile logic, pagination appending 124 - - [ ] 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 121 + - [x] Unit tests: `ConstellationClient` - each endpoint method, error handling, timeout, URL construction 122 + - [x] Unit tests: `ProfileContextRepository` - DID hydration batching, list URI derivation, cursor passthrough 123 + - [x] Unit tests: `ProfileContextCubit` - state transitions for each tab, own-profile vs other-profile logic, pagination appending 124 + - [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
+3 -1
ios/Runner.xcodeproj/project.pbxproj
··· 161 161 90899372CAA28BD74B2D49A1 /* Pods-RunnerTests.release.xcconfig */, 162 162 AA7C2CE85592DCD41249030A /* Pods-RunnerTests.profile.xcconfig */, 163 163 ); 164 - name = Pods; 165 164 path = Pods; 166 165 sourceTree = "<group>"; 167 166 }; ··· 490 489 CLANG_ENABLE_MODULES = YES; 491 490 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; 492 491 ENABLE_BITCODE = NO; 492 + EXCLUDED_ARCHS = ""; 493 493 INFOPLIST_FILE = Runner/Info.plist; 494 494 LD_RUNPATH_SEARCH_PATHS = ( 495 495 "$(inherited)", ··· 672 672 CLANG_ENABLE_MODULES = YES; 673 673 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; 674 674 ENABLE_BITCODE = NO; 675 + EXCLUDED_ARCHS = ""; 675 676 INFOPLIST_FILE = Runner/Info.plist; 676 677 LD_RUNPATH_SEARCH_PATHS = ( 677 678 "$(inherited)", ··· 694 695 CLANG_ENABLE_MODULES = YES; 695 696 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; 696 697 ENABLE_BITCODE = NO; 698 + EXCLUDED_ARCHS = ""; 697 699 INFOPLIST_FILE = Runner/Info.plist; 698 700 LD_RUNPATH_SEARCH_PATHS = ( 699 701 "$(inherited)",
+33 -8
lib/core/network/constellation_client.dart
··· 46 46 47 47 class ConstellationClient { 48 48 ConstellationClient({String? baseUrl, http.Client? httpClient}) 49 - : _baseUrl = baseUrl ?? _defaultBaseUrl, 49 + : _baseUrl = _normalizeBaseUrl(baseUrl), 50 50 _httpClient = httpClient ?? http.Client(); 51 51 52 52 final String _baseUrl; 53 53 final http.Client _httpClient; 54 54 55 + String get baseUrl => _baseUrl; 56 + 57 + static String _normalizeBaseUrl(String? baseUrl) { 58 + final trimmed = baseUrl?.trim(); 59 + if (trimmed == null || trimmed.isEmpty) { 60 + return _defaultBaseUrl; 61 + } 62 + 63 + return trimmed.replaceFirst(RegExp(r'/+$'), ''); 64 + } 65 + 55 66 Uri _xrpcUri(String endpoint, Map<String, String?> params) { 56 67 final filtered = <String, String>{}; 57 68 for (final entry in params.entries) { ··· 71 82 return jsonDecode(response.body) as Map<String, dynamic>; 72 83 } 73 84 85 + List<dynamic> _listField(Map<String, dynamic> data, String key) { 86 + final value = data[key]; 87 + if (value == null) return const []; 88 + return value as List<dynamic>; 89 + } 90 + 91 + List<dynamic> _listFieldAny(Map<String, dynamic> data, List<String> keys) { 92 + for (final key in keys) { 93 + if (data.containsKey(key) && data[key] != null) { 94 + return data[key] as List<dynamic>; 95 + } 96 + } 97 + return const []; 98 + } 99 + 74 100 Future<int> getBacklinksCount(String subject, String source) async { 75 101 final uri = _xrpcUri('blue.microcosm.links.getBacklinksCount', {'subject': subject, 'source': source}); 76 102 final data = await _get(uri); ··· 92 118 final data = await _get(uri); 93 119 return ( 94 120 total: data['total'] as int, 95 - dids: (data['dids'] as List<dynamic>).cast<String>(), 121 + dids: _listField(data, 'dids').cast<String>(), 96 122 cursor: data['cursor'] as String?, 97 123 ); 98 124 } ··· 110 136 'cursor': cursor, 111 137 }); 112 138 final data = await _get(uri); 113 - final records = (data['linking_records'] as List<dynamic>) 114 - .map((r) => ConstellationLinkRecord.fromJson(r as Map<String, dynamic>)) 115 - .toList(); 139 + final records = _listFieldAny(data, [ 140 + 'records', 141 + 'linking_records', 142 + ]).map((r) => ConstellationLinkRecord.fromJson(r as Map<String, dynamic>)).toList(); 116 143 return (total: data['total'] as int, records: records, cursor: data['cursor'] as String?); 117 144 } 118 145 ··· 131 158 'cursor': cursor, 132 159 }); 133 160 final data = await _get(uri); 134 - final items = (data['items'] as List<dynamic>) 135 - .map((i) => ManyToManyItem.fromJson(i as Map<String, dynamic>)) 136 - .toList(); 161 + final items = _listField(data, 'items').map((i) => ManyToManyItem.fromJson(i as Map<String, dynamic>)).toList(); 137 162 return (items: items, cursor: data['cursor'] as String?); 138 163 } 139 164 }
+1
lib/core/router/app_router.dart
··· 195 195 final constellationUrl = context.read<SettingsCubit>().state.constellationUrl; 196 196 final repository = ProfileContextRepository( 197 197 bluesky: context.read<Bluesky>(), 198 + publicBluesky: Bluesky.anonymous(service: profileContextPublicAppViewService), 198 199 constellationClient: ConstellationClient(baseUrl: constellationUrl), 199 200 ); 200 201 return BlocProvider(
+2 -1
lib/features/profile/bloc/profile_bloc.dart
··· 36 36 emit(state.copyWith(isRefreshing: true)); 37 37 38 38 try { 39 - final profile = await _profileRepository.getProfile(currentProfile.did); 39 + final actor = currentProfile.handle.isNotEmpty ? currentProfile.handle : currentProfile.did; 40 + final profile = await _profileRepository.getProfile(actor); 40 41 emit(ProfileState.loaded(profile: profile)); 41 42 } catch (error) { 42 43 emit(state.copyWith(isRefreshing: false));
+44 -14
lib/features/profile/cubit/profile_context_cubit.dart
··· 14 14 15 15 final ProfileContextRepository _repository; 16 16 17 - /// Loads blocked-by and lists-on counts in parallel for tab header badges. 17 + /// Loads tab counts in parallel for the header badges. 18 18 Future<void> init() async { 19 - try { 20 - final results = await Future.wait([ 21 - _repository.getBlockedByCount(state.did), 22 - _repository.getListsOnCount(state.did), 23 - ]); 24 - emit(state.copyWith(blockedByCount: results[0], listsOnCount: results[1])); 25 - } catch (error) { 26 - log.w('failed to load initial counts: $error'); 27 - } 19 + final futures = <Future<int?>>[ 20 + _loadCount(() => _repository.getBlockedByCount(state.did), 'blocked-by'), 21 + _loadCount(() => _repository.getListsOnCount(state.did), 'lists-on'), 22 + if (state.isOwnProfile) _loadCount(() => _repository.getBlockingCount(state.did), 'blocking'), 23 + ]; 24 + final results = await Future.wait(futures); 25 + 26 + emit( 27 + state.copyWith( 28 + blockedByCount: results[0] ?? state.blockedByCount, 29 + listsOnCount: results[1] ?? state.listsOnCount, 30 + blockingCount: state.isOwnProfile ? (results[2] ?? state.blockingCount) : state.blockingCount, 31 + ), 32 + ); 28 33 } 29 34 30 35 /// Fetches a page of profiles that have blocked the viewed user and appends ··· 39 44 emit( 40 45 state.copyWith( 41 46 blockedByStatus: ProfileContextTabStatus.loaded, 42 - blockedByProfiles: [...state.blockedByProfiles, ...result.profiles], 47 + blockedByEntries: [...state.blockedByEntries, ...result.entries], 43 48 blockedByCount: result.total, 44 49 blockedByCursor: result.cursor, 45 50 blockedByHasMore: result.cursor != null, ··· 70 75 state.copyWith( 71 76 blockingStatus: ProfileContextTabStatus.loaded, 72 77 blockingProfiles: merged, 73 - blockingCount: merged.length, 78 + blockingUnavailable: _mergeUnavailable(state.blockingUnavailable, result.unavailable), 79 + blockingCount: merged.length > state.blockingCount ? merged.length : state.blockingCount, 74 80 blockingCursor: result.cursor, 75 81 blockingHasMore: result.cursor != null, 76 82 ), ··· 112 118 Future<void> refreshBlockedBy() async { 113 119 emit( 114 120 state.copyWith( 115 - blockedByProfiles: [], 121 + blockedByEntries: [], 116 122 blockedByCursor: null, 117 123 blockedByHasMore: false, 118 124 blockedByStatus: ProfileContextTabStatus.initial, ··· 128 134 emit( 129 135 state.copyWith( 130 136 blockingProfiles: [], 137 + blockingUnavailable: [], 131 138 blockingCursor: null, 132 139 blockingHasMore: false, 133 140 blockingStatus: ProfileContextTabStatus.initial, 134 141 blockingError: null, 135 - blockingCount: 0, 136 142 ), 137 143 ); 138 144 await loadBlocking(); 145 + final refreshedCount = await _loadCount(() => _repository.getBlockingCount(state.did), 'blocking'); 146 + if (refreshedCount != null) { 147 + emit(state.copyWith(blockingCount: refreshedCount)); 148 + } 139 149 } 140 150 141 151 /// Resets the lists-on list and reloads from the first page. ··· 150 160 ), 151 161 ); 152 162 await loadListsOn(); 163 + } 164 + 165 + Future<int?> _loadCount(Future<int> Function() loader, String label) async { 166 + try { 167 + return await loader(); 168 + } catch (error) { 169 + log.w('failed to load $label count: $error'); 170 + return null; 171 + } 172 + } 173 + 174 + List<UnavailableProfileRef> _mergeUnavailable( 175 + List<UnavailableProfileRef> existing, 176 + List<UnavailableProfileRef> incoming, 177 + ) { 178 + final merged = <String, UnavailableProfileRef>{for (final item in existing) item.did: item}; 179 + for (final item in incoming) { 180 + merged[item.did] = item; 181 + } 182 + return merged.values.toList(); 153 183 } 154 184 }
+10 -5
lib/features/profile/cubit/profile_context_state.dart
··· 12 12 this.blockingCount = 0, 13 13 this.listsOnCount = 0, 14 14 this.blockedByStatus = ProfileContextTabStatus.initial, 15 - this.blockedByProfiles = const [], 15 + this.blockedByEntries = const [], 16 16 this.blockedByCursor, 17 17 this.blockedByHasMore = false, 18 18 this.blockedByError, 19 19 this.blockingStatus = ProfileContextTabStatus.initial, 20 20 this.blockingProfiles = const [], 21 + this.blockingUnavailable = const [], 21 22 this.blockingCursor, 22 23 this.blockingHasMore = false, 23 24 this.blockingError, ··· 39 40 final int listsOnCount; 40 41 41 42 final ProfileContextTabStatus blockedByStatus; 42 - final List<ProfileView> blockedByProfiles; 43 + final List<BlockedByEntry> blockedByEntries; 43 44 final String? blockedByCursor; 44 45 final bool blockedByHasMore; 45 46 final String? blockedByError; 46 47 47 48 final ProfileContextTabStatus blockingStatus; 48 49 final List<ProfileView> blockingProfiles; 50 + final List<UnavailableProfileRef> blockingUnavailable; 49 51 final String? blockingCursor; 50 52 final bool blockingHasMore; 51 53 final String? blockingError; ··· 61 63 int? blockingCount, 62 64 int? listsOnCount, 63 65 ProfileContextTabStatus? blockedByStatus, 64 - List<ProfileView>? blockedByProfiles, 66 + List<BlockedByEntry>? blockedByEntries, 65 67 Object? blockedByCursor = _profileContextNoValue, 66 68 bool? blockedByHasMore, 67 69 Object? blockedByError = _profileContextNoValue, 68 70 ProfileContextTabStatus? blockingStatus, 69 71 List<ProfileView>? blockingProfiles, 72 + List<UnavailableProfileRef>? blockingUnavailable, 70 73 Object? blockingCursor = _profileContextNoValue, 71 74 bool? blockingHasMore, 72 75 Object? blockingError = _profileContextNoValue, ··· 83 86 blockingCount: blockingCount ?? this.blockingCount, 84 87 listsOnCount: listsOnCount ?? this.listsOnCount, 85 88 blockedByStatus: blockedByStatus ?? this.blockedByStatus, 86 - blockedByProfiles: blockedByProfiles ?? this.blockedByProfiles, 89 + blockedByEntries: blockedByEntries ?? this.blockedByEntries, 87 90 blockedByCursor: identical(blockedByCursor, _profileContextNoValue) 88 91 ? this.blockedByCursor 89 92 : blockedByCursor as String?, ··· 93 96 : blockedByError as String?, 94 97 blockingStatus: blockingStatus ?? this.blockingStatus, 95 98 blockingProfiles: blockingProfiles ?? this.blockingProfiles, 99 + blockingUnavailable: blockingUnavailable ?? this.blockingUnavailable, 96 100 blockingCursor: identical(blockingCursor, _profileContextNoValue) 97 101 ? this.blockingCursor 98 102 : blockingCursor as String?, ··· 114 118 blockingCount, 115 119 listsOnCount, 116 120 blockedByStatus, 117 - blockedByProfiles, 121 + blockedByEntries, 118 122 blockedByCursor, 119 123 blockedByHasMore, 120 124 blockedByError, 121 125 blockingStatus, 122 126 blockingProfiles, 127 + blockingUnavailable, 123 128 blockingCursor, 124 129 blockingHasMore, 125 130 blockingError,
+315 -33
lib/features/profile/data/profile_context_repository.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart' show AtUri; 2 2 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 3 import 'package:bluesky/app_bsky_graph_defs.dart'; 4 + import 'package:equatable/equatable.dart'; 5 + import 'package:lazurite/core/logging/app_logger.dart'; 4 6 import 'package:lazurite/core/network/constellation_client.dart'; 5 7 8 + const profileContextPublicAppViewService = 'public.api.bsky.app'; 9 + const _blockedByPageSize = 16; 10 + const _listsPageSize = 16; 11 + 12 + class UnavailableProfileRef extends Equatable { 13 + const UnavailableProfileRef({required this.did, required this.reason}); 14 + 15 + final String did; 16 + final String reason; 17 + 18 + @override 19 + List<Object?> get props => [did, reason]; 20 + } 21 + 22 + class BlockedByEntry extends Equatable { 23 + BlockedByEntry.profile({required ProfileView this.profile}) : did = profile.did, unavailableReason = null; 24 + 25 + const BlockedByEntry.unavailable({required this.did, required this.unavailableReason}) : profile = null; 26 + 27 + final String did; 28 + final ProfileView? profile; 29 + final String? unavailableReason; 30 + 31 + bool get isAvailable => profile != null; 32 + 33 + @override 34 + List<Object?> get props => [did, profile, unavailableReason]; 35 + } 36 + 6 37 class ProfileContextRepository { 7 - ProfileContextRepository({required dynamic bluesky, required ConstellationClient constellationClient}) 8 - : _bluesky = bluesky, 9 - _constellation = constellationClient; 38 + ProfileContextRepository({ 39 + required dynamic bluesky, 40 + dynamic publicBluesky, 41 + required ConstellationClient constellationClient, 42 + }) : _bluesky = bluesky, 43 + _publicBluesky = publicBluesky ?? bluesky, 44 + _constellation = constellationClient; 10 45 11 46 final dynamic _bluesky; 47 + final dynamic _publicBluesky; 12 48 final ConstellationClient _constellation; 13 49 14 50 /// Returns the number of accounts that have blocked [did]. ··· 23 59 24 60 /// Returns a page of profiles that have blocked [did], along with the total 25 61 /// count and a cursor for the next page. 26 - Future<({List<ProfileView> profiles, String? cursor, int total})> getBlockedByProfiles( 62 + Future<({List<BlockedByEntry> entries, String? cursor, int total})> getBlockedByProfiles( 27 63 String did, { 28 64 String? cursor, 29 65 }) async { 30 - final result = await _constellation.getDistinct( 31 - did, 32 - 'app.bsky.graph.block:subject', 33 - cursor: cursor, 66 + final offset = int.tryParse(cursor ?? '0') ?? 0; 67 + log.i('ProfileContextRepository: blocked-by load start for $did offset=$offset via ${_constellation.baseUrl}'); 68 + final collected = await _collectBlockedByDids(did); 69 + final hydrated = await _hydrateProfiles(collected.dids); 70 + final unavailableByDid = <String, String>{for (final entry in hydrated.unavailable) entry.did: entry.reason}; 71 + final profileByDid = <String, ProfileView>{for (final profile in hydrated.profiles) profile.did: profile}; 72 + 73 + final pageDids = collected.dids.skip(offset).take(_blockedByPageSize).toList(); 74 + final entries = <BlockedByEntry>[]; 75 + for (final pageDid in pageDids) { 76 + final profile = profileByDid[pageDid]; 77 + if (profile != null) { 78 + entries.add(BlockedByEntry.profile(profile: profile)); 79 + continue; 80 + } 81 + 82 + final unavailableReason = unavailableByDid[pageDid]; 83 + if (unavailableReason != null) { 84 + entries.add(BlockedByEntry.unavailable(did: pageDid, unavailableReason: unavailableReason)); 85 + } 86 + } 87 + 88 + final nextOffset = offset + _blockedByPageSize; 89 + log.i( 90 + '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}', 91 + ); 92 + return ( 93 + entries: entries, 94 + cursor: nextOffset < collected.dids.length ? '$nextOffset' : null, 95 + total: collected.total, 34 96 ); 35 - final profiles = await _hydrateProfiles(result.dids); 36 - return (profiles: profiles, cursor: result.cursor, total: result.total); 97 + } 98 + 99 + /// Returns the total number of accounts that [did] is blocking. 100 + Future<int> getBlockingCount(String did) async { 101 + var total = 0; 102 + String? cursor; 103 + 104 + do { 105 + final response = await _bluesky.atproto.repo.listRecords( 106 + repo: did, 107 + collection: 'app.bsky.graph.block', 108 + limit: 100, 109 + cursor: cursor, 110 + ); 111 + 112 + total += (response.data.records as List<dynamic>).length; 113 + cursor = response.data.cursor as String?; 114 + } while (cursor != null); 115 + 116 + return total; 37 117 } 38 118 39 119 /// Returns a page of profiles that [did] is blocking, along with a cursor. 40 120 /// Uses `com.atproto.repo.listRecords` on the actor's own repo. 41 121 /// [total] reflects the number of profiles hydrated in this page. 42 - Future<({List<ProfileView> profiles, String? cursor, int total})> getBlockingProfiles( 43 - String did, { 44 - String? cursor, 45 - }) async { 122 + Future<({List<ProfileView> profiles, List<UnavailableProfileRef> unavailable, String? cursor, int total})> 123 + getBlockingProfiles(String did, {String? cursor}) async { 46 124 final response = await _bluesky.atproto.repo.listRecords( 47 125 repo: did, 48 126 collection: 'app.bsky.graph.block', ··· 50 128 cursor: cursor, 51 129 ); 52 130 53 - final subjectDids = (response.data.records as List<dynamic>) 54 - .map((r) => r.value['subject'] as String) 55 - .toList(); 56 - final profiles = await _hydrateProfiles(subjectDids); 57 - return (profiles: profiles, cursor: response.data.cursor as String?, total: profiles.length); 131 + final subjectDids = (response.data.records as List<dynamic>).map((r) => r.value['subject'] as String).toList(); 132 + final hydrated = await _hydrateProfiles(subjectDids); 133 + return ( 134 + profiles: hydrated.profiles, 135 + unavailable: hydrated.unavailable, 136 + cursor: response.data.cursor as String?, 137 + total: hydrated.profiles.length, 138 + ); 58 139 } 59 140 60 141 /// Returns a page of lists that [did] is a member of, along with the total ··· 65 146 did, 66 147 'app.bsky.graph.listitem:subject', 67 148 'list', 149 + limit: _listsPageSize, 68 150 cursor: cursor, 69 151 ); 70 152 71 - final lists = await Future.wait( 72 - result.items.map((item) async { 73 - final uri = AtUri.parse(item.otherSubject); 74 - final response = await _bluesky.graph.getList(list: uri, limit: 1); 75 - return response.data.list as ListView; 76 - }), 77 - ); 153 + final uniqueListUris = <String>{}; 154 + final listUris = <String>[]; 155 + for (final item in result.items) { 156 + if (uniqueListUris.add(item.otherSubject)) { 157 + listUris.add(item.otherSubject); 158 + } 159 + } 160 + 161 + final lists = <ListView>[]; 162 + for (final uriString in listUris) { 163 + try { 164 + final uri = AtUri.parse(uriString); 165 + final response = await _publicBluesky.graph.getList(list: uri, limit: 1); 166 + lists.add(response.data.list as ListView); 167 + } catch (error, stackTrace) { 168 + log.w( 169 + 'skipping invalid or unavailable list in profile context: $uriString', 170 + error: error, 171 + stackTrace: stackTrace, 172 + ); 173 + } 174 + } 78 175 79 176 return (lists: lists, cursor: result.cursor, total: total); 80 177 } 81 178 82 179 /// Hydrates [dids] into [ProfileView] objects in batches of 25. 83 - Future<List<ProfileView>> _hydrateProfiles(List<String> dids) async { 84 - if (dids.isEmpty) return []; 180 + Future<({List<ProfileView> profiles, List<UnavailableProfileRef> unavailable})> _hydrateProfiles( 181 + List<String> dids, 182 + ) async { 183 + final normalizedDids = _normalizeDids(dids); 184 + if (normalizedDids.isEmpty) { 185 + return (profiles: <ProfileView>[], unavailable: <UnavailableProfileRef>[]); 186 + } 187 + 85 188 final allProfiles = <ProfileView>[]; 86 - for (var i = 0; i < dids.length; i += 25) { 87 - final batch = dids.sublist(i, (i + 25).clamp(0, dids.length)); 88 - final response = await _bluesky.actor.getProfiles(actors: batch); 89 - allProfiles.addAll(response.data.profiles as List<ProfileView>); 189 + final unavailable = <UnavailableProfileRef>[]; 190 + for (var i = 0; i < normalizedDids.length; i += 25) { 191 + final batch = normalizedDids.sublist(i, (i + 25).clamp(0, normalizedDids.length)); 192 + final resolvedProfiles = <String, ProfileView>{}; 193 + log.d( 194 + 'ProfileContextRepository: blocked-by public batch ${i ~/ 25 + 1} size=${batch.length} dids=${batch.join(',')}', 195 + ); 196 + try { 197 + final response = await _publicBluesky.actor.getProfiles(actors: batch); 198 + for (final profile in response.data.profiles as List<dynamic>) { 199 + final converted = _asProfileView(profile); 200 + if (converted != null) { 201 + resolvedProfiles[converted.did] = converted; 202 + } 203 + } 204 + } catch (error, stackTrace) { 205 + log.w( 206 + 'ProfileContextRepository: blocked-by public batch failed, falling back to per-DID lookups for ${batch.length} actors', 207 + error: error, 208 + stackTrace: stackTrace, 209 + ); 210 + } 211 + 212 + for (final did in batch) { 213 + final existing = resolvedProfiles[did]; 214 + if (existing != null) { 215 + log.d('ProfileContextRepository: blocked-by public batch resolved $did'); 216 + allProfiles.add(existing); 217 + continue; 218 + } 219 + 220 + try { 221 + final response = await _publicBluesky.actor.getProfile(actor: did); 222 + final converted = _asProfileView(response.data); 223 + if (converted != null) { 224 + log.d('ProfileContextRepository: blocked-by per-DID resolved $did'); 225 + allProfiles.add(converted); 226 + } else { 227 + const reason = 'Public profile lookup failed'; 228 + unavailable.add(UnavailableProfileRef(did: did, reason: reason)); 229 + log.w('ProfileContextRepository: blocked-by per-DID returned an unsupported profile shape for $did'); 230 + } 231 + } catch (error, stackTrace) { 232 + final reason = _publicProfileFailureReason(error); 233 + unavailable.add(UnavailableProfileRef(did: did, reason: reason)); 234 + log.w( 235 + 'ProfileContextRepository: blocked-by per-DID failed for $did: $reason', 236 + error: error, 237 + stackTrace: stackTrace, 238 + ); 239 + } 240 + } 90 241 } 91 - return allProfiles; 242 + return (profiles: allProfiles, unavailable: unavailable); 92 243 } 244 + 245 + Future<({List<String> dids, int total})> _collectBlockedByDids(String did) async { 246 + try { 247 + final dids = <String>[]; 248 + final seen = <String>{}; 249 + var total = 0; 250 + String? cursor; 251 + log.i('ProfileContextRepository: blocked-by using getDistinct for $did'); 252 + 253 + do { 254 + final result = await _constellation.getDistinct( 255 + did, 256 + 'app.bsky.graph.block:subject', 257 + limit: _blockedByPageSize, 258 + cursor: cursor, 259 + ); 260 + final inputCursor = cursor; 261 + if (total == 0) { 262 + total = result.total; 263 + log.i('ProfileContextRepository: blocked-by count for $did is $total'); 264 + } 265 + for (final rawDid in result.dids) { 266 + final normalizedDid = _normalizeDid(rawDid); 267 + if (normalizedDid != null && seen.add(normalizedDid)) { 268 + dids.add(normalizedDid); 269 + } 270 + } 271 + cursor = result.cursor; 272 + log.d( 273 + 'ProfileContextRepository: blocked-by getDistinct page cursorIn=${inputCursor ?? 'null'} records=${result.dids.length} uniqueTotal=${dids.length} cursorOut=${cursor ?? 'null'}', 274 + ); 275 + } while (cursor != null); 276 + 277 + log.i('ProfileContextRepository: blocked-by collected ${dids.length} unique DIDs from getDistinct for $did'); 278 + return (dids: dids, total: total); 279 + } on ConstellationException catch (error) { 280 + if (!_isNotFound(error)) rethrow; 281 + log.w('ProfileContextRepository: blocked-by getDistinct returned 404 for $did, falling back to getBacklinks'); 282 + 283 + final dids = <String>[]; 284 + final seen = <String>{}; 285 + var total = 0; 286 + String? cursor; 287 + 288 + do { 289 + final result = await _constellation.getBacklinks( 290 + did, 291 + 'app.bsky.graph.block:subject', 292 + limit: _blockedByPageSize, 293 + cursor: cursor, 294 + ); 295 + final inputCursor = cursor; 296 + if (total == 0) { 297 + total = result.total; 298 + log.i('ProfileContextRepository: blocked-by count for $did is $total'); 299 + } 300 + for (final record in result.records) { 301 + final normalizedDid = _normalizeDid(record.did); 302 + if (normalizedDid != null && seen.add(normalizedDid)) { 303 + dids.add(normalizedDid); 304 + } 305 + } 306 + cursor = result.cursor; 307 + log.d( 308 + 'ProfileContextRepository: blocked-by getBacklinks page cursorIn=${inputCursor ?? 'null'} records=${result.records.length} uniqueTotal=${dids.length} cursorOut=${cursor ?? 'null'}', 309 + ); 310 + } while (cursor != null); 311 + 312 + log.i('ProfileContextRepository: blocked-by collected ${dids.length} unique DIDs from getBacklinks for $did'); 313 + return (dids: dids, total: total); 314 + } 315 + } 316 + 317 + List<String> _normalizeDids(List<String> dids) { 318 + final normalized = <String>[]; 319 + final seen = <String>{}; 320 + for (final did in dids) { 321 + final value = _normalizeDid(did); 322 + if (value != null && seen.add(value)) { 323 + normalized.add(value); 324 + } 325 + } 326 + return normalized; 327 + } 328 + 329 + String? _normalizeDid(String did) { 330 + final trimmed = did.trim(); 331 + if (trimmed.isEmpty) return null; 332 + return trimmed; 333 + } 334 + 335 + String _publicProfileFailureReason(Object error) { 336 + final message = error.toString(); 337 + final lowerMessage = message.toLowerCase(); 338 + if (message.contains('AccountTakedown') || lowerMessage.contains('account has been suspended')) { 339 + return 'Suspended account'; 340 + } 341 + if (message.contains('HTTP 404') || lowerMessage.contains('not found')) { 342 + return 'Profile unavailable'; 343 + } 344 + return 'Public profile lookup failed'; 345 + } 346 + 347 + ProfileView? _asProfileView(dynamic profile) { 348 + if (profile is ProfileView) { 349 + return profile; 350 + } 351 + 352 + if (profile is ProfileViewDetailed) { 353 + return ProfileView( 354 + did: profile.did, 355 + handle: profile.handle, 356 + displayName: profile.displayName, 357 + pronouns: profile.pronouns, 358 + description: profile.description, 359 + avatar: profile.avatar, 360 + associated: profile.associated, 361 + indexedAt: profile.indexedAt, 362 + createdAt: profile.createdAt, 363 + viewer: profile.viewer, 364 + labels: profile.labels, 365 + verification: profile.verification, 366 + status: profile.status, 367 + debug: profile.debug, 368 + ); 369 + } 370 + 371 + return null; 372 + } 373 + 374 + bool _isNotFound(ConstellationException error) => error.message.startsWith('HTTP 404'); 93 375 }
+479 -181
lib/features/profile/presentation/profile_context_screen.dart
··· 1 1 import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; 2 3 import 'package:flutter/material.dart'; 3 4 import 'package:flutter_bloc/flutter_bloc.dart'; 4 5 import 'package:go_router/go_router.dart'; 5 - import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart'; 6 6 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 7 7 import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; 8 + import 'package:lazurite/features/profile/data/profile_context_repository.dart'; 8 9 9 10 class ProfileContextScreen extends StatefulWidget { 10 11 const ProfileContextScreen({super.key, required this.handle}); ··· 113 114 114 115 return RefreshIndicator( 115 116 onRefresh: cubit.refreshBlockedBy, 116 - child: CustomScrollView( 117 - slivers: [ 118 - // Contextualizing note at the top. 119 - const SliverToBoxAdapter( 120 - child: Padding( 121 - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), 122 - child: Text( 123 - 'Blocks are a normal part of social media. ' 124 - 'This data is public on the AT Protocol.', 125 - textAlign: TextAlign.center, 117 + child: NotificationListener<ScrollNotification>( 118 + onNotification: (notification) => _maybeLoadMore( 119 + notification: notification, 120 + status: state.blockedByStatus, 121 + hasMore: state.blockedByHasMore, 122 + cursor: state.blockedByCursor, 123 + onLoadMore: (cursor) => cubit.loadBlockedBy(cursor: cursor), 124 + ), 125 + child: CustomScrollView( 126 + physics: const AlwaysScrollableScrollPhysics(), 127 + slivers: [ 128 + // Contextualizing note at the top. 129 + const SliverToBoxAdapter( 130 + child: Padding( 131 + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), 132 + child: Text( 133 + 'Blocks are a normal part of social media. ' 134 + 'This data is public on the AT Protocol.', 135 + textAlign: TextAlign.center, 136 + ), 126 137 ), 127 138 ), 128 - ), 129 - // Count header + expand button. 130 - SliverToBoxAdapter( 131 - child: Padding( 132 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 133 - child: Row( 134 - children: [ 135 - Text( 136 - '${state.blockedByCount} account${state.blockedByCount == 1 ? '' : 's'}', 137 - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 138 - ), 139 - const Spacer(), 140 - if (state.blockedByStatus == ProfileContextTabStatus.initial) 141 - TextButton.icon( 142 - key: const Key('blocked_by_show_accounts'), 143 - onPressed: () => cubit.loadBlockedBy(), 144 - icon: const Icon(Icons.expand_more), 145 - label: const Text('Show accounts'), 139 + // Count header + expand button. 140 + SliverToBoxAdapter( 141 + child: Padding( 142 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 143 + child: Row( 144 + children: [ 145 + Text( 146 + '${state.blockedByCount} account${state.blockedByCount == 1 ? '' : 's'}', 147 + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 146 148 ), 147 - ], 148 - ), 149 - ), 150 - ), 151 - // Content based on status. 152 - if (state.blockedByStatus == ProfileContextTabStatus.loading && state.blockedByProfiles.isEmpty) 153 - const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) 154 - else if (state.blockedByStatus == ProfileContextTabStatus.error && state.blockedByProfiles.isEmpty) 155 - SliverFillRemaining( 156 - hasScrollBody: false, 157 - child: Center( 158 - child: _ErrorRetry( 159 - message: state.blockedByError ?? 'Failed to load accounts', 160 - onRetry: () => cubit.loadBlockedBy(), 149 + const Spacer(), 150 + if (state.blockedByStatus == ProfileContextTabStatus.initial) 151 + TextButton.icon( 152 + key: const Key('blocked_by_show_accounts'), 153 + onPressed: () => cubit.loadBlockedBy(), 154 + icon: const Icon(Icons.expand_more), 155 + label: const Text('Show accounts'), 156 + ), 157 + ], 161 158 ), 162 159 ), 163 - ) 164 - else if (state.blockedByStatus == ProfileContextTabStatus.loaded && state.blockedByProfiles.isEmpty) 165 - const SliverFillRemaining( 166 - hasScrollBody: false, 167 - child: Center(child: Text('No accounts have blocked this user')), 168 - ) 169 - else ...[ 170 - SliverList.builder( 171 - itemCount: state.blockedByProfiles.length, 172 - itemBuilder: (context, index) { 173 - final profile = state.blockedByProfiles[index]; 174 - return _ProfileTile( 175 - key: ValueKey('blocked_by_${profile.did}'), 176 - profile: profile, 177 - onTap: () => context.push('/profile/view?actor=${profile.did}'), 178 - ); 179 - }, 180 160 ), 181 - if (state.blockedByStatus == ProfileContextTabStatus.loading) 182 - const SliverToBoxAdapter( 183 - child: Padding( 184 - padding: EdgeInsets.all(16), 185 - child: Center(child: CircularProgressIndicator()), 161 + // Content based on status. 162 + if (state.blockedByStatus == ProfileContextTabStatus.loading && state.blockedByEntries.isEmpty) 163 + const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) 164 + else if (state.blockedByStatus == ProfileContextTabStatus.error && state.blockedByEntries.isEmpty) 165 + SliverFillRemaining( 166 + hasScrollBody: false, 167 + child: Center( 168 + child: _ErrorRetry( 169 + message: state.blockedByError ?? 'Failed to load accounts', 170 + onRetry: () => cubit.loadBlockedBy(), 171 + ), 186 172 ), 187 173 ) 188 - else if (state.blockedByStatus == ProfileContextTabStatus.error) 189 - SliverToBoxAdapter( 190 - child: Padding( 191 - padding: const EdgeInsets.all(16), 192 - child: _ErrorRetry( 193 - message: state.blockedByError ?? 'Failed to load more', 194 - onRetry: () => cubit.loadBlockedBy(cursor: state.blockedByCursor), 174 + else if (state.blockedByStatus == ProfileContextTabStatus.loaded && state.blockedByEntries.isEmpty) 175 + SliverFillRemaining( 176 + hasScrollBody: false, 177 + child: Center( 178 + child: Text( 179 + state.blockedByCount > 0 180 + ? 'Found ${state.blockedByCount} blocked-by accounts, but public Bluesky profile details could not be loaded.' 181 + : 'No accounts have blocked this user', 182 + textAlign: TextAlign.center, 195 183 ), 196 184 ), 197 185 ) 198 - else if (state.blockedByHasMore) 199 - SliverToBoxAdapter( 200 - child: Padding( 201 - padding: const EdgeInsets.all(16), 202 - child: Center( 203 - child: TextButton( 204 - onPressed: () => cubit.loadBlockedBy(cursor: state.blockedByCursor), 205 - child: const Text('Load more'), 186 + else ...[ 187 + SliverList.builder( 188 + itemCount: state.blockedByEntries.length, 189 + itemBuilder: (context, index) { 190 + final entry = state.blockedByEntries[index]; 191 + if (entry.profile != null) { 192 + final profile = entry.profile!; 193 + return _ProfileTile( 194 + key: ValueKey('blocked_by_${profile.did}'), 195 + profile: profile, 196 + onTap: () => 197 + context.push('/profile/view?actor=${Uri.encodeQueryComponent(_profileActor(profile))}'), 198 + ); 199 + } 200 + 201 + return _UnavailableProfileTile( 202 + key: ValueKey('blocked_by_unavailable_${entry.did}'), 203 + did: entry.did, 204 + reason: entry.unavailableReason ?? 'Profile unavailable', 205 + ); 206 + }, 207 + ), 208 + if (state.blockedByStatus == ProfileContextTabStatus.loading) 209 + const SliverToBoxAdapter( 210 + child: Padding( 211 + padding: EdgeInsets.all(16), 212 + child: Center(child: CircularProgressIndicator()), 213 + ), 214 + ) 215 + else if (state.blockedByStatus == ProfileContextTabStatus.error) 216 + SliverToBoxAdapter( 217 + child: Padding( 218 + padding: const EdgeInsets.all(16), 219 + child: _ErrorRetry( 220 + message: state.blockedByError ?? 'Failed to load more', 221 + onRetry: () => cubit.loadBlockedBy(cursor: state.blockedByCursor), 206 222 ), 207 223 ), 208 224 ), 209 - ), 225 + ], 210 226 ], 211 - ], 227 + ), 212 228 ), 213 229 ); 214 230 } ··· 241 257 242 258 return RefreshIndicator( 243 259 onRefresh: cubit.refreshBlocking, 244 - child: CustomScrollView( 245 - slivers: [ 246 - if (state.blockingStatus == ProfileContextTabStatus.initial || 247 - (state.blockingStatus == ProfileContextTabStatus.loading && state.blockingProfiles.isEmpty)) 248 - const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) 249 - else if (state.blockingStatus == ProfileContextTabStatus.error && state.blockingProfiles.isEmpty) 250 - SliverFillRemaining( 251 - hasScrollBody: false, 252 - child: Center( 253 - child: _ErrorRetry( 254 - message: state.blockingError ?? 'Failed to load accounts', 255 - onRetry: () => cubit.loadBlocking(), 260 + child: NotificationListener<ScrollNotification>( 261 + onNotification: (notification) => _maybeLoadMore( 262 + notification: notification, 263 + status: state.blockingStatus, 264 + hasMore: state.blockingHasMore, 265 + cursor: state.blockingCursor, 266 + onLoadMore: (cursor) => cubit.loadBlocking(cursor: cursor), 267 + ), 268 + child: CustomScrollView( 269 + physics: const AlwaysScrollableScrollPhysics(), 270 + slivers: [ 271 + SliverToBoxAdapter( 272 + child: Padding( 273 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), 274 + child: Text( 275 + '${state.blockingCount} account${state.blockingCount == 1 ? '' : 's'}', 276 + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 256 277 ), 257 278 ), 258 - ) 259 - else if (state.blockingStatus == ProfileContextTabStatus.loaded && state.blockingProfiles.isEmpty) 260 - const SliverFillRemaining(hasScrollBody: false, child: Center(child: Text('Not blocking anyone'))) 261 - else ...[ 262 - SliverList.builder( 263 - itemCount: state.blockingProfiles.length, 264 - itemBuilder: (context, index) { 265 - final profile = state.blockingProfiles[index]; 266 - return _ProfileTile( 267 - key: ValueKey('blocking_${profile.did}'), 268 - profile: profile, 269 - onTap: () => context.push('/profile/view?actor=${profile.did}'), 270 - ); 271 - }, 272 279 ), 273 - if (state.blockingStatus == ProfileContextTabStatus.loading) 274 - const SliverToBoxAdapter( 280 + if (state.blockingUnavailable.isNotEmpty) 281 + SliverToBoxAdapter( 275 282 child: Padding( 276 - padding: EdgeInsets.all(16), 277 - child: Center(child: CircularProgressIndicator()), 283 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), 284 + child: _UnavailableAccountsCard(entries: state.blockingUnavailable), 285 + ), 286 + ), 287 + if (state.blockingStatus == ProfileContextTabStatus.initial || 288 + (state.blockingStatus == ProfileContextTabStatus.loading && state.blockingProfiles.isEmpty)) 289 + const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) 290 + else if (state.blockingStatus == ProfileContextTabStatus.error && state.blockingProfiles.isEmpty) 291 + SliverFillRemaining( 292 + hasScrollBody: false, 293 + child: Center( 294 + child: _ErrorRetry( 295 + message: state.blockingError ?? 'Failed to load accounts', 296 + onRetry: () => cubit.loadBlocking(), 297 + ), 278 298 ), 279 299 ) 280 - else if (state.blockingStatus == ProfileContextTabStatus.error) 281 - SliverToBoxAdapter( 282 - child: Padding( 283 - padding: const EdgeInsets.all(16), 284 - child: _ErrorRetry( 285 - message: state.blockingError ?? 'Failed to load more', 286 - onRetry: () => cubit.loadBlocking(cursor: state.blockingCursor), 300 + else if (state.blockingStatus == ProfileContextTabStatus.loaded && state.blockingProfiles.isEmpty) 301 + SliverFillRemaining( 302 + hasScrollBody: false, 303 + child: Center( 304 + child: Text( 305 + state.blockingUnavailable.isNotEmpty 306 + ? 'Some blocked accounts are suspended or unavailable.' 307 + : 'Not blocking anyone', 308 + textAlign: TextAlign.center, 287 309 ), 288 310 ), 289 311 ) 290 - else if (state.blockingHasMore) 291 - SliverToBoxAdapter( 292 - child: Padding( 293 - padding: const EdgeInsets.all(16), 294 - child: Center( 295 - child: TextButton( 296 - onPressed: () => cubit.loadBlocking(cursor: state.blockingCursor), 297 - child: const Text('Load more'), 312 + else ...[ 313 + SliverList.builder( 314 + itemCount: state.blockingProfiles.length, 315 + itemBuilder: (context, index) { 316 + final profile = state.blockingProfiles[index]; 317 + return _ProfileTile( 318 + key: ValueKey('blocking_${profile.did}'), 319 + profile: profile, 320 + onTap: () => 321 + context.push('/profile/view?actor=${Uri.encodeQueryComponent(_profileActor(profile))}'), 322 + ); 323 + }, 324 + ), 325 + if (state.blockingStatus == ProfileContextTabStatus.loading) 326 + const SliverToBoxAdapter( 327 + child: Padding( 328 + padding: EdgeInsets.all(16), 329 + child: Center(child: CircularProgressIndicator()), 330 + ), 331 + ) 332 + else if (state.blockingStatus == ProfileContextTabStatus.error) 333 + SliverToBoxAdapter( 334 + child: Padding( 335 + padding: const EdgeInsets.all(16), 336 + child: _ErrorRetry( 337 + message: state.blockingError ?? 'Failed to load more', 338 + onRetry: () => cubit.loadBlocking(cursor: state.blockingCursor), 298 339 ), 299 340 ), 300 341 ), 301 - ), 342 + ], 302 343 ], 303 - ], 344 + ), 304 345 ), 305 346 ); 306 347 } ··· 321 362 322 363 return RefreshIndicator( 323 364 onRefresh: cubit.refreshListsOn, 324 - child: CustomScrollView( 325 - slivers: [ 326 - if (state.listsOnStatus == ProfileContextTabStatus.initial || 327 - (state.listsOnStatus == ProfileContextTabStatus.loading && state.listsOn.isEmpty)) 328 - const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) 329 - else if (state.listsOnStatus == ProfileContextTabStatus.error && state.listsOn.isEmpty) 330 - SliverFillRemaining( 331 - hasScrollBody: false, 332 - child: Center( 333 - child: _ErrorRetry( 334 - message: state.listsOnError ?? 'Failed to load lists', 335 - onRetry: () => cubit.loadListsOn(), 336 - ), 337 - ), 338 - ) 339 - else if (state.listsOnStatus == ProfileContextTabStatus.loaded && state.listsOn.isEmpty) 340 - const SliverFillRemaining(hasScrollBody: false, child: Center(child: Text('Not on any lists'))) 341 - else ...[ 342 - SliverList.builder( 343 - itemCount: state.listsOn.length, 344 - itemBuilder: (context, index) { 345 - final list = state.listsOn[index]; 346 - return ListRowTile( 347 - key: ValueKey('list_on_${list.uri}'), 348 - list: list, 349 - onTap: () => context.push('/list?uri=${Uri.encodeComponent(list.uri.toString())}'), 350 - ); 351 - }, 352 - ), 353 - if (state.listsOnStatus == ProfileContextTabStatus.loading) 354 - const SliverToBoxAdapter( 355 - child: Padding( 356 - padding: EdgeInsets.all(16), 357 - child: Center(child: CircularProgressIndicator()), 358 - ), 359 - ) 360 - else if (state.listsOnStatus == ProfileContextTabStatus.error) 361 - SliverToBoxAdapter( 362 - child: Padding( 363 - padding: const EdgeInsets.all(16), 365 + child: NotificationListener<ScrollNotification>( 366 + onNotification: (notification) => _maybeLoadMore( 367 + notification: notification, 368 + status: state.listsOnStatus, 369 + hasMore: state.listsOnHasMore, 370 + cursor: state.listsOnCursor, 371 + onLoadMore: (cursor) => cubit.loadListsOn(cursor: cursor), 372 + ), 373 + child: CustomScrollView( 374 + physics: const AlwaysScrollableScrollPhysics(), 375 + slivers: [ 376 + if (state.listsOnStatus == ProfileContextTabStatus.initial || 377 + (state.listsOnStatus == ProfileContextTabStatus.loading && state.listsOn.isEmpty)) 378 + const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) 379 + else if (state.listsOnStatus == ProfileContextTabStatus.error && state.listsOn.isEmpty) 380 + SliverFillRemaining( 381 + hasScrollBody: false, 382 + child: Center( 364 383 child: _ErrorRetry( 365 - message: state.listsOnError ?? 'Failed to load more', 366 - onRetry: () => cubit.loadListsOn(cursor: state.listsOnCursor), 384 + message: state.listsOnError ?? 'Failed to load lists', 385 + onRetry: () => cubit.loadListsOn(), 367 386 ), 368 387 ), 369 388 ) 370 - else if (state.listsOnHasMore) 371 - SliverToBoxAdapter( 372 - child: Padding( 373 - padding: const EdgeInsets.all(16), 374 - child: Center( 375 - child: TextButton( 376 - onPressed: () => cubit.loadListsOn(cursor: state.listsOnCursor), 377 - child: const Text('Load more'), 389 + else if (state.listsOnStatus == ProfileContextTabStatus.loaded && state.listsOn.isEmpty) 390 + const SliverFillRemaining(hasScrollBody: false, child: Center(child: Text('Not on any lists'))) 391 + else ...[ 392 + ..._buildListSections( 393 + context, 394 + state.listsOn, 395 + (list) => context.push('/list?uri=${Uri.encodeComponent(list.uri.toString())}'), 396 + ), 397 + if (state.listsOnStatus == ProfileContextTabStatus.loading) 398 + const SliverToBoxAdapter( 399 + child: Padding( 400 + padding: EdgeInsets.all(16), 401 + child: Center(child: CircularProgressIndicator()), 402 + ), 403 + ) 404 + else if (state.listsOnStatus == ProfileContextTabStatus.error) 405 + SliverToBoxAdapter( 406 + child: Padding( 407 + padding: const EdgeInsets.all(16), 408 + child: _ErrorRetry( 409 + message: state.listsOnError ?? 'Failed to load more', 410 + onRetry: () => cubit.loadListsOn(cursor: state.listsOnCursor), 378 411 ), 379 412 ), 380 413 ), 381 - ), 414 + ], 382 415 ], 383 - ], 416 + ), 384 417 ), 385 418 ); 386 419 } ··· 415 448 if (parts.isEmpty || parts.first.isEmpty) return '?'; 416 449 if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); 417 450 return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 451 + } 452 + } 453 + 454 + class _UnavailableAccountsCard extends StatelessWidget { 455 + const _UnavailableAccountsCard({required this.entries}); 456 + 457 + final List<UnavailableProfileRef> entries; 458 + 459 + @override 460 + Widget build(BuildContext context) { 461 + final colorScheme = Theme.of(context).colorScheme; 462 + return Card( 463 + child: Padding( 464 + padding: const EdgeInsets.symmetric(vertical: 8), 465 + child: Column( 466 + crossAxisAlignment: CrossAxisAlignment.start, 467 + children: [ 468 + ListTile( 469 + leading: const Icon(Icons.warning_amber_rounded), 470 + title: Text('Unavailable accounts (${entries.length})'), 471 + subtitle: const Text('These accounts are suspended or their public profile could not be fetched.'), 472 + ), 473 + for (final entry in entries) 474 + ListTile( 475 + dense: true, 476 + leading: Icon(Icons.person_off_outlined, color: colorScheme.onSurfaceVariant), 477 + title: Text(entry.did, maxLines: 1, overflow: TextOverflow.ellipsis), 478 + subtitle: Text(entry.reason), 479 + ), 480 + ], 481 + ), 482 + ), 483 + ); 484 + } 485 + } 486 + 487 + class _UnavailableProfileTile extends StatelessWidget { 488 + const _UnavailableProfileTile({super.key, required this.did, required this.reason}); 489 + 490 + final String did; 491 + final String reason; 492 + 493 + @override 494 + Widget build(BuildContext context) { 495 + final colorScheme = Theme.of(context).colorScheme; 496 + return ListTile( 497 + leading: Icon(Icons.person_off_outlined, color: colorScheme.onSurfaceVariant), 498 + title: Text(did, maxLines: 1, overflow: TextOverflow.ellipsis), 499 + subtitle: Text(reason), 500 + enabled: false, 501 + ); 502 + } 503 + } 504 + 505 + String _profileActor(ProfileView profile) { 506 + final handle = profile.handle.trim(); 507 + return handle.isNotEmpty ? handle : profile.did; 508 + } 509 + 510 + bool _maybeLoadMore({ 511 + required ScrollNotification notification, 512 + required ProfileContextTabStatus status, 513 + required bool hasMore, 514 + required String? cursor, 515 + required ValueChanged<String?> onLoadMore, 516 + }) { 517 + if (notification.metrics.extentAfter > 300 || 518 + status == ProfileContextTabStatus.loading || 519 + !hasMore || 520 + cursor == null) { 521 + return false; 522 + } 523 + 524 + onLoadMore(cursor); 525 + return false; 526 + } 527 + 528 + List<Widget> _buildListSections( 529 + BuildContext context, 530 + List<bsky_graph.ListView> lists, 531 + ValueChanged<bsky_graph.ListView> onTap, 532 + ) { 533 + final sections = _groupListsByPurpose(lists); 534 + return [ 535 + for (final section in sections) ...[ 536 + SliverToBoxAdapter( 537 + child: Padding( 538 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), 539 + child: Text( 540 + section.title, 541 + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), 542 + ), 543 + ), 544 + ), 545 + SliverList.builder( 546 + itemCount: section.lists.length, 547 + itemBuilder: (context, index) { 548 + final list = section.lists[index]; 549 + return _ListContextCard(key: ValueKey('list_on_${list.uri}'), list: list, onTap: () => onTap(list)); 550 + }, 551 + ), 552 + ], 553 + ]; 554 + } 555 + 556 + List<({String title, List<bsky_graph.ListView> lists})> _groupListsByPurpose(List<bsky_graph.ListView> lists) { 557 + final buckets = <_ListPurposeGroup, List<bsky_graph.ListView>>{ 558 + _ListPurposeGroup.curation: [], 559 + _ListPurposeGroup.moderation: [], 560 + _ListPurposeGroup.reference: [], 561 + _ListPurposeGroup.other: [], 562 + }; 563 + 564 + for (final list in lists) { 565 + buckets[_purposeGroupFor(list)]!.add(list); 566 + } 567 + 568 + return [ 569 + (title: 'Curation Lists', lists: buckets[_ListPurposeGroup.curation]!), 570 + (title: 'Moderation Lists', lists: buckets[_ListPurposeGroup.moderation]!), 571 + (title: 'Reference Lists', lists: buckets[_ListPurposeGroup.reference]!), 572 + (title: 'Other Lists', lists: buckets[_ListPurposeGroup.other]!), 573 + ].where((section) => section.lists.isNotEmpty).toList(); 574 + } 575 + 576 + _ListPurposeGroup _purposeGroupFor(bsky_graph.ListView list) { 577 + switch (list.purpose.knownValue) { 578 + case bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist: 579 + return _ListPurposeGroup.curation; 580 + case bsky_graph.KnownListPurpose.appBskyGraphDefsModlist: 581 + return _ListPurposeGroup.moderation; 582 + case bsky_graph.KnownListPurpose.appBskyGraphDefsReferencelist: 583 + return _ListPurposeGroup.reference; 584 + case null: 585 + return _ListPurposeGroup.other; 586 + } 587 + } 588 + 589 + enum _ListPurposeGroup { curation, moderation, reference, other } 590 + 591 + class _ListContextCard extends StatelessWidget { 592 + const _ListContextCard({super.key, required this.list, this.onTap}); 593 + 594 + final bsky_graph.ListView list; 595 + final VoidCallback? onTap; 596 + 597 + @override 598 + Widget build(BuildContext context) { 599 + final colorScheme = Theme.of(context).colorScheme; 600 + final description = list.description?.trim(); 601 + 602 + return Padding( 603 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), 604 + child: Card( 605 + margin: EdgeInsets.zero, 606 + clipBehavior: Clip.antiAlias, 607 + child: InkWell( 608 + onTap: onTap, 609 + child: Padding( 610 + padding: const EdgeInsets.all(16), 611 + child: Column( 612 + crossAxisAlignment: CrossAxisAlignment.start, 613 + children: [ 614 + Row( 615 + crossAxisAlignment: CrossAxisAlignment.start, 616 + children: [ 617 + CircleAvatar( 618 + backgroundImage: list.avatar != null ? NetworkImage(list.avatar!) : null, 619 + backgroundColor: colorScheme.surfaceContainerHighest, 620 + child: list.avatar == null ? Icon(Icons.list, color: colorScheme.onSurfaceVariant) : null, 621 + ), 622 + const SizedBox(width: 12), 623 + Expanded( 624 + child: Column( 625 + crossAxisAlignment: CrossAxisAlignment.start, 626 + children: [ 627 + Text(list.name, maxLines: 1, overflow: TextOverflow.ellipsis), 628 + const SizedBox(height: 4), 629 + Text( 630 + '@${list.creator.handle}', 631 + maxLines: 1, 632 + overflow: TextOverflow.ellipsis, 633 + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 634 + ), 635 + ], 636 + ), 637 + ), 638 + const SizedBox(width: 12), 639 + _ListPurposeBadge(purpose: list.purpose.knownValue), 640 + ], 641 + ), 642 + const SizedBox(height: 12), 643 + _ListMetaChip( 644 + icon: Icons.group_outlined, 645 + label: '${list.listItemCount ?? 0} member${(list.listItemCount ?? 0) == 1 ? '' : 's'}', 646 + ), 647 + if (description != null && description.isNotEmpty) ...[ 648 + const SizedBox(height: 12), 649 + Text(description, maxLines: 2, overflow: TextOverflow.ellipsis), 650 + ], 651 + ], 652 + ), 653 + ), 654 + ), 655 + ), 656 + ); 657 + } 658 + } 659 + 660 + class _ListPurposeBadge extends StatelessWidget { 661 + const _ListPurposeBadge({required this.purpose}); 662 + 663 + final bsky_graph.KnownListPurpose? purpose; 664 + 665 + @override 666 + Widget build(BuildContext context) { 667 + final colorScheme = Theme.of(context).colorScheme; 668 + final (label, color) = switch (purpose) { 669 + bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist => ('CURATE', colorScheme.primary), 670 + bsky_graph.KnownListPurpose.appBskyGraphDefsModlist => ('MOD', colorScheme.error), 671 + bsky_graph.KnownListPurpose.appBskyGraphDefsReferencelist => ('REFERENCE', colorScheme.tertiary), 672 + null => ('LIST', colorScheme.secondary), 673 + }; 674 + 675 + return Container( 676 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), 677 + decoration: BoxDecoration( 678 + color: color.withValues(alpha: 0.1), 679 + borderRadius: BorderRadius.circular(999), 680 + border: Border.all(color: color.withValues(alpha: 0.25)), 681 + ), 682 + child: Text( 683 + label, 684 + style: Theme.of( 685 + context, 686 + ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700, color: color, letterSpacing: 0.6), 687 + ), 688 + ); 689 + } 690 + } 691 + 692 + class _ListMetaChip extends StatelessWidget { 693 + const _ListMetaChip({required this.icon, required this.label}); 694 + 695 + final IconData icon; 696 + final String label; 697 + 698 + @override 699 + Widget build(BuildContext context) { 700 + final colorScheme = Theme.of(context).colorScheme; 701 + 702 + return DecoratedBox( 703 + decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(999)), 704 + child: Padding( 705 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), 706 + child: Row( 707 + mainAxisSize: MainAxisSize.min, 708 + children: [ 709 + Icon(icon, size: 16, color: colorScheme.onSurfaceVariant), 710 + const SizedBox(width: 6), 711 + Text(label, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)), 712 + ], 713 + ), 714 + ), 715 + ); 418 716 } 419 717 } 420 718
+16 -9
lib/main.dart
··· 66 66 67 67 log.i('AppLogger: App started'); 68 68 69 - runApp( 70 - LazuriteApp( 71 - authBloc: authBloc, 72 - database: database, 73 - settingsCubit: settingsCubit, 74 - connectivityCubit: connectivityCubit, 75 - accountSwitcherCubit: accountSwitcherCubit, 76 - ), 77 - ); 69 + runApp(LazuriteApp.from(authBloc, database, settingsCubit, connectivityCubit, accountSwitcherCubit)); 78 70 } 79 71 80 72 class LazuriteApp extends StatefulWidget { ··· 92 84 final SettingsCubit settingsCubit; 93 85 final ConnectivityCubit connectivityCubit; 94 86 final AccountSwitcherCubit accountSwitcherCubit; 87 + 88 + /// factory constructor with positional params 89 + static LazuriteApp from( 90 + AuthBloc authBloc, 91 + AppDatabase database, 92 + SettingsCubit settingsCubit, 93 + ConnectivityCubit connectivityCubit, 94 + AccountSwitcherCubit accountSwitcherCubit, 95 + ) => LazuriteApp( 96 + authBloc: authBloc, 97 + database: database, 98 + settingsCubit: settingsCubit, 99 + connectivityCubit: connectivityCubit, 100 + accountSwitcherCubit: accountSwitcherCubit, 101 + ); 95 102 96 103 @override 97 104 State<LazuriteApp> createState() => _LazuriteAppState();
+2 -1
scripts/package.json
··· 8 8 "scripts": { 9 9 "start": "bun run index.ts", 10 10 "build": "bun build index.ts --outfile=dist/splash-gen --target=bun", 11 - "generate": "bun run index.ts" 11 + "generate": "bun run index.ts", 12 + "profile-context": "bun run profile-context.ts" 12 13 }, 13 14 "devDependencies": { 14 15 "@types/bun": "latest"
+318
scripts/profile-context.ts
··· 1 + #!/usr/bin/env bun 2 + 3 + import { parseArgs } from "util"; 4 + 5 + const MICROCOSM_XRPC_BASE = "https://constellation.microcosm.blue/xrpc"; 6 + const PUBLIC_BSKY_XRPC_BASE = "https://public.api.bsky.app/xrpc"; 7 + const BLOCK_SOURCE = "app.bsky.graph.block:subject"; 8 + const BACKLINK_LIMIT = 16; 9 + const PROFILE_BATCH_SIZE = 25; 10 + 11 + type JsonMap = Record<string, unknown>; 12 + 13 + type FetchResult = { 14 + status: number; 15 + ok: boolean; 16 + text: string; 17 + data: JsonMap | null; 18 + }; 19 + 20 + type OrderedEntry = 21 + | { 22 + did: string; 23 + status: "resolved"; 24 + handle: string; 25 + displayName: string | null; 26 + } 27 + | { 28 + did: string; 29 + status: "unavailable"; 30 + reason: string; 31 + }; 32 + 33 + function buildXrpcUrl(base: string, method: string, params: Record<string, string | number | null | undefined>): URL { 34 + const url = new URL(`${base}/${method}`); 35 + for (const [key, value] of Object.entries(params)) { 36 + if (value !== null && value !== undefined) { 37 + url.searchParams.set(key, String(value)); 38 + } 39 + } 40 + return url; 41 + } 42 + 43 + async function fetchJson(url: URL): Promise<FetchResult> { 44 + const response = await fetch(url, { 45 + headers: { 46 + Accept: "application/json", 47 + "User-Agent": "lazurite", 48 + }, 49 + }); 50 + const text = await response.text(); 51 + 52 + let data: JsonMap | null = null; 53 + try { 54 + data = text.length > 0 ? (JSON.parse(text) as JsonMap) : null; 55 + } catch { 56 + data = null; 57 + } 58 + 59 + return { 60 + status: response.status, 61 + ok: response.ok, 62 + text, 63 + data, 64 + }; 65 + } 66 + 67 + function getListFieldAny(data: JsonMap | null, keys: string[]): unknown[] { 68 + if (!data) return []; 69 + 70 + for (const key of keys) { 71 + const value = data[key]; 72 + if (Array.isArray(value)) { 73 + return value; 74 + } 75 + } 76 + 77 + return []; 78 + } 79 + 80 + function asString(value: unknown): string | null { 81 + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; 82 + } 83 + 84 + function classifyProfileFailure(result: FetchResult): string { 85 + const error = asString(result.data?.error); 86 + const message = asString(result.data?.message) ?? result.text; 87 + const combined = `${error ?? ""} ${message}`.toLowerCase(); 88 + 89 + if (combined.includes("accounttakedown") || combined.includes("suspended")) { 90 + return "Suspended account"; 91 + } 92 + if (result.status === 404 || combined.includes("not found")) { 93 + return "Profile unavailable"; 94 + } 95 + return "Public profile lookup failed"; 96 + } 97 + 98 + function chunk<T>(items: T[], size: number): T[][] { 99 + const output: T[][] = []; 100 + for (let index = 0; index < items.length; index += size) { 101 + output.push(items.slice(index, index + size)); 102 + } 103 + return output; 104 + } 105 + 106 + async function fetchBlockedByCount(did: string): Promise<number> { 107 + const url = buildXrpcUrl(MICROCOSM_XRPC_BASE, "blue.microcosm.links.getBacklinksCount", { 108 + subject: did, 109 + source: BLOCK_SOURCE, 110 + }); 111 + const result = await fetchJson(url); 112 + 113 + if (!result.ok || typeof result.data?.total !== "number") { 114 + throw new Error(`getBacklinksCount failed (${result.status}): ${result.text}`); 115 + } 116 + 117 + return result.data.total as number; 118 + } 119 + 120 + async function fetchDistinct(did: string): Promise<FetchResult> { 121 + const url = buildXrpcUrl(MICROCOSM_XRPC_BASE, "blue.microcosm.links.getDistinct", { 122 + subject: did, 123 + source: BLOCK_SOURCE, 124 + limit: BACKLINK_LIMIT, 125 + }); 126 + return fetchJson(url); 127 + } 128 + 129 + async function fetchAllBacklinkDids( 130 + did: string, 131 + ): Promise<{ orderedDids: string[]; pages: Array<{ page: number; cursorIn: string | null; cursorOut: string | null; recordCount: number }> }> { 132 + const orderedDids: string[] = []; 133 + const seen = new Set<string>(); 134 + const pages: Array<{ page: number; cursorIn: string | null; cursorOut: string | null; recordCount: number }> = []; 135 + 136 + let cursor: string | null = null; 137 + let page = 1; 138 + 139 + do { 140 + const url = buildXrpcUrl(MICROCOSM_XRPC_BASE, "blue.microcosm.links.getBacklinks", { 141 + subject: did, 142 + source: BLOCK_SOURCE, 143 + limit: BACKLINK_LIMIT, 144 + cursor, 145 + }); 146 + const result = await fetchJson(url); 147 + if (!result.ok) { 148 + throw new Error(`getBacklinks failed (${result.status}) on page ${page}: ${result.text}`); 149 + } 150 + 151 + const records = getListFieldAny(result.data, ["records", "linking_records"]); 152 + for (const record of records) { 153 + if (record && typeof record === "object") { 154 + const blockerDid = asString((record as JsonMap).did); 155 + if (blockerDid && !seen.has(blockerDid)) { 156 + seen.add(blockerDid); 157 + orderedDids.push(blockerDid); 158 + } 159 + } 160 + } 161 + 162 + const nextCursor = asString(result.data?.cursor); 163 + pages.push({ 164 + page, 165 + cursorIn: cursor, 166 + cursorOut: nextCursor, 167 + recordCount: records.length, 168 + }); 169 + cursor = nextCursor; 170 + page += 1; 171 + } while (cursor !== null); 172 + 173 + return { orderedDids, pages }; 174 + } 175 + 176 + async function fetchProfilesBatch(dids: string[]): Promise<Map<string, OrderedEntry>> { 177 + const resolved = new Map<string, OrderedEntry>(); 178 + 179 + for (const batch of chunk(dids, PROFILE_BATCH_SIZE)) { 180 + const url = new URL(`${PUBLIC_BSKY_XRPC_BASE}/app.bsky.actor.getProfiles`); 181 + for (const did of batch) { 182 + url.searchParams.append("actors", did); 183 + } 184 + 185 + const batchResult = await fetchJson(url); 186 + const batchProfiles = batchResult.ok ? getListFieldAny(batchResult.data, ["profiles"]) : []; 187 + const batchResolved = new Map<string, OrderedEntry>(); 188 + 189 + for (const profile of batchProfiles) { 190 + if (!profile || typeof profile !== "object") continue; 191 + const record = profile as JsonMap; 192 + const did = asString(record.did); 193 + const handle = asString(record.handle); 194 + if (!did || !handle) continue; 195 + 196 + batchResolved.set(did, { 197 + did, 198 + status: "resolved", 199 + handle, 200 + displayName: asString(record.displayName), 201 + }); 202 + } 203 + 204 + for (const did of batch) { 205 + const existing = batchResolved.get(did); 206 + if (existing) { 207 + resolved.set(did, existing); 208 + continue; 209 + } 210 + 211 + const singleUrl = buildXrpcUrl(PUBLIC_BSKY_XRPC_BASE, "app.bsky.actor.getProfile", { 212 + actor: did, 213 + }); 214 + const singleResult = await fetchJson(singleUrl); 215 + 216 + if (singleResult.ok) { 217 + const handle = asString(singleResult.data?.handle); 218 + if (handle) { 219 + resolved.set(did, { 220 + did, 221 + status: "resolved", 222 + handle, 223 + displayName: asString(singleResult.data?.displayName), 224 + }); 225 + continue; 226 + } 227 + } 228 + 229 + resolved.set(did, { 230 + did, 231 + status: "unavailable", 232 + reason: classifyProfileFailure(singleResult), 233 + }); 234 + } 235 + } 236 + 237 + return resolved; 238 + } 239 + 240 + function printUsage(): void { 241 + console.error("Usage: bun run profile-context.ts --did <did:plc:...>"); 242 + } 243 + 244 + async function main(): Promise<void> { 245 + const { values } = parseArgs({ 246 + args: Bun.argv.slice(2), 247 + options: { 248 + did: { 249 + type: "string", 250 + }, 251 + help: { 252 + type: "boolean", 253 + short: "h", 254 + default: false, 255 + }, 256 + }, 257 + strict: true, 258 + allowPositionals: false, 259 + }); 260 + 261 + if (values.help) { 262 + printUsage(); 263 + process.exit(0); 264 + } 265 + 266 + const did = asString(values.did); 267 + if (!did) { 268 + printUsage(); 269 + process.exit(1); 270 + } 271 + 272 + const count = await fetchBlockedByCount(did); 273 + const distinct = await fetchDistinct(did); 274 + const backlinks = await fetchAllBacklinkDids(did); 275 + const hydrated = await fetchProfilesBatch(backlinks.orderedDids); 276 + const orderedEntries = backlinks.orderedDids.map((blockerDid) => { 277 + return ( 278 + hydrated.get(blockerDid) ?? { 279 + did: blockerDid, 280 + status: "unavailable" as const, 281 + reason: "Public profile lookup failed", 282 + } 283 + ); 284 + }); 285 + 286 + const resolvedCount = orderedEntries.filter((entry) => entry.status === "resolved").length; 287 + const unavailableEntries = orderedEntries.filter((entry) => entry.status === "unavailable"); 288 + 289 + console.log(`Target DID: ${did}`); 290 + console.log(`Microcosm blocked-by count: ${count}`); 291 + console.log( 292 + `Microcosm getDistinct: HTTP ${distinct.status}${distinct.ok ? "" : ` ${distinct.text.trim()}`}`, 293 + ); 294 + console.log(""); 295 + console.log("Backlink pages:"); 296 + for (const page of backlinks.pages) { 297 + console.log( 298 + ` page ${page.page}: cursorIn=${page.cursorIn ?? "null"} records=${page.recordCount} cursorOut=${page.cursorOut ?? "null"}`, 299 + ); 300 + } 301 + console.log(""); 302 + console.log(`Collected blocker DIDs: ${backlinks.orderedDids.length}`); 303 + console.log(`Resolved public profiles: ${resolvedCount}`); 304 + console.log(`Unavailable public profiles: ${unavailableEntries.length}`); 305 + console.log(""); 306 + console.log("Ordered results:"); 307 + orderedEntries.forEach((entry, index) => { 308 + if (entry.status === "resolved") { 309 + const display = entry.displayName ? ` (${entry.displayName})` : ""; 310 + console.log(`${String(index + 1).padStart(2, "0")}. RESOLVED ${entry.did} -> @${entry.handle}${display}`); 311 + return; 312 + } 313 + 314 + console.log(`${String(index + 1).padStart(2, "0")}. UNAVAILABLE ${entry.did} -> ${entry.reason}`); 315 + }); 316 + } 317 + 318 + await main();
+2 -1
scripts/tsconfig.json
··· 17 17 "noImplicitOverride": true, 18 18 "noUnusedLocals": false, 19 19 "noUnusedParameters": false, 20 - "noPropertyAccessFromIndexSignature": false 20 + "noPropertyAccessFromIndexSignature": false, 21 + "types": ["bun"] 21 22 } 22 23 }
+66
test/core/network/constellation_client_test.dart
··· 39 39 expect(capturedUri?.host, 'my.instance.example'); 40 40 }); 41 41 42 + test('trims trailing slashes from custom base URL', () async { 43 + Uri? capturedUri; 44 + final client = ConstellationClient( 45 + baseUrl: 'https://my.instance.example///', 46 + httpClient: MockClient((request) async { 47 + capturedUri = request.url; 48 + return http.Response(jsonEncode({'total': 0}), 200); 49 + }), 50 + ); 51 + 52 + await client.getBacklinksCount('did:plc:test', 'app.bsky.graph.block:subject'); 53 + 54 + expect( 55 + capturedUri.toString(), 56 + 'https://my.instance.example/xrpc/blue.microcosm.links.getBacklinksCount?subject=did%3Aplc%3Atest&source=app.bsky.graph.block%3Asubject', 57 + ); 58 + }); 59 + 42 60 test('builds XRPC path correctly', () async { 43 61 Uri? capturedUri; 44 62 final client = ConstellationClient( ··· 142 160 expect(result.cursor, isNull); 143 161 }); 144 162 163 + test('treats null dids as empty list', () async { 164 + final responseBody = jsonEncode({'total': 1, 'dids': null}); 165 + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); 166 + 167 + final result = await client.getDistinct('did:plc:abc', 'source'); 168 + 169 + expect(result.dids, isEmpty); 170 + }); 171 + 145 172 test('passes limit and cursor as query parameters', () async { 146 173 Uri? capturedUri; 147 174 final client = ConstellationClient( ··· 180 207 }); 181 208 182 209 group('getBacklinks', () { 210 + test('prefers records payload when returned by Microcosm', () async { 211 + final responseBody = jsonEncode({ 212 + 'total': 2, 213 + 'records': [ 214 + {'did': 'did:plc:aaa', 'collection': 'app.bsky.graph.block', 'rkey': 'rkey1'}, 215 + {'did': 'did:plc:bbb', 'collection': 'app.bsky.graph.block', 'rkey': 'rkey2'}, 216 + ], 217 + 'linking_records': [ 218 + {'did': 'did:plc:ignored', 'collection': 'app.bsky.graph.block', 'rkey': 'ignored'}, 219 + ], 220 + 'cursor': 'cursor-xyz', 221 + }); 222 + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); 223 + 224 + final result = await client.getBacklinks('did:plc:abc', 'app.bsky.graph.block:subject'); 225 + 226 + expect(result.total, 2); 227 + expect(result.records.map((record) => record.did).toList(), ['did:plc:aaa', 'did:plc:bbb']); 228 + expect(result.cursor, 'cursor-xyz'); 229 + }); 230 + 183 231 test('returns total, records and cursor from response', () async { 184 232 final responseBody = jsonEncode({ 185 233 'total': 2, ··· 209 257 210 258 expect(result.records, isEmpty); 211 259 expect(result.cursor, isNull); 260 + }); 261 + 262 + test('treats null linking_records as empty list', () async { 263 + final responseBody = jsonEncode({'total': 0, 'linking_records': null}); 264 + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); 265 + 266 + final result = await client.getBacklinks('did:plc:abc', 'source'); 267 + 268 + expect(result.records, isEmpty); 212 269 }); 213 270 214 271 test('passes limit and cursor as query parameters', () async { ··· 276 333 final result = await client.getManyToMany('did:plc:abc', 'source', 'list'); 277 334 278 335 expect(result.cursor, isNull); 336 + }); 337 + 338 + test('treats null items as empty list', () async { 339 + final responseBody = jsonEncode({'items': null}); 340 + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); 341 + 342 + final result = await client.getManyToMany('did:plc:abc', 'source', 'list'); 343 + 344 + expect(result.items, isEmpty); 279 345 }); 280 346 281 347 test('throws ConstellationException on error response', () async {
+38
test/features/profile/bloc/profile_bloc_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 5 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 6 + import 'package:mocktail/mocktail.dart'; 7 + 8 + class MockProfileRepository extends Mock implements ProfileRepository {} 9 + 10 + ProfileViewDetailed _profile({String did = 'did:plc:alice', String handle = 'alice.bsky.social'}) { 11 + return ProfileViewDetailed(did: did, handle: handle, indexedAt: DateTime.utc(2026)); 12 + } 13 + 14 + void main() { 15 + late MockProfileRepository repository; 16 + 17 + setUp(() { 18 + repository = MockProfileRepository(); 19 + }); 20 + 21 + blocTest<ProfileBloc, ProfileState>( 22 + 'refresh uses handle when available', 23 + build: () => ProfileBloc(profileRepository: repository), 24 + seed: () => ProfileState.loaded(profile: _profile()), 25 + setUp: () { 26 + when(() => repository.getProfile('alice.bsky.social')).thenAnswer((_) async => _profile()); 27 + }, 28 + act: (bloc) => bloc.add(const ProfileRefreshRequested()), 29 + expect: () => [ 30 + predicate<ProfileState>((state) => state.isRefreshing), 31 + predicate<ProfileState>((state) => state.status == ProfileStatus.loaded && !state.isRefreshing), 32 + ], 33 + verify: (_) { 34 + verify(() => repository.getProfile('alice.bsky.social')).called(1); 35 + verifyNever(() => repository.getProfile('did:plc:alice')); 36 + }, 37 + ); 38 + }
+88 -18
test/features/profile/cubit/profile_context_cubit_test.dart
··· 63 63 ); 64 64 65 65 blocTest<ProfileContextCubit, ProfileContextState>( 66 - 'emits nothing when counts fail (best-effort)', 67 - build: buildCubit, 66 + 'loads blockingCount too for own profile', 67 + build: () => buildCubit(isOwnProfile: true), 68 + setUp: () { 69 + when(() => mockRepository.getBlockedByCount(_did)).thenAnswer((_) async => 42); 70 + when(() => mockRepository.getListsOnCount(_did)).thenAnswer((_) async => 7); 71 + when(() => mockRepository.getBlockingCount(_did)).thenAnswer((_) async => 3); 72 + }, 73 + act: (cubit) => cubit.init(), 74 + expect: () => [ 75 + predicate<ProfileContextState>((s) => s.blockedByCount == 42 && s.listsOnCount == 7 && s.blockingCount == 3), 76 + ], 77 + ); 78 + 79 + blocTest<ProfileContextCubit, ProfileContextState>( 80 + 'preserves successful counts when one loader fails', 81 + build: () => buildCubit(isOwnProfile: true), 68 82 setUp: () { 69 83 when(() => mockRepository.getBlockedByCount(any())).thenThrow(Exception('network error')); 70 - when(() => mockRepository.getListsOnCount(any())).thenThrow(Exception('network error')); 84 + when(() => mockRepository.getListsOnCount(_did)).thenAnswer((_) async => 7); 85 + when(() => mockRepository.getBlockingCount(_did)).thenAnswer((_) async => 3); 71 86 }, 72 87 act: (cubit) => cubit.init(), 73 - expect: () => [], 88 + expect: () => [ 89 + predicate<ProfileContextState>((s) => s.blockedByCount == 0 && s.listsOnCount == 7 && s.blockingCount == 3), 90 + ], 74 91 ); 75 92 }); 76 93 ··· 82 99 'emits loading then loaded with profiles on success', 83 100 build: buildCubit, 84 101 setUp: () { 85 - when( 86 - () => mockRepository.getBlockedByProfiles(_did, cursor: null), 87 - ).thenAnswer((_) async => (profiles: [profile1, profile2], cursor: 'next', total: 10)); 102 + when(() => mockRepository.getBlockedByProfiles(_did, cursor: null)).thenAnswer( 103 + (_) async => ( 104 + entries: [ 105 + BlockedByEntry.profile(profile: profile1), 106 + BlockedByEntry.profile(profile: profile2), 107 + ], 108 + cursor: 'next', 109 + total: 10, 110 + ), 111 + ); 88 112 }, 89 113 act: (cubit) => cubit.loadBlockedBy(), 90 114 expect: () => [ ··· 92 116 predicate<ProfileContextState>( 93 117 (s) => 94 118 s.blockedByStatus == ProfileContextTabStatus.loaded && 95 - s.blockedByProfiles.length == 2 && 119 + s.blockedByEntries.length == 2 && 96 120 s.blockedByCount == 10 && 97 121 s.blockedByCursor == 'next' && 98 122 s.blockedByHasMore, ··· 105 129 build: buildCubit, 106 130 seed: () => const ProfileContextState.initial(did: _did, isOwnProfile: false).copyWith( 107 131 blockedByStatus: ProfileContextTabStatus.loaded, 108 - blockedByProfiles: [_profile('did:plc:first')], 132 + blockedByEntries: [BlockedByEntry.profile(profile: _profile('did:plc:first'))], 109 133 blockedByCursor: 'cursor-1', 110 134 blockedByHasMore: true, 111 135 ), 112 136 setUp: () { 113 137 when( 114 138 () => mockRepository.getBlockedByProfiles(_did, cursor: 'cursor-1'), 115 - ).thenAnswer((_) async => (profiles: [profile1], cursor: null, total: 2)); 139 + ).thenAnswer((_) async => (entries: [BlockedByEntry.profile(profile: profile1)], cursor: null, total: 2)); 116 140 }, 117 141 act: (cubit) => cubit.loadBlockedBy(cursor: 'cursor-1'), 118 142 expect: () => [ ··· 120 144 predicate<ProfileContextState>( 121 145 (s) => 122 146 s.blockedByStatus == ProfileContextTabStatus.loaded && 123 - s.blockedByProfiles.length == 2 && 147 + s.blockedByEntries.length == 2 && 124 148 s.blockedByCursor == null && 125 149 !s.blockedByHasMore, 126 150 ), ··· 128 152 ); 129 153 130 154 blocTest<ProfileContextCubit, ProfileContextState>( 155 + 'tracks unavailable blocked-by accounts without breaking loaded state', 156 + build: buildCubit, 157 + setUp: () { 158 + when(() => mockRepository.getBlockedByProfiles(_did, cursor: null)).thenAnswer( 159 + (_) async => ( 160 + entries: [ 161 + BlockedByEntry.profile( 162 + profile: const ProfileView(did: 'did:plc:blocker1', handle: 'did:plc:blocker1.bsky.social'), 163 + ), 164 + const BlockedByEntry.unavailable(did: 'did:plc:suspended', unavailableReason: 'Suspended account'), 165 + ], 166 + cursor: null, 167 + total: 2, 168 + ), 169 + ); 170 + }, 171 + act: (cubit) => cubit.loadBlockedBy(), 172 + expect: () => [ 173 + predicate<ProfileContextState>((s) => s.blockedByStatus == ProfileContextTabStatus.loading), 174 + predicate<ProfileContextState>( 175 + (s) => 176 + s.blockedByStatus == ProfileContextTabStatus.loaded && 177 + s.blockedByEntries.length == 2 && 178 + s.blockedByEntries.last.did == 'did:plc:suspended' && 179 + s.blockedByEntries.last.unavailableReason == 'Suspended account', 180 + ), 181 + ], 182 + ); 183 + 184 + blocTest<ProfileContextCubit, ProfileContextState>( 131 185 'emits error state on failure', 132 186 build: buildCubit, 133 187 setUp: () { ··· 142 196 (s) => 143 197 s.blockedByStatus == ProfileContextTabStatus.error && 144 198 s.blockedByError != null && 145 - s.blockedByProfiles.isEmpty, 199 + s.blockedByEntries.isEmpty, 146 200 ), 147 201 ], 148 202 ); ··· 166 220 'emits loading then loaded with profiles on success for own profile', 167 221 build: () => buildCubit(isOwnProfile: true), 168 222 setUp: () { 169 - when( 170 - () => mockRepository.getBlockingProfiles(_did, cursor: null), 171 - ).thenAnswer((_) async => (profiles: [profile1], cursor: 'next', total: 1)); 223 + when(() => mockRepository.getBlockingProfiles(_did, cursor: null)).thenAnswer( 224 + (_) async => (profiles: [profile1], unavailable: <UnavailableProfileRef>[], cursor: 'next', total: 1), 225 + ); 172 226 }, 173 227 act: (cubit) => cubit.loadBlocking(), 174 228 expect: () => [ ··· 202 256 blockingCount: 1, 203 257 ), 204 258 setUp: () { 205 - when( 206 - () => mockRepository.getBlockingProfiles(_did, cursor: 'cursor-1'), 207 - ).thenAnswer((_) async => (profiles: [profile1], cursor: null, total: 1)); 259 + when(() => mockRepository.getBlockingProfiles(_did, cursor: 'cursor-1')).thenAnswer( 260 + (_) async => (profiles: [profile1], unavailable: <UnavailableProfileRef>[], cursor: null, total: 1), 261 + ); 208 262 }, 209 263 act: (cubit) => cubit.loadBlocking(cursor: 'cursor-1'), 210 264 expect: () => [ 211 265 predicate<ProfileContextState>((s) => s.blockingStatus == ProfileContextTabStatus.loading), 212 266 predicate<ProfileContextState>((s) => s.blockingProfiles.length == 2 && s.blockingCount == 2), 267 + ], 268 + ); 269 + 270 + blocTest<ProfileContextCubit, ProfileContextState>( 271 + 'does not shrink blockingCount when a known total already exists', 272 + build: () => buildCubit(isOwnProfile: true), 273 + seed: () => const ProfileContextState.initial(did: _did, isOwnProfile: true).copyWith(blockingCount: 5), 274 + setUp: () { 275 + when(() => mockRepository.getBlockingProfiles(_did, cursor: null)).thenAnswer( 276 + (_) async => (profiles: [profile1], unavailable: <UnavailableProfileRef>[], cursor: 'next', total: 1), 277 + ); 278 + }, 279 + act: (cubit) => cubit.loadBlocking(), 280 + expect: () => [ 281 + predicate<ProfileContextState>((s) => s.blockingStatus == ProfileContextTabStatus.loading), 282 + predicate<ProfileContextState>((s) => s.blockingProfiles.length == 1 && s.blockingCount == 5), 213 283 ], 214 284 ); 215 285
+622 -21
test/features/profile/data/profile_context_repository_test.dart
··· 13 13 return ProfileView(did: did, handle: handle, indexedAt: DateTime.utc(2026, 1, 1)); 14 14 } 15 15 16 + ProfileViewDetailed _buildProfileViewDetailed(String did, String handle) { 17 + return ProfileViewDetailed( 18 + did: did, 19 + handle: handle, 20 + displayName: 'Detailed $did', 21 + description: 'Bio for $did', 22 + indexedAt: DateTime.utc(2026, 1, 1), 23 + ); 24 + } 25 + 16 26 ListView _buildListView(String uriStr, String name) { 17 27 return ListView( 18 28 uri: AtUri.parse(uriStr), ··· 48 58 } 49 59 50 60 class _FakeActorService { 51 - _FakeActorService({required this.profiles}); 61 + _FakeActorService({required this.profiles, Map<String, dynamic>? profileByActor, Map<String, Object>? errorsByActor}) 62 + : _profileByActor = profileByActor ?? const {}, 63 + _errorsByActor = errorsByActor ?? const {}; 52 64 53 - final List<ProfileView> profiles; 65 + final List<dynamic> profiles; 66 + final Map<String, dynamic> _profileByActor; 67 + final Map<String, Object> _errorsByActor; 54 68 55 69 Future<_FakeResponse<_FakeProfilesOutput>> getProfiles({ 56 70 required List<String> actors, 71 + String? $service, 57 72 Map<String, String>? $headers, 58 73 }) async { 59 74 final matched = profiles.where((p) => actors.contains(p.did) || actors.contains(p.handle)).toList(); 60 75 return _FakeResponse(_FakeProfilesOutput(matched)); 61 76 } 77 + 78 + Future<_FakeResponse<dynamic>> getProfile({ 79 + required String actor, 80 + String? $service, 81 + Map<String, String>? $headers, 82 + }) async { 83 + final error = _errorsByActor[actor]; 84 + if (error != null) throw error; 85 + final profile = _profileByActor[actor]; 86 + if (profile == null) throw Exception('Profile not found: $actor'); 87 + return _FakeResponse(profile); 88 + } 89 + } 90 + 91 + class _ThrowingActorService { 92 + Future<_FakeResponse<_FakeProfilesOutput>> getProfiles({ 93 + required List<String> actors, 94 + String? $service, 95 + Map<String, String>? $headers, 96 + }) async { 97 + throw Exception('Authenticated actor client should not be used for public profile hydration'); 98 + } 99 + 100 + Future<_FakeResponse<dynamic>> getProfile({ 101 + required String actor, 102 + String? $service, 103 + Map<String, String>? $headers, 104 + }) async { 105 + throw Exception('Authenticated actor client should not be used for public profile hydration'); 106 + } 107 + } 108 + 109 + class _BatchThrowingActorService { 110 + _BatchThrowingActorService({required Map<String, dynamic> profileByActor}) : _profileByActor = profileByActor; 111 + 112 + final Map<String, dynamic> _profileByActor; 113 + 114 + Future<_FakeResponse<_FakeProfilesOutput>> getProfiles({ 115 + required List<String> actors, 116 + String? $service, 117 + Map<String, String>? $headers, 118 + }) async { 119 + throw Exception('Batch lookup failed'); 120 + } 121 + 122 + Future<_FakeResponse<dynamic>> getProfile({ 123 + required String actor, 124 + String? $service, 125 + Map<String, String>? $headers, 126 + }) async { 127 + final profile = _profileByActor[actor]; 128 + if (profile == null) throw Exception('Profile not found: $actor'); 129 + return _FakeResponse(profile); 130 + } 62 131 } 63 132 64 133 class _FakeProfilesOutput { 65 134 _FakeProfilesOutput(this.profiles); 66 135 67 - final List<ProfileView> profiles; 136 + final List<dynamic> profiles; 68 137 } 69 138 70 139 class _FakeGraphService { ··· 75 144 Future<_FakeResponse<_FakeGetListOutput>> getList({ 76 145 required AtUri list, 77 146 int limit = 50, 147 + String? $service, 78 148 Map<String, String>? $headers, 79 149 }) async { 80 150 final listView = lists[list.toString()]; ··· 83 153 } 84 154 } 85 155 156 + class _ThrowingGraphService { 157 + Future<_FakeResponse<_FakeGetListOutput>> getList({ 158 + required AtUri list, 159 + int limit = 50, 160 + String? $service, 161 + Map<String, String>? $headers, 162 + }) async { 163 + throw Exception('Authenticated graph client should not be used for public list hydration'); 164 + } 165 + } 166 + 86 167 class _FakeGetListOutput { 87 168 _FakeGetListOutput(this.list); 88 169 ··· 174 255 final result = await repo.getBlockedByProfiles('did:plc:target'); 175 256 176 257 expect(result.total, 2); 177 - expect(result.profiles.length, 2); 178 - expect(result.profiles.map((p) => p.did), containsAll(['did:plc:alice', 'did:plc:bob'])); 258 + expect(result.entries, hasLength(2)); 259 + expect(result.entries.map((entry) => entry.did), containsAll(['did:plc:alice', 'did:plc:bob'])); 260 + expect(result.entries.every((entry) => entry.isAvailable), isTrue); 179 261 expect(result.cursor, isNull); 180 262 }); 181 263 182 - test('passes cursor to getDistinct and returns cursor from response', () async { 183 - String? capturedCursor; 264 + test('converts detailed profiles returned by getProfiles into ProfileView', () async { 265 + final aliceProfile = _buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social'); 266 + 267 + final constellation = _constellationWithResponses((_) { 268 + return { 269 + 'total': 1, 270 + 'dids': ['did:plc:alice'], 271 + }; 272 + }); 273 + 274 + final repo = ProfileContextRepository( 275 + bluesky: _buildBluesky(profiles: [aliceProfile]), 276 + constellationClient: constellation, 277 + ); 278 + 279 + final result = await repo.getBlockedByProfiles('did:plc:target'); 280 + 281 + expect(result.entries, hasLength(1)); 282 + expect(result.entries.first.profile?.did, 'did:plc:alice'); 283 + expect(result.entries.first.profile?.handle, 'alice.bsky.social'); 284 + expect(result.entries.first.profile?.displayName, 'Detailed did:plc:alice'); 285 + }); 286 + 287 + test('falls back to getProfile for DIDs missing from getProfiles', () async { 288 + final repo = ProfileContextRepository( 289 + bluesky: _FakeBluesky( 290 + actor: _ThrowingActorService(), 291 + graph: _FakeGraphService(), 292 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 293 + ), 294 + publicBluesky: _FakeBluesky( 295 + actor: _FakeActorService( 296 + profiles: const [], 297 + profileByActor: {'did:plc:alice': _buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social')}, 298 + ), 299 + graph: _FakeGraphService(), 300 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 301 + ), 302 + constellationClient: _constellationWithResponses((_) { 303 + return { 304 + 'total': 1, 305 + 'dids': ['did:plc:alice'], 306 + }; 307 + }), 308 + ); 309 + 310 + final result = await repo.getBlockedByProfiles('did:plc:target'); 311 + 312 + expect(result.entries, hasLength(1)); 313 + expect(result.entries.first.profile?.did, 'did:plc:alice'); 314 + expect(result.entries.first.profile?.handle, 'alice.bsky.social'); 315 + }); 316 + 317 + test('falls back to per-DID getProfile when batch getProfiles fails', () async { 318 + final repo = ProfileContextRepository( 319 + bluesky: _FakeBluesky( 320 + actor: _ThrowingActorService(), 321 + graph: _FakeGraphService(), 322 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 323 + ), 324 + publicBluesky: _FakeBluesky( 325 + actor: _BatchThrowingActorService( 326 + profileByActor: {'did:plc:alice': _buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social')}, 327 + ), 328 + graph: _FakeGraphService(), 329 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 330 + ), 331 + constellationClient: _constellationWithResponses((_) { 332 + return { 333 + 'total': 2, 334 + 'dids': ['did:plc:alice', 'did:plc:suspended'], 335 + }; 336 + }), 337 + ); 338 + 339 + final result = await repo.getBlockedByProfiles('did:plc:target'); 340 + 341 + expect(result.total, 2); 342 + expect(result.entries, hasLength(2)); 343 + expect(result.entries.first.profile?.did, 'did:plc:alice'); 344 + expect(result.entries.last.did, 'did:plc:suspended'); 345 + expect(result.entries.last.unavailableReason, 'Profile unavailable'); 346 + }); 347 + 348 + test('uses public bluesky client for batch profile hydration', () async { 349 + final repo = ProfileContextRepository( 350 + bluesky: _FakeBluesky( 351 + actor: _ThrowingActorService(), 352 + graph: _FakeGraphService(), 353 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 354 + ), 355 + publicBluesky: _FakeBluesky( 356 + actor: _FakeActorService(profiles: [_buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social')]), 357 + graph: _FakeGraphService(), 358 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 359 + ), 360 + constellationClient: _constellationWithResponses((_) { 361 + return { 362 + 'total': 1, 363 + 'dids': ['did:plc:alice'], 364 + }; 365 + }), 366 + ); 367 + 368 + await repo.getBlockedByProfiles('did:plc:target'); 369 + }); 370 + 371 + test('uses local offset cursor after collecting blocked-by DIDs', () async { 372 + var getDistinctCalls = 0; 373 + String? capturedLimit; 184 374 final constellation = _constellationWithResponses((uri) { 185 - capturedCursor = uri.queryParameters['cursor']; 186 - return {'total': 10, 'dids': [], 'cursor': 'page2'}; 375 + getDistinctCalls += 1; 376 + capturedLimit = uri.queryParameters['limit']; 377 + if (getDistinctCalls == 1) { 378 + expect(uri.queryParameters['cursor'], isNull); 379 + return { 380 + 'total': 3, 381 + 'dids': ['did:plc:one', 'did:plc:two'], 382 + 'cursor': 'page2', 383 + }; 384 + } 385 + expect(uri.queryParameters['cursor'], 'page2'); 386 + return { 387 + 'total': 3, 388 + 'dids': ['did:plc:three'], 389 + 'cursor': null, 390 + }; 187 391 }); 188 392 189 - final repo = ProfileContextRepository(bluesky: _buildBluesky(), constellationClient: constellation); 393 + final repo = ProfileContextRepository( 394 + bluesky: _buildBluesky( 395 + profiles: [ 396 + _buildProfileView('did:plc:one', 'one.bsky.social'), 397 + _buildProfileView('did:plc:two', 'two.bsky.social'), 398 + _buildProfileView('did:plc:three', 'three.bsky.social'), 399 + ], 400 + ), 401 + constellationClient: constellation, 402 + ); 403 + 404 + final result = await repo.getBlockedByProfiles('did:plc:target', cursor: '2'); 405 + 406 + expect(getDistinctCalls, 2); 407 + expect(capturedLimit, '16'); 408 + expect(result.entries.map((entry) => entry.did), ['did:plc:three']); 409 + expect(result.cursor, isNull); 410 + }); 411 + 412 + test('falls back to getBacklinks when getDistinct returns 404', () async { 413 + final aliceProfile = _buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social'); 414 + final bobProfile = _buildProfileViewDetailed('did:plc:bob', 'bob.bsky.social'); 190 415 191 - final result = await repo.getBlockedByProfiles('did:plc:target', cursor: 'page1'); 416 + final constellation = ConstellationClient( 417 + httpClient: MockClient((request) async { 418 + if (request.url.path.contains('getDistinct')) { 419 + return http.Response('Not Found', 404); 420 + } 421 + 422 + if (request.url.path.contains('getBacklinks')) { 423 + return http.Response( 424 + jsonEncode({ 425 + 'total': 3, 426 + 'records': [ 427 + {'did': 'did:plc:alice', 'collection': 'app.bsky.graph.block', 'rkey': '1'}, 428 + {'did': 'did:plc:bob', 'collection': 'app.bsky.graph.block', 'rkey': '2'}, 429 + {'did': 'did:plc:alice', 'collection': 'app.bsky.graph.block', 'rkey': '3'}, 430 + ], 431 + }), 432 + 200, 433 + ); 434 + } 435 + 436 + throw Exception('Unexpected endpoint: ${request.url.path}'); 437 + }), 438 + ); 439 + 440 + final repo = ProfileContextRepository( 441 + bluesky: _buildBluesky(profiles: [aliceProfile, bobProfile]), 442 + constellationClient: constellation, 443 + ); 444 + 445 + final result = await repo.getBlockedByProfiles('did:plc:target'); 446 + 447 + expect(result.total, 3); 448 + expect(result.cursor, isNull); 449 + expect(result.entries.map((entry) => entry.did).toList(), ['did:plc:alice', 'did:plc:bob']); 450 + }); 451 + 452 + test('collects blocked-by DIDs across pages before hydrating', () async { 453 + var getDistinctCalls = 0; 454 + final repo = ProfileContextRepository( 455 + bluesky: _FakeBluesky( 456 + actor: _ThrowingActorService(), 457 + graph: _FakeGraphService(), 458 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 459 + ), 460 + publicBluesky: _FakeBluesky( 461 + actor: _FakeActorService( 462 + profiles: [_buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social')], 463 + profileByActor: const {}, 464 + ), 465 + graph: _FakeGraphService(), 466 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 467 + ), 468 + constellationClient: _constellationWithResponses((uri) { 469 + if (!uri.path.contains('getDistinct')) { 470 + throw Exception('Unexpected endpoint: ${uri.path}'); 471 + } 472 + 473 + getDistinctCalls += 1; 474 + if (getDistinctCalls == 1) { 475 + expect(uri.queryParameters['cursor'], isNull); 476 + return { 477 + 'total': 23, 478 + 'dids': ['did:plc:suspended'], 479 + 'cursor': 'page-2', 480 + }; 481 + } 482 + 483 + expect(uri.queryParameters['cursor'], 'page-2'); 484 + return { 485 + 'total': 23, 486 + 'dids': ['did:plc:alice'], 487 + 'cursor': null, 488 + }; 489 + }), 490 + ); 491 + 492 + final result = await repo.getBlockedByProfiles('did:plc:target'); 192 493 193 - expect(capturedCursor, 'page1'); 194 - expect(result.cursor, 'page2'); 494 + expect(getDistinctCalls, 2); 495 + expect(result.total, 23); 496 + expect(result.cursor, isNull); 497 + expect(result.entries.map((entry) => entry.did), ['did:plc:suspended', 'did:plc:alice']); 498 + expect(result.entries.first.unavailableReason, 'Profile unavailable'); 499 + expect(result.entries.last.profile?.did, 'did:plc:alice'); 195 500 }); 196 501 197 502 test('hydrates profiles in batches of 25', () async { ··· 213 518 final result = await repo.getBlockedByProfiles('did:plc:target'); 214 519 215 520 expect(batchSizes, [25, 5]); 216 - expect(result.profiles.length, 30); 521 + expect(result.entries.length, 16); 522 + expect(result.cursor, '16'); 217 523 }); 218 524 219 525 test('returns empty profiles when no DIDs returned', () async { ··· 222 528 223 529 final result = await repo.getBlockedByProfiles('did:plc:target'); 224 530 225 - expect(result.profiles, isEmpty); 531 + expect(result.entries, isEmpty); 226 532 expect(result.total, 0); 227 533 }); 534 + 535 + test('preserves blocked-by order across two local pages and marks suspended accounts inline', () async { 536 + const unavailableDidOne = 'did:plc:tsy5hmbt5lruc5jjaqglajtp'; 537 + const unavailableDidTwo = 'did:plc:hcton3oag5fbmtx2r55wduei'; 538 + final orderedDids = <String>[ 539 + 'did:plc:blocker00', 540 + 'did:plc:blocker01', 541 + 'did:plc:blocker02', 542 + 'did:plc:blocker03', 543 + unavailableDidOne, 544 + 'did:plc:blocker05', 545 + 'did:plc:blocker06', 546 + 'did:plc:blocker07', 547 + 'did:plc:blocker08', 548 + 'did:plc:blocker09', 549 + 'did:plc:blocker10', 550 + 'did:plc:blocker11', 551 + 'did:plc:blocker12', 552 + 'did:plc:blocker13', 553 + 'did:plc:blocker14', 554 + 'did:plc:blocker15', 555 + 'did:plc:blocker16', 556 + 'did:plc:blocker17', 557 + 'did:plc:blocker18', 558 + unavailableDidTwo, 559 + 'did:plc:blocker20', 560 + 'did:plc:blocker21', 561 + 'did:plc:blocker22', 562 + ]; 563 + final resolvedProfiles = orderedDids 564 + .where((did) => did != unavailableDidOne && did != unavailableDidTwo) 565 + .map((did) => _buildProfileViewDetailed(did, '$did.bsky.social')) 566 + .toList(); 567 + 568 + final constellation = ConstellationClient( 569 + httpClient: MockClient((request) async { 570 + if (request.url.path.contains('getDistinct')) { 571 + return http.Response('Not Found', 404); 572 + } 573 + 574 + if (request.url.path.contains('getBacklinks')) { 575 + final cursor = request.url.queryParameters['cursor']; 576 + final pageDids = cursor == null ? orderedDids.take(16).toList() : orderedDids.skip(16).toList(); 577 + return http.Response( 578 + jsonEncode({ 579 + 'total': 23, 580 + 'records': [ 581 + for (var i = 0; i < pageDids.length; i++) 582 + {'did': pageDids[i], 'collection': 'app.bsky.graph.block', 'rkey': '${cursor ?? 'page-1'}-$i'}, 583 + ], 584 + 'cursor': cursor == null ? '190209' : null, 585 + }), 586 + 200, 587 + ); 588 + } 589 + 590 + throw Exception('Unexpected endpoint: ${request.url.path}'); 591 + }), 592 + ); 593 + 594 + final repo = ProfileContextRepository( 595 + bluesky: _FakeBluesky( 596 + actor: _ThrowingActorService(), 597 + graph: _FakeGraphService(), 598 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 599 + ), 600 + publicBluesky: _FakeBluesky( 601 + actor: _FakeActorService( 602 + profiles: resolvedProfiles, 603 + errorsByActor: const { 604 + unavailableDidOne: ConstellationException('HTTP 400: AccountTakedown'), 605 + unavailableDidTwo: ConstellationException('HTTP 400: AccountTakedown'), 606 + }, 607 + ), 608 + graph: _FakeGraphService(), 609 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 610 + ), 611 + constellationClient: constellation, 612 + ); 613 + 614 + final firstPage = await repo.getBlockedByProfiles('did:plc:xg2vq45muivyy3xwatcehspu'); 615 + final secondPage = await repo.getBlockedByProfiles( 616 + 'did:plc:xg2vq45muivyy3xwatcehspu', 617 + cursor: firstPage.cursor, 618 + ); 619 + final allEntries = [...firstPage.entries, ...secondPage.entries]; 620 + 621 + expect(firstPage.total, 23); 622 + expect(firstPage.cursor, '16'); 623 + expect(secondPage.cursor, isNull); 624 + expect(allEntries, hasLength(23)); 625 + expect(allEntries.map((entry) => entry.did).toList(), orderedDids); 626 + expect(allEntries.where((entry) => entry.isAvailable), hasLength(21)); 627 + expect(allEntries.where((entry) => !entry.isAvailable).map((entry) => entry.did).toList(), [ 628 + unavailableDidOne, 629 + unavailableDidTwo, 630 + ]); 631 + expect( 632 + allEntries 633 + .where((entry) => !entry.isAvailable) 634 + .every((entry) => entry.unavailableReason == 'Suspended account'), 635 + isTrue, 636 + ); 637 + }); 228 638 }); 229 639 230 640 group('getBlockingProfiles', () { ··· 285 695 }); 286 696 }); 287 697 698 + group('getBlockingCount', () { 699 + test('counts records across every listRecords page', () async { 700 + final repo = ProfileContextRepository( 701 + bluesky: _FakeBluesky( 702 + actor: _FakeActorService(profiles: []), 703 + graph: _FakeGraphService(), 704 + atproto: _FakeAtProto( 705 + repo: _PaginatedRepoService( 706 + pages: [ 707 + _FakeListRecordsOutput( 708 + records: [ 709 + _FakeRecord({'subject': 'did:plc:one'}), 710 + _FakeRecord({'subject': 'did:plc:two'}), 711 + ], 712 + cursor: 'page-2', 713 + ), 714 + _FakeListRecordsOutput( 715 + records: [ 716 + _FakeRecord({'subject': 'did:plc:three'}), 717 + ], 718 + ), 719 + ], 720 + ), 721 + ), 722 + ), 723 + constellationClient: _alwaysThrowConstellation(), 724 + ); 725 + 726 + final result = await repo.getBlockingCount('did:plc:actor'); 727 + 728 + expect(result, 3); 729 + }); 730 + }); 731 + 288 732 group('getListsOn', () { 289 733 test('returns lists hydrated from getManyToMany results', () async { 290 734 const listUri = 'at://did:plc:owner/app.bsky.graph.list/listkey'; ··· 307 751 }); 308 752 309 753 final repo = ProfileContextRepository( 310 - bluesky: _buildBluesky(lists: {listUri: listView}), 754 + bluesky: _FakeBluesky( 755 + actor: _FakeActorService(profiles: const []), 756 + graph: _ThrowingGraphService(), 757 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 758 + ), 759 + publicBluesky: _FakeBluesky( 760 + actor: _FakeActorService(profiles: const []), 761 + graph: _FakeGraphService(lists: {listUri: listView}), 762 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 763 + ), 311 764 constellationClient: constellation, 312 765 ); 313 766 ··· 339 792 340 793 test('passes cursor to getManyToMany and returns response cursor', () async { 341 794 String? capturedCursor; 795 + String? capturedLimit; 342 796 final constellation = _constellationWithResponses((uri) { 343 797 if (uri.path.contains('getBacklinksCount')) return {'total': 0}; 344 798 if (uri.path.contains('getManyToMany')) { 345 799 capturedCursor = uri.queryParameters['cursor']; 800 + capturedLimit = uri.queryParameters['limit']; 346 801 return {'items': [], 'cursor': 'next-page'}; 347 802 } 348 803 return {}; ··· 353 808 final result = await repo.getListsOn('did:plc:target', cursor: 'page1'); 354 809 355 810 expect(capturedCursor, 'page1'); 811 + expect(capturedLimit, '16'); 356 812 expect(result.cursor, 'next-page'); 357 813 }); 358 814 ··· 398 854 399 855 expect(capturedUri.toString(), listUri); 400 856 }); 857 + 858 + test('deduplicates list URIs before hydrating metadata', () async { 859 + const listUri = 'at://did:plc:owner/app.bsky.graph.list/abc123'; 860 + final listView = _buildListView(listUri, 'Test List'); 861 + 862 + var getListCalls = 0; 863 + 864 + final repo = ProfileContextRepository( 865 + bluesky: _FakeBluesky( 866 + actor: _FakeActorService(profiles: []), 867 + graph: _CountingGraphService(lists: {listUri: listView}, onGetList: () => getListCalls += 1), 868 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 869 + ), 870 + constellationClient: _constellationWithResponses((uri) { 871 + if (uri.path.contains('getBacklinksCount')) return {'total': 2}; 872 + return { 873 + 'items': [ 874 + { 875 + 'linkRecord': {'did': 'did:plc:owner', 'collection': 'app.bsky.graph.listitem', 'rkey': 'rk-1'}, 876 + 'otherSubject': listUri, 877 + }, 878 + { 879 + 'linkRecord': {'did': 'did:plc:owner', 'collection': 'app.bsky.graph.listitem', 'rkey': 'rk-2'}, 880 + 'otherSubject': listUri, 881 + }, 882 + ], 883 + }; 884 + }), 885 + ); 886 + 887 + final result = await repo.getListsOn('did:plc:target'); 888 + 889 + expect(getListCalls, 1); 890 + expect(result.lists, [listView]); 891 + }); 892 + 893 + test('skips lists that fail hydration instead of failing the whole page', () async { 894 + const goodListUri = 'at://did:plc:owner/app.bsky.graph.list/good'; 895 + const missingListUri = 'at://did:plc:owner/app.bsky.graph.list/missing'; 896 + final goodListView = _buildListView(goodListUri, 'Good List'); 897 + 898 + final repo = ProfileContextRepository( 899 + bluesky: _buildBluesky(lists: {goodListUri: goodListView}), 900 + constellationClient: _constellationWithResponses((uri) { 901 + if (uri.path.contains('getBacklinksCount')) return {'total': 2}; 902 + return { 903 + 'items': [ 904 + { 905 + 'linkRecord': {'did': 'did:plc:owner', 'collection': 'app.bsky.graph.listitem', 'rkey': 'good'}, 906 + 'otherSubject': goodListUri, 907 + }, 908 + { 909 + 'linkRecord': {'did': 'did:plc:owner', 'collection': 'app.bsky.graph.listitem', 'rkey': 'bad'}, 910 + 'otherSubject': missingListUri, 911 + }, 912 + ], 913 + }; 914 + }), 915 + ); 916 + 917 + final result = await repo.getListsOn('did:plc:target'); 918 + 919 + expect(result.total, 2); 920 + expect(result.lists, [goodListView]); 921 + }); 922 + }); 923 + 924 + group('_hydrateProfiles normalization behavior via public APIs', () { 925 + test('trims and deduplicates DIDs before public hydration', () async { 926 + final capturedActors = <List<String>>[]; 927 + final repo = ProfileContextRepository( 928 + bluesky: _FakeBluesky( 929 + actor: _ThrowingActorService(), 930 + graph: _FakeGraphService(), 931 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 932 + ), 933 + publicBluesky: _FakeBluesky( 934 + actor: _BatchTrackingActorService( 935 + profiles: [_buildProfileView('did:plc:alice', 'alice.bsky.social')], 936 + batchSizes: [], 937 + onGetProfiles: (actors) => capturedActors.add(actors), 938 + ), 939 + graph: _FakeGraphService(), 940 + atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), 941 + ), 942 + constellationClient: _constellationWithResponses((_) { 943 + return { 944 + 'total': 3, 945 + 'dids': [' did:plc:alice ', 'did:plc:alice', ' '], 946 + }; 947 + }), 948 + ); 949 + 950 + final result = await repo.getBlockedByProfiles('did:plc:target'); 951 + 952 + expect(capturedActors, [ 953 + ['did:plc:alice'], 954 + ]); 955 + expect(result.entries, hasLength(1)); 956 + expect(result.entries.first.profile?.did, 'did:plc:alice'); 957 + }); 401 958 }); 402 959 }); 403 960 } 404 961 405 962 _FakeBluesky _buildBluesky({ 406 - List<ProfileView> profiles = const [], 963 + List<dynamic> profiles = const [], 964 + Map<String, dynamic> profileByActor = const {}, 407 965 Map<String, ListView> lists = const {}, 408 966 List<Map<String, dynamic>> blockRecords = const [], 409 967 String? blockRecordsCursor, 410 968 }) { 411 969 return _FakeBluesky( 412 - actor: _FakeActorService(profiles: profiles), 970 + actor: _FakeActorService(profiles: profiles, profileByActor: profileByActor), 413 971 graph: _FakeGraphService(lists: lists), 414 972 atproto: _FakeAtProto( 415 973 repo: _FakeRepoService(records: blockRecords, cursor: blockRecordsCursor), ··· 424 982 } 425 983 426 984 class _BatchTrackingActorService { 427 - _BatchTrackingActorService({required this.profiles, required this.batchSizes}); 985 + _BatchTrackingActorService({required this.profiles, required this.batchSizes, this.onGetProfiles}); 428 986 429 - final List<ProfileView> profiles; 987 + final List<dynamic> profiles; 430 988 final List<int> batchSizes; 989 + final void Function(List<String> actors)? onGetProfiles; 431 990 432 991 Future<_FakeResponse<_FakeProfilesOutput>> getProfiles({ 433 992 required List<String> actors, 993 + String? $service, 434 994 Map<String, String>? $headers, 435 995 }) async { 436 996 batchSizes.add(actors.length); 437 - final matched = profiles.where((p) => actors.contains(p.did)).toList(); 997 + onGetProfiles?.call(actors); 998 + final matched = profiles.where((p) => actors.contains(p.did as String)).toList(); 438 999 return _FakeResponse(_FakeProfilesOutput(matched)); 439 1000 } 440 1001 } ··· 462 1023 } 463 1024 } 464 1025 1026 + class _PaginatedRepoService { 1027 + _PaginatedRepoService({required this.pages}); 1028 + 1029 + final List<_FakeListRecordsOutput> pages; 1030 + var _pageIndex = 0; 1031 + 1032 + Future<_FakeResponse<_FakeListRecordsOutput>> listRecords({ 1033 + required String repo, 1034 + required String collection, 1035 + int limit = 50, 1036 + String? cursor, 1037 + }) async { 1038 + final page = pages[_pageIndex]; 1039 + if (_pageIndex < pages.length - 1) { 1040 + _pageIndex += 1; 1041 + } 1042 + return _FakeResponse(page); 1043 + } 1044 + } 1045 + 465 1046 class _UriCapturingGraphService { 466 1047 _UriCapturingGraphService({required this.lists, required this.onGetList}); 467 1048 ··· 471 1052 Future<_FakeResponse<_FakeGetListOutput>> getList({ 472 1053 required AtUri list, 473 1054 int limit = 50, 1055 + String? $service, 474 1056 Map<String, String>? $headers, 475 1057 }) async { 476 1058 onGetList(list); ··· 479 1061 return _FakeResponse(_FakeGetListOutput(listView)); 480 1062 } 481 1063 } 1064 + 1065 + class _CountingGraphService { 1066 + _CountingGraphService({required this.lists, required this.onGetList}); 1067 + 1068 + final Map<String, ListView> lists; 1069 + final void Function() onGetList; 1070 + 1071 + Future<_FakeResponse<_FakeGetListOutput>> getList({ 1072 + required AtUri list, 1073 + int limit = 50, 1074 + String? $service, 1075 + Map<String, String>? $headers, 1076 + }) async { 1077 + onGetList(); 1078 + final listView = lists[list.toString()]; 1079 + if (listView == null) throw Exception('List not found: $list'); 1080 + return _FakeResponse(_FakeGetListOutput(listView)); 1081 + } 1082 + }
+149 -30
test/features/profile/presentation/profile_context_screen_test.dart
··· 21 21 ProfileView _profile(String did) => 22 22 ProfileView(did: did, handle: '$did.bsky.social', displayName: 'User $did', indexedAt: DateTime.utc(2026)); 23 23 24 - bsky_graph.ListView _list(String rkey) => bsky_graph.ListView( 24 + bsky_graph.ListView _list( 25 + String rkey, { 26 + bsky_graph.KnownListPurpose purpose = bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist, 27 + String? description, 28 + int? memberCount, 29 + String creatorHandle = 'owner.bsky.social', 30 + }) => bsky_graph.ListView( 25 31 uri: AtUri.parse('at://did:plc:owner/app.bsky.graph.list/$rkey'), 26 32 cid: 'cid-$rkey', 27 - creator: const ProfileView(did: 'did:plc:owner', handle: 'owner.bsky.social'), 33 + creator: ProfileView(did: 'did:plc:owner', handle: creatorHandle), 28 34 name: 'List $rkey', 29 - purpose: const bsky_graph.ListPurpose.knownValue(data: bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist), 35 + purpose: bsky_graph.ListPurpose.knownValue(data: purpose), 36 + description: description, 37 + listItemCount: memberCount, 30 38 indexedAt: DateTime.utc(2026), 31 39 ); 32 40 ··· 51 59 when(() => cubit.refreshListsOn()).thenAnswer((_) async {}); 52 60 }); 53 61 54 - Widget buildSubject({ 55 - required ProfileContextState state, 56 - List<NavigatorObserver> observers = const [], 57 - String? Function(BuildContext, GoRouterState)? redirect, 58 - }) { 62 + Widget buildSubject({required ProfileContextState state, List<NavigatorObserver> observers = const []}) { 59 63 when(() => cubit.state).thenReturn(state); 60 64 whenListen(cubit, const Stream<ProfileContextState>.empty(), initialState: state); 61 65 ··· 71 75 routes: [ 72 76 GoRoute( 73 77 path: 'profile/view', 74 - builder: (context, state) => const Scaffold(body: Text('Profile View')), 78 + builder: (context, state) => Scaffold(body: Text('Profile View ${state.uri.queryParameters['actor']}')), 75 79 ), 76 80 GoRoute( 77 81 path: 'list', ··· 137 141 final profiles = [_profile('did:plc:user1'), _profile('did:plc:user2')]; 138 142 final state = initialState().copyWith( 139 143 blockedByStatus: ProfileContextTabStatus.loaded, 140 - blockedByProfiles: profiles, 144 + blockedByEntries: profiles.map((profile) => BlockedByEntry.profile(profile: profile)).toList(), 141 145 ); 142 146 await tester.pumpWidget(buildSubject(state: state)); 143 147 ··· 149 153 final profiles = [_profile('did:plc:user1')]; 150 154 final state = initialState().copyWith( 151 155 blockedByStatus: ProfileContextTabStatus.loaded, 152 - blockedByProfiles: profiles, 156 + blockedByEntries: profiles.map((profile) => BlockedByEntry.profile(profile: profile)).toList(), 153 157 ); 154 158 155 - String? pushedRoute; 156 - final observer = _TestNavigatorObserver(onPush: (route, _) => pushedRoute = route.settings.name); 157 - 158 - await tester.pumpWidget(buildSubject(state: state, observers: [observer])); 159 + await tester.pumpWidget(buildSubject(state: state)); 159 160 await tester.tap(find.text('User did:plc:user1')); 160 161 await tester.pumpAndSettle(); 161 162 162 - expect(pushedRoute, '/profile/view'); 163 - expect(find.text('Profile View'), findsOneWidget); 163 + expect(find.text('Profile View did:plc:user1.bsky.social'), findsOneWidget); 164 + }); 165 + 166 + testWidgets('loads next blocked-by page when scrolled near the end', (tester) async { 167 + final profiles = List.generate(20, (index) => _profile('did:plc:user$index')); 168 + final state = initialState().copyWith( 169 + blockedByStatus: ProfileContextTabStatus.loaded, 170 + blockedByEntries: profiles.map((profile) => BlockedByEntry.profile(profile: profile)).toList(), 171 + blockedByCursor: 'next-page', 172 + blockedByHasMore: true, 173 + ); 174 + await tester.pumpWidget(buildSubject(state: state)); 175 + 176 + await tester.drag(find.byType(CustomScrollView).first, const Offset(0, -4000)); 177 + await tester.pump(); 178 + 179 + verify(() => cubit.loadBlockedBy(cursor: 'next-page')).called(greaterThanOrEqualTo(1)); 164 180 }); 165 181 166 182 testWidgets('shows contextualizing note', (tester) async { ··· 170 186 }); 171 187 172 188 testWidgets('shows empty state when loaded with no profiles', (tester) async { 173 - final state = initialState().copyWith(blockedByStatus: ProfileContextTabStatus.loaded, blockedByProfiles: []); 189 + final state = initialState().copyWith(blockedByStatus: ProfileContextTabStatus.loaded, blockedByEntries: []); 174 190 await tester.pumpWidget(buildSubject(state: state)); 175 191 176 192 expect(find.text('No accounts have blocked this user'), findsOneWidget); 177 193 }); 178 194 195 + testWidgets('shows unresolved-details message when count is non-zero but no profiles resolved', (tester) async { 196 + final state = initialState().copyWith( 197 + blockedByStatus: ProfileContextTabStatus.loaded, 198 + blockedByCount: 23, 199 + blockedByEntries: [], 200 + ); 201 + await tester.pumpWidget(buildSubject(state: state)); 202 + 203 + expect(find.textContaining('public Bluesky profile details could not be loaded'), findsOneWidget); 204 + }); 205 + 206 + testWidgets('shows unavailable blocked-by rows inline with resolved profiles', (tester) async { 207 + final state = initialState().copyWith( 208 + blockedByStatus: ProfileContextTabStatus.loaded, 209 + blockedByCount: 23, 210 + blockedByEntries: [ 211 + BlockedByEntry.profile(profile: _profile('did:plc:user1')), 212 + const BlockedByEntry.unavailable(did: 'did:plc:suspended', unavailableReason: 'Suspended account'), 213 + ], 214 + ); 215 + await tester.pumpWidget(buildSubject(state: state)); 216 + 217 + expect(find.byKey(const ValueKey('blocked_by_did:plc:user1')), findsOneWidget); 218 + expect(find.byKey(const ValueKey('blocked_by_unavailable_did:plc:suspended')), findsOneWidget); 219 + expect(find.text('did:plc:suspended'), findsOneWidget); 220 + expect(find.text('Suspended account'), findsOneWidget); 221 + expect(find.text('User did:plc:user1'), findsOneWidget); 222 + expect(find.text('23 accounts'), findsOneWidget); 223 + }); 224 + 225 + testWidgets('unavailable blocked-by rows are non-navigable', (tester) async { 226 + final state = initialState().copyWith( 227 + blockedByStatus: ProfileContextTabStatus.loaded, 228 + blockedByCount: 1, 229 + blockedByEntries: const [ 230 + BlockedByEntry.unavailable(did: 'did:plc:suspended', unavailableReason: 'Suspended account'), 231 + ], 232 + ); 233 + await tester.pumpWidget(buildSubject(state: state)); 234 + 235 + await tester.tap(find.text('did:plc:suspended')); 236 + await tester.pumpAndSettle(); 237 + 238 + expect(find.textContaining('Profile View'), findsNothing); 239 + }); 240 + 179 241 testWidgets('shows error and retry button on error state', (tester) async { 180 242 final state = initialState().copyWith( 181 243 blockedByStatus: ProfileContextTabStatus.error, ··· 221 283 expect(find.text('User did:plc:blocked1'), findsOneWidget); 222 284 }); 223 285 286 + testWidgets('shows count header for own profile', (tester) async { 287 + final state = const ProfileContextState.initial( 288 + did: _did, 289 + isOwnProfile: true, 290 + ).copyWith(blockingCount: 4, blockingStatus: ProfileContextTabStatus.loaded); 291 + await tester.pumpWidget(buildSubject(state: state)); 292 + 293 + await tester.tap(find.text('Blocking (4)')); 294 + await tester.pumpAndSettle(); 295 + 296 + expect(find.text('4 accounts'), findsOneWidget); 297 + }); 298 + 299 + testWidgets('shows unavailable-account card on blocking tab', (tester) async { 300 + final state = const ProfileContextState.initial(did: _did, isOwnProfile: true).copyWith( 301 + blockingStatus: ProfileContextTabStatus.loaded, 302 + blockingUnavailable: const [ 303 + UnavailableProfileRef(did: 'did:plc:takedown', reason: 'Suspended or taken-down account'), 304 + ], 305 + ); 306 + await tester.pumpWidget(buildSubject(state: state)); 307 + 308 + await tester.tap(find.text('Blocking')); 309 + await tester.pumpAndSettle(); 310 + 311 + expect(find.text('Unavailable accounts (1)'), findsOneWidget); 312 + expect(find.text('did:plc:takedown'), findsOneWidget); 313 + }); 314 + 224 315 testWidgets('shows empty state for own profile when loaded with no profiles', (tester) async { 225 316 final state = const ProfileContextState.initial( 226 317 did: _did, ··· 276 367 await tester.tap(find.text('User did:plc:blocked1')); 277 368 await tester.pumpAndSettle(); 278 369 279 - expect(find.text('Profile View'), findsOneWidget); 370 + expect(find.text('Profile View did:plc:blocked1.bsky.social'), findsOneWidget); 280 371 }); 281 372 }); 282 373 ··· 293 384 expect(find.text('List rkey2'), findsOneWidget); 294 385 }); 295 386 387 + testWidgets('groups lists by purpose and shows owner, purpose, and description', (tester) async { 388 + final lists = [ 389 + _list( 390 + 'curate', 391 + purpose: bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist, 392 + description: 'Curated follows', 393 + memberCount: 12, 394 + creatorHandle: 'curator.bsky.social', 395 + ), 396 + _list( 397 + 'mod', 398 + purpose: bsky_graph.KnownListPurpose.appBskyGraphDefsModlist, 399 + description: 'Moderation context', 400 + memberCount: 3, 401 + creatorHandle: 'moderator.bsky.social', 402 + ), 403 + _list( 404 + 'ref', 405 + purpose: bsky_graph.KnownListPurpose.appBskyGraphDefsReferencelist, 406 + memberCount: 8, 407 + creatorHandle: 'reference.bsky.social', 408 + ), 409 + ]; 410 + final state = initialState().copyWith(listsOnStatus: ProfileContextTabStatus.loaded, listsOn: lists); 411 + await tester.pumpWidget(buildSubject(state: state)); 412 + 413 + await tester.tap(find.text('Lists')); 414 + await tester.pumpAndSettle(); 415 + 416 + expect(find.text('Curation Lists'), findsOneWidget); 417 + expect(find.text('Moderation Lists'), findsOneWidget); 418 + expect(find.text('Reference Lists'), findsOneWidget); 419 + expect(find.text('@curator.bsky.social'), findsOneWidget); 420 + expect(find.text('Curated follows'), findsOneWidget); 421 + expect(find.text('CURATE'), findsOneWidget); 422 + expect(find.text('MOD'), findsOneWidget); 423 + expect(find.text('REFERENCE'), findsOneWidget); 424 + }); 425 + 296 426 testWidgets('list card navigates to /list on tap', (tester) async { 297 427 final lists = [_list('rkey1')]; 298 428 final state = initialState().copyWith(listsOnStatus: ProfileContextTabStatus.loaded, listsOn: lists); ··· 344 474 }); 345 475 }); 346 476 } 347 - 348 - class _TestNavigatorObserver extends NavigatorObserver { 349 - _TestNavigatorObserver({this.onPush}); 350 - 351 - final void Function(Route<dynamic>, Route<dynamic>?)? onPush; 352 - 353 - @override 354 - void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { 355 - onPush?.call(route, previousRoute); 356 - } 357 - }