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.

fix: feed generator hydration

+183 -45
+29 -21
lib/features/feed/cubit/feed_preferences_cubit.dart
··· 19 19 _accountDid = accountDid, 20 20 super(const FeedPreferencesState.initial()); 21 21 22 + static const int _generatorHydrationBatchSize = 25; 23 + 22 24 final FeedRepository _feedRepository; 23 25 final AppDatabase _database; 24 26 final String _accountDid; ··· 248 250 return; 249 251 } 250 252 251 - try { 252 - final generatorViews = await _feedRepository.getFeedGenerators(feedUris); 253 - log.d('FeedPreferencesCubit: Hydrated ${generatorViews.length} generator views for $_accountDid'); 254 - emit(state.copyWith(generatorViews: generatorViews, status: FeedPreferencesStatus.loaded, feeds: feeds)); 255 - } catch (e, stackTrace) { 256 - log.w( 257 - 'FeedPreferencesCubit: Failed to hydrate ${feedUris.length} generator views for $_accountDid', 258 - error: e, 259 - stackTrace: stackTrace, 260 - ); 253 + final generatorViews = <GeneratorView>[]; 261 254 262 - // Fall back to per-feed hydration so one bad feed URI does not suppress 263 - // metadata (display names / avatars) for all remaining feeds. 264 - final fallbackViews = <GeneratorView>[]; 265 - for (final feedUri in feedUris) { 255 + for (var i = 0; i < feedUris.length; i += _generatorHydrationBatchSize) { 256 + final end = i + _generatorHydrationBatchSize > feedUris.length 257 + ? feedUris.length 258 + : i + _generatorHydrationBatchSize; 259 + final chunk = feedUris.sublist(i, end); 260 + 261 + try { 262 + final chunkViews = await _feedRepository.getFeedGenerators(chunk); 263 + generatorViews.addAll(chunkViews); 264 + continue; 265 + } catch (_) { 266 + // Fall back to per-feed hydration for this chunk so one bad URI (or 267 + // oversized/invalid batch response) does not suppress all remaining 268 + // metadata. 269 + } 270 + 271 + for (final feedUri in chunk) { 266 272 try { 267 - fallbackViews.add(await _feedRepository.getFeedGenerator(feedUri)); 273 + generatorViews.add(await _feedRepository.getFeedGenerator(feedUri)); 268 274 } catch (_) { 269 275 continue; 270 276 } 271 277 } 278 + } 272 279 273 - if (fallbackViews.isNotEmpty) { 274 - log.d( 275 - 'FeedPreferencesCubit: Fallback hydrated ${fallbackViews.length}/${feedUris.length} generator views for $_accountDid', 276 - ); 277 - emit(state.copyWith(generatorViews: fallbackViews, status: FeedPreferencesStatus.loaded, feeds: feeds)); 278 - } 280 + if (generatorViews.isNotEmpty) { 281 + log.d( 282 + 'FeedPreferencesCubit: Hydrated ${generatorViews.length}/${feedUris.length} generator views for $_accountDid', 283 + ); 284 + emit(state.copyWith(generatorViews: generatorViews, status: FeedPreferencesStatus.loaded, feeds: feeds)); 285 + } else if (state.generatorViews.isNotEmpty) { 286 + emit(state.copyWith(generatorViews: const [], status: FeedPreferencesStatus.loaded, feeds: feeds)); 279 287 } 280 288 } 281 289
+1 -1
lib/features/feed/presentation/widgets/grid_post_card.dart
··· 66 66 return Container( 67 67 decoration: BoxDecoration( 68 68 border: Border.all(color: colorScheme.outlineVariant), 69 - color: colorScheme.surfaceContainerLowest, 69 + color: colorScheme.surface.withValues(alpha: 0.91), 70 70 ), 71 71 child: ModeratedBlurOverlay( 72 72 ui: postUi,
+2 -2
lib/features/feed/presentation/widgets/post_card.dart
··· 3 3 import 'package:bluesky/app_bsky_feed_post.dart'; 4 4 import 'package:bluesky/moderation.dart' as bsky_moderation; 5 5 import 'package:flutter/material.dart'; 6 + import 'package:lazurite/core/theme/theme_extensions.dart'; 6 7 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 7 8 import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 8 9 import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; ··· 13 14 import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 14 15 import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; 15 16 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 16 - import 'package:lazurite/core/theme/theme_extensions.dart'; 17 17 18 18 class PostCard extends StatelessWidget { 19 19 const PostCard({ ··· 50 50 margin: const EdgeInsets.symmetric(vertical: 1), 51 51 decoration: BoxDecoration( 52 52 border: Border.all(color: colorScheme.outlineVariant), 53 - color: colorScheme.surfaceContainerLowest, 53 + color: colorScheme.surface.withValues(alpha: 0.9), 54 54 ), 55 55 child: Column( 56 56 crossAxisAlignment: CrossAxisAlignment.start,
+35 -21
lib/features/feed/presentation/widgets/post_embed_view.dart
··· 34 34 35 35 @override 36 36 Widget build(BuildContext context) { 37 - return _buildEmbed(context, embed) ?? const SizedBox.shrink(); 37 + final rootHeroNamespace = '${feedViewPost.post.uri}#${identityHashCode(this)}'; 38 + return _buildEmbed(context, embed, heroNamespace: rootHeroNamespace) ?? const SizedBox.shrink(); 38 39 } 39 40 40 - Widget? _buildEmbed(BuildContext context, UPostViewEmbed embed) { 41 + Widget? _buildEmbed(BuildContext context, UPostViewEmbed embed, {required String heroNamespace}) { 41 42 if (embed.isEmbedImagesView) { 42 - return _buildImagesEmbed(context, embed.embedImagesView!.images); 43 + return _buildImagesEmbed(context, embed.embedImagesView!.images, heroNamespace: '$heroNamespace/images'); 43 44 } 44 45 45 46 if (embed.isEmbedExternalView) { ··· 47 48 } 48 49 49 50 if (embed.isEmbedRecordView) { 50 - return _buildQuotedRecord(context, embed.embedRecordView!); 51 + return _buildQuotedRecord(context, embed.embedRecordView!, heroNamespace: '$heroNamespace/record'); 51 52 } 52 53 53 54 if (embed.isEmbedVideoView) { ··· 59 60 return Column( 60 61 crossAxisAlignment: CrossAxisAlignment.start, 61 62 children: [ 62 - _buildRecordWithMediaMedia(context, recordWithMedia.media), 63 + _buildRecordWithMediaMedia(context, recordWithMedia.media, heroNamespace: '$heroNamespace/rwm-media'), 63 64 const SizedBox(height: 8), 64 - _buildQuotedRecord(context, recordWithMedia.record), 65 + _buildQuotedRecord(context, recordWithMedia.record, heroNamespace: '$heroNamespace/rwm-record'), 65 66 ], 66 67 ); 67 68 } ··· 69 70 return null; 70 71 } 71 72 72 - Widget _buildRecordWithMediaMedia(BuildContext context, UEmbedRecordWithMediaViewMedia media) { 73 + Widget _buildRecordWithMediaMedia( 74 + BuildContext context, 75 + UEmbedRecordWithMediaViewMedia media, { 76 + required String heroNamespace, 77 + }) { 73 78 if (media.isEmbedImagesView) { 74 - return _buildImagesEmbed(context, media.embedImagesView!.images); 79 + return _buildImagesEmbed(context, media.embedImagesView!.images, heroNamespace: '$heroNamespace/images'); 75 80 } 76 81 if (media.isEmbedExternalView) { 77 82 return _buildExternalEmbed(context, media.embedExternalView!.external); ··· 82 87 return const SizedBox.shrink(); 83 88 } 84 89 85 - Widget _buildImagesEmbed(BuildContext context, List<EmbedImagesViewImage> images) { 90 + Widget _buildImagesEmbed(BuildContext context, List<EmbedImagesViewImage> images, {required String heroNamespace}) { 86 91 final crossAxisCount = images.length == 1 ? 1 : 2; 87 92 final childAspectRatio = images.length == 1 ? 16 / 9 : 1.0; 88 - final postUri = feedViewPost.post.uri.toString(); 89 93 final moderationService = maybeModerationService(context); 90 94 final mediaUi = 91 95 moderationService?.postUi(feedViewPost.post, bsky_moderation.ModerationBehaviorContext.contentMedia) ?? ··· 106 110 ), 107 111 itemBuilder: (context, index) { 108 112 final image = images[index]; 109 - final heroTag = _imageHeroTag(postUri, index); 113 + final heroTag = _imageHeroTag(heroNamespace, index); 110 114 111 115 return GestureDetector( 112 116 onLongPressStart: (details) => _showImageContextMenu(context, details.globalPosition, image: image), 113 117 child: InkWell( 114 - onTap: () => _openImageViewer(context, images, initialIndex: index), 118 + onTap: () => _openImageViewer(context, images, initialIndex: index, heroNamespace: heroNamespace), 115 119 child: Hero( 116 120 tag: heroTag, 117 121 child: Image.network( ··· 240 244 ); 241 245 } 242 246 243 - Widget _buildQuotedRecord(BuildContext context, EmbedRecordView recordView) { 247 + Widget _buildQuotedRecord(BuildContext context, EmbedRecordView recordView, {required String heroNamespace}) { 244 248 final record = recordView.record; 245 249 final theme = Theme.of(context); 246 250 final colorScheme = theme.colorScheme; ··· 248 252 if (record.isEmbedRecordViewRecord) { 249 253 final quoted = record.embedRecordViewRecord!; 250 254 final quotedRecord = _tryParseRecord(quoted.value); 251 - final nestedEmbed = _buildQuotedEmbeds(context, quoted.embeds); 255 + final nestedHeroNamespace = '$heroNamespace/quote:${quoted.uri}'; 256 + final nestedEmbed = _buildQuotedEmbeds(context, quoted.embeds, heroNamespace: '$nestedHeroNamespace/embeds'); 252 257 253 258 return Container( 254 259 decoration: BoxDecoration( ··· 334 339 ); 335 340 } 336 341 337 - Widget? _buildQuotedEmbeds(BuildContext context, List<UEmbedRecordViewRecordEmbeds>? embeds) { 342 + Widget? _buildQuotedEmbeds( 343 + BuildContext context, 344 + List<UEmbedRecordViewRecordEmbeds>? embeds, { 345 + required String heroNamespace, 346 + }) { 338 347 if (embeds == null || embeds.isEmpty) return null; 339 348 340 349 final embed = embeds.first; 341 350 342 351 if (embed.isEmbedImagesView) { 343 - return _buildImagesEmbed(context, embed.embedImagesView!.images); 352 + return _buildImagesEmbed(context, embed.embedImagesView!.images, heroNamespace: '$heroNamespace/images'); 344 353 } 345 354 if (embed.isEmbedExternalView) { 346 355 return _buildExternalEmbed(context, embed.embedExternalView!.external); ··· 353 362 return Column( 354 363 crossAxisAlignment: CrossAxisAlignment.start, 355 364 children: [ 356 - _buildRecordWithMediaMedia(context, recordWithMedia.media), 365 + _buildRecordWithMediaMedia(context, recordWithMedia.media, heroNamespace: '$heroNamespace/rwm-media'), 357 366 const SizedBox(height: 8), 358 - _buildQuotedRecord(context, recordWithMedia.record), 367 + _buildQuotedRecord(context, recordWithMedia.record, heroNamespace: '$heroNamespace/rwm-record'), 359 368 ], 360 369 ); 361 370 } ··· 363 372 return null; 364 373 } 365 374 366 - void _openImageViewer(BuildContext context, List<EmbedImagesViewImage> images, {required int initialIndex}) { 375 + void _openImageViewer( 376 + BuildContext context, 377 + List<EmbedImagesViewImage> images, { 378 + required int initialIndex, 379 + required String heroNamespace, 380 + }) { 367 381 GoRouter.maybeOf(context)?.push( 368 382 '/images', 369 383 extra: ImageViewerRouteArgs( ··· 373 387 fullsizeUrl: images[i].fullsize, 374 388 thumbnailUrl: images[i].thumb, 375 389 altText: images[i].alt, 376 - heroTag: _imageHeroTag(feedViewPost.post.uri.toString(), i), 390 + heroTag: _imageHeroTag(heroNamespace, i), 377 391 ), 378 392 ], 379 393 initialIndex: initialIndex, ··· 420 434 ); 421 435 } 422 436 423 - String _imageHeroTag(String postUri, int index) => 'post-image-$postUri-$index'; 437 + String _imageHeroTag(String heroNamespace, int index) => 'post-image-$heroNamespace-$index'; 424 438 425 439 String _downloadFileName(String url) { 426 440 final uri = Uri.tryParse(url);
+50
test/features/feed/cubit/feed_preferences_cubit_test.dart
··· 169 169 ); 170 170 171 171 blocTest<FeedPreferencesCubit, FeedPreferencesState>( 172 + 'loadPreferences hydrates generator views in bounded batches', 173 + build: () => 174 + FeedPreferencesCubit(feedRepository: mockFeedRepository, database: database, accountDid: 'did:plc:test'), 175 + setUp: () { 176 + final feeds = List.generate( 177 + 65, 178 + (i) => 179 + createTestFeed(id: 'feed-$i', pinned: i == 0, value: 'at://did:plc:test/app.bsky.feed.generator/feed-$i'), 180 + ); 181 + 182 + when(() => mockFeedRepository.getPreferences()).thenAnswer( 183 + (_) async => PreferencesResult( 184 + preferences: [UPreferences.savedFeedsPrefV2(data: SavedFeedsPrefV2(items: feeds))], 185 + ), 186 + ); 187 + 188 + when(() => mockFeedRepository.getFeedGenerators(any())).thenAnswer((invocation) async { 189 + final chunk = invocation.positionalArguments.first as List<AtUri>; 190 + return [ 191 + for (final uri in chunk) 192 + GeneratorView( 193 + uri: uri, 194 + cid: 'cid-${uri.rkey}', 195 + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), 196 + did: 'did:plc:test', 197 + displayName: uri.rkey, 198 + indexedAt: DateTime.utc(2026, 3, 16), 199 + ), 200 + ]; 201 + }); 202 + }, 203 + act: (cubit) => cubit.loadPreferences(), 204 + expect: () => [ 205 + isA<FeedPreferencesState>().having((s) => s.status, 'status', FeedPreferencesStatus.loading), 206 + isA<FeedPreferencesState>().having((s) => s.status, 'status', FeedPreferencesStatus.loaded), 207 + isA<FeedPreferencesState>() 208 + .having((s) => s.status, 'status', FeedPreferencesStatus.loaded) 209 + .having((s) => s.generatorViews.length, 'generatorViews.length', 65), 210 + ], 211 + verify: (_) { 212 + final captured = verify(() => mockFeedRepository.getFeedGenerators(captureAny())).captured.cast<List<AtUri>>(); 213 + expect(captured.length, 3); 214 + expect(captured[0].length, 25); 215 + expect(captured[1].length, 25); 216 + expect(captured[2].length, 15); 217 + verifyNever(() => mockFeedRepository.getFeedGenerator(any())); 218 + }, 219 + ); 220 + 221 + blocTest<FeedPreferencesCubit, FeedPreferencesState>( 172 222 'pinFeed moves feed to pinned section', 173 223 build: () => 174 224 FeedPreferencesCubit(feedRepository: mockFeedRepository, database: database, accountDid: 'did:plc:test'),
+66
test/features/feed/presentation/post_card_test.dart
··· 3 3 import 'package:atproto_core/atproto_core.dart'; 4 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 5 5 import 'package:bluesky/app_bsky_embed_external.dart'; 6 + import 'package:bluesky/app_bsky_embed_images.dart'; 6 7 import 'package:bluesky/app_bsky_embed_record.dart'; 8 + import 'package:bluesky/app_bsky_embed_recordwithmedia.dart'; 7 9 import 'package:bluesky/app_bsky_feed_defs.dart'; 8 10 import 'package:bluesky/app_bsky_feed_post.dart'; 9 11 import 'package:bluesky/app_bsky_richtext_facet.dart'; ··· 434 436 await tester.pumpAndSettle(); 435 437 436 438 expect(pushedRoute, contains('did%3Aplc%3Atest')); 439 + }); 440 + 441 + testWidgets('uses unique image hero tags across record-with-media and quoted embeds', (tester) async { 442 + final quotedRecord = FeedPostRecord(text: 'Quoted with image', createdAt: DateTime.utc(2026, 3, 15)); 443 + final post = FeedViewPost( 444 + post: PostView( 445 + uri: const AtUri('at://did:plc:test/app.bsky.feed.post/xyz'), 446 + cid: 'cid-xyz', 447 + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'), 448 + record: FeedPostRecord(text: 'Main post with media quote', createdAt: DateTime.utc(2026, 3, 16)).toJson(), 449 + indexedAt: DateTime.utc(2026, 3, 16), 450 + embed: UPostViewEmbed.embedRecordWithMediaView( 451 + data: EmbedRecordWithMediaView( 452 + media: const UEmbedRecordWithMediaViewMedia.embedImagesView( 453 + data: EmbedImagesView( 454 + images: [ 455 + EmbedImagesViewImage( 456 + thumb: 'https://example.com/main-thumb.jpg', 457 + fullsize: 'https://example.com/main-full.jpg', 458 + alt: 'main image', 459 + ), 460 + ], 461 + ), 462 + ), 463 + record: EmbedRecordView( 464 + record: UEmbedRecordViewRecord.embedRecordViewRecord( 465 + data: EmbedRecordViewRecord( 466 + uri: AtUri.parse('at://did:plc:quoted/app.bsky.feed.post/quoted123'), 467 + cid: 'cid-quoted', 468 + author: const ProfileViewBasic(did: 'did:plc:quoted', handle: 'quoted.bsky.social'), 469 + value: quotedRecord.toJson(), 470 + embeds: [ 471 + const UEmbedRecordViewRecordEmbeds.embedImagesView( 472 + data: EmbedImagesView( 473 + images: [ 474 + EmbedImagesViewImage( 475 + thumb: 'https://example.com/quoted-thumb.jpg', 476 + fullsize: 'https://example.com/quoted-full.jpg', 477 + alt: 'quoted image', 478 + ), 479 + ], 480 + ), 481 + ), 482 + ], 483 + indexedAt: DateTime.utc(2026, 3, 15), 484 + ), 485 + ), 486 + ), 487 + ), 488 + ), 489 + ), 490 + ); 491 + 492 + await tester.pumpWidget( 493 + MaterialApp( 494 + home: Scaffold( 495 + body: SingleChildScrollView(child: PostCard(feedViewPost: post)), 496 + ), 497 + ), 498 + ); 499 + 500 + final heroTags = tester.widgetList<Hero>(find.byType(Hero)).map((hero) => hero.tag).toList(); 501 + expect(heroTags.length, greaterThanOrEqualTo(2)); 502 + expect(heroTags.toSet().length, heroTags.length); 437 503 }); 438 504 }