diff --git a/lib/src/features/profile/providers/profile_feed_provider.dart b/lib/src/features/profile/providers/profile_feed_provider.dart index 16fe2714..7b11b43c 100644 --- a/lib/src/features/profile/providers/profile_feed_provider.dart +++ b/lib/src/features/profile/providers/profile_feed_provider.dart @@ -36,6 +36,8 @@ class ProfileFeed extends _$ProfileFeed { } } + /// Load author feed from Spark first, falling back to Bluesky if Spark fails. + /// This mirrors the profile loading behavior where we only show one source. Future _loadUnifiedFeed({ required AtUri profileUri, required String? sparkCursor, @@ -48,50 +50,31 @@ class ProfileFeed extends _$ProfileFeed { final postViews = Map.from(currentState?.postViews ?? {}); final allPosts = List.from(currentState?.allPosts ?? []); - final sparkRkeys = allPosts.where((uri) => postSources[uri] == 'sprk').map((uri) => uri.rkey).toSet(); - final newPosts = []; - final sparkResult = await _fetchFromSource( + // Fetch from Spark API (which internally falls back to Bluesky if Spark fails) + // This mirrors the profile loading behavior: Spark first, Bluesky only as fallback + final result = await _fetchFromSource( (cursor) => _feedRepository.getAuthorFeed(profileUri, limit: ProfileFeedState.fetchLimit, cursor: cursor), sparkCursor, - 'Sprk', + 'AuthorFeed', ); - for (final feedViewPost in sparkResult.posts) { + for (final feedViewPost in result.posts) { final uri = feedViewPost.uri; if (!postViews.containsKey(uri)) { final postView = feedViewPost.asPost; if (postView != null) { newPosts.add(postView); - postSources[uri] = 'sprk'; + // Determine source based on URI collection + final isBlueskyPost = uri.collection.toString().startsWith('app.bsky'); + postSources[uri] = isBlueskyPost ? 'bsky' : 'sprk'; postTypes[uri] = postView.videoUrl.isNotEmpty; postViews[uri] = postView; - sparkRkeys.add(uri.rkey); } } } - final bskyResult = await _fetchFromSource( - (cursor) => _feedRepository.getAuthorFeed(profileUri, limit: ProfileFeedState.fetchLimit, cursor: cursor, bluesky: true), - blueskyCursor, - 'Bsky', - ); - - for (final feedViewPost in bskyResult.posts) { - final uri = feedViewPost.uri; - if (sparkRkeys.contains(uri.rkey) || postViews.containsKey(uri)) { - continue; - } - final postView = feedViewPost.asPost; - if (postView != null) { - newPosts.add(postView); - postSources[uri] = 'bsky'; - postTypes[uri] = _isMediaVideo(postView.media); - postViews[uri] = postView; - } - } - newPosts.sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); allPosts.addAll(newPosts.map((post) => post.uri)); @@ -121,16 +104,21 @@ class ProfileFeed extends _$ProfileFeed { // Here we only apply label-based filtering and return all posts. final filteredPosts = await _filterHiddenPosts(allPosts, postViews); + // End of network when: + // 1. API returns null cursor (no more pages) + // 2. API returns fewer posts than requested (last page) + // 3. No new posts were added (duplicates or empty response) final isEndOfNetwork = - (sparkResult.cursor == null && bskyResult.cursor == null) || + result.cursor == null || + result.posts.length < ProfileFeedState.fetchLimit || (currentState != null && currentState.allPosts.length == allPosts.length); return ProfileFeedState( loadedPosts: filteredPosts, allPosts: allPosts, isEndOfNetwork: isEndOfNetwork, - cursor: sparkResult.cursor, - blueskyCursor: bskyResult.cursor, + cursor: result.cursor, + blueskyCursor: null, // No longer tracking separate Bluesky cursor // ignore: prefer_collection_literals extraInfo: currentState?.extraInfo ?? LinkedHashMap(), postSources: postSources, @@ -153,20 +141,6 @@ class ProfileFeed extends _$ProfileFeed { } } - bool _isMediaVideo(MediaView? embed) { - if (embed == null) return false; - return embed.when( - video: (cid, playlist, thumbnail, alt) => true, - bskyVideo: (cid, playlist, thumbnail, alt) => true, - bskyRecordWithMedia: (record, media) => _isMediaVideo(media), - image: (image) => false, - images: (images) => false, - bskyImages: (images) => false, - bskyRecord: (record) => false, - bskyExternal: (external) => false, - ); - } - Future loadMore() async { if (_isLoading || (state.value?.isEndOfNetwork ?? true)) return; diff --git a/lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart b/lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart index 4ccd51a0..60b6dc65 100644 --- a/lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart +++ b/lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart @@ -85,10 +85,6 @@ class _StandaloneProfileFeedPageState extends ConsumerState= filteredUris.length) { - return const Center(child: CircularProgressIndicator(color: AppColors.white)); - } - final postUri = filteredUris[index]; final post = state.postViews[postUri]; return ProfileFeedPostWidget(postUri: postUri, profileUri: profileAtUri, videosOnly: widget.videosOnly, post: post); diff --git a/lib/src/features/profile/ui/widgets/profile_grid_widget.dart b/lib/src/features/profile/ui/widgets/profile_grid_widget.dart index 9dc4450f..503965b2 100644 --- a/lib/src/features/profile/ui/widgets/profile_grid_widget.dart +++ b/lib/src/features/profile/ui/widgets/profile_grid_widget.dart @@ -46,7 +46,8 @@ class _ProfileGridWidgetState extends ConsumerState { } void _onScroll() { - if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 200) { + // Trigger loading when user is within ~2 rows of the bottom (each row is roughly 200px at 9:16 aspect ratio) + if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 500) { ref.read(profileFeedProvider(widget.profileUri, widget.videosOnly).notifier).loadMore(); } } @@ -90,37 +91,39 @@ class _ProfileGridWidgetState extends ConsumerState { ); } - return GridView.builder( + return CustomScrollView( controller: scrollController, - padding: const EdgeInsets.all(5), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 5, - mainAxisSpacing: 5, - childAspectRatio: 9 / 16, - ), - itemCount: filteredUris.length + (state.isEndOfNetwork ? 0 : 1), - itemBuilder: (context, index) { - if (index >= filteredUris.length) { - return ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - ); - } - - final postUri = filteredUris[index]; - final postView = state.postViews[postUri]; - final postSource = state.postSources[postUri]; + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(5), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 5, + mainAxisSpacing: 5, + childAspectRatio: 9 / 16, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final postUri = filteredUris[index]; + final postView = state.postViews[postUri]; + final postSource = state.postSources[postUri]; - if (postView == null) { - return const SizedBox.shrink(); - } + if (postView == null) { + return const SizedBox.shrink(); + } - return ProfileGridTile( - postView: postView, - postSource: postSource, - onTap: () => _onPostTapDynamic(postUri), - ); - }, + return ProfileGridTile( + postView: postView, + postSource: postSource, + onTap: () => _onPostTapDynamic(postUri), + ); + }, + childCount: filteredUris.length, + ), + ), + ), + ], ); }, loading: () => Skeletonizer(